refactor(entrypoint): move route registry into entrypoint context (#200)

- Introduced `NewTestRoute` function to simplify route creation in benchmark tests.
- Replaced direct route validation and starting with error handling using `require.NoError`.
- Updated server retrieval to use `common.ProxyHTTPAddr` for consistency.
- Improved logging for HTTP route addition errors in `AddRoute` method.

* fix(tcp): wrap proxy proto listener before acl

* refactor(entrypoint): propagate errors from route registration and stream serving

* fix(docs): correct swagger and package README
This commit is contained in:
Yuzerion
2026-02-08 09:17:46 +08:00
committed by GitHub
parent bd49f1b348
commit 31b4fedf72
68 changed files with 1839 additions and 1291 deletions

View File

@@ -30,9 +30,11 @@ 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
Host string // Target 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
@@ -196,6 +198,7 @@ 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"`
@@ -218,23 +221,26 @@ labels:
routes:
myapp:
scheme: http
root: /var/www/myapp
spa: true
host: myapp.local
bind: 192.168.1.100 # Optional: bind to specific address
port:
proxy: 80
target: 3000
```
## Dependency and Integration Map
| 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 |
| 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 |
## Observability
@@ -305,6 +311,18 @@ 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{Listening: 8443, Proxy: 80},
}
```
### File Server Route
```go

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ import (
"github.com/yusing/godoxy/agent/pkg/agent"
"github.com/yusing/godoxy/agent/pkg/agentproxy"
config "github.com/yusing/godoxy/internal/config/types"
entrypoint "github.com/yusing/godoxy/internal/entrypoint/types"
"github.com/yusing/godoxy/internal/health/monitor"
"github.com/yusing/godoxy/internal/idlewatcher"
"github.com/yusing/godoxy/internal/logging/accesslog"
@@ -14,7 +14,6 @@ import (
"github.com/yusing/godoxy/internal/net/gphttp/loadbalancer"
"github.com/yusing/godoxy/internal/net/gphttp/middleware"
nettypes "github.com/yusing/godoxy/internal/net/types"
"github.com/yusing/godoxy/internal/route/routes"
route "github.com/yusing/godoxy/internal/route/types"
"github.com/yusing/godoxy/internal/types"
gperr "github.com/yusing/goutils/errs"
@@ -159,23 +158,28 @@ func (r *ReveseProxyRoute) Start(parent task.Parent) gperr.Error {
if r.HealthMon != nil {
if err := r.HealthMon.Start(r.task); err != nil {
return err
gperr.LogWarn("health monitor error", err, &r.rp.Logger)
r.HealthMon = nil
}
}
ep := entrypoint.FromCtx(parent.Context())
if ep == nil {
err := gperr.New("entrypoint not initialized")
r.task.Finish(err)
return err
}
if r.UseLoadBalance() {
r.addToLoadBalancer(parent)
} else {
routes.HTTP.Add(r)
if state := config.WorkingState.Load(); state != nil {
state.ShortLinkMatcher().AddRoute(r.Alias)
if err := r.addToLoadBalancer(parent, ep); err != nil {
r.task.Finish(err)
return gperr.Wrap(err)
}
} else {
if err := ep.StartAddRoute(r); err != nil {
r.task.Finish(err)
return gperr.Wrap(err)
}
r.task.OnCancel("remove_route", func() {
routes.HTTP.Del(r)
if state := config.WorkingState.Load(); state != nil {
state.ShortLinkMatcher().DelRoute(r.Alias)
}
})
}
return nil
}
@@ -187,16 +191,16 @@ func (r *ReveseProxyRoute) ServeHTTP(w http.ResponseWriter, req *http.Request) {
var lbLock sync.Mutex
func (r *ReveseProxyRoute) addToLoadBalancer(parent task.Parent) {
func (r *ReveseProxyRoute) addToLoadBalancer(parent task.Parent, ep entrypoint.Entrypoint) error {
var lb *loadbalancer.LoadBalancer
cfg := r.LoadBalance
lbLock.Lock()
defer lbLock.Unlock()
l, ok := routes.HTTP.Get(cfg.Link)
l, ok := ep.HTTPRoutes().Get(cfg.Link)
var linked *ReveseProxyRoute
if ok {
lbLock.Unlock()
linked = l.(*ReveseProxyRoute)
linked = l.(*ReveseProxyRoute) // it must be a reverse proxy route
lb = linked.loadBalancer
lb.UpdateConfigIfNeeded(cfg)
if linked.Homepage.Name == "" {
@@ -209,22 +213,20 @@ func (r *ReveseProxyRoute) addToLoadBalancer(parent task.Parent) {
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)
routes.HTTP.AddKey(cfg.Link, linked)
if state := config.WorkingState.Load(); state != nil {
state.ShortLinkMatcher().AddRoute(cfg.Link)
if err := ep.StartAddRoute(linked); err != nil {
lb.Finish(err)
return err
}
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
@@ -233,4 +235,5 @@ func (r *ReveseProxyRoute) addToLoadBalancer(parent task.Parent) {
r.task.OnCancel("lb_remove_server", func() {
lb.RemoveServer(server)
})
return nil
}

View File

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

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,20 +19,8 @@ func TestRouteValidate(t *testing.T) {
Port: route.Port{Proxy: common.ProxyHTTPPort},
}
err := r.Validate()
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")
require.Error(t, err, "Validate should return error for localhost with reserved port")
require.ErrorContains(t, err, "reserved for godoxy")
})
t.Run("DisabledHealthCheckWithLoadBalancer", func(t *testing.T) {
@@ -49,8 +37,8 @@ func TestRouteValidate(t *testing.T) {
}, // Minimal LoadBalance config with non-empty Link will be checked by UseLoadBalance
}
err := r.Validate()
expect.HasError(t, err, "Validate should return error for disabled healthcheck with loadbalancer")
expect.ErrorContains(t, err, "cannot disable healthcheck")
require.Error(t, err, "Validate should return error for disabled healthcheck with loadbalancer")
require.ErrorContains(t, err, "cannot disable healthcheck")
})
t.Run("FileServerScheme", func(t *testing.T) {
@@ -62,8 +50,8 @@ func TestRouteValidate(t *testing.T) {
Root: "/tmp", // Root is required for file server
}
err := r.Validate()
expect.NoError(t, err, "Validate should not return error for valid file server route")
expect.NotNil(t, r.impl, "Impl should be initialized")
require.NoError(t, err, "Validate should not return error for valid file server route")
require.NotNil(t, r.impl, "Impl should be initialized")
})
t.Run("HTTPScheme", func(t *testing.T) {
@@ -74,8 +62,8 @@ func TestRouteValidate(t *testing.T) {
Port: route.Port{Proxy: 80},
}
err := r.Validate()
expect.NoError(t, err, "Validate should not return error for valid HTTP route")
expect.NotNil(t, r.impl, "Impl should be initialized")
require.NoError(t, err, "Validate should not return error for valid HTTP route")
require.NotNil(t, r.impl, "Impl should be initialized")
})
t.Run("TCPScheme", func(t *testing.T) {
@@ -86,8 +74,8 @@ func TestRouteValidate(t *testing.T) {
Port: route.Port{Proxy: 80, Listening: 8080},
}
err := r.Validate()
expect.NoError(t, err, "Validate should not return error for valid TCP route")
expect.NotNil(t, r.impl, "Impl should be initialized")
require.NoError(t, err, "Validate should not return error for valid TCP route")
require.NotNil(t, r.impl, "Impl should be initialized")
})
t.Run("DockerContainer", func(t *testing.T) {
@@ -106,8 +94,8 @@ func TestRouteValidate(t *testing.T) {
},
}
err := r.Validate()
expect.NoError(t, err, "Validate should not return error for valid docker container route")
expect.NotNil(t, r.ProxyURL, "ProxyURL should be set")
require.NoError(t, err, "Validate should not return error for valid docker container route")
require.NotNil(t, r.ProxyURL, "ProxyURL should be set")
})
t.Run("InvalidScheme", func(t *testing.T) {
@@ -117,7 +105,7 @@ func TestRouteValidate(t *testing.T) {
Host: "example.com",
Port: route.Port{Proxy: 80},
}
expect.Panics(t, func() {
require.Panics(t, func() {
_ = r.Validate()
}, "Validate should panic for invalid scheme")
})
@@ -130,9 +118,9 @@ func TestRouteValidate(t *testing.T) {
Port: route.Port{Proxy: 80},
}
err := r.Validate()
expect.NoError(t, err)
expect.NotNil(t, r.ProxyURL)
expect.NotNil(t, r.HealthCheck)
require.NoError(t, err)
require.NotNil(t, r.ProxyURL)
require.NotNil(t, r.HealthCheck)
})
}
@@ -144,7 +132,7 @@ func TestPreferredPort(t *testing.T) {
}
port := preferredPort(ports)
expect.Equal(t, port, 3000)
require.Equal(t, 3000, port)
}
func TestDockerRouteDisallowAgent(t *testing.T) {
@@ -164,8 +152,8 @@ func TestDockerRouteDisallowAgent(t *testing.T) {
},
}
err := r.Validate()
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")
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")
}
func TestRouteAgent(t *testing.T) {
@@ -177,8 +165,8 @@ func TestRouteAgent(t *testing.T) {
Agent: "test-agent",
}
err := r.Validate()
expect.NoError(t, err, "Validate should not return error for valid route with agent")
expect.NotNil(t, r.GetAgent(), "GetAgent should return agent")
require.NoError(t, err, "Validate should not return error for valid route with agent")
require.NotNil(t, r.GetAgent(), "GetAgent should return agent")
}
func TestRouteApplyingHealthCheckDefaults(t *testing.T) {
@@ -188,6 +176,106 @@ func TestRouteApplyingHealthCheckDefaults(t *testing.T) {
Timeout: 10 * time.Second,
})
expect.Equal(t, hc.Interval, 15*time.Second)
expect.Equal(t, hc.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.SchemeHTTP,
Host: "example.com",
Port: route.Port{Proxy: 80},
}
err := r.Validate()
require.NoError(t, err, "Validate should not return error for HTTP route without 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.SchemeHTTP,
Host: "example.com",
Port: route.Port{Proxy: 80},
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.SchemeHTTP,
Host: "example.com",
Port: route.Port{Listening: 8080, Proxy: 80},
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:8080", 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")
})
}

View File

@@ -1,307 +0,0 @@
# Route Registry
Provides centralized route registry with O(1) lookups and route context management for HTTP handlers.
## Overview
The `internal/route/routes` package maintains the global route registry for GoDoxy. It provides thread-safe route lookups by alias, route iteration, and utilities for propagating route context through HTTP request handlers.
### Primary Consumers
- **HTTP handlers**: Lookup routes and extract request context
- **Route providers**: Register and unregister routes
- **Health system**: Query route health status
- **WebUI**: Display route information
### Non-goals
- Does not create or modify routes
- Does not handle route validation
- Does not implement routing logic (matching)
### Stability
Internal package with stable public API.
## Public API
### Route Pools
```go
var (
HTTP = pool.New[types.HTTPRoute]("http_routes")
Stream = pool.New[types.StreamRoute]("stream_routes")
Excluded = pool.New[types.Route]("excluded_routes")
)
```
Pool methods:
- `Get(alias string) (T, bool)` - O(1) lookup
- `Add(r T)` - Register route
- `Del(r T)` - Unregister route
- `Size() int` - Route count
- `Clear()` - Remove all routes
- `Iter` - Channel-based iteration
### Exported Functions
```go
// Iterate over active routes (HTTP + Stream)
func IterActive(yield func(r types.Route) bool)
// Iterate over all routes (HTTP + Stream + Excluded)
func IterAll(yield func(r types.Route) bool)
// Get route count
func NumActiveRoutes() int
func NumAllRoutes() int
// Clear all routes
func Clear()
// Lookup functions
func Get(alias string) (types.Route, bool)
func GetHTTPRouteOrExact(alias, host string) (types.HTTPRoute, bool)
```
### Route Context
```go
type RouteContext struct {
context.Context
Route types.HTTPRoute
}
// Attach route to request context (uses unsafe pointer for performance)
func WithRouteContext(r *http.Request, route types.HTTPRoute) *http.Request
// Extract route from request context
func TryGetRoute(r *http.Request) types.HTTPRoute
```
### Upstream Information
```go
func TryGetUpstreamName(r *http.Request) string
func TryGetUpstreamScheme(r *http.Request) string
func TryGetUpstreamHost(r *http.Request) string
func TryGetUpstreamPort(r *http.Request) string
func TryGetUpstreamHostPort(r *http.Request) string
func TryGetUpstreamAddr(r *http.Request) string
func TryGetUpstreamURL(r *http.Request) string
```
### Health Information
```go
type HealthInfo struct {
HealthInfoWithoutDetail
Detail string
}
type HealthInfoWithoutDetail struct {
Status types.HealthStatus
Uptime time.Duration
Latency time.Duration
}
func GetHealthInfo() map[string]HealthInfo
func GetHealthInfoWithoutDetail() map[string]HealthInfoWithoutDetail
func GetHealthInfoSimple() map[string]types.HealthStatus
```
### Provider Grouping
```go
func ByProvider() map[string][]types.Route
```
## Proxmox Integration
Routes can be automatically linked to Proxmox nodes or LXC containers through reverse lookup during validation.
### Node-Level Routes
Routes can be linked to a Proxmox node directly (VMID = 0) when the route's hostname, IP, or alias matches a node name or IP:
```go
// Route linked to Proxmox node (no specific VM)
route.Proxmox = &proxmox.NodeConfig{
Node: "pve-node-01",
VMID: 0, // node-level, no container
VMName: "",
}
```
### Container-Level Routes
Routes are linked to LXC containers when they match a VM resource by hostname, IP, or alias:
```go
// Route linked to LXC container
route.Proxmox = &proxmox.NodeConfig{
Node: "pve-node-01",
VMID: 100,
VMName: "my-container",
}
```
### Lookup Priority
1. **Node match** - If hostname, IP, or alias matches a Proxmox node
2. **VM match** - If hostname, IP, or alias matches a VM resource
Node-level routes skip container control logic (start/check IPs) and can be used to proxy node services directly.
## Architecture
### Core Components
```mermaid
classDiagram
class HTTP
class Stream
class Excluded
class RouteContext
HTTP : +Get(alias) T
HTTP : +Add(r)
HTTP : +Del(r)
HTTP : +Size() int
HTTP : +Iter chan
Stream : +Get(alias) T
Stream : +Add(r)
Stream : +Del(r)
Excluded : +Get(alias) T
Excluded : +Add(r)
Excluded : +Del(r)
```
### Route Lookup Flow
```mermaid
flowchart TD
A[Lookup Request] --> B{HTTP Pool}
B -->|Found| C[Return Route]
B -->|Not Found| D{Stream Pool}
D -->|Found| C
D -->|Not Found| E[Return nil]
```
### Context Propagation
```mermaid
sequenceDiagram
participant H as HTTP Handler
participant R as Registry
participant C as RouteContext
H->>R: WithRouteContext(req, route)
R->>C: Attach route via unsafe pointer
C-->>H: Modified request
H->>R: TryGetRoute(req)
R->>C: Extract route from context
C-->>R: Route
R-->>H: Route
```
## Dependency and Integration Map
| Dependency | Purpose |
| -------------------------------- | ---------------------------------- |
| `internal/types` | Route and health type definitions |
| `internal/proxmox` | Proxmox node/container integration |
| `github.com/yusing/goutils/pool` | Thread-safe pool implementation |
## Observability
### Logs
Registry operations logged at DEBUG level:
- Route add/remove
- Pool iteration
- Context operations
### Performance
- `WithRouteContext` uses `unsafe.Pointer` to avoid request cloning
- Route lookups are O(1) using internal maps
- Iteration uses channels for memory efficiency
## Security Considerations
- Route context propagation is internal to the process
- No sensitive data exposed in context keys
- Routes are validated before registration
## Failure Modes and Recovery
| Failure | Behavior | Recovery |
| ---------------------------------------- | ------------------------------ | -------------------- |
| Route not found | Returns (nil, false) | Verify route alias |
| Context extraction on non-route request | Returns nil | Check request origin |
| Concurrent modification during iteration | Handled by pool implementation | N/A |
## Usage Examples
### Basic Route Lookup
```go
route, ok := routes.Get("myapp")
if !ok {
return fmt.Errorf("route not found")
}
```
### Iterating Over All Routes
```go
for r := range routes.IterActive {
log.Printf("Route: %s", r.Name())
}
```
### Getting Health Status
```go
healthMap := routes.GetHealthInfo()
for name, health := range healthMap {
log.Printf("Route %s: %s (uptime: %v)", name, health.Status, health.Uptime)
}
```
### Using Route Context in Handler
```go
func MyHandler(w http.ResponseWriter, r *http.Request) {
route := routes.TryGetRoute(r)
if route == nil {
http.Error(w, "Route not found", http.StatusNotFound)
return
}
upstreamHost := routes.TryGetUpstreamHost(r)
log.Printf("Proxying to: %s", upstreamHost)
}
```
### Grouping Routes by Provider
```go
byProvider := routes.ByProvider()
for providerName, routeList := range byProvider {
log.Printf("Provider %s: %d routes", providerName, len(routeList))
}
```
## Testing Notes
- Unit tests for pool thread safety
- Context propagation tests
- Health info aggregation tests
- Provider grouping tests

View File

@@ -1,103 +0,0 @@
package routes
import (
"time"
"github.com/yusing/godoxy/internal/types"
)
type HealthInfo struct {
HealthInfoWithoutDetail
Detail string `json:"detail"`
} // @name HealthInfo
type HealthInfoWithoutDetail struct {
Status types.HealthStatus `json:"status" swaggertype:"string" enums:"healthy,unhealthy,napping,starting,error,unknown"`
Uptime time.Duration `json:"uptime" swaggertype:"number"` // uptime in milliseconds
Latency time.Duration `json:"latency" swaggertype:"number"` // latency in microseconds
} // @name HealthInfoWithoutDetail
type HealthMap = map[string]types.HealthStatusString // @name HealthMap
// GetHealthInfo returns a map of route name to health info.
//
// The health info is for all routes, including excluded routes.
func GetHealthInfo() map[string]HealthInfo {
healthMap := make(map[string]HealthInfo, NumAllRoutes())
for r := range IterAll {
healthMap[r.Name()] = getHealthInfo(r)
}
return healthMap
}
// GetHealthInfoWithoutDetail returns a map of route name to health info without detail.
//
// The health info is for all routes, including excluded routes.
func GetHealthInfoWithoutDetail() map[string]HealthInfoWithoutDetail {
healthMap := make(map[string]HealthInfoWithoutDetail, NumAllRoutes())
for r := range IterAll {
healthMap[r.Name()] = getHealthInfoWithoutDetail(r)
}
return healthMap
}
func GetHealthInfoSimple() map[string]types.HealthStatus {
healthMap := make(map[string]types.HealthStatus, NumAllRoutes())
for r := range IterAll {
healthMap[r.Name()] = getHealthInfoSimple(r)
}
return healthMap
}
func getHealthInfo(r types.Route) HealthInfo {
mon := r.HealthMonitor()
if mon == nil {
return HealthInfo{
HealthInfoWithoutDetail: HealthInfoWithoutDetail{
Status: types.StatusUnknown,
},
Detail: "n/a",
}
}
return HealthInfo{
HealthInfoWithoutDetail: HealthInfoWithoutDetail{
Status: mon.Status(),
Uptime: mon.Uptime(),
Latency: mon.Latency(),
},
Detail: mon.Detail(),
}
}
func getHealthInfoWithoutDetail(r types.Route) HealthInfoWithoutDetail {
mon := r.HealthMonitor()
if mon == nil {
return HealthInfoWithoutDetail{
Status: types.StatusUnknown,
}
}
return HealthInfoWithoutDetail{
Status: mon.Status(),
Uptime: mon.Uptime(),
Latency: mon.Latency(),
}
}
func getHealthInfoSimple(r types.Route) types.HealthStatus {
mon := r.HealthMonitor()
if mon == nil {
return types.StatusUnknown
}
return mon.Status()
}
// ByProvider returns a map of provider name to routes.
//
// The routes are all routes, including excluded routes.
func ByProvider() map[string][]types.Route {
rts := make(map[string][]types.Route)
for r := range IterAll {
rts[r.ProviderName()] = append(rts[r.ProviderName()], r)
}
return rts
}

View File

@@ -1,91 +0,0 @@
package routes
import (
"github.com/yusing/godoxy/internal/types"
"github.com/yusing/goutils/pool"
)
var (
HTTP = pool.New[types.HTTPRoute]("http_routes")
Stream = pool.New[types.StreamRoute]("stream_routes")
Excluded = pool.New[types.Route]("excluded_routes")
)
func IterActive(yield func(r types.Route) bool) {
for _, r := range HTTP.Iter {
if !yield(r) {
break
}
}
for _, r := range Stream.Iter {
if !yield(r) {
break
}
}
}
func IterAll(yield func(r types.Route) bool) {
for _, r := range HTTP.Iter {
if !yield(r) {
break
}
}
for _, r := range Stream.Iter {
if !yield(r) {
break
}
}
for _, r := range Excluded.Iter {
if !yield(r) {
break
}
}
}
func NumActiveRoutes() int {
return HTTP.Size() + Stream.Size()
}
func NumAllRoutes() int {
return HTTP.Size() + Stream.Size() + Excluded.Size()
}
func Clear() {
HTTP.Clear()
Stream.Clear()
Excluded.Clear()
}
func GetHTTPRouteOrExact(alias, host string) (types.HTTPRoute, bool) {
r, ok := HTTP.Get(alias)
if ok {
return r, true
}
// try find with exact match
return HTTP.Get(host)
}
// Get returns the route with the given alias.
//
// It does not return excluded routes.
func Get(alias string) (types.Route, bool) {
if r, ok := HTTP.Get(alias); ok {
return r, true
}
if r, ok := Stream.Get(alias); ok {
return r, true
}
return nil, false
}
// GetIncludeExcluded returns the route with the given alias, including excluded routes.
func GetIncludeExcluded(alias string) (types.Route, bool) {
if r, ok := HTTP.Get(alias); ok {
return r, true
}
if r, ok := Stream.Get(alias); ok {
return r, true
}
return Excluded.Get(alias)
}

View File

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

View File

@@ -3,14 +3,16 @@
do: pass
- name: protected
on: |
!path glob("@tanstack-start/*")
!path glob("/@tanstack-start/*")
!path glob("/@vite-plugin-pwa/*")
!path glob("/__tsd/*")
!path /@react-refresh
!path /@vite/client
!path regex("\?token=\w{5}-\w{5}")
!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 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

View File

@@ -7,11 +7,10 @@ import (
"strings"
"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"
@@ -26,6 +25,8 @@ type StreamRoute struct {
l zerolog.Logger
}
var _ types.StreamRoute = (*StreamRoute)(nil)
func NewStreamRoute(base *Route) (types.Route, gperr.Error) {
// TODO: support non-coherent scheme
return &StreamRoute{Route: base}, nil
@@ -65,31 +66,25 @@ 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
}
}
r.ListenAndServe(r.task.Context(), nil, nil)
r.l = log.With().
Str("type", r.LisURL.Scheme+"->"+r.ProxyURL.Scheme).
Str("name", r.Name()).
Stringer("rurl", r.ProxyURL).
Stringer("laddr", r.LocalAddr()).Logger()
r.l.Info().Msg("stream started")
r.task.OnCancel("close_stream", func() {
r.stream.Close()
r.l.Info().Msg("stream closed")
})
routes.Stream.Add(r)
r.task.OnCancel("remove_route_from_stream", func() {
routes.Stream.Del(r)
})
ep := entrypoint.FromCtx(parent.Context())
if ep == nil {
err := gperr.New("entrypoint not initialized")
r.task.Finish(err)
return err
}
if err := ep.StartAddRoute(r); err != nil {
r.task.Finish(err)
return gperr.Wrap(err)
}
return nil
}
func (r *StreamRoute) ListenAndServe(ctx context.Context, preDial, onRead nettypes.HookFunc) {
r.stream.ListenAndServe(ctx, preDial, onRead)
func (r *StreamRoute) ListenAndServe(ctx context.Context, preDial, onRead nettypes.HookFunc) error {
return r.stream.ListenAndServe(ctx, preDial, onRead)
}
func (r *StreamRoute) Close() error {

View File

@@ -63,10 +63,9 @@ func NewUDPUDPStream(network, listenAddr, dstAddr string) (nettypes.Stream, erro
```go
type Stream interface {
ListenAndServe(ctx context.Context, preDial, onRead HookFunc)
ListenAndServe(ctx context.Context, preDial, onRead HookFunc) error
Close() error
LocalAddr() net.Addr
zerolog.LogObjectMarshaler
}
type HookFunc func(ctx context.Context) error

View File

@@ -7,9 +7,9 @@ import (
"github.com/pires/go-proxyproto"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/yusing/godoxy/internal/acl"
acl "github.com/yusing/godoxy/internal/acl/types"
"github.com/yusing/godoxy/internal/agentpool"
"github.com/yusing/godoxy/internal/entrypoint"
entrypoint "github.com/yusing/godoxy/internal/entrypoint/types"
nettypes "github.com/yusing/godoxy/internal/net/types"
ioutils "github.com/yusing/goutils/io"
"go.uber.org/atomic"
@@ -43,26 +43,28 @@ func NewTCPTCPStream(network, dstNetwork, listenAddr, dstAddr string, agent *age
return &TCPTCPStream{network: network, dstNetwork: dstNetwork, laddr: laddr, dst: dst, agent: agent}, nil
}
func (s *TCPTCPStream) ListenAndServe(ctx context.Context, preDial, onRead nettypes.HookFunc) {
func (s *TCPTCPStream) ListenAndServe(ctx context.Context, preDial, onRead nettypes.HookFunc) error {
var err error
s.listener, err = net.ListenTCP(s.network, s.laddr)
if err != nil {
logErr(s, err, "failed to listen")
return
return err
}
if acl, ok := ctx.Value(acl.ContextKey{}).(*acl.Config); ok {
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 {
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)
return nil
}
func (s *TCPTCPStream) Close() error {

View File

@@ -11,7 +11,7 @@ import (
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/yusing/godoxy/internal/acl"
acl "github.com/yusing/godoxy/internal/acl/types"
"github.com/yusing/godoxy/internal/agentpool"
nettypes "github.com/yusing/godoxy/internal/net/types"
"github.com/yusing/goutils/synk"
@@ -75,14 +75,13 @@ func NewUDPUDPStream(network, dstNetwork, listenAddr, dstAddr string, agent *age
}, nil
}
func (s *UDPUDPStream) ListenAndServe(ctx context.Context, preDial, onRead nettypes.HookFunc) {
func (s *UDPUDPStream) ListenAndServe(ctx context.Context, preDial, onRead nettypes.HookFunc) error {
l, err := net.ListenUDP(s.network, s.laddr)
if err != nil {
logErr(s, err, "failed to listen")
return
return err
}
s.listener = l
if acl, ok := ctx.Value(acl.ContextKey{}).(*acl.Config); ok {
if acl := acl.FromCtx(ctx); acl != nil {
log.Debug().Str("listener", s.listener.LocalAddr().String()).Msg("wrapping listener with ACL")
s.listener = acl.WrapUDP(s.listener)
}
@@ -90,6 +89,7 @@ func (s *UDPUDPStream) ListenAndServe(ctx context.Context, preDial, onRead netty
s.onRead = onRead
go s.listen(ctx)
go s.cleanUp(ctx)
return nil
}
func (s *UDPUDPStream) Close() error {

View File

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