Compare commits

..

12 Commits

Author SHA1 Message Date
yusing
f2939fb6e8 fix: remove redundant entrypoint.FromCtx(ctx) call 2026-02-06 23:29:43 +08:00
yusing
f5ab86c233 fix(route): remove incorrect default values for bind and port 2026-02-06 23:28:35 +08:00
yusing
6a473d7096 refactor(config): drop unnecessary explicit alias 2026-02-06 23:24:51 +08:00
yusing
3880d1f1fd refactor(api): remove unnecessary blank import 2026-02-06 23:24:08 +08:00
yusing
e16ba3e438 fix(route): correct proxy url for fileserver route 2026-02-06 23:23:22 +08:00
yusing
100d77bd06 fix(route): properly cleanup task on error 2026-02-06 23:20:39 +08:00
yusing
9abe948d1d refactor(entrypoint): streamline benchmark tests and enhance error handling
- Introduced `NewTestRoute` function to simplify route creation in benchmark tests.
- Replaced direct route validation and starting with error handling using `require.NoError`.
- Updated server retrieval to use `common.ProxyHTTPAddr` for consistency.
- Improved logging for HTTP route addition errors in `AddRoute` method.
2026-02-06 15:38:22 +08:00
yusing
ad59ddb9d8 fix: add nil guard to Route.start 2026-02-06 12:19:11 +08:00
yusing
cd94479030 fix(rules): uncomment code 2026-02-06 12:02:18 +08:00
yusing
a6fed3f221 fix: add nil guard before entrypoint retrieval; move config from types/ 2026-02-06 12:01:09 +08:00
yusing
e383cd247a fix(agent): pass argument to Poller.Start 2026-02-06 00:30:50 +08:00
yusing
f9ee33f464 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.
2026-02-06 00:23:12 +08:00
51 changed files with 1001 additions and 862 deletions

View File

@@ -161,7 +161,7 @@ Tips:
srv.Serve(l)
}
systeminfo.Poller.Start()
systeminfo.Poller.Start(t)
task.WaitExit(3)
}

View File

@@ -1,12 +1,12 @@
package main
import (
"errors"
"os"
"sync"
"time"
"github.com/rs/zerolog/log"
"github.com/yusing/godoxy/internal/api"
"github.com/yusing/godoxy/internal/auth"
"github.com/yusing/godoxy/internal/common"
"github.com/yusing/godoxy/internal/config"
@@ -14,12 +14,9 @@ import (
iconlist "github.com/yusing/godoxy/internal/homepage/icons/list"
"github.com/yusing/godoxy/internal/logging"
"github.com/yusing/godoxy/internal/logging/memlogger"
"github.com/yusing/godoxy/internal/metrics/systeminfo"
"github.com/yusing/godoxy/internal/metrics/uptime"
"github.com/yusing/godoxy/internal/net/gphttp/middleware"
"github.com/yusing/godoxy/internal/route/rules"
gperr "github.com/yusing/goutils/errs"
"github.com/yusing/goutils/server"
"github.com/yusing/goutils/task"
"github.com/yusing/goutils/version"
)
@@ -51,7 +48,6 @@ func main() {
parallel(
dnsproviders.InitProviders,
iconlist.InitCache,
systeminfo.Poller.Start,
middleware.LoadComposeFiles,
)
@@ -73,32 +69,13 @@ func main() {
gperr.LogWarn("errors in config", err)
}
config.StartProxyServers()
if err := auth.Initialize(); err != nil {
log.Fatal().Err(err).Msg("failed to initialize authentication")
}
rules.InitAuthHandler(auth.AuthOrProceed)
// API Handler needs to start after auth is initialized.
server.StartServer(task.RootTask("api_server", false), server.Options{
Name: "api",
HTTPAddr: common.APIHTTPAddr,
Handler: api.NewHandler(true),
})
// Local API Handler is used for unauthenticated access.
if common.LocalAPIHTTPAddr != "" {
server.StartServer(task.RootTask("local_api_server", false), server.Options{
Name: "local_api",
HTTPAddr: common.LocalAPIHTTPAddr,
Handler: api.NewHandler(false),
})
}
listenDebugServer()
uptime.Poller.Start()
config.WatchChanges()
close(done)

Submodule goutils updated: 52ea531e95...a270ef85af

View File

@@ -74,8 +74,6 @@ type ipLog struct {
allowed bool
}
type ContextKey struct{}
const cacheTTL = 1 * time.Minute
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/client"
"github.com/yusing/godoxy/internal/docker"
"github.com/yusing/godoxy/internal/route/routes"
entrypoint "github.com/yusing/godoxy/internal/entrypoint/types"
"github.com/yusing/godoxy/internal/types"
apitypes "github.com/yusing/goutils/apitypes"
"github.com/yusing/goutils/http/httpheaders"
@@ -44,7 +44,7 @@ func Stats(c *gin.Context) {
dockerCfg, ok := docker.GetDockerCfgByContainerID(id)
if !ok {
var route types.Route
route, ok = routes.GetIncludeExcluded(id)
route, ok = entrypoint.FromCtx(c.Request.Context()).GetRoute(id)
if ok {
cont := route.ContainerInfo()
if cont == nil {

View File

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

View File

@@ -5,11 +5,10 @@ import (
"time"
"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"
"github.com/yusing/goutils/http/httpheaders"
"github.com/yusing/goutils/http/websocket"
_ "github.com/yusing/goutils/apitypes"
)
// @x-id "health"
@@ -24,11 +23,16 @@ import (
// @Failure 500 {object} apitypes.ErrorResponse
// @Router /health [get]
func Health(c *gin.Context) {
ep := entrypoint.FromCtx(c.Request.Context())
if ep == nil { // impossible, but just in case
c.JSON(http.StatusInternalServerError, apitypes.Error("entrypoint not initialized"))
return
}
if httpheaders.IsWebsocket(c.Request.Header) {
websocket.PeriodicWrite(c, 1*time.Second, func() (any, error) {
return routes.GetHealthInfoSimple(), nil
return ep.GetHealthInfoSimple(), nil
})
} else {
c.JSON(http.StatusOK, routes.GetHealthInfoSimple())
c.JSON(http.StatusOK, ep.GetHealthInfoSimple())
}
}

View File

@@ -4,10 +4,11 @@ import (
"net/http"
"github.com/gin-gonic/gin"
entrypoint "github.com/yusing/godoxy/internal/entrypoint/types"
"github.com/yusing/godoxy/internal/homepage"
"github.com/yusing/godoxy/internal/route/routes"
_ "github.com/yusing/goutils/apitypes"
apitypes "github.com/yusing/goutils/apitypes"
)
// @x-id "categories"
@@ -21,15 +22,20 @@ import (
// @Failure 403 {object} apitypes.ErrorResponse
// @Router /homepage/categories [get]
func Categories(c *gin.Context) {
c.JSON(http.StatusOK, HomepageCategories())
ep := entrypoint.FromCtx(c.Request.Context())
if ep == nil { // impossible, but just in case
c.JSON(http.StatusInternalServerError, apitypes.Error("entrypoint not initialized"))
return
}
c.JSON(http.StatusOK, HomepageCategories(ep))
}
func HomepageCategories() []string {
func HomepageCategories(ep entrypoint.Entrypoint) []string {
check := make(map[string]struct{})
categories := make([]string, 0)
categories = append(categories, homepage.CategoryAll)
categories = append(categories, homepage.CategoryFavorites)
for _, r := range routes.HTTP.Iter {
for _, r := range ep.HTTPRoutes().Iter {
item := r.HomepageItem()
if item.Category == "" {
continue

View File

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

View File

@@ -4,10 +4,11 @@ import (
"net/http"
"github.com/gin-gonic/gin"
entrypoint "github.com/yusing/godoxy/internal/entrypoint/types"
"github.com/yusing/godoxy/internal/route"
"github.com/yusing/godoxy/internal/route/routes"
_ "github.com/yusing/goutils/apitypes"
apitypes "github.com/yusing/goutils/apitypes"
)
type RoutesByProvider map[string][]route.Route
@@ -24,5 +25,10 @@ type RoutesByProvider map[string][]route.Route
// @Failure 500 {object} apitypes.ErrorResponse
// @Router /route/by_provider [get]
func ByProvider(c *gin.Context) {
c.JSON(http.StatusOK, routes.ByProvider())
ep := entrypoint.FromCtx(c.Request.Context())
if ep == nil { // impossible, but just in case
c.JSON(http.StatusInternalServerError, apitypes.Error("entrypoint not initialized"))
return
}
c.JSON(http.StatusOK, ep.RoutesByProvider())
}

View File

@@ -4,7 +4,7 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/yusing/godoxy/internal/route/routes"
entrypoint "github.com/yusing/godoxy/internal/entrypoint/types"
apitypes "github.com/yusing/goutils/apitypes"
)
@@ -32,7 +32,13 @@ func Route(c *gin.Context) {
return
}
route, ok := routes.GetIncludeExcluded(request.Which)
ep := entrypoint.FromCtx(c.Request.Context())
if ep == nil { // impossible, but just in case
c.JSON(http.StatusInternalServerError, apitypes.Error("entrypoint not initialized"))
return
}
route, ok := ep.GetRoute(request.Which)
if ok {
c.JSON(http.StatusOK, route)
return

View File

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

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 {
Setup() error
GetCert(*tls.ClientHelloInfo) (*tls.Certificate, error)
ScheduleRenewalAll(task.Parent)
ObtainCertAll() error

View File

@@ -10,11 +10,9 @@ import (
"github.com/yusing/godoxy/internal/common"
config "github.com/yusing/godoxy/internal/config/types"
"github.com/yusing/godoxy/internal/notif"
"github.com/yusing/godoxy/internal/route/routes"
"github.com/yusing/godoxy/internal/watcher"
"github.com/yusing/godoxy/internal/watcher/events"
gperr "github.com/yusing/goutils/errs"
"github.com/yusing/goutils/server"
"github.com/yusing/goutils/strings/ansi"
"github.com/yusing/goutils/task"
)
@@ -71,19 +69,19 @@ func Load() error {
}
// disable pool logging temporary since we already have pretty logging
routes.HTTP.DisableLog(true)
routes.Stream.DisableLog(true)
state.Entrypoint().DisablePoolsLog(true)
defer func() {
routes.HTTP.DisableLog(false)
routes.Stream.DisableLog(false)
state.Entrypoint().DisablePoolsLog(false)
}()
initErr := state.InitFromFile(common.ConfigPath)
err := errors.Join(initErr, state.StartProviders())
if err != nil {
logNotifyError("init", err)
}
state.StartAPIServers()
state.StartMetrics()
SetState(state)
// flush temporary log
@@ -118,7 +116,9 @@ func Reload() gperr.Error {
logNotifyError("start providers", err)
return nil // continue
}
StartProxyServers()
newState.StartAPIServers()
newState.StartMetrics()
return nil
}
@@ -152,16 +152,3 @@ func OnConfigChange(ev []events.Event) {
panic(err)
}
}
func StartProxyServers() {
cfg := GetState()
server.StartServer(cfg.Task(), server.Options{
Name: "proxy",
CertProvider: cfg.AutoCertProvider(),
HTTPAddr: common.ProxyHTTPAddr,
HTTPSAddr: common.ProxyHTTPSAddr,
Handler: cfg.EntrypointHandler(),
ACL: cfg.Value().ACL,
SupportProxyProtocol: cfg.Value().Entrypoint.SupportProxyProtocol,
})
}

View File

@@ -9,7 +9,6 @@ import (
"fmt"
"io/fs"
"iter"
"net/http"
"os"
"strconv"
"strings"
@@ -18,14 +17,20 @@ import (
"github.com/goccy/go-yaml"
"github.com/puzpuzpuz/xsync/v4"
"github.com/rs/zerolog"
"github.com/yusing/godoxy/internal/acl"
acl "github.com/yusing/godoxy/internal/acl/types"
"github.com/yusing/godoxy/internal/agentpool"
"github.com/yusing/godoxy/internal/api"
"github.com/yusing/godoxy/internal/autocert"
autocertctx "github.com/yusing/godoxy/internal/autocert/types"
"github.com/yusing/godoxy/internal/common"
config "github.com/yusing/godoxy/internal/config/types"
"github.com/yusing/godoxy/internal/entrypoint"
entrypointctx "github.com/yusing/godoxy/internal/entrypoint/types"
homepage "github.com/yusing/godoxy/internal/homepage/types"
"github.com/yusing/godoxy/internal/logging"
"github.com/yusing/godoxy/internal/maxmind"
"github.com/yusing/godoxy/internal/metrics/systeminfo"
"github.com/yusing/godoxy/internal/metrics/uptime"
"github.com/yusing/godoxy/internal/notif"
route "github.com/yusing/godoxy/internal/route/provider"
"github.com/yusing/godoxy/internal/serialization"
@@ -40,7 +45,7 @@ type state struct {
providers *xsync.Map[string, types.RouteProvider]
autocertProvider *autocert.Provider
entrypoint entrypoint.Entrypoint
entrypoint *entrypoint.Entrypoint
task *task.Task
@@ -65,11 +70,10 @@ func (e CriticalError) Unwrap() error {
func NewState() config.State {
tmpLogBuf := bytes.NewBuffer(make([]byte, 0, 4096))
return &state{
providers: xsync.NewMap[string, types.RouteProvider](),
entrypoint: entrypoint.NewEntrypoint(),
task: task.RootTask("config", false),
tmpLogBuf: tmpLogBuf,
tmpLog: logging.NewLoggerWithFixedLevel(zerolog.InfoLevel, tmpLogBuf),
providers: xsync.NewMap[string, types.RouteProvider](),
task: task.RootTask("config", false),
tmpLogBuf: tmpLogBuf,
tmpLog: logging.NewLoggerWithFixedLevel(zerolog.InfoLevel, tmpLogBuf),
}
}
@@ -85,7 +89,6 @@ func SetState(state config.State) {
cfg := state.Value()
config.ActiveState.Store(state)
entrypoint.ActiveConfig.Store(&cfg.Entrypoint)
homepage.ActiveConfig.Store(&cfg.Homepage)
if autocertProvider := state.AutoCertProvider(); autocertProvider != nil {
autocert.ActiveProvider.Store(autocertProvider.(*autocert.Provider))
@@ -148,8 +151,8 @@ func (state *state) Value() *config.Config {
return &state.Config
}
func (state *state) EntrypointHandler() http.Handler {
return &state.entrypoint
func (state *state) Entrypoint() entrypointctx.Entrypoint {
return state.entrypoint
}
func (state *state) ShortLinkMatcher() config.ShortLinkMatcher {
@@ -204,6 +207,29 @@ func (state *state) FlushTmpLog() {
state.tmpLogBuf.Reset()
}
func (state *state) StartAPIServers() {
// API Handler needs to start after auth is initialized.
server.StartServer(state.task.Subtask("api_server", false), server.Options{
Name: "api",
HTTPAddr: common.APIHTTPAddr,
Handler: api.NewHandler(true),
})
// Local API Handler is used for unauthenticated access.
if common.LocalAPIHTTPAddr != "" {
server.StartServer(state.task.Subtask("local_api_server", false), server.Options{
Name: "local_api",
HTTPAddr: common.LocalAPIHTTPAddr,
Handler: api.NewHandler(false),
})
}
}
func (state *state) StartMetrics() {
systeminfo.Poller.Start(state.task)
uptime.Poller.Start(state.task)
}
// initACL initializes the ACL.
func (state *state) initACL() error {
if !state.ACL.Valid() {
@@ -213,7 +239,7 @@ func (state *state) initACL() error {
if err != nil {
return err
}
state.task.SetValue(acl.ContextKey{}, state.ACL)
acl.SetCtx(state.task, state.ACL)
return nil
}
@@ -221,6 +247,7 @@ func (state *state) initEntrypoint() error {
epCfg := state.Config.Entrypoint
matchDomains := state.MatchDomains
state.entrypoint = entrypoint.NewEntrypoint(state.task, &epCfg)
state.entrypoint.SetFindRouteDomains(matchDomains)
state.entrypoint.SetNotFoundRules(epCfg.Rules.NotFound)
@@ -234,6 +261,8 @@ func (state *state) initEntrypoint() error {
}
}
entrypointctx.SetCtx(state.task, state.entrypoint)
errs := gperr.NewBuilder("entrypoint error")
errs.Add(state.entrypoint.SetMiddlewares(epCfg.Middlewares))
errs.Add(state.entrypoint.SetAccessLogger(state.task, epCfg.AccessLog))
@@ -310,6 +339,7 @@ func (state *state) initAutoCert() error {
p.PrintCertExpiriesAll()
state.autocertProvider = p
autocertctx.SetCtx(state.task, p)
return nil
}

View File

@@ -8,7 +8,7 @@ import (
"github.com/yusing/godoxy/agent/pkg/agent"
"github.com/yusing/godoxy/internal/acl"
"github.com/yusing/godoxy/internal/autocert"
entrypoint "github.com/yusing/godoxy/internal/entrypoint/types"
"github.com/yusing/godoxy/internal/entrypoint"
homepage "github.com/yusing/godoxy/internal/homepage/types"
maxmind "github.com/yusing/godoxy/internal/maxmind/types"
"github.com/yusing/godoxy/internal/notif"

View File

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

View File

@@ -1,49 +1,131 @@
package entrypoint
import (
"net"
"net/http"
"strings"
"sync/atomic"
"github.com/puzpuzpuz/xsync/v4"
"github.com/rs/zerolog/log"
"github.com/yusing/godoxy/internal/common"
entrypoint "github.com/yusing/godoxy/internal/entrypoint/types"
"github.com/yusing/godoxy/internal/logging/accesslog"
"github.com/yusing/godoxy/internal/net/gphttp/middleware"
"github.com/yusing/godoxy/internal/net/gphttp/middleware/errorpage"
"github.com/yusing/godoxy/internal/route/routes"
"github.com/yusing/godoxy/internal/route/rules"
"github.com/yusing/godoxy/internal/types"
gperr "github.com/yusing/goutils/errs"
"github.com/yusing/goutils/pool"
"github.com/yusing/goutils/task"
)
type HTTPRoutes interface {
Get(alias string) (types.HTTPRoute, bool)
}
type findRouteFunc func(HTTPRoutes, string) types.HTTPRoute
type Entrypoint struct {
middleware *middleware.Middleware
notFoundHandler http.Handler
accessLogger accesslog.AccessLogger
findRouteFunc func(host string) types.HTTPRoute
shortLinkMatcher *ShortLinkMatcher
task *task.Task
cfg *Config
middleware *middleware.Middleware
notFoundHandler http.Handler
accessLogger accesslog.AccessLogger
findRouteFunc findRouteFunc
shortLinkMatcher *ShortLinkMatcher
streamRoutes *pool.Pool[types.StreamRoute]
excludedRoutes *pool.Pool[types.Route]
// this only affects future http servers creation
httpPoolDisableLog atomic.Bool
servers *xsync.Map[string, *httpServer] // listen addr -> server
tcpListeners *xsync.Map[string, net.Listener] // listen addr -> listener
udpListeners *xsync.Map[string, net.PacketConn] // listen addr -> listener
}
// nil-safe
var ActiveConfig atomic.Pointer[entrypoint.Config]
var _ entrypoint.Entrypoint = &Entrypoint{}
func init() {
// make sure it's not nil
ActiveConfig.Store(&entrypoint.Config{})
}
var emptyCfg Config
func NewEntrypoint() Entrypoint {
return Entrypoint{
findRouteFunc: findRouteAnyDomain,
shortLinkMatcher: newShortLinkMatcher(),
func NewEntrypoint(parent task.Parent, cfg *Config) *Entrypoint {
if cfg == nil {
cfg = &emptyCfg
}
ep := &Entrypoint{
task: parent.Subtask("entrypoint", false),
cfg: cfg,
findRouteFunc: findRouteAnyDomain,
shortLinkMatcher: newShortLinkMatcher(),
streamRoutes: pool.New[types.StreamRoute]("stream_routes"),
excludedRoutes: pool.New[types.Route]("excluded_routes"),
servers: xsync.NewMap[string, *httpServer](),
tcpListeners: xsync.NewMap[string, net.Listener](),
udpListeners: xsync.NewMap[string, net.PacketConn](),
}
ep.task.OnCancel("stop", func() {
// servers stop on their own when context is cancelled
var errs gperr.Group
for _, listener := range ep.tcpListeners.Range {
errs.Go(func() error {
return listener.Close()
})
}
for _, listener := range ep.udpListeners.Range {
errs.Go(func() error {
return listener.Close()
})
}
if err := errs.Wait().Error(); err != nil {
gperr.LogError("failed to stop entrypoint listeners", err)
}
})
ep.task.OnFinished("cleanup", func() {
ep.servers.Clear()
ep.tcpListeners.Clear()
ep.udpListeners.Clear()
})
return ep
}
func (ep *Entrypoint) SupportProxyProtocol() bool {
return ep.cfg.SupportProxyProtocol
}
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) ShortLinkMatcher() *ShortLinkMatcher {
return ep.shortLinkMatcher
}
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) (http.Handler, bool) {
return ep.servers.Load(addr)
}
func (ep *Entrypoint) SetFindRouteDomains(domains []string) {
if len(domains) == 0 {
ep.findRouteFunc = findRouteAnyDomain
@@ -74,7 +156,7 @@ func (ep *Entrypoint) SetMiddlewares(mws []map[string]any) error {
}
func (ep *Entrypoint) SetNotFoundRules(rules rules.Rules) {
ep.notFoundHandler = rules.BuildHandler(http.HandlerFunc(ep.serveNotFound))
ep.notFoundHandler = rules.BuildHandler(serveNotFound)
}
func (ep *Entrypoint) SetAccessLogger(parent task.Parent, cfg *accesslog.RequestLoggerConfig) (err error) {
@@ -91,111 +173,39 @@ func (ep *Entrypoint) SetAccessLogger(parent task.Parent, cfg *accesslog.Request
return err
}
func (ep *Entrypoint) FindRoute(s string) types.HTTPRoute {
return ep.findRouteFunc(s)
}
func (ep *Entrypoint) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if ep.accessLogger != nil {
rec := accesslog.GetResponseRecorder(w)
w = rec
defer func() {
ep.accessLogger.LogRequest(r, rec.Response())
accesslog.PutResponseRecorder(rec)
}()
}
route := ep.findRouteFunc(r.Host)
switch {
case route != nil:
r = routes.WithRouteContext(r, route)
if ep.middleware != nil {
ep.middleware.ServeHTTP(route.ServeHTTP, w, r)
} else {
route.ServeHTTP(w, r)
}
case ep.tryHandleShortLink(w, r):
return
case ep.notFoundHandler != nil:
ep.notFoundHandler.ServeHTTP(w, r)
default:
ep.serveNotFound(w, r)
}
}
func (ep *Entrypoint) tryHandleShortLink(w http.ResponseWriter, r *http.Request) (handled bool) {
host := r.Host
if before, _, ok := strings.Cut(host, ":"); ok {
host = before
}
if strings.EqualFold(host, common.ShortLinkPrefix) {
if ep.middleware != nil {
ep.middleware.ServeHTTP(ep.shortLinkMatcher.ServeHTTP, w, r)
} else {
ep.shortLinkMatcher.ServeHTTP(w, r)
}
return true
}
return false
}
func (ep *Entrypoint) serveNotFound(w http.ResponseWriter, r *http.Request) {
// Why use StatusNotFound instead of StatusBadRequest or StatusBadGateway?
// On nginx, when route for domain does not exist, it returns StatusBadGateway.
// Then scraper / scanners will know the subdomain is invalid.
// With StatusNotFound, they won't know whether it's the path, or the subdomain that is invalid.
if served := middleware.ServeStaticErrorPageFile(w, r); !served {
log.Error().
Str("method", r.Method).
Str("url", r.URL.String()).
Str("remote", r.RemoteAddr).
Msgf("not found: %s", r.Host)
errorPage, ok := errorpage.GetErrorPageByStatus(http.StatusNotFound)
if ok {
w.WriteHeader(http.StatusNotFound)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if _, err := w.Write(errorPage); err != nil {
log.Err(err).Msg("failed to write error page")
}
} else {
http.NotFound(w, r)
}
}
}
func findRouteAnyDomain(host string) types.HTTPRoute {
func findRouteAnyDomain(routes HTTPRoutes, host string) types.HTTPRoute {
idx := strings.IndexByte(host, '.')
if idx != -1 {
target := host[:idx]
if r, ok := routes.HTTP.Get(target); ok {
if r, ok := routes.Get(target); ok {
return r
}
}
if r, ok := routes.HTTP.Get(host); ok {
if r, ok := routes.Get(host); ok {
return r
}
// try striping the trailing :port from the host
if before, _, ok := strings.Cut(host, ":"); ok {
if r, ok := routes.HTTP.Get(before); ok {
if r, ok := routes.Get(before); ok {
return r
}
}
return nil
}
func findRouteByDomains(domains []string) func(host string) types.HTTPRoute {
return func(host string) types.HTTPRoute {
func findRouteByDomains(domains []string) func(routes HTTPRoutes, host string) types.HTTPRoute {
return func(routes HTTPRoutes, host string) types.HTTPRoute {
host, _, _ = strings.Cut(host, ":") // strip the trailing :port
for _, domain := range domains {
if target, ok := strings.CutSuffix(host, domain); ok {
if r, ok := routes.HTTP.Get(target); ok {
if r, ok := routes.Get(target); ok {
return r
}
}
}
// fallback to exact match
if r, ok := routes.HTTP.Get(host); ok {
if r, ok := routes.Get(host); ok {
return r
}
return nil

View File

@@ -10,9 +10,11 @@ import (
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/yusing/godoxy/internal/common"
. "github.com/yusing/godoxy/internal/entrypoint"
entrypoint "github.com/yusing/godoxy/internal/entrypoint/types"
"github.com/yusing/godoxy/internal/route"
"github.com/yusing/godoxy/internal/route/routes"
routeTypes "github.com/yusing/godoxy/internal/route/types"
"github.com/yusing/godoxy/internal/types"
"github.com/yusing/goutils/task"
@@ -48,13 +50,15 @@ func (t noopTransport) RoundTrip(req *http.Request) (*http.Response, error) {
}
func BenchmarkEntrypointReal(b *testing.B) {
var ep Entrypoint
task := task.NewTestTask(b)
ep := NewEntrypoint(task, nil)
req := http.Request{
Method: "GET",
URL: &url.URL{Path: "/", RawPath: "/"},
Host: "test.domain.tld",
}
ep.SetFindRouteDomains([]string{})
entrypoint.SetCtx(task, ep)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", "1")
@@ -77,48 +81,48 @@ func BenchmarkEntrypointReal(b *testing.B) {
b.Fatal(err)
}
r := &route.Route{
r, err := route.NewTestRoute(b, task, &route.Route{
Alias: "test",
Scheme: routeTypes.SchemeHTTP,
Host: host,
Port: route.Port{Proxy: portInt},
HealthCheck: types.HealthCheckConfig{Disable: true},
}
})
err = r.Validate()
if err != nil {
b.Fatal(err)
}
err = r.Start(task.RootTask("test", false))
if err != nil {
b.Fatal(err)
}
require.NoError(b, err)
require.False(b, r.ShouldExclude())
var w noopResponseWriter
server, ok := ep.GetServer(common.ProxyHTTPAddr)
if !ok {
b.Fatal("server not found")
}
b.ResetTimer()
for b.Loop() {
ep.ServeHTTP(&w, &req)
// if w.statusCode != http.StatusOK {
// b.Fatalf("status code is not 200: %d", w.statusCode)
// }
// if string(w.written) != "1" {
// b.Fatalf("written is not 1: %s", string(w.written))
// }
server.ServeHTTP(&w, &req)
if w.statusCode != http.StatusOK {
b.Fatalf("status code is not 200: %d", w.statusCode)
}
if string(w.written) != "1" {
b.Fatalf("written is not 1: %s", string(w.written))
}
}
}
func BenchmarkEntrypoint(b *testing.B) {
var ep Entrypoint
task := task.NewTestTask(b)
ep := NewEntrypoint(task, nil)
req := http.Request{
Method: "GET",
URL: &url.URL{Path: "/", RawPath: "/"},
Host: "test.domain.tld",
}
ep.SetFindRouteDomains([]string{})
entrypoint.SetCtx(task, ep)
r := &route.Route{
r, err := route.NewTestRoute(b, task, &route.Route{
Alias: "test",
Scheme: routeTypes.SchemeHTTP,
Host: "localhost",
@@ -128,29 +132,23 @@ func BenchmarkEntrypoint(b *testing.B) {
HealthCheck: types.HealthCheckConfig{
Disable: true,
},
}
})
err := r.Validate()
if err != nil {
b.Fatal(err)
}
require.NoError(b, err)
require.False(b, r.ShouldExclude())
err = r.Start(task.RootTask("test", false))
if err != nil {
b.Fatal(err)
}
rev, ok := routes.HTTP.Get("test")
if !ok {
b.Fatal("route not found")
}
rev.(types.ReverseProxyRoute).ReverseProxy().Transport = noopTransport{}
r.(types.ReverseProxyRoute).ReverseProxy().Transport = noopTransport{}
var w noopResponseWriter
server, ok := ep.GetServer(common.ProxyHTTPAddr)
if !ok {
b.Fatal("server not found")
}
b.ResetTimer()
for b.Loop() {
ep.ServeHTTP(&w, &req)
server.ServeHTTP(&w, &req)
if w.statusCode != http.StatusOK {
b.Fatalf("status code is not 200: %d", w.statusCode)
}

View File

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

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,121 @@
package entrypoint
import (
"errors"
"net"
"strconv"
"github.com/rs/zerolog/log"
"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:
if err := ep.AddHTTPRoute(r); err != nil {
log.Error().
Err(err).
Str("route", r.Key()).
Str("listen_url", r.ListenURL().String()).
Msg("failed to add HTTP route")
}
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()
var httpAddr, httpsAddr string
if host == "" {
httpAddr = common.ProxyHTTPAddr
httpsAddr = common.ProxyHTTPSAddr
} else {
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() (newSrv *httpServer, cancel bool) {
newSrv = newHTTPServer(ep)
err = newSrv.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"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yusing/godoxy/internal/common"
. "github.com/yusing/godoxy/internal/entrypoint"
"github.com/yusing/goutils/task"
)
func TestShortLinkMatcher_FQDNAlias(t *testing.T) {
ep := NewEntrypoint()
ep := NewEntrypoint(task.NewTestTask(t), nil)
matcher := ep.ShortLinkMatcher()
matcher.AddRoute("app.domain.com")
@@ -45,7 +47,7 @@ func TestShortLinkMatcher_FQDNAlias(t *testing.T) {
}
func TestShortLinkMatcher_SubdomainAlias(t *testing.T) {
ep := NewEntrypoint()
ep := NewEntrypoint(task.NewTestTask(t), nil)
matcher := ep.ShortLinkMatcher()
matcher.SetDefaultDomainSuffix(".example.com")
matcher.AddRoute("app")
@@ -70,7 +72,7 @@ func TestShortLinkMatcher_SubdomainAlias(t *testing.T) {
}
func TestShortLinkMatcher_NotFound(t *testing.T) {
ep := NewEntrypoint()
ep := NewEntrypoint(task.NewTestTask(t), nil)
matcher := ep.ShortLinkMatcher()
matcher.SetDefaultDomainSuffix(".example.com")
matcher.AddRoute("app")
@@ -93,7 +95,7 @@ func TestShortLinkMatcher_NotFound(t *testing.T) {
}
func TestShortLinkMatcher_AddDelRoute(t *testing.T) {
ep := NewEntrypoint()
ep := NewEntrypoint(task.NewTestTask(t), nil)
matcher := ep.ShortLinkMatcher()
matcher.SetDefaultDomainSuffix(".example.com")
@@ -131,7 +133,7 @@ func TestShortLinkMatcher_AddDelRoute(t *testing.T) {
}
func TestShortLinkMatcher_NoDefaultDomainSuffix(t *testing.T) {
ep := NewEntrypoint()
ep := NewEntrypoint(task.NewTestTask(t), nil)
matcher := ep.ShortLinkMatcher()
// no SetDefaultDomainSuffix called
@@ -158,15 +160,19 @@ func TestShortLinkMatcher_NoDefaultDomainSuffix(t *testing.T) {
}
func TestEntrypoint_ShortLinkDispatch(t *testing.T) {
ep := NewEntrypoint()
ep := NewEntrypoint(task.NewTestTask(t), nil)
ep.ShortLinkMatcher().SetDefaultDomainSuffix(".example.com")
ep.ShortLinkMatcher().AddRoute("app")
server := NewHTTPServer(ep)
err := server.Listen("localhost:0", HTTPProtoHTTP)
require.NoError(t, err)
t.Run("shortlink host", func(t *testing.T) {
req := httptest.NewRequest("GET", "/app", nil)
req.Host = common.ShortLinkPrefix
w := httptest.NewRecorder()
ep.ServeHTTP(w, req)
server.ServeHTTP(w, req)
assert.Equal(t, http.StatusTemporaryRedirect, w.Code)
assert.Equal(t, "https://app.example.com/", w.Header().Get("Location"))
@@ -176,7 +182,7 @@ func TestEntrypoint_ShortLinkDispatch(t *testing.T) {
req := httptest.NewRequest("GET", "/app", nil)
req.Host = common.ShortLinkPrefix + ":8080"
w := httptest.NewRecorder()
ep.ServeHTTP(w, req)
server.ServeHTTP(w, req)
assert.Equal(t, http.StatusTemporaryRedirect, w.Code)
assert.Equal(t, "https://app.example.com/", w.Header().Get("Location"))
@@ -186,7 +192,7 @@ func TestEntrypoint_ShortLinkDispatch(t *testing.T) {
req := httptest.NewRequest("GET", "/app", nil)
req.Host = "app.example.com"
w := httptest.NewRecorder()
ep.ServeHTTP(w, req)
server.ServeHTTP(w, req)
// Should not redirect, should try normal route lookup (which will 404)
assert.NotEqual(t, http.StatusTemporaryRedirect, w.Code)

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

View File

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

View File

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

View File

@@ -231,7 +231,7 @@ func TestEntrypointBypassRoute(t *testing.T) {
expect.NoError(t, err)
expect.NoError(t, err)
entry := entrypoint.NewEntrypoint()
entry := entrypoint.NewEntrypoint(task.NewTestTask(t), nil)
r := &route.Route{
Alias: "test-route",
Host: host,
@@ -260,7 +260,11 @@ func TestEntrypointBypassRoute(t *testing.T) {
recorder := httptest.NewRecorder()
req := httptest.NewRequest("GET", "http://test-route.example.com", nil)
entry.ServeHTTP(recorder, req)
server, ok := entry.GetServer(r.ListenURL().Host)
if !ok {
t.Fatal("server not found")
}
server.ServeHTTP(recorder, req)
expect.Equal(t, recorder.Code, http.StatusOK, "should bypass http redirect")
expect.Equal(t, recorder.Body.String(), "test")
expect.Equal(t, recorder.Header().Get("Test-Header"), "test-value")

View File

@@ -11,7 +11,7 @@ import (
"strings"
"time"
"github.com/yusing/godoxy/internal/route/routes"
entrypoint "github.com/yusing/godoxy/internal/entrypoint/types"
httputils "github.com/yusing/goutils/http"
ioutils "github.com/yusing/goutils/io"
)
@@ -66,7 +66,7 @@ func (m *crowdsecMiddleware) finalize() error {
// before implements RequestModifier.
func (m *crowdsecMiddleware) before(w http.ResponseWriter, r *http.Request) (proceed bool) {
// Build CrowdSec URL
crowdsecURL, err := m.buildCrowdSecURL()
crowdsecURL, err := m.buildCrowdSecURL(r.Context())
if err != nil {
Crowdsec.LogError(r).Err(err).Msg("failed to build CrowdSec URL")
w.WriteHeader(http.StatusInternalServerError)
@@ -167,10 +167,10 @@ func (m *crowdsecMiddleware) before(w http.ResponseWriter, r *http.Request) (pro
}
// buildCrowdSecURL constructs the CrowdSec server URL based on route or IP configuration
func (m *crowdsecMiddleware) buildCrowdSecURL() (string, error) {
func (m *crowdsecMiddleware) buildCrowdSecURL(ctx context.Context) (string, error) {
// Try to get route first
if m.Route != "" {
if route, ok := routes.HTTP.Get(m.Route); ok {
if route, ok := entrypoint.FromCtx(ctx).GetRoute(m.Route); ok {
// Using route name
targetURL := *route.TargetURL()
targetURL.Path = m.Endpoint

View File

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

View File

@@ -1,12 +1,17 @@
package route
import (
"github.com/yusing/godoxy/internal/route/routes"
"context"
entrypoint "github.com/yusing/godoxy/internal/entrypoint/types"
"github.com/yusing/godoxy/internal/types"
gperr "github.com/yusing/goutils/errs"
)
func checkExists(r types.Route) gperr.Error {
// checkExists checks if the route already exists in the entrypoint.
//
// Context must be passed from the parent task that carries the entrypoint value.
func checkExists(ctx context.Context, r types.Route) gperr.Error {
if r.UseLoadBalance() { // skip checking for load balanced routes
return nil
}
@@ -16,9 +21,9 @@ func checkExists(r types.Route) gperr.Error {
)
switch r := r.(type) {
case types.HTTPRoute:
existing, ok = routes.HTTP.Get(r.Key())
existing, ok = entrypoint.FromCtx(ctx).HTTPRoutes().Get(r.Key())
case types.StreamRoute:
existing, ok = routes.Stream.Get(r.Key())
existing, ok = entrypoint.FromCtx(ctx).StreamRoutes().Get(r.Key())
}
if ok {
return gperr.Errorf("route already exists: from provider %s and %s", existing.ProviderName(), r.ProviderName())

View File

@@ -6,12 +6,12 @@ import (
"path"
"path/filepath"
config "github.com/yusing/godoxy/internal/config/types"
"github.com/rs/zerolog/log"
entrypoint "github.com/yusing/godoxy/internal/entrypoint/types"
"github.com/yusing/godoxy/internal/health/monitor"
"github.com/yusing/godoxy/internal/logging/accesslog"
gphttp "github.com/yusing/godoxy/internal/net/gphttp"
"github.com/yusing/godoxy/internal/net/gphttp/middleware"
"github.com/yusing/godoxy/internal/route/routes"
"github.com/yusing/godoxy/internal/types"
gperr "github.com/yusing/goutils/errs"
"github.com/yusing/goutils/task"
@@ -120,20 +120,19 @@ func (s *FileServer) Start(parent task.Parent) gperr.Error {
if s.UseHealthCheck() {
s.HealthMon = monitor.NewMonitor(s)
if err := s.HealthMon.Start(s.task); err != nil {
return err
l := log.With().Str("type", "fileserver").Str("name", s.Name()).Logger()
gperr.LogWarn("health monitor error", err, &l)
s.HealthMon = nil
}
}
routes.HTTP.Add(s)
if state := config.WorkingState.Load(); state != nil {
state.ShortLinkMatcher().AddRoute(s.Alias)
ep := entrypoint.FromCtx(parent.Context())
if ep == nil {
err := gperr.New("entrypoint not initialized")
s.task.Finish(err)
return err
}
s.task.OnFinished("remove_route_from_http", func() {
routes.HTTP.Del(s)
if state := config.WorkingState.Load(); state != nil {
state.ShortLinkMatcher().DelRoute(s.Alias)
}
})
ep.AddRoute(s)
return nil
}

View File

@@ -6,7 +6,7 @@ import (
"github.com/yusing/godoxy/agent/pkg/agent"
"github.com/yusing/godoxy/agent/pkg/agentproxy"
config "github.com/yusing/godoxy/internal/config/types"
entrypoint "github.com/yusing/godoxy/internal/entrypoint/types"
"github.com/yusing/godoxy/internal/health/monitor"
"github.com/yusing/godoxy/internal/idlewatcher"
"github.com/yusing/godoxy/internal/logging/accesslog"
@@ -14,7 +14,6 @@ import (
"github.com/yusing/godoxy/internal/net/gphttp/loadbalancer"
"github.com/yusing/godoxy/internal/net/gphttp/middleware"
nettypes "github.com/yusing/godoxy/internal/net/types"
"github.com/yusing/godoxy/internal/route/routes"
route "github.com/yusing/godoxy/internal/route/types"
"github.com/yusing/godoxy/internal/types"
gperr "github.com/yusing/goutils/errs"
@@ -159,23 +158,22 @@ func (r *ReveseProxyRoute) Start(parent task.Parent) gperr.Error {
if r.HealthMon != nil {
if err := r.HealthMon.Start(r.task); err != nil {
return err
gperr.LogWarn("health monitor error", err, &r.rp.Logger)
r.HealthMon = nil
}
}
ep := entrypoint.FromCtx(parent.Context())
if ep == nil {
err := gperr.New("entrypoint not initialized")
r.task.Finish(err)
return err
}
if r.UseLoadBalance() {
r.addToLoadBalancer(parent)
r.addToLoadBalancer(parent, ep)
} else {
routes.HTTP.Add(r)
if state := config.WorkingState.Load(); state != nil {
state.ShortLinkMatcher().AddRoute(r.Alias)
}
r.task.OnCancel("remove_route", func() {
routes.HTTP.Del(r)
if state := config.WorkingState.Load(); state != nil {
state.ShortLinkMatcher().DelRoute(r.Alias)
}
})
ep.AddRoute(r)
}
return nil
}
@@ -187,12 +185,12 @@ func (r *ReveseProxyRoute) ServeHTTP(w http.ResponseWriter, req *http.Request) {
var lbLock sync.Mutex
func (r *ReveseProxyRoute) addToLoadBalancer(parent task.Parent) {
func (r *ReveseProxyRoute) addToLoadBalancer(parent task.Parent, ep entrypoint.Entrypoint) {
var lb *loadbalancer.LoadBalancer
cfg := r.LoadBalance
lbLock.Lock()
l, ok := routes.HTTP.Get(cfg.Link)
l, ok := ep.HTTPRoutes().Get(cfg.Link)
var linked *ReveseProxyRoute
if ok {
lbLock.Unlock()
@@ -214,16 +212,7 @@ func (r *ReveseProxyRoute) addToLoadBalancer(parent task.Parent) {
handler: lb,
}
linked.SetHealthMonitor(lb)
routes.HTTP.AddKey(cfg.Link, linked)
if state := config.WorkingState.Load(); state != nil {
state.ShortLinkMatcher().AddRoute(cfg.Link)
}
r.task.OnFinished("remove_loadbalancer_route", func() {
routes.HTTP.DelKey(cfg.Link)
if state := config.WorkingState.Load(); state != nil {
state.ShortLinkMatcher().DelRoute(cfg.Link)
}
})
ep.AddRoute(linked)
lbLock.Unlock()
}
r.loadBalancer = lb

View File

@@ -18,6 +18,7 @@ import (
"github.com/yusing/godoxy/internal/agentpool"
config "github.com/yusing/godoxy/internal/config/types"
"github.com/yusing/godoxy/internal/docker"
entrypoint "github.com/yusing/godoxy/internal/entrypoint/types"
"github.com/yusing/godoxy/internal/health/monitor"
"github.com/yusing/godoxy/internal/homepage"
iconlist "github.com/yusing/godoxy/internal/homepage/icons/list"
@@ -33,7 +34,6 @@ import (
"github.com/yusing/godoxy/internal/common"
"github.com/yusing/godoxy/internal/logging/accesslog"
"github.com/yusing/godoxy/internal/route/routes"
"github.com/yusing/godoxy/internal/route/rules"
rulepresets "github.com/yusing/godoxy/internal/route/rules/presets"
route "github.com/yusing/godoxy/internal/route/types"
@@ -46,7 +46,6 @@ type (
Host string `json:"host,omitempty"`
Port route.Port `json:"port"`
// for TCP and UDP routes, bind address to listen on
Bind string `json:"bind,omitempty" validate:"omitempty,ip_addr" extensions:"x-nullable"`
Root string `json:"root,omitempty"`
@@ -200,7 +199,11 @@ func (r *Route) validate() gperr.Error {
if (r.Proxmox == nil || r.Proxmox.Node == "" || r.Proxmox.VMID == nil) && r.Container == nil {
wasNotNil := r.Proxmox != nil
proxmoxProviders := config.WorkingState.Load().Value().Providers.Proxmox
workingState := config.WorkingState.Load()
var proxmoxProviders []*proxmox.Config
if workingState != nil { // nil in tests
proxmoxProviders = workingState.Value().Providers.Proxmox
}
if len(proxmoxProviders) > 0 {
// it's fine if ip is nil
hostname := r.Host
@@ -274,24 +277,19 @@ func (r *Route) validate() gperr.Error {
var impl types.Route
var err gperr.Error
switch r.Scheme {
case route.SchemeFileServer:
r.Host = ""
r.Port.Proxy = 0
r.ProxyURL = gperr.Collect(&errs, nettypes.ParseURL, "file://"+r.Root)
case route.SchemeHTTP, route.SchemeHTTPS, route.SchemeH2C:
if r.Port.Listening != 0 {
errs.Addf("unexpected listening port for %s scheme", r.Scheme)
}
if r.ShouldExclude() {
r.ProxyURL = gperr.Collect(&errs, nettypes.ParseURL, fmt.Sprintf("%s://%s", r.Scheme, net.JoinHostPort(r.Host, strconv.Itoa(r.Port.Proxy))))
case route.SchemeTCP, route.SchemeUDP:
if r.ShouldExclude() {
// should exclude, we don't care the scheme here.
} else {
switch r.Scheme {
case route.SchemeFileServer:
r.Host = ""
r.Port.Proxy = 0
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, "file://"+r.Root)
case route.SchemeHTTP, route.SchemeHTTPS, route.SchemeH2C:
r.LisURL = gperr.Collect(&errs, nettypes.ParseURL, fmt.Sprintf("https://%s", net.JoinHostPort(r.Bind, strconv.Itoa(r.Port.Listening))))
r.ProxyURL = gperr.Collect(&errs, nettypes.ParseURL, fmt.Sprintf("%s://%s", r.Scheme, net.JoinHostPort(r.Host, strconv.Itoa(r.Port.Proxy))))
} else {
if r.Bind == "" {
r.Bind = "0.0.0.0"
}
case route.SchemeTCP, route.SchemeUDP:
bindIP := net.ParseIP(r.Bind)
remoteIP := net.ParseIP(r.Host)
toNetwork := func(ip net.IP, scheme route.Scheme) string {
@@ -360,8 +358,8 @@ func (r *Route) validateRules() error {
return errors.New("rule preset `webui.yml` not found")
}
r.Rules = rules
return nil
}
return nil
}
if r.RuleFile != "" && len(r.Rules) > 0 {
@@ -504,7 +502,7 @@ func (r *Route) start(parent task.Parent) gperr.Error {
// skip checking for excluded routes
excluded := r.ShouldExclude()
if !excluded {
if err := checkExists(r); err != nil {
if err := checkExists(parent.Context(), r); err != nil {
return err
}
}
@@ -518,15 +516,22 @@ func (r *Route) start(parent task.Parent) gperr.Error {
return err
}
} else {
r.task = parent.Subtask("excluded."+r.Name(), true)
routes.Excluded.Add(r.impl)
ep := entrypoint.FromCtx(parent.Context())
if ep == nil {
return gperr.New("entrypoint not initialized")
}
r.task = parent.Subtask("excluded."+r.Name(), false)
ep.ExcludedRoutes().Add(r.impl)
r.task.OnCancel("remove_route_from_excluded", func() {
routes.Excluded.Del(r.impl)
ep.ExcludedRoutes().Del(r.impl)
})
if r.UseHealthCheck() {
r.HealthMon = monitor.NewMonitor(r.impl)
err := r.HealthMon.Start(r.task)
return err
if err != nil {
return err
}
}
}
return nil
@@ -564,6 +569,10 @@ func (r *Route) ProviderName() string {
return r.Provider
}
func (r *Route) ListenURL() *nettypes.URL {
return r.LisURL
}
func (r *Route) TargetURL() *nettypes.URL {
return r.ProxyURL
}
@@ -932,6 +941,13 @@ func (r *Route) Finalize() {
}
}
switch r.Scheme {
case route.SchemeTCP, route.SchemeUDP:
if r.Bind == "" {
r.Bind = "0.0.0.0"
}
}
r.Port.Listening, r.Port.Proxy = lp, pp
workingState := config.WorkingState.Load()
@@ -942,6 +958,7 @@ func (r *Route) Finalize() {
panic("bug: working state is nil")
}
// TODO: default value from context
r.HealthCheck.ApplyDefaults(config.WorkingState.Load().Value().Defaults.HealthCheck)
}

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

@@ -10,6 +10,7 @@ import (
"strings"
"github.com/rs/zerolog"
entrypoint "github.com/yusing/godoxy/internal/entrypoint/types"
"github.com/yusing/godoxy/internal/logging"
gphttp "github.com/yusing/godoxy/internal/net/gphttp"
nettypes "github.com/yusing/godoxy/internal/net/types"
@@ -197,9 +198,10 @@ var commands = map[string]struct {
build: func(args any) CommandHandler {
route := args.(string)
return TerminatingCommand(func(w http.ResponseWriter, req *http.Request) error {
r, ok := routes.HTTP.Get(route)
ep := entrypoint.FromCtx(req.Context())
r, ok := ep.HTTPRoutes().Get(route)
if !ok {
excluded, has := routes.Excluded.Get(route)
excluded, has := ep.ExcludedRoutes().Get(route)
if has {
r, ok = excluded.(types.HTTPRoute)
}

View File

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

View File

@@ -7,9 +7,9 @@ import (
"github.com/pires/go-proxyproto"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/yusing/godoxy/internal/acl"
acl "github.com/yusing/godoxy/internal/acl/types"
"github.com/yusing/godoxy/internal/agentpool"
"github.com/yusing/godoxy/internal/entrypoint"
entrypoint "github.com/yusing/godoxy/internal/entrypoint/types"
nettypes "github.com/yusing/godoxy/internal/net/types"
ioutils "github.com/yusing/goutils/io"
"go.uber.org/atomic"
@@ -51,13 +51,17 @@ func (s *TCPTCPStream) ListenAndServe(ctx context.Context, preDial, onRead netty
return
}
if acl, ok := ctx.Value(acl.ContextKey{}).(*acl.Config); ok {
// TODO: add to entrypoint
if acl := acl.FromCtx(ctx); acl != nil {
log.Debug().Str("listener", s.listener.Addr().String()).Msg("wrapping listener with ACL")
s.listener = acl.WrapTCP(s.listener)
}
if proxyProto := entrypoint.ActiveConfig.Load().SupportProxyProtocol; proxyProto {
s.listener = &proxyproto.Listener{Listener: s.listener}
if ep := entrypoint.FromCtx(ctx); ep != nil {
if proxyProto := ep.SupportProxyProtocol(); proxyProto {
s.listener = &proxyproto.Listener{Listener: s.listener}
}
}
s.preDial = preDial

View File

@@ -11,7 +11,7 @@ import (
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/yusing/godoxy/internal/acl"
acl "github.com/yusing/godoxy/internal/acl/types"
"github.com/yusing/godoxy/internal/agentpool"
nettypes "github.com/yusing/godoxy/internal/net/types"
"github.com/yusing/goutils/synk"
@@ -82,10 +82,11 @@ func (s *UDPUDPStream) ListenAndServe(ctx context.Context, preDial, onRead netty
return
}
s.listener = l
if acl, ok := ctx.Value(acl.ContextKey{}).(*acl.Config); ok {
if acl := acl.FromCtx(ctx); acl != nil {
log.Debug().Str("listener", s.listener.LocalAddr().String()).Msg("wrapping listener with ACL")
s.listener = acl.WrapUDP(s.listener)
}
// TODO: add to entrypoint
s.preDial = preDial
s.onRead = onRead
go s.listen(ctx)

View File

@@ -0,0 +1,22 @@
package route
import (
"github.com/yusing/godoxy/internal/types"
"github.com/yusing/goutils/task"
)
func NewTestRoute[T interface{ Helper() }](t T, task task.Parent, base *Route) (types.Route, error) {
t.Helper()
err := base.Validate()
if err != nil {
return nil, err
}
err = base.Start(task)
if err != nil {
return nil, err
}
return base.impl, nil
}

View File

@@ -74,6 +74,19 @@ type (
Config *LoadBalancerConfig `json:"config"`
Pool map[string]any `json:"pool"`
} // @name HealthExtra
HealthInfoWithoutDetail struct {
Status HealthStatus `json:"status" swaggertype:"string" enums:"healthy,unhealthy,napping,starting,error,unknown"`
Uptime time.Duration `json:"uptime" swaggertype:"number"` // uptime in milliseconds
Latency time.Duration `json:"latency" swaggertype:"number"` // latency in microseconds
} // @name HealthInfoWithoutDetail
HealthInfo struct {
HealthInfoWithoutDetail
Detail string `json:"detail"`
} // @name HealthInfo
HealthMap = map[string]HealthStatusString // @name HealthMap
)
const (

View File

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