pull/4973/merge
Muhammad Nasrul 2025-09-08 10:46:53 +00:00 committed by GitHub
commit 7c73f0e7b4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 234 additions and 0 deletions

View File

@ -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 = """
<!doctype html>
<html>
<body>
<h1>Service Unavailable</h1>
<p>The server is currently unavailable.<br/>
Please try again later.</p>
</body>
</html>
"""
[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"

View File

@ -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"`
}

View File

@ -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
}

View File

@ -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())
},

View File

@ -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 = `<!DOCTYPE html>
@ -47,6 +62,27 @@ Please try again later.</p>
<p><em>Faithfully yours, frp.</em></p>
</body>
</html>
`
ServerUnavailable = `<!DOCTYPE html>
<html>
<head>
<title>Service Unavailable</title>
<style>
body {
width: 35em;
margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif;
}
</style>
</head>
<body>
<h1>Hostname not found.</h1>
<p>Sorry, the page you are looking for is currently unavailable.<br/>
Please try again later.</p>
<p>The server is powered by <a href="https://github.com/fatedier/frp">frp</a>.</p>
<p><em>Faithfully yours, frp.</em></p>
</body>
</html>
`
)
@ -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
}

View File

@ -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