refactor(entrypoint): move route registry into entrypoint context

Replace global routes registry with entrypoint-scoped pools and
context lookups, and centralize API/metrics startup in config state.
This commit is contained in:
yusing
2026-02-06 00:23:12 +08:00
parent bd49f1b348
commit f9ee33f464
47 changed files with 916 additions and 835 deletions

View File

@@ -1,12 +1,12 @@
package main package main
import ( import (
"errors"
"os" "os"
"sync" "sync"
"time" "time"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/yusing/godoxy/internal/api"
"github.com/yusing/godoxy/internal/auth" "github.com/yusing/godoxy/internal/auth"
"github.com/yusing/godoxy/internal/common" "github.com/yusing/godoxy/internal/common"
"github.com/yusing/godoxy/internal/config" "github.com/yusing/godoxy/internal/config"
@@ -14,12 +14,9 @@ import (
iconlist "github.com/yusing/godoxy/internal/homepage/icons/list" iconlist "github.com/yusing/godoxy/internal/homepage/icons/list"
"github.com/yusing/godoxy/internal/logging" "github.com/yusing/godoxy/internal/logging"
"github.com/yusing/godoxy/internal/logging/memlogger" "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/net/gphttp/middleware"
"github.com/yusing/godoxy/internal/route/rules" "github.com/yusing/godoxy/internal/route/rules"
gperr "github.com/yusing/goutils/errs" gperr "github.com/yusing/goutils/errs"
"github.com/yusing/goutils/server"
"github.com/yusing/goutils/task" "github.com/yusing/goutils/task"
"github.com/yusing/goutils/version" "github.com/yusing/goutils/version"
) )
@@ -51,7 +48,6 @@ func main() {
parallel( parallel(
dnsproviders.InitProviders, dnsproviders.InitProviders,
iconlist.InitCache, iconlist.InitCache,
systeminfo.Poller.Start,
middleware.LoadComposeFiles, middleware.LoadComposeFiles,
) )
@@ -73,32 +69,13 @@ func main() {
gperr.LogWarn("errors in config", err) gperr.LogWarn("errors in config", err)
} }
config.StartProxyServers()
if err := auth.Initialize(); err != nil { if err := auth.Initialize(); err != nil {
log.Fatal().Err(err).Msg("failed to initialize authentication") log.Fatal().Err(err).Msg("failed to initialize authentication")
} }
rules.InitAuthHandler(auth.AuthOrProceed) 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() listenDebugServer()
uptime.Poller.Start()
config.WatchChanges() config.WatchChanges()
close(done) close(done)

Submodule goutils updated: 52ea531e95...a270ef85af

View File

@@ -74,8 +74,6 @@ type ipLog struct {
allowed bool allowed bool
} }
type ContextKey struct{}
const cacheTTL = 1 * time.Minute const cacheTTL = 1 * time.Minute
func (c *checkCache) Expired() bool { func (c *checkCache) Expired() bool {

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -10,7 +10,7 @@ import (
"github.com/moby/moby/api/types/container" "github.com/moby/moby/api/types/container"
"github.com/moby/moby/client" "github.com/moby/moby/client"
"github.com/yusing/godoxy/internal/docker" "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" "github.com/yusing/godoxy/internal/types"
apitypes "github.com/yusing/goutils/apitypes" apitypes "github.com/yusing/goutils/apitypes"
"github.com/yusing/goutils/http/httpheaders" "github.com/yusing/goutils/http/httpheaders"
@@ -44,7 +44,7 @@ func Stats(c *gin.Context) {
dockerCfg, ok := docker.GetDockerCfgByContainerID(id) dockerCfg, ok := docker.GetDockerCfgByContainerID(id)
if !ok { if !ok {
var route types.Route var route types.Route
route, ok = routes.GetIncludeExcluded(id) route, ok = entrypoint.FromCtx(c.Request.Context()).GetRoute(id)
if ok { if ok {
cont := route.ContainerInfo() cont := route.ContainerInfo()
if cont == nil { if cont == nil {

View File

@@ -5,9 +5,9 @@ import (
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
entrypoint "github.com/yusing/godoxy/internal/entrypoint/types"
"github.com/yusing/godoxy/internal/homepage/icons" "github.com/yusing/godoxy/internal/homepage/icons"
iconfetch "github.com/yusing/godoxy/internal/homepage/icons/fetch" iconfetch "github.com/yusing/godoxy/internal/homepage/icons/fetch"
"github.com/yusing/godoxy/internal/route/routes"
apitypes "github.com/yusing/goutils/apitypes" apitypes "github.com/yusing/goutils/apitypes"
_ "unsafe" _ "unsafe"
@@ -73,7 +73,8 @@ func FavIcon(c *gin.Context) {
//go:linkname GetFavIconFromAlias v1.GetFavIconFromAlias //go:linkname GetFavIconFromAlias v1.GetFavIconFromAlias
func GetFavIconFromAlias(ctx context.Context, alias string, variant icons.Variant) (iconfetch.Result, error) { func GetFavIconFromAlias(ctx context.Context, alias string, variant icons.Variant) (iconfetch.Result, error) {
// try with route.Icon // try with route.Icon
r, ok := routes.HTTP.Get(alias) ep := entrypoint.FromCtx(ctx)
r, ok := ep.HTTPRoutes().Get(alias)
if !ok { if !ok {
return iconfetch.FetchResultWithErrorf(http.StatusNotFound, "route not found") return iconfetch.FetchResultWithErrorf(http.StatusNotFound, "route not found")
} }

View File

@@ -5,7 +5,7 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "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/httpheaders"
"github.com/yusing/goutils/http/websocket" "github.com/yusing/goutils/http/websocket"
@@ -24,11 +24,12 @@ import (
// @Failure 500 {object} apitypes.ErrorResponse // @Failure 500 {object} apitypes.ErrorResponse
// @Router /health [get] // @Router /health [get]
func Health(c *gin.Context) { func Health(c *gin.Context) {
ep := entrypoint.FromCtx(c.Request.Context())
if httpheaders.IsWebsocket(c.Request.Header) { if httpheaders.IsWebsocket(c.Request.Header) {
websocket.PeriodicWrite(c, 1*time.Second, func() (any, error) { websocket.PeriodicWrite(c, 1*time.Second, func() (any, error) {
return routes.GetHealthInfoSimple(), nil return ep.GetHealthInfoSimple(), nil
}) })
} else { } else {
c.JSON(http.StatusOK, routes.GetHealthInfoSimple()) c.JSON(http.StatusOK, ep.GetHealthInfoSimple())
} }
} }

View File

@@ -4,8 +4,8 @@ import (
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
entrypoint "github.com/yusing/godoxy/internal/entrypoint/types"
"github.com/yusing/godoxy/internal/homepage" "github.com/yusing/godoxy/internal/homepage"
"github.com/yusing/godoxy/internal/route/routes"
_ "github.com/yusing/goutils/apitypes" _ "github.com/yusing/goutils/apitypes"
) )
@@ -21,15 +21,16 @@ import (
// @Failure 403 {object} apitypes.ErrorResponse // @Failure 403 {object} apitypes.ErrorResponse
// @Router /homepage/categories [get] // @Router /homepage/categories [get]
func Categories(c *gin.Context) { 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{}) check := make(map[string]struct{})
categories := make([]string, 0) categories := make([]string, 0)
categories = append(categories, homepage.CategoryAll) categories = append(categories, homepage.CategoryAll)
categories = append(categories, homepage.CategoryFavorites) categories = append(categories, homepage.CategoryFavorites)
for _, r := range routes.HTTP.Iter { for _, r := range ep.HTTPRoutes().Iter {
item := r.HomepageItem() item := r.HomepageItem()
if item.Category == "" { if item.Category == "" {
continue continue

View File

@@ -10,8 +10,8 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/lithammer/fuzzysearch/fuzzy" "github.com/lithammer/fuzzysearch/fuzzy"
entrypoint "github.com/yusing/godoxy/internal/entrypoint/types"
"github.com/yusing/godoxy/internal/homepage" "github.com/yusing/godoxy/internal/homepage"
"github.com/yusing/godoxy/internal/route/routes"
apitypes "github.com/yusing/goutils/apitypes" apitypes "github.com/yusing/goutils/apitypes"
"github.com/yusing/goutils/http/httpheaders" "github.com/yusing/goutils/http/httpheaders"
"github.com/yusing/goutils/http/websocket" "github.com/yusing/goutils/http/websocket"
@@ -53,29 +53,30 @@ func Items(c *gin.Context) {
hostname = host hostname = host
} }
ep := entrypoint.FromCtx(c.Request.Context())
if httpheaders.IsWebsocket(c.Request.Header) { if httpheaders.IsWebsocket(c.Request.Header) {
websocket.PeriodicWrite(c, 2*time.Second, func() (any, error) { websocket.PeriodicWrite(c, 2*time.Second, func() (any, error) {
return HomepageItems(proto, hostname, &request), nil return HomepageItems(ep, proto, hostname, &request), nil
}) })
} else { } 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 { switch proto {
case "http", "https": case "http", "https":
default: default:
proto = "http" proto = "http"
} }
hp := homepage.NewHomepageMap(routes.HTTP.Size()) hp := homepage.NewHomepageMap(ep.HTTPRoutes().Size())
if strings.Count(hostname, ".") > 1 { if strings.Count(hostname, ".") > 1 {
_, hostname, _ = strings.Cut(hostname, ".") // remove the subdomain _, 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 { if request.Provider != "" && r.ProviderName() != request.Provider {
continue continue
} }

View File

@@ -4,8 +4,8 @@ import (
"net/http" "net/http"
"github.com/gin-gonic/gin" "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"
"github.com/yusing/godoxy/internal/route/routes"
_ "github.com/yusing/goutils/apitypes" _ "github.com/yusing/goutils/apitypes"
) )
@@ -24,5 +24,6 @@ type RoutesByProvider map[string][]route.Route
// @Failure 500 {object} apitypes.ErrorResponse // @Failure 500 {object} apitypes.ErrorResponse
// @Router /route/by_provider [get] // @Router /route/by_provider [get]
func ByProvider(c *gin.Context) { func ByProvider(c *gin.Context) {
c.JSON(http.StatusOK, routes.ByProvider()) ep := entrypoint.FromCtx(c.Request.Context())
c.JSON(http.StatusOK, ep.RoutesByProvider())
} }

View File

@@ -4,7 +4,7 @@ import (
"net/http" "net/http"
"github.com/gin-gonic/gin" "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" apitypes "github.com/yusing/goutils/apitypes"
) )
@@ -32,7 +32,8 @@ func Route(c *gin.Context) {
return return
} }
route, ok := routes.GetIncludeExcluded(request.Which) ep := entrypoint.FromCtx(c.Request.Context())
route, ok := ep.GetRoute(request.Which)
if ok { if ok {
c.JSON(http.StatusOK, route) c.JSON(http.StatusOK, route)
return return

View File

@@ -6,8 +6,8 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "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"
"github.com/yusing/godoxy/internal/route/routes"
"github.com/yusing/godoxy/internal/types" "github.com/yusing/godoxy/internal/types"
"github.com/yusing/goutils/http/httpheaders" "github.com/yusing/goutils/http/httpheaders"
"github.com/yusing/goutils/http/websocket" "github.com/yusing/goutils/http/websocket"
@@ -32,14 +32,16 @@ func Routes(c *gin.Context) {
return return
} }
ep := entrypoint.FromCtx(c.Request.Context())
provider := c.Query("provider") provider := c.Query("provider")
if provider == "" { if provider == "" {
c.JSON(http.StatusOK, slices.Collect(routes.IterAll)) c.JSON(http.StatusOK, slices.Collect(ep.IterRoutes))
return return
} }
rts := make([]types.Route, 0, routes.NumAllRoutes()) rts := make([]types.Route, 0, ep.NumRoutes())
for r := range routes.IterAll { for r := range ep.IterRoutes {
if r.ProviderName() == provider { if r.ProviderName() == provider {
rts = append(rts, r) rts = append(rts, r)
} }
@@ -48,17 +50,19 @@ func Routes(c *gin.Context) {
} }
func RoutesWS(c *gin.Context) { func RoutesWS(c *gin.Context) {
ep := entrypoint.FromCtx(c.Request.Context())
provider := c.Query("provider") provider := c.Query("provider")
if provider == "" { if provider == "" {
websocket.PeriodicWrite(c, 3*time.Second, func() (any, error) { websocket.PeriodicWrite(c, 3*time.Second, func() (any, error) {
return slices.Collect(routes.IterAll), nil return slices.Collect(ep.IterRoutes), nil
}) })
return return
} }
websocket.PeriodicWrite(c, 3*time.Second, func() (any, error) { websocket.PeriodicWrite(c, 3*time.Second, func() (any, error) {
rts := make([]types.Route, 0, routes.NumAllRoutes()) rts := make([]types.Route, 0, ep.NumRoutes())
for r := range routes.IterAll { for r := range ep.IterRoutes {
if r.ProviderName() == provider { if r.ProviderName() == provider {
rts = append(rts, r) rts = append(rts, r)
} }

View File

@@ -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
}

View File

@@ -7,7 +7,6 @@ import (
) )
type Provider interface { type Provider interface {
Setup() error
GetCert(*tls.ClientHelloInfo) (*tls.Certificate, error) GetCert(*tls.ClientHelloInfo) (*tls.Certificate, error)
ScheduleRenewalAll(task.Parent) ScheduleRenewalAll(task.Parent)
ObtainCertAll() error ObtainCertAll() error

View File

@@ -10,11 +10,9 @@ import (
"github.com/yusing/godoxy/internal/common" "github.com/yusing/godoxy/internal/common"
config "github.com/yusing/godoxy/internal/config/types" config "github.com/yusing/godoxy/internal/config/types"
"github.com/yusing/godoxy/internal/notif" "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"
"github.com/yusing/godoxy/internal/watcher/events" "github.com/yusing/godoxy/internal/watcher/events"
gperr "github.com/yusing/goutils/errs" gperr "github.com/yusing/goutils/errs"
"github.com/yusing/goutils/server"
"github.com/yusing/goutils/strings/ansi" "github.com/yusing/goutils/strings/ansi"
"github.com/yusing/goutils/task" "github.com/yusing/goutils/task"
) )
@@ -71,19 +69,19 @@ func Load() error {
} }
// disable pool logging temporary since we already have pretty logging // disable pool logging temporary since we already have pretty logging
routes.HTTP.DisableLog(true) state.Entrypoint().DisablePoolsLog(true)
routes.Stream.DisableLog(true)
defer func() { defer func() {
routes.HTTP.DisableLog(false) state.Entrypoint().DisablePoolsLog(false)
routes.Stream.DisableLog(false)
}() }()
initErr := state.InitFromFile(common.ConfigPath)
err := errors.Join(initErr, state.StartProviders()) err := errors.Join(initErr, state.StartProviders())
if err != nil { if err != nil {
logNotifyError("init", err) logNotifyError("init", err)
} }
state.StartAPIServers()
state.StartMetrics()
SetState(state) SetState(state)
// flush temporary log // flush temporary log
@@ -118,7 +116,9 @@ func Reload() gperr.Error {
logNotifyError("start providers", err) logNotifyError("start providers", err)
return nil // continue return nil // continue
} }
StartProxyServers()
newState.StartAPIServers()
newState.StartMetrics()
return nil return nil
} }
@@ -152,16 +152,3 @@ func OnConfigChange(ev []events.Event) {
panic(err) 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,
})
}

View File

@@ -9,7 +9,6 @@ import (
"fmt" "fmt"
"io/fs" "io/fs"
"iter" "iter"
"net/http"
"os" "os"
"strconv" "strconv"
"strings" "strings"
@@ -18,14 +17,20 @@ import (
"github.com/goccy/go-yaml" "github.com/goccy/go-yaml"
"github.com/puzpuzpuz/xsync/v4" "github.com/puzpuzpuz/xsync/v4"
"github.com/rs/zerolog" "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/agentpool"
"github.com/yusing/godoxy/internal/api"
"github.com/yusing/godoxy/internal/autocert" "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" config "github.com/yusing/godoxy/internal/config/types"
"github.com/yusing/godoxy/internal/entrypoint" "github.com/yusing/godoxy/internal/entrypoint"
entrypointctx "github.com/yusing/godoxy/internal/entrypoint/types"
homepage "github.com/yusing/godoxy/internal/homepage/types" homepage "github.com/yusing/godoxy/internal/homepage/types"
"github.com/yusing/godoxy/internal/logging" "github.com/yusing/godoxy/internal/logging"
"github.com/yusing/godoxy/internal/maxmind" "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" "github.com/yusing/godoxy/internal/notif"
route "github.com/yusing/godoxy/internal/route/provider" route "github.com/yusing/godoxy/internal/route/provider"
"github.com/yusing/godoxy/internal/serialization" "github.com/yusing/godoxy/internal/serialization"
@@ -40,7 +45,7 @@ type state struct {
providers *xsync.Map[string, types.RouteProvider] providers *xsync.Map[string, types.RouteProvider]
autocertProvider *autocert.Provider autocertProvider *autocert.Provider
entrypoint entrypoint.Entrypoint entrypoint *entrypoint.Entrypoint
task *task.Task task *task.Task
@@ -65,11 +70,10 @@ func (e CriticalError) Unwrap() error {
func NewState() config.State { func NewState() config.State {
tmpLogBuf := bytes.NewBuffer(make([]byte, 0, 4096)) tmpLogBuf := bytes.NewBuffer(make([]byte, 0, 4096))
return &state{ return &state{
providers: xsync.NewMap[string, types.RouteProvider](), providers: xsync.NewMap[string, types.RouteProvider](),
entrypoint: entrypoint.NewEntrypoint(), task: task.RootTask("config", false),
task: task.RootTask("config", false), tmpLogBuf: tmpLogBuf,
tmpLogBuf: tmpLogBuf, tmpLog: logging.NewLoggerWithFixedLevel(zerolog.InfoLevel, tmpLogBuf),
tmpLog: logging.NewLoggerWithFixedLevel(zerolog.InfoLevel, tmpLogBuf),
} }
} }
@@ -85,7 +89,6 @@ func SetState(state config.State) {
cfg := state.Value() cfg := state.Value()
config.ActiveState.Store(state) config.ActiveState.Store(state)
entrypoint.ActiveConfig.Store(&cfg.Entrypoint)
homepage.ActiveConfig.Store(&cfg.Homepage) homepage.ActiveConfig.Store(&cfg.Homepage)
if autocertProvider := state.AutoCertProvider(); autocertProvider != nil { if autocertProvider := state.AutoCertProvider(); autocertProvider != nil {
autocert.ActiveProvider.Store(autocertProvider.(*autocert.Provider)) autocert.ActiveProvider.Store(autocertProvider.(*autocert.Provider))
@@ -148,8 +151,8 @@ func (state *state) Value() *config.Config {
return &state.Config return &state.Config
} }
func (state *state) EntrypointHandler() http.Handler { func (state *state) Entrypoint() entrypointctx.Entrypoint {
return &state.entrypoint return state.entrypoint
} }
func (state *state) ShortLinkMatcher() config.ShortLinkMatcher { func (state *state) ShortLinkMatcher() config.ShortLinkMatcher {
@@ -204,6 +207,29 @@ func (state *state) FlushTmpLog() {
state.tmpLogBuf.Reset() 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. // initACL initializes the ACL.
func (state *state) initACL() error { func (state *state) initACL() error {
if !state.ACL.Valid() { if !state.ACL.Valid() {
@@ -213,7 +239,7 @@ func (state *state) initACL() error {
if err != nil { if err != nil {
return err return err
} }
state.task.SetValue(acl.ContextKey{}, state.ACL) acl.SetCtx(state.task, state.ACL)
return nil return nil
} }
@@ -221,6 +247,7 @@ func (state *state) initEntrypoint() error {
epCfg := state.Config.Entrypoint epCfg := state.Config.Entrypoint
matchDomains := state.MatchDomains matchDomains := state.MatchDomains
state.entrypoint = entrypoint.NewEntrypoint(state.task, &epCfg)
state.entrypoint.SetFindRouteDomains(matchDomains) state.entrypoint.SetFindRouteDomains(matchDomains)
state.entrypoint.SetNotFoundRules(epCfg.Rules.NotFound) 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 := gperr.NewBuilder("entrypoint error")
errs.Add(state.entrypoint.SetMiddlewares(epCfg.Middlewares)) errs.Add(state.entrypoint.SetMiddlewares(epCfg.Middlewares))
errs.Add(state.entrypoint.SetAccessLogger(state.task, epCfg.AccessLog)) errs.Add(state.entrypoint.SetAccessLogger(state.task, epCfg.AccessLog))
@@ -310,6 +339,7 @@ func (state *state) initAutoCert() error {
p.PrintCertExpiriesAll() p.PrintCertExpiriesAll()
state.autocertProvider = p state.autocertProvider = p
autocertctx.SetCtx(state.task, p)
return nil return nil
} }

View File

@@ -6,6 +6,7 @@ import (
"iter" "iter"
"net/http" "net/http"
entrypoint "github.com/yusing/godoxy/internal/entrypoint/types"
"github.com/yusing/godoxy/internal/types" "github.com/yusing/godoxy/internal/types"
"github.com/yusing/goutils/server" "github.com/yusing/goutils/server"
"github.com/yusing/goutils/synk" "github.com/yusing/goutils/synk"
@@ -21,7 +22,7 @@ type State interface {
Value() *Config Value() *Config
EntrypointHandler() http.Handler Entrypoint() entrypoint.Entrypoint
ShortLinkMatcher() ShortLinkMatcher ShortLinkMatcher() ShortLinkMatcher
AutoCertProvider() server.CertProvider AutoCertProvider() server.CertProvider
@@ -32,6 +33,9 @@ type State interface {
StartProviders() error StartProviders() error
FlushTmpLog() FlushTmpLog()
StartAPIServers()
StartMetrics()
} }
type ShortLinkMatcher interface { type ShortLinkMatcher interface {

View File

@@ -1,49 +1,131 @@
package entrypoint package entrypoint
import ( import (
"net"
"net/http" "net/http"
"strings" "strings"
"sync/atomic" "sync/atomic"
"github.com/puzpuzpuz/xsync/v4"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/yusing/godoxy/internal/common"
entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" entrypoint "github.com/yusing/godoxy/internal/entrypoint/types"
"github.com/yusing/godoxy/internal/logging/accesslog" "github.com/yusing/godoxy/internal/logging/accesslog"
"github.com/yusing/godoxy/internal/net/gphttp/middleware" "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/route/rules"
"github.com/yusing/godoxy/internal/types" "github.com/yusing/godoxy/internal/types"
gperr "github.com/yusing/goutils/errs"
"github.com/yusing/goutils/pool"
"github.com/yusing/goutils/task" "github.com/yusing/goutils/task"
) )
type HTTPRoutes interface {
Get(alias string) (types.HTTPRoute, bool)
}
type findRouteFunc func(HTTPRoutes, string) types.HTTPRoute
type Entrypoint struct { type Entrypoint struct {
middleware *middleware.Middleware task *task.Task
notFoundHandler http.Handler
accessLogger accesslog.AccessLogger cfg *entrypoint.Config
findRouteFunc func(host string) types.HTTPRoute
shortLinkMatcher *ShortLinkMatcher 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 _ entrypoint.Entrypoint = &Entrypoint{}
var ActiveConfig atomic.Pointer[entrypoint.Config]
func init() { var emptyCfg entrypoint.Config
// make sure it's not nil
ActiveConfig.Store(&entrypoint.Config{})
}
func NewEntrypoint() Entrypoint { func NewEntrypoint(parent task.Parent, cfg *entrypoint.Config) *Entrypoint {
return Entrypoint{ if cfg == nil {
findRouteFunc: findRouteAnyDomain, cfg = &emptyCfg
shortLinkMatcher: newShortLinkMatcher(),
} }
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 { func (ep *Entrypoint) ShortLinkMatcher() *ShortLinkMatcher {
return ep.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) { func (ep *Entrypoint) SetFindRouteDomains(domains []string) {
if len(domains) == 0 { if len(domains) == 0 {
ep.findRouteFunc = findRouteAnyDomain ep.findRouteFunc = findRouteAnyDomain
@@ -74,7 +156,7 @@ func (ep *Entrypoint) SetMiddlewares(mws []map[string]any) error {
} }
func (ep *Entrypoint) SetNotFoundRules(rules rules.Rules) { 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) { 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 return err
} }
func (ep *Entrypoint) FindRoute(s string) types.HTTPRoute { func findRouteAnyDomain(routes HTTPRoutes, host 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 {
idx := strings.IndexByte(host, '.') idx := strings.IndexByte(host, '.')
if idx != -1 { if idx != -1 {
target := host[:idx] target := host[:idx]
if r, ok := routes.HTTP.Get(target); ok { if r, ok := routes.Get(target); ok {
return r return r
} }
} }
if r, ok := routes.HTTP.Get(host); ok { if r, ok := routes.Get(host); ok {
return r return r
} }
// try striping the trailing :port from the host // try striping the trailing :port from the host
if before, _, ok := strings.Cut(host, ":"); ok { 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 r
} }
} }
return nil return nil
} }
func findRouteByDomains(domains []string) func(host string) types.HTTPRoute { func findRouteByDomains(domains []string) func(routes HTTPRoutes, host string) types.HTTPRoute {
return func(host string) types.HTTPRoute { return func(routes HTTPRoutes, host string) types.HTTPRoute {
host, _, _ = strings.Cut(host, ":") // strip the trailing :port host, _, _ = strings.Cut(host, ":") // strip the trailing :port
for _, domain := range domains { for _, domain := range domains {
if target, ok := strings.CutSuffix(host, domain); ok { 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 return r
} }
} }
} }
// fallback to exact match // fallback to exact match
if r, ok := routes.HTTP.Get(host); ok { if r, ok := routes.Get(host); ok {
return r return r
} }
return nil return nil

View File

@@ -12,7 +12,6 @@ import (
. "github.com/yusing/godoxy/internal/entrypoint" . "github.com/yusing/godoxy/internal/entrypoint"
"github.com/yusing/godoxy/internal/route" "github.com/yusing/godoxy/internal/route"
"github.com/yusing/godoxy/internal/route/routes"
routeTypes "github.com/yusing/godoxy/internal/route/types" routeTypes "github.com/yusing/godoxy/internal/route/types"
"github.com/yusing/godoxy/internal/types" "github.com/yusing/godoxy/internal/types"
"github.com/yusing/goutils/task" "github.com/yusing/goutils/task"
@@ -90,16 +89,21 @@ func BenchmarkEntrypointReal(b *testing.B) {
b.Fatal(err) b.Fatal(err)
} }
err = r.Start(task.RootTask("test", false)) err = r.Start(task.NewTestTask(b))
if err != nil { if err != nil {
b.Fatal(err) b.Fatal(err)
} }
var w noopResponseWriter var w noopResponseWriter
server, ok := ep.GetServer(r.ListenURL().Host)
if !ok {
b.Fatal("server not found")
}
b.ResetTimer() b.ResetTimer()
for b.Loop() { for b.Loop() {
ep.ServeHTTP(&w, &req) server.ServeHTTP(&w, &req)
// if w.statusCode != http.StatusOK { // if w.statusCode != http.StatusOK {
// b.Fatalf("status code is not 200: %d", w.statusCode) // b.Fatalf("status code is not 200: %d", w.statusCode)
// } // }
@@ -140,7 +144,7 @@ func BenchmarkEntrypoint(b *testing.B) {
b.Fatal(err) b.Fatal(err)
} }
rev, ok := routes.HTTP.Get("test") rev, ok := ep.HTTPRoutes().Get("test")
if !ok { if !ok {
b.Fatal("route not found") b.Fatal("route not found")
} }
@@ -148,9 +152,14 @@ func BenchmarkEntrypoint(b *testing.B) {
var w noopResponseWriter var w noopResponseWriter
server, ok := ep.GetServer(r.ListenURL().Host)
if !ok {
b.Fatal("server not found")
}
b.ResetTimer() b.ResetTimer()
for b.Loop() { for b.Loop() {
ep.ServeHTTP(&w, &req) server.ServeHTTP(&w, &req)
if w.statusCode != http.StatusOK { if w.statusCode != http.StatusOK {
b.Fatalf("status code is not 200: %d", w.statusCode) b.Fatalf("status code is not 200: %d", w.statusCode)
} }

View File

@@ -5,15 +5,13 @@ import (
. "github.com/yusing/godoxy/internal/entrypoint" . "github.com/yusing/godoxy/internal/entrypoint"
"github.com/yusing/godoxy/internal/route" "github.com/yusing/godoxy/internal/route"
"github.com/yusing/godoxy/internal/route/routes"
"github.com/yusing/goutils/task"
expect "github.com/yusing/goutils/testing" expect "github.com/yusing/goutils/testing"
) )
var ep = NewEntrypoint() func addRoute(ep *Entrypoint, alias string) {
ep.AddRoute(&route.ReveseProxyRoute{
func addRoute(alias string) {
routes.HTTP.Add(&route.ReveseProxyRoute{
Route: &route.Route{ Route: &route.Route{
Alias: alias, Alias: alias,
Port: route.Port{ Port: route.Port{
@@ -25,26 +23,28 @@ func addRoute(alias string) {
func run(t *testing.T, match []string, noMatch []string) { func run(t *testing.T, match []string, noMatch []string) {
t.Helper() t.Helper()
t.Cleanup(routes.Clear) ep := NewEntrypoint(task.NewTestTask(t), nil)
t.Cleanup(func() { ep.SetFindRouteDomains(nil) })
for _, test := range match { for _, test := range match {
t.Run(test, func(t *testing.T) { 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) expect.NotNil(t, found)
}) })
} }
for _, test := range noMatch { for _, test := range noMatch {
t.Run(test, func(t *testing.T) { 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) expect.Nil(t, found)
}) })
} }
} }
func TestFindRouteAnyDomain(t *testing.T) { func TestFindRouteAnyDomain(t *testing.T) {
addRoute("app1") ep := NewEntrypoint(task.NewTestTask(t), nil)
addRoute(ep, "app1")
tests := []string{ tests := []string{
"app1.com", "app1.com",
@@ -62,6 +62,7 @@ func TestFindRouteAnyDomain(t *testing.T) {
} }
func TestFindRouteExactHostMatch(t *testing.T) { func TestFindRouteExactHostMatch(t *testing.T) {
ep := NewEntrypoint(task.NewTestTask(t), nil)
tests := []string{ tests := []string{
"app2.com", "app2.com",
"app2.domain.com", "app2.domain.com",
@@ -75,19 +76,20 @@ func TestFindRouteExactHostMatch(t *testing.T) {
} }
for _, test := range tests { for _, test := range tests {
addRoute(test) addRoute(ep, test)
} }
run(t, tests, testsNoMatch) run(t, tests, testsNoMatch)
} }
func TestFindRouteByDomains(t *testing.T) { func TestFindRouteByDomains(t *testing.T) {
ep := NewEntrypoint(task.NewTestTask(t), nil)
ep.SetFindRouteDomains([]string{ ep.SetFindRouteDomains([]string{
".domain.com", ".domain.com",
".sub.domain.com", ".sub.domain.com",
}) })
addRoute("app1") addRoute(ep, "app1")
tests := []string{ tests := []string{
"app1.domain.com", "app1.domain.com",
@@ -107,12 +109,13 @@ func TestFindRouteByDomains(t *testing.T) {
} }
func TestFindRouteByDomainsExactMatch(t *testing.T) { func TestFindRouteByDomainsExactMatch(t *testing.T) {
ep := NewEntrypoint(task.NewTestTask(t), nil)
ep.SetFindRouteDomains([]string{ ep.SetFindRouteDomains([]string{
".domain.com", ".domain.com",
".sub.domain.com", ".sub.domain.com",
}) })
addRoute("app1.foo.bar") addRoute(ep, "app1.foo.bar")
tests := []string{ tests := []string{
"app1.foo.bar", // exact match "app1.foo.bar", // exact match
@@ -131,8 +134,9 @@ func TestFindRouteByDomainsExactMatch(t *testing.T) {
func TestFindRouteWithPort(t *testing.T) { func TestFindRouteWithPort(t *testing.T) {
t.Run("AnyDomain", func(t *testing.T) { t.Run("AnyDomain", func(t *testing.T) {
addRoute("app1") ep := NewEntrypoint(task.NewTestTask(t), nil)
addRoute("app2.com") addRoute(ep, "app1")
addRoute(ep, "app2.com")
tests := []string{ tests := []string{
"app1:8080", "app1:8080",
@@ -148,12 +152,13 @@ func TestFindRouteWithPort(t *testing.T) {
}) })
t.Run("ByDomains", func(t *testing.T) { t.Run("ByDomains", func(t *testing.T) {
ep := NewEntrypoint(task.NewTestTask(t), nil)
ep.SetFindRouteDomains([]string{ ep.SetFindRouteDomains([]string{
".domain.com", ".domain.com",
}) })
addRoute("app1") addRoute(ep, "app1")
addRoute("app2") addRoute(ep, "app2")
addRoute("app3.domain.com") addRoute(ep, "app3.domain.com")
tests := []string{ tests := []string{
"app1.domain.com:8080", "app1.domain.com:8080",

View File

@@ -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
}

View File

@@ -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)
}
}
}

View File

@@ -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()
}

View File

@@ -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
}

View File

@@ -6,13 +6,15 @@ import (
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yusing/godoxy/internal/common" "github.com/yusing/godoxy/internal/common"
. "github.com/yusing/godoxy/internal/entrypoint" . "github.com/yusing/godoxy/internal/entrypoint"
"github.com/yusing/goutils/task"
) )
func TestShortLinkMatcher_FQDNAlias(t *testing.T) { func TestShortLinkMatcher_FQDNAlias(t *testing.T) {
ep := NewEntrypoint() ep := NewEntrypoint(task.NewTestTask(t), nil)
matcher := ep.ShortLinkMatcher() matcher := ep.ShortLinkMatcher()
matcher.AddRoute("app.domain.com") matcher.AddRoute("app.domain.com")
@@ -45,7 +47,7 @@ func TestShortLinkMatcher_FQDNAlias(t *testing.T) {
} }
func TestShortLinkMatcher_SubdomainAlias(t *testing.T) { func TestShortLinkMatcher_SubdomainAlias(t *testing.T) {
ep := NewEntrypoint() ep := NewEntrypoint(task.NewTestTask(t), nil)
matcher := ep.ShortLinkMatcher() matcher := ep.ShortLinkMatcher()
matcher.SetDefaultDomainSuffix(".example.com") matcher.SetDefaultDomainSuffix(".example.com")
matcher.AddRoute("app") matcher.AddRoute("app")
@@ -70,7 +72,7 @@ func TestShortLinkMatcher_SubdomainAlias(t *testing.T) {
} }
func TestShortLinkMatcher_NotFound(t *testing.T) { func TestShortLinkMatcher_NotFound(t *testing.T) {
ep := NewEntrypoint() ep := NewEntrypoint(task.NewTestTask(t), nil)
matcher := ep.ShortLinkMatcher() matcher := ep.ShortLinkMatcher()
matcher.SetDefaultDomainSuffix(".example.com") matcher.SetDefaultDomainSuffix(".example.com")
matcher.AddRoute("app") matcher.AddRoute("app")
@@ -93,7 +95,7 @@ func TestShortLinkMatcher_NotFound(t *testing.T) {
} }
func TestShortLinkMatcher_AddDelRoute(t *testing.T) { func TestShortLinkMatcher_AddDelRoute(t *testing.T) {
ep := NewEntrypoint() ep := NewEntrypoint(task.NewTestTask(t), nil)
matcher := ep.ShortLinkMatcher() matcher := ep.ShortLinkMatcher()
matcher.SetDefaultDomainSuffix(".example.com") matcher.SetDefaultDomainSuffix(".example.com")
@@ -131,7 +133,7 @@ func TestShortLinkMatcher_AddDelRoute(t *testing.T) {
} }
func TestShortLinkMatcher_NoDefaultDomainSuffix(t *testing.T) { func TestShortLinkMatcher_NoDefaultDomainSuffix(t *testing.T) {
ep := NewEntrypoint() ep := NewEntrypoint(task.NewTestTask(t), nil)
matcher := ep.ShortLinkMatcher() matcher := ep.ShortLinkMatcher()
// no SetDefaultDomainSuffix called // no SetDefaultDomainSuffix called
@@ -158,15 +160,19 @@ func TestShortLinkMatcher_NoDefaultDomainSuffix(t *testing.T) {
} }
func TestEntrypoint_ShortLinkDispatch(t *testing.T) { func TestEntrypoint_ShortLinkDispatch(t *testing.T) {
ep := NewEntrypoint() ep := NewEntrypoint(task.NewTestTask(t), nil)
ep.ShortLinkMatcher().SetDefaultDomainSuffix(".example.com") ep.ShortLinkMatcher().SetDefaultDomainSuffix(".example.com")
ep.ShortLinkMatcher().AddRoute("app") 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) { t.Run("shortlink host", func(t *testing.T) {
req := httptest.NewRequest("GET", "/app", nil) req := httptest.NewRequest("GET", "/app", nil)
req.Host = common.ShortLinkPrefix req.Host = common.ShortLinkPrefix
w := httptest.NewRecorder() w := httptest.NewRecorder()
ep.ServeHTTP(w, req) server.ServeHTTP(w, req)
assert.Equal(t, http.StatusTemporaryRedirect, w.Code) assert.Equal(t, http.StatusTemporaryRedirect, w.Code)
assert.Equal(t, "https://app.example.com/", w.Header().Get("Location")) 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 := httptest.NewRequest("GET", "/app", nil)
req.Host = common.ShortLinkPrefix + ":8080" req.Host = common.ShortLinkPrefix + ":8080"
w := httptest.NewRecorder() w := httptest.NewRecorder()
ep.ServeHTTP(w, req) server.ServeHTTP(w, req)
assert.Equal(t, http.StatusTemporaryRedirect, w.Code) assert.Equal(t, http.StatusTemporaryRedirect, w.Code)
assert.Equal(t, "https://app.example.com/", w.Header().Get("Location")) 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 := httptest.NewRequest("GET", "/app", nil)
req.Host = "app.example.com" req.Host = "app.example.com"
w := httptest.NewRecorder() w := httptest.NewRecorder()
ep.ServeHTTP(w, req) server.ServeHTTP(w, req)
// Should not redirect, should try normal route lookup (which will 404) // Should not redirect, should try normal route lookup (which will 404)
assert.NotEqual(t, http.StatusTemporaryRedirect, w.Code) assert.NotEqual(t, http.StatusTemporaryRedirect, w.Code)

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -14,11 +14,11 @@ import (
"github.com/yusing/ds/ordered" "github.com/yusing/ds/ordered"
config "github.com/yusing/godoxy/internal/config/types" config "github.com/yusing/godoxy/internal/config/types"
"github.com/yusing/godoxy/internal/docker" "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/health/monitor"
"github.com/yusing/godoxy/internal/idlewatcher/provider" "github.com/yusing/godoxy/internal/idlewatcher/provider"
idlewatcher "github.com/yusing/godoxy/internal/idlewatcher/types" idlewatcher "github.com/yusing/godoxy/internal/idlewatcher/types"
nettypes "github.com/yusing/godoxy/internal/net/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/types"
"github.com/yusing/godoxy/internal/watcher/events" "github.com/yusing/godoxy/internal/watcher/events"
gperr "github.com/yusing/goutils/errs" gperr "github.com/yusing/goutils/errs"
@@ -173,7 +173,7 @@ func NewWatcher(parent task.Parent, r types.Route, cfg *types.IdlewatcherConfig)
} }
if !ok { if !ok {
depRoute, ok = routes.GetIncludeExcluded(dep) depRoute, ok = entrypoint.FromCtx(parent.Context()).GetRoute(dep)
if !ok { if !ok {
depErrors.Addf("dependency %q not found", dep) depErrors.Addf("dependency %q not found", dep)
continue continue

View File

@@ -153,8 +153,8 @@ func (p *Poller[T, AggregateT]) pollWithTimeout(ctx context.Context) {
p.lastResult.Store(data) p.lastResult.Store(data)
} }
func (p *Poller[T, AggregateT]) Start() { func (p *Poller[T, AggregateT]) Start(parent task.Parent) {
t := task.RootTask("poller."+p.name, true) t := parent.Subtask("poller."+p.name, true)
l := log.With().Str("name", p.name).Logger() l := log.With().Str("name", p.name).Logger()
err := p.load() err := p.load()
if err != nil { if err != nil {

View File

@@ -8,16 +8,17 @@ import (
"github.com/bytedance/sonic" "github.com/bytedance/sonic"
"github.com/lithammer/fuzzysearch/fuzzy" "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" "github.com/yusing/godoxy/internal/metrics/period"
metricsutils "github.com/yusing/godoxy/internal/metrics/utils" metricsutils "github.com/yusing/godoxy/internal/metrics/utils"
"github.com/yusing/godoxy/internal/route/routes"
"github.com/yusing/godoxy/internal/types" "github.com/yusing/godoxy/internal/types"
) )
type ( type (
StatusByAlias struct { StatusByAlias struct {
Map map[string]routes.HealthInfoWithoutDetail `json:"statuses"` Map map[string]types.HealthInfoWithoutDetail `json:"statuses"`
Timestamp int64 `json:"timestamp"` Timestamp int64 `json:"timestamp"`
} // @name RouteStatusesByAlias } // @name RouteStatusesByAlias
Status struct { Status struct {
Status types.HealthStatus `json:"status" swaggertype:"string" enums:"healthy,unhealthy,unknown,napping,starting"` 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) { func getStatuses(ctx context.Context, _ StatusByAlias) (StatusByAlias, error) {
return StatusByAlias{ return StatusByAlias{
Map: routes.GetHealthInfoWithoutDetail(), Map: entrypoint.FromCtx(ctx).GetHealthInfoWithoutDetail(),
Timestamp: time.Now().Unix(), Timestamp: time.Now().Unix(),
}, nil }, nil
} }
@@ -127,11 +128,13 @@ func (rs RouteStatuses) aggregate(limit int, offset int) Aggregated {
up, down, idle, latency := rs.calculateInfo(statuses) up, down, idle, latency := rs.calculateInfo(statuses)
status := types.StatusUnknown status := types.StatusUnknown
r, ok := routes.GetIncludeExcluded(alias) if state := config.ActiveState.Load(); state != nil {
if ok { r, ok := entrypoint.FromCtx(state.Context()).GetRoute(alias)
mon := r.HealthMonitor() if ok {
if mon != nil { mon := r.HealthMonitor()
status = mon.Status() if mon != nil {
status = mon.Status()
}
} }
} }

View File

@@ -231,7 +231,7 @@ func TestEntrypointBypassRoute(t *testing.T) {
expect.NoError(t, err) expect.NoError(t, err)
expect.NoError(t, err) expect.NoError(t, err)
entry := entrypoint.NewEntrypoint() entry := entrypoint.NewEntrypoint(task.NewTestTask(t), nil)
r := &route.Route{ r := &route.Route{
Alias: "test-route", Alias: "test-route",
Host: host, Host: host,
@@ -260,7 +260,11 @@ func TestEntrypointBypassRoute(t *testing.T) {
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
req := httptest.NewRequest("GET", "http://test-route.example.com", nil) 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.Code, http.StatusOK, "should bypass http redirect")
expect.Equal(t, recorder.Body.String(), "test") expect.Equal(t, recorder.Body.String(), "test")
expect.Equal(t, recorder.Header().Get("Test-Header"), "test-value") expect.Equal(t, recorder.Header().Get("Test-Header"), "test-value")

View File

@@ -11,7 +11,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/yusing/godoxy/internal/route/routes" entrypoint "github.com/yusing/godoxy/internal/entrypoint/types"
httputils "github.com/yusing/goutils/http" httputils "github.com/yusing/goutils/http"
ioutils "github.com/yusing/goutils/io" ioutils "github.com/yusing/goutils/io"
) )
@@ -66,7 +66,7 @@ func (m *crowdsecMiddleware) finalize() error {
// before implements RequestModifier. // before implements RequestModifier.
func (m *crowdsecMiddleware) before(w http.ResponseWriter, r *http.Request) (proceed bool) { func (m *crowdsecMiddleware) before(w http.ResponseWriter, r *http.Request) (proceed bool) {
// Build CrowdSec URL // Build CrowdSec URL
crowdsecURL, err := m.buildCrowdSecURL() crowdsecURL, err := m.buildCrowdSecURL(r.Context())
if err != nil { if err != nil {
Crowdsec.LogError(r).Err(err).Msg("failed to build CrowdSec URL") Crowdsec.LogError(r).Err(err).Msg("failed to build CrowdSec URL")
w.WriteHeader(http.StatusInternalServerError) 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 // 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 // Try to get route first
if m.Route != "" { 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 // Using route name
targetURL := *route.TargetURL() targetURL := *route.TargetURL()
targetURL.Path = m.Endpoint targetURL.Path = m.Endpoint

View File

@@ -8,7 +8,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/yusing/godoxy/internal/route/routes" entrypoint "github.com/yusing/godoxy/internal/entrypoint/types"
httputils "github.com/yusing/goutils/http" httputils "github.com/yusing/goutils/http"
"github.com/yusing/goutils/http/httpheaders" "github.com/yusing/goutils/http/httpheaders"
) )
@@ -46,7 +46,7 @@ func (m *forwardAuthMiddleware) setup() {
// before implements RequestModifier. // before implements RequestModifier.
func (m *forwardAuthMiddleware) before(w http.ResponseWriter, r *http.Request) (proceed bool) { 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 { if !ok {
ForwardAuth.LogWarn(r).Str("route", m.Route).Msg("forwardauth route not found") ForwardAuth.LogWarn(r).Str("route", m.Route).Msg("forwardauth route not found")
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)

View File

@@ -1,12 +1,17 @@
package route package route
import ( import (
"github.com/yusing/godoxy/internal/route/routes" "context"
entrypoint "github.com/yusing/godoxy/internal/entrypoint/types"
"github.com/yusing/godoxy/internal/types" "github.com/yusing/godoxy/internal/types"
gperr "github.com/yusing/goutils/errs" 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 if r.UseLoadBalance() { // skip checking for load balanced routes
return nil return nil
} }
@@ -16,9 +21,9 @@ func checkExists(r types.Route) gperr.Error {
) )
switch r := r.(type) { switch r := r.(type) {
case types.HTTPRoute: case types.HTTPRoute:
existing, ok = routes.HTTP.Get(r.Key()) existing, ok = entrypoint.FromCtx(ctx).HTTPRoutes().Get(r.Key())
case types.StreamRoute: case types.StreamRoute:
existing, ok = routes.Stream.Get(r.Key()) existing, ok = entrypoint.FromCtx(ctx).StreamRoutes().Get(r.Key())
} }
if ok { if ok {
return gperr.Errorf("route already exists: from provider %s and %s", existing.ProviderName(), r.ProviderName()) return gperr.Errorf("route already exists: from provider %s and %s", existing.ProviderName(), r.ProviderName())

View File

@@ -6,12 +6,12 @@ import (
"path" "path"
"path/filepath" "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/health/monitor"
"github.com/yusing/godoxy/internal/logging/accesslog" "github.com/yusing/godoxy/internal/logging/accesslog"
gphttp "github.com/yusing/godoxy/internal/net/gphttp" gphttp "github.com/yusing/godoxy/internal/net/gphttp"
"github.com/yusing/godoxy/internal/net/gphttp/middleware" "github.com/yusing/godoxy/internal/net/gphttp/middleware"
"github.com/yusing/godoxy/internal/route/routes"
"github.com/yusing/godoxy/internal/types" "github.com/yusing/godoxy/internal/types"
gperr "github.com/yusing/goutils/errs" gperr "github.com/yusing/goutils/errs"
"github.com/yusing/goutils/task" "github.com/yusing/goutils/task"
@@ -120,20 +120,13 @@ func (s *FileServer) Start(parent task.Parent) gperr.Error {
if s.UseHealthCheck() { if s.UseHealthCheck() {
s.HealthMon = monitor.NewMonitor(s) s.HealthMon = monitor.NewMonitor(s)
if err := s.HealthMon.Start(s.task); err != nil { 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) entrypoint.FromCtx(parent.Context()).AddRoute(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)
}
})
return nil return nil
} }

View File

@@ -6,7 +6,7 @@ import (
"github.com/yusing/godoxy/agent/pkg/agent" "github.com/yusing/godoxy/agent/pkg/agent"
"github.com/yusing/godoxy/agent/pkg/agentproxy" "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/health/monitor"
"github.com/yusing/godoxy/internal/idlewatcher" "github.com/yusing/godoxy/internal/idlewatcher"
"github.com/yusing/godoxy/internal/logging/accesslog" "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/loadbalancer"
"github.com/yusing/godoxy/internal/net/gphttp/middleware" "github.com/yusing/godoxy/internal/net/gphttp/middleware"
nettypes "github.com/yusing/godoxy/internal/net/types" nettypes "github.com/yusing/godoxy/internal/net/types"
"github.com/yusing/godoxy/internal/route/routes"
route "github.com/yusing/godoxy/internal/route/types" route "github.com/yusing/godoxy/internal/route/types"
"github.com/yusing/godoxy/internal/types" "github.com/yusing/godoxy/internal/types"
gperr "github.com/yusing/goutils/errs" gperr "github.com/yusing/goutils/errs"
@@ -159,23 +158,15 @@ func (r *ReveseProxyRoute) Start(parent task.Parent) gperr.Error {
if r.HealthMon != nil { if r.HealthMon != nil {
if err := r.HealthMon.Start(r.task); err != 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() { if r.UseLoadBalance() {
r.addToLoadBalancer(parent) r.addToLoadBalancer(parent)
} else { } else {
routes.HTTP.Add(r) entrypoint.FromCtx(parent.Context()).AddRoute(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)
}
})
} }
return nil return nil
} }
@@ -192,7 +183,8 @@ func (r *ReveseProxyRoute) addToLoadBalancer(parent task.Parent) {
cfg := r.LoadBalance cfg := r.LoadBalance
lbLock.Lock() 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 var linked *ReveseProxyRoute
if ok { if ok {
lbLock.Unlock() lbLock.Unlock()
@@ -214,16 +206,7 @@ func (r *ReveseProxyRoute) addToLoadBalancer(parent task.Parent) {
handler: lb, handler: lb,
} }
linked.SetHealthMonitor(lb) linked.SetHealthMonitor(lb)
routes.HTTP.AddKey(cfg.Link, linked) ep.AddRoute(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)
}
})
lbLock.Unlock() lbLock.Unlock()
} }
r.loadBalancer = lb r.loadBalancer = lb

View File

@@ -18,6 +18,7 @@ import (
"github.com/yusing/godoxy/internal/agentpool" "github.com/yusing/godoxy/internal/agentpool"
config "github.com/yusing/godoxy/internal/config/types" config "github.com/yusing/godoxy/internal/config/types"
"github.com/yusing/godoxy/internal/docker" "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/health/monitor"
"github.com/yusing/godoxy/internal/homepage" "github.com/yusing/godoxy/internal/homepage"
iconlist "github.com/yusing/godoxy/internal/homepage/icons/list" 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/common"
"github.com/yusing/godoxy/internal/logging/accesslog" "github.com/yusing/godoxy/internal/logging/accesslog"
"github.com/yusing/godoxy/internal/route/routes"
"github.com/yusing/godoxy/internal/route/rules" "github.com/yusing/godoxy/internal/route/rules"
rulepresets "github.com/yusing/godoxy/internal/route/rules/presets" rulepresets "github.com/yusing/godoxy/internal/route/rules/presets"
route "github.com/yusing/godoxy/internal/route/types" route "github.com/yusing/godoxy/internal/route/types"
@@ -46,8 +46,7 @@ type (
Host string `json:"host,omitempty"` Host string `json:"host,omitempty"`
Port route.Port `json:"port"` Port route.Port `json:"port"`
// for TCP and UDP routes, bind address to listen on Bind string `json:"bind,omitempty" validate:"omitempty,dive,ip_addr" extensions:"x-nullable"`
Bind string `json:"bind,omitempty" validate:"omitempty,ip_addr" extensions:"x-nullable"`
Root string `json:"root,omitempty"` Root string `json:"root,omitempty"`
SPA bool `json:"spa,omitempty"` // Single-page app mode: serves index for non-existent paths 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 impl types.Route
var err gperr.Error var err gperr.Error
switch r.Scheme { if r.ShouldExclude() {
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)
}
r.ProxyURL = gperr.Collect(&errs, nettypes.ParseURL, fmt.Sprintf("%s://%s", r.Scheme, net.JoinHostPort(r.Host, strconv.Itoa(r.Port.Proxy)))) 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: } else {
if r.ShouldExclude() { switch r.Scheme {
// should exclude, we don't care the scheme here. 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)))) r.ProxyURL = gperr.Collect(&errs, nettypes.ParseURL, fmt.Sprintf("%s://%s", r.Scheme, net.JoinHostPort(r.Host, strconv.Itoa(r.Port.Proxy))))
} else { case route.SchemeHTTP, route.SchemeHTTPS, route.SchemeH2C:
if r.Bind == "" { r.LisURL = gperr.Collect(&errs, nettypes.ParseURL, fmt.Sprintf("https://%s", net.JoinHostPort(r.Bind, strconv.Itoa(r.Port.Listening))))
r.Bind = "0.0.0.0" 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) bindIP := net.ParseIP(r.Bind)
remoteIP := net.ParseIP(r.Host) remoteIP := net.ParseIP(r.Host)
toNetwork := func(ip net.IP, scheme route.Scheme) string { 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()) { if !r.UseHealthCheck() && (r.UseLoadBalance() || r.UseIdleWatcher()) {
errs.Adds("cannot disable healthcheck when loadbalancer or idle watcher is enabled") 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") return errors.New("rule preset `webui.yml` not found")
} }
r.Rules = rules r.Rules = rules
return nil
} }
return nil
} }
if r.RuleFile != "" && len(r.Rules) > 0 { 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 // skip checking for excluded routes
excluded := r.ShouldExclude() excluded := r.ShouldExclude()
if !excluded { if !excluded {
if err := checkExists(r); err != nil { if err := checkExists(parent.Context(), r); err != nil {
return err return err
} }
} }
@@ -518,15 +516,19 @@ func (r *Route) start(parent task.Parent) gperr.Error {
return err return err
} }
} else { } else {
r.task = parent.Subtask("excluded."+r.Name(), true) ep := entrypoint.FromCtx(parent.Context())
routes.Excluded.Add(r.impl)
r.task = parent.Subtask("excluded."+r.Name(), false)
ep.ExcludedRoutes().Add(r.impl)
r.task.OnCancel("remove_route_from_excluded", func() { r.task.OnCancel("remove_route_from_excluded", func() {
routes.Excluded.Del(r.impl) ep.ExcludedRoutes().Del(r.impl)
}) })
if r.UseHealthCheck() { if r.UseHealthCheck() {
r.HealthMon = monitor.NewMonitor(r.impl) r.HealthMon = monitor.NewMonitor(r.impl)
err := r.HealthMon.Start(r.task) err := r.HealthMon.Start(r.task)
return err if err != nil {
return err
}
} }
} }
return nil return nil
@@ -564,6 +566,10 @@ func (r *Route) ProviderName() string {
return r.Provider return r.Provider
} }
func (r *Route) ListenURL() *nettypes.URL {
return r.LisURL
}
func (r *Route) TargetURL() *nettypes.URL { func (r *Route) TargetURL() *nettypes.URL {
return r.ProxyURL 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 r.Port.Listening, r.Port.Proxy = lp, pp
workingState := config.WorkingState.Load() workingState := config.WorkingState.Load()
@@ -942,6 +962,7 @@ func (r *Route) Finalize() {
panic("bug: working state is nil") panic("bug: working state is nil")
} }
// TODO: default value from context
r.HealthCheck.ApplyDefaults(config.WorkingState.Load().Value().Defaults.HealthCheck) r.HealthCheck.ApplyDefaults(config.WorkingState.Load().Value().Defaults.HealthCheck)
} }

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -15,7 +15,6 @@ import (
nettypes "github.com/yusing/godoxy/internal/net/types" nettypes "github.com/yusing/godoxy/internal/net/types"
"github.com/yusing/godoxy/internal/notif" "github.com/yusing/godoxy/internal/notif"
"github.com/yusing/godoxy/internal/route/routes" "github.com/yusing/godoxy/internal/route/routes"
"github.com/yusing/godoxy/internal/types"
gperr "github.com/yusing/goutils/errs" gperr "github.com/yusing/goutils/errs"
httputils "github.com/yusing/goutils/http" httputils "github.com/yusing/goutils/http"
"github.com/yusing/goutils/http/reverseproxy" "github.com/yusing/goutils/http/reverseproxy"
@@ -195,20 +194,23 @@ var commands = map[string]struct {
return args[0], nil return args[0], nil
}, },
build: func(args any) CommandHandler { build: func(args any) CommandHandler {
route := args.(string) // route := args.(string)
return TerminatingCommand(func(w http.ResponseWriter, req *http.Request) error { return TerminatingCommand(func(w http.ResponseWriter, req *http.Request) error {
r, ok := routes.HTTP.Get(route)
if !ok { // FIXME: circular dependency
excluded, has := routes.Excluded.Get(route) // ep := entrypoint.FromCtx(req.Context())
if has { // r, ok := ep.HTTPRoutes().Get(route)
r, ok = excluded.(types.HTTPRoute) // if !ok {
} // excluded, has := ep.ExcludedRoutes().Get(route)
} // if has {
if ok { // r, ok = excluded.(types.HTTPRoute)
r.ServeHTTP(w, req) // }
} else { // }
http.Error(w, fmt.Sprintf("Route %q not found", route), http.StatusNotFound) // if ok {
} // r.ServeHTTP(w, req)
// } else {
// http.Error(w, fmt.Sprintf("Route %q not found", route), http.StatusNotFound)
// }
return nil return nil
}) })
}, },

View File

@@ -8,10 +8,10 @@ import (
"github.com/rs/zerolog" "github.com/rs/zerolog"
"github.com/rs/zerolog/log" "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/health/monitor"
"github.com/yusing/godoxy/internal/idlewatcher" "github.com/yusing/godoxy/internal/idlewatcher"
nettypes "github.com/yusing/godoxy/internal/net/types" 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/route/stream"
"github.com/yusing/godoxy/internal/types" "github.com/yusing/godoxy/internal/types"
gperr "github.com/yusing/goutils/errs" gperr "github.com/yusing/goutils/errs"
@@ -65,6 +65,7 @@ func (r *StreamRoute) Start(parent task.Parent) gperr.Error {
if r.HealthMon != nil { if r.HealthMon != nil {
if err := r.HealthMon.Start(r.task); err != nil { if err := r.HealthMon.Start(r.task); err != nil {
gperr.LogWarn("health monitor error", err, &r.l) 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") r.l.Info().Msg("stream closed")
}) })
routes.Stream.Add(r) entrypoint.FromCtx(parent.Context()).AddRoute(r)
r.task.OnCancel("remove_route_from_stream", func() {
routes.Stream.Del(r)
})
return nil return nil
} }

View File

@@ -7,9 +7,9 @@ import (
"github.com/pires/go-proxyproto" "github.com/pires/go-proxyproto"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"github.com/rs/zerolog/log" "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/agentpool"
"github.com/yusing/godoxy/internal/entrypoint" entrypoint "github.com/yusing/godoxy/internal/entrypoint/types"
nettypes "github.com/yusing/godoxy/internal/net/types" nettypes "github.com/yusing/godoxy/internal/net/types"
ioutils "github.com/yusing/goutils/io" ioutils "github.com/yusing/goutils/io"
"go.uber.org/atomic" "go.uber.org/atomic"
@@ -51,12 +51,14 @@ func (s *TCPTCPStream) ListenAndServe(ctx context.Context, preDial, onRead netty
return 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") log.Debug().Str("listener", s.listener.Addr().String()).Msg("wrapping listener with ACL")
s.listener = acl.WrapTCP(s.listener) 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} s.listener = &proxyproto.Listener{Listener: s.listener}
} }

View File

@@ -11,7 +11,7 @@ import (
"github.com/rs/zerolog" "github.com/rs/zerolog"
"github.com/rs/zerolog/log" "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/agentpool"
nettypes "github.com/yusing/godoxy/internal/net/types" nettypes "github.com/yusing/godoxy/internal/net/types"
"github.com/yusing/goutils/synk" "github.com/yusing/goutils/synk"
@@ -82,10 +82,11 @@ func (s *UDPUDPStream) ListenAndServe(ctx context.Context, preDial, onRead netty
return return
} }
s.listener = l 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") log.Debug().Str("listener", s.listener.LocalAddr().String()).Msg("wrapping listener with ACL")
s.listener = acl.WrapUDP(s.listener) s.listener = acl.WrapUDP(s.listener)
} }
// TODO: add to entrypoint
s.preDial = preDial s.preDial = preDial
s.onRead = onRead s.onRead = onRead
go s.listen(ctx) go s.listen(ctx)

View File

@@ -74,6 +74,19 @@ type (
Config *LoadBalancerConfig `json:"config"` Config *LoadBalancerConfig `json:"config"`
Pool map[string]any `json:"pool"` Pool map[string]any `json:"pool"`
} // @name HealthExtra } // @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 ( const (

View File

@@ -20,6 +20,7 @@ type (
pool.Object pool.Object
ProviderName() string ProviderName() string
GetProvider() RouteProvider GetProvider() RouteProvider
ListenURL() *nettypes.URL
TargetURL() *nettypes.URL TargetURL() *nettypes.URL
HealthMonitor() HealthMonitor HealthMonitor() HealthMonitor
SetHealthMonitor(m HealthMonitor) SetHealthMonitor(m HealthMonitor)