[ADD] feature vHost customResponse

pull/4973/head
Muhammad Nasrul 2025-09-08 15:48:59 +07:00
parent 0a798a7a69
commit d63f7fd0e4
5 changed files with 177 additions and 0 deletions

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.Infof("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