mirror of
https://github.com/yusing/godoxy.git
synced 2026-03-31 06:03:06 +02:00
v0.26.0
This commit is contained in:
@@ -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, and access logging.
|
||||
The entrypoint package provides the main HTTP entry point for GoDoxy, handling domain-based routing, middleware application, short link matching, access logging, and HTTP server lifecycle management.
|
||||
|
||||
## Overview
|
||||
|
||||
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.
|
||||
The entrypoint package implements the primary HTTP handler that receives all incoming requests, manages the lifecycle of HTTP servers, determines the target route based on hostname, applies middleware, and forwards requests to the appropriate route handler.
|
||||
|
||||
### Key Features
|
||||
|
||||
@@ -14,103 +14,350 @@ 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
|
||||
- HTTP server management (HTTP/HTTPS)
|
||||
- Route pool abstractions via [`PoolLike`](internal/entrypoint/types/entrypoint.go:27) and [`RWPoolLike`](internal/entrypoint/types/entrypoint.go:33) interfaces
|
||||
|
||||
## Architecture
|
||||
### Primary Consumers
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[HTTP Request] --> B[Entrypoint Handler]
|
||||
B --> C{Access Logger?}
|
||||
C -->|Yes| D[Wrap Response Recorder]
|
||||
C -->|No| E[Skip Logging]
|
||||
- **HTTP servers**: Per-listen-addr servers dispatch requests to routes
|
||||
- **Route providers**: Register routes via [`StartAddRoute`](internal/entrypoint/routes.go:48)
|
||||
- **Configuration layer**: Validates and applies middleware/access-logging config
|
||||
|
||||
D --> F[Find Route by Host]
|
||||
E --> F
|
||||
### Non-goals
|
||||
|
||||
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]
|
||||
- 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`)
|
||||
- Does not manage TCP/UDP listeners directly (only HTTP/HTTPS via `goutils/server`)
|
||||
|
||||
H -->|Yes| N[Apply Middleware]
|
||||
H -->|No| O[Direct Route]
|
||||
N --> O
|
||||
### Stability
|
||||
|
||||
O --> P[Route ServeHTTP]
|
||||
P --> Q[Response]
|
||||
|
||||
L --> R[404 Response]
|
||||
J --> Q
|
||||
M --> R
|
||||
```
|
||||
|
||||
## Core Components
|
||||
|
||||
### Entrypoint Structure
|
||||
|
||||
```go
|
||||
type Entrypoint struct {
|
||||
middleware *middleware.Middleware
|
||||
notFoundHandler http.Handler
|
||||
accessLogger accesslog.AccessLogger
|
||||
findRouteFunc func(host string) types.HTTPRoute
|
||||
shortLinkTree *ShortLinkMatcher
|
||||
}
|
||||
```
|
||||
|
||||
### Active Config
|
||||
|
||||
```go
|
||||
var ActiveConfig atomic.Pointer[entrypoint.Config]
|
||||
```
|
||||
Internal package with stable core interfaces. The [`Entrypoint`](internal/entrypoint/types/entrypoint.go:7) interface is the public contract.
|
||||
|
||||
## Public API
|
||||
|
||||
### Creation
|
||||
### Entrypoint Interface
|
||||
|
||||
```go
|
||||
// NewEntrypoint creates a new entrypoint instance.
|
||||
func NewEntrypoint() Entrypoint
|
||||
type Entrypoint interface {
|
||||
// Server capabilities
|
||||
SupportProxyProtocol() bool
|
||||
DisablePoolsLog(v bool)
|
||||
|
||||
// Route registry access
|
||||
GetRoute(alias string) (types.Route, bool)
|
||||
StartAddRoute(r types.Route) error
|
||||
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
|
||||
|
||||
// Configuration
|
||||
SetFindRouteDomains(domains []string)
|
||||
SetMiddlewares(mws []map[string]any) error
|
||||
SetNotFoundRules(rules rules.Rules)
|
||||
SetAccessLogger(parent task.Parent, cfg *accesslog.RequestLoggerConfig) error
|
||||
|
||||
// Context integration
|
||||
ShortLinkMatcher() *ShortLinkMatcher
|
||||
}
|
||||
```
|
||||
|
||||
### Pool Interfaces
|
||||
|
||||
```go
|
||||
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)
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
```go
|
||||
// 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
|
||||
type Config struct {
|
||||
SupportProxyProtocol bool `json:"support_proxy_protocol"`
|
||||
Rules struct {
|
||||
NotFound rules.Rules `json:"not_found"`
|
||||
} `json:"rules"`
|
||||
Middlewares []map[string]any `json:"middlewares"`
|
||||
AccessLog *accesslog.RequestLoggerConfig `json:"access_log" validate:"omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
### Request Handling
|
||||
### Context Functions
|
||||
|
||||
```go
|
||||
// ServeHTTP is the main HTTP handler.
|
||||
func (ep *Entrypoint) ServeHTTP(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// FindRoute looks up a route by hostname.
|
||||
func (ep *Entrypoint) FindRoute(s string) types.HTTPRoute
|
||||
func SetCtx(ctx interface{ SetValue(any, any) }, ep Entrypoint)
|
||||
func FromCtx(ctx context.Context) Entrypoint
|
||||
```
|
||||
|
||||
## Usage
|
||||
## Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class Entrypoint {
|
||||
+task *task.new_task
|
||||
+cfg *Config
|
||||
+middleware *middleware.Middleware
|
||||
+notFoundHandler http.Handler
|
||||
+accessLogger AccessLogger
|
||||
+findRouteFunc findRouteFunc
|
||||
+shortLinkMatcher *ShortLinkMatcher
|
||||
+streamRoutes *pool.Pool[types.StreamRoute]
|
||||
+excludedRoutes *pool.Pool[types.Route]
|
||||
+servers *xsync.Map[string, *httpServer]
|
||||
+SupportProxyProtocol() bool
|
||||
+StartAddRoute(r) error
|
||||
+IterRoutes(yield)
|
||||
+HTTPRoutes() PoolLike
|
||||
}
|
||||
|
||||
class httpServer {
|
||||
+routes *pool.Pool[types.HTTPRoute]
|
||||
+ServeHTTP(w, r)
|
||||
+AddRoute(route)
|
||||
+DelRoute(route)
|
||||
+FindRoute(s) types.HTTPRoute
|
||||
}
|
||||
|
||||
class PoolLike {
|
||||
<<interface>>
|
||||
+Get(alias) (Route, bool)
|
||||
+Iter(yield) bool
|
||||
+Size() int
|
||||
}
|
||||
|
||||
class RWPoolLike {
|
||||
<<interface>>
|
||||
+PoolLike
|
||||
+Add(r Route)
|
||||
+Del(r Route)
|
||||
}
|
||||
|
||||
class ShortLinkMatcher {
|
||||
+fqdnRoutes *xsync.Map[string, string]
|
||||
+subdomainRoutes *xsync.Map[string, struct{}]
|
||||
+ServeHTTP(w, r)
|
||||
+AddRoute(alias)
|
||||
+DelRoute(alias)
|
||||
+SetDefaultDomainSuffix(suffix)
|
||||
}
|
||||
|
||||
Entrypoint --> httpServer : manages
|
||||
Entrypoint --> ShortLinkMatcher : owns
|
||||
Entrypoint --> PoolLike : HTTPRoutes()
|
||||
Entrypoint --> RWPoolLike : ExcludedRoutes()
|
||||
httpServer --> PoolLike : routes pool
|
||||
```
|
||||
|
||||
### 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: StartAddRoute()
|
||||
Listening --> Listening: StartAddRoute()
|
||||
Listening --> Listening: delHTTPRoute()
|
||||
Listening --> [*]: Cancel()
|
||||
|
||||
Listening --> AddingServer: addHTTPRoute()
|
||||
AddingServer --> Listening: Server starts
|
||||
|
||||
note right of Listening
|
||||
servers map: addr -> httpServer
|
||||
For HTTPS, routes are added to ProxyHTTPSAddr
|
||||
Default routes added to both HTTP and HTTPS
|
||||
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 (go.example.com/alias)
|
||||
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 managed per-entrypoint:
|
||||
|
||||
```go
|
||||
// Adding a route (main entry point for providers)
|
||||
if err := ep.StartAddRoute(route); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Iterating all routes including excluded
|
||||
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()
|
||||
```
|
||||
|
||||
## Configuration Surface
|
||||
|
||||
### Config Source
|
||||
|
||||
Environment variables and YAML config file:
|
||||
|
||||
```yaml
|
||||
entrypoint:
|
||||
support_proxy_protocol: true
|
||||
middlewares:
|
||||
- rate_limit:
|
||||
requests_per_second: 100
|
||||
rules:
|
||||
not_found:
|
||||
# not-found rules configuration
|
||||
access_log:
|
||||
path: /var/log/godoxy/access.log
|
||||
```
|
||||
|
||||
### 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 |
|
||||
| `github.com/yusing/goutils/server` | HTTP/HTTPS server lifecycle |
|
||||
|
||||
## 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
|
||||
- Short link matching is limited to configured domains
|
||||
|
||||
## Failure Modes and Recovery
|
||||
|
||||
| Failure | Behavior | Recovery |
|
||||
| --------------------- | ------------------------------- | ---------------------------- |
|
||||
| Server bind fails | Error returned, route not added | Fix port/address conflict |
|
||||
| Route start fails | Route excluded, error logged | Fix route configuration |
|
||||
| Middleware load fails | SetMiddlewares returns error | Fix middleware configuration |
|
||||
| Context cancelled | All servers stopped gracefully | Restart entrypoint |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Setup
|
||||
|
||||
```go
|
||||
ep := entrypoint.NewEntrypoint()
|
||||
ep := entrypoint.NewEntrypoint(parent, &entrypoint.Config{
|
||||
SupportProxyProtocol: false,
|
||||
})
|
||||
|
||||
// Configure domain matching
|
||||
ep.SetFindRouteDomains([]string{".example.com", "example.com"})
|
||||
@@ -120,7 +367,7 @@ err := ep.SetMiddlewares([]map[string]any{
|
||||
{"rate_limit": map[string]any{"requests_per_second": 100}},
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Configure access logging
|
||||
@@ -128,181 +375,58 @@ err = ep.SetAccessLogger(parent, &accesslog.RequestLoggerConfig{
|
||||
Path: "/var/log/godoxy/access.log",
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Start server
|
||||
http.ListenAndServe(":80", &ep)
|
||||
```
|
||||
|
||||
### 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
|
||||
### Route Querying
|
||||
|
||||
```go
|
||||
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
|
||||
}
|
||||
}
|
||||
// Iterate all routes including excluded
|
||||
ep.IterRoutes(func(r types.Route) bool {
|
||||
log.Info().
|
||||
Str("alias", r.Name()).
|
||||
Str("provider", r.ProviderName()).
|
||||
Bool("excluded", r.ShouldExclude()).
|
||||
Msg("route")
|
||||
return true // continue iteration
|
||||
})
|
||||
|
||||
// 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
|
||||
// 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")
|
||||
}
|
||||
```
|
||||
|
||||
### Short Links
|
||||
### Route Addition
|
||||
|
||||
Short links use a special `.short` domain:
|
||||
Routes are typically added by providers via `StartAddRoute`:
|
||||
|
||||
```go
|
||||
// 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)
|
||||
// StartAddRoute handles route registration and server creation
|
||||
if err := ep.StartAddRoute(route); err != nil {
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
## Data Flow
|
||||
### Context Integration
|
||||
|
||||
```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
|
||||
Routes can access the entrypoint from request context:
|
||||
|
||||
```go
|
||||
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)
|
||||
// Set entrypoint in context (typically during initialization)
|
||||
entrypoint.SetCtx(task, ep)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
// Get entrypoint from context
|
||||
if ep := entrypoint.FromCtx(r.Context()); ep != nil {
|
||||
route, ok := ep.GetRoute("alias")
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration Structure
|
||||
## Testing Notes
|
||||
|
||||
```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
|
||||
- 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)
|
||||
|
||||
@@ -5,11 +5,13 @@ import (
|
||||
"github.com/yusing/godoxy/internal/route/rules"
|
||||
)
|
||||
|
||||
// Config defines the entrypoint configuration for proxy handling,
|
||||
// including proxy protocol support, routing rules, middlewares, and access logging.
|
||||
type Config struct {
|
||||
SupportProxyProtocol bool `json:"support_proxy_protocol"`
|
||||
Rules struct {
|
||||
NotFound rules.Rules `json:"not_found"`
|
||||
} `json:"rules"`
|
||||
Middlewares []map[string]any `json:"middlewares"`
|
||||
AccessLog *accesslog.RequestLoggerConfig `json:"access_log" validate:"omitempty"`
|
||||
AccessLog *accesslog.RequestLoggerConfig `json:"access_log"`
|
||||
}
|
||||
@@ -4,44 +4,112 @@ import (
|
||||
"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"
|
||||
"github.com/yusing/goutils/pool"
|
||||
"github.com/yusing/goutils/task"
|
||||
)
|
||||
|
||||
type HTTPRoutes interface {
|
||||
Get(alias string) (types.HTTPRoute, bool)
|
||||
}
|
||||
|
||||
type findRouteFunc func(HTTPRoutes, string) types.HTTPRoute
|
||||
|
||||
type Entrypoint struct {
|
||||
middleware *middleware.Middleware
|
||||
notFoundHandler http.Handler
|
||||
accessLogger accesslog.AccessLogger
|
||||
findRouteFunc func(host string) types.HTTPRoute
|
||||
shortLinkTree *ShortLinkMatcher
|
||||
task *task.Task
|
||||
|
||||
cfg *Config
|
||||
|
||||
middleware *middleware.Middleware
|
||||
notFoundHandler http.Handler
|
||||
accessLogger accesslog.AccessLogger
|
||||
findRouteFunc findRouteFunc
|
||||
shortLinkMatcher *ShortLinkMatcher
|
||||
|
||||
streamRoutes *pool.Pool[types.StreamRoute]
|
||||
excludedRoutes *pool.Pool[types.Route]
|
||||
|
||||
// this only affects future http servers creation
|
||||
httpPoolDisableLog atomic.Bool
|
||||
|
||||
servers *xsync.Map[string, *httpServer] // listen addr -> server
|
||||
}
|
||||
|
||||
// nil-safe
|
||||
var ActiveConfig atomic.Pointer[entrypoint.Config]
|
||||
var _ entrypoint.Entrypoint = &Entrypoint{}
|
||||
|
||||
func init() {
|
||||
// make sure it's not nil
|
||||
ActiveConfig.Store(&entrypoint.Config{})
|
||||
var emptyCfg Config
|
||||
|
||||
func NewTestEntrypoint(tb testing.TB, cfg *Config) *Entrypoint {
|
||||
tb.Helper()
|
||||
|
||||
testTask := task.GetTestTask(tb)
|
||||
ep := NewEntrypoint(testTask, cfg)
|
||||
entrypoint.SetCtx(testTask, ep)
|
||||
return ep
|
||||
}
|
||||
|
||||
func NewEntrypoint() Entrypoint {
|
||||
return Entrypoint{
|
||||
findRouteFunc: findRouteAnyDomain,
|
||||
shortLinkTree: newShortLinkTree(),
|
||||
func NewEntrypoint(parent task.Parent, cfg *Config) *Entrypoint {
|
||||
if cfg == nil {
|
||||
cfg = &emptyCfg
|
||||
}
|
||||
|
||||
ep := &Entrypoint{
|
||||
task: parent.Subtask("entrypoint", false),
|
||||
cfg: cfg,
|
||||
findRouteFunc: findRouteAnyDomain,
|
||||
shortLinkMatcher: newShortLinkMatcher(),
|
||||
streamRoutes: pool.New[types.StreamRoute]("stream_routes", "stream_routes"),
|
||||
excludedRoutes: pool.New[types.Route]("excluded_routes", "excluded_routes"),
|
||||
servers: xsync.NewMap[string, *httpServer](),
|
||||
}
|
||||
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.shortLinkTree
|
||||
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)
|
||||
}
|
||||
|
||||
func (ep *Entrypoint) SetFindRouteDomains(domains []string) {
|
||||
@@ -74,128 +142,59 @@ func (ep *Entrypoint) SetMiddlewares(mws []map[string]any) error {
|
||||
}
|
||||
|
||||
func (ep *Entrypoint) SetNotFoundRules(rules rules.Rules) {
|
||||
ep.notFoundHandler = rules.BuildHandler(http.HandlerFunc(ep.serveNotFound))
|
||||
ep.notFoundHandler = rules.BuildHandler(serveNotFound)
|
||||
}
|
||||
|
||||
func (ep *Entrypoint) SetAccessLogger(parent task.Parent, cfg *accesslog.RequestLoggerConfig) (err error) {
|
||||
func (ep *Entrypoint) SetAccessLogger(parent task.Parent, cfg *accesslog.RequestLoggerConfig) error {
|
||||
if cfg == nil {
|
||||
ep.accessLogger = nil
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
ep.accessLogger, err = accesslog.NewAccessLogger(parent, cfg)
|
||||
accessLogger, err := accesslog.NewAccessLogger(parent, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ep.accessLogger = accessLogger
|
||||
log.Debug().Msg("entrypoint access logger created")
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
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 {
|
||||
func findRouteAnyDomain(routes HTTPRoutes, host string) types.HTTPRoute {
|
||||
//nolint:modernize
|
||||
idx := strings.IndexByte(host, '.')
|
||||
if idx != -1 {
|
||||
target := host[:idx]
|
||||
if r, ok := routes.HTTP.Get(target); ok {
|
||||
if r, ok := routes.Get(target); ok {
|
||||
return r
|
||||
}
|
||||
}
|
||||
if r, ok := routes.HTTP.Get(host); ok {
|
||||
if r, ok := routes.Get(host); ok {
|
||||
return r
|
||||
}
|
||||
// try striping the trailing :port from the host
|
||||
if before, _, ok := strings.Cut(host, ":"); ok {
|
||||
if r, ok := routes.HTTP.Get(before); ok {
|
||||
if r, ok := routes.Get(before); ok {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func findRouteByDomains(domains []string) func(host string) types.HTTPRoute {
|
||||
return func(host string) types.HTTPRoute {
|
||||
func findRouteByDomains(domains []string) func(routes HTTPRoutes, host string) types.HTTPRoute {
|
||||
return func(routes HTTPRoutes, host string) types.HTTPRoute {
|
||||
host, _, _ = strings.Cut(host, ":") // strip the trailing :port
|
||||
for _, domain := range domains {
|
||||
if target, ok := strings.CutSuffix(host, domain); ok {
|
||||
if r, ok := routes.HTTP.Get(target); ok {
|
||||
if r, ok := routes.Get(target); ok {
|
||||
return r
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fallback to exact match
|
||||
if r, ok := routes.HTTP.Get(host); ok {
|
||||
if r, ok := routes.Get(host); ok {
|
||||
return r
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -10,12 +10,12 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yusing/godoxy/internal/common"
|
||||
. "github.com/yusing/godoxy/internal/entrypoint"
|
||||
"github.com/yusing/godoxy/internal/route"
|
||||
"github.com/yusing/godoxy/internal/route/routes"
|
||||
routeTypes "github.com/yusing/godoxy/internal/route/types"
|
||||
"github.com/yusing/godoxy/internal/types"
|
||||
"github.com/yusing/goutils/task"
|
||||
)
|
||||
|
||||
type noopResponseWriter struct {
|
||||
@@ -48,9 +48,9 @@ func (t noopTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
}
|
||||
|
||||
func BenchmarkEntrypointReal(b *testing.B) {
|
||||
var ep Entrypoint
|
||||
ep := NewTestEntrypoint(b, nil)
|
||||
req := http.Request{
|
||||
Method: "GET",
|
||||
Method: http.MethodGet,
|
||||
URL: &url.URL{Path: "/", RawPath: "/"},
|
||||
Host: "test.domain.tld",
|
||||
}
|
||||
@@ -77,48 +77,48 @@ func BenchmarkEntrypointReal(b *testing.B) {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
r := &route.Route{
|
||||
r, err := route.NewStartedTestRoute(b, &route.Route{
|
||||
Alias: "test",
|
||||
Scheme: routeTypes.SchemeHTTP,
|
||||
Host: host,
|
||||
Port: route.Port{Proxy: portInt},
|
||||
Port: route.Port{Listening: 1000, Proxy: portInt},
|
||||
HealthCheck: types.HealthCheckConfig{Disable: true},
|
||||
}
|
||||
})
|
||||
|
||||
err = r.Validate()
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
err = r.Start(task.RootTask("test", false))
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
require.NoError(b, err)
|
||||
require.False(b, r.ShouldExclude())
|
||||
|
||||
var w noopResponseWriter
|
||||
|
||||
server, ok := ep.GetServer(":1000")
|
||||
if !ok {
|
||||
b.Fatal("server not found")
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
ep.ServeHTTP(&w, &req)
|
||||
// if w.statusCode != http.StatusOK {
|
||||
// b.Fatalf("status code is not 200: %d", w.statusCode)
|
||||
// }
|
||||
// if string(w.written) != "1" {
|
||||
// b.Fatalf("written is not 1: %s", string(w.written))
|
||||
// }
|
||||
server.ServeHTTP(&w, &req)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkEntrypoint(b *testing.B) {
|
||||
var ep Entrypoint
|
||||
ep := NewTestEntrypoint(b, nil)
|
||||
req := http.Request{
|
||||
Method: "GET",
|
||||
Method: http.MethodGet,
|
||||
URL: &url.URL{Path: "/", RawPath: "/"},
|
||||
Host: "test.domain.tld",
|
||||
}
|
||||
ep.SetFindRouteDomains([]string{})
|
||||
|
||||
r := &route.Route{
|
||||
r, err := route.NewStartedTestRoute(b, &route.Route{
|
||||
Alias: "test",
|
||||
Scheme: routeTypes.SchemeHTTP,
|
||||
Host: "localhost",
|
||||
@@ -128,29 +128,23 @@ func BenchmarkEntrypoint(b *testing.B) {
|
||||
HealthCheck: types.HealthCheckConfig{
|
||||
Disable: true,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
err := r.Validate()
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
require.NoError(b, err)
|
||||
require.False(b, r.ShouldExclude())
|
||||
|
||||
err = r.Start(task.RootTask("test", false))
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
rev, ok := routes.HTTP.Get("test")
|
||||
if !ok {
|
||||
b.Fatal("route not found")
|
||||
}
|
||||
rev.(types.ReverseProxyRoute).ReverseProxy().Transport = noopTransport{}
|
||||
r.(types.ReverseProxyRoute).ReverseProxy().Transport = noopTransport{}
|
||||
|
||||
var w noopResponseWriter
|
||||
|
||||
server, ok := ep.GetServer(common.ProxyHTTPAddr)
|
||||
if !ok {
|
||||
b.Fatal("server not found")
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
ep.ServeHTTP(&w, &req)
|
||||
server.ServeHTTP(&w, &req)
|
||||
if w.statusCode != http.StatusOK {
|
||||
b.Fatalf("status code is not 200: %d", w.statusCode)
|
||||
}
|
||||
|
||||
@@ -3,48 +3,70 @@ 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"
|
||||
"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"
|
||||
expect "github.com/yusing/goutils/testing"
|
||||
)
|
||||
|
||||
var ep = NewEntrypoint()
|
||||
func addRoute(t *testing.T, alias string) {
|
||||
t.Helper()
|
||||
|
||||
func addRoute(alias string) {
|
||||
routes.HTTP.Add(&route.ReveseProxyRoute{
|
||||
Route: &route.Route{
|
||||
Alias: alias,
|
||||
Port: route.Port{
|
||||
Proxy: 80,
|
||||
},
|
||||
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,
|
||||
},
|
||||
})
|
||||
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, match []string, noMatch []string) {
|
||||
func run(t *testing.T, ep *Entrypoint, match []string, noMatch []string) {
|
||||
t.Helper()
|
||||
t.Cleanup(routes.Clear)
|
||||
t.Cleanup(func() { ep.SetFindRouteDomains(nil) })
|
||||
|
||||
server, ok := ep.GetServer(":1000")
|
||||
require.True(t, ok, "server not found")
|
||||
require.NotNil(t, server)
|
||||
|
||||
for _, test := range match {
|
||||
t.Run(test, func(t *testing.T) {
|
||||
found := ep.FindRoute(test)
|
||||
expect.NotNil(t, found)
|
||||
route := server.FindRoute(test)
|
||||
assert.NotNil(t, route)
|
||||
})
|
||||
}
|
||||
|
||||
for _, test := range noMatch {
|
||||
t.Run(test, func(t *testing.T) {
|
||||
found := ep.FindRoute(test)
|
||||
expect.Nil(t, found)
|
||||
found, ok := ep.HTTPRoutes().Get(test)
|
||||
assert.False(t, ok)
|
||||
assert.Nil(t, found)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindRouteAnyDomain(t *testing.T) {
|
||||
addRoute("app1")
|
||||
ep := NewTestEntrypoint(t, nil)
|
||||
|
||||
addRoute(t, "app1")
|
||||
|
||||
tests := []string{
|
||||
"app1.com",
|
||||
@@ -58,10 +80,12 @@ func TestFindRouteAnyDomain(t *testing.T) {
|
||||
"app2.sub.domain.com",
|
||||
}
|
||||
|
||||
run(t, tests, testsNoMatch)
|
||||
run(t, ep, tests, testsNoMatch)
|
||||
}
|
||||
|
||||
func TestFindRouteExactHostMatch(t *testing.T) {
|
||||
ep := NewTestEntrypoint(t, nil)
|
||||
|
||||
tests := []string{
|
||||
"app2.com",
|
||||
"app2.domain.com",
|
||||
@@ -75,19 +99,20 @@ func TestFindRouteExactHostMatch(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
addRoute(test)
|
||||
addRoute(t, test)
|
||||
}
|
||||
|
||||
run(t, tests, testsNoMatch)
|
||||
run(t, ep, tests, testsNoMatch)
|
||||
}
|
||||
|
||||
func TestFindRouteByDomains(t *testing.T) {
|
||||
ep := NewTestEntrypoint(t, nil)
|
||||
ep.SetFindRouteDomains([]string{
|
||||
".domain.com",
|
||||
".sub.domain.com",
|
||||
})
|
||||
|
||||
addRoute("app1")
|
||||
addRoute(t, "app1")
|
||||
|
||||
tests := []string{
|
||||
"app1.domain.com",
|
||||
@@ -103,16 +128,17 @@ func TestFindRouteByDomains(t *testing.T) {
|
||||
"app2.sub.domain.com",
|
||||
}
|
||||
|
||||
run(t, tests, testsNoMatch)
|
||||
run(t, ep, tests, testsNoMatch)
|
||||
}
|
||||
|
||||
func TestFindRouteByDomainsExactMatch(t *testing.T) {
|
||||
ep := NewTestEntrypoint(t, nil)
|
||||
ep.SetFindRouteDomains([]string{
|
||||
".domain.com",
|
||||
".sub.domain.com",
|
||||
})
|
||||
|
||||
addRoute("app1.foo.bar")
|
||||
addRoute(t, "app1.foo.bar")
|
||||
|
||||
tests := []string{
|
||||
"app1.foo.bar", // exact match
|
||||
@@ -126,13 +152,14 @@ func TestFindRouteByDomainsExactMatch(t *testing.T) {
|
||||
"app1.sub.domain.com",
|
||||
}
|
||||
|
||||
run(t, tests, testsNoMatch)
|
||||
run(t, ep, tests, testsNoMatch)
|
||||
}
|
||||
|
||||
func TestFindRouteWithPort(t *testing.T) {
|
||||
t.Run("AnyDomain", func(t *testing.T) {
|
||||
addRoute("app1")
|
||||
addRoute("app2.com")
|
||||
ep := NewTestEntrypoint(t, nil)
|
||||
addRoute(t, "app1")
|
||||
addRoute(t, "app2.com")
|
||||
|
||||
tests := []string{
|
||||
"app1:8080",
|
||||
@@ -144,16 +171,17 @@ func TestFindRouteWithPort(t *testing.T) {
|
||||
"app2.co",
|
||||
"app2.co:8080",
|
||||
}
|
||||
run(t, tests, testsNoMatch)
|
||||
run(t, ep, tests, testsNoMatch)
|
||||
})
|
||||
|
||||
t.Run("ByDomains", func(t *testing.T) {
|
||||
ep := NewTestEntrypoint(t, nil)
|
||||
ep.SetFindRouteDomains([]string{
|
||||
".domain.com",
|
||||
})
|
||||
addRoute("app1")
|
||||
addRoute("app2")
|
||||
addRoute("app3.domain.com")
|
||||
addRoute(t, "app1")
|
||||
addRoute(t, "app2")
|
||||
addRoute(t, "app3.domain.com")
|
||||
|
||||
tests := []string{
|
||||
"app1.domain.com:8080",
|
||||
@@ -169,6 +197,120 @@ func TestFindRouteWithPort(t *testing.T) {
|
||||
"app3.domain.co",
|
||||
"app3.domain.co:8080",
|
||||
}
|
||||
run(t, tests, testsNoMatch)
|
||||
run(t, ep, 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())
|
||||
}
|
||||
|
||||
51
internal/entrypoint/http_pool_adapter.go
Normal file
51
internal/entrypoint/http_pool_adapter.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package entrypoint
|
||||
|
||||
import (
|
||||
"github.com/yusing/godoxy/internal/common"
|
||||
"github.com/yusing/godoxy/internal/types"
|
||||
)
|
||||
|
||||
// httpPoolAdapter implements the PoolLike interface for the HTTP routes.
|
||||
type httpPoolAdapter struct {
|
||||
ep *Entrypoint
|
||||
}
|
||||
|
||||
func newHTTPPoolAdapter(ep *Entrypoint) httpPoolAdapter {
|
||||
return httpPoolAdapter{ep: ep}
|
||||
}
|
||||
|
||||
func (h httpPoolAdapter) Iter(yield func(alias string, route types.HTTPRoute) bool) {
|
||||
for addr, srv := range h.ep.servers.Range {
|
||||
// default routes are added to both HTTP and HTTPS servers, we don't need to iterate over them twice.
|
||||
if addr == common.ProxyHTTPSAddr {
|
||||
continue
|
||||
}
|
||||
for alias, route := range srv.routes.Iter {
|
||||
if !yield(alias, route) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h httpPoolAdapter) Get(alias string) (types.HTTPRoute, bool) {
|
||||
for addr, srv := range h.ep.servers.Range {
|
||||
if addr == common.ProxyHTTPSAddr {
|
||||
continue
|
||||
}
|
||||
if route, ok := srv.routes.Get(alias); ok {
|
||||
return route, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (h httpPoolAdapter) Size() (n int) {
|
||||
for addr, srv := range h.ep.servers.Range {
|
||||
if addr == common.ProxyHTTPSAddr {
|
||||
continue
|
||||
}
|
||||
n += srv.routes.Size()
|
||||
}
|
||||
return
|
||||
}
|
||||
175
internal/entrypoint/http_server.go
Normal file
175
internal/entrypoint/http_server.go
Normal file
@@ -0,0 +1,175 @@
|
||||
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 {
|
||||
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.addr != "" {
|
||||
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)
|
||||
_, err := server.StartServer(task, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
srv.stopFunc = task.FinishAndWait
|
||||
srv.addr = addr
|
||||
srv.routes = pool.New[types.HTTPRoute](fmt.Sprintf("[%s] %s", proto, addr), "http_routes")
|
||||
srv.routes.DisableLog(srv.ep.httpPoolDisableLog.Load())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (srv *httpServer) Close() {
|
||||
if srv.stopFunc == nil {
|
||||
return
|
||||
}
|
||||
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() {
|
||||
// there is no body to close
|
||||
//nolint:bodyclose
|
||||
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.Warn().
|
||||
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.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
if _, err := w.Write(errorPage); err != nil {
|
||||
log.Err(err).Msg("failed to write error page")
|
||||
}
|
||||
} else {
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
92
internal/entrypoint/query.go
Normal file
92
internal/entrypoint/query.go
Normal file
@@ -0,0 +1,92 @@
|
||||
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 {
|
||||
providerName := r.ProviderName()
|
||||
rts[providerName] = append(rts[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()
|
||||
}
|
||||
146
internal/entrypoint/routes.go
Normal file
146
internal/entrypoint/routes.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package entrypoint
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
|
||||
"github.com/yusing/godoxy/internal/common"
|
||||
"github.com/yusing/godoxy/internal/types"
|
||||
)
|
||||
|
||||
func (ep *Entrypoint) IterRoutes(yield func(r types.Route) bool) {
|
||||
for _, r := range ep.HTTPRoutes().Iter {
|
||||
if !yield(r) {
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, r := range ep.streamRoutes.Iter {
|
||||
if !yield(r) {
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, r := range ep.excludedRoutes.Iter {
|
||||
if !yield(r) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) StartAddRoute(r types.Route) error {
|
||||
if r.ShouldExclude() {
|
||||
ep.excludedRoutes.Add(r)
|
||||
r.Task().OnCancel("remove_route", func() {
|
||||
ep.excludedRoutes.Del(r)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
switch r := r.(type) {
|
||||
case types.HTTPRoute:
|
||||
if err := ep.AddHTTPRoute(r); err != nil {
|
||||
return err
|
||||
}
|
||||
ep.shortLinkMatcher.AddRoute(r.Key())
|
||||
r.Task().OnCancel("remove_route", func() {
|
||||
ep.delHTTPRoute(r)
|
||||
ep.shortLinkMatcher.DelRoute(r.Key())
|
||||
})
|
||||
case types.StreamRoute:
|
||||
err := r.ListenAndServe(r.Task().Context(), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ep.streamRoutes.Add(r)
|
||||
|
||||
r.Task().OnCancel("remove_route", func() {
|
||||
r.Stream().Close()
|
||||
ep.streamRoutes.Del(r)
|
||||
})
|
||||
default:
|
||||
return fmt.Errorf("unknown route type: %T", r)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getAddr(route types.HTTPRoute) (httpAddr, httpsAddr string) {
|
||||
if port := route.ListenURL().Port(); port == "" || port == "0" {
|
||||
host := route.ListenURL().Hostname()
|
||||
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 httpAddr, httpsAddr
|
||||
}
|
||||
|
||||
httpsAddr = route.ListenURL().Host
|
||||
return
|
||||
}
|
||||
|
||||
// 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 {
|
||||
httpAddr, httpsAddr := getAddr(route)
|
||||
var httpErr, httpsErr error
|
||||
if httpAddr != "" {
|
||||
httpErr = ep.addHTTPRoute(route, httpAddr, HTTPProtoHTTP)
|
||||
}
|
||||
if httpsAddr != "" {
|
||||
httpsErr = ep.addHTTPRoute(route, httpsAddr, HTTPProtoHTTPS)
|
||||
}
|
||||
return errors.Join(httpErr, httpsErr)
|
||||
}
|
||||
|
||||
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) {
|
||||
httpAddr, httpsAddr := getAddr(route)
|
||||
if httpAddr != "" {
|
||||
srv, _ := ep.servers.Load(httpAddr)
|
||||
if srv != nil {
|
||||
srv.DelRoute(route)
|
||||
}
|
||||
}
|
||||
if httpsAddr != "" {
|
||||
srv, _ := ep.servers.Load(httpsAddr)
|
||||
if srv != nil {
|
||||
srv.DelRoute(route)
|
||||
}
|
||||
}
|
||||
// TODO: close server if no routes are left
|
||||
}
|
||||
@@ -14,7 +14,7 @@ type ShortLinkMatcher struct {
|
||||
subdomainRoutes *xsync.Map[string, struct{}]
|
||||
}
|
||||
|
||||
func newShortLinkTree() *ShortLinkMatcher {
|
||||
func newShortLinkMatcher() *ShortLinkMatcher {
|
||||
return &ShortLinkMatcher{
|
||||
fqdnRoutes: xsync.NewMap[string, string](),
|
||||
subdomainRoutes: xsync.NewMap[string, struct{}](),
|
||||
|
||||
@@ -6,18 +6,20 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/yusing/godoxy/internal/common"
|
||||
. "github.com/yusing/godoxy/internal/entrypoint"
|
||||
"github.com/yusing/goutils/task"
|
||||
)
|
||||
|
||||
func TestShortLinkMatcher_FQDNAlias(t *testing.T) {
|
||||
ep := NewEntrypoint()
|
||||
ep := NewEntrypoint(task.GetTestTask(t), nil)
|
||||
matcher := ep.ShortLinkMatcher()
|
||||
matcher.AddRoute("app.domain.com")
|
||||
|
||||
t.Run("exact path", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/app", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/app", nil)
|
||||
w := httptest.NewRecorder()
|
||||
matcher.ServeHTTP(w, req)
|
||||
|
||||
@@ -26,7 +28,7 @@ func TestShortLinkMatcher_FQDNAlias(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("with path remainder", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/app/foo/bar", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/foo/bar", nil)
|
||||
w := httptest.NewRecorder()
|
||||
matcher.ServeHTTP(w, req)
|
||||
|
||||
@@ -35,7 +37,7 @@ func TestShortLinkMatcher_FQDNAlias(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("with query", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/app/foo?x=y&z=1", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/foo?x=y&z=1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
matcher.ServeHTTP(w, req)
|
||||
|
||||
@@ -45,13 +47,13 @@ func TestShortLinkMatcher_FQDNAlias(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestShortLinkMatcher_SubdomainAlias(t *testing.T) {
|
||||
ep := NewEntrypoint()
|
||||
ep := NewEntrypoint(task.GetTestTask(t), nil)
|
||||
matcher := ep.ShortLinkMatcher()
|
||||
matcher.SetDefaultDomainSuffix(".example.com")
|
||||
matcher.AddRoute("app")
|
||||
|
||||
t.Run("exact path", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/app", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/app", nil)
|
||||
w := httptest.NewRecorder()
|
||||
matcher.ServeHTTP(w, req)
|
||||
|
||||
@@ -60,7 +62,7 @@ func TestShortLinkMatcher_SubdomainAlias(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("with path remainder", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/app/foo/bar", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/foo/bar", nil)
|
||||
w := httptest.NewRecorder()
|
||||
matcher.ServeHTTP(w, req)
|
||||
|
||||
@@ -70,13 +72,13 @@ func TestShortLinkMatcher_SubdomainAlias(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestShortLinkMatcher_NotFound(t *testing.T) {
|
||||
ep := NewEntrypoint()
|
||||
ep := NewEntrypoint(task.GetTestTask(t), nil)
|
||||
matcher := ep.ShortLinkMatcher()
|
||||
matcher.SetDefaultDomainSuffix(".example.com")
|
||||
matcher.AddRoute("app")
|
||||
|
||||
t.Run("missing key", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
matcher.ServeHTTP(w, req)
|
||||
|
||||
@@ -84,7 +86,7 @@ func TestShortLinkMatcher_NotFound(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("unknown key", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/unknown", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/unknown", nil)
|
||||
w := httptest.NewRecorder()
|
||||
matcher.ServeHTTP(w, req)
|
||||
|
||||
@@ -93,7 +95,7 @@ func TestShortLinkMatcher_NotFound(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestShortLinkMatcher_AddDelRoute(t *testing.T) {
|
||||
ep := NewEntrypoint()
|
||||
ep := NewEntrypoint(task.GetTestTask(t), nil)
|
||||
matcher := ep.ShortLinkMatcher()
|
||||
matcher.SetDefaultDomainSuffix(".example.com")
|
||||
|
||||
@@ -101,13 +103,13 @@ func TestShortLinkMatcher_AddDelRoute(t *testing.T) {
|
||||
matcher.AddRoute("app2.domain.com")
|
||||
|
||||
t.Run("both routes work", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/app1", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/app1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
matcher.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusTemporaryRedirect, w.Code)
|
||||
assert.Equal(t, "https://app1.example.com/", w.Header().Get("Location"))
|
||||
|
||||
req = httptest.NewRequest("GET", "/app2.domain.com", nil)
|
||||
req = httptest.NewRequest(http.MethodGet, "/app2.domain.com", nil)
|
||||
w = httptest.NewRecorder()
|
||||
matcher.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusTemporaryRedirect, w.Code)
|
||||
@@ -117,12 +119,12 @@ func TestShortLinkMatcher_AddDelRoute(t *testing.T) {
|
||||
t.Run("delete route", func(t *testing.T) {
|
||||
matcher.DelRoute("app1")
|
||||
|
||||
req := httptest.NewRequest("GET", "/app1", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/app1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
matcher.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
|
||||
req = httptest.NewRequest("GET", "/app2.domain.com", nil)
|
||||
req = httptest.NewRequest(http.MethodGet, "/app2.domain.com", nil)
|
||||
w = httptest.NewRecorder()
|
||||
matcher.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusTemporaryRedirect, w.Code)
|
||||
@@ -131,14 +133,14 @@ func TestShortLinkMatcher_AddDelRoute(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestShortLinkMatcher_NoDefaultDomainSuffix(t *testing.T) {
|
||||
ep := NewEntrypoint()
|
||||
ep := NewEntrypoint(task.GetTestTask(t), nil)
|
||||
matcher := ep.ShortLinkMatcher()
|
||||
// no SetDefaultDomainSuffix called
|
||||
|
||||
t.Run("subdomain alias ignored", func(t *testing.T) {
|
||||
matcher.AddRoute("app")
|
||||
|
||||
req := httptest.NewRequest("GET", "/app", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/app", nil)
|
||||
w := httptest.NewRecorder()
|
||||
matcher.ServeHTTP(w, req)
|
||||
|
||||
@@ -148,7 +150,7 @@ func TestShortLinkMatcher_NoDefaultDomainSuffix(t *testing.T) {
|
||||
t.Run("FQDN alias still works", func(t *testing.T) {
|
||||
matcher.AddRoute("app.domain.com")
|
||||
|
||||
req := httptest.NewRequest("GET", "/app.domain.com", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/app.domain.com", nil)
|
||||
w := httptest.NewRecorder()
|
||||
matcher.ServeHTTP(w, req)
|
||||
|
||||
@@ -158,35 +160,39 @@ func TestShortLinkMatcher_NoDefaultDomainSuffix(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEntrypoint_ShortLinkDispatch(t *testing.T) {
|
||||
ep := NewEntrypoint()
|
||||
ep := NewEntrypoint(task.GetTestTask(t), nil)
|
||||
ep.ShortLinkMatcher().SetDefaultDomainSuffix(".example.com")
|
||||
ep.ShortLinkMatcher().AddRoute("app")
|
||||
|
||||
server := NewHTTPServer(ep)
|
||||
err := server.Listen("localhost:0", HTTPProtoHTTP)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("shortlink host", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/app", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/app", nil)
|
||||
req.Host = common.ShortLinkPrefix
|
||||
w := httptest.NewRecorder()
|
||||
ep.ServeHTTP(w, req)
|
||||
server.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusTemporaryRedirect, w.Code)
|
||||
assert.Equal(t, "https://app.example.com/", w.Header().Get("Location"))
|
||||
})
|
||||
|
||||
t.Run("shortlink host with port", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/app", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/app", nil)
|
||||
req.Host = common.ShortLinkPrefix + ":8080"
|
||||
w := httptest.NewRecorder()
|
||||
ep.ServeHTTP(w, req)
|
||||
server.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusTemporaryRedirect, w.Code)
|
||||
assert.Equal(t, "https://app.example.com/", w.Header().Get("Location"))
|
||||
})
|
||||
|
||||
t.Run("normal host", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/app", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/app", nil)
|
||||
req.Host = "app.example.com"
|
||||
w := httptest.NewRecorder()
|
||||
ep.ServeHTTP(w, req)
|
||||
server.ServeHTTP(w, req)
|
||||
|
||||
// Should not redirect, should try normal route lookup (which will 404)
|
||||
assert.NotEqual(t, http.StatusTemporaryRedirect, w.Code)
|
||||
|
||||
18
internal/entrypoint/types/context.go
Normal file
18
internal/entrypoint/types/context.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package entrypoint
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
type ContextKey struct{}
|
||||
|
||||
func SetCtx(ctx interface{ SetValue(any, any) }, ep Entrypoint) {
|
||||
ctx.SetValue(ContextKey{}, ep)
|
||||
}
|
||||
|
||||
func FromCtx(ctx context.Context) Entrypoint {
|
||||
if ep, ok := ctx.Value(ContextKey{}).(Entrypoint); ok {
|
||||
return ep
|
||||
}
|
||||
return nil
|
||||
}
|
||||
58
internal/entrypoint/types/entrypoint.go
Normal file
58
internal/entrypoint/types/entrypoint.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package entrypoint
|
||||
|
||||
import (
|
||||
"github.com/yusing/godoxy/internal/types"
|
||||
)
|
||||
|
||||
// Entrypoint is the main HTTP entry point for the proxy: it performs domain-based
|
||||
// route lookup, applies middleware, manages HTTP/HTTPS server lifecycle, and
|
||||
// exposes route pools and health info. Route providers register routes via
|
||||
// StartAddRoute; request handling uses the route pools to resolve targets.
|
||||
type Entrypoint interface {
|
||||
// SupportProxyProtocol reports whether the entrypoint is configured to accept
|
||||
// PROXY protocol (v1/v2) on incoming connections. When true, servers expect
|
||||
// the PROXY header before reading HTTP.
|
||||
SupportProxyProtocol() bool
|
||||
|
||||
// DisablePoolsLog sets whether add/del logging for route pools is disabled.
|
||||
// When v is true, logging for HTTP, stream, and excluded route pools is
|
||||
// turned off; when false, it is turned on. Affects all existing and future
|
||||
// pool operations until called again.
|
||||
DisablePoolsLog(v bool)
|
||||
|
||||
GetRoute(alias string) (types.Route, bool)
|
||||
// StartAddRoute registers the route with the entrypoint. It is synchronous:
|
||||
// it does not return until the route is registered or an error occurs. For
|
||||
// HTTP routes, a server for the route's listen address is created and
|
||||
// started if needed. For stream routes, ListenAndServe is invoked and the
|
||||
// route is added to the pool only on success. Excluded routes are added to
|
||||
// the excluded pool only. Returns an error on listen/bind failure, stream
|
||||
// listen failure, or unsupported route type.
|
||||
StartAddRoute(r types.Route) error
|
||||
IterRoutes(yield func(r types.Route) bool)
|
||||
NumRoutes() int
|
||||
RoutesByProvider() map[string][]types.Route
|
||||
|
||||
// HTTPRoutes returns a read-only view of all HTTP routes (across listen addrs).
|
||||
HTTPRoutes() PoolLike[types.HTTPRoute]
|
||||
// StreamRoutes returns a read-only view of all stream (e.g. TCP/UDP) routes.
|
||||
StreamRoutes() PoolLike[types.StreamRoute]
|
||||
// ExcludedRoutes returns the read-write pool of excluded routes (e.g. disabled).
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user