Merge pull request #34987 from timstclair/redirect

Automatic merge from submit-queue

Handle redirects in apiserver proxy handler

Overview:
1. Peek at the HTTP response from the proxied backend
2. If it is a redirect response (302/3), redo the request to the redirect location
3. If it's not a redirect, forward the response to the client and then set up the proxy as before

This change is required for implementing streaming requests in the Container Runtime Interface (CRI). See [design](https://docs.google.com/document/d/1OE_QoInPlVCK9rMAx9aybRmgFiVjHpJCHI9LrfdNM_s/edit).

For https://github.com/kubernetes/kubernetes/issues/29579

/cc @yujuhong
This commit is contained in:
Kubernetes Submit Queue 2016-11-05 14:58:26 -07:00 committed by GitHub
commit 7d1ef3e9c9
5 changed files with 300 additions and 61 deletions

View File

@ -70,7 +70,7 @@ func (r *ProxyREST) Connect(ctx api.Context, id string, opts runtime.Object, res
} }
location.Path = path.Join(location.Path, proxyOpts.Path) location.Path = path.Join(location.Path, proxyOpts.Path)
// Return a proxy handler that uses the desired transport, wrapped with additional proxy handling (to get URL rewriting, X-Forwarded-* headers, etc) // Return a proxy handler that uses the desired transport, wrapped with additional proxy handling (to get URL rewriting, X-Forwarded-* headers, etc)
return newThrottledUpgradeAwareProxyHandler(location, transport, true, false, responder), nil return newThrottledUpgradeAwareProxyHandler(location, transport, true, false, false, responder), nil
} }
// Support both GET and POST methods. We must support GET for browsers that want to use WebSockets. // Support both GET and POST methods. We must support GET for browsers that want to use WebSockets.
@ -100,7 +100,7 @@ func (r *AttachREST) Connect(ctx api.Context, name string, opts runtime.Object,
if err != nil { if err != nil {
return nil, err return nil, err
} }
return newThrottledUpgradeAwareProxyHandler(location, transport, false, true, responder), nil return newThrottledUpgradeAwareProxyHandler(location, transport, false, true, true, responder), nil
} }
// NewConnectOptions returns the versioned object that represents exec parameters // NewConnectOptions returns the versioned object that represents exec parameters
@ -137,7 +137,7 @@ func (r *ExecREST) Connect(ctx api.Context, name string, opts runtime.Object, re
if err != nil { if err != nil {
return nil, err return nil, err
} }
return newThrottledUpgradeAwareProxyHandler(location, transport, false, true, responder), nil return newThrottledUpgradeAwareProxyHandler(location, transport, false, true, true, responder), nil
} }
// NewConnectOptions returns the versioned object that represents exec parameters // NewConnectOptions returns the versioned object that represents exec parameters
@ -180,11 +180,12 @@ func (r *PortForwardREST) Connect(ctx api.Context, name string, opts runtime.Obj
if err != nil { if err != nil {
return nil, err return nil, err
} }
return newThrottledUpgradeAwareProxyHandler(location, transport, false, true, responder), nil return newThrottledUpgradeAwareProxyHandler(location, transport, false, true, true, responder), nil
} }
func newThrottledUpgradeAwareProxyHandler(location *url.URL, transport http.RoundTripper, wrapTransport, upgradeRequired bool, responder rest.Responder) *genericrest.UpgradeAwareProxyHandler { func newThrottledUpgradeAwareProxyHandler(location *url.URL, transport http.RoundTripper, wrapTransport, upgradeRequired, interceptRedirects bool, responder rest.Responder) *genericrest.UpgradeAwareProxyHandler {
handler := genericrest.NewUpgradeAwareProxyHandler(location, transport, wrapTransport, upgradeRequired, responder) handler := genericrest.NewUpgradeAwareProxyHandler(location, transport, wrapTransport, upgradeRequired, responder)
handler.InterceptRedirects = interceptRedirects
handler.MaxBytesPerSec = capabilities.Get().PerConnectionBandwidthLimitBytesPerSec handler.MaxBytesPerSec = capabilities.Get().PerConnectionBandwidthLimitBytesPerSec
return handler return handler
} }

View File

@ -23,9 +23,11 @@ go_library(
"//pkg/api/errors:go_default_library", "//pkg/api/errors:go_default_library",
"//pkg/api/rest:go_default_library", "//pkg/api/rest:go_default_library",
"//pkg/api/unversioned:go_default_library", "//pkg/api/unversioned:go_default_library",
"//pkg/util/config:go_default_library",
"//pkg/util/httpstream:go_default_library", "//pkg/util/httpstream:go_default_library",
"//pkg/util/net:go_default_library", "//pkg/util/net:go_default_library",
"//pkg/util/proxy:go_default_library", "//pkg/util/proxy:go_default_library",
"//pkg/util/runtime:go_default_library",
"//vendor:github.com/golang/glog", "//vendor:github.com/golang/glog",
"//vendor:github.com/mxk/go-flowrate/flowrate", "//vendor:github.com/mxk/go-flowrate/flowrate",
], ],
@ -43,8 +45,12 @@ go_test(
deps = [ deps = [
"//pkg/api:go_default_library", "//pkg/api:go_default_library",
"//pkg/api/errors:go_default_library", "//pkg/api/errors:go_default_library",
"//pkg/util/config:go_default_library",
"//pkg/util/httpstream:go_default_library",
"//pkg/util/net:go_default_library", "//pkg/util/net:go_default_library",
"//pkg/util/proxy:go_default_library", "//pkg/util/proxy:go_default_library",
"//vendor:github.com/stretchr/testify/assert",
"//vendor:github.com/stretchr/testify/require",
"//vendor:golang.org/x/net/websocket", "//vendor:golang.org/x/net/websocket",
], ],
) )

View File

@ -17,7 +17,11 @@ limitations under the License.
package rest package rest
import ( import (
"bufio"
"bytes"
"fmt"
"io" "io"
"net"
"net/http" "net/http"
"net/http/httputil" "net/http/httputil"
"net/url" "net/url"
@ -26,9 +30,11 @@ import (
"time" "time"
"k8s.io/kubernetes/pkg/api/errors" "k8s.io/kubernetes/pkg/api/errors"
utilconfig "k8s.io/kubernetes/pkg/util/config"
"k8s.io/kubernetes/pkg/util/httpstream" "k8s.io/kubernetes/pkg/util/httpstream"
"k8s.io/kubernetes/pkg/util/net" utilnet "k8s.io/kubernetes/pkg/util/net"
"k8s.io/kubernetes/pkg/util/proxy" "k8s.io/kubernetes/pkg/util/proxy"
utilruntime "k8s.io/kubernetes/pkg/util/runtime"
"github.com/golang/glog" "github.com/golang/glog"
"github.com/mxk/go-flowrate/flowrate" "github.com/mxk/go-flowrate/flowrate"
@ -42,6 +48,9 @@ type UpgradeAwareProxyHandler struct {
Transport http.RoundTripper Transport http.RoundTripper
// WrapTransport indicates whether the provided Transport should be wrapped with default proxy transport behavior (URL rewriting, X-Forwarded-* header setting) // WrapTransport indicates whether the provided Transport should be wrapped with default proxy transport behavior (URL rewriting, X-Forwarded-* header setting)
WrapTransport bool WrapTransport bool
// InterceptRedirects determines whether the proxy should sniff backend responses for redirects,
// following them as necessary.
InterceptRedirects bool
FlushInterval time.Duration FlushInterval time.Duration
MaxBytesPerSec int64 MaxBytesPerSec int64
Responder ErrorResponder Responder ErrorResponder
@ -131,32 +140,44 @@ func (h *UpgradeAwareProxyHandler) tryUpgrade(w http.ResponseWriter, req *http.R
return false return false
} }
backendConn, err := proxy.DialURL(h.Location, h.Transport) var (
backendConn net.Conn
rawResponse []byte
err error
)
if h.InterceptRedirects && utilconfig.DefaultFeatureGate.StreamingProxyRedirects() {
backendConn, rawResponse, err = h.connectBackendWithRedirects(req)
} else {
backendConn, err = h.connectBackend(req.Method, h.Location, req.Header, req.Body)
}
if err != nil { if err != nil {
h.Responder.Error(err) h.Responder.Error(err)
return true return true
} }
defer backendConn.Close() defer backendConn.Close()
requestHijackedConn, _, err := w.(http.Hijacker).Hijack() // Once the connection is hijacked, the ErrorResponder will no longer work, so
// hijacking should be the last step in the upgrade.
requestHijacker, ok := w.(http.Hijacker)
if !ok {
h.Responder.Error(fmt.Errorf("request connection cannot be hijacked: %T", w))
return true
}
requestHijackedConn, _, err := requestHijacker.Hijack()
if err != nil { if err != nil {
h.Responder.Error(err) h.Responder.Error(fmt.Errorf("error hijacking request connection: %v", err))
return true return true
} }
defer requestHijackedConn.Close() defer requestHijackedConn.Close()
newReq, err := http.NewRequest(req.Method, h.Location.String(), req.Body) // Forward raw response bytes back to client.
if err != nil { if len(rawResponse) > 0 {
h.Responder.Error(err) if _, err = requestHijackedConn.Write(rawResponse); err != nil {
return true utilruntime.HandleError(fmt.Errorf("Error proxying response from backend to client: %v", err))
} }
newReq.Header = req.Header
if err = newReq.Write(backendConn); err != nil {
h.Responder.Error(err)
return true
} }
// Proxy the connection.
wg := &sync.WaitGroup{} wg := &sync.WaitGroup{}
wg.Add(2) wg.Add(2)
@ -192,6 +213,113 @@ func (h *UpgradeAwareProxyHandler) tryUpgrade(w http.ResponseWriter, req *http.R
return true return true
} }
// connectBackend dials the backend at location and forwards a copy of the client request.
func (h *UpgradeAwareProxyHandler) connectBackend(method string, location *url.URL, header http.Header, body io.Reader) (conn net.Conn, err error) {
defer func() {
if err != nil && conn != nil {
conn.Close()
conn = nil
}
}()
beReq, err := http.NewRequest(method, location.String(), body)
if err != nil {
return nil, err
}
beReq.Header = header
conn, err = proxy.DialURL(location, h.Transport)
if err != nil {
return conn, fmt.Errorf("error dialing backend: %v", err)
}
if err = beReq.Write(conn); err != nil {
return conn, fmt.Errorf("error sending request: %v", err)
}
return conn, err
}
// connectBackendWithRedirects dials the backend and forwards a copy of the client request. If the
// client responds with a redirect, it is followed. The raw response bytes are returned, and should
// be forwarded back to the client.
func (h *UpgradeAwareProxyHandler) connectBackendWithRedirects(req *http.Request) (net.Conn, []byte, error) {
const (
maxRedirects = 10
maxResponseSize = 4096
)
var (
initialReq = req
rawResponse = bytes.NewBuffer(make([]byte, 0, 256))
location = h.Location
intermediateConn net.Conn
err error
)
defer func() {
if intermediateConn != nil {
intermediateConn.Close()
}
}()
redirectLoop:
for redirects := 0; ; redirects++ {
if redirects > maxRedirects {
return nil, nil, fmt.Errorf("too many redirects (%d)", redirects)
}
if redirects == 0 {
intermediateConn, err = h.connectBackend(req.Method, location, req.Header, req.Body)
} else {
// Redirected requests switch to "GET" according to the HTTP spec:
// https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.3
intermediateConn, err = h.connectBackend("GET", location, initialReq.Header, nil)
}
if err != nil {
return nil, nil, err
}
// Peek at the backend response.
rawResponse.Reset()
respReader := bufio.NewReader(io.TeeReader(
io.LimitReader(intermediateConn, maxResponseSize), // Don't read more than maxResponseSize bytes.
rawResponse)) // Save the raw response.
resp, err := http.ReadResponse(respReader, req)
if err != nil {
// Unable to read the backend response; let the client handle it.
glog.Warningf("Error reading backend response: %v", err)
break redirectLoop
}
resp.Body.Close() // Unused.
switch resp.StatusCode {
case http.StatusFound:
// Redirect, continue.
default:
// Don't redirect.
break redirectLoop
}
// Reset the connection.
intermediateConn.Close()
intermediateConn = nil
// Prepare to follow the redirect.
redirectStr := resp.Header.Get("Location")
if redirectStr == "" {
return nil, nil, fmt.Errorf("%d response missing Location header", resp.StatusCode)
}
location, err = h.Location.Parse(redirectStr)
if err != nil {
return nil, nil, fmt.Errorf("malformed Location header: %v", err)
}
}
backendConn := intermediateConn
intermediateConn = nil // Don't close the connection when we return it.
return backendConn, rawResponse.Bytes(), nil
}
func (h *UpgradeAwareProxyHandler) defaultProxyTransport(url *url.URL, internalTransport http.RoundTripper) http.RoundTripper { func (h *UpgradeAwareProxyHandler) defaultProxyTransport(url *url.URL, internalTransport http.RoundTripper) http.RoundTripper {
scheme := url.Scheme scheme := url.Scheme
host := url.Host host := url.Host
@ -213,12 +341,15 @@ func (h *UpgradeAwareProxyHandler) defaultProxyTransport(url *url.URL, internalT
// corsRemovingTransport is a wrapper for an internal transport. It removes CORS headers // corsRemovingTransport is a wrapper for an internal transport. It removes CORS headers
// from the internal response. // from the internal response.
// Implements pkg/util/net.RoundTripperWrapper
type corsRemovingTransport struct { type corsRemovingTransport struct {
http.RoundTripper http.RoundTripper
} }
func (p *corsRemovingTransport) RoundTrip(req *http.Request) (*http.Response, error) { var _ = utilnet.RoundTripperWrapper(&corsRemovingTransport{})
resp, err := p.RoundTripper.RoundTrip(req)
func (rt *corsRemovingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
resp, err := rt.RoundTripper.RoundTrip(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -226,8 +357,6 @@ func (p *corsRemovingTransport) RoundTrip(req *http.Request) (*http.Response, er
return resp, nil return resp, nil
} }
var _ = net.RoundTripperWrapper(&corsRemovingTransport{})
func (rt *corsRemovingTransport) WrappedRoundTripper() http.RoundTripper { func (rt *corsRemovingTransport) WrappedRoundTripper() http.RoundTripper {
return rt.RoundTripper return rt.RoundTripper
} }

View File

@ -21,6 +21,7 @@ import (
"compress/gzip" "compress/gzip"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"errors"
"fmt" "fmt"
"io" "io"
"io/ioutil" "io/ioutil"
@ -33,26 +34,59 @@ import (
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/net/websocket" "golang.org/x/net/websocket"
utilconfig "k8s.io/kubernetes/pkg/util/config"
"k8s.io/kubernetes/pkg/util/httpstream"
utilnet "k8s.io/kubernetes/pkg/util/net" utilnet "k8s.io/kubernetes/pkg/util/net"
"k8s.io/kubernetes/pkg/util/proxy" "k8s.io/kubernetes/pkg/util/proxy"
) )
const fakeStatusCode = 567
type fakeResponder struct { type fakeResponder struct {
t *testing.T
called bool called bool
err error err error
// called chan error
w http.ResponseWriter
} }
func (r *fakeResponder) Error(err error) { func (r *fakeResponder) Error(err error) {
if r.called { if r.called {
panic("called twice") r.t.Errorf("Error responder called again!\nprevious error: %v\nnew error: %v", r.err, err)
} }
if r.w != nil {
r.w.WriteHeader(fakeStatusCode)
_, writeErr := r.w.Write([]byte(err.Error()))
assert.NoError(r.t, writeErr)
} else {
r.t.Logf("No ResponseWriter set")
}
r.called = true r.called = true
r.err = err r.err = err
} }
type fakeConn struct {
err error // The error to return when io is performed over the connection.
}
func (f *fakeConn) Read([]byte) (int, error) { return 0, f.err }
func (f *fakeConn) Write([]byte) (int, error) { return 0, f.err }
func (f *fakeConn) Close() error { return nil }
func (fakeConn) LocalAddr() net.Addr { return nil }
func (fakeConn) RemoteAddr() net.Addr { return nil }
func (fakeConn) SetDeadline(t time.Time) error { return nil }
func (fakeConn) SetReadDeadline(t time.Time) error { return nil }
func (fakeConn) SetWriteDeadline(t time.Time) error { return nil }
type SimpleBackendHandler struct { type SimpleBackendHandler struct {
requestURL url.URL requestURL url.URL
requestHeader http.Header requestHeader http.Header
@ -210,7 +244,7 @@ func TestServeHTTP(t *testing.T) {
backendServer := httptest.NewServer(backendHandler) backendServer := httptest.NewServer(backendHandler)
defer backendServer.Close() defer backendServer.Close()
responder := &fakeResponder{} responder := &fakeResponder{t: t}
backendURL, _ := url.Parse(backendServer.URL) backendURL, _ := url.Parse(backendServer.URL)
backendURL.Path = test.requestPath backendURL.Path = test.requestPath
proxyHandler := &UpgradeAwareProxyHandler{ proxyHandler := &UpgradeAwareProxyHandler{
@ -367,45 +401,103 @@ func TestProxyUpgrade(t *testing.T) {
}, },
} }
for k, tc := range testcases { // Enable StreamingProxyRedirects for test.
utilconfig.DefaultFeatureGate.Set("StreamingProxyRedirects=true")
backendServer := tc.ServerFunc(websocket.Handler(func(ws *websocket.Conn) { for k, tc := range testcases {
for _, redirect := range []bool{false, true} {
tcName := k
backendPath := "/hello"
if redirect {
tcName += " with redirect"
backendPath = "/redirect"
}
func() { // Cleanup after each test case.
backend := http.NewServeMux()
backend.Handle("/hello", websocket.Handler(func(ws *websocket.Conn) {
defer ws.Close() defer ws.Close()
body := make([]byte, 5) body := make([]byte, 5)
ws.Read(body) ws.Read(body)
ws.Write([]byte("hello " + string(body))) ws.Write([]byte("hello " + string(body)))
})) }))
backend.Handle("/redirect", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/hello", http.StatusFound)
}))
backendServer := tc.ServerFunc(backend)
defer backendServer.Close() defer backendServer.Close()
serverURL, _ := url.Parse(backendServer.URL) serverURL, _ := url.Parse(backendServer.URL)
serverURL.Path = backendPath
proxyHandler := &UpgradeAwareProxyHandler{ proxyHandler := &UpgradeAwareProxyHandler{
Location: serverURL, Location: serverURL,
Transport: tc.ProxyTransport, Transport: tc.ProxyTransport,
InterceptRedirects: redirect,
} }
proxy := httptest.NewServer(proxyHandler) proxy := httptest.NewServer(proxyHandler)
defer proxy.Close() defer proxy.Close()
ws, err := websocket.Dial("ws://"+proxy.Listener.Addr().String()+"/some/path", "", "http://127.0.0.1/") ws, err := websocket.Dial("ws://"+proxy.Listener.Addr().String()+"/some/path", "", "http://127.0.0.1/")
if err != nil { if err != nil {
t.Fatalf("%s: websocket dial err: %s", k, err) t.Fatalf("%s: websocket dial err: %s", tcName, err)
} }
defer ws.Close() defer ws.Close()
if _, err := ws.Write([]byte("world")); err != nil { if _, err := ws.Write([]byte("world")); err != nil {
t.Fatalf("%s: write err: %s", k, err) t.Fatalf("%s: write err: %s", tcName, err)
} }
response := make([]byte, 20) response := make([]byte, 20)
n, err := ws.Read(response) n, err := ws.Read(response)
if err != nil { if err != nil {
t.Fatalf("%s: read err: %s", k, err) t.Fatalf("%s: read err: %s", tcName, err)
} }
if e, a := "hello world", string(response[0:n]); e != a { if e, a := "hello world", string(response[0:n]); e != a {
t.Fatalf("%s: expected '%#v', got '%#v'", k, e, a) t.Fatalf("%s: expected '%#v', got '%#v'", tcName, e, a)
}
}()
} }
} }
} }
func TestProxyUpgradeErrorResponse(t *testing.T) {
var (
responder *fakeResponder
expectedErr = errors.New("EXPECTED")
)
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
transport := http.DefaultTransport.(*http.Transport)
transport.Dial = func(network, addr string) (net.Conn, error) {
return &fakeConn{err: expectedErr}, nil
}
responder = &fakeResponder{t: t, w: w}
proxyHandler := &UpgradeAwareProxyHandler{
Location: &url.URL{
Host: "fake-backend",
},
UpgradeRequired: true,
Responder: responder,
Transport: transport,
}
proxyHandler.ServeHTTP(w, r)
}))
defer proxy.Close()
// Send request to proxy server.
req, err := http.NewRequest("POST", "http://"+proxy.Listener.Addr().String()+"/some/path", nil)
require.NoError(t, err)
req.Header.Set(httpstream.HeaderConnection, httpstream.HeaderUpgrade)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
// Expect error response.
assert.True(t, responder.called)
assert.Equal(t, fakeStatusCode, resp.StatusCode)
msg, err := ioutil.ReadAll(resp.Body)
require.NoError(t, err)
assert.Contains(t, string(msg), expectedErr.Error())
}
func TestDefaultProxyTransport(t *testing.T) { func TestDefaultProxyTransport(t *testing.T) {
tests := []struct { tests := []struct {
name, name,
@ -415,7 +507,6 @@ func TestDefaultProxyTransport(t *testing.T) {
expectedHost, expectedHost,
expectedPathPrepend string expectedPathPrepend string
}{ }{
{ {
name: "simple path", name: "simple path",
url: "http://test.server:8080/a/test/location", url: "http://test.server:8080/a/test/location",
@ -619,7 +710,7 @@ func TestProxyRequestContentLengthAndTransferEncoding(t *testing.T) {
})) }))
defer downstreamServer.Close() defer downstreamServer.Close()
responder := &fakeResponder{} responder := &fakeResponder{t: t}
backendURL, _ := url.Parse(downstreamServer.URL) backendURL, _ := url.Parse(downstreamServer.URL)
proxyHandler := &UpgradeAwareProxyHandler{ proxyHandler := &UpgradeAwareProxyHandler{
Location: backendURL, Location: backendURL,

View File

@ -42,6 +42,7 @@ const (
appArmor = "AppArmor" appArmor = "AppArmor"
dynamicKubeletConfig = "DynamicKubeletConfig" dynamicKubeletConfig = "DynamicKubeletConfig"
dynamicVolumeProvisioning = "DynamicVolumeProvisioning" dynamicVolumeProvisioning = "DynamicVolumeProvisioning"
streamingProxyRedirects = "StreamingProxyRedirects"
) )
var ( var (
@ -53,6 +54,7 @@ var (
appArmor: {true, beta}, appArmor: {true, beta},
dynamicKubeletConfig: {false, alpha}, dynamicKubeletConfig: {false, alpha},
dynamicVolumeProvisioning: {true, alpha}, dynamicVolumeProvisioning: {true, alpha},
streamingProxyRedirects: {false, alpha},
} }
// Special handling for a few gates. // Special handling for a few gates.
@ -109,6 +111,10 @@ type FeatureGate interface {
// owner: @mtaufen // owner: @mtaufen
// alpha: v1.4 // alpha: v1.4
DynamicKubeletConfig() bool DynamicKubeletConfig() bool
// owner: timstclair
// alpha: v1.5
StreamingProxyRedirects() bool
} }
// featureGate implements FeatureGate as well as pflag.Value for flag parsing. // featureGate implements FeatureGate as well as pflag.Value for flag parsing.
@ -197,6 +203,12 @@ func (f *featureGate) DynamicVolumeProvisioning() bool {
return f.lookup(dynamicVolumeProvisioning) return f.lookup(dynamicVolumeProvisioning)
} }
// StreamingProxyRedirects controls whether the apiserver should intercept (and follow)
// redirects from the backend (Kubelet) for streaming requests (exec/attach/port-forward).
func (f *featureGate) StreamingProxyRedirects() bool {
return f.lookup(streamingProxyRedirects)
}
func (f *featureGate) lookup(key string) bool { func (f *featureGate) lookup(key string) bool {
defaultValue := f.known[key].enabled defaultValue := f.known[key].enabled
if f.enabled != nil { if f.enabled != nil {