mirror of https://github.com/fatedier/frp
Merge b50472340d
into 0a798a7a69
commit
7c73f0e7b4
|
@ -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"
|
||||
|
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
},
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
Loading…
Reference in New Issue