Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions conf/frps_full_example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,32 @@ subDomainHost = "frps.com"
# custom 404 page for HTTP requests
# custom404Page = "/path/to/404.html"

# custom error pages for HTTP status codes generated by frps itself, such as
# 404 (no route found), 504 (proxy timeout) and 401/407 (auth failed). The
# body is inline content, so no files need to be mounted into the frps
# container. For status code 404, custom404Page takes precedence over an
# entry with statusCode 404.
# [[customErrorPages]]
# statusCode = 404
# contentType = "text/html"
# body = """
# <!DOCTYPE html>
# <html>
# <head><title>Not Found</title></head>
# <body>
# <h1>The page you requested was not found.</h1>
# <p>Contact support: support@example.com</p>
# </body>
# </html>
# """
#
# [[customErrorPages]]
# statusCode = 504
# contentType = "text/html"
# body = "Service temporarily unavailable, please try again later."
# [customErrorPages.headers]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Move the error-page examples below the remaining root settings

Uncommenting these examples in their current location leaves TOML inside [customErrorPages.headers], so the following udpPacketSize = 1500 and natholeAnalysisDataReserveHours = 168 become response-header entries instead of server settings. Configuration loading then fails because CustomErrorPage.Headers is a map[string]string but those values are numbers, even with strict parsing disabled. Enabling only the 404 example also captures those settings inside its array element and fails default strict parsing. Move the entire example block after the remaining root/dotted settings, immediately before [[httpPlugins]], so enabling this documented option does not prevent frps from starting.

# Retry-After = "30"

# specify udp packet size, unit is byte. If not set, the default value is 1500.
# This parameter should be same between client and server.
# It affects the udp and sudp proxy.
Expand Down
21 changes: 21 additions & 0 deletions pkg/config/v1/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ type ServerConfig struct {
// Custom404Page specifies a path to a custom 404 page to display. If this
// value is "", a default page will be displayed.
Custom404Page string `json:"custom404Page,omitempty"`
// CustomErrorPages defines custom error pages for HTTP status codes
// generated by frps itself, such as 404 (no route found) and 504 (proxy
// timeout). The response body is inline content, so no files need to be
// mounted into the frps container. For status code 404, custom404Page
// takes precedence over an entry with statusCode 404.
CustomErrorPages []CustomErrorPage `json:"customErrorPages,omitempty"`

SSHTunnelGateway SSHTunnelGateway `json:"sshTunnelGateway,omitempty"`

Expand Down Expand Up @@ -205,6 +211,21 @@ type TLSServerConfig struct {
TLSConfig
}

// CustomErrorPage defines a custom error page for a specific HTTP status
// code.
type CustomErrorPage struct {
// StatusCode is the HTTP status code this page responds with. It must be
// in the range [400, 599].
StatusCode int `json:"statusCode"`
// ContentType is the Content-Type of the response. If empty, "text/html"
// will be used.
ContentType string `json:"contentType,omitempty"`
// Body is the inline response body.
Body string `json:"body"`
// Headers contains additional response headers.
Headers map[string]string `json:"headers,omitempty"`
}

type SSHTunnelGateway struct {
BindPort int `json:"bindPort,omitempty"`
PrivateKeyFile string `json:"privateKeyFile,omitempty"`
Expand Down
11 changes: 11 additions & 0 deletions pkg/config/v1/validation/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,17 @@ func (v *ConfigValidator) ValidateServerConfig(c *v1.ServerConfig) (Warning, err
errs = AppendError(errs, fmt.Errorf("invalid transport.maxPoolCount, must be non-negative"))
}

seenStatusCodes := make(map[int]struct{}, len(c.CustomErrorPages))
for _, p := range c.CustomErrorPages {
if p.StatusCode < 400 || p.StatusCode > 599 {
errs = AppendError(errs, fmt.Errorf("invalid customErrorPages statusCode %d, must be in the range [400, 599]", p.StatusCode))
}
if _, ok := seenStatusCodes[p.StatusCode]; ok {
errs = AppendError(errs, fmt.Errorf("duplicate customErrorPages statusCode %d", p.StatusCode))
}
seenStatusCodes[p.StatusCode] = struct{}{}
}

for _, p := range c.HTTPPlugins {
if !lo.Every(SupportedHTTPPluginOps, p.Ops) {
errs = AppendError(errs, fmt.Errorf("invalid http plugin ops, optional values are %v", SupportedHTTPPluginOps))
Expand Down
25 changes: 16 additions & 9 deletions pkg/util/vhost/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,14 +127,17 @@ func NewHTTPReverseProxy(option HTTPReverseProxyOptions, vhostRouter *Routers) *
ErrorLog: stdlog.New(log.NewWriteLogger(log.WarnLevel, 2), "", 0),
ErrorHandler: func(rw http.ResponseWriter, req *http.Request, err error) {
log.Logf(log.WarnLevel, 1, "do http proxy request [host: %s] error: %v", req.Host, err)
if err != nil {
if e, ok := err.(net.Error); ok && e.Timeout() {
rw.WriteHeader(http.StatusGatewayTimeout)
return
}
statusCode := http.StatusNotFound
if e, ok := err.(net.Error); ok && e.Timeout() {
statusCode = http.StatusGatewayTimeout
}
if statusCode == http.StatusNotFound {
writeErrorPage(rw, statusCode, resolveNotFoundPage())
return
}
if !writeCustomErrorPage(rw, statusCode) {
rw.WriteHeader(statusCode)
}
rw.WriteHeader(http.StatusNotFound)
_, _ = rw.Write(getNotFoundPageContent())
},
}
rp.proxy = proxy
Expand Down Expand Up @@ -291,10 +294,14 @@ func (rp *HTTPReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request)
if !checkRouteAuthByRequest(req, rc) {
if req.URL.Host != "" {
rw.Header().Set("Proxy-Authenticate", `Basic realm="Restricted"`)
http.Error(rw, http.StatusText(http.StatusProxyAuthRequired), http.StatusProxyAuthRequired)
if !writeCustomErrorPage(rw, http.StatusProxyAuthRequired) {
http.Error(rw, http.StatusText(http.StatusProxyAuthRequired), http.StatusProxyAuthRequired)
}
} else {
rw.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
if !writeCustomErrorPage(rw, http.StatusUnauthorized) {
http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
}
}
return
}
Expand Down
80 changes: 68 additions & 12 deletions pkg/util/vhost/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,26 @@ import (

var NotFoundPagePath = ""

// CustomErrorPage defines a custom error page for a specific HTTP status code.
// It is used for errors generated by frps itself, such as 404 (no route found)
// and 504 (proxy timeout).
type CustomErrorPage struct {
// ContentType is the Content-Type of the response. If empty, "text/html"
// will be used.
ContentType string
// Body is the inline response body.
Body string
// Headers contains additional response headers.
Headers map[string]string
}

var customErrorPages = make(map[int]CustomErrorPage)

// SetCustomErrorPages registers custom error pages keyed by HTTP status code.
func SetCustomErrorPages(pages map[int]CustomErrorPage) {
customErrorPages = pages
}

const (
NotFound = `<!DOCTYPE html>
<html>
Expand All @@ -50,29 +70,65 @@ Please try again later.</p>
`
)

func getNotFoundPageContent() []byte {
var (
buf []byte
err error
)
func (p CustomErrorPage) contentType() string {
if p.ContentType != "" {
return p.ContentType
}
return "text/html"
}

// resolveNotFoundPage returns the error page used for 404 responses. A custom
// page file (NotFoundPagePath) takes precedence over a customErrorPages entry
// with status code 404, which in turn takes precedence over the built-in
// default page.
func resolveNotFoundPage() CustomErrorPage {
if NotFoundPagePath != "" {
buf, err = os.ReadFile(NotFoundPagePath)
buf, err := os.ReadFile(NotFoundPagePath)
if err != nil {
log.Warnf("read custom 404 page error: %v", err)
buf = []byte(NotFound)
} else {
return CustomErrorPage{Body: string(buf)}
}
} else {
buf = []byte(NotFound)
}
return buf
if p, ok := customErrorPages[http.StatusNotFound]; ok {
return p
}
return CustomErrorPage{Body: NotFound}
}

// writeErrorPage writes statusCode and the given error page to rw.
func writeErrorPage(rw http.ResponseWriter, statusCode int, p CustomErrorPage) {
for k, v := range p.Headers {
rw.Header().Set(k, v)
}
rw.Header().Set("server", "frp/"+version.Full())
rw.Header().Set("Content-Type", p.contentType())
rw.WriteHeader(statusCode)
_, _ = rw.Write([]byte(p.Body))
}

// writeCustomErrorPage writes the custom error page registered for statusCode
// to rw. It reports whether a custom page was written.
func writeCustomErrorPage(rw http.ResponseWriter, statusCode int) bool {
p, ok := customErrorPages[statusCode]
if !ok {
return false
}
writeErrorPage(rw, statusCode, p)
return true
}

func NotFoundResponse() *http.Response {
p := resolveNotFoundPage()

header := make(http.Header)
for k, v := range p.Headers {
header.Set(k, v)
}
header.Set("server", "frp/"+version.Full())
header.Set("Content-Type", "text/html")
header.Set("Content-Type", p.contentType())

content := getNotFoundPageContent()
content := []byte(p.Body)
res := &http.Response{
Status: "Not Found",
StatusCode: 404,
Expand Down
106 changes: 106 additions & 0 deletions pkg/util/vhost/resource_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package vhost

import (
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/require"
)

func resetErrorPageState(t *testing.T) {
t.Helper()
oldPath, oldPages := NotFoundPagePath, customErrorPages
NotFoundPagePath = ""
customErrorPages = make(map[int]CustomErrorPage)
t.Cleanup(func() {
NotFoundPagePath = oldPath
customErrorPages = oldPages
})
}

func TestResolveNotFoundPage(t *testing.T) {
t.Run("default page", func(t *testing.T) {
resetErrorPageState(t)
p := resolveNotFoundPage()
require.Equal(t, NotFound, p.Body)
require.Equal(t, "text/html", p.contentType())
})

t.Run("custom error page for 404", func(t *testing.T) {
resetErrorPageState(t)
customErrorPages[http.StatusNotFound] = CustomErrorPage{
ContentType: "text/plain",
Body: "custom not found",
Headers: map[string]string{"X-Custom": "yes"},
}
p := resolveNotFoundPage()
require.Equal(t, "custom not found", p.Body)
require.Equal(t, "text/plain", p.contentType())
require.Equal(t, "yes", p.Headers["X-Custom"])
})

t.Run("custom404Page file takes precedence", func(t *testing.T) {
resetErrorPageState(t)
customErrorPages[http.StatusNotFound] = CustomErrorPage{Body: "inline"}
file := filepath.Join(t.TempDir(), "404.html")
require.NoError(t, os.WriteFile(file, []byte("from file"), 0o644))
NotFoundPagePath = file

p := resolveNotFoundPage()
require.Equal(t, "from file", p.Body)
})

t.Run("missing custom404Page file falls back to inline page", func(t *testing.T) {
resetErrorPageState(t)
customErrorPages[http.StatusNotFound] = CustomErrorPage{Body: "inline"}
NotFoundPagePath = filepath.Join(t.TempDir(), "missing.html")

p := resolveNotFoundPage()
require.Equal(t, "inline", p.Body)
})
}

func TestWriteCustomErrorPage(t *testing.T) {
t.Run("no custom page", func(t *testing.T) {
resetErrorPageState(t)
rw := httptest.NewRecorder()
require.False(t, writeCustomErrorPage(rw, http.StatusGatewayTimeout))
require.Equal(t, http.StatusOK, rw.Code)
})

t.Run("writes custom page", func(t *testing.T) {
resetErrorPageState(t)
customErrorPages[http.StatusGatewayTimeout] = CustomErrorPage{
Body: "timeout page",
ContentType: "text/plain",
Headers: map[string]string{"Retry-After": "30"},
}
rw := httptest.NewRecorder()
require.True(t, writeCustomErrorPage(rw, http.StatusGatewayTimeout))
require.Equal(t, http.StatusGatewayTimeout, rw.Code)
require.Equal(t, "text/plain", rw.Header().Get("Content-Type"))
require.Equal(t, "30", rw.Header().Get("Retry-After"))
require.Equal(t, "timeout page", rw.Body.String())
})
}

func TestNotFoundResponseCustomPage(t *testing.T) {
resetErrorPageState(t)
customErrorPages[http.StatusNotFound] = CustomErrorPage{
Body: "inline not found",
ContentType: "application/json",
}

res := NotFoundResponse()
defer res.Body.Close()
require.Equal(t, http.StatusNotFound, res.StatusCode)
require.Equal(t, "application/json", res.Header.Get("Content-Type"))
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.Equal(t, "inline not found", string(body))
require.Equal(t, int64(len("inline not found")), res.ContentLength)
}
10 changes: 10 additions & 0 deletions server/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,16 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) {
// Init 404 not found page
vhost.NotFoundPagePath = cfg.Custom404Page

customErrorPages := make(map[int]vhost.CustomErrorPage, len(cfg.CustomErrorPages))
for _, p := range cfg.CustomErrorPages {
customErrorPages[p.StatusCode] = vhost.CustomErrorPage{
ContentType: p.ContentType,
Body: p.Body,
Headers: p.Headers,
}
}
vhost.SetCustomErrorPages(customErrorPages)

var (
httpMuxOn bool
httpsMuxOn bool
Expand Down