diff --git a/conf/frps_full_example.toml b/conf/frps_full_example.toml index aba37435..606dd8f0 100644 --- a/conf/frps_full_example.toml +++ b/conf/frps_full_example.toml @@ -167,3 +167,60 @@ name = "port-manager" addr = "127.0.0.1:9001" path = "/handler" ops = ["NewProxy"] + +# ======================================================================== +# Custom response configuration (for HTTP vhost requests) +# ======================================================================== +# This section allows FRPS to return a custom response when a client sends +# an HTTP request with a Host header that is not registered by any FRPC. +# +# - Set enable = true to turn this feature on. +# - Rules are matched in order; the first match wins. +# - Supported hostname patterns: +# * Exact domain: "example.com" +# * Wildcard: "*.example.com" (matches foo.example.com, bar.example.com, but NOT example.com itself) +# * Catch-all: "*" (matches any host) +# - Each rule can define: +# * statusCode : HTTP status code (e.g. 404, 503) +# * contentType : MIME type of the response (e.g. text/html, application/json) +# * body : Response body (string, multi-line supported with """ ... """) +# * headers : Extra headers (map of key:value) +# +# If no rule matches or enable = false, FRPS falls back to the default 404. +# ======================================================================== + +[customResponse] +# Enable or disable the custom response feature. Default = false (disabled). +enable = false + +# ------------------------------------------------------------------------ +# Rule 1: Return a 503 HTML page for *.example.com and example.com +# ------------------------------------------------------------------------ +[[customResponse.rules]] +hostname = ["*.example.com", "example.com"] +statusCode = 503 +contentType = "text/html" +body = """ + + + +

Service Unavailable

+

The server is currently unavailable.
+Please try again later.

+ + +""" +[customResponse.rules.headers] +Cache-Control = "no-store" +X-Error-Code = "UNREGISTERED_HOST" + +# ------------------------------------------------------------------------ +# Rule 2: Return a 404 JSON payload for spesific.example2.com +# ------------------------------------------------------------------------ +[[customResponse.rules]] +hostname = ["spesific.example2.com"] +statusCode = 404 +contentType = "application/json" +body = "{\"error\":\"unregistered_host\",\"hint\":\"register frpc for this hostname\"}" +[customResponse.rules.headers] +Cache-Control = "no-store" diff --git a/pkg/config/v1/server.go b/pkg/config/v1/server.go index 54aac080..11e397e0 100644 --- a/pkg/config/v1/server.go +++ b/pkg/config/v1/server.go @@ -99,6 +99,8 @@ type ServerConfig struct { AllowPorts []types.PortsRange `json:"allowPorts,omitempty"` HTTPPlugins []HTTPPluginOptions `json:"httpPlugins,omitempty"` + + CustomResponse *CustomResponseConfig `json:"customResponse,omitempty" toml:"customResponse" yaml:"customResponse"` } func (c *ServerConfig) Complete() error { @@ -227,3 +229,16 @@ type SSHTunnelGateway struct { func (c *SSHTunnelGateway) Complete() { c.AutoGenPrivateKeyPath = util.EmptyOr(c.AutoGenPrivateKeyPath, "./.autogen_ssh_key") } + +type CustomResponseConfig struct { + Enable bool `toml:"enable" json:"enable" yaml:"enable"` + Rules []CustomResponseRule `toml:"rules" json:"rules" yaml:"rules"` +} + +type CustomResponseRule struct { + Hostname []string `toml:"hostname" json:"hostname" yaml:"hostname"` + StatusCode int `toml:"statusCode" json:"statusCode" yaml:"statusCode"` + ContentType string `toml:"contentType" json:"contentType" yaml:"contentType"` + Body string `toml:"body" json:"body" yaml:"body"` + Headers map[string]string `toml:"headers" json:"headers" yaml:"headers"` +} diff --git a/pkg/util/http/http.go b/pkg/util/http/http.go index b85a46a3..e4c2f5b6 100644 --- a/pkg/util/http/http.go +++ b/pkg/util/http/http.go @@ -100,3 +100,26 @@ func BasicAuth(username, passwd string) string { auth := username + ":" + passwd return "Basic " + base64.StdEncoding.EncodeToString([]byte(auth)) } + +// MatchDomain checks if the request host matches the config host. +// The config host can be a wildcard domain like "*.example.com". +func MatchDomain(requestHost, configHost string) bool { + if len(requestHost) == 0 || len(configHost) == 0 { + return false + } + if configHost == "*" { + return true + } + if requestHost == configHost { + return true + } + if strings.HasPrefix(configHost, "*.") { + // The config host is a wildcard domain like "*.example.com". + // We need to check if the request host ends with the config host. + suffix := configHost[1:] // Remove '*' + if strings.HasSuffix(requestHost, suffix) && len(requestHost) > len(suffix) { + return true + } + } + return false +} diff --git a/pkg/util/vhost/http.go b/pkg/util/vhost/http.go index 05ec174b..de26ca3f 100644 --- a/pkg/util/vhost/http.go +++ b/pkg/util/vhost/http.go @@ -40,6 +40,7 @@ var ErrNoRouteFound = errors.New("no route found") type HTTPReverseProxyOptions struct { ResponseHeaderTimeoutS int64 + CustomErrorPage *CustomErrorPage } type HTTPReverseProxy struct { @@ -136,6 +137,10 @@ func NewHTTPReverseProxy(option HTTPReverseProxyOptions, vhostRouter *Routers) * return } } + customResponseStatus := CustomErrorResponse(rw, req, option.CustomErrorPage) + if customResponseStatus { + return + } rw.WriteHeader(http.StatusNotFound) _, _ = rw.Write(getNotFoundPageContent()) }, diff --git a/pkg/util/vhost/resource.go b/pkg/util/vhost/resource.go index a65e2997..5c55777a 100644 --- a/pkg/util/vhost/resource.go +++ b/pkg/util/vhost/resource.go @@ -20,11 +20,26 @@ import ( "net/http" "os" + httppkg "github.com/fatedier/frp/pkg/util/http" "github.com/fatedier/frp/pkg/util/log" "github.com/fatedier/frp/pkg/util/version" ) +type CustomErrorPage struct { + Enable bool + Rules []CustomResponseRule +} + +type CustomResponseRule struct { + Hostname []string + StatusCode int + ContentType string + Body string + Headers map[string]string +} + var NotFoundPagePath = "" +var ServiceUnavailablePagePath = "" const ( NotFound = ` @@ -47,6 +62,27 @@ Please try again later.

Faithfully yours, frp.

+` + ServerUnavailable = ` + + +Service Unavailable + + + +

Hostname not found.

+

Sorry, the page you are looking for is currently unavailable.
+Please try again later.

+

The server is powered by frp.

+

Faithfully yours, frp.

+ + ` ) @@ -85,3 +121,81 @@ func NotFoundResponse() *http.Response { } return res } + +func getServerUnavailablePageContent() []byte { + var ( + buf []byte + err error + ) + if ServiceUnavailablePagePath != "" { + buf, err = os.ReadFile(ServiceUnavailablePagePath) + if err != nil { + log.Warnf("read custom 404 page error: %v", err) + buf = []byte(ServerUnavailable) + } + } else { + buf = []byte(ServerUnavailable) + } + return buf +} +func ServerUnavailableResponse() *http.Response { + header := make(http.Header) + header.Set("server", "frp/"+version.Full()) + header.Set("Content-Type", "text/html") + header.Set("Frp-Custom-Error", "true") + + content := []byte("Service Unavailable") + res := &http.Response{ + Status: "Service Unavailable", + StatusCode: 503, + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Header: header, + Body: io.NopCloser(bytes.NewReader(content)), + ContentLength: int64(len(content)), + } + return res +} +func CustomErrorResponse(rw http.ResponseWriter, r *http.Request, option *CustomErrorPage) bool { + if option == nil || !option.Enable { + return false + } + host, err := httppkg.CanonicalHost(r.Host) + if err != nil { + return false + } + for _, rule := range option.Rules { + for _, pat := range rule.Hostname { + if pat == "" { + continue + } + if httppkg.MatchDomain(host, pat) { + for k, v := range rule.Headers { + rw.Header().Set(k, v) + } + if rule.ContentType != "" { + rw.Header().Set("Content-Type", rule.ContentType) + } else { + rw.Header().Set("Content-Type", "text/html") + } + if rw.Header().Get("Cache-Control") == "" { + rw.Header().Set("Cache-Control", "no-store") + } + code := rule.StatusCode + if code == 0 { + code = http.StatusNotFound + } + rw.WriteHeader(code) + if rule.Body != "" { + _, _ = rw.Write([]byte(rule.Body)) + } else { + _, _ = rw.Write(getServerUnavailablePageContent()) + } + return true + } + } + } + + return false // fallback ke 404 default +} diff --git a/server/service.go b/server/service.go index 7ca80dc8..92f48eb2 100644 --- a/server/service.go +++ b/server/service.go @@ -278,8 +278,28 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) { // Create http vhost muxer. if cfg.VhostHTTPPort > 0 { + var customErrorPage *vhost.CustomErrorPage + if cfg.CustomResponse != nil && cfg.CustomResponse.Enable { + rules := make([]vhost.CustomResponseRule, 0, len(cfg.CustomResponse.Rules)) + for _, r := range cfg.CustomResponse.Rules { + rules = append(rules, vhost.CustomResponseRule{ + Hostname: r.Hostname, + StatusCode: r.StatusCode, + ContentType: r.ContentType, + Body: r.Body, + Headers: r.Headers, + }) + } + customErrorPage = &vhost.CustomErrorPage{ + Enable: cfg.CustomResponse.Enable, + Rules: rules, + } + log.Debugf("custom response rules loaded: %v", rules) + log.Infof("custom response is enabled") + } rp := vhost.NewHTTPReverseProxy(vhost.HTTPReverseProxyOptions{ ResponseHeaderTimeoutS: cfg.VhostHTTPTimeout, + CustomErrorPage: customErrorPage, }, svr.httpVhostRouter) svr.rc.HTTPReverseProxy = rp