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.