diff --git a/config/config.go b/config/config.go index 4bf32e896..ca3f179ed 100644 --- a/config/config.go +++ b/config/config.go @@ -14,6 +14,7 @@ type Config struct { Metrics Metrics UI UI Runtime Runtime + FastCGI FastCGI ProfileMode string ProfilePath string Insecure bool @@ -142,3 +143,11 @@ type Consul struct { CheckScheme string CheckTLSSkipVerify bool } + +type FastCGI struct { + Index string + Root string + SplitPath string + ReadTimeout time.Duration + WriteTimeout time.Duration +} diff --git a/config/default.go b/config/default.go index c01b22914..14b3f0195 100644 --- a/config/default.go +++ b/config/default.go @@ -75,4 +75,11 @@ var defaultConfig = &Config{ Color: "light-green", Access: "rw", }, + FastCGI: FastCGI{ + Root: "", + Index: "index.php", + SplitPath: ".php", + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + }, } diff --git a/config/load.go b/config/load.go index 4529693f5..3b98b6739 100644 --- a/config/load.go +++ b/config/load.go @@ -184,6 +184,11 @@ func load(cmdline, environ, envprefix []string, props *properties.Properties) (c f.StringVar(&cfg.UI.Title, "ui.title", defaultConfig.UI.Title, "optional title for the UI") f.StringVar(&cfg.ProfileMode, "profile.mode", defaultConfig.ProfileMode, "enable profiling mode, one of [cpu, mem, mutex, block]") f.StringVar(&cfg.ProfilePath, "profile.path", defaultConfig.ProfilePath, "path to profile dump file") + f.StringVar(&cfg.FastCGI.Index, "fcgi.index", defaultConfig.FastCGI.Index, "FastCGI index file name") + f.StringVar(&cfg.FastCGI.Root, "fcgi.root", defaultConfig.FastCGI.Root, "Document root of FastCGI upstream") + f.StringVar(&cfg.FastCGI.SplitPath, "fcgi.path.split", defaultConfig.FastCGI.SplitPath, "String literal to split the document path") + f.DurationVar(&cfg.FastCGI.ReadTimeout, "fcgi.timeout.read", defaultConfig.FastCGI.ReadTimeout, "FastCGI request read timeout") + f.DurationVar(&cfg.FastCGI.WriteTimeout, "fcgi.timeout.write", defaultConfig.FastCGI.WriteTimeout, "FastCGI request write timeout") // deprecated flags var proxyLogRoutes string diff --git a/docs/content/feature/fastcgi-upstream.md b/docs/content/feature/fastcgi-upstream.md new file mode 100644 index 000000000..3532543fa --- /dev/null +++ b/docs/content/feature/fastcgi-upstream.md @@ -0,0 +1,14 @@ +--- +title: "FastCGI Upstream" +--- + +To support FastCGI upstream add `proto=fcgi` option to the `urlprefix-` tag. + +FastCGI upstreams support following configuration options: + + - `index`: Used to specify the index file that should be used if the request URL does not contain a + file. + - `root`: Document root of the FastCGI server. + +Note that `index` and `root` can also be set in Fabio configuration as global default. + diff --git a/docs/content/quickstart/_index.md b/docs/content/quickstart/_index.md index 3bc0f6cd2..4bd376679 100644 --- a/docs/content/quickstart/_index.md +++ b/docs/content/quickstart/_index.md @@ -46,6 +46,10 @@ and you need to add a separate `urlprefix-` tag for every `host/path` prefix the # TCP examples urlprefix-:3306 proto=tcp # route external port 3306 + + # Fast-CGI example + urlprefix-/blog proto=fcgi + urlprefix-/home proto=fcgi strip=/home ``` 5. Start fabio without a config file diff --git a/docs/content/ref/fcgi.index.md b/docs/content/ref/fcgi.index.md new file mode 100644 index 000000000..70f902e0f --- /dev/null +++ b/docs/content/ref/fcgi.index.md @@ -0,0 +1,12 @@ +--- +title: "fcgi.index" +--- + +`fcgi.index` configures the index file to be used in FastCGI requests if the URL does not contain +it. + +Default value is + +``` +fcgi.index = index.php +``` diff --git a/docs/content/ref/fcgi.path.split.md b/docs/content/ref/fcgi.path.split.md new file mode 100644 index 000000000..dca597b32 --- /dev/null +++ b/docs/content/ref/fcgi.path.split.md @@ -0,0 +1,12 @@ +--- +title: "fcgi.path.split" +--- + +`fcgi.path.split` specifies how to split the URL; the split value becomes the end of the first part +and anything in the URL after it becomes part of the `PATH_INFO` CGI variable. + +Default value is + +``` +fcgi.path.split = .php +``` diff --git a/docs/content/ref/fcgi.root.md b/docs/content/ref/fcgi.root.md new file mode 100644 index 000000000..9e4cc1dbf --- /dev/null +++ b/docs/content/ref/fcgi.root.md @@ -0,0 +1,11 @@ +--- +title: "fcgi.root" +--- + +`fcgi.root` sets the document root for FastCGI requests. + +Default value is empty string + +``` +fcgi.root = +``` diff --git a/docs/content/ref/fcgi.timeout.read.md b/docs/content/ref/fcgi.timeout.read.md new file mode 100644 index 000000000..5b6f109fa --- /dev/null +++ b/docs/content/ref/fcgi.timeout.read.md @@ -0,0 +1,11 @@ +--- +title: "fcgi.timeout.read" +--- + +`fcgi.timeout.read` is the time allowed to read a response from upstream. + +Default value is + +``` +fcgi.timeout.read = 10s +``` diff --git a/docs/content/ref/fcgi.timeout.write.md b/docs/content/ref/fcgi.timeout.write.md new file mode 100644 index 000000000..798d145e8 --- /dev/null +++ b/docs/content/ref/fcgi.timeout.write.md @@ -0,0 +1,11 @@ +--- +title: "fcgi.timeout.write" +--- + +`fcgi.timeout.write` is the time allowed to upload complete request to upstream. + +Default value is + +``` +fcgi.timeout.write = 10s +``` diff --git a/main.go b/main.go index a8637354e..859beed6e 100644 --- a/main.go +++ b/main.go @@ -181,7 +181,7 @@ func newHTTPProxy(cfg *config.Config) http.Handler { } return &proxy.HTTPProxy{ - Config: cfg.Proxy, + Config: cfg, Transport: newTransport(nil), InsecureTransport: newTransport(&tls.Config{InsecureSkipVerify: true}), Lookup: func(r *http.Request) *route.Target { diff --git a/proxy/fastcgi/helper_test.go b/proxy/fastcgi/helper_test.go new file mode 100644 index 000000000..ec931b9d5 --- /dev/null +++ b/proxy/fastcgi/helper_test.go @@ -0,0 +1,48 @@ +package fastcgi + +import ( + "io" + "net/http" + "time" +) + +type staticFcgiBackend struct { + OptionsFunc func(map[string]string) (*http.Response, error) + HeadFunc func(map[string]string) (*http.Response, error) + GetFunc func(map[string]string) (*http.Response, error) + PostFunc func(map[string]string, string, string, io.Reader, int64) (*http.Response, error) + SetReadTimeoutFunc func(time.Duration) error + SetSendTimeoutFunc func(time.Duration) error + StderrFunc func() string + CloseFunc func() +} + +func (b *staticFcgiBackend) Options(params map[string]string) (*http.Response, error) { + return b.OptionsFunc(params) +} + +func (b *staticFcgiBackend) Head(params map[string]string) (*http.Response, error) { + return b.HeadFunc(params) +} + +func (b *staticFcgiBackend) Get(params map[string]string) (*http.Response, error) { + return b.GetFunc(params) +} + +func (b *staticFcgiBackend) SetReadTimeout(dur time.Duration) error { + return b.SetReadTimeoutFunc(dur) +} + +func (b *staticFcgiBackend) SetSendTimeout(dur time.Duration) error { + return b.SetSendTimeoutFunc(dur) +} + +func (b *staticFcgiBackend) Stderr() string { + return b.StderrFunc() +} + +func (b *staticFcgiBackend) Post(params map[string]string, method string, bodyType string, body io.Reader, l int64) (*http.Response, error) { + return b.PostFunc(params, method, bodyType, body, l) +} + +func (b *staticFcgiBackend) Close() {} diff --git a/proxy/fastcgi/proxy.go b/proxy/fastcgi/proxy.go new file mode 100644 index 000000000..398e26d85 --- /dev/null +++ b/proxy/fastcgi/proxy.go @@ -0,0 +1,287 @@ +package fastcgi + +import ( + "fmt" + "io" + "log" + "net" + "net/http" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/fabiolb/fabio/config" +) + +var ( + headerNameReplacer = strings.NewReplacer(" ", "_", "-", "_") +) + +type Proxy struct { + root string + index string + stripPrefix string + upstream string + config *config.Config + dialFunc func(string) (FCGIBackend, error) +} + +// FCGIBackend describes the capabilities offered by +// FastCGI bakcned server +type FCGIBackend interface { + // Options proxies HTTP OPTION request to FCGI backend + Options(parameters map[string]string) (resp *http.Response, err error) + + // Head proxies HTTP HEAD request to FCGI backend + Head(parameters map[string]string) (resp *http.Response, err error) + + // Get proxies HTTP GET request to FCGI backend + Get(parameters map[string]string) (resp *http.Response, err error) + + // Post proxies HTTP Post request to FCGI backend + Post(parameters map[string]string, method string, bodyType string, body io.Reader, contentLength int64) (resp *http.Response, err error) + + // SetReadTimeout sets the maximum time duration the + // connection will wait to read full response. If the + // deadline is reached before the response is read in + // full, the client receives a gateway timeout error. + SetReadTimeout(time.Duration) error + + // SetSendTimeout sets the maximum time duration the + // connection will wait to send full request to FCGI backend. + // If the deadline is reached before the request is sent + // completely, the client receives a gateway timeout error. + SetSendTimeout(time.Duration) error + + // Stderr returns any error produced by FCGI backend + // while processing the request. + Stderr() string + + // Close closes the connection. + Close() +} + +func NewProxy(cfg *config.Config, upstream string) *Proxy { + return &Proxy{ + root: cfg.FastCGI.Root, + index: cfg.FastCGI.Index, + upstream: upstream, + config: cfg, + dialFunc: Connect, + } +} + +// Connect to FCGI backend +func Connect(upstream string) (FCGIBackend, error) { + return Dial("tcp", upstream) +} + +func (p *Proxy) SetRoot(root string) { + p.root = root +} + +func (p *Proxy) SetStripPathPrefix(prefix string) { + p.stripPrefix = prefix +} + +func (p *Proxy) SetIndex(index string) { + p.index = index +} + +func (p *Proxy) stripPathPrefix(path string) string { + return strings.TrimPrefix(path, p.stripPrefix) +} + +func (p *Proxy) ensureIndexFile(path string) string { + prefix := "" + if strings.HasPrefix(path, "/") { + prefix = "/" + } + + if strings.HasPrefix(path, prefix+p.index) { + return path + } + + return filepath.Join(p.index, path) +} + +func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { + fpath := p.stripPathPrefix(r.URL.Path) + env, err := p.buildEnv(r, p.ensureIndexFile(fpath)) + if err != nil { + log.Printf("[WARN] failed to create fastcgi environment. %s", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + for x, y := range env { + log.Printf("[INFO] >>>> %s => %s", x, y) + } + + fcgiBackend, err := p.dialFunc(p.upstream) + if err != nil { + log.Printf("[WARN] failed to connect with FastCGI upstream. %s", err) + http.Error(w, "Bad Gateway", http.StatusBadGateway) + return + } + defer fcgiBackend.Close() + + if err := fcgiBackend.SetReadTimeout(p.config.FastCGI.ReadTimeout); err != nil { + log.Printf("[ERROR] failed to set connection read timeout. %s", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + if err := fcgiBackend.SetSendTimeout(p.config.FastCGI.WriteTimeout); err != nil { + log.Printf("[ERROR] failed to set connection write timeout. %s", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + var resp *http.Response + + contentLength := r.ContentLength + if contentLength == 0 { + contentLength, _ = strconv.ParseInt(r.Header.Get("Content-Length"), 10, 64) + } + + switch r.Method { + case "HEAD": + resp, err = fcgiBackend.Head(env) + case "GET": + resp, err = fcgiBackend.Get(env) + case "OPTIONS": + resp, err = fcgiBackend.Options(env) + default: + resp, err = fcgiBackend.Post(env, r.Method, r.Header.Get("Content-Type"), r.Body, contentLength) + } + + if resp != nil && resp.Body != nil { + defer resp.Body.Close() + } + + if err != nil { + if err, ok := err.(net.Error); ok && err.Timeout() { + log.Printf("[ERROR] FastCGI upstream timed out during request. %s", err) + http.Error(w, "Gateway Timeout", http.StatusGatewayTimeout) + return + } else if err != io.EOF { + log.Printf("[ERROR] failed to read response from FastCGI upstream. %s", err) + http.Error(w, "Bad Gateway", http.StatusBadGateway) + return + } + } + + writeHeader(w, resp) + + _, err = io.Copy(w, resp.Body) + if err != nil { + log.Printf("[ERROR] failed to write response body. %s", err) + http.Error(w, "Bad Gateway", http.StatusBadGateway) + return + } + + if errOut := fcgiBackend.Stderr(); errOut != "" { + log.Printf("[WARN] Error from FastCGI upstream: %s", errOut) + return + } +} + +func writeHeader(w http.ResponseWriter, r *http.Response) { + for key, vals := range r.Header { + for _, val := range vals { + w.Header().Add(key, val) + } + } + w.WriteHeader(r.StatusCode) +} + +func (p Proxy) buildEnv(r *http.Request, fpath string) (map[string]string, error) { + absPath := filepath.Join(p.root, fpath) + + // Separate remote IP and port; more lenient than net.SplitHostPort + var ip, port string + if idx := strings.LastIndex(r.RemoteAddr, ":"); idx > -1 { + ip = r.RemoteAddr[:idx] + port = r.RemoteAddr[idx+1:] + } else { + ip = r.RemoteAddr + } + + username := "" + if r.URL.User != nil { + username = r.URL.User.Username() + } + + // Remove [] from IPv6 addresses + ip = strings.TrimPrefix(ip, "[") + ip = strings.TrimSuffix(ip, "]") + + splitPos := p.splitPos(fpath) + if splitPos == -1 { + return nil, fmt.Errorf("cannot split path on %s", p.config.FastCGI.SplitPath) + } + + // Request has the extension; path was split successfully + docURI := fpath[:splitPos+len(p.config.FastCGI.SplitPath)] + pathInfo := fpath[splitPos+len(p.config.FastCGI.SplitPath):] + scriptName := fpath + scriptFilename := absPath + + // Strip PATH_INFO from SCRIPT_NAME + scriptName = strings.TrimSuffix(scriptName, pathInfo) + + // Some variables are unused but cleared explicitly to prevent + // the parent environment from interfering. + env := map[string]string{ + // Variables defined in CGI 1.1 spec + "AUTH_TYPE": "", // Not used + "CONTENT_LENGTH": r.Header.Get("Content-Length"), + "CONTENT_TYPE": r.Header.Get("Content-Type"), + "GATEWAY_INTERFACE": "CGI/1.1", + "PATH_INFO": pathInfo, + "QUERY_STRING": r.URL.RawQuery, + "REMOTE_ADDR": ip, + "REMOTE_HOST": ip, // For speed, remote host lookups disabled + "REMOTE_PORT": port, + "REMOTE_IDENT": "", // Not used + "REMOTE_USER": username, + "REQUEST_METHOD": r.Method, + "SERVER_NAME": r.URL.Hostname(), + "SERVER_PORT": r.URL.Port(), + "SERVER_PROTOCOL": r.Proto, + "SERVER_SOFTWARE": "fabio", + + // Other variables + "DOCUMENT_ROOT": p.root, + "DOCUMENT_URI": docURI, + "HTTP_HOST": r.Host, // added here, since not always part of headers + "REQUEST_URI": p.stripPathPrefix(r.URL.RequestURI()), + "SCRIPT_FILENAME": scriptFilename, + "SCRIPT_NAME": scriptName, + } + + // compliance with the CGI specification requires that + // PATH_TRANSLATED should only exist if PATH_INFO is defined. + // Info: https://www.ietf.org/rfc/rfc3875 Page 14 + if env["PATH_INFO"] != "" { + env["PATH_TRANSLATED"] = filepath.Join(p.root, pathInfo) // Info: http://www.oreilly.com/openbook/cgi/ch02_04.html + } + + // Some web apps rely on knowing HTTPS or not + if r.TLS != nil { + env["HTTPS"] = "on" + } + + // Add all HTTP headers to env variables + for field, val := range r.Header { + header := strings.ToUpper(field) + header = headerNameReplacer.Replace(header) + env["HTTP_"+header] = strings.Join(val, ", ") + } + return env, nil +} + +func (p *Proxy) splitPos(path string) int { + return strings.Index(strings.ToLower(path), strings.ToLower(p.config.FastCGI.SplitPath)) +} diff --git a/proxy/fastcgi/proxy_test.go b/proxy/fastcgi/proxy_test.go new file mode 100644 index 000000000..2d8865b8d --- /dev/null +++ b/proxy/fastcgi/proxy_test.go @@ -0,0 +1,180 @@ +package fastcgi + +import ( + "crypto/tls" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/fabiolb/fabio/config" +) + +func getBackendDialer(b *staticFcgiBackend) func(string) (FCGIBackend, error) { + return func(u string) (FCGIBackend, error) { + return b, nil + } +} + +type rc struct { + eof error + v []byte +} + +func (r *rc) Close() error { return nil } + +func (r *rc) Read(p []byte) (n int, err error) { + if r.eof != nil { + return 0, r.eof + } + + copy(p, r.v) + r.eof = io.EOF + return len(r.v), nil +} + +func TestServeHTTP(t *testing.T) { + data := struct { + readTimeout time.Duration + sendTimeout time.Duration + env map[string]string + method string + contentType string + body io.Reader + contentLength int64 + }{} + + req, err := http.NewRequest("post", "https://app.host/user/index.php/profile?key=value", strings.NewReader("test request body")) + if err != nil { + t.Error("failed to create new http request", err) + } + req.Header.Add("Content-Length", "17") + req.Header.Add("Content-Type", "text/plain") + + response := http.Response{ + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Header: http.Header{}, + ContentLength: 10, + TransferEncoding: nil, + Close: true, + Uncompressed: false, + Request: req, + TLS: nil, + Body: &rc{ + v: []byte("successful response"), + }, + } + + backend := &staticFcgiBackend{ + SetReadTimeoutFunc: func(t time.Duration) error { + data.readTimeout = t + return nil + }, + SetSendTimeoutFunc: func(t time.Duration) error { + data.sendTimeout = t + return nil + }, + PostFunc: func(params map[string]string, m string, ct string, b io.Reader, cl int64) (*http.Response, error) { + data.env = params + data.method = m + data.contentType = ct + data.body = b + data.contentLength = cl + return &response, nil + }, + StderrFunc: func() string { return "" }, + } + + proxy := Proxy{ + upstream: "app.fpm.internal", + config: &config.Config{ + FastCGI: config.FastCGI{ + Root: "/site", + SplitPath: ".php", + ReadTimeout: 3 * time.Second, + WriteTimeout: 3 * time.Second, + }, + }, + dialFunc: getBackendDialer(backend), + } + + resp := httptest.NewRecorder() + proxy.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Errorf("expected response code '200', got '%d'", resp.Code) + } + if resp.Body.String() != "successful response" { + t.Errorf("expected response body 'successful response', got '%s'", resp.Body.String()) + } +} + +func TestBuildEnv(t *testing.T) { + proxy := Proxy{ + upstream: "app.fpm.internal", + config: &config.Config{ + FastCGI: config.FastCGI{ + Root: "/site", + SplitPath: ".php", + }, + }, + dialFunc: nil, + } + + req, err := http.NewRequest("post", "https://app.host:443/test/url?key=value", strings.NewReader("test request body")) + if err != nil { + t.Error("failed to create new http request", err) + } + + req.Header.Add("Content-Length", "17") + req.Header.Add("Content-Type", "text/plain") + req.Header.Add("X-Custom-Header-One", "One") + req.TLS = &tls.ConnectionState{} + + env, err := proxy.buildEnv(req, "/docs/index.php/user/profile") + if err != nil { + t.Error("failed to build environment", err) + } + + expected := map[string]string{ + "AUTH_TYPE": "", + "CONTENT_LENGTH": "17", + "CONTENT_TYPE": "text/plain", + "GATEWAY_INTERFACE": "CGI/1.1", + "PATH_INFO": "/user/profile", + "QUERY_STRING": "key=value", + "REMOTE_ADDR": "", + "REMOTE_HOST": "", + "REMOTE_PORT": "", + "REMOTE_IDENT": "", + "REMOTE_USER": "", + "REQUEST_METHOD": "post", + "SERVER_NAME": "app.host", + "SERVER_PORT": "443", + "SERVER_PROTOCOL": "HTTP/1.1", + "SERVER_SOFTWARE": "fabio", + "DOCUMENT_ROOT": "/site", + "DOCUMENT_URI": "/docs/index.php", + "HTTP_HOST": "app.host:443", + "REQUEST_URI": "/test/url?key=value", + "SCRIPT_FILENAME": "/site/docs/index.php/user/profile", + "SCRIPT_NAME": "/docs/index.php", + "PATH_TRANSLATED": "/site/user/profile", + "HTTPS": "on", + "HTTP_X_CUSTOM_HEADER_ONE": "One", + } + + for ke, ve := range expected { + if v, ok := env[ke]; !ok { + t.Errorf("key '%s' is not present in environment map", ke) + } else if v != ve { + t.Errorf("Key '%s': expected value '%s', got '%s'", ke, ve, v) + } + } +} diff --git a/proxy/http_integration_test.go b/proxy/http_integration_test.go index c430c4ae0..42ad53e9d 100644 --- a/proxy/http_integration_test.go +++ b/proxy/http_integration_test.go @@ -34,7 +34,9 @@ func TestProxyProducesCorrectXForwardedSomethingHeader(t *testing.T) { defer server.Close() proxy := httptest.NewServer(&HTTPProxy{ - Config: config.Proxy{LocalIP: "1.1.1.1", ClientIPHeader: "X-Forwarded-For"}, + Config: &config.Config{ + Proxy: config.Proxy{LocalIP: "1.1.1.1", ClientIPHeader: "X-Forwarded-For"}, + }, Transport: http.DefaultTransport, Lookup: func(r *http.Request) *route.Target { return &route.Target{URL: mustParse(server.URL)} @@ -63,7 +65,7 @@ func TestProxyRequestIDHeader(t *testing.T) { defer server.Close() proxy := httptest.NewServer(&HTTPProxy{ - Config: config.Proxy{RequestID: "X-Request-Id"}, + Config: &config.Config{Proxy: config.Proxy{RequestID: "X-Request-Id"}}, Transport: http.DefaultTransport, UUID: func() string { return "f47ac10b-58cc-0372-8567-0e02b2c3d479" }, Lookup: func(r *http.Request) *route.Target { @@ -85,11 +87,13 @@ func TestProxySTSHeader(t *testing.T) { defer server.Close() proxy := httptest.NewTLSServer(&HTTPProxy{ - Config: config.Proxy{ - STSHeader: config.STSHeader{ - MaxAge: 31536000, - Subdomains: true, - Preload: true, + Config: &config.Config{ + Proxy: config.Proxy{ + STSHeader: config.STSHeader{ + MaxAge: 31536000, + Subdomains: true, + Preload: true, + }, }, }, Transport: &http.Transport{TLSClientConfig: tlsInsecureConfig()}, @@ -119,6 +123,7 @@ func TestProxyNoRouteHTML(t *testing.T) { want := "503" noroute.SetHTML(want) proxy := httptest.NewServer(&HTTPProxy{ + Config: &config.Config{}, Transport: http.DefaultTransport, Lookup: func(*http.Request) *route.Target { return nil }, }) @@ -132,7 +137,9 @@ func TestProxyNoRouteHTML(t *testing.T) { func TestProxyNoRouteStatus(t *testing.T) { proxy := httptest.NewServer(&HTTPProxy{ - Config: config.Proxy{NoRouteStatus: 999}, + Config: &config.Config{ + Proxy: config.Proxy{NoRouteStatus: 999}, + }, Transport: http.DefaultTransport, Lookup: func(*http.Request) *route.Target { return nil }, }) @@ -155,6 +162,7 @@ func TestProxyStripsPath(t *testing.T) { })) proxy := httptest.NewServer(&HTTPProxy{ + Config: &config.Config{}, Transport: http.DefaultTransport, Lookup: func(r *http.Request) *route.Target { tbl, _ := route.NewTable("route add mock /foo/bar " + server.URL + ` opts "strip=/foo"`) @@ -187,6 +195,7 @@ func TestProxyHost(t *testing.T) { tbl, _ := route.NewTable(routes) proxy := httptest.NewServer(&HTTPProxy{ + Config: &config.Config{}, Transport: &http.Transport{ Dial: func(network, addr string) (net.Conn, error) { addr = server.URL[len("http://"):] @@ -239,6 +248,7 @@ func TestRedirect(t *testing.T) { tbl, _ := route.NewTable(routes) proxy := httptest.NewServer(&HTTPProxy{ + Config: &config.Config{}, Transport: http.DefaultTransport, Lookup: func(r *http.Request) *route.Target { return tbl.Lookup(r, "", route.Picker["rr"], route.Matcher["prefix"]) @@ -299,6 +309,7 @@ func TestProxyLogOutput(t *testing.T) { // create a proxy handler with mocked time tm := time.Date(2016, 1, 1, 0, 0, 0, 12345678, time.UTC) proxyHandler := &HTTPProxy{ + Config: &config.Config{}, Time: func() time.Time { defer func() { tm = tm.Add(1111111111 * time.Nanosecond) }() return tm @@ -386,7 +397,7 @@ func TestProxyHTTPSUpstream(t *testing.T) { defer server.Close() proxy := httptest.NewServer(&HTTPProxy{ - Config: config.Proxy{}, + Config: &config.Config{}, Transport: &http.Transport{TLSClientConfig: tlsClientConfig()}, Lookup: func(r *http.Request) *route.Target { tbl, _ := route.NewTable("route add srv / " + server.URL + ` opts "proto=https"`) @@ -411,7 +422,7 @@ func TestProxyHTTPSUpstreamSkipVerify(t *testing.T) { defer server.Close() proxy := httptest.NewServer(&HTTPProxy{ - Config: config.Proxy{}, + Config: &config.Config{}, Transport: http.DefaultTransport, InsecureTransport: &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, @@ -491,8 +502,10 @@ func TestProxyGzipHandler(t *testing.T) { defer server.Close() proxy := httptest.NewServer(&HTTPProxy{ - Config: config.Proxy{ - GZIPContentTypes: regexp.MustCompile("^text/plain(;.*)?$"), + Config: &config.Config{ + Proxy: config.Proxy{ + GZIPContentTypes: regexp.MustCompile("^text/plain(;.*)?$"), + }, }, Transport: http.DefaultTransport, Lookup: func(r *http.Request) *route.Target { @@ -612,9 +625,11 @@ func BenchmarkProxyLogger(b *testing.B) { } proxy := &HTTPProxy{ - Config: config.Proxy{ - LocalIP: "1.1.1.1", - ClientIPHeader: "X-Forwarded-For", + Config: &config.Config{ + Proxy: config.Proxy{ + LocalIP: "1.1.1.1", + ClientIPHeader: "X-Forwarded-For", + }, }, Transport: http.DefaultTransport, Lookup: func(r *http.Request) *route.Target { diff --git a/proxy/http_proxy.go b/proxy/http_proxy.go index ba505fc97..605b38fc1 100644 --- a/proxy/http_proxy.go +++ b/proxy/http_proxy.go @@ -15,6 +15,7 @@ import ( "github.com/fabiolb/fabio/logger" "github.com/fabiolb/fabio/metrics" "github.com/fabiolb/fabio/noroute" + "github.com/fabiolb/fabio/proxy/fastcgi" "github.com/fabiolb/fabio/proxy/gzip" "github.com/fabiolb/fabio/route" "github.com/fabiolb/fabio/uuid" @@ -23,7 +24,7 @@ import ( // HTTPProxy is a dynamic reverse proxy for HTTP and HTTPS protocols. type HTTPProxy struct { // Config is the proxy configuration as provided during startup. - Config config.Proxy + Config *config.Config // Time returns the current time as the number of seconds since the epoch. // If Time is nil, time.Now is used. @@ -62,17 +63,17 @@ func (p *HTTPProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { panic("no lookup function") } - if p.Config.RequestID != "" { + if p.Config.Proxy.RequestID != "" { id := p.UUID if id == nil { id = uuid.NewUUID } - r.Header.Set(p.Config.RequestID, id()) + r.Header.Set(p.Config.Proxy.RequestID, id()) } t := p.Lookup(r) if t == nil { - status := p.Config.NoRouteStatus + status := p.Config.Proxy.NoRouteStatus if status < 100 || status > 999 { status = http.StatusNotFound } @@ -129,12 +130,12 @@ func (p *HTTPProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { targetURL.Path = targetURL.Path[len(t.StripPath):] } - if err := addHeaders(r, p.Config, t.StripPath); err != nil { + if err := addHeaders(r, p.Config.Proxy, t.StripPath); err != nil { http.Error(w, "cannot parse "+r.RemoteAddr, http.StatusInternalServerError) return } - if err := addResponseHeaders(w, r, p.Config); err != nil { + if err := addResponseHeaders(w, r, p.Config.Proxy); err != nil { http.Error(w, "cannot add response headers", http.StatusInternalServerError) return } @@ -146,8 +147,27 @@ func (p *HTTPProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { tr = p.InsecureTransport } + isFCGI := false + if v, ok := t.Opts["proto"]; ok && v == "fcgi" { + isFCGI = true + } + var h http.Handler switch { + case isFCGI: + fcgiProxy := fastcgi.NewProxy(p.Config, targetURL.Host) + if fcgiRoot, ok := t.Opts["root"]; ok { + fcgiProxy.SetRoot(fcgiRoot) + } + if stripPrefix, ok := t.Opts["strip"]; ok { + fcgiProxy.SetStripPathPrefix(stripPrefix) + } + if indexFile, ok := t.Opts["index"]; ok { + fcgiProxy.SetIndex(indexFile) + } + + h = fcgiProxy + case upgrade == "websocket" || upgrade == "Websocket": r.URL = targetURL if targetURL.Scheme == "https" || targetURL.Scheme == "wss" { @@ -161,14 +181,14 @@ func (p *HTTPProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { case accept == "text/event-stream": // use the flush interval for SSE (server-sent events) // must be > 0s to be effective - h = newHTTPProxy(targetURL, tr, p.Config.FlushInterval) + h = newHTTPProxy(targetURL, tr, p.Config.Proxy.FlushInterval) default: h = newHTTPProxy(targetURL, tr, time.Duration(0)) } - if p.Config.GZIPContentTypes != nil { - h = gzip.NewGzipHandler(h, p.Config.GZIPContentTypes) + if p.Config.Proxy.GZIPContentTypes != nil { + h = gzip.NewGzipHandler(h, p.Config.Proxy.GZIPContentTypes) } timeNow := p.Time diff --git a/proxy/listen_test.go b/proxy/listen_test.go index 607461641..a57058f65 100644 --- a/proxy/listen_test.go +++ b/proxy/listen_test.go @@ -29,6 +29,7 @@ func TestGracefulShutdown(t *testing.T) { go func() { defer wg.Done() h := &HTTPProxy{ + Config: &config.Config{}, Transport: http.DefaultTransport, Lookup: func(r *http.Request) *route.Target { tbl, _ := route.NewTable("route add svc / " + srv.URL) diff --git a/proxy/ws_integration_test.go b/proxy/ws_integration_test.go index a03977481..24dcbcdf7 100644 --- a/proxy/ws_integration_test.go +++ b/proxy/ws_integration_test.go @@ -39,7 +39,7 @@ func TestProxyWSUpstream(t *testing.T) { routes += "route add ws /foo/strip " + wsServer.URL + ` opts "strip=/foo"` + "\n" httpProxy := httptest.NewServer(&HTTPProxy{ - Config: config.Proxy{NoRouteStatus: 404, GZIPContentTypes: regexp.MustCompile(".*")}, + Config: &config.Config{Proxy: config.Proxy{NoRouteStatus: 404, GZIPContentTypes: regexp.MustCompile(".*")}}, Transport: &http.Transport{TLSClientConfig: tlsClientConfig()}, InsecureTransport: &http.Transport{TLSClientConfig: tlsInsecureConfig()}, Lookup: func(r *http.Request) *route.Target { @@ -51,7 +51,7 @@ func TestProxyWSUpstream(t *testing.T) { t.Log("Started HTTP proxy: ", httpProxy.URL) httpsProxy := httptest.NewUnstartedServer(&HTTPProxy{ - Config: config.Proxy{NoRouteStatus: 404}, + Config: &config.Config{Proxy: config.Proxy{NoRouteStatus: 404}}, Transport: &http.Transport{TLSClientConfig: tlsClientConfig()}, InsecureTransport: &http.Transport{TLSClientConfig: tlsInsecureConfig()}, Lookup: func(r *http.Request) *route.Target {