diff --git a/cmd/main.go b/cmd/main.go index f5d6cb98..3494bdc3 100755 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,12 +1,12 @@ package main import ( + "errors" "os" "sync" "time" "github.com/rs/zerolog/log" - "github.com/yusing/godoxy/internal/api" "github.com/yusing/godoxy/internal/auth" "github.com/yusing/godoxy/internal/common" "github.com/yusing/godoxy/internal/config" @@ -14,12 +14,9 @@ import ( iconlist "github.com/yusing/godoxy/internal/homepage/icons/list" "github.com/yusing/godoxy/internal/logging" "github.com/yusing/godoxy/internal/logging/memlogger" - "github.com/yusing/godoxy/internal/metrics/systeminfo" - "github.com/yusing/godoxy/internal/metrics/uptime" "github.com/yusing/godoxy/internal/net/gphttp/middleware" "github.com/yusing/godoxy/internal/route/rules" gperr "github.com/yusing/goutils/errs" - "github.com/yusing/goutils/server" "github.com/yusing/goutils/task" "github.com/yusing/goutils/version" ) @@ -51,7 +48,6 @@ func main() { parallel( dnsproviders.InitProviders, iconlist.InitCache, - systeminfo.Poller.Start, middleware.LoadComposeFiles, ) @@ -73,32 +69,13 @@ func main() { gperr.LogWarn("errors in config", err) } - config.StartProxyServers() - if err := auth.Initialize(); err != nil { log.Fatal().Err(err).Msg("failed to initialize authentication") } rules.InitAuthHandler(auth.AuthOrProceed) - // API Handler needs to start after auth is initialized. - server.StartServer(task.RootTask("api_server", false), server.Options{ - Name: "api", - HTTPAddr: common.APIHTTPAddr, - Handler: api.NewHandler(true), - }) - - // Local API Handler is used for unauthenticated access. - if common.LocalAPIHTTPAddr != "" { - server.StartServer(task.RootTask("local_api_server", false), server.Options{ - Name: "local_api", - HTTPAddr: common.LocalAPIHTTPAddr, - Handler: api.NewHandler(false), - }) - } - listenDebugServer() - uptime.Poller.Start() config.WatchChanges() close(done) diff --git a/goutils b/goutils index 52ea531e..a270ef85 160000 --- a/goutils +++ b/goutils @@ -1 +1 @@ -Subproject commit 52ea531e95bef1b21c4c832f36facb3e509d1191 +Subproject commit a270ef85af4ad120e10a8fef210b88dc13bee510 diff --git a/internal/acl/config.go b/internal/acl/config.go index b608a621..bca4fc8a 100644 --- a/internal/acl/config.go +++ b/internal/acl/config.go @@ -74,8 +74,6 @@ type ipLog struct { allowed bool } -type ContextKey struct{} - const cacheTTL = 1 * time.Minute func (c *checkCache) Expired() bool { diff --git a/internal/acl/types/acl.go b/internal/acl/types/acl.go new file mode 100644 index 00000000..414821ec --- /dev/null +++ b/internal/acl/types/acl.go @@ -0,0 +1,9 @@ +package acl + +import "net" + +type ACL interface { + IPAllowed(ip net.IP) bool + WrapTCP(l net.Listener) net.Listener + WrapUDP(l net.PacketConn) net.PacketConn +} diff --git a/internal/acl/types/context.go b/internal/acl/types/context.go new file mode 100644 index 00000000..fe65b807 --- /dev/null +++ b/internal/acl/types/context.go @@ -0,0 +1,16 @@ +package acl + +import "context" + +type ContextKey struct{} + +func SetCtx(ctx interface{ SetValue(any, any) }, acl ACL) { + ctx.SetValue(ContextKey{}, acl) +} + +func FromCtx(ctx context.Context) ACL { + if acl, ok := ctx.Value(ContextKey{}).(ACL); ok { + return acl + } + return nil +} diff --git a/internal/api/v1/docker/stats.go b/internal/api/v1/docker/stats.go index 612346ff..38f7c866 100644 --- a/internal/api/v1/docker/stats.go +++ b/internal/api/v1/docker/stats.go @@ -10,7 +10,7 @@ import ( "github.com/moby/moby/api/types/container" "github.com/moby/moby/client" "github.com/yusing/godoxy/internal/docker" - "github.com/yusing/godoxy/internal/route/routes" + entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" "github.com/yusing/godoxy/internal/types" apitypes "github.com/yusing/goutils/apitypes" "github.com/yusing/goutils/http/httpheaders" @@ -44,7 +44,7 @@ func Stats(c *gin.Context) { dockerCfg, ok := docker.GetDockerCfgByContainerID(id) if !ok { var route types.Route - route, ok = routes.GetIncludeExcluded(id) + route, ok = entrypoint.FromCtx(c.Request.Context()).GetRoute(id) if ok { cont := route.ContainerInfo() if cont == nil { diff --git a/internal/api/v1/favicon.go b/internal/api/v1/favicon.go index ea5b9dff..835a43c1 100644 --- a/internal/api/v1/favicon.go +++ b/internal/api/v1/favicon.go @@ -5,9 +5,9 @@ import ( "net/http" "github.com/gin-gonic/gin" + entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" "github.com/yusing/godoxy/internal/homepage/icons" iconfetch "github.com/yusing/godoxy/internal/homepage/icons/fetch" - "github.com/yusing/godoxy/internal/route/routes" apitypes "github.com/yusing/goutils/apitypes" _ "unsafe" @@ -73,7 +73,8 @@ func FavIcon(c *gin.Context) { //go:linkname GetFavIconFromAlias v1.GetFavIconFromAlias func GetFavIconFromAlias(ctx context.Context, alias string, variant icons.Variant) (iconfetch.Result, error) { // try with route.Icon - r, ok := routes.HTTP.Get(alias) + ep := entrypoint.FromCtx(ctx) + r, ok := ep.HTTPRoutes().Get(alias) if !ok { return iconfetch.FetchResultWithErrorf(http.StatusNotFound, "route not found") } diff --git a/internal/api/v1/health.go b/internal/api/v1/health.go index 6fc19bfa..7b5fc960 100644 --- a/internal/api/v1/health.go +++ b/internal/api/v1/health.go @@ -5,7 +5,7 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/yusing/godoxy/internal/route/routes" + entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" "github.com/yusing/goutils/http/httpheaders" "github.com/yusing/goutils/http/websocket" @@ -24,11 +24,12 @@ import ( // @Failure 500 {object} apitypes.ErrorResponse // @Router /health [get] func Health(c *gin.Context) { + ep := entrypoint.FromCtx(c.Request.Context()) if httpheaders.IsWebsocket(c.Request.Header) { websocket.PeriodicWrite(c, 1*time.Second, func() (any, error) { - return routes.GetHealthInfoSimple(), nil + return ep.GetHealthInfoSimple(), nil }) } else { - c.JSON(http.StatusOK, routes.GetHealthInfoSimple()) + c.JSON(http.StatusOK, ep.GetHealthInfoSimple()) } } diff --git a/internal/api/v1/homepage/categories.go b/internal/api/v1/homepage/categories.go index 1180ce2b..a361cfe8 100644 --- a/internal/api/v1/homepage/categories.go +++ b/internal/api/v1/homepage/categories.go @@ -4,8 +4,8 @@ import ( "net/http" "github.com/gin-gonic/gin" + entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" "github.com/yusing/godoxy/internal/homepage" - "github.com/yusing/godoxy/internal/route/routes" _ "github.com/yusing/goutils/apitypes" ) @@ -21,15 +21,16 @@ import ( // @Failure 403 {object} apitypes.ErrorResponse // @Router /homepage/categories [get] func Categories(c *gin.Context) { - c.JSON(http.StatusOK, HomepageCategories()) + ep := entrypoint.FromCtx(c.Request.Context()) + c.JSON(http.StatusOK, HomepageCategories(ep)) } -func HomepageCategories() []string { +func HomepageCategories(ep entrypoint.Entrypoint) []string { check := make(map[string]struct{}) categories := make([]string, 0) categories = append(categories, homepage.CategoryAll) categories = append(categories, homepage.CategoryFavorites) - for _, r := range routes.HTTP.Iter { + for _, r := range ep.HTTPRoutes().Iter { item := r.HomepageItem() if item.Category == "" { continue diff --git a/internal/api/v1/homepage/items.go b/internal/api/v1/homepage/items.go index ed0b1b2c..618dfc1f 100644 --- a/internal/api/v1/homepage/items.go +++ b/internal/api/v1/homepage/items.go @@ -10,8 +10,8 @@ import ( "github.com/gin-gonic/gin" "github.com/lithammer/fuzzysearch/fuzzy" + entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" "github.com/yusing/godoxy/internal/homepage" - "github.com/yusing/godoxy/internal/route/routes" apitypes "github.com/yusing/goutils/apitypes" "github.com/yusing/goutils/http/httpheaders" "github.com/yusing/goutils/http/websocket" @@ -53,29 +53,30 @@ func Items(c *gin.Context) { hostname = host } + ep := entrypoint.FromCtx(c.Request.Context()) if httpheaders.IsWebsocket(c.Request.Header) { websocket.PeriodicWrite(c, 2*time.Second, func() (any, error) { - return HomepageItems(proto, hostname, &request), nil + return HomepageItems(ep, proto, hostname, &request), nil }) } else { - c.JSON(http.StatusOK, HomepageItems(proto, hostname, &request)) + c.JSON(http.StatusOK, HomepageItems(ep, proto, hostname, &request)) } } -func HomepageItems(proto, hostname string, request *HomepageItemsRequest) homepage.Homepage { +func HomepageItems(ep entrypoint.Entrypoint, proto, hostname string, request *HomepageItemsRequest) homepage.Homepage { switch proto { case "http", "https": default: proto = "http" } - hp := homepage.NewHomepageMap(routes.HTTP.Size()) + hp := homepage.NewHomepageMap(ep.HTTPRoutes().Size()) if strings.Count(hostname, ".") > 1 { _, hostname, _ = strings.Cut(hostname, ".") // remove the subdomain } - for _, r := range routes.HTTP.Iter { + for _, r := range ep.HTTPRoutes().Iter { if request.Provider != "" && r.ProviderName() != request.Provider { continue } diff --git a/internal/api/v1/route/by_provider.go b/internal/api/v1/route/by_provider.go index 04fd8113..a31fe7fb 100644 --- a/internal/api/v1/route/by_provider.go +++ b/internal/api/v1/route/by_provider.go @@ -4,8 +4,8 @@ import ( "net/http" "github.com/gin-gonic/gin" + entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" "github.com/yusing/godoxy/internal/route" - "github.com/yusing/godoxy/internal/route/routes" _ "github.com/yusing/goutils/apitypes" ) @@ -24,5 +24,6 @@ type RoutesByProvider map[string][]route.Route // @Failure 500 {object} apitypes.ErrorResponse // @Router /route/by_provider [get] func ByProvider(c *gin.Context) { - c.JSON(http.StatusOK, routes.ByProvider()) + ep := entrypoint.FromCtx(c.Request.Context()) + c.JSON(http.StatusOK, ep.RoutesByProvider()) } diff --git a/internal/api/v1/route/route.go b/internal/api/v1/route/route.go index bd6ab1be..d732b0ec 100644 --- a/internal/api/v1/route/route.go +++ b/internal/api/v1/route/route.go @@ -4,7 +4,7 @@ import ( "net/http" "github.com/gin-gonic/gin" - "github.com/yusing/godoxy/internal/route/routes" + entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" apitypes "github.com/yusing/goutils/apitypes" ) @@ -32,7 +32,8 @@ func Route(c *gin.Context) { return } - route, ok := routes.GetIncludeExcluded(request.Which) + ep := entrypoint.FromCtx(c.Request.Context()) + route, ok := ep.GetRoute(request.Which) if ok { c.JSON(http.StatusOK, route) return diff --git a/internal/api/v1/route/routes.go b/internal/api/v1/route/routes.go index 8dcf2820..37b003df 100644 --- a/internal/api/v1/route/routes.go +++ b/internal/api/v1/route/routes.go @@ -6,8 +6,8 @@ import ( "time" "github.com/gin-gonic/gin" + entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" "github.com/yusing/godoxy/internal/route" - "github.com/yusing/godoxy/internal/route/routes" "github.com/yusing/godoxy/internal/types" "github.com/yusing/goutils/http/httpheaders" "github.com/yusing/goutils/http/websocket" @@ -32,14 +32,16 @@ func Routes(c *gin.Context) { return } + ep := entrypoint.FromCtx(c.Request.Context()) + provider := c.Query("provider") if provider == "" { - c.JSON(http.StatusOK, slices.Collect(routes.IterAll)) + c.JSON(http.StatusOK, slices.Collect(ep.IterRoutes)) return } - rts := make([]types.Route, 0, routes.NumAllRoutes()) - for r := range routes.IterAll { + rts := make([]types.Route, 0, ep.NumRoutes()) + for r := range ep.IterRoutes { if r.ProviderName() == provider { rts = append(rts, r) } @@ -48,17 +50,19 @@ func Routes(c *gin.Context) { } func RoutesWS(c *gin.Context) { + ep := entrypoint.FromCtx(c.Request.Context()) + provider := c.Query("provider") if provider == "" { websocket.PeriodicWrite(c, 3*time.Second, func() (any, error) { - return slices.Collect(routes.IterAll), nil + return slices.Collect(ep.IterRoutes), nil }) return } websocket.PeriodicWrite(c, 3*time.Second, func() (any, error) { - rts := make([]types.Route, 0, routes.NumAllRoutes()) - for r := range routes.IterAll { + rts := make([]types.Route, 0, ep.NumRoutes()) + for r := range ep.IterRoutes { if r.ProviderName() == provider { rts = append(rts, r) } diff --git a/internal/autocert/types/context.go b/internal/autocert/types/context.go new file mode 100644 index 00000000..6b4167bc --- /dev/null +++ b/internal/autocert/types/context.go @@ -0,0 +1,16 @@ +package autocert + +import "context" + +type ContextKey struct{} + +func SetCtx(ctx interface{ SetValue(any, any) }, p Provider) { + ctx.SetValue(ContextKey{}, p) +} + +func FromCtx(ctx context.Context) Provider { + if provider, ok := ctx.Value(ContextKey{}).(Provider); ok { + return provider + } + return nil +} diff --git a/internal/autocert/types/provider.go b/internal/autocert/types/provider.go index 64b95224..685a2942 100644 --- a/internal/autocert/types/provider.go +++ b/internal/autocert/types/provider.go @@ -7,7 +7,6 @@ import ( ) type Provider interface { - Setup() error GetCert(*tls.ClientHelloInfo) (*tls.Certificate, error) ScheduleRenewalAll(task.Parent) ObtainCertAll() error diff --git a/internal/config/events.go b/internal/config/events.go index 11d07145..ee49660c 100644 --- a/internal/config/events.go +++ b/internal/config/events.go @@ -10,11 +10,9 @@ import ( "github.com/yusing/godoxy/internal/common" config "github.com/yusing/godoxy/internal/config/types" "github.com/yusing/godoxy/internal/notif" - "github.com/yusing/godoxy/internal/route/routes" "github.com/yusing/godoxy/internal/watcher" "github.com/yusing/godoxy/internal/watcher/events" gperr "github.com/yusing/goutils/errs" - "github.com/yusing/goutils/server" "github.com/yusing/goutils/strings/ansi" "github.com/yusing/goutils/task" ) @@ -71,19 +69,19 @@ func Load() error { } // disable pool logging temporary since we already have pretty logging - routes.HTTP.DisableLog(true) - routes.Stream.DisableLog(true) - + state.Entrypoint().DisablePoolsLog(true) defer func() { - routes.HTTP.DisableLog(false) - routes.Stream.DisableLog(false) + state.Entrypoint().DisablePoolsLog(false) }() - initErr := state.InitFromFile(common.ConfigPath) err := errors.Join(initErr, state.StartProviders()) if err != nil { logNotifyError("init", err) } + + state.StartAPIServers() + state.StartMetrics() + SetState(state) // flush temporary log @@ -118,7 +116,9 @@ func Reload() gperr.Error { logNotifyError("start providers", err) return nil // continue } - StartProxyServers() + + newState.StartAPIServers() + newState.StartMetrics() return nil } @@ -152,16 +152,3 @@ func OnConfigChange(ev []events.Event) { panic(err) } } - -func StartProxyServers() { - cfg := GetState() - server.StartServer(cfg.Task(), server.Options{ - Name: "proxy", - CertProvider: cfg.AutoCertProvider(), - HTTPAddr: common.ProxyHTTPAddr, - HTTPSAddr: common.ProxyHTTPSAddr, - Handler: cfg.EntrypointHandler(), - ACL: cfg.Value().ACL, - SupportProxyProtocol: cfg.Value().Entrypoint.SupportProxyProtocol, - }) -} diff --git a/internal/config/state.go b/internal/config/state.go index 31029dd2..934d3bbf 100644 --- a/internal/config/state.go +++ b/internal/config/state.go @@ -9,7 +9,6 @@ import ( "fmt" "io/fs" "iter" - "net/http" "os" "strconv" "strings" @@ -18,14 +17,20 @@ import ( "github.com/goccy/go-yaml" "github.com/puzpuzpuz/xsync/v4" "github.com/rs/zerolog" - "github.com/yusing/godoxy/internal/acl" + acl "github.com/yusing/godoxy/internal/acl/types" "github.com/yusing/godoxy/internal/agentpool" + "github.com/yusing/godoxy/internal/api" "github.com/yusing/godoxy/internal/autocert" + autocertctx "github.com/yusing/godoxy/internal/autocert/types" + "github.com/yusing/godoxy/internal/common" config "github.com/yusing/godoxy/internal/config/types" "github.com/yusing/godoxy/internal/entrypoint" + entrypointctx "github.com/yusing/godoxy/internal/entrypoint/types" homepage "github.com/yusing/godoxy/internal/homepage/types" "github.com/yusing/godoxy/internal/logging" "github.com/yusing/godoxy/internal/maxmind" + "github.com/yusing/godoxy/internal/metrics/systeminfo" + "github.com/yusing/godoxy/internal/metrics/uptime" "github.com/yusing/godoxy/internal/notif" route "github.com/yusing/godoxy/internal/route/provider" "github.com/yusing/godoxy/internal/serialization" @@ -40,7 +45,7 @@ type state struct { providers *xsync.Map[string, types.RouteProvider] autocertProvider *autocert.Provider - entrypoint entrypoint.Entrypoint + entrypoint *entrypoint.Entrypoint task *task.Task @@ -65,11 +70,10 @@ func (e CriticalError) Unwrap() error { func NewState() config.State { tmpLogBuf := bytes.NewBuffer(make([]byte, 0, 4096)) return &state{ - providers: xsync.NewMap[string, types.RouteProvider](), - entrypoint: entrypoint.NewEntrypoint(), - task: task.RootTask("config", false), - tmpLogBuf: tmpLogBuf, - tmpLog: logging.NewLoggerWithFixedLevel(zerolog.InfoLevel, tmpLogBuf), + providers: xsync.NewMap[string, types.RouteProvider](), + task: task.RootTask("config", false), + tmpLogBuf: tmpLogBuf, + tmpLog: logging.NewLoggerWithFixedLevel(zerolog.InfoLevel, tmpLogBuf), } } @@ -85,7 +89,6 @@ func SetState(state config.State) { cfg := state.Value() config.ActiveState.Store(state) - entrypoint.ActiveConfig.Store(&cfg.Entrypoint) homepage.ActiveConfig.Store(&cfg.Homepage) if autocertProvider := state.AutoCertProvider(); autocertProvider != nil { autocert.ActiveProvider.Store(autocertProvider.(*autocert.Provider)) @@ -148,8 +151,8 @@ func (state *state) Value() *config.Config { return &state.Config } -func (state *state) EntrypointHandler() http.Handler { - return &state.entrypoint +func (state *state) Entrypoint() entrypointctx.Entrypoint { + return state.entrypoint } func (state *state) ShortLinkMatcher() config.ShortLinkMatcher { @@ -204,6 +207,29 @@ func (state *state) FlushTmpLog() { state.tmpLogBuf.Reset() } +func (state *state) StartAPIServers() { + // API Handler needs to start after auth is initialized. + server.StartServer(state.task.Subtask("api_server", false), server.Options{ + Name: "api", + HTTPAddr: common.APIHTTPAddr, + Handler: api.NewHandler(true), + }) + + // Local API Handler is used for unauthenticated access. + if common.LocalAPIHTTPAddr != "" { + server.StartServer(state.task.Subtask("local_api_server", false), server.Options{ + Name: "local_api", + HTTPAddr: common.LocalAPIHTTPAddr, + Handler: api.NewHandler(false), + }) + } +} + +func (state *state) StartMetrics() { + systeminfo.Poller.Start(state.task) + uptime.Poller.Start(state.task) +} + // initACL initializes the ACL. func (state *state) initACL() error { if !state.ACL.Valid() { @@ -213,7 +239,7 @@ func (state *state) initACL() error { if err != nil { return err } - state.task.SetValue(acl.ContextKey{}, state.ACL) + acl.SetCtx(state.task, state.ACL) return nil } @@ -221,6 +247,7 @@ func (state *state) initEntrypoint() error { epCfg := state.Config.Entrypoint matchDomains := state.MatchDomains + state.entrypoint = entrypoint.NewEntrypoint(state.task, &epCfg) state.entrypoint.SetFindRouteDomains(matchDomains) state.entrypoint.SetNotFoundRules(epCfg.Rules.NotFound) @@ -234,6 +261,8 @@ func (state *state) initEntrypoint() error { } } + entrypointctx.SetCtx(state.task, state.entrypoint) + errs := gperr.NewBuilder("entrypoint error") errs.Add(state.entrypoint.SetMiddlewares(epCfg.Middlewares)) errs.Add(state.entrypoint.SetAccessLogger(state.task, epCfg.AccessLog)) @@ -310,6 +339,7 @@ func (state *state) initAutoCert() error { p.PrintCertExpiriesAll() state.autocertProvider = p + autocertctx.SetCtx(state.task, p) return nil } diff --git a/internal/config/types/state.go b/internal/config/types/state.go index 06b26111..2464eb57 100644 --- a/internal/config/types/state.go +++ b/internal/config/types/state.go @@ -6,6 +6,7 @@ import ( "iter" "net/http" + entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" "github.com/yusing/godoxy/internal/types" "github.com/yusing/goutils/server" "github.com/yusing/goutils/synk" @@ -21,7 +22,7 @@ type State interface { Value() *Config - EntrypointHandler() http.Handler + Entrypoint() entrypoint.Entrypoint ShortLinkMatcher() ShortLinkMatcher AutoCertProvider() server.CertProvider @@ -32,6 +33,9 @@ type State interface { StartProviders() error FlushTmpLog() + + StartAPIServers() + StartMetrics() } type ShortLinkMatcher interface { diff --git a/internal/entrypoint/entrypoint.go b/internal/entrypoint/entrypoint.go index da7370d6..5da7cebc 100644 --- a/internal/entrypoint/entrypoint.go +++ b/internal/entrypoint/entrypoint.go @@ -1,49 +1,131 @@ package entrypoint import ( + "net" "net/http" "strings" "sync/atomic" + "github.com/puzpuzpuz/xsync/v4" "github.com/rs/zerolog/log" - "github.com/yusing/godoxy/internal/common" entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" "github.com/yusing/godoxy/internal/logging/accesslog" "github.com/yusing/godoxy/internal/net/gphttp/middleware" - "github.com/yusing/godoxy/internal/net/gphttp/middleware/errorpage" - "github.com/yusing/godoxy/internal/route/routes" "github.com/yusing/godoxy/internal/route/rules" "github.com/yusing/godoxy/internal/types" + gperr "github.com/yusing/goutils/errs" + "github.com/yusing/goutils/pool" "github.com/yusing/goutils/task" ) +type HTTPRoutes interface { + Get(alias string) (types.HTTPRoute, bool) +} + +type findRouteFunc func(HTTPRoutes, string) types.HTTPRoute + type Entrypoint struct { - middleware *middleware.Middleware - notFoundHandler http.Handler - accessLogger accesslog.AccessLogger - findRouteFunc func(host string) types.HTTPRoute - shortLinkMatcher *ShortLinkMatcher + task *task.Task + + cfg *entrypoint.Config + + middleware *middleware.Middleware + notFoundHandler http.Handler + accessLogger accesslog.AccessLogger + findRouteFunc findRouteFunc + shortLinkMatcher *ShortLinkMatcher + + streamRoutes *pool.Pool[types.StreamRoute] + excludedRoutes *pool.Pool[types.Route] + + // this only affects future http servers creation + httpPoolDisableLog atomic.Bool + + servers *xsync.Map[string, *httpServer] // listen addr -> server + tcpListeners *xsync.Map[string, net.Listener] // listen addr -> listener + udpListeners *xsync.Map[string, net.PacketConn] // listen addr -> listener } -// nil-safe -var ActiveConfig atomic.Pointer[entrypoint.Config] +var _ entrypoint.Entrypoint = &Entrypoint{} -func init() { - // make sure it's not nil - ActiveConfig.Store(&entrypoint.Config{}) -} +var emptyCfg entrypoint.Config -func NewEntrypoint() Entrypoint { - return Entrypoint{ - findRouteFunc: findRouteAnyDomain, - shortLinkMatcher: newShortLinkMatcher(), +func NewEntrypoint(parent task.Parent, cfg *entrypoint.Config) *Entrypoint { + if cfg == nil { + cfg = &emptyCfg } + + ep := &Entrypoint{ + task: parent.Subtask("entrypoint", false), + cfg: cfg, + findRouteFunc: findRouteAnyDomain, + shortLinkMatcher: newShortLinkMatcher(), + streamRoutes: pool.New[types.StreamRoute]("stream_routes"), + excludedRoutes: pool.New[types.Route]("excluded_routes"), + servers: xsync.NewMap[string, *httpServer](), + tcpListeners: xsync.NewMap[string, net.Listener](), + udpListeners: xsync.NewMap[string, net.PacketConn](), + } + ep.task.OnCancel("stop", func() { + // servers stop on their own when context is cancelled + var errs gperr.Group + for _, listener := range ep.tcpListeners.Range { + errs.Go(func() error { + return listener.Close() + }) + } + for _, listener := range ep.udpListeners.Range { + errs.Go(func() error { + return listener.Close() + }) + } + if err := errs.Wait().Error(); err != nil { + gperr.LogError("failed to stop entrypoint listeners", err) + } + }) + ep.task.OnFinished("cleanup", func() { + ep.servers.Clear() + ep.tcpListeners.Clear() + ep.udpListeners.Clear() + }) + return ep } func (ep *Entrypoint) ShortLinkMatcher() *ShortLinkMatcher { return ep.shortLinkMatcher } +func (ep *Entrypoint) Config() *entrypoint.Config { + return ep.cfg +} + +func (ep *Entrypoint) HTTPRoutes() entrypoint.PoolLike[types.HTTPRoute] { + return newHTTPPoolAdapter(ep) +} + +func (ep *Entrypoint) StreamRoutes() entrypoint.PoolLike[types.StreamRoute] { + return ep.streamRoutes +} + +func (ep *Entrypoint) ExcludedRoutes() entrypoint.RWPoolLike[types.Route] { + return ep.excludedRoutes +} + +func (ep *Entrypoint) GetServer(addr string) (*httpServer, bool) { + return ep.servers.Load(addr) +} + +func (ep *Entrypoint) DisablePoolsLog(v bool) { + ep.httpPoolDisableLog.Store(v) + // apply to all running http servers + for _, srv := range ep.servers.Range { + srv.routes.DisableLog(v) + } + // apply to other pools + ep.streamRoutes.DisableLog(v) + ep.excludedRoutes.DisableLog(v) +} + func (ep *Entrypoint) SetFindRouteDomains(domains []string) { if len(domains) == 0 { ep.findRouteFunc = findRouteAnyDomain @@ -74,7 +156,7 @@ func (ep *Entrypoint) SetMiddlewares(mws []map[string]any) error { } func (ep *Entrypoint) SetNotFoundRules(rules rules.Rules) { - ep.notFoundHandler = rules.BuildHandler(http.HandlerFunc(ep.serveNotFound)) + ep.notFoundHandler = rules.BuildHandler(serveNotFound) } func (ep *Entrypoint) SetAccessLogger(parent task.Parent, cfg *accesslog.RequestLoggerConfig) (err error) { @@ -91,111 +173,39 @@ func (ep *Entrypoint) SetAccessLogger(parent task.Parent, cfg *accesslog.Request return err } -func (ep *Entrypoint) FindRoute(s string) types.HTTPRoute { - return ep.findRouteFunc(s) -} - -func (ep *Entrypoint) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if ep.accessLogger != nil { - rec := accesslog.GetResponseRecorder(w) - w = rec - defer func() { - ep.accessLogger.LogRequest(r, rec.Response()) - accesslog.PutResponseRecorder(rec) - }() - } - - route := ep.findRouteFunc(r.Host) - switch { - case route != nil: - r = routes.WithRouteContext(r, route) - if ep.middleware != nil { - ep.middleware.ServeHTTP(route.ServeHTTP, w, r) - } else { - route.ServeHTTP(w, r) - } - case ep.tryHandleShortLink(w, r): - return - case ep.notFoundHandler != nil: - ep.notFoundHandler.ServeHTTP(w, r) - default: - ep.serveNotFound(w, r) - } -} - -func (ep *Entrypoint) tryHandleShortLink(w http.ResponseWriter, r *http.Request) (handled bool) { - host := r.Host - if before, _, ok := strings.Cut(host, ":"); ok { - host = before - } - if strings.EqualFold(host, common.ShortLinkPrefix) { - if ep.middleware != nil { - ep.middleware.ServeHTTP(ep.shortLinkMatcher.ServeHTTP, w, r) - } else { - ep.shortLinkMatcher.ServeHTTP(w, r) - } - return true - } - return false -} - -func (ep *Entrypoint) serveNotFound(w http.ResponseWriter, r *http.Request) { - // Why use StatusNotFound instead of StatusBadRequest or StatusBadGateway? - // On nginx, when route for domain does not exist, it returns StatusBadGateway. - // Then scraper / scanners will know the subdomain is invalid. - // With StatusNotFound, they won't know whether it's the path, or the subdomain that is invalid. - if served := middleware.ServeStaticErrorPageFile(w, r); !served { - log.Error(). - Str("method", r.Method). - Str("url", r.URL.String()). - Str("remote", r.RemoteAddr). - Msgf("not found: %s", r.Host) - errorPage, ok := errorpage.GetErrorPageByStatus(http.StatusNotFound) - if ok { - w.WriteHeader(http.StatusNotFound) - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if _, err := w.Write(errorPage); err != nil { - log.Err(err).Msg("failed to write error page") - } - } else { - http.NotFound(w, r) - } - } -} - -func findRouteAnyDomain(host string) types.HTTPRoute { +func findRouteAnyDomain(routes HTTPRoutes, host string) types.HTTPRoute { idx := strings.IndexByte(host, '.') if idx != -1 { target := host[:idx] - if r, ok := routes.HTTP.Get(target); ok { + if r, ok := routes.Get(target); ok { return r } } - if r, ok := routes.HTTP.Get(host); ok { + if r, ok := routes.Get(host); ok { return r } // try striping the trailing :port from the host if before, _, ok := strings.Cut(host, ":"); ok { - if r, ok := routes.HTTP.Get(before); ok { + if r, ok := routes.Get(before); ok { return r } } return nil } -func findRouteByDomains(domains []string) func(host string) types.HTTPRoute { - return func(host string) types.HTTPRoute { +func findRouteByDomains(domains []string) func(routes HTTPRoutes, host string) types.HTTPRoute { + return func(routes HTTPRoutes, host string) types.HTTPRoute { host, _, _ = strings.Cut(host, ":") // strip the trailing :port for _, domain := range domains { if target, ok := strings.CutSuffix(host, domain); ok { - if r, ok := routes.HTTP.Get(target); ok { + if r, ok := routes.Get(target); ok { return r } } } // fallback to exact match - if r, ok := routes.HTTP.Get(host); ok { + if r, ok := routes.Get(host); ok { return r } return nil diff --git a/internal/entrypoint/entrypoint_benchmark_test.go b/internal/entrypoint/entrypoint_benchmark_test.go index 2b432199..fef26765 100644 --- a/internal/entrypoint/entrypoint_benchmark_test.go +++ b/internal/entrypoint/entrypoint_benchmark_test.go @@ -12,7 +12,6 @@ import ( . "github.com/yusing/godoxy/internal/entrypoint" "github.com/yusing/godoxy/internal/route" - "github.com/yusing/godoxy/internal/route/routes" routeTypes "github.com/yusing/godoxy/internal/route/types" "github.com/yusing/godoxy/internal/types" "github.com/yusing/goutils/task" @@ -90,16 +89,21 @@ func BenchmarkEntrypointReal(b *testing.B) { b.Fatal(err) } - err = r.Start(task.RootTask("test", false)) + err = r.Start(task.NewTestTask(b)) if err != nil { b.Fatal(err) } var w noopResponseWriter + server, ok := ep.GetServer(r.ListenURL().Host) + if !ok { + b.Fatal("server not found") + } + b.ResetTimer() for b.Loop() { - ep.ServeHTTP(&w, &req) + server.ServeHTTP(&w, &req) // if w.statusCode != http.StatusOK { // b.Fatalf("status code is not 200: %d", w.statusCode) // } @@ -140,7 +144,7 @@ func BenchmarkEntrypoint(b *testing.B) { b.Fatal(err) } - rev, ok := routes.HTTP.Get("test") + rev, ok := ep.HTTPRoutes().Get("test") if !ok { b.Fatal("route not found") } @@ -148,9 +152,14 @@ func BenchmarkEntrypoint(b *testing.B) { var w noopResponseWriter + server, ok := ep.GetServer(r.ListenURL().Host) + if !ok { + b.Fatal("server not found") + } + b.ResetTimer() for b.Loop() { - ep.ServeHTTP(&w, &req) + server.ServeHTTP(&w, &req) if w.statusCode != http.StatusOK { b.Fatalf("status code is not 200: %d", w.statusCode) } diff --git a/internal/entrypoint/entrypoint_test.go b/internal/entrypoint/entrypoint_test.go index 526e878a..4bf212ed 100644 --- a/internal/entrypoint/entrypoint_test.go +++ b/internal/entrypoint/entrypoint_test.go @@ -5,15 +5,13 @@ import ( . "github.com/yusing/godoxy/internal/entrypoint" "github.com/yusing/godoxy/internal/route" - "github.com/yusing/godoxy/internal/route/routes" + "github.com/yusing/goutils/task" expect "github.com/yusing/goutils/testing" ) -var ep = NewEntrypoint() - -func addRoute(alias string) { - routes.HTTP.Add(&route.ReveseProxyRoute{ +func addRoute(ep *Entrypoint, alias string) { + ep.AddRoute(&route.ReveseProxyRoute{ Route: &route.Route{ Alias: alias, Port: route.Port{ @@ -25,26 +23,28 @@ func addRoute(alias string) { func run(t *testing.T, match []string, noMatch []string) { t.Helper() - t.Cleanup(routes.Clear) - t.Cleanup(func() { ep.SetFindRouteDomains(nil) }) + ep := NewEntrypoint(task.NewTestTask(t), nil) for _, test := range match { t.Run(test, func(t *testing.T) { - found := ep.FindRoute(test) + found, ok := ep.HTTPRoutes().Get(test) + expect.True(t, ok) expect.NotNil(t, found) }) } for _, test := range noMatch { t.Run(test, func(t *testing.T) { - found := ep.FindRoute(test) + found, ok := ep.HTTPRoutes().Get(test) + expect.False(t, ok) expect.Nil(t, found) }) } } func TestFindRouteAnyDomain(t *testing.T) { - addRoute("app1") + ep := NewEntrypoint(task.NewTestTask(t), nil) + addRoute(ep, "app1") tests := []string{ "app1.com", @@ -62,6 +62,7 @@ func TestFindRouteAnyDomain(t *testing.T) { } func TestFindRouteExactHostMatch(t *testing.T) { + ep := NewEntrypoint(task.NewTestTask(t), nil) tests := []string{ "app2.com", "app2.domain.com", @@ -75,19 +76,20 @@ func TestFindRouteExactHostMatch(t *testing.T) { } for _, test := range tests { - addRoute(test) + addRoute(ep, test) } run(t, tests, testsNoMatch) } func TestFindRouteByDomains(t *testing.T) { + ep := NewEntrypoint(task.NewTestTask(t), nil) ep.SetFindRouteDomains([]string{ ".domain.com", ".sub.domain.com", }) - addRoute("app1") + addRoute(ep, "app1") tests := []string{ "app1.domain.com", @@ -107,12 +109,13 @@ func TestFindRouteByDomains(t *testing.T) { } func TestFindRouteByDomainsExactMatch(t *testing.T) { + ep := NewEntrypoint(task.NewTestTask(t), nil) ep.SetFindRouteDomains([]string{ ".domain.com", ".sub.domain.com", }) - addRoute("app1.foo.bar") + addRoute(ep, "app1.foo.bar") tests := []string{ "app1.foo.bar", // exact match @@ -131,8 +134,9 @@ func TestFindRouteByDomainsExactMatch(t *testing.T) { func TestFindRouteWithPort(t *testing.T) { t.Run("AnyDomain", func(t *testing.T) { - addRoute("app1") - addRoute("app2.com") + ep := NewEntrypoint(task.NewTestTask(t), nil) + addRoute(ep, "app1") + addRoute(ep, "app2.com") tests := []string{ "app1:8080", @@ -148,12 +152,13 @@ func TestFindRouteWithPort(t *testing.T) { }) t.Run("ByDomains", func(t *testing.T) { + ep := NewEntrypoint(task.NewTestTask(t), nil) ep.SetFindRouteDomains([]string{ ".domain.com", }) - addRoute("app1") - addRoute("app2") - addRoute("app3.domain.com") + addRoute(ep, "app1") + addRoute(ep, "app2") + addRoute(ep, "app3.domain.com") tests := []string{ "app1.domain.com:8080", diff --git a/internal/entrypoint/http_pool_adapter.go b/internal/entrypoint/http_pool_adapter.go new file mode 100644 index 00000000..15af15de --- /dev/null +++ b/internal/entrypoint/http_pool_adapter.go @@ -0,0 +1,51 @@ +package entrypoint + +import ( + "github.com/yusing/godoxy/internal/common" + "github.com/yusing/godoxy/internal/types" +) + +// httpPoolAdapter implements the PoolLike interface for the HTTP routes. +type httpPoolAdapter struct { + ep *Entrypoint +} + +func newHTTPPoolAdapter(ep *Entrypoint) httpPoolAdapter { + return httpPoolAdapter{ep: ep} +} + +func (h httpPoolAdapter) Iter(yield func(alias string, route types.HTTPRoute) bool) { + for addr, srv := range h.ep.servers.Range { + // default routes are added to both HTTP and HTTPS servers, we don't need to iterate over them twice. + if addr == common.ProxyHTTPSAddr { + continue + } + for alias, route := range srv.routes.Iter { + if !yield(alias, route) { + return + } + } + } +} + +func (h httpPoolAdapter) Get(alias string) (types.HTTPRoute, bool) { + for addr, srv := range h.ep.servers.Range { + if addr == common.ProxyHTTPSAddr { + continue + } + if route, ok := srv.routes.Get(alias); ok { + return route, true + } + } + return nil, false +} + +func (h httpPoolAdapter) Size() (n int) { + for addr, srv := range h.ep.servers.Range { + if addr == common.ProxyHTTPSAddr { + continue + } + n += srv.routes.Size() + } + return +} diff --git a/internal/entrypoint/http_server.go b/internal/entrypoint/http_server.go new file mode 100644 index 00000000..d1eed7b4 --- /dev/null +++ b/internal/entrypoint/http_server.go @@ -0,0 +1,172 @@ +package entrypoint + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "github.com/rs/zerolog/log" + acl "github.com/yusing/godoxy/internal/acl/types" + autocert "github.com/yusing/godoxy/internal/autocert/types" + "github.com/yusing/godoxy/internal/common" + "github.com/yusing/godoxy/internal/logging/accesslog" + "github.com/yusing/godoxy/internal/net/gphttp/middleware" + "github.com/yusing/godoxy/internal/net/gphttp/middleware/errorpage" + "github.com/yusing/godoxy/internal/route/routes" + "github.com/yusing/godoxy/internal/types" + "github.com/yusing/goutils/pool" + "github.com/yusing/goutils/server" +) + +// httpServer is a server that listens on a given address and serves HTTP routes. +type HTTPServer interface { + Listen(addr string, proto HTTPProto) error + AddRoute(route types.HTTPRoute) + DelRoute(route types.HTTPRoute) + FindRoute(s string) types.HTTPRoute + ServeHTTP(w http.ResponseWriter, r *http.Request) +} + +type httpServer struct { + srv *server.Server + ep *Entrypoint + + stopFunc func(reason any) + + addr string + routes *pool.Pool[types.HTTPRoute] +} + +type HTTPProto string + +const ( + HTTPProtoHTTP HTTPProto = "http" + HTTPProtoHTTPS HTTPProto = "https" +) + +func NewHTTPServer(ep *Entrypoint) HTTPServer { + return newHTTPServer(ep) +} + +func newHTTPServer(ep *Entrypoint) *httpServer { + return &httpServer{ep: ep} +} + +// Listen starts the server and stop when entrypoint is stopped. +func (srv *httpServer) Listen(addr string, proto HTTPProto) error { + if srv.srv != nil { + return errors.New("server already started") + } + + opts := server.Options{ + Name: addr, + Handler: srv, + ACL: acl.FromCtx(srv.ep.task.Context()), + SupportProxyProtocol: srv.ep.cfg.SupportProxyProtocol, + } + + switch proto { + case HTTPProtoHTTP: + opts.HTTPAddr = addr + case HTTPProtoHTTPS: + opts.HTTPSAddr = addr + opts.CertProvider = autocert.FromCtx(srv.ep.task.Context()) + } + + task := srv.ep.task.Subtask("http_server", false) + server, err := server.StartServer(task, opts) + if err != nil { + return err + } + srv.stopFunc = task.FinishAndWait + srv.addr = addr + srv.srv = server + srv.routes = pool.New[types.HTTPRoute](fmt.Sprintf("[%s] %s", proto, addr)) + srv.routes.DisableLog(srv.ep.httpPoolDisableLog.Load()) + return nil +} + +func (srv *httpServer) Close() { + srv.stopFunc(nil) +} + +func (srv *httpServer) AddRoute(route types.HTTPRoute) { + srv.routes.Add(route) +} + +func (srv *httpServer) DelRoute(route types.HTTPRoute) { + srv.routes.Del(route) +} + +func (srv *httpServer) FindRoute(s string) types.HTTPRoute { + return srv.ep.findRouteFunc(srv.routes, s) +} + +func (srv *httpServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if srv.ep.accessLogger != nil { + rec := accesslog.GetResponseRecorder(w) + w = rec + defer func() { + srv.ep.accessLogger.LogRequest(r, rec.Response()) + accesslog.PutResponseRecorder(rec) + }() + } + + route := srv.ep.findRouteFunc(srv.routes, r.Host) + switch { + case route != nil: + r = routes.WithRouteContext(r, route) + if srv.ep.middleware != nil { + srv.ep.middleware.ServeHTTP(route.ServeHTTP, w, r) + } else { + route.ServeHTTP(w, r) + } + case srv.tryHandleShortLink(w, r): + return + case srv.ep.notFoundHandler != nil: + srv.ep.notFoundHandler.ServeHTTP(w, r) + default: + serveNotFound(w, r) + } +} + +func (srv *httpServer) tryHandleShortLink(w http.ResponseWriter, r *http.Request) (handled bool) { + host := r.Host + if before, _, ok := strings.Cut(host, ":"); ok { + host = before + } + if strings.EqualFold(host, common.ShortLinkPrefix) { + if srv.ep.middleware != nil { + srv.ep.middleware.ServeHTTP(srv.ep.shortLinkMatcher.ServeHTTP, w, r) + } else { + srv.ep.shortLinkMatcher.ServeHTTP(w, r) + } + return true + } + return false +} + +func serveNotFound(w http.ResponseWriter, r *http.Request) { + // Why use StatusNotFound instead of StatusBadRequest or StatusBadGateway? + // On nginx, when route for domain does not exist, it returns StatusBadGateway. + // Then scraper / scanners will know the subdomain is invalid. + // With StatusNotFound, they won't know whether it's the path, or the subdomain that is invalid. + if served := middleware.ServeStaticErrorPageFile(w, r); !served { + log.Error(). + Str("method", r.Method). + Str("url", r.URL.String()). + Str("remote", r.RemoteAddr). + Msgf("not found: %s", r.Host) + errorPage, ok := errorpage.GetErrorPageByStatus(http.StatusNotFound) + if ok { + w.WriteHeader(http.StatusNotFound) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if _, err := w.Write(errorPage); err != nil { + log.Err(err).Msg("failed to write error page") + } + } else { + http.NotFound(w, r) + } + } +} diff --git a/internal/entrypoint/query.go b/internal/entrypoint/query.go new file mode 100644 index 00000000..df5b2177 --- /dev/null +++ b/internal/entrypoint/query.go @@ -0,0 +1,91 @@ +package entrypoint + +import ( + "github.com/yusing/godoxy/internal/types" +) + +// GetHealthInfo returns a map of route name to health info. +// +// The health info is for all routes, including excluded routes. +func (ep *Entrypoint) GetHealthInfo() map[string]types.HealthInfo { + healthMap := make(map[string]types.HealthInfo, ep.NumRoutes()) + for r := range ep.IterRoutes { + healthMap[r.Name()] = getHealthInfo(r) + } + return healthMap +} + +// GetHealthInfoWithoutDetail returns a map of route name to health info without detail. +// +// The health info is for all routes, including excluded routes. +func (ep *Entrypoint) GetHealthInfoWithoutDetail() map[string]types.HealthInfoWithoutDetail { + healthMap := make(map[string]types.HealthInfoWithoutDetail, ep.NumRoutes()) + for r := range ep.IterRoutes { + healthMap[r.Name()] = getHealthInfoWithoutDetail(r) + } + return healthMap +} + +// GetHealthInfoSimple returns a map of route name to health status. +// +// The health status is for all routes, including excluded routes. +func (ep *Entrypoint) GetHealthInfoSimple() map[string]types.HealthStatus { + healthMap := make(map[string]types.HealthStatus, ep.NumRoutes()) + for r := range ep.IterRoutes { + healthMap[r.Name()] = getHealthInfoSimple(r) + } + return healthMap +} + +// RoutesByProvider returns a map of provider name to routes. +// +// The routes are all routes, including excluded routes. +func (ep *Entrypoint) RoutesByProvider() map[string][]types.Route { + rts := make(map[string][]types.Route) + for r := range ep.IterRoutes { + rts[r.ProviderName()] = append(rts[r.ProviderName()], r) + } + return rts +} + +func getHealthInfo(r types.Route) types.HealthInfo { + mon := r.HealthMonitor() + if mon == nil { + return types.HealthInfo{ + HealthInfoWithoutDetail: types.HealthInfoWithoutDetail{ + Status: types.StatusUnknown, + }, + Detail: "n/a", + } + } + return types.HealthInfo{ + HealthInfoWithoutDetail: types.HealthInfoWithoutDetail{ + Status: mon.Status(), + Uptime: mon.Uptime(), + Latency: mon.Latency(), + }, + Detail: mon.Detail(), + } +} + +func getHealthInfoWithoutDetail(r types.Route) types.HealthInfoWithoutDetail { + mon := r.HealthMonitor() + if mon == nil { + return types.HealthInfoWithoutDetail{ + Status: types.StatusUnknown, + } + } + return types.HealthInfoWithoutDetail{ + Status: mon.Status(), + Uptime: mon.Uptime(), + Latency: mon.Latency(), + } +} + +func getHealthInfoSimple(r types.Route) types.HealthStatus { + mon := r.HealthMonitor() + if mon == nil { + return types.StatusUnknown + } + return mon.Status() +} diff --git a/internal/entrypoint/routes.go b/internal/entrypoint/routes.go new file mode 100644 index 00000000..03373683 --- /dev/null +++ b/internal/entrypoint/routes.go @@ -0,0 +1,111 @@ +package entrypoint + +import ( + "errors" + "net" + "strconv" + + "github.com/yusing/godoxy/internal/common" + "github.com/yusing/godoxy/internal/types" +) + +func (ep *Entrypoint) IterRoutes(yield func(r types.Route) bool) { + for _, r := range ep.HTTPRoutes().Iter { + if !yield(r) { + break + } + } + for _, r := range ep.streamRoutes.Iter { + if !yield(r) { + break + } + } + for _, r := range ep.excludedRoutes.Iter { + if !yield(r) { + break + } + } +} + +func (ep *Entrypoint) NumRoutes() int { + return ep.HTTPRoutes().Size() + ep.streamRoutes.Size() + ep.excludedRoutes.Size() +} + +func (ep *Entrypoint) GetRoute(alias string) (types.Route, bool) { + if r, ok := ep.HTTPRoutes().Get(alias); ok { + return r, true + } + if r, ok := ep.streamRoutes.Get(alias); ok { + return r, true + } + if r, ok := ep.excludedRoutes.Get(alias); ok { + return r, true + } + return nil, false +} + +func (ep *Entrypoint) AddRoute(r types.Route) { + if r.ShouldExclude() { + ep.excludedRoutes.Add(r) + r.Task().OnCancel("remove_route", func() { + ep.excludedRoutes.Del(r) + }) + return + } + switch r := r.(type) { + case types.HTTPRoute: + ep.AddHTTPRoute(r) + ep.shortLinkMatcher.AddRoute(r.Key()) + r.Task().OnCancel("remove_route", func() { + ep.delHTTPRoute(r) + ep.shortLinkMatcher.DelRoute(r.Key()) + }) + case types.StreamRoute: + ep.streamRoutes.Add(r) + r.Task().OnCancel("remove_route", func() { + ep.streamRoutes.Del(r) + }) + } +} + +// AddHTTPRoute adds a HTTP route to the entrypoint's server. +// +// If the server does not exist, it will be created, started and return any error. +func (ep *Entrypoint) AddHTTPRoute(route types.HTTPRoute) error { + if port := route.ListenURL().Port(); port == "" || port == "0" { + host := route.ListenURL().Hostname() + if host == "" { + host = common.ProxyHTTPHost + } + httpAddr := net.JoinHostPort(host, strconv.Itoa(common.ProxyHTTPPort)) + httpsAddr := net.JoinHostPort(host, strconv.Itoa(common.ProxyHTTPSPort)) + return errors.Join(ep.addHTTPRoute(route, httpAddr, HTTPProtoHTTP), ep.addHTTPRoute(route, httpsAddr, HTTPProtoHTTPS)) + } + + return ep.addHTTPRoute(route, route.ListenURL().Host, HTTPProtoHTTPS) +} + +func (ep *Entrypoint) addHTTPRoute(route types.HTTPRoute, addr string, proto HTTPProto) error { + var err error + srv, _ := ep.servers.LoadOrCompute(addr, func() (srv *httpServer, cancel bool) { + srv = newHTTPServer(ep) + err = srv.Listen(addr, proto) + cancel = err != nil + return + }) + if err != nil { + return err + } + + srv.AddRoute(route) + return nil +} + +func (ep *Entrypoint) delHTTPRoute(route types.HTTPRoute) { + addr := route.ListenURL().Host + srv, _ := ep.servers.Load(addr) + if srv != nil { + srv.DelRoute(route) + } + // TODO: close if no servers left +} diff --git a/internal/entrypoint/shortlink_test.go b/internal/entrypoint/shortlink_test.go index 6e28a8b0..1d60fdc4 100644 --- a/internal/entrypoint/shortlink_test.go +++ b/internal/entrypoint/shortlink_test.go @@ -6,13 +6,15 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/yusing/godoxy/internal/common" . "github.com/yusing/godoxy/internal/entrypoint" + "github.com/yusing/goutils/task" ) func TestShortLinkMatcher_FQDNAlias(t *testing.T) { - ep := NewEntrypoint() + ep := NewEntrypoint(task.NewTestTask(t), nil) matcher := ep.ShortLinkMatcher() matcher.AddRoute("app.domain.com") @@ -45,7 +47,7 @@ func TestShortLinkMatcher_FQDNAlias(t *testing.T) { } func TestShortLinkMatcher_SubdomainAlias(t *testing.T) { - ep := NewEntrypoint() + ep := NewEntrypoint(task.NewTestTask(t), nil) matcher := ep.ShortLinkMatcher() matcher.SetDefaultDomainSuffix(".example.com") matcher.AddRoute("app") @@ -70,7 +72,7 @@ func TestShortLinkMatcher_SubdomainAlias(t *testing.T) { } func TestShortLinkMatcher_NotFound(t *testing.T) { - ep := NewEntrypoint() + ep := NewEntrypoint(task.NewTestTask(t), nil) matcher := ep.ShortLinkMatcher() matcher.SetDefaultDomainSuffix(".example.com") matcher.AddRoute("app") @@ -93,7 +95,7 @@ func TestShortLinkMatcher_NotFound(t *testing.T) { } func TestShortLinkMatcher_AddDelRoute(t *testing.T) { - ep := NewEntrypoint() + ep := NewEntrypoint(task.NewTestTask(t), nil) matcher := ep.ShortLinkMatcher() matcher.SetDefaultDomainSuffix(".example.com") @@ -131,7 +133,7 @@ func TestShortLinkMatcher_AddDelRoute(t *testing.T) { } func TestShortLinkMatcher_NoDefaultDomainSuffix(t *testing.T) { - ep := NewEntrypoint() + ep := NewEntrypoint(task.NewTestTask(t), nil) matcher := ep.ShortLinkMatcher() // no SetDefaultDomainSuffix called @@ -158,15 +160,19 @@ func TestShortLinkMatcher_NoDefaultDomainSuffix(t *testing.T) { } func TestEntrypoint_ShortLinkDispatch(t *testing.T) { - ep := NewEntrypoint() + ep := NewEntrypoint(task.NewTestTask(t), nil) ep.ShortLinkMatcher().SetDefaultDomainSuffix(".example.com") ep.ShortLinkMatcher().AddRoute("app") + server := NewHTTPServer(ep) + err := server.Listen("localhost:8080", HTTPProtoHTTP) + require.NoError(t, err) + t.Run("shortlink host", func(t *testing.T) { req := httptest.NewRequest("GET", "/app", nil) req.Host = common.ShortLinkPrefix w := httptest.NewRecorder() - ep.ServeHTTP(w, req) + server.ServeHTTP(w, req) assert.Equal(t, http.StatusTemporaryRedirect, w.Code) assert.Equal(t, "https://app.example.com/", w.Header().Get("Location")) @@ -176,7 +182,7 @@ func TestEntrypoint_ShortLinkDispatch(t *testing.T) { req := httptest.NewRequest("GET", "/app", nil) req.Host = common.ShortLinkPrefix + ":8080" w := httptest.NewRecorder() - ep.ServeHTTP(w, req) + server.ServeHTTP(w, req) assert.Equal(t, http.StatusTemporaryRedirect, w.Code) assert.Equal(t, "https://app.example.com/", w.Header().Get("Location")) @@ -186,7 +192,7 @@ func TestEntrypoint_ShortLinkDispatch(t *testing.T) { req := httptest.NewRequest("GET", "/app", nil) req.Host = "app.example.com" w := httptest.NewRecorder() - ep.ServeHTTP(w, req) + server.ServeHTTP(w, req) // Should not redirect, should try normal route lookup (which will 404) assert.NotEqual(t, http.StatusTemporaryRedirect, w.Code) diff --git a/internal/entrypoint/types/context.go b/internal/entrypoint/types/context.go new file mode 100644 index 00000000..f2bde899 --- /dev/null +++ b/internal/entrypoint/types/context.go @@ -0,0 +1,18 @@ +package entrypoint + +import ( + "context" +) + +type ContextKey struct{} + +func SetCtx(ctx interface{ SetValue(any, any) }, ep Entrypoint) { + ctx.SetValue(ContextKey{}, ep) +} + +func FromCtx(ctx context.Context) Entrypoint { + if ep, ok := ctx.Value(ContextKey{}).(Entrypoint); ok { + return ep + } + return nil +} diff --git a/internal/entrypoint/types/entrypoint.go b/internal/entrypoint/types/entrypoint.go new file mode 100644 index 00000000..f0543bf1 --- /dev/null +++ b/internal/entrypoint/types/entrypoint.go @@ -0,0 +1,37 @@ +package entrypoint + +import ( + "github.com/yusing/godoxy/internal/types" +) + +type Entrypoint interface { + Config() *Config + + DisablePoolsLog(v bool) + + GetRoute(alias string) (types.Route, bool) + AddRoute(r types.Route) + IterRoutes(yield func(r types.Route) bool) + NumRoutes() int + RoutesByProvider() map[string][]types.Route + + HTTPRoutes() PoolLike[types.HTTPRoute] + StreamRoutes() PoolLike[types.StreamRoute] + ExcludedRoutes() RWPoolLike[types.Route] + + GetHealthInfo() map[string]types.HealthInfo + GetHealthInfoWithoutDetail() map[string]types.HealthInfoWithoutDetail + GetHealthInfoSimple() map[string]types.HealthStatus +} + +type PoolLike[Route types.Route] interface { + Get(alias string) (Route, bool) + Iter(yield func(alias string, r Route) bool) + Size() int +} + +type RWPoolLike[Route types.Route] interface { + PoolLike[Route] + Add(r Route) + Del(r Route) +} diff --git a/internal/idlewatcher/watcher.go b/internal/idlewatcher/watcher.go index 5f43f9e5..6236de0f 100644 --- a/internal/idlewatcher/watcher.go +++ b/internal/idlewatcher/watcher.go @@ -14,11 +14,11 @@ import ( "github.com/yusing/ds/ordered" config "github.com/yusing/godoxy/internal/config/types" "github.com/yusing/godoxy/internal/docker" + entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" "github.com/yusing/godoxy/internal/health/monitor" "github.com/yusing/godoxy/internal/idlewatcher/provider" idlewatcher "github.com/yusing/godoxy/internal/idlewatcher/types" nettypes "github.com/yusing/godoxy/internal/net/types" - "github.com/yusing/godoxy/internal/route/routes" "github.com/yusing/godoxy/internal/types" "github.com/yusing/godoxy/internal/watcher/events" gperr "github.com/yusing/goutils/errs" @@ -173,7 +173,7 @@ func NewWatcher(parent task.Parent, r types.Route, cfg *types.IdlewatcherConfig) } if !ok { - depRoute, ok = routes.GetIncludeExcluded(dep) + depRoute, ok = entrypoint.FromCtx(parent.Context()).GetRoute(dep) if !ok { depErrors.Addf("dependency %q not found", dep) continue diff --git a/internal/metrics/period/poller.go b/internal/metrics/period/poller.go index 390b735f..d5efb577 100644 --- a/internal/metrics/period/poller.go +++ b/internal/metrics/period/poller.go @@ -153,8 +153,8 @@ func (p *Poller[T, AggregateT]) pollWithTimeout(ctx context.Context) { p.lastResult.Store(data) } -func (p *Poller[T, AggregateT]) Start() { - t := task.RootTask("poller."+p.name, true) +func (p *Poller[T, AggregateT]) Start(parent task.Parent) { + t := parent.Subtask("poller."+p.name, true) l := log.With().Str("name", p.name).Logger() err := p.load() if err != nil { diff --git a/internal/metrics/uptime/uptime.go b/internal/metrics/uptime/uptime.go index c8fc7bf9..1df8ccd3 100644 --- a/internal/metrics/uptime/uptime.go +++ b/internal/metrics/uptime/uptime.go @@ -8,16 +8,17 @@ import ( "github.com/bytedance/sonic" "github.com/lithammer/fuzzysearch/fuzzy" + config "github.com/yusing/godoxy/internal/config/types" + entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" "github.com/yusing/godoxy/internal/metrics/period" metricsutils "github.com/yusing/godoxy/internal/metrics/utils" - "github.com/yusing/godoxy/internal/route/routes" "github.com/yusing/godoxy/internal/types" ) type ( StatusByAlias struct { - Map map[string]routes.HealthInfoWithoutDetail `json:"statuses"` - Timestamp int64 `json:"timestamp"` + Map map[string]types.HealthInfoWithoutDetail `json:"statuses"` + Timestamp int64 `json:"timestamp"` } // @name RouteStatusesByAlias Status struct { Status types.HealthStatus `json:"status" swaggertype:"string" enums:"healthy,unhealthy,unknown,napping,starting"` @@ -41,7 +42,7 @@ var Poller = period.NewPoller("uptime", getStatuses, aggregateStatuses) func getStatuses(ctx context.Context, _ StatusByAlias) (StatusByAlias, error) { return StatusByAlias{ - Map: routes.GetHealthInfoWithoutDetail(), + Map: entrypoint.FromCtx(ctx).GetHealthInfoWithoutDetail(), Timestamp: time.Now().Unix(), }, nil } @@ -127,11 +128,13 @@ func (rs RouteStatuses) aggregate(limit int, offset int) Aggregated { up, down, idle, latency := rs.calculateInfo(statuses) status := types.StatusUnknown - r, ok := routes.GetIncludeExcluded(alias) - if ok { - mon := r.HealthMonitor() - if mon != nil { - status = mon.Status() + if state := config.ActiveState.Load(); state != nil { + r, ok := entrypoint.FromCtx(state.Context()).GetRoute(alias) + if ok { + mon := r.HealthMonitor() + if mon != nil { + status = mon.Status() + } } } diff --git a/internal/net/gphttp/middleware/bypass_test.go b/internal/net/gphttp/middleware/bypass_test.go index b4a72f45..d6dff351 100644 --- a/internal/net/gphttp/middleware/bypass_test.go +++ b/internal/net/gphttp/middleware/bypass_test.go @@ -231,7 +231,7 @@ func TestEntrypointBypassRoute(t *testing.T) { expect.NoError(t, err) expect.NoError(t, err) - entry := entrypoint.NewEntrypoint() + entry := entrypoint.NewEntrypoint(task.NewTestTask(t), nil) r := &route.Route{ Alias: "test-route", Host: host, @@ -260,7 +260,11 @@ func TestEntrypointBypassRoute(t *testing.T) { recorder := httptest.NewRecorder() req := httptest.NewRequest("GET", "http://test-route.example.com", nil) - entry.ServeHTTP(recorder, req) + server, ok := entry.GetServer(r.ListenURL().Host) + if !ok { + t.Fatal("server not found") + } + server.ServeHTTP(recorder, req) expect.Equal(t, recorder.Code, http.StatusOK, "should bypass http redirect") expect.Equal(t, recorder.Body.String(), "test") expect.Equal(t, recorder.Header().Get("Test-Header"), "test-value") diff --git a/internal/net/gphttp/middleware/crowdsec.go b/internal/net/gphttp/middleware/crowdsec.go index ec3af22b..362ef6e1 100644 --- a/internal/net/gphttp/middleware/crowdsec.go +++ b/internal/net/gphttp/middleware/crowdsec.go @@ -11,7 +11,7 @@ import ( "strings" "time" - "github.com/yusing/godoxy/internal/route/routes" + entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" httputils "github.com/yusing/goutils/http" ioutils "github.com/yusing/goutils/io" ) @@ -66,7 +66,7 @@ func (m *crowdsecMiddleware) finalize() error { // before implements RequestModifier. func (m *crowdsecMiddleware) before(w http.ResponseWriter, r *http.Request) (proceed bool) { // Build CrowdSec URL - crowdsecURL, err := m.buildCrowdSecURL() + crowdsecURL, err := m.buildCrowdSecURL(r.Context()) if err != nil { Crowdsec.LogError(r).Err(err).Msg("failed to build CrowdSec URL") w.WriteHeader(http.StatusInternalServerError) @@ -167,10 +167,10 @@ func (m *crowdsecMiddleware) before(w http.ResponseWriter, r *http.Request) (pro } // buildCrowdSecURL constructs the CrowdSec server URL based on route or IP configuration -func (m *crowdsecMiddleware) buildCrowdSecURL() (string, error) { +func (m *crowdsecMiddleware) buildCrowdSecURL(ctx context.Context) (string, error) { // Try to get route first if m.Route != "" { - if route, ok := routes.HTTP.Get(m.Route); ok { + if route, ok := entrypoint.FromCtx(ctx).GetRoute(m.Route); ok { // Using route name targetURL := *route.TargetURL() targetURL.Path = m.Endpoint diff --git a/internal/net/gphttp/middleware/forwardauth.go b/internal/net/gphttp/middleware/forwardauth.go index 90b16f0f..434bbabd 100644 --- a/internal/net/gphttp/middleware/forwardauth.go +++ b/internal/net/gphttp/middleware/forwardauth.go @@ -8,7 +8,7 @@ import ( "strings" "time" - "github.com/yusing/godoxy/internal/route/routes" + entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" httputils "github.com/yusing/goutils/http" "github.com/yusing/goutils/http/httpheaders" ) @@ -46,7 +46,7 @@ func (m *forwardAuthMiddleware) setup() { // before implements RequestModifier. func (m *forwardAuthMiddleware) before(w http.ResponseWriter, r *http.Request) (proceed bool) { - route, ok := routes.HTTP.Get(m.Route) + route, ok := entrypoint.FromCtx(r.Context()).HTTPRoutes().Get(m.Route) if !ok { ForwardAuth.LogWarn(r).Str("route", m.Route).Msg("forwardauth route not found") w.WriteHeader(http.StatusInternalServerError) diff --git a/internal/route/common.go b/internal/route/common.go index a1f1a785..bda3cf3e 100644 --- a/internal/route/common.go +++ b/internal/route/common.go @@ -1,12 +1,17 @@ package route import ( - "github.com/yusing/godoxy/internal/route/routes" + "context" + + entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" "github.com/yusing/godoxy/internal/types" gperr "github.com/yusing/goutils/errs" ) -func checkExists(r types.Route) gperr.Error { +// checkExists checks if the route already exists in the entrypoint. +// +// Context must be passed from the parent task that carries the entrypoint value. +func checkExists(ctx context.Context, r types.Route) gperr.Error { if r.UseLoadBalance() { // skip checking for load balanced routes return nil } @@ -16,9 +21,9 @@ func checkExists(r types.Route) gperr.Error { ) switch r := r.(type) { case types.HTTPRoute: - existing, ok = routes.HTTP.Get(r.Key()) + existing, ok = entrypoint.FromCtx(ctx).HTTPRoutes().Get(r.Key()) case types.StreamRoute: - existing, ok = routes.Stream.Get(r.Key()) + existing, ok = entrypoint.FromCtx(ctx).StreamRoutes().Get(r.Key()) } if ok { return gperr.Errorf("route already exists: from provider %s and %s", existing.ProviderName(), r.ProviderName()) diff --git a/internal/route/fileserver.go b/internal/route/fileserver.go index fbdfcc8f..62bfb408 100644 --- a/internal/route/fileserver.go +++ b/internal/route/fileserver.go @@ -6,12 +6,12 @@ import ( "path" "path/filepath" - config "github.com/yusing/godoxy/internal/config/types" + "github.com/rs/zerolog/log" + entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" "github.com/yusing/godoxy/internal/health/monitor" "github.com/yusing/godoxy/internal/logging/accesslog" gphttp "github.com/yusing/godoxy/internal/net/gphttp" "github.com/yusing/godoxy/internal/net/gphttp/middleware" - "github.com/yusing/godoxy/internal/route/routes" "github.com/yusing/godoxy/internal/types" gperr "github.com/yusing/goutils/errs" "github.com/yusing/goutils/task" @@ -120,20 +120,13 @@ func (s *FileServer) Start(parent task.Parent) gperr.Error { if s.UseHealthCheck() { s.HealthMon = monitor.NewMonitor(s) if err := s.HealthMon.Start(s.task); err != nil { - return err + l := log.With().Str("type", "fileserver").Str("name", s.Name()).Logger() + gperr.LogWarn("health monitor error", err, &l) + s.HealthMon = nil } } - routes.HTTP.Add(s) - if state := config.WorkingState.Load(); state != nil { - state.ShortLinkMatcher().AddRoute(s.Alias) - } - s.task.OnFinished("remove_route_from_http", func() { - routes.HTTP.Del(s) - if state := config.WorkingState.Load(); state != nil { - state.ShortLinkMatcher().DelRoute(s.Alias) - } - }) + entrypoint.FromCtx(parent.Context()).AddRoute(s) return nil } diff --git a/internal/route/reverse_proxy.go b/internal/route/reverse_proxy.go index 08225aa5..22a61658 100755 --- a/internal/route/reverse_proxy.go +++ b/internal/route/reverse_proxy.go @@ -6,7 +6,7 @@ import ( "github.com/yusing/godoxy/agent/pkg/agent" "github.com/yusing/godoxy/agent/pkg/agentproxy" - config "github.com/yusing/godoxy/internal/config/types" + entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" "github.com/yusing/godoxy/internal/health/monitor" "github.com/yusing/godoxy/internal/idlewatcher" "github.com/yusing/godoxy/internal/logging/accesslog" @@ -14,7 +14,6 @@ import ( "github.com/yusing/godoxy/internal/net/gphttp/loadbalancer" "github.com/yusing/godoxy/internal/net/gphttp/middleware" nettypes "github.com/yusing/godoxy/internal/net/types" - "github.com/yusing/godoxy/internal/route/routes" route "github.com/yusing/godoxy/internal/route/types" "github.com/yusing/godoxy/internal/types" gperr "github.com/yusing/goutils/errs" @@ -159,23 +158,15 @@ func (r *ReveseProxyRoute) Start(parent task.Parent) gperr.Error { if r.HealthMon != nil { if err := r.HealthMon.Start(r.task); err != nil { - return err + gperr.LogWarn("health monitor error", err, &r.rp.Logger) + r.HealthMon = nil } } if r.UseLoadBalance() { r.addToLoadBalancer(parent) } else { - routes.HTTP.Add(r) - if state := config.WorkingState.Load(); state != nil { - state.ShortLinkMatcher().AddRoute(r.Alias) - } - r.task.OnCancel("remove_route", func() { - routes.HTTP.Del(r) - if state := config.WorkingState.Load(); state != nil { - state.ShortLinkMatcher().DelRoute(r.Alias) - } - }) + entrypoint.FromCtx(parent.Context()).AddRoute(r) } return nil } @@ -192,7 +183,8 @@ func (r *ReveseProxyRoute) addToLoadBalancer(parent task.Parent) { cfg := r.LoadBalance lbLock.Lock() - l, ok := routes.HTTP.Get(cfg.Link) + ep := entrypoint.FromCtx(r.task.Context()) + l, ok := ep.HTTPRoutes().Get(cfg.Link) var linked *ReveseProxyRoute if ok { lbLock.Unlock() @@ -214,16 +206,7 @@ func (r *ReveseProxyRoute) addToLoadBalancer(parent task.Parent) { handler: lb, } linked.SetHealthMonitor(lb) - routes.HTTP.AddKey(cfg.Link, linked) - if state := config.WorkingState.Load(); state != nil { - state.ShortLinkMatcher().AddRoute(cfg.Link) - } - r.task.OnFinished("remove_loadbalancer_route", func() { - routes.HTTP.DelKey(cfg.Link) - if state := config.WorkingState.Load(); state != nil { - state.ShortLinkMatcher().DelRoute(cfg.Link) - } - }) + ep.AddRoute(linked) lbLock.Unlock() } r.loadBalancer = lb diff --git a/internal/route/route.go b/internal/route/route.go index 3a472eae..f5c0a7db 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -18,6 +18,7 @@ import ( "github.com/yusing/godoxy/internal/agentpool" config "github.com/yusing/godoxy/internal/config/types" "github.com/yusing/godoxy/internal/docker" + entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" "github.com/yusing/godoxy/internal/health/monitor" "github.com/yusing/godoxy/internal/homepage" iconlist "github.com/yusing/godoxy/internal/homepage/icons/list" @@ -33,7 +34,6 @@ import ( "github.com/yusing/godoxy/internal/common" "github.com/yusing/godoxy/internal/logging/accesslog" - "github.com/yusing/godoxy/internal/route/routes" "github.com/yusing/godoxy/internal/route/rules" rulepresets "github.com/yusing/godoxy/internal/route/rules/presets" route "github.com/yusing/godoxy/internal/route/types" @@ -46,8 +46,7 @@ type ( Host string `json:"host,omitempty"` Port route.Port `json:"port"` - // for TCP and UDP routes, bind address to listen on - Bind string `json:"bind,omitempty" validate:"omitempty,ip_addr" extensions:"x-nullable"` + Bind string `json:"bind,omitempty" validate:"omitempty,dive,ip_addr" extensions:"x-nullable"` Root string `json:"root,omitempty"` SPA bool `json:"spa,omitempty"` // Single-page app mode: serves index for non-existent paths @@ -274,24 +273,17 @@ func (r *Route) validate() gperr.Error { var impl types.Route var err gperr.Error - switch r.Scheme { - case route.SchemeFileServer: - r.Host = "" - r.Port.Proxy = 0 - r.ProxyURL = gperr.Collect(&errs, nettypes.ParseURL, "file://"+r.Root) - case route.SchemeHTTP, route.SchemeHTTPS, route.SchemeH2C: - if r.Port.Listening != 0 { - errs.Addf("unexpected listening port for %s scheme", r.Scheme) - } + if r.ShouldExclude() { r.ProxyURL = gperr.Collect(&errs, nettypes.ParseURL, fmt.Sprintf("%s://%s", r.Scheme, net.JoinHostPort(r.Host, strconv.Itoa(r.Port.Proxy)))) - case route.SchemeTCP, route.SchemeUDP: - if r.ShouldExclude() { - // should exclude, we don't care the scheme here. + } else { + switch r.Scheme { + case route.SchemeFileServer: + r.LisURL = gperr.Collect(&errs, nettypes.ParseURL, fmt.Sprintf("https://%s", net.JoinHostPort(r.Bind, strconv.Itoa(r.Port.Listening)))) r.ProxyURL = gperr.Collect(&errs, nettypes.ParseURL, fmt.Sprintf("%s://%s", r.Scheme, net.JoinHostPort(r.Host, strconv.Itoa(r.Port.Proxy)))) - } else { - if r.Bind == "" { - r.Bind = "0.0.0.0" - } + case route.SchemeHTTP, route.SchemeHTTPS, route.SchemeH2C: + r.LisURL = gperr.Collect(&errs, nettypes.ParseURL, fmt.Sprintf("https://%s", net.JoinHostPort(r.Bind, strconv.Itoa(r.Port.Listening)))) + r.ProxyURL = gperr.Collect(&errs, nettypes.ParseURL, fmt.Sprintf("%s://%s", r.Scheme, net.JoinHostPort(r.Host, strconv.Itoa(r.Port.Proxy)))) + case route.SchemeTCP, route.SchemeUDP: bindIP := net.ParseIP(r.Bind) remoteIP := net.ParseIP(r.Host) toNetwork := func(ip net.IP, scheme route.Scheme) string { @@ -317,6 +309,12 @@ func (r *Route) validate() gperr.Error { } } + if r.Scheme == route.SchemeFileServer { + r.Host = "" + r.Port.Proxy = 0 + r.ProxyURL = gperr.Collect(&errs, nettypes.ParseURL, "file://"+r.Root) + } + if !r.UseHealthCheck() && (r.UseLoadBalance() || r.UseIdleWatcher()) { errs.Adds("cannot disable healthcheck when loadbalancer or idle watcher is enabled") } @@ -360,8 +358,8 @@ func (r *Route) validateRules() error { return errors.New("rule preset `webui.yml` not found") } r.Rules = rules + return nil } - return nil } if r.RuleFile != "" && len(r.Rules) > 0 { @@ -504,7 +502,7 @@ func (r *Route) start(parent task.Parent) gperr.Error { // skip checking for excluded routes excluded := r.ShouldExclude() if !excluded { - if err := checkExists(r); err != nil { + if err := checkExists(parent.Context(), r); err != nil { return err } } @@ -518,15 +516,19 @@ func (r *Route) start(parent task.Parent) gperr.Error { return err } } else { - r.task = parent.Subtask("excluded."+r.Name(), true) - routes.Excluded.Add(r.impl) + ep := entrypoint.FromCtx(parent.Context()) + + r.task = parent.Subtask("excluded."+r.Name(), false) + ep.ExcludedRoutes().Add(r.impl) r.task.OnCancel("remove_route_from_excluded", func() { - routes.Excluded.Del(r.impl) + ep.ExcludedRoutes().Del(r.impl) }) if r.UseHealthCheck() { r.HealthMon = monitor.NewMonitor(r.impl) err := r.HealthMon.Start(r.task) - return err + if err != nil { + return err + } } } return nil @@ -564,6 +566,10 @@ func (r *Route) ProviderName() string { return r.Provider } +func (r *Route) ListenURL() *nettypes.URL { + return r.LisURL +} + func (r *Route) TargetURL() *nettypes.URL { return r.ProxyURL } @@ -932,6 +938,20 @@ func (r *Route) Finalize() { } } + switch r.Scheme { + case route.SchemeTCP, route.SchemeUDP: + if r.Bind == "" { + r.Bind = "0.0.0.0" + } + default: + if r.Bind == "" { + r.Bind = common.ProxyHTTPSHost + } + if r.Port.Proxy == 0 { + r.Port.Proxy = common.ProxyHTTPSPort + } + } + r.Port.Listening, r.Port.Proxy = lp, pp workingState := config.WorkingState.Load() @@ -942,6 +962,7 @@ func (r *Route) Finalize() { panic("bug: working state is nil") } + // TODO: default value from context r.HealthCheck.ApplyDefaults(config.WorkingState.Load().Value().Defaults.HealthCheck) } diff --git a/internal/route/routes/README.md b/internal/route/routes/README.md deleted file mode 100644 index 16bf1a0a..00000000 --- a/internal/route/routes/README.md +++ /dev/null @@ -1,307 +0,0 @@ -# Route Registry - -Provides centralized route registry with O(1) lookups and route context management for HTTP handlers. - -## Overview - -The `internal/route/routes` package maintains the global route registry for GoDoxy. It provides thread-safe route lookups by alias, route iteration, and utilities for propagating route context through HTTP request handlers. - -### Primary Consumers - -- **HTTP handlers**: Lookup routes and extract request context -- **Route providers**: Register and unregister routes -- **Health system**: Query route health status -- **WebUI**: Display route information - -### Non-goals - -- Does not create or modify routes -- Does not handle route validation -- Does not implement routing logic (matching) - -### Stability - -Internal package with stable public API. - -## Public API - -### Route Pools - -```go -var ( - HTTP = pool.New[types.HTTPRoute]("http_routes") - Stream = pool.New[types.StreamRoute]("stream_routes") - Excluded = pool.New[types.Route]("excluded_routes") -) -``` - -Pool methods: - -- `Get(alias string) (T, bool)` - O(1) lookup -- `Add(r T)` - Register route -- `Del(r T)` - Unregister route -- `Size() int` - Route count -- `Clear()` - Remove all routes -- `Iter` - Channel-based iteration - -### Exported Functions - -```go -// Iterate over active routes (HTTP + Stream) -func IterActive(yield func(r types.Route) bool) - -// Iterate over all routes (HTTP + Stream + Excluded) -func IterAll(yield func(r types.Route) bool) - -// Get route count -func NumActiveRoutes() int -func NumAllRoutes() int - -// Clear all routes -func Clear() - -// Lookup functions -func Get(alias string) (types.Route, bool) -func GetHTTPRouteOrExact(alias, host string) (types.HTTPRoute, bool) -``` - -### Route Context - -```go -type RouteContext struct { - context.Context - Route types.HTTPRoute -} - -// Attach route to request context (uses unsafe pointer for performance) -func WithRouteContext(r *http.Request, route types.HTTPRoute) *http.Request - -// Extract route from request context -func TryGetRoute(r *http.Request) types.HTTPRoute -``` - -### Upstream Information - -```go -func TryGetUpstreamName(r *http.Request) string -func TryGetUpstreamScheme(r *http.Request) string -func TryGetUpstreamHost(r *http.Request) string -func TryGetUpstreamPort(r *http.Request) string -func TryGetUpstreamHostPort(r *http.Request) string -func TryGetUpstreamAddr(r *http.Request) string -func TryGetUpstreamURL(r *http.Request) string -``` - -### Health Information - -```go -type HealthInfo struct { - HealthInfoWithoutDetail - Detail string -} - -type HealthInfoWithoutDetail struct { - Status types.HealthStatus - Uptime time.Duration - Latency time.Duration -} - -func GetHealthInfo() map[string]HealthInfo -func GetHealthInfoWithoutDetail() map[string]HealthInfoWithoutDetail -func GetHealthInfoSimple() map[string]types.HealthStatus -``` - -### Provider Grouping - -```go -func ByProvider() map[string][]types.Route -``` - -## Proxmox Integration - -Routes can be automatically linked to Proxmox nodes or LXC containers through reverse lookup during validation. - -### Node-Level Routes - -Routes can be linked to a Proxmox node directly (VMID = 0) when the route's hostname, IP, or alias matches a node name or IP: - -```go -// Route linked to Proxmox node (no specific VM) -route.Proxmox = &proxmox.NodeConfig{ - Node: "pve-node-01", - VMID: 0, // node-level, no container - VMName: "", -} -``` - -### Container-Level Routes - -Routes are linked to LXC containers when they match a VM resource by hostname, IP, or alias: - -```go -// Route linked to LXC container -route.Proxmox = &proxmox.NodeConfig{ - Node: "pve-node-01", - VMID: 100, - VMName: "my-container", -} -``` - -### Lookup Priority - -1. **Node match** - If hostname, IP, or alias matches a Proxmox node -2. **VM match** - If hostname, IP, or alias matches a VM resource - -Node-level routes skip container control logic (start/check IPs) and can be used to proxy node services directly. - -## Architecture - -### Core Components - -```mermaid -classDiagram - class HTTP - class Stream - class Excluded - class RouteContext - - HTTP : +Get(alias) T - HTTP : +Add(r) - HTTP : +Del(r) - HTTP : +Size() int - HTTP : +Iter chan - - Stream : +Get(alias) T - Stream : +Add(r) - Stream : +Del(r) - - Excluded : +Get(alias) T - Excluded : +Add(r) - Excluded : +Del(r) -``` - -### Route Lookup Flow - -```mermaid -flowchart TD - A[Lookup Request] --> B{HTTP Pool} - B -->|Found| C[Return Route] - B -->|Not Found| D{Stream Pool} - D -->|Found| C - D -->|Not Found| E[Return nil] -``` - -### Context Propagation - -```mermaid -sequenceDiagram - participant H as HTTP Handler - participant R as Registry - participant C as RouteContext - - H->>R: WithRouteContext(req, route) - R->>C: Attach route via unsafe pointer - C-->>H: Modified request - - H->>R: TryGetRoute(req) - R->>C: Extract route from context - C-->>R: Route - R-->>H: Route -``` - -## Dependency and Integration Map - -| Dependency | Purpose | -| -------------------------------- | ---------------------------------- | -| `internal/types` | Route and health type definitions | -| `internal/proxmox` | Proxmox node/container integration | -| `github.com/yusing/goutils/pool` | Thread-safe pool implementation | - -## Observability - -### Logs - -Registry operations logged at DEBUG level: - -- Route add/remove -- Pool iteration -- Context operations - -### Performance - -- `WithRouteContext` uses `unsafe.Pointer` to avoid request cloning -- Route lookups are O(1) using internal maps -- Iteration uses channels for memory efficiency - -## Security Considerations - -- Route context propagation is internal to the process -- No sensitive data exposed in context keys -- Routes are validated before registration - -## Failure Modes and Recovery - -| Failure | Behavior | Recovery | -| ---------------------------------------- | ------------------------------ | -------------------- | -| Route not found | Returns (nil, false) | Verify route alias | -| Context extraction on non-route request | Returns nil | Check request origin | -| Concurrent modification during iteration | Handled by pool implementation | N/A | - -## Usage Examples - -### Basic Route Lookup - -```go -route, ok := routes.Get("myapp") -if !ok { - return fmt.Errorf("route not found") -} -``` - -### Iterating Over All Routes - -```go -for r := range routes.IterActive { - log.Printf("Route: %s", r.Name()) -} -``` - -### Getting Health Status - -```go -healthMap := routes.GetHealthInfo() -for name, health := range healthMap { - log.Printf("Route %s: %s (uptime: %v)", name, health.Status, health.Uptime) -} -``` - -### Using Route Context in Handler - -```go -func MyHandler(w http.ResponseWriter, r *http.Request) { - route := routes.TryGetRoute(r) - if route == nil { - http.Error(w, "Route not found", http.StatusNotFound) - return - } - - upstreamHost := routes.TryGetUpstreamHost(r) - log.Printf("Proxying to: %s", upstreamHost) -} -``` - -### Grouping Routes by Provider - -```go -byProvider := routes.ByProvider() -for providerName, routeList := range byProvider { - log.Printf("Provider %s: %d routes", providerName, len(routeList)) -} -``` - -## Testing Notes - -- Unit tests for pool thread safety -- Context propagation tests -- Health info aggregation tests -- Provider grouping tests diff --git a/internal/route/routes/query.go b/internal/route/routes/query.go deleted file mode 100644 index 50849c9a..00000000 --- a/internal/route/routes/query.go +++ /dev/null @@ -1,103 +0,0 @@ -package routes - -import ( - "time" - - "github.com/yusing/godoxy/internal/types" -) - -type HealthInfo struct { - HealthInfoWithoutDetail - Detail string `json:"detail"` -} // @name HealthInfo - -type HealthInfoWithoutDetail struct { - Status types.HealthStatus `json:"status" swaggertype:"string" enums:"healthy,unhealthy,napping,starting,error,unknown"` - Uptime time.Duration `json:"uptime" swaggertype:"number"` // uptime in milliseconds - Latency time.Duration `json:"latency" swaggertype:"number"` // latency in microseconds -} // @name HealthInfoWithoutDetail - -type HealthMap = map[string]types.HealthStatusString // @name HealthMap - -// GetHealthInfo returns a map of route name to health info. -// -// The health info is for all routes, including excluded routes. -func GetHealthInfo() map[string]HealthInfo { - healthMap := make(map[string]HealthInfo, NumAllRoutes()) - for r := range IterAll { - healthMap[r.Name()] = getHealthInfo(r) - } - return healthMap -} - -// GetHealthInfoWithoutDetail returns a map of route name to health info without detail. -// -// The health info is for all routes, including excluded routes. -func GetHealthInfoWithoutDetail() map[string]HealthInfoWithoutDetail { - healthMap := make(map[string]HealthInfoWithoutDetail, NumAllRoutes()) - for r := range IterAll { - healthMap[r.Name()] = getHealthInfoWithoutDetail(r) - } - return healthMap -} - -func GetHealthInfoSimple() map[string]types.HealthStatus { - healthMap := make(map[string]types.HealthStatus, NumAllRoutes()) - for r := range IterAll { - healthMap[r.Name()] = getHealthInfoSimple(r) - } - return healthMap -} - -func getHealthInfo(r types.Route) HealthInfo { - mon := r.HealthMonitor() - if mon == nil { - return HealthInfo{ - HealthInfoWithoutDetail: HealthInfoWithoutDetail{ - Status: types.StatusUnknown, - }, - Detail: "n/a", - } - } - return HealthInfo{ - HealthInfoWithoutDetail: HealthInfoWithoutDetail{ - Status: mon.Status(), - Uptime: mon.Uptime(), - Latency: mon.Latency(), - }, - Detail: mon.Detail(), - } -} - -func getHealthInfoWithoutDetail(r types.Route) HealthInfoWithoutDetail { - mon := r.HealthMonitor() - if mon == nil { - return HealthInfoWithoutDetail{ - Status: types.StatusUnknown, - } - } - return HealthInfoWithoutDetail{ - Status: mon.Status(), - Uptime: mon.Uptime(), - Latency: mon.Latency(), - } -} - -func getHealthInfoSimple(r types.Route) types.HealthStatus { - mon := r.HealthMonitor() - if mon == nil { - return types.StatusUnknown - } - return mon.Status() -} - -// ByProvider returns a map of provider name to routes. -// -// The routes are all routes, including excluded routes. -func ByProvider() map[string][]types.Route { - rts := make(map[string][]types.Route) - for r := range IterAll { - rts[r.ProviderName()] = append(rts[r.ProviderName()], r) - } - return rts -} diff --git a/internal/route/routes/routes.go b/internal/route/routes/routes.go deleted file mode 100644 index fc8d39d9..00000000 --- a/internal/route/routes/routes.go +++ /dev/null @@ -1,91 +0,0 @@ -package routes - -import ( - "github.com/yusing/godoxy/internal/types" - "github.com/yusing/goutils/pool" -) - -var ( - HTTP = pool.New[types.HTTPRoute]("http_routes") - Stream = pool.New[types.StreamRoute]("stream_routes") - - Excluded = pool.New[types.Route]("excluded_routes") -) - -func IterActive(yield func(r types.Route) bool) { - for _, r := range HTTP.Iter { - if !yield(r) { - break - } - } - for _, r := range Stream.Iter { - if !yield(r) { - break - } - } -} - -func IterAll(yield func(r types.Route) bool) { - for _, r := range HTTP.Iter { - if !yield(r) { - break - } - } - for _, r := range Stream.Iter { - if !yield(r) { - break - } - } - for _, r := range Excluded.Iter { - if !yield(r) { - break - } - } -} - -func NumActiveRoutes() int { - return HTTP.Size() + Stream.Size() -} - -func NumAllRoutes() int { - return HTTP.Size() + Stream.Size() + Excluded.Size() -} - -func Clear() { - HTTP.Clear() - Stream.Clear() - Excluded.Clear() -} - -func GetHTTPRouteOrExact(alias, host string) (types.HTTPRoute, bool) { - r, ok := HTTP.Get(alias) - if ok { - return r, true - } - // try find with exact match - return HTTP.Get(host) -} - -// Get returns the route with the given alias. -// -// It does not return excluded routes. -func Get(alias string) (types.Route, bool) { - if r, ok := HTTP.Get(alias); ok { - return r, true - } - if r, ok := Stream.Get(alias); ok { - return r, true - } - return nil, false -} - -// GetIncludeExcluded returns the route with the given alias, including excluded routes. -func GetIncludeExcluded(alias string) (types.Route, bool) { - if r, ok := HTTP.Get(alias); ok { - return r, true - } - if r, ok := Stream.Get(alias); ok { - return r, true - } - return Excluded.Get(alias) -} diff --git a/internal/route/rules/do.go b/internal/route/rules/do.go index f0343d0c..8c40e9b6 100644 --- a/internal/route/rules/do.go +++ b/internal/route/rules/do.go @@ -15,7 +15,6 @@ import ( nettypes "github.com/yusing/godoxy/internal/net/types" "github.com/yusing/godoxy/internal/notif" "github.com/yusing/godoxy/internal/route/routes" - "github.com/yusing/godoxy/internal/types" gperr "github.com/yusing/goutils/errs" httputils "github.com/yusing/goutils/http" "github.com/yusing/goutils/http/reverseproxy" @@ -195,20 +194,23 @@ var commands = map[string]struct { return args[0], nil }, build: func(args any) CommandHandler { - route := args.(string) + // route := args.(string) return TerminatingCommand(func(w http.ResponseWriter, req *http.Request) error { - r, ok := routes.HTTP.Get(route) - if !ok { - excluded, has := routes.Excluded.Get(route) - if has { - r, ok = excluded.(types.HTTPRoute) - } - } - if ok { - r.ServeHTTP(w, req) - } else { - http.Error(w, fmt.Sprintf("Route %q not found", route), http.StatusNotFound) - } + + // FIXME: circular dependency + // ep := entrypoint.FromCtx(req.Context()) + // r, ok := ep.HTTPRoutes().Get(route) + // if !ok { + // excluded, has := ep.ExcludedRoutes().Get(route) + // if has { + // r, ok = excluded.(types.HTTPRoute) + // } + // } + // if ok { + // r.ServeHTTP(w, req) + // } else { + // http.Error(w, fmt.Sprintf("Route %q not found", route), http.StatusNotFound) + // } return nil }) }, diff --git a/internal/route/stream.go b/internal/route/stream.go index a9cd14ca..57703f72 100755 --- a/internal/route/stream.go +++ b/internal/route/stream.go @@ -8,10 +8,10 @@ import ( "github.com/rs/zerolog" "github.com/rs/zerolog/log" + entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" "github.com/yusing/godoxy/internal/health/monitor" "github.com/yusing/godoxy/internal/idlewatcher" nettypes "github.com/yusing/godoxy/internal/net/types" - "github.com/yusing/godoxy/internal/route/routes" "github.com/yusing/godoxy/internal/route/stream" "github.com/yusing/godoxy/internal/types" gperr "github.com/yusing/goutils/errs" @@ -65,6 +65,7 @@ func (r *StreamRoute) Start(parent task.Parent) gperr.Error { if r.HealthMon != nil { if err := r.HealthMon.Start(r.task); err != nil { gperr.LogWarn("health monitor error", err, &r.l) + r.HealthMon = nil } } @@ -81,10 +82,7 @@ func (r *StreamRoute) Start(parent task.Parent) gperr.Error { r.l.Info().Msg("stream closed") }) - routes.Stream.Add(r) - r.task.OnCancel("remove_route_from_stream", func() { - routes.Stream.Del(r) - }) + entrypoint.FromCtx(parent.Context()).AddRoute(r) return nil } diff --git a/internal/route/stream/tcp_tcp.go b/internal/route/stream/tcp_tcp.go index f9ecd743..d84a166f 100644 --- a/internal/route/stream/tcp_tcp.go +++ b/internal/route/stream/tcp_tcp.go @@ -7,9 +7,9 @@ import ( "github.com/pires/go-proxyproto" "github.com/rs/zerolog" "github.com/rs/zerolog/log" - "github.com/yusing/godoxy/internal/acl" + acl "github.com/yusing/godoxy/internal/acl/types" "github.com/yusing/godoxy/internal/agentpool" - "github.com/yusing/godoxy/internal/entrypoint" + entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" nettypes "github.com/yusing/godoxy/internal/net/types" ioutils "github.com/yusing/goutils/io" "go.uber.org/atomic" @@ -51,12 +51,14 @@ func (s *TCPTCPStream) ListenAndServe(ctx context.Context, preDial, onRead netty return } - if acl, ok := ctx.Value(acl.ContextKey{}).(*acl.Config); ok { + // TODO: add to entrypoint + + if acl := acl.FromCtx(ctx); acl != nil { log.Debug().Str("listener", s.listener.Addr().String()).Msg("wrapping listener with ACL") s.listener = acl.WrapTCP(s.listener) } - if proxyProto := entrypoint.ActiveConfig.Load().SupportProxyProtocol; proxyProto { + if proxyProto := entrypoint.FromCtx(ctx).Config().SupportProxyProtocol; proxyProto { s.listener = &proxyproto.Listener{Listener: s.listener} } diff --git a/internal/route/stream/udp_udp.go b/internal/route/stream/udp_udp.go index 65972cc8..00aa2a61 100644 --- a/internal/route/stream/udp_udp.go +++ b/internal/route/stream/udp_udp.go @@ -11,7 +11,7 @@ import ( "github.com/rs/zerolog" "github.com/rs/zerolog/log" - "github.com/yusing/godoxy/internal/acl" + acl "github.com/yusing/godoxy/internal/acl/types" "github.com/yusing/godoxy/internal/agentpool" nettypes "github.com/yusing/godoxy/internal/net/types" "github.com/yusing/goutils/synk" @@ -82,10 +82,11 @@ func (s *UDPUDPStream) ListenAndServe(ctx context.Context, preDial, onRead netty return } s.listener = l - if acl, ok := ctx.Value(acl.ContextKey{}).(*acl.Config); ok { + if acl := acl.FromCtx(ctx); acl != nil { log.Debug().Str("listener", s.listener.LocalAddr().String()).Msg("wrapping listener with ACL") s.listener = acl.WrapUDP(s.listener) } + // TODO: add to entrypoint s.preDial = preDial s.onRead = onRead go s.listen(ctx) diff --git a/internal/types/health.go b/internal/types/health.go index be45b3f5..47c9a0ed 100644 --- a/internal/types/health.go +++ b/internal/types/health.go @@ -74,6 +74,19 @@ type ( Config *LoadBalancerConfig `json:"config"` Pool map[string]any `json:"pool"` } // @name HealthExtra + + HealthInfoWithoutDetail struct { + Status HealthStatus `json:"status" swaggertype:"string" enums:"healthy,unhealthy,napping,starting,error,unknown"` + Uptime time.Duration `json:"uptime" swaggertype:"number"` // uptime in milliseconds + Latency time.Duration `json:"latency" swaggertype:"number"` // latency in microseconds + } // @name HealthInfoWithoutDetail + + HealthInfo struct { + HealthInfoWithoutDetail + Detail string `json:"detail"` + } // @name HealthInfo + + HealthMap = map[string]HealthStatusString // @name HealthMap ) const ( diff --git a/internal/types/routes.go b/internal/types/routes.go index 2d63215a..7b3bbb1f 100644 --- a/internal/types/routes.go +++ b/internal/types/routes.go @@ -20,6 +20,7 @@ type ( pool.Object ProviderName() string GetProvider() RouteProvider + ListenURL() *nettypes.URL TargetURL() *nettypes.URL HealthMonitor() HealthMonitor SetHealthMonitor(m HealthMonitor)