Compare commits

..

32 Commits

Author SHA1 Message Date
yusing
6bb36e2e83 feat(autocert): generate unique ACME key paths per CA directory URL
Previously, ACME keys were stored at a single default path regardless of
which CA directory URL was configured. This caused key conflicts when
using multiple different ACME CAs.

Now, the key path is derived from a SHA256 hash of the CA directory URL,
allowing each CA to have its own key file:
- Default CA (Let's Encrypt): certs/acme.key
- Custom CA: certs/acme_<url_hash_16chars>.key

This enables running certificates against multiple ACME providers without
key collision issues.
2026-01-31 16:48:18 +08:00
yusing
4b57ef1cad fix(autocert): correct ObtainCert error handling
- ObtainCertIfNotExistsAll longer fail on fs.ErrNotExists
- Separate public LoadCertAll (loads all providers) from private loadCert
- LoadCertAll now uses allProviders() for iteration
- Updated tests to use LoadCertAll
2026-01-31 16:17:07 +08:00
yusing
3850a4a6e7 Merge branch 'main' into dev 2026-01-30 00:34:33 +08:00
yusing
da3c624582 Merge branch 'main' into dev 2026-01-27 00:39:19 +08:00
yusing
157a83bef8 Merge branch 'main' into dev 2026-01-27 00:06:26 +08:00
yusing
d61bd5ce51 chore: update go to 1.25.6 and dependencies 2026-01-16 18:34:39 +08:00
yusing
bad3e9a989 chore(README): remove zeabur badge 2026-01-16 14:21:37 +08:00
yusing
9adfd73121 fix(health): correct docker fallback url 2026-01-16 14:09:07 +08:00
yusing
4a652aaf55 fix(swagger): explicit set type names for IconFetchResult and IconMetaSearch 2026-01-16 12:26:14 +08:00
yusing
16c986978d chore(idlewatcher): remove junk comment 2026-01-16 11:35:40 +08:00
yusing
107b7c5f64 Merge branch 'main' into dev 2026-01-16 10:10:21 +08:00
yusing
818d75c8b7 Merge branch 'main' into dev 2026-01-04 12:43:18 +08:00
yusing
f1bc5de3ea Merge branch 'main' into dev 2026-01-04 12:28:32 +08:00
yusing
425ff0b25c Merge branch 'main' into dev 2026-01-02 22:12:11 +08:00
yusing
1f6614e337 refactor(config): correct logic in InitFromFile 2026-01-02 21:57:31 +08:00
yusing
9ba102a33d chore: update goutils 2026-01-02 21:56:55 +08:00
yusing
31c616246b Merge branch 'main' into dev 2026-01-02 15:49:20 +08:00
yusing
390859bd1f Merge branch 'main' into dev 2026-01-02 15:43:04 +08:00
yusing
243662c13b Merge branch 'main' into dev 2026-01-01 18:25:56 +08:00
yusing
588e9f5b18 Merge branch 'main' into dev 2025-12-30 22:01:48 +08:00
yusing
a3bf88cc9c chore(goutils): update subproject commit reference to 51a75d68 2025-12-30 22:00:28 +08:00
yusing
9b1af57859 Merge branch 'main' into dev 2025-12-30 21:52:24 +08:00
yusing
bb7471cc9c fix(tests/metrics): correct syntax error 2025-12-30 21:52:22 +08:00
yusing
a403b2b629 Merge branch 'main' into dev 2025-12-23 12:30:26 +08:00
yusing
54b9e7f236 Merge branch 'main' into dev 2025-12-22 17:15:02 +08:00
yusing
45b89cd452 fix(oidc): add trailing slash to OIDCAuthBasePath to work with paths like /authorize 2025-12-22 17:13:42 +08:00
yusing
72fea96c7b Merge branch 'main' into dev 2025-12-22 12:10:31 +08:00
yusing
aef646be6f Merge branch 'main' into dev 2025-12-22 10:45:44 +08:00
yusing
135a4ff6c7 Merge branch 'main' into dev 2025-12-20 19:31:12 +08:00
yusing
5f418b62c7 chore: upgrade dependencies 2025-12-17 17:37:58 +08:00
yusing
bd92c46375 refactor(http): enhance health check error logic by treating all 5xx as unhealthy 2025-12-17 12:24:04 +08:00
yusing
21a23dd147 fix(idlewatcher): directly serve the request on ready instead of redirecting 2025-12-17 11:48:22 +08:00
82 changed files with 1308 additions and 1842 deletions

View File

@@ -1,5 +1,5 @@
# Stage 1: deps
FROM golang:1.25.7-alpine AS deps
FROM golang:1.25.6-alpine AS deps
HEALTHCHECK NONE
# package version does not matter

View File

@@ -92,7 +92,7 @@ docker-build-test:
go_ver := $(shell go version | cut -d' ' -f3 | cut -d'o' -f2)
files := $(shell find . -name go.mod -type f -or -name Dockerfile -type f)
gomod_paths := $(shell find . -name go.mod -type f | grep -vE '^./internal/(go-oidc|go-proxmox|gopsutil)/' | xargs dirname)
gomod_paths := $(shell find . -name go.mod -type f | xargs dirname)
update-go:
for file in ${files}; do \

View File

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

View File

@@ -1,6 +1,6 @@
module github.com/yusing/godoxy/agent
go 1.25.7
go 1.25.6
exclude (
github.com/moby/moby/api v1.53.0 // allow older daemon versions
@@ -90,7 +90,7 @@ require (
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.69.0 // indirect
github.com/yusing/ds v0.4.1 // indirect
github.com/yusing/gointernals v0.1.18 // indirect
github.com/yusing/gointernals v0.1.16 // indirect
github.com/yusing/goutils/http/reverseproxy v0.0.0-20260129081554-24e52ede7468 // indirect
github.com/yusing/goutils/http/websocket v0.0.0-20260129081554-24e52ede7468 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect

View File

@@ -210,8 +210,8 @@ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZ
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yusing/ds v0.4.1 h1:syMCh7hO6Yw8xfcFkEaln3W+lVeWB/U/meYv6Wf2/Ig=
github.com/yusing/ds v0.4.1/go.mod h1:XhKV4l7cZwBbbl7lRzNC9zX27zvCM0frIwiuD40ULRk=
github.com/yusing/gointernals v0.1.18 h1:ou8/0tPURUgAOBJu3TN/iWF4S/5ZYQaap+rVkaJNUMw=
github.com/yusing/gointernals v0.1.18/go.mod h1:B/0FVXt4WPmgzVy3ynzkqKi+BSGaJVmwCJBRXYapo34=
github.com/yusing/gointernals v0.1.16 h1:GrhZZdxzA+jojLEqankctJrOuAYDb7kY1C93S1pVR34=
github.com/yusing/gointernals v0.1.16/go.mod h1:B/0FVXt4WPmgzVy3ynzkqKi+BSGaJVmwCJBRXYapo34=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=

View File

@@ -1,4 +1,4 @@
FROM golang:1.25.7-alpine AS builder
FROM golang:1.25.6-alpine AS builder
HEALTHCHECK NONE

View File

@@ -1,3 +1,3 @@
module github.com/yusing/godoxy/cmd/bench_server
go 1.25.7
go 1.25.6

View File

@@ -181,6 +181,7 @@ func newApiHandler(debugMux *debugMux) *gin.Engine {
registerGinRoute(v1, "GET", "Route favicon", "/favicon", apiV1.FavIcon)
registerGinRoute(v1, "GET", "Route health", "/health", apiV1.Health)
registerGinRoute(v1, "GET", "List icons", "/icons", apiV1.Icons)
registerGinRoute(v1, "POST", "Config reload", "/reload", apiV1.Reload)
registerGinRoute(v1, "GET", "Route stats", "/stats", apiV1.Stats)
route := v1.Group("/route")

View File

@@ -1,4 +1,4 @@
FROM golang:1.25.7-alpine AS builder
FROM golang:1.25.6-alpine AS builder
HEALTHCHECK NONE

View File

@@ -1,6 +1,6 @@
module github.com/yusing/godoxy/cmd/h2c_test_server
go 1.25.7
go 1.25.6
require golang.org/x/net v0.49.0

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,9 +14,12 @@ 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"
)
@@ -48,6 +51,7 @@ func main() {
parallel(
dnsproviders.InitProviders,
iconlist.InitCache,
systeminfo.Poller.Start,
middleware.LoadComposeFiles,
)
@@ -62,20 +66,35 @@ func main() {
err := config.Load()
if err != nil {
var criticalErr config.CriticalError
if errors.As(err, &criticalErr) {
gperr.LogFatal("critical error in config", criticalErr)
}
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)

View File

@@ -31,8 +31,8 @@ services:
user: ${GODOXY_UID:-1000}:${GODOXY_GID:-1000}
read_only: true
tmpfs:
- /tmp:rw
- /app/node_modules/.cache:rw
- /app/.next/cache # next image caching
# for lite variant, do not change uid/gid
# - /var/cache/nginx:uid=101,gid=101
# - /run:uid=101,gid=101

4
go.mod
View File

@@ -1,6 +1,6 @@
module github.com/yusing/godoxy
go 1.25.7
go 1.25.6
exclude (
github.com/moby/moby/api v1.53.0 // allow older daemon versions
@@ -59,7 +59,7 @@ require (
github.com/yusing/ds v0.4.1 // data structures and algorithms
github.com/yusing/godoxy/agent v0.0.0-20260129101716-0f13004ad6ba
github.com/yusing/godoxy/internal/dnsproviders v0.0.0-20260129101716-0f13004ad6ba
github.com/yusing/gointernals v0.1.18
github.com/yusing/gointernals v0.1.16
github.com/yusing/goutils v0.7.0
github.com/yusing/goutils/http/reverseproxy v0.0.0-20260129081554-24e52ede7468
github.com/yusing/goutils/http/websocket v0.0.0-20260129081554-24e52ede7468

4
go.sum
View File

@@ -329,8 +329,8 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfS
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusing/ds v0.4.1 h1:syMCh7hO6Yw8xfcFkEaln3W+lVeWB/U/meYv6Wf2/Ig=
github.com/yusing/ds v0.4.1/go.mod h1:XhKV4l7cZwBbbl7lRzNC9zX27zvCM0frIwiuD40ULRk=
github.com/yusing/gointernals v0.1.18 h1:ou8/0tPURUgAOBJu3TN/iWF4S/5ZYQaap+rVkaJNUMw=
github.com/yusing/gointernals v0.1.18/go.mod h1:B/0FVXt4WPmgzVy3ynzkqKi+BSGaJVmwCJBRXYapo34=
github.com/yusing/gointernals v0.1.16 h1:GrhZZdxzA+jojLEqankctJrOuAYDb7kY1C93S1pVR34=
github.com/yusing/gointernals v0.1.16/go.mod h1:B/0FVXt4WPmgzVy3ynzkqKi+BSGaJVmwCJBRXYapo34=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=

Submodule goutils updated: a270ef85af...52ea531e95

View File

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

View File

@@ -1,9 +0,0 @@
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

@@ -1,16 +0,0 @@
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

@@ -76,6 +76,7 @@ func NewHandler(requireAuth bool) *gin.Engine {
v1.GET("/favicon", apiV1.FavIcon)
v1.GET("/health", apiV1.Health)
v1.GET("/icons", apiV1.Icons)
v1.POST("/reload", apiV1.Reload)
v1.GET("/stats", apiV1.Stats)
route := v1.Group("/route")

View File

@@ -1,7 +1,6 @@
package agentapi
import (
"context"
"fmt"
"net/http"
"os"
@@ -37,9 +36,6 @@ type VerifyNewAgentRequest struct {
// @Failure 500 {object} ErrorResponse
// @Router /agent/verify [post]
func Verify(c *gin.Context) {
// avoid timeout waiting for response headers
c.Status(http.StatusContinue)
var request VerifyNewAgentRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err))
@@ -64,7 +60,7 @@ func Verify(c *gin.Context) {
return
}
nRoutesAdded, err := verifyNewAgent(c.Request.Context(), request.Host, ca, client, request.ContainerRuntime)
nRoutesAdded, err := verifyNewAgent(request.Host, ca, client, request.ContainerRuntime)
if err != nil {
c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err))
return
@@ -86,7 +82,7 @@ func Verify(c *gin.Context) {
var errAgentAlreadyExists = gperr.New("agent already exists")
func verifyNewAgent(ctx context.Context, host string, ca agent.PEMPair, client agent.PEMPair, containerRuntime agent.ContainerRuntime) (int, gperr.Error) {
func verifyNewAgent(host string, ca agent.PEMPair, client agent.PEMPair, containerRuntime agent.ContainerRuntime) (int, gperr.Error) {
var agentCfg agent.AgentConfig
agentCfg.Addr = host
agentCfg.Runtime = containerRuntime
@@ -103,7 +99,7 @@ func verifyNewAgent(ctx context.Context, host string, ca agent.PEMPair, client a
return 0, errAgentAlreadyExists
}
err := agentCfg.InitWithCerts(ctx, ca.Cert, client.Cert, client.Key)
err := agentCfg.InitWithCerts(cfgState.Context(), ca.Cert, client.Cert, client.Key)
if err != nil {
return 0, gperr.Wrap(err, "failed to initialize agent config")
}

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"
entrypoint "github.com/yusing/godoxy/internal/entrypoint/types"
"github.com/yusing/godoxy/internal/route/routes"
"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 = entrypoint.FromCtx(c.Request.Context()).GetRoute(id)
route, ok = routes.GetIncludeExcluded(id)
if ok {
cont := route.ContainerInfo()
if cont == nil {

View File

@@ -1171,10 +1171,7 @@
"200": {
"description": "Health info by route name",
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/HealthStatusString"
}
"$ref": "#/definitions/HealthMap"
}
},
"403": {
@@ -2823,6 +2820,43 @@
"operationId": "tail"
}
},
"/reload": {
"post": {
"description": "Reload config",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"v1"
],
"summary": "Reload config",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/SuccessResponse"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
},
"x-id": "reload",
"operationId": "reload"
}
},
"/route/by_provider": {
"get": {
"description": "List routes by provider",
@@ -4037,6 +4071,14 @@
"x-nullable": false,
"x-omitempty": false
},
"HealthMap": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/HealthStatusString"
},
"x-nullable": false,
"x-omitempty": false
},
"HealthStatusString": {
"type": "string",
"enum": [
@@ -5287,6 +5329,7 @@
"x-omitempty": false
},
"bind": {
"description": "for TCP and UDP routes, bind address to listen on",
"type": "string",
"x-nullable": true
},

View File

@@ -419,6 +419,10 @@ definitions:
url:
type: string
type: object
HealthMap:
additionalProperties:
$ref: '#/definitions/HealthStatusString'
type: object
HealthStatusString:
enum:
- unknown
@@ -1003,6 +1007,7 @@ definitions:
alias:
type: string
bind:
description: for TCP and UDP routes, bind address to listen on
type: string
x-nullable: true
container:
@@ -1802,12 +1807,12 @@ definitions:
type: string
kernel_version:
type: string
load_avg_5m:
type: string
load_avg_15m:
type: string
load_avg_1m:
type: string
load_avg_5m:
type: string
mem_pct:
type: string
mem_total:
@@ -2670,9 +2675,7 @@ paths:
"200":
description: Health info by route name
schema:
additionalProperties:
$ref: '#/definitions/HealthStatusString'
type: object
$ref: '#/definitions/HealthMap'
"403":
description: Forbidden
schema:
@@ -3787,6 +3790,30 @@ paths:
- proxmox
- websocket
x-id: tail
/reload:
post:
consumes:
- application/json
description: Reload config
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/SuccessResponse'
"403":
description: Forbidden
schema:
$ref: '#/definitions/ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ErrorResponse'
summary: Reload config
tags:
- v1
x-id: reload
/route/{which}:
get:
consumes:

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,11 +73,7 @@ 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
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)
r, ok := routes.HTTP.Get(alias)
if !ok {
return iconfetch.FetchResultWithErrorf(http.StatusNotFound, "route not found")
}

View File

@@ -5,10 +5,11 @@ import (
"time"
"github.com/gin-gonic/gin"
entrypoint "github.com/yusing/godoxy/internal/entrypoint/types"
"github.com/yusing/goutils/apitypes"
"github.com/yusing/godoxy/internal/route/routes"
"github.com/yusing/goutils/http/httpheaders"
"github.com/yusing/goutils/http/websocket"
_ "github.com/yusing/goutils/apitypes"
)
// @x-id "health"
@@ -18,21 +19,16 @@ import (
// @Tags v1,websocket
// @Accept json
// @Produce json
// @Success 200 {object} map[string]types.HealthStatusString "Health info by route name"
// @Success 200 {object} routes.HealthMap "Health info by route name"
// @Failure 403 {object} apitypes.ErrorResponse
// @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 ep.GetHealthInfoSimple(), nil
return routes.GetHealthInfoSimple(), nil
})
} else {
c.JSON(http.StatusOK, ep.GetHealthInfoSimple())
c.JSON(http.StatusOK, routes.GetHealthInfoSimple())
}
}

View File

@@ -4,11 +4,10 @@ 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"
@@ -22,20 +21,15 @@ import (
// @Failure 403 {object} apitypes.ErrorResponse
// @Router /homepage/categories [get]
func Categories(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
}
c.JSON(http.StatusOK, HomepageCategories(ep))
c.JSON(http.StatusOK, HomepageCategories())
}
func HomepageCategories(ep entrypoint.Entrypoint) []string {
func HomepageCategories() []string {
check := make(map[string]struct{})
categories := make([]string, 0)
categories = append(categories, homepage.CategoryAll)
categories = append(categories, homepage.CategoryFavorites)
for _, r := range ep.HTTPRoutes().Iter {
for _, r := range routes.HTTP.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,30 +53,29 @@ 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(ep, proto, hostname, &request), nil
return HomepageItems(proto, hostname, &request), nil
})
} else {
c.JSON(http.StatusOK, HomepageItems(ep, proto, hostname, &request))
c.JSON(http.StatusOK, HomepageItems(proto, hostname, &request))
}
}
func HomepageItems(ep entrypoint.Entrypoint, proto, hostname string, request *HomepageItemsRequest) homepage.Homepage {
func HomepageItems(proto, hostname string, request *HomepageItemsRequest) homepage.Homepage {
switch proto {
case "http", "https":
default:
proto = "http"
}
hp := homepage.NewHomepageMap(ep.HTTPRoutes().Size())
hp := homepage.NewHomepageMap(routes.HTTP.Size())
if strings.Count(hostname, ".") > 1 {
_, hostname, _ = strings.Cut(hostname, ".") // remove the subdomain
}
for _, r := range ep.HTTPRoutes().Iter {
for _, r := range routes.HTTP.Iter {
if request.Provider != "" && r.ProviderName() != request.Provider {
continue
}

28
internal/api/v1/reload.go Normal file
View File

@@ -0,0 +1,28 @@
package v1
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/yusing/godoxy/internal/config"
apitypes "github.com/yusing/goutils/apitypes"
)
// @x-id "reload"
// @BasePath /api/v1
// @Summary Reload config
// @Description Reload config
// @Tags v1
// @Accept json
// @Produce json
// @Success 200 {object} apitypes.SuccessResponse
// @Failure 403 {object} apitypes.ErrorResponse
// @Failure 500 {object} apitypes.ErrorResponse
// @Router /reload [post]
func Reload(c *gin.Context) {
if err := config.Reload(); err != nil {
c.Error(apitypes.InternalServerError(err, "failed to reload config"))
return
}
c.JSON(http.StatusOK, apitypes.Success("config reloaded"))
}

View File

@@ -4,11 +4,10 @@ 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
@@ -25,10 +24,5 @@ type RoutesByProvider map[string][]route.Route
// @Failure 500 {object} apitypes.ErrorResponse
// @Router /route/by_provider [get]
func ByProvider(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
}
c.JSON(http.StatusOK, ep.RoutesByProvider())
c.JSON(http.StatusOK, routes.ByProvider())
}

View File

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

View File

@@ -222,9 +222,8 @@ func (p *Provider) ObtainCertIfNotExistsAll() error {
})
}
err := errs.Wait().Error()
p.rebuildSNIMatcher()
return err
return errs.Wait().Error()
}
// obtainCertIfNotExists obtains a new certificate for this provider if it does not exist.
@@ -262,10 +261,7 @@ func (p *Provider) ObtainCertAll() error {
return nil
})
}
err := errs.Wait().Error()
p.rebuildSNIMatcher()
return err
return errs.Wait().Error()
}
// ObtainCert renews existing certificate or obtains a new certificate for this provider.

View File

@@ -1,16 +0,0 @@
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,6 +7,7 @@ import (
)
type Provider interface {
Setup() error
GetCert(*tls.ClientHelloInfo) (*tls.Certificate, error)
ScheduleRenewalAll(task.Parent)
ObtainCertAll() error

View File

@@ -54,7 +54,7 @@ type State interface {
Task() *task.Task
Context() context.Context
Value() *Config
Entrypoint() entrypoint.Entrypoint
EntrypointHandler() http.Handler
ShortLinkMatcher() config.ShortLinkMatcher
AutoCertProvider() server.CertProvider
LoadOrStoreProvider(key string, value types.RouteProvider) (actual types.RouteProvider, loaded bool)
@@ -62,12 +62,6 @@ type State interface {
IterProviders() iter.Seq2[string, types.RouteProvider]
StartProviders() error
NumProviders() int
// Lifecycle management
StartAPIServers()
StartMetrics()
FlushTmpLog()
}
```
@@ -220,15 +214,12 @@ Configuration supports hot-reloading via editing `config/config.yml`.
- `internal/acl` - Access control configuration
- `internal/autocert` - SSL certificate management
- `internal/entrypoint` - HTTP entrypoint setup (now via interface)
- `internal/entrypoint` - HTTP entrypoint setup
- `internal/route/provider` - Route providers (Docker, file, agent)
- `internal/maxmind` - GeoIP configuration
- `internal/notif` - Notification providers
- `internal/proxmox` - LXC container management
- `internal/homepage/types` - Dashboard configuration
- `internal/api` - REST API servers
- `internal/metrics/systeminfo` - System metrics polling
- `internal/metrics/uptime` - Uptime tracking
- `github.com/yusing/goutils/task` - Object lifecycle management
### External dependencies
@@ -321,8 +312,5 @@ for name, provider := range config.GetState().IterProviders() {
```go
state := config.GetState()
// Get entrypoint interface for route management
ep := state.Entrypoint()
// Add routes directly to entrypoint
ep.AddRoute(route)
http.Handle("/", state.EntrypointHandler())
```

View File

@@ -10,9 +10,11 @@ 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"
)
@@ -58,30 +60,20 @@ func Load() error {
cfgWatcher = watcher.NewConfigFileWatcher(common.ConfigFileName)
initErr := state.InitFromFile(common.ConfigPath)
if initErr != nil {
// if error is critical, notify and return it without starting providers
var criticalErr CriticalError
if errors.As(initErr, &criticalErr) {
logNotifyError("init", criticalErr.err)
return criticalErr.err
}
}
// disable pool logging temporary since we already have pretty logging
state.Entrypoint().DisablePoolsLog(true)
routes.HTTP.DisableLog(true)
routes.Stream.DisableLog(true)
defer func() {
state.Entrypoint().DisablePoolsLog(false)
routes.HTTP.DisableLog(false)
routes.Stream.DisableLog(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
@@ -116,9 +108,7 @@ func Reload() gperr.Error {
logNotifyError("start providers", err)
return nil // continue
}
newState.StartAPIServers()
newState.StartMetrics()
StartProxyServers()
return nil
}
@@ -152,3 +142,16 @@ 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,6 +9,7 @@ import (
"fmt"
"io/fs"
"iter"
"net/http"
"os"
"strconv"
"strings"
@@ -17,20 +18,14 @@ import (
"github.com/goccy/go-yaml"
"github.com/puzpuzpuz/xsync/v4"
"github.com/rs/zerolog"
acl "github.com/yusing/godoxy/internal/acl/types"
"github.com/yusing/godoxy/internal/acl"
"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"
@@ -45,7 +40,7 @@ type state struct {
providers *xsync.Map[string, types.RouteProvider]
autocertProvider *autocert.Provider
entrypoint *entrypoint.Entrypoint
entrypoint entrypoint.Entrypoint
task *task.Task
@@ -55,25 +50,14 @@ type state struct {
tmpLog zerolog.Logger
}
type CriticalError struct {
err error
}
func (e CriticalError) Error() string {
return e.err.Error()
}
func (e CriticalError) Unwrap() error {
return e.err
}
func NewState() config.State {
tmpLogBuf := bytes.NewBuffer(make([]byte, 0, 4096))
return &state{
providers: xsync.NewMap[string, types.RouteProvider](),
task: task.RootTask("config", false),
tmpLogBuf: tmpLogBuf,
tmpLog: logging.NewLoggerWithFixedLevel(zerolog.InfoLevel, tmpLogBuf),
providers: xsync.NewMap[string, types.RouteProvider](),
entrypoint: entrypoint.NewEntrypoint(),
task: task.RootTask("config", false),
tmpLogBuf: tmpLogBuf,
tmpLog: logging.NewLoggerWithFixedLevel(zerolog.InfoLevel, tmpLogBuf),
}
}
@@ -89,6 +73,7 @@ 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))
@@ -111,7 +96,7 @@ func (state *state) InitFromFile(filename string) error {
if errors.Is(err, fs.ErrNotExist) {
state.Config = config.DefaultConfig()
} else {
return CriticalError{err}
return err
}
}
return state.Init(data)
@@ -120,7 +105,7 @@ func (state *state) InitFromFile(filename string) error {
func (state *state) Init(data []byte) error {
err := serialization.UnmarshalValidate(data, &state.Config, yaml.Unmarshal)
if err != nil {
return CriticalError{err}
return err
}
g := gperr.NewGroup("config load error")
@@ -132,9 +117,7 @@ func (state *state) Init(data []byte) error {
// these won't benefit from running on goroutines
errs.Add(state.initNotification())
errs.Add(state.initACL())
if err := state.initEntrypoint(); err != nil {
errs.Add(CriticalError{err})
}
errs.Add(state.initEntrypoint())
errs.Add(state.loadRouteProviders())
return errs.Error()
}
@@ -151,8 +134,8 @@ func (state *state) Value() *config.Config {
return &state.Config
}
func (state *state) Entrypoint() entrypointctx.Entrypoint {
return state.entrypoint
func (state *state) EntrypointHandler() http.Handler {
return &state.entrypoint
}
func (state *state) ShortLinkMatcher() config.ShortLinkMatcher {
@@ -207,29 +190,6 @@ 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() {
@@ -239,7 +199,7 @@ func (state *state) initACL() error {
if err != nil {
return err
}
acl.SetCtx(state.task, state.ACL)
state.task.SetValue(acl.ContextKey{}, state.ACL)
return nil
}
@@ -247,7 +207,6 @@ 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)
@@ -261,8 +220,6 @@ 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))
@@ -339,7 +296,6 @@ 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"
"github.com/yusing/godoxy/internal/entrypoint"
entrypoint "github.com/yusing/godoxy/internal/entrypoint/types"
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,7 +6,6 @@ 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"
@@ -22,7 +21,7 @@ type State interface {
Value() *Config
Entrypoint() entrypoint.Entrypoint
EntrypointHandler() http.Handler
ShortLinkMatcher() ShortLinkMatcher
AutoCertProvider() server.CertProvider
@@ -33,9 +32,6 @@ type State interface {
StartProviders() error
FlushTmpLog()
StartAPIServers()
StartMetrics()
}
type ShortLinkMatcher interface {

View File

@@ -1,6 +1,6 @@
module github.com/yusing/godoxy/internal/dnsproviders
go 1.25.7
go 1.25.6
replace github.com/yusing/godoxy => ../..
@@ -81,7 +81,7 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/vultr/govultr/v3 v3.26.1 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
github.com/yusing/gointernals v0.1.18 // indirect
github.com/yusing/gointernals v0.1.16 // indirect
github.com/yusing/goutils v0.7.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect

View File

@@ -197,8 +197,8 @@ github.com/vultr/govultr/v3 v3.26.1 h1:G/M0rMQKwVSmL+gb0UgETbW5mcQi0Vf/o/ZSGdBCx
github.com/vultr/govultr/v3 v3.26.1/go.mod h1:9WwnWGCKnwDlNjHjtt+j+nP+0QWq6hQXzaHgddqrLWY=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yusing/gointernals v0.1.18 h1:ou8/0tPURUgAOBJu3TN/iWF4S/5ZYQaap+rVkaJNUMw=
github.com/yusing/gointernals v0.1.18/go.mod h1:B/0FVXt4WPmgzVy3ynzkqKi+BSGaJVmwCJBRXYapo34=
github.com/yusing/gointernals v0.1.16 h1:GrhZZdxzA+jojLEqankctJrOuAYDb7kY1C93S1pVR34=
github.com/yusing/gointernals v0.1.16/go.mod h1:B/0FVXt4WPmgzVy3ynzkqKi+BSGaJVmwCJBRXYapo34=
github.com/yusing/goutils v0.7.0 h1:I5hd8GwZ+3WZqFPK0tWqek1Q5MY6Xg29hKZcwwQi4SY=
github.com/yusing/goutils v0.7.0/go.mod h1:CtF/KFH4q8jkr7cvBpkaExnudE0lLu8sLe43F73Bn5Q=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=

View File

@@ -1,10 +1,10 @@
# Entrypoint
The entrypoint package provides the main HTTP entry point for GoDoxy, handling domain-based routing, middleware application, short link matching, access logging, and HTTP/TCP/UDP server lifecycle management.
The entrypoint package provides the main HTTP entry point for GoDoxy, handling domain-based routing, middleware application, short link matching, and access logging.
## Overview
The entrypoint package implements the primary HTTP handler that receives all incoming requests, manages the lifecycle of HTTP/TCP/UDP servers, determines the target route based on hostname, applies middleware, and forwards requests to the appropriate route handler.
The entrypoint package implements the primary HTTP handler that receives all incoming requests, determines the target route based on hostname, applies middleware, and forwards requests to the appropriate route handler.
### Key Features
@@ -14,310 +14,103 @@ The entrypoint package implements the primary HTTP handler that receives all inc
- Access logging for all requests
- Configurable not-found handling
- Per-domain route resolution
- Multi-protocol server management (HTTP/HTTPS/TCP/UDP)
- Route pool abstractions via [`PoolLike`](internal/entrypoint/types/entrypoint.go:27) and [`RWPoolLike`](internal/entrypoint/types/entrypoint.go:33) interfaces
### Primary Consumers
## Architecture
- **HTTP servers**: Per-listen-addr servers dispatch requests to routes
- **Route providers**: Register routes via [`AddRoute`](internal/entrypoint/routes.go:48)
- **Configuration layer**: Validates and applies middleware/access-logging config
```mermaid
graph TD
A[HTTP Request] --> B[Entrypoint Handler]
B --> C{Access Logger?}
C -->|Yes| D[Wrap Response Recorder]
C -->|No| E[Skip Logging]
### Non-goals
D --> F[Find Route by Host]
E --> F
- Does not implement route discovery (delegates to providers)
- Does not handle TLS certificate management (delegates to autocert)
- Does not implement health checks (delegates to `internal/health/monitor`)
F --> G{Route Found?}
G -->|Yes| H{Middleware?}
G -->|No| I{Short Link?}
I -->|Yes| J[Short Link Handler]
I -->|No| K{Not Found Handler?}
K -->|Yes| L[Not Found Handler]
K -->|No| M[Serve 404]
### Stability
H -->|Yes| N[Apply Middleware]
H -->|No| O[Direct Route]
N --> O
Internal package with stable core interfaces. The [`Entrypoint`](internal/entrypoint/types/entrypoint.go:7) interface is the public contract.
O --> P[Route ServeHTTP]
P --> Q[Response]
## Public API
L --> R[404 Response]
J --> Q
M --> R
```
### Entrypoint Interface
## Core Components
### Entrypoint Structure
```go
type Entrypoint interface {
// Server capabilities
SupportProxyProtocol() bool
DisablePoolsLog(v bool)
// Route registry access
GetRoute(alias string) (types.Route, bool)
AddRoute(r types.Route)
IterRoutes(yield func(r types.Route) bool)
NumRoutes() int
RoutesByProvider() map[string][]types.Route
// Route pool accessors
HTTPRoutes() PoolLike[types.HTTPRoute]
StreamRoutes() PoolLike[types.StreamRoute]
ExcludedRoutes() RWPoolLike[types.Route]
// Health info queries
GetHealthInfo() map[string]types.HealthInfo
GetHealthInfoWithoutDetail() map[string]types.HealthInfoWithoutDetail
GetHealthInfoSimple() map[string]types.HealthStatus
type Entrypoint struct {
middleware *middleware.Middleware
notFoundHandler http.Handler
accessLogger accesslog.AccessLogger
findRouteFunc func(host string) types.HTTPRoute
shortLinkTree *ShortLinkMatcher
}
```
### Pool Interfaces
### Active Config
```go
type PoolLike[Route types.Route] interface {
Get(alias string) (Route, bool)
Iter(yield func(alias string, r Route) bool)
Size() int
}
var ActiveConfig atomic.Pointer[entrypoint.Config]
```
type RWPoolLike[Route types.Route] interface {
PoolLike[Route]
Add(r Route)
Del(r Route)
}
## Public API
### Creation
```go
// NewEntrypoint creates a new entrypoint instance.
func NewEntrypoint() Entrypoint
```
### Configuration
```go
type Config struct {
SupportProxyProtocol bool `json:"support_proxy_protocol"`
}
// SetFindRouteDomains configures domain-based route lookup.
func (ep *Entrypoint) SetFindRouteDomains(domains []string)
// SetMiddlewares loads and configures middleware chain.
func (ep *Entrypoint) SetMiddlewares(mws []map[string]any) error
// SetNotFoundRules configures the not-found handler.
func (ep *Entrypoint) SetNotFoundRules(rules rules.Rules)
// SetAccessLogger initializes access logging.
func (ep *Entrypoint) SetAccessLogger(parent task.Parent, cfg *accesslog.RequestLoggerConfig) error
// ShortLinkMatcher returns the short link matcher.
func (ep *Entrypoint) ShortLinkMatcher() *ShortLinkMatcher
```
## Architecture
### Core Components
```mermaid
classDiagram
class Entrypoint {
+task *task.Task
+cfg *Config
+middleware *middleware.Middleware
+shortLinkMatcher *ShortLinkMatcher
+streamRoutes *pool.Pool[types.StreamRoute]
+excludedRoutes *pool.Pool[types.Route]
+servers *xsync.Map[string, *httpServer]
+tcpListeners *xsync.Map[string, net.Listener]
+udpListeners *xsync.Map[string, net.PacketConn]
+SupportProxyProtocol() bool
+AddRoute(r)
+IterRoutes(yield)
+HTTPRoutes() PoolLike
}
class httpServer {
+routes *routePool
+ServeHTTP(w, r)
+AddRoute(route)
+DelRoute(route)
}
class routePool {
+Get(alias) (HTTPRoute, bool)
+AddRoute(route)
+DelRoute(route)
}
class PoolLike {
<<interface>>
+Get(alias) (Route, bool)
+Iter(yield) bool
+Size() int
}
class RWPoolLike {
<<interface>>
+PoolLike
+Add(r Route)
+Del(r Route)
}
Entrypoint --> httpServer : manages
Entrypoint --> routePool : HTTPRoutes()
Entrypoint --> PoolLike : returns
Entrypoint --> RWPoolLike : ExcludedRoutes()
```
### Request Processing Pipeline
```mermaid
flowchart TD
A[HTTP Request] --> B[Find Route by Host]
B --> C{Route Found?}
C -->|Yes| D{Middleware?}
C -->|No| E{Short Link?}
E -->|Yes| F[Short Link Handler]
E -->|No| G{Not Found Handler?}
G -->|Yes| H[Not Found Handler]
G -->|No| I[Serve 404]
D -->|Yes| J[Apply Middleware Chain]
D -->|No| K[Direct Route Handler]
J --> K
K --> L[Route ServeHTTP]
L --> M[Response]
F --> M
H --> N[404 Response]
I --> N
```
### Server Lifecycle
```mermaid
stateDiagram-v2
[*] --> Empty: NewEntrypoint()
Empty --> Listening: AddRoute()
Listening --> Listening: AddRoute()
Listening --> Listening: delHTTPRoute()
Listening --> [*]: Cancel()
Listening --> AddingServer: addHTTPRoute()
AddingServer --> Listening: Server starts
note right of Listening
servers map: addr -> httpServer
tcpListeners map: addr -> Listener
udpListeners map: addr -> PacketConn
end note
```
## Data Flow
```mermaid
sequenceDiagram
participant Client
participant httpServer
participant Entrypoint
participant Middleware
participant Route
Client->>httpServer: GET /path
httpServer->>Entrypoint: FindRoute(host)
alt Route Found
Entrypoint-->>httpServer: HTTPRoute
httpServer->>Middleware: ServeHTTP(routeHandler)
alt Has Middleware
Middleware->>Middleware: Process Chain
end
Middleware->>Route: Forward Request
Route-->>Middleware: Response
Middleware-->>httpServer: Response
else Short Link
httpServer->>ShortLinkMatcher: Match short code
ShortLinkMatcher-->>httpServer: Redirect
else Not Found
httpServer->>NotFoundHandler: Serve 404
NotFoundHandler-->>httpServer: 404 Page
end
httpServer-->>Client: Response
```
## Route Registry
Routes are now managed per-entrypoint instead of global registry:
### Request Handling
```go
// Adding a route
ep.AddRoute(route)
// ServeHTTP is the main HTTP handler.
func (ep *Entrypoint) ServeHTTP(w http.ResponseWriter, r *http.Request)
// Iterating all routes
ep.IterRoutes(func(r types.Route) bool {
log.Info().Str("alias", r.Name()).Msg("route")
return true // continue iteration
})
// Querying by alias
route, ok := ep.GetRoute("myapp")
// Grouping by provider
byProvider := ep.RoutesByProvider()
// FindRoute looks up a route by hostname.
func (ep *Entrypoint) FindRoute(s string) types.HTTPRoute
```
## Configuration Surface
### Config Source
Environment variables and YAML config file:
```yaml
entrypoint:
support_proxy_protocol: true
```
### Environment Variables
| Variable | Description |
| ------------------------------ | ----------------------------- |
| `PROXY_SUPPORT_PROXY_PROTOCOL` | Enable PROXY protocol support |
## Dependency and Integration Map
| Dependency | Purpose |
| -------------------------------- | -------------------------- |
| `internal/route` | Route types and handlers |
| `internal/route/rules` | Not-found rules processing |
| `internal/logging/accesslog` | Request logging |
| `internal/net/gphttp/middleware` | Middleware chain |
| `internal/types` | Route and health types |
| `github.com/puzpuzpuz/xsync/v4` | Concurrent server map |
| `github.com/yusing/goutils/pool` | Route pool implementations |
| `github.com/yusing/goutils/task` | Lifecycle management |
## Observability
### Logs
| Level | Context | Description |
| ------- | --------------------- | ----------------------- |
| `DEBUG` | `route`, `listen_url` | Route addition/removal |
| `DEBUG` | `addr`, `proto` | Server lifecycle |
| `ERROR` | `route`, `listen_url` | Server startup failures |
### Metrics
Route metrics exposed via [`GetHealthInfo`](internal/entrypoint/query.go:10) methods:
```go
// Health info for all routes
healthMap := ep.GetHealthInfo()
// {
// "myapp": {Status: "healthy", Uptime: 3600, Latency: 5ms},
// "excluded-route": {Status: "unknown", Detail: "n/a"},
// }
```
## Security Considerations
- Route lookup is read-only from route pools
- Middleware chain is applied per-request
- Proxy protocol support must be explicitly enabled
- Access logger captures request metadata before processing
## Failure Modes and Recovery
| Failure | Behavior | Recovery |
| --------------------- | ------------------------------ | ---------------------------- |
| Server bind fails | Error logged, route not added | Fix port/address conflict |
| Route start fails | Route excluded, error logged | Fix route configuration |
| Middleware load fails | AddRoute returns error | Fix middleware configuration |
| Context cancelled | All servers stopped gracefully | Restart entrypoint |
## Usage Examples
## Usage
### Basic Setup
```go
ep := entrypoint.NewEntrypoint(parent, &entrypoint.Config{
SupportProxyProtocol: false,
})
ep := entrypoint.NewEntrypoint()
// Configure domain matching
ep.SetFindRouteDomains([]string{".example.com", "example.com"})
@@ -327,7 +120,7 @@ err := ep.SetMiddlewares([]map[string]any{
{"rate_limit": map[string]any{"requests_per_second": 100}},
})
if err != nil {
return err
log.Fatal(err)
}
// Configure access logging
@@ -335,59 +128,181 @@ err = ep.SetAccessLogger(parent, &accesslog.RequestLoggerConfig{
Path: "/var/log/godoxy/access.log",
})
if err != nil {
return err
log.Fatal(err)
}
// Start server
http.ListenAndServe(":80", &ep)
```
### Route Querying
### Route Lookup Logic
The entrypoint uses multiple strategies to find routes:
1. **Subdomain Matching**: For `sub.domain.com`, looks for `sub`
1. **Exact Match**: Looks for the full hostname
1. **Port Stripping**: Strips port from host if present
```go
// Iterate all routes including excluded
for r := range ep.IterRoutes {
log.Info().
Str("alias", r.Name()).
Str("provider", r.ProviderName()).
Bool("excluded", r.ShouldExclude()).
Msg("route")
}
func findRouteAnyDomain(host string) types.HTTPRoute {
// Try subdomain (everything before first dot)
idx := strings.IndexByte(host, '.')
if idx != -1 {
target := host[:idx]
if r, ok := routes.HTTP.Get(target); ok {
return r
}
}
// Get health info for all routes
healthMap := ep.GetHealthInfoSimple()
for alias, status := range healthMap {
log.Info().Str("alias", alias).Str("status", string(status)).Msg("health")
// Try exact match
if r, ok := routes.HTTP.Get(host); ok {
return r
}
// Try stripping port
if before, _, ok := strings.Cut(host, ":"); ok {
if r, ok := routes.HTTP.Get(before); ok {
return r
}
}
return nil
}
```
### Route Addition
### Short Links
Short links use a special `.short` domain:
```go
route := &route.Route{
Alias: "myapp",
Scheme: route.SchemeHTTP,
Host: "myapp",
Port: route.Port{Proxy: 80, Target: 3000},
// Request to: https://abc.short.example.com
// Looks for route with alias "abc"
if strings.EqualFold(host, common.ShortLinkPrefix) {
// Handle short link
ep.shortLinkTree.ServeHTTP(w, r)
}
ep.AddRoute(route)
```
## Context Integration
## Data Flow
Routes can access the entrypoint from request context:
```mermaid
sequenceDiagram
participant Client
participant Entrypoint
participant Middleware
participant Route
participant Logger
Client->>Entrypoint: GET /path
Entrypoint->>Entrypoint: FindRoute(host)
alt Route Found
Entrypoint->>Logger: Get ResponseRecorder
Logger-->>Entrypoint: Recorder
Entrypoint->>Middleware: ServeHTTP(routeHandler)
alt Has Middleware
Middleware->>Middleware: Process Chain
end
Middleware->>Route: Forward Request
Route-->>Middleware: Response
Middleware-->>Entrypoint: Response
else Short Link
Entrypoint->>ShortLinkTree: Match short code
ShortLinkTree-->>Entrypoint: Redirect
else Not Found
Entrypoint->>NotFoundHandler: Serve 404
NotFoundHandler-->>Entrypoint: 404 Page
end
Entrypoint->>Logger: Log Request
Logger-->>Entrypoint: Complete
Entrypoint-->>Client: Response
```
## Not-Found Handling
When no route is found, the entrypoint:
1. Attempts to serve a static error page file
1. Logs the 404 request
1. Falls back to the configured error page
1. Returns 404 status code
```go
// Set entrypoint in context
entrypoint.SetCtx(r.Context(), ep)
func (ep *Entrypoint) serveNotFound(w http.ResponseWriter, r *http.Request) {
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)
// Get entrypoint from context
if ep := entrypoint.FromCtx(r.Context()); ep != nil {
route, ok := ep.GetRoute("alias")
errorPage, ok := errorpage.GetErrorPageByStatus(http.StatusNotFound)
if ok {
w.WriteHeader(http.StatusNotFound)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(errorPage)
} else {
http.NotFound(w, r)
}
}
}
```
## Testing Notes
## Configuration Structure
- Benchmark tests in [`entrypoint_benchmark_test.go`](internal/entrypoint/entrypoint_benchmark_test.go)
- Integration tests in [`entrypoint_test.go`](internal/entrypoint/entrypoint_test.go)
- Mock route pools for unit testing
- Short link tests in [`shortlink_test.go`](internal/entrypoint/shortlink_test.go)
```go
type Config struct {
Middlewares []map[string]any `json:"middlewares"`
Rules rules.Rules `json:"rules"`
AccessLog *accesslog.RequestLoggerConfig `json:"access_log"`
}
```
## Middleware Integration
The entrypoint supports middleware chains configured via YAML:
```yaml
entrypoint:
middlewares:
- use: rate_limit
average: 100
burst: 200
bypass:
- remote 192.168.1.0/24
- use: redirect_http
```
## Access Logging
Access logging wraps the response recorder to capture:
- Request method and URL
- Response status code
- Response size
- Request duration
- Client IP address
```go
func (ep *Entrypoint) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if ep.accessLogger != nil {
rec := accesslog.GetResponseRecorder(w)
w = rec
defer func() {
ep.accessLogger.Log(r, rec.Response())
accesslog.PutResponseRecorder(rec)
}()
}
// ... handle request
}
```
## Integration Points
The entrypoint integrates with:
- **Route Registry**: HTTP route lookup
- **Middleware**: Request processing chain
- **AccessLog**: Request logging
- **ErrorPage**: 404 error pages
- **ShortLink**: Short link handling

View File

@@ -1,143 +1,47 @@
package entrypoint
import (
"net"
"net/http"
"strings"
"sync/atomic"
"testing"
"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 {
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
middleware *middleware.Middleware
notFoundHandler http.Handler
accessLogger accesslog.AccessLogger
findRouteFunc func(host string) types.HTTPRoute
shortLinkTree *ShortLinkMatcher
}
var _ entrypoint.Entrypoint = &Entrypoint{}
// nil-safe
var ActiveConfig atomic.Pointer[entrypoint.Config]
var emptyCfg Config
func NewTestEntrypoint(t testing.TB, cfg *Config) *Entrypoint {
t.Helper()
task := task.GetTestTask(t)
ep := NewEntrypoint(task, cfg)
entrypoint.SetCtx(task, ep)
return ep
func init() {
// make sure it's not nil
ActiveConfig.Store(&entrypoint.Config{})
}
func NewEntrypoint(parent task.Parent, cfg *Config) *Entrypoint {
if cfg == nil {
cfg = &emptyCfg
func NewEntrypoint() Entrypoint {
return Entrypoint{
findRouteFunc: findRouteAnyDomain,
shortLinkTree: newShortLinkTree(),
}
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) Task() *task.Task {
return ep.task
}
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) (HTTPServer, bool) {
return ep.servers.Load(addr)
return ep.shortLinkTree
}
func (ep *Entrypoint) SetFindRouteDomains(domains []string) {
@@ -170,7 +74,7 @@ func (ep *Entrypoint) SetMiddlewares(mws []map[string]any) error {
}
func (ep *Entrypoint) SetNotFoundRules(rules rules.Rules) {
ep.notFoundHandler = rules.BuildHandler(serveNotFound)
ep.notFoundHandler = rules.BuildHandler(http.HandlerFunc(ep.serveNotFound))
}
func (ep *Entrypoint) SetAccessLogger(parent task.Parent, cfg *accesslog.RequestLoggerConfig) (err error) {
@@ -187,39 +91,111 @@ func (ep *Entrypoint) SetAccessLogger(parent task.Parent, cfg *accesslog.Request
return err
}
func findRouteAnyDomain(routes HTTPRoutes, host string) types.HTTPRoute {
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.shortLinkTree.ServeHTTP, w, r)
} else {
ep.shortLinkTree.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, '.')
if idx != -1 {
target := host[:idx]
if r, ok := routes.Get(target); ok {
if r, ok := routes.HTTP.Get(target); ok {
return r
}
}
if r, ok := routes.Get(host); ok {
if r, ok := routes.HTTP.Get(host); ok {
return r
}
// try striping the trailing :port from the host
if before, _, ok := strings.Cut(host, ":"); ok {
if r, ok := routes.Get(before); ok {
if r, ok := routes.HTTP.Get(before); ok {
return r
}
}
return nil
}
func findRouteByDomains(domains []string) func(routes HTTPRoutes, host string) types.HTTPRoute {
return func(routes HTTPRoutes, host string) types.HTTPRoute {
func findRouteByDomains(domains []string) func(host string) types.HTTPRoute {
return func(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.Get(target); ok {
if r, ok := routes.HTTP.Get(target); ok {
return r
}
}
}
// fallback to exact match
if r, ok := routes.Get(host); ok {
if r, ok := routes.HTTP.Get(host); ok {
return r
}
return nil

View File

@@ -10,11 +10,9 @@ 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"
@@ -50,15 +48,13 @@ func (t noopTransport) RoundTrip(req *http.Request) (*http.Response, error) {
}
func BenchmarkEntrypointReal(b *testing.B) {
task := task.GetTestTask(b)
ep := NewEntrypoint(task, nil)
var ep Entrypoint
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")
@@ -81,48 +77,48 @@ func BenchmarkEntrypointReal(b *testing.B) {
b.Fatal(err)
}
r, err := route.NewStartedTestRoute(b, &route.Route{
r := &route.Route{
Alias: "test",
Scheme: routeTypes.SchemeHTTP,
Host: host,
Port: route.Port{Proxy: portInt},
HealthCheck: types.HealthCheckConfig{Disable: true},
})
}
require.NoError(b, err)
require.False(b, r.ShouldExclude())
err = r.Validate()
if err != nil {
b.Fatal(err)
}
err = r.Start(task.RootTask("test", false))
if err != nil {
b.Fatal(err)
}
var w noopResponseWriter
server, ok := ep.GetServer(common.ProxyHTTPAddr)
if !ok {
b.Fatal("server not found")
}
b.ResetTimer()
for b.Loop() {
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))
}
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))
// }
}
}
func BenchmarkEntrypoint(b *testing.B) {
task := task.GetTestTask(b)
ep := NewEntrypoint(task, nil)
var ep Entrypoint
req := http.Request{
Method: "GET",
URL: &url.URL{Path: "/", RawPath: "/"},
Host: "test.domain.tld",
}
ep.SetFindRouteDomains([]string{})
entrypoint.SetCtx(task, ep)
r, err := route.NewStartedTestRoute(b, &route.Route{
r := &route.Route{
Alias: "test",
Scheme: routeTypes.SchemeHTTP,
Host: "localhost",
@@ -132,23 +128,29 @@ func BenchmarkEntrypoint(b *testing.B) {
HealthCheck: types.HealthCheckConfig{
Disable: true,
},
})
}
require.NoError(b, err)
require.False(b, r.ShouldExclude())
err := r.Validate()
if err != nil {
b.Fatal(err)
}
r.(types.ReverseProxyRoute).ReverseProxy().Transport = noopTransport{}
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{}
var w noopResponseWriter
server, ok := ep.GetServer(common.ProxyHTTPAddr)
if !ok {
b.Fatal("server not found")
}
b.ResetTimer()
for b.Loop() {
server.ServeHTTP(&w, &req)
ep.ServeHTTP(&w, &req)
if w.statusCode != http.StatusOK {
b.Fatalf("status code is not 200: %d", w.statusCode)
}

View File

@@ -3,70 +3,48 @@ package entrypoint_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
. "github.com/yusing/godoxy/internal/entrypoint"
entrypoint "github.com/yusing/godoxy/internal/entrypoint/types"
"github.com/yusing/godoxy/internal/route"
routeTypes "github.com/yusing/godoxy/internal/route/types"
"github.com/yusing/godoxy/internal/types"
"github.com/yusing/goutils/task"
"github.com/yusing/godoxy/internal/route/routes"
expect "github.com/yusing/goutils/testing"
)
func addRoute(t *testing.T, alias string) {
t.Helper()
var ep = NewEntrypoint()
ep := entrypoint.FromCtx(task.GetTestTask(t).Context())
require.NotNil(t, ep)
_, err := route.NewStartedTestRoute(t, &route.Route{
Alias: alias,
Scheme: routeTypes.SchemeHTTP,
Port: route.Port{
Listening: 1000,
Proxy: 8080,
},
HealthCheck: types.HealthCheckConfig{
Disable: true,
func addRoute(alias string) {
routes.HTTP.Add(&route.ReveseProxyRoute{
Route: &route.Route{
Alias: alias,
Port: route.Port{
Proxy: 80,
},
},
})
if err != nil {
t.Fatal(err)
}
route, ok := ep.HTTPRoutes().Get(alias)
require.True(t, ok, "route not found")
require.NotNil(t, route)
}
func run(t *testing.T, ep *Entrypoint, match []string, noMatch []string) {
func run(t *testing.T, match []string, noMatch []string) {
t.Helper()
server, ok := ep.GetServer(":1000")
require.True(t, ok, "server not found")
require.NotNil(t, server)
t.Cleanup(routes.Clear)
t.Cleanup(func() { ep.SetFindRouteDomains(nil) })
for _, test := range match {
t.Run(test, func(t *testing.T) {
route := server.FindRoute(test)
assert.NotNil(t, route)
found := ep.FindRoute(test)
expect.NotNil(t, found)
})
}
for _, test := range noMatch {
t.Run(test, func(t *testing.T) {
found, ok := ep.HTTPRoutes().Get(test)
assert.False(t, ok)
assert.Nil(t, found)
found := ep.FindRoute(test)
expect.Nil(t, found)
})
}
}
func TestFindRouteAnyDomain(t *testing.T) {
ep := NewTestEntrypoint(t, nil)
addRoute(t, "app1")
addRoute("app1")
tests := []string{
"app1.com",
@@ -80,12 +58,10 @@ func TestFindRouteAnyDomain(t *testing.T) {
"app2.sub.domain.com",
}
run(t, ep, tests, testsNoMatch)
run(t, tests, testsNoMatch)
}
func TestFindRouteExactHostMatch(t *testing.T) {
ep := NewTestEntrypoint(t, nil)
tests := []string{
"app2.com",
"app2.domain.com",
@@ -99,20 +75,19 @@ func TestFindRouteExactHostMatch(t *testing.T) {
}
for _, test := range tests {
addRoute(t, test)
addRoute(test)
}
run(t, ep, tests, testsNoMatch)
run(t, tests, testsNoMatch)
}
func TestFindRouteByDomains(t *testing.T) {
ep := NewTestEntrypoint(t, nil)
ep.SetFindRouteDomains([]string{
".domain.com",
".sub.domain.com",
})
addRoute(t, "app1")
addRoute("app1")
tests := []string{
"app1.domain.com",
@@ -128,17 +103,16 @@ func TestFindRouteByDomains(t *testing.T) {
"app2.sub.domain.com",
}
run(t, ep, tests, testsNoMatch)
run(t, tests, testsNoMatch)
}
func TestFindRouteByDomainsExactMatch(t *testing.T) {
ep := NewTestEntrypoint(t, nil)
ep.SetFindRouteDomains([]string{
".domain.com",
".sub.domain.com",
})
addRoute(t, "app1.foo.bar")
addRoute("app1.foo.bar")
tests := []string{
"app1.foo.bar", // exact match
@@ -152,14 +126,13 @@ func TestFindRouteByDomainsExactMatch(t *testing.T) {
"app1.sub.domain.com",
}
run(t, ep, tests, testsNoMatch)
run(t, tests, testsNoMatch)
}
func TestFindRouteWithPort(t *testing.T) {
t.Run("AnyDomain", func(t *testing.T) {
ep := NewTestEntrypoint(t, nil)
addRoute(t, "app1")
addRoute(t, "app2.com")
addRoute("app1")
addRoute("app2.com")
tests := []string{
"app1:8080",
@@ -171,17 +144,16 @@ func TestFindRouteWithPort(t *testing.T) {
"app2.co",
"app2.co:8080",
}
run(t, ep, tests, testsNoMatch)
run(t, tests, testsNoMatch)
})
t.Run("ByDomains", func(t *testing.T) {
ep := NewTestEntrypoint(t, nil)
ep.SetFindRouteDomains([]string{
".domain.com",
})
addRoute(t, "app1")
addRoute(t, "app2")
addRoute(t, "app3.domain.com")
addRoute("app1")
addRoute("app2")
addRoute("app3.domain.com")
tests := []string{
"app1.domain.com:8080",
@@ -197,120 +169,6 @@ func TestFindRouteWithPort(t *testing.T) {
"app3.domain.co",
"app3.domain.co:8080",
}
run(t, ep, tests, testsNoMatch)
run(t, tests, testsNoMatch)
})
}
func TestHealthInfoQueries(t *testing.T) {
ep := NewTestEntrypoint(t, nil)
// Add routes without health monitors (default case)
addRoute(t, "app1")
addRoute(t, "app2")
// Test GetHealthInfo
t.Run("GetHealthInfo", func(t *testing.T) {
info := ep.GetHealthInfo()
expect.Equal(t, 2, len(info))
for _, health := range info {
expect.Equal(t, types.StatusUnknown, health.Status)
expect.Equal(t, "n/a", health.Detail)
}
})
// Test GetHealthInfoWithoutDetail
t.Run("GetHealthInfoWithoutDetail", func(t *testing.T) {
info := ep.GetHealthInfoWithoutDetail()
expect.Equal(t, 2, len(info))
for _, health := range info {
expect.Equal(t, types.StatusUnknown, health.Status)
}
})
// Test GetHealthInfoSimple
t.Run("GetHealthInfoSimple", func(t *testing.T) {
info := ep.GetHealthInfoSimple()
expect.Equal(t, 2, len(info))
for _, status := range info {
expect.Equal(t, types.StatusUnknown, status)
}
})
}
func TestRoutesByProvider(t *testing.T) {
ep := NewTestEntrypoint(t, nil)
// Add routes with provider info
addRoute(t, "app1")
addRoute(t, "app2")
byProvider := ep.RoutesByProvider()
expect.Equal(t, 1, len(byProvider)) // All routes are from same implicit provider
routes, ok := byProvider[""]
expect.True(t, ok)
expect.Equal(t, 2, len(routes))
}
func TestNumRoutes(t *testing.T) {
ep := NewTestEntrypoint(t, nil)
expect.Equal(t, 0, ep.NumRoutes())
addRoute(t, "app1")
expect.Equal(t, 1, ep.NumRoutes())
addRoute(t, "app2")
expect.Equal(t, 2, ep.NumRoutes())
}
func TestIterRoutes(t *testing.T) {
ep := NewTestEntrypoint(t, nil)
addRoute(t, "app1")
addRoute(t, "app2")
addRoute(t, "app3")
count := 0
for r := range ep.IterRoutes {
count++
expect.NotNil(t, r)
}
expect.Equal(t, 3, count)
}
func TestGetRoute(t *testing.T) {
ep := NewTestEntrypoint(t, nil)
// Route not found case
_, ok := ep.GetRoute("nonexistent")
expect.False(t, ok)
addRoute(t, "app1")
route, ok := ep.GetRoute("app1")
expect.True(t, ok)
expect.NotNil(t, route)
}
func TestHTTPRoutesPool(t *testing.T) {
ep := NewTestEntrypoint(t, nil)
pool := ep.HTTPRoutes()
expect.Equal(t, 0, pool.Size())
addRoute(t, "app1")
expect.Equal(t, 1, pool.Size())
// Verify route is accessible
route, ok := pool.Get("app1")
expect.True(t, ok)
expect.NotNil(t, route)
}
func TestExcludedRoutesPool(t *testing.T) {
ep := NewTestEntrypoint(t, nil)
excludedPool := ep.ExcludedRoutes()
expect.Equal(t, 0, excludedPool.Size())
}

View File

@@ -1,51 +0,0 @@
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

@@ -1,172 +0,0 @@
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

@@ -1,91 +0,0 @@
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

@@ -1,121 +0,0 @@
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

@@ -14,7 +14,7 @@ type ShortLinkMatcher struct {
subdomainRoutes *xsync.Map[string, struct{}]
}
func newShortLinkMatcher() *ShortLinkMatcher {
func newShortLinkTree() *ShortLinkMatcher {
return &ShortLinkMatcher{
fqdnRoutes: xsync.NewMap[string, string](),
subdomainRoutes: xsync.NewMap[string, struct{}](),

View File

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

View File

@@ -1,18 +0,0 @@
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

@@ -1,37 +0,0 @@
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

@@ -12,14 +12,6 @@ import (
)
func Stream(ctx context.Context, url *url.URL, timeout time.Duration) (types.HealthCheckResult, error) {
if port := url.Port(); port == "" || port == "0" {
return types.HealthCheckResult{
Latency: 0,
Healthy: false,
Detail: "no port specified",
}, nil
}
dialer := net.Dialer{
Timeout: timeout,
FallbackDelay: -1,

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 = entrypoint.FromCtx(parent.Context()).GetRoute(dep)
depRoute, ok = routes.GetIncludeExcluded(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(parent task.Parent) {
t := parent.Subtask("poller."+p.name, true)
func (p *Poller[T, AggregateT]) Start() {
t := task.RootTask("poller."+p.name, true)
l := log.With().Str("name", p.name).Logger()
err := p.load()
if err != nil {

View File

@@ -8,17 +8,16 @@ 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]types.HealthInfoWithoutDetail `json:"statuses"`
Timestamp int64 `json:"timestamp"`
Map map[string]routes.HealthInfoWithoutDetail `json:"statuses"`
Timestamp int64 `json:"timestamp"`
} // @name RouteStatusesByAlias
Status struct {
Status types.HealthStatus `json:"status" swaggertype:"string" enums:"healthy,unhealthy,unknown,napping,starting"`
@@ -42,7 +41,7 @@ var Poller = period.NewPoller("uptime", getStatuses, aggregateStatuses)
func getStatuses(ctx context.Context, _ StatusByAlias) (StatusByAlias, error) {
return StatusByAlias{
Map: entrypoint.FromCtx(ctx).GetHealthInfoWithoutDetail(),
Map: routes.GetHealthInfoWithoutDetail(),
Timestamp: time.Now().Unix(),
}, nil
}
@@ -128,13 +127,11 @@ func (rs RouteStatuses) aggregate(limit int, offset int) Aggregated {
up, down, idle, latency := rs.calculateInfo(statuses)
status := types.StatusUnknown
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()
}
r, ok := routes.GetIncludeExcluded(alias)
if ok {
mon := r.HealthMonitor()
if mon != nil {
status = mon.Status()
}
}

View File

@@ -10,12 +10,12 @@ import (
"strings"
"testing"
"github.com/yusing/godoxy/internal/common"
"github.com/yusing/godoxy/internal/entrypoint"
. "github.com/yusing/godoxy/internal/net/gphttp/middleware"
"github.com/yusing/godoxy/internal/route"
routeTypes "github.com/yusing/godoxy/internal/route/types"
"github.com/yusing/goutils/http/reverseproxy"
"github.com/yusing/goutils/task"
expect "github.com/yusing/goutils/testing"
)
@@ -231,15 +231,14 @@ func TestEntrypointBypassRoute(t *testing.T) {
expect.NoError(t, err)
expect.NoError(t, err)
entry := entrypoint.NewTestEntrypoint(t, nil)
_, err = route.NewStartedTestRoute(t, &route.Route{
entry := entrypoint.NewEntrypoint()
r := &route.Route{
Alias: "test-route",
Host: host,
Port: routeTypes.Port{
Proxy: portInt,
},
})
expect.NoError(t, err)
}
err = entry.SetMiddlewares([]map[string]any{
{
@@ -255,13 +254,13 @@ func TestEntrypointBypassRoute(t *testing.T) {
})
expect.NoError(t, err)
err = r.Validate()
expect.NoError(t, err)
r.Start(task.RootTask("test", false))
recorder := httptest.NewRecorder()
req := httptest.NewRequest("GET", "http://test-route.example.com", nil)
server, ok := entry.GetServer(common.ProxyHTTPAddr)
if !ok {
t.Fatal("server not found")
}
server.ServeHTTP(recorder, req)
entry.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"
entrypoint "github.com/yusing/godoxy/internal/entrypoint/types"
"github.com/yusing/godoxy/internal/route/routes"
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(r.Context())
crowdsecURL, err := m.buildCrowdSecURL()
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(ctx context.Context) (string, error) {
func (m *crowdsecMiddleware) buildCrowdSecURL() (string, error) {
// Try to get route first
if m.Route != "" {
if route, ok := entrypoint.FromCtx(ctx).GetRoute(m.Route); ok {
if route, ok := routes.HTTP.Get(m.Route); ok {
// Using route name
targetURL := *route.TargetURL()
targetURL.Path = m.Endpoint

View File

@@ -8,7 +8,7 @@ import (
"strings"
"time"
entrypoint "github.com/yusing/godoxy/internal/entrypoint/types"
"github.com/yusing/godoxy/internal/route/routes"
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 := entrypoint.FromCtx(r.Context()).HTTPRoutes().Get(m.Route)
route, ok := routes.HTTP.Get(m.Route)
if !ok {
ForwardAuth.LogWarn(r).Str("route", m.Route).Msg("forwardauth route not found")
w.WriteHeader(http.StatusInternalServerError)

View File

@@ -30,11 +30,9 @@ Internal package with stable core types. Route configuration schema is versioned
type Route struct {
Alias string // Unique route identifier
Scheme Scheme // http, https, h2c, tcp, udp, fileserver
Host string // Virtual host / target address
Host string // Virtual host
Port Port // Listen and target ports
Bind string // Bind address for listening (IP address, optional)
// File serving
Root string // Document root
SPA bool // Single-page app mode
@@ -198,7 +196,6 @@ type Route struct {
Alias string `json:"alias"`
Scheme Scheme `json:"scheme"`
Host string `json:"host,omitempty"`
Bind string `json:"bind,omitempty"` // Listen bind address
Port Port `json:"port"`
Root string `json:"root,omitempty"`
SPA bool `json:"spa,omitempty"`
@@ -221,28 +218,23 @@ labels:
routes:
myapp:
scheme: http
host: myapp.local
bind: 192.168.1.100 # Optional: bind to specific address
port:
proxy: 80
target: 3000
root: /var/www/myapp
spa: true
```
### Route with Custom Bind Address
## Dependency and Integration Map
| Dependency | Purpose |
| ---------------------------------- | --------------------------------- |
| `internal/route/routes/context.go` | Route context helpers (only file) |
| `internal/route/rules` | Request/response rule processing |
| `internal/route/stream` | TCP/UDP stream proxying |
| `internal/route/provider` | Route discovery and loading |
| `internal/health/monitor` | Health checking |
| `internal/idlewatcher` | Idle container management |
| `internal/logging/accesslog` | Request logging |
| `internal/homepage` | Dashboard integration |
| `github.com/yusing/goutils/errs` | Error handling |
| Dependency | Purpose |
| -------------------------------- | -------------------------------- |
| `internal/route/routes` | Route registry and lookup |
| `internal/route/rules` | Request/response rule processing |
| `internal/route/stream` | TCP/UDP stream proxying |
| `internal/route/provider` | Route discovery and loading |
| `internal/health/monitor` | Health checking |
| `internal/idlewatcher` | Idle container management |
| `internal/logging/accesslog` | Request logging |
| `internal/homepage` | Dashboard integration |
| `github.com/yusing/goutils/errs` | Error handling |
## Observability
@@ -313,18 +305,6 @@ route := &route.Route{
}
```
### Route with Custom Bind Address
```go
route := &route.Route{
Alias: "myapp",
Scheme: route.SchemeHTTP,
Host: "myapp.local",
Bind: "192.168.1.100", // Bind to specific interface
Port: route.Port{Proxy: 80, Target: 3000, Listening: 8443},
}
```
### File Server Route
```go

View File

@@ -1,17 +1,12 @@
package route
import (
"context"
entrypoint "github.com/yusing/godoxy/internal/entrypoint/types"
"github.com/yusing/godoxy/internal/route/routes"
"github.com/yusing/godoxy/internal/types"
gperr "github.com/yusing/goutils/errs"
)
// 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 {
func checkExists(r types.Route) gperr.Error {
if r.UseLoadBalance() { // skip checking for load balanced routes
return nil
}
@@ -21,9 +16,9 @@ func checkExists(ctx context.Context, r types.Route) gperr.Error {
)
switch r := r.(type) {
case types.HTTPRoute:
existing, ok = entrypoint.FromCtx(ctx).HTTPRoutes().Get(r.Key())
existing, ok = routes.HTTP.Get(r.Key())
case types.StreamRoute:
existing, ok = entrypoint.FromCtx(ctx).StreamRoutes().Get(r.Key())
existing, ok = routes.Stream.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"
"github.com/rs/zerolog/log"
entrypoint "github.com/yusing/godoxy/internal/entrypoint/types"
config "github.com/yusing/godoxy/internal/config/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,19 +120,20 @@ 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 {
l := log.With().Str("type", "fileserver").Str("name", s.Name()).Logger()
gperr.LogWarn("health monitor error", err, &l)
s.HealthMon = nil
return err
}
}
ep := entrypoint.FromCtx(parent.Context())
if ep == nil {
err := gperr.New("entrypoint not initialized")
s.task.Finish(err)
return err
routes.HTTP.Add(s)
if state := config.WorkingState.Load(); state != nil {
state.ShortLinkMatcher().AddRoute(s.Alias)
}
ep.AddRoute(s)
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
}

View File

@@ -6,7 +6,7 @@ import (
"github.com/yusing/godoxy/agent/pkg/agent"
"github.com/yusing/godoxy/agent/pkg/agentproxy"
entrypoint "github.com/yusing/godoxy/internal/entrypoint/types"
config "github.com/yusing/godoxy/internal/config/types"
"github.com/yusing/godoxy/internal/health/monitor"
"github.com/yusing/godoxy/internal/idlewatcher"
"github.com/yusing/godoxy/internal/logging/accesslog"
@@ -14,6 +14,7 @@ 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"
@@ -158,22 +159,23 @@ func (r *ReveseProxyRoute) 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.rp.Logger)
r.HealthMon = nil
return err
}
}
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, ep)
r.addToLoadBalancer(parent)
} else {
ep.AddRoute(r)
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)
}
})
}
return nil
}
@@ -185,16 +187,16 @@ func (r *ReveseProxyRoute) ServeHTTP(w http.ResponseWriter, req *http.Request) {
var lbLock sync.Mutex
func (r *ReveseProxyRoute) addToLoadBalancer(parent task.Parent, ep entrypoint.Entrypoint) {
func (r *ReveseProxyRoute) addToLoadBalancer(parent task.Parent) {
var lb *loadbalancer.LoadBalancer
cfg := r.LoadBalance
lbLock.Lock()
l, ok := ep.HTTPRoutes().Get(cfg.Link)
l, ok := routes.HTTP.Get(cfg.Link)
var linked *ReveseProxyRoute
if ok {
lbLock.Unlock()
linked = l.(*ReveseProxyRoute) // it must be a reverse proxy route
linked = l.(*ReveseProxyRoute)
lb = linked.loadBalancer
lb.UpdateConfigIfNeeded(cfg)
if linked.Homepage.Name == "" {
@@ -207,17 +209,21 @@ func (r *ReveseProxyRoute) addToLoadBalancer(parent task.Parent, ep entrypoint.E
Route: &Route{
Alias: cfg.Link,
Homepage: r.Homepage,
Bind: r.Bind,
Metadata: Metadata{
LisURL: r.ListenURL(),
task: lb.Task(),
},
},
loadBalancer: lb,
handler: lb,
}
linked.SetHealthMonitor(lb)
ep.AddRoute(linked)
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)
}
})
lbLock.Unlock()
}
r.loadBalancer = lb

View File

@@ -1,39 +0,0 @@
package route
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
route "github.com/yusing/godoxy/internal/route/types"
"github.com/yusing/godoxy/internal/types"
)
func TestReverseProxyRoute(t *testing.T) {
t.Run("LinkToLoadBalancer", func(t *testing.T) {
cfg := Route{
Alias: "test",
Scheme: route.SchemeHTTP,
Host: "example.com",
Port: Port{Proxy: 80},
LoadBalance: &types.LoadBalancerConfig{
Link: "test",
},
}
cfg1 := Route{
Alias: "test1",
Scheme: route.SchemeHTTP,
Host: "example.com",
Port: Port{Proxy: 80},
LoadBalance: &types.LoadBalancerConfig{
Link: "test",
},
}
r, err := NewStartedTestRoute(t, &cfg)
require.NoError(t, err)
assert.NotNil(t, r)
r2, err := NewStartedTestRoute(t, &cfg1)
require.NoError(t, err)
assert.NotNil(t, r2)
})
}

View File

@@ -18,7 +18,6 @@ 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"
@@ -34,6 +33,7 @@ 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,6 +46,7 @@ type (
Host string `json:"host,omitempty"`
Port route.Port `json:"port"`
// for TCP and UDP routes, bind address to listen on
Bind string `json:"bind,omitempty" validate:"omitempty,ip_addr" extensions:"x-nullable"`
Root string `json:"root,omitempty"`
@@ -199,11 +200,7 @@ func (r *Route) validate() gperr.Error {
if (r.Proxmox == nil || r.Proxmox.Node == "" || r.Proxmox.VMID == nil) && r.Container == nil {
wasNotNil := r.Proxmox != nil
workingState := config.WorkingState.Load()
var proxmoxProviders []*proxmox.Config
if workingState != nil { // nil in tests
proxmoxProviders = workingState.Value().Providers.Proxmox
}
proxmoxProviders := config.WorkingState.Load().Value().Providers.Proxmox
if len(proxmoxProviders) > 0 {
// it's fine if ip is nil
hostname := r.Host
@@ -257,7 +254,7 @@ func (r *Route) validate() gperr.Error {
}
// return error if route is localhost:<godoxy_port> but route is not agent
if !r.IsAgent() && !r.ShouldExclude() {
if !r.IsAgent() {
switch r.Host {
case "localhost", "127.0.0.1":
switch r.Port.Proxy {
@@ -277,19 +274,24 @@ func (r *Route) validate() gperr.Error {
var impl types.Route
var err gperr.Error
if r.ShouldExclude() {
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)
}
r.ProxyURL = gperr.Collect(&errs, nettypes.ParseURL, fmt.Sprintf("%s://%s", r.Scheme, net.JoinHostPort(r.Host, strconv.Itoa(r.Port.Proxy))))
} 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))))
case route.SchemeTCP, route.SchemeUDP:
if r.ShouldExclude() {
// should exclude, we don't care the scheme here.
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.Bind == "" {
r.Bind = "0.0.0.0"
}
bindIP := net.ParseIP(r.Bind)
remoteIP := net.ParseIP(r.Host)
toNetwork := func(ip net.IP, scheme route.Scheme) string {
@@ -358,8 +360,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 {
@@ -502,7 +504,7 @@ func (r *Route) start(parent task.Parent) gperr.Error {
// skip checking for excluded routes
excluded := r.ShouldExclude()
if !excluded {
if err := checkExists(parent.Context(), r); err != nil {
if err := checkExists(r); err != nil {
return err
}
}
@@ -516,22 +518,15 @@ func (r *Route) start(parent task.Parent) gperr.Error {
return err
}
} else {
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 = parent.Subtask("excluded."+r.Name(), true)
routes.Excluded.Add(r.impl)
r.task.OnCancel("remove_route_from_excluded", func() {
ep.ExcludedRoutes().Del(r.impl)
routes.Excluded.Del(r.impl)
})
if r.UseHealthCheck() {
r.HealthMon = monitor.NewMonitor(r.impl)
err := r.HealthMon.Start(r.task)
if err != nil {
return err
}
return err
}
}
return nil
@@ -569,10 +564,6 @@ 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
}
@@ -758,7 +749,6 @@ const (
ExcludedReasonNoPortSpecified
ExcludedReasonBlacklisted
ExcludedReasonBuildx
ExcludedReasonYAMLAnchor
ExcludedReasonOld
)
@@ -778,8 +768,6 @@ func (re ExcludedReason) String() string {
return "Blacklisted (backend service or database)"
case ExcludedReasonBuildx:
return "Buildx"
case ExcludedReasonYAMLAnchor:
return "YAML anchor or reference"
case ExcludedReasonOld:
return "Container renaming intermediate state"
default:
@@ -814,12 +802,6 @@ func (r *Route) findExcludedReason() ExcludedReason {
} else if r.IsZeroPort() && r.Scheme != route.SchemeFileServer {
return ExcludedReasonNoPortSpecified
}
// this should happen on validation API only,
// those routes are removed before validation.
// see removeXPrefix in provider/file.go
if strings.HasPrefix(r.Alias, "x-") { // for YAML anchors and references
return ExcludedReasonYAMLAnchor
}
if strings.HasSuffix(r.Alias, "-old") {
return ExcludedReasonOld
}
@@ -941,13 +923,6 @@ 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()
@@ -958,7 +933,6 @@ 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

@@ -4,10 +4,10 @@ import (
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/yusing/godoxy/internal/common"
route "github.com/yusing/godoxy/internal/route/types"
"github.com/yusing/godoxy/internal/types"
expect "github.com/yusing/goutils/testing"
)
func TestRouteValidate(t *testing.T) {
@@ -19,8 +19,20 @@ func TestRouteValidate(t *testing.T) {
Port: route.Port{Proxy: common.ProxyHTTPPort},
}
err := r.Validate()
require.Error(t, err, "Validate should return error for localhost with reserved port")
require.ErrorContains(t, err, "reserved for godoxy")
expect.HasError(t, err, "Validate should return error for localhost with reserved port")
expect.ErrorContains(t, err, "reserved for godoxy")
})
t.Run("ListeningPortWithHTTP", func(t *testing.T) {
r := &Route{
Alias: "test",
Scheme: route.SchemeHTTP,
Host: "example.com",
Port: route.Port{Proxy: 80, Listening: 1234},
}
err := r.Validate()
expect.HasError(t, err, "Validate should return error for HTTP scheme with listening port")
expect.ErrorContains(t, err, "unexpected listening port")
})
t.Run("DisabledHealthCheckWithLoadBalancer", func(t *testing.T) {
@@ -37,8 +49,8 @@ func TestRouteValidate(t *testing.T) {
}, // Minimal LoadBalance config with non-empty Link will be checked by UseLoadBalance
}
err := r.Validate()
require.Error(t, err, "Validate should return error for disabled healthcheck with loadbalancer")
require.ErrorContains(t, err, "cannot disable healthcheck")
expect.HasError(t, err, "Validate should return error for disabled healthcheck with loadbalancer")
expect.ErrorContains(t, err, "cannot disable healthcheck")
})
t.Run("FileServerScheme", func(t *testing.T) {
@@ -50,8 +62,8 @@ func TestRouteValidate(t *testing.T) {
Root: "/tmp", // Root is required for file server
}
err := r.Validate()
require.NoError(t, err, "Validate should not return error for valid file server route")
require.NotNil(t, r.impl, "Impl should be initialized")
expect.NoError(t, err, "Validate should not return error for valid file server route")
expect.NotNil(t, r.impl, "Impl should be initialized")
})
t.Run("HTTPScheme", func(t *testing.T) {
@@ -62,8 +74,8 @@ func TestRouteValidate(t *testing.T) {
Port: route.Port{Proxy: 80},
}
err := r.Validate()
require.NoError(t, err, "Validate should not return error for valid HTTP route")
require.NotNil(t, r.impl, "Impl should be initialized")
expect.NoError(t, err, "Validate should not return error for valid HTTP route")
expect.NotNil(t, r.impl, "Impl should be initialized")
})
t.Run("TCPScheme", func(t *testing.T) {
@@ -74,8 +86,8 @@ func TestRouteValidate(t *testing.T) {
Port: route.Port{Proxy: 80, Listening: 8080},
}
err := r.Validate()
require.NoError(t, err, "Validate should not return error for valid TCP route")
require.NotNil(t, r.impl, "Impl should be initialized")
expect.NoError(t, err, "Validate should not return error for valid TCP route")
expect.NotNil(t, r.impl, "Impl should be initialized")
})
t.Run("DockerContainer", func(t *testing.T) {
@@ -94,8 +106,8 @@ func TestRouteValidate(t *testing.T) {
},
}
err := r.Validate()
require.NoError(t, err, "Validate should not return error for valid docker container route")
require.NotNil(t, r.ProxyURL, "ProxyURL should be set")
expect.NoError(t, err, "Validate should not return error for valid docker container route")
expect.NotNil(t, r.ProxyURL, "ProxyURL should be set")
})
t.Run("InvalidScheme", func(t *testing.T) {
@@ -105,7 +117,7 @@ func TestRouteValidate(t *testing.T) {
Host: "example.com",
Port: route.Port{Proxy: 80},
}
require.Panics(t, func() {
expect.Panics(t, func() {
_ = r.Validate()
}, "Validate should panic for invalid scheme")
})
@@ -118,9 +130,9 @@ func TestRouteValidate(t *testing.T) {
Port: route.Port{Proxy: 80},
}
err := r.Validate()
require.NoError(t, err)
require.NotNil(t, r.ProxyURL)
require.NotNil(t, r.HealthCheck)
expect.NoError(t, err)
expect.NotNil(t, r.ProxyURL)
expect.NotNil(t, r.HealthCheck)
})
}
@@ -132,7 +144,7 @@ func TestPreferredPort(t *testing.T) {
}
port := preferredPort(ports)
require.Equal(t, 3000, port)
expect.Equal(t, port, 3000)
}
func TestDockerRouteDisallowAgent(t *testing.T) {
@@ -152,8 +164,8 @@ func TestDockerRouteDisallowAgent(t *testing.T) {
},
}
err := r.Validate()
require.Error(t, err, "Validate should return error for docker route with agent")
require.ErrorContains(t, err, "specifying agent is not allowed for docker container routes")
expect.HasError(t, err, "Validate should return error for docker route with agent")
expect.ErrorContains(t, err, "specifying agent is not allowed for docker container routes")
}
func TestRouteAgent(t *testing.T) {
@@ -165,8 +177,8 @@ func TestRouteAgent(t *testing.T) {
Agent: "test-agent",
}
err := r.Validate()
require.NoError(t, err, "Validate should not return error for valid route with agent")
require.NotNil(t, r.GetAgent(), "GetAgent should return agent")
expect.NoError(t, err, "Validate should not return error for valid route with agent")
expect.NotNil(t, r.GetAgent(), "GetAgent should return agent")
}
func TestRouteApplyingHealthCheckDefaults(t *testing.T) {
@@ -176,106 +188,6 @@ func TestRouteApplyingHealthCheckDefaults(t *testing.T) {
Timeout: 10 * time.Second,
})
require.Equal(t, 15*time.Second, hc.Interval)
require.Equal(t, 10*time.Second, hc.Timeout)
}
func TestRouteBindField(t *testing.T) {
t.Run("TCPSchemeWithCustomBind", func(t *testing.T) {
r := &Route{
Alias: "test-tcp",
Scheme: route.SchemeTCP,
Host: "192.168.1.100",
Port: route.Port{Proxy: 80, Listening: 8080},
Bind: "192.168.1.1",
}
err := r.Validate()
require.NoError(t, err, "Validate should not return error for TCP route with custom bind")
require.NotNil(t, r.LisURL, "LisURL should be set")
require.Equal(t, "tcp4://192.168.1.1:8080", r.LisURL.String(), "LisURL should contain custom bind address")
})
t.Run("UDPSchemeWithCustomBind", func(t *testing.T) {
r := &Route{
Alias: "test-udp",
Scheme: route.SchemeUDP,
Host: "10.0.0.1",
Port: route.Port{Proxy: 53, Listening: 53},
Bind: "10.0.0.254",
}
err := r.Validate()
require.NoError(t, err, "Validate should not return error for UDP route with custom bind")
require.NotNil(t, r.LisURL, "LisURL should be set")
require.Equal(t, "udp4://10.0.0.254:53", r.LisURL.String(), "LisURL should contain custom bind address")
})
t.Run("HTTPSchemeWithoutBind", func(t *testing.T) {
r := &Route{
Alias: "test-http",
Scheme: route.SchemeHTTPS,
Host: "example.com",
Port: route.Port{Proxy: 443},
}
err := r.Validate()
require.NoError(t, err, "Validate should not return error for HTTP route with bind")
require.NotNil(t, r.LisURL, "LisURL should be set")
require.Equal(t, "https://:0", r.LisURL.String(), "LisURL should contain bind address")
})
t.Run("HTTPSchemeWithBind", func(t *testing.T) {
r := &Route{
Alias: "test-http",
Scheme: route.SchemeHTTPS,
Host: "example.com",
Port: route.Port{Proxy: 443},
Bind: "0.0.0.0",
}
err := r.Validate()
require.NoError(t, err, "Validate should not return error for HTTP route with bind")
require.NotNil(t, r.LisURL, "LisURL should be set")
require.Equal(t, "https://0.0.0.0:0", r.LisURL.String(), "LisURL should contain bind address")
})
t.Run("HTTPSchemeWithBindAndPort", func(t *testing.T) {
r := &Route{
Alias: "test-http",
Scheme: route.SchemeHTTPS,
Host: "example.com",
Port: route.Port{Listening: 8443, Proxy: 443},
Bind: "0.0.0.0",
}
err := r.Validate()
require.NoError(t, err, "Validate should not return error for HTTP route with bind and port")
require.NotNil(t, r.LisURL, "LisURL should be set")
require.Equal(t, "https://0.0.0.0:8443", r.LisURL.String(), "LisURL should contain bind address and listening port")
})
t.Run("TCPSchemeDefaultsToZeroBind", func(t *testing.T) {
r := &Route{
Alias: "test-default-bind",
Scheme: route.SchemeTCP,
Host: "example.com",
Port: route.Port{Proxy: 80, Listening: 8080},
Bind: "",
}
err := r.Validate()
require.NoError(t, err, "Validate should not return error for TCP route with empty bind")
require.Equal(t, "0.0.0.0", r.Bind, "Bind should default to 0.0.0.0 for TCP scheme")
require.NotNil(t, r.LisURL, "LisURL should be set")
require.Equal(t, "tcp4://0.0.0.0:8080", r.LisURL.String(), "LisURL should use default bind address")
})
t.Run("FileServerSchemeWithBind", func(t *testing.T) {
r := &Route{
Alias: "test-fileserver",
Scheme: route.SchemeFileServer,
Port: route.Port{Listening: 9000},
Root: "/tmp",
Bind: "127.0.0.1",
}
err := r.Validate()
require.NoError(t, err, "Validate should not return error for fileserver route with bind")
require.NotNil(t, r.LisURL, "LisURL should be set")
require.Equal(t, "https://127.0.0.1:9000", r.LisURL.String(), "LisURL should contain bind address")
})
expect.Equal(t, hc.Interval, 15*time.Second)
expect.Equal(t, hc.Timeout, 10*time.Second)
}

View File

@@ -0,0 +1,307 @@
# 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

@@ -0,0 +1,103 @@
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

@@ -0,0 +1,91 @@
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,7 +10,6 @@ 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"
@@ -198,10 +197,9 @@ var commands = map[string]struct {
build: func(args any) CommandHandler {
route := args.(string)
return TerminatingCommand(func(w http.ResponseWriter, req *http.Request) error {
ep := entrypoint.FromCtx(req.Context())
r, ok := ep.HTTPRoutes().Get(route)
r, ok := routes.HTTP.Get(route)
if !ok {
excluded, has := ep.ExcludedRoutes().Get(route)
excluded, has := routes.Excluded.Get(route)
if has {
r, ok = excluded.(types.HTTPRoute)
}

View File

@@ -3,19 +3,12 @@
do: pass
- name: protected
on: |
!path regex("(_next/static|_next/image|favicon.ico).*")
!path glob("/api/v1/auth/*")
!path glob("/auth/*")
!path /icon0.svg
!path /favicon.ico
!path /apple-icon.png
!path glob("/web-app-manifest-*x*.png")
!path regex("\/assets\/(chunks\/)?[a-zA-Z0-9\-_]+\.(css|js|woff2)")
!path regex("\/assets\/workbox-window\.prod\.es5-[a-zA-Z0-9]+\.js")
!path regex("/workbox-[a-zA-Z0-9]+\.js")
!path regex("[A-Za-z0-9_-]+\.(svg|png|jpg|jpeg|gif|ico|webp|woff2?|eot|ttf|otf|txt)(\?.+)?")
!path /api/v1/version
!path /manifest.webmanifest
!path /sw.js
!path /registerSW.js
!path /manifest.json
do: require_auth
- name: proxy to backend
on: path glob("/api/v1/*")

View File

@@ -1,26 +0,0 @@
- name: login page
on: path /login
do: pass
- name: protected
on: |
!path glob("/@tanstack-start/*")
!path glob("/@vite-plugin-pwa/*")
!path glob("/__tsd/*")
!path /@react-refresh
!path /@vite/client
!path regex("/\?token=[a-zA-Z0-9-_]+")
!path glob("/@id/*")
!path glob("/api/v1/auth/*")
!path glob("/auth/*")
!path regex("([A-Za-z0-9_\-/]+)+\.(css|ts|js|mjs|svg|png|jpg|jpeg|gif|ico|webp|woff2?|eot|ttf|otf|txt)(\?.*)?")
!path /api/v1/version
!path /manifest.webmanifest
do: require_auth
- name: proxy to backend
on: path glob("/api/v1/*")
do: proxy http://${API_ADDR}/
- name: proxy to auth api
on: path glob("/auth/*")
do: |
rewrite /auth /api/v1/auth
proxy http://${API_ADDR}/

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,7 +65,6 @@ 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
}
}
@@ -82,13 +81,10 @@ func (r *StreamRoute) Start(parent task.Parent) gperr.Error {
r.l.Info().Msg("stream closed")
})
ep := entrypoint.FromCtx(parent.Context())
if ep == nil {
err := gperr.New("entrypoint not initialized")
r.task.Finish(err)
return err
}
ep.AddRoute(r)
routes.Stream.Add(r)
r.task.OnCancel("remove_route_from_stream", func() {
routes.Stream.Del(r)
})
return nil
}

View File

@@ -7,9 +7,9 @@ import (
"github.com/pires/go-proxyproto"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
acl "github.com/yusing/godoxy/internal/acl/types"
"github.com/yusing/godoxy/internal/acl"
"github.com/yusing/godoxy/internal/agentpool"
entrypoint "github.com/yusing/godoxy/internal/entrypoint/types"
"github.com/yusing/godoxy/internal/entrypoint"
nettypes "github.com/yusing/godoxy/internal/net/types"
ioutils "github.com/yusing/goutils/io"
"go.uber.org/atomic"
@@ -51,19 +51,15 @@ func (s *TCPTCPStream) ListenAndServe(ctx context.Context, preDial, onRead netty
return
}
// TODO: add to entrypoint
if ep := entrypoint.FromCtx(ctx); ep != nil {
if proxyProto := ep.SupportProxyProtocol(); proxyProto {
s.listener = &proxyproto.Listener{Listener: s.listener}
}
}
if acl := acl.FromCtx(ctx); acl != nil {
if acl, ok := ctx.Value(acl.ContextKey{}).(*acl.Config); ok {
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}
}
s.preDial = preDial
s.onRead = onRead
go s.listen(ctx)

View File

@@ -11,7 +11,7 @@ import (
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
acl "github.com/yusing/godoxy/internal/acl/types"
"github.com/yusing/godoxy/internal/acl"
"github.com/yusing/godoxy/internal/agentpool"
nettypes "github.com/yusing/godoxy/internal/net/types"
"github.com/yusing/goutils/synk"
@@ -82,11 +82,10 @@ func (s *UDPUDPStream) ListenAndServe(ctx context.Context, preDial, onRead netty
return
}
s.listener = l
if acl := acl.FromCtx(ctx); acl != nil {
if acl, ok := ctx.Value(acl.ContextKey{}).(*acl.Config); ok {
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

@@ -1,32 +0,0 @@
package route
import (
"testing"
"github.com/yusing/godoxy/internal/entrypoint"
epctx "github.com/yusing/godoxy/internal/entrypoint/types"
"github.com/yusing/godoxy/internal/types"
"github.com/yusing/goutils/task"
)
func NewStartedTestRoute(t testing.TB, base *Route) (types.Route, error) {
t.Helper()
task := task.GetTestTask(t)
if ep := epctx.FromCtx(task.Context()); ep == nil {
ep = entrypoint.NewEntrypoint(task, nil)
epctx.SetCtx(task, ep)
}
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,19 +74,6 @@ 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,7 +20,6 @@ type (
pool.Object
ProviderName() string
GetProvider() RouteProvider
ListenURL() *nettypes.URL
TargetURL() *nettypes.URL
HealthMonitor() HealthMonitor
SetHealthMonitor(m HealthMonitor)

View File

@@ -1,5 +1,5 @@
# Stage 1: deps
FROM golang:1.25.7-alpine AS deps
FROM golang:1.25.6-alpine AS deps
HEALTHCHECK NONE
# package version does not matter
@@ -49,7 +49,5 @@ COPY --from=builder /app/run /app/run
WORKDIR /app
LABEL proxy.#1.healthcheck.disable=true
ENV LISTEN_ADDR=0.0.0.0:2375
CMD ["/app/run"]

View File

@@ -1,6 +1,6 @@
module github.com/yusing/godoxy/socketproxy
go 1.25.7
go 1.25.6
replace github.com/yusing/goutils => ../goutils