diff --git a/cmd/main.go b/cmd/main.go index 64e10a2..394b469 100755 --- a/cmd/main.go +++ b/cmd/main.go @@ -137,23 +137,21 @@ func main() { HTTPAddr: common.ProxyHTTPAddr, HTTPSAddr: common.ProxyHTTPSAddr, Handler: http.HandlerFunc(entrypoint.Handler), - RedirectToHTTPS: config.Value().RedirectToHTTPS, + RedirectToHTTPS: config.Value().Entrypoint.RedirectToHTTPS, }) server.StartServer(server.Options{ - Name: "api", - CertProvider: autocert, - HTTPAddr: common.APIHTTPAddr, - Handler: api.NewHandler(), - RedirectToHTTPS: config.Value().RedirectToHTTPS, + Name: "api", + CertProvider: autocert, + HTTPAddr: common.APIHTTPAddr, + Handler: api.NewHandler(), }) if common.PrometheusEnabled { server.StartServer(server.Options{ - Name: "metrics", - CertProvider: autocert, - HTTPAddr: common.MetricsHTTPAddr, - Handler: metrics.NewHandler(), - RedirectToHTTPS: config.Value().RedirectToHTTPS, + Name: "metrics", + CertProvider: autocert, + HTTPAddr: common.MetricsHTTPAddr, + Handler: metrics.NewHandler(), }) } diff --git a/config.example.yml b/config.example.yml index 5a5937e..a9bd66b 100644 --- a/config.example.yml +++ b/config.example.yml @@ -20,6 +20,20 @@ # # 3. other providers, check docs/dns_providers.md for more +entrypoint: + # global setting redirect http requests to https (if https available, otherwise this will be ignored) + # proxy..middlewares.redirect_http will override this + # + redirect_to_https: false + middlewares: + - use: CIDRWhitelist + allow: + - "127.0.0.1" + - "10.0.0.0/8" + - "192.168.0.0/16" + status: 403 + message: "Forbidden" + providers: # include files are standalone yaml files under `config/` directory # @@ -41,6 +55,28 @@ providers: # # remote-1: tcp://10.0.2.1:2375 # remote-2: ssh://root:1234@10.0.2.2 + + # notification providers (notify when service health changes) + # + # notification: + # - name: gotify + # provider: gotify + # url: https://gotify.domain.tld + # token: abcd + # - name: discord + # provider: webhook + # url: https://discord.com/api/webhooks/... + # template: discord + # # payload: | # discord template implies the following + # # { + # # "embeds": [ + # # { + # # "title": $title, + # # "fields": $fields, + # # "color": "$color" + # # } + # # ] + # # } # if match_domains not defined # any host = alias+[any domain] will match # i.e. https://app1.y.z will match alias app1 for any domain y.z @@ -68,8 +104,3 @@ homepage: # timeout for shutdown (in seconds) # timeout_shutdown: 5 - -# global setting redirect http requests to https (if https available, otherwise this will be ignored) -# proxy..middlewares.redirect_http will override this -# -redirect_to_https: false diff --git a/internal/config/config.go b/internal/config/config.go index 2173430..35874e0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -173,6 +173,7 @@ func (cfg *Config) load() E.Error { // errors are non fatal below errs := E.NewBuilder(errMsg) + errs.Add(entrypoint.SetMiddlewares(model.Entrypoint.Middlewares)) errs.Add(cfg.initNotification(model.Providers.Notification)) errs.Add(cfg.initAutoCert(&model.AutoCert)) errs.Add(cfg.loadRouteProviders(&model.Providers)) diff --git a/internal/config/types/config.go b/internal/config/types/config.go index 3a40687..3c59e53 100644 --- a/internal/config/types/config.go +++ b/internal/config/types/config.go @@ -2,19 +2,22 @@ package types type ( Config struct { - Providers Providers `json:"providers" yaml:",flow"` AutoCert AutoCertConfig `json:"autocert" yaml:",flow"` - ExplicitOnly bool `json:"explicit_only" yaml:"explicit_only"` + Entrypoint Entrypoint `json:"entrypoint" yaml:",flow"` + Providers Providers `json:"providers" yaml:",flow"` MatchDomains []string `json:"match_domains" yaml:"match_domains"` Homepage HomepageConfig `json:"homepage" yaml:"homepage"` TimeoutShutdown int `json:"timeout_shutdown" yaml:"timeout_shutdown"` - RedirectToHTTPS bool `json:"redirect_to_https" yaml:"redirect_to_https"` } Providers struct { Files []string `json:"include" yaml:"include"` Docker map[string]string `json:"docker" yaml:"docker"` Notification []NotificationConfig `json:"notification" yaml:"notification"` } + Entrypoint struct { + RedirectToHTTPS bool `json:"redirect_to_https" yaml:"redirect_to_https"` + Middlewares []map[string]any + } NotificationConfig map[string]any ) @@ -24,6 +27,8 @@ func DefaultConfig() *Config { Homepage: HomepageConfig{ UseDefaultCategories: true, }, - RedirectToHTTPS: false, + Entrypoint: Entrypoint{ + RedirectToHTTPS: false, + }, } } diff --git a/internal/entrypoint/entrypoint.go b/internal/entrypoint/entrypoint.go index 7aa5223..fbd703f 100644 --- a/internal/entrypoint/entrypoint.go +++ b/internal/entrypoint/entrypoint.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "strings" + "sync" "github.com/yusing/go-proxy/internal/net/http/middleware" "github.com/yusing/go-proxy/internal/net/http/middleware/errorpage" @@ -14,6 +15,11 @@ import ( var findRouteFunc = findRouteAnyDomain +var ( + epMiddleware *middleware.Middleware + epMiddlewareMu sync.Mutex +) + func SetFindRouteDomains(domains []string) { if len(domains) == 0 { findRouteFunc = findRouteAnyDomain @@ -22,9 +28,25 @@ func SetFindRouteDomains(domains []string) { } } +func SetMiddlewares(mws []map[string]any) error { + epMiddlewareMu.Lock() + defer epMiddlewareMu.Unlock() + + mid, err := middleware.BuildMiddlewareFromChainRaw("entrypoint", mws) + if err != nil { + return err + } + epMiddleware = mid + return nil +} + func Handler(w http.ResponseWriter, r *http.Request) { mux, err := findRouteFunc(r.Host) if err == nil { + if epMiddleware != nil { + epMiddleware.ServeHTTP(mux.ServeHTTP, w, r) + return + } mux.ServeHTTP(w, r) return } diff --git a/internal/net/http/middleware/middleware.go b/internal/net/http/middleware/middleware.go index 9a1ad4a..72899bb 100644 --- a/internal/net/http/middleware/middleware.go +++ b/internal/net/http/middleware/middleware.go @@ -23,7 +23,7 @@ type ( BeforeFunc func(next http.HandlerFunc, w ResponseWriter, r *Request) RewriteFunc func(req *Request) - ModifyResponseFunc func(resp *Response) error + ModifyResponseFunc = gphttp.ModifyResponseFunc CloneWithOptFunc func(opts OptionsRaw) (*Middleware, E.Error) OptionsRaw = map[string]any @@ -114,6 +114,17 @@ func (m *Middleware) ModifyResponse(resp *Response) error { return nil } +func (m *Middleware) ServeHTTP(next http.HandlerFunc, w ResponseWriter, r *Request) { + if m.modifyResponse != nil { + w = gphttp.NewModifyResponseWriter(w, r, m.modifyResponse) + } + if m.before != nil { + m.before(next, w, r) + } else { + next(w, r) + } +} + // TODO: check conflict or duplicates. func createMiddlewares(middlewaresMap map[string]OptionsRaw) ([]*Middleware, E.Error) { middlewares := make([]*Middleware, 0, len(middlewaresMap)) diff --git a/internal/net/http/middleware/middleware_builder.go b/internal/net/http/middleware/middleware_builder.go index 328cc64..eec7c9e 100644 --- a/internal/net/http/middleware/middleware_builder.go +++ b/internal/net/http/middleware/middleware_builder.go @@ -11,13 +11,20 @@ import ( "gopkg.in/yaml.v3" ) +var ErrMissingMiddlewareUse = E.New("missing middleware 'use' field") + func BuildMiddlewaresFromComposeFile(filePath string, eb *E.Builder) map[string]*Middleware { fileContent, err := os.ReadFile(filePath) if err != nil { eb.Add(err) return nil } - return BuildMiddlewaresFromYAML(path.Base(filePath), fileContent, eb) + mids := BuildMiddlewaresFromYAML(path.Base(filePath), fileContent, eb) + results := make(map[string]*Middleware, len(mids)) + for k, v := range mids { + results[k+"@file"] = v + } + return results } func BuildMiddlewaresFromYAML(source string, data []byte, eb *E.Builder) map[string]*Middleware { @@ -29,37 +36,46 @@ func BuildMiddlewaresFromYAML(source string, data []byte, eb *E.Builder) map[str } middlewares := make(map[string]*Middleware) for name, defs := range rawMap { - chainErr := E.NewBuilder("") - chain := make([]*Middleware, 0, len(defs)) - for i, def := range defs { - if def["use"] == nil || def["use"] == "" { - chainErr.Addf("item %d: missing field 'use'", i) - continue - } - baseName := def["use"].(string) - base, err := Get(baseName) - if err != nil { - chainErr.Add(err.Subjectf("%s[%d]", name, i)) - continue - } - delete(def, "use") - m, err := base.WithOptionsClone(def) - if err != nil { - chainErr.Add(err.Subjectf("%s[%d]", name, i)) - continue - } - m.name = fmt.Sprintf("%s[%d]", name, i) - chain = append(chain, m) - } - if chainErr.HasError() { - eb.Add(chainErr.Error().Subject(source)) + chain, err := BuildMiddlewareFromChainRaw(name, defs) + if err != nil { + eb.Add(err.Subject(source)) } else { - middlewares[name+"@file"] = BuildMiddlewareFromChain(name, chain) + middlewares[name] = chain } } return middlewares } +func BuildMiddlewareFromChainRaw(name string, defs []map[string]any) (*Middleware, E.Error) { + chainErr := E.NewBuilder("") + chain := make([]*Middleware, 0, len(defs)) + for i, def := range defs { + if def["use"] == nil || def["use"] == "" { + chainErr.Add(ErrMissingMiddlewareUse.Subjectf("%s[%d]", name, i)) + continue + } + baseName := def["use"].(string) + base, err := Get(baseName) + if err != nil { + chainErr.Add(err.Subjectf("%s[%d]", name, i)) + continue + } + delete(def, "use") + m, err := base.WithOptionsClone(def) + if err != nil { + chainErr.Add(err.Subjectf("%s[%d]", name, i)) + continue + } + m.name = fmt.Sprintf("%s[%d]", name, i) + chain = append(chain, m) + } + if chainErr.HasError() { + return nil, chainErr.Error() + } else { + return BuildMiddlewareFromChain(name, chain), nil + } +} + // TODO: check conflict or duplicates. func BuildMiddlewareFromChain(name string, chain []*Middleware) *Middleware { m := &Middleware{name: name, children: chain} diff --git a/internal/net/http/server/server.go b/internal/net/http/server/server.go index cd563c8..650d99e 100644 --- a/internal/net/http/server/server.go +++ b/internal/net/http/server/server.go @@ -6,6 +6,7 @@ import ( "errors" "io" "log" + "net" "net/http" "time" @@ -57,7 +58,11 @@ func NewServer(opt Options) (s *Server) { } if certAvailable && opt.RedirectToHTTPS && opt.HTTPSAddr != "" { - httpHandler = redirectToTLSHandler(opt.HTTPSAddr) + _, port, err := net.SplitHostPort(opt.HTTPSAddr) + if err != nil { + panic(err) + } + httpHandler = redirectToTLSHandler(port) } else { httpHandler = opt.Handler } @@ -151,7 +156,7 @@ func (s *Server) handleErr(scheme string, err error) { func redirectToTLSHandler(port string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { r.URL.Scheme = "https" - r.URL.Host = r.URL.Hostname() + port + r.URL.Host = r.URL.Hostname() + ":" + port var redirectCode int if r.Method == http.MethodGet { diff --git a/schema/config.schema.json b/schema/config.schema.json index 90b9001..9364dff 100644 --- a/schema/config.schema.json +++ b/schema/config.schema.json @@ -303,28 +303,105 @@ }, "notification": { "description": "Notification provider configuration", - "type": "object", - "additionalProperties": false, - "properties": { - "gotify": { - "description": "Gotify configuration", - "type": "object", - "additionalProperties": false, - "properties": { - "url": { - "description": "Gotify URL", - "type": "string" - }, - "token": { - "description": "Gotify token", - "type": "string" - } + "type": "array", + "items": { + "type": "object", + "required": [ + "name", + "provider" + ], + "properties": { + "name": { + "type": "string", + "description": "Notifier name" }, - "required": [ - "url", - "token" - ] - } + "provider": { + "description": "Notifier provider", + "type": "string", + "enum": [ + "gotify", + "webhook" + ] + } + }, + "oneOf": [ + { + "description": "Gotify configuration", + "additionalProperties": false, + "properties": { + "name": {}, + "provider": { + "const": "gotify" + }, + "url": { + "description": "Gotify URL", + "type": "string" + }, + "token": { + "description": "Gotify token", + "type": "string" + } + }, + "required": [ + "url", + "token" + ] + }, + { + "description": "Webhook configuration", + "additionalProperties": false, + "properties": { + "name": {}, + "provider": { + "const": "webhook" + }, + "url": { + "description": "Webhook URL", + "type": "string" + }, + "token": { + "description": "Webhook bearer token", + "type": "string" + }, + "template": { + "description": "Webhook template", + "type": "string", + "enum": [ + "discord" + ] + }, + "payload": { + "description": "Webhook payload", + "type": "string", + "format": "json" + }, + "method": { + "description": "Webhook request method", + "type": "string", + "enum": [ + "GET", + "POST", + "PUT" + ] + }, + "mime_type": { + "description": "Webhook NIME type", + "type": "string" + }, + "color_mode": { + "description": "Webhook color mode", + "type": "string", + "enum": [ + "hex", + "dec" + ] + } + }, + "required": [ + "url" + ] + } + ] } } } @@ -337,14 +414,48 @@ }, "minItems": 1 }, + "homepage": { + "title": "Homepage configuration", + "type": "object", + "additionalProperties": false, + "properties": { + "use_default_categories": { + "title": "Use default categories", + "type": "boolean" + } + } + }, + "entrypoint": { + "title": "Entrypoint configuration", + "type": "object", + "additionalProperties": false, + "properties": { + "redirect_to_https": { + "title": "Redirect to HTTPS on HTTP requests", + "type": "boolean" + }, + "middlewares": { + "title": "Entrypoint middlewares", + "type": "array", + "items": { + "type": "object", + "required": [ + "use" + ], + "properties": { + "use": { + "type": "string", + "description": "Middleware to use" + } + } + } + } + } + }, "timeout_shutdown": { "title": "Shutdown timeout (in seconds)", "type": "integer", "minimum": 0 - }, - "redirect_to_https": { - "title": "Redirect to HTTPS on HTTP requests", - "type": "boolean" } }, "additionalProperties": false,