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"
|
addr = "127.0.0.1:9001"
|
||||||
path = "/handler"
|
path = "/handler"
|
||||||
ops = ["NewProxy"]
|
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"`
|
AllowPorts []types.PortsRange `json:"allowPorts,omitempty"`
|
||||||
|
|
||||||
HTTPPlugins []HTTPPluginOptions `json:"httpPlugins,omitempty"`
|
HTTPPlugins []HTTPPluginOptions `json:"httpPlugins,omitempty"`
|
||||||
|
|
||||||
|
CustomResponse *CustomResponseConfig `json:"customResponse,omitempty" toml:"customResponse" yaml:"customResponse"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ServerConfig) Complete() error {
|
func (c *ServerConfig) Complete() error {
|
||||||
|
@ -227,3 +229,16 @@ type SSHTunnelGateway struct {
|
||||||
func (c *SSHTunnelGateway) Complete() {
|
func (c *SSHTunnelGateway) Complete() {
|
||||||
c.AutoGenPrivateKeyPath = util.EmptyOr(c.AutoGenPrivateKeyPath, "./.autogen_ssh_key")
|
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
|
auth := username + ":" + passwd
|
||||||
return "Basic " + base64.StdEncoding.EncodeToString([]byte(auth))
|
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 {
|
type HTTPReverseProxyOptions struct {
|
||||||
ResponseHeaderTimeoutS int64
|
ResponseHeaderTimeoutS int64
|
||||||
|
CustomErrorPage *CustomErrorPage
|
||||||
}
|
}
|
||||||
|
|
||||||
type HTTPReverseProxy struct {
|
type HTTPReverseProxy struct {
|
||||||
|
@ -136,6 +137,10 @@ func NewHTTPReverseProxy(option HTTPReverseProxyOptions, vhostRouter *Routers) *
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
customResponseStatus := CustomErrorResponse(rw, req, option.CustomErrorPage)
|
||||||
|
if customResponseStatus {
|
||||||
|
return
|
||||||
|
}
|
||||||
rw.WriteHeader(http.StatusNotFound)
|
rw.WriteHeader(http.StatusNotFound)
|
||||||
_, _ = rw.Write(getNotFoundPageContent())
|
_, _ = rw.Write(getNotFoundPageContent())
|
||||||
},
|
},
|
||||||
|
|
|
@ -20,11 +20,26 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
httppkg "github.com/fatedier/frp/pkg/util/http"
|
||||||
"github.com/fatedier/frp/pkg/util/log"
|
"github.com/fatedier/frp/pkg/util/log"
|
||||||
"github.com/fatedier/frp/pkg/util/version"
|
"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 NotFoundPagePath = ""
|
||||||
|
var ServiceUnavailablePagePath = ""
|
||||||
|
|
||||||
const (
|
const (
|
||||||
NotFound = `<!DOCTYPE html>
|
NotFound = `<!DOCTYPE html>
|
||||||
|
@ -47,6 +62,27 @@ Please try again later.</p>
|
||||||
<p><em>Faithfully yours, frp.</em></p>
|
<p><em>Faithfully yours, frp.</em></p>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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
|
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.
|
// Create http vhost muxer.
|
||||||
if cfg.VhostHTTPPort > 0 {
|
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{
|
rp := vhost.NewHTTPReverseProxy(vhost.HTTPReverseProxyOptions{
|
||||||
ResponseHeaderTimeoutS: cfg.VhostHTTPTimeout,
|
ResponseHeaderTimeoutS: cfg.VhostHTTPTimeout,
|
||||||
|
CustomErrorPage: customErrorPage,
|
||||||
}, svr.httpVhostRouter)
|
}, svr.httpVhostRouter)
|
||||||
svr.rc.HTTPReverseProxy = rp
|
svr.rc.HTTPReverseProxy = rp
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue