Feat/fileserver (#60)

* cleanup code for URL type

* fix makefile for trace mode

* refactor, merge Entry, RawEntry and Route into one. 

* Implement fileserver.

* refactor: rename HTTPRoute to ReverseProxyRoute to avoid confusion

* refactor: move metrics logger to middleware package

- fix prometheus metrics for load balanced routes
  - route will now fail when health monitor fail to start

* fix extra output of ls-* commands by defer initializaing stuff, speed up start time

* add test for path traversal attack, small fix on FileServer.Start method

* rename rule.on.bypass to pass

* refactor and fixed map-to-map  deserialization

* updated route loading logic

* schemas: add "add_prefix" option to modify_request middleware


* updated route JSONMarshalling

---------

Co-authored-by: yusing <yusing@6uo.me>
This commit is contained in:
Yuzerion
2025-02-06 18:23:10 +08:00
committed by GitHub
parent 4d47eb0e91
commit 1a5f3735cf
79 changed files with 1484 additions and 1276 deletions

413
internal/route/route.go Executable file → Normal file
View File

@@ -1,104 +1,367 @@
package route
import (
"fmt"
"strconv"
"strings"
"github.com/yusing/go-proxy/internal/docker"
E "github.com/yusing/go-proxy/internal/error"
url "github.com/yusing/go-proxy/internal/net/types"
"github.com/yusing/go-proxy/internal/route/entry"
"github.com/yusing/go-proxy/internal/route/types"
idlewatcher "github.com/yusing/go-proxy/internal/docker/idlewatcher/types"
"github.com/yusing/go-proxy/internal/homepage"
"github.com/yusing/go-proxy/internal/logging"
net "github.com/yusing/go-proxy/internal/net/types"
"github.com/yusing/go-proxy/internal/task"
U "github.com/yusing/go-proxy/internal/utils"
F "github.com/yusing/go-proxy/internal/utils/functional"
"github.com/yusing/go-proxy/internal/watcher/health"
dockertypes "github.com/docker/docker/api/types"
"github.com/yusing/go-proxy/internal/common"
E "github.com/yusing/go-proxy/internal/error"
"github.com/yusing/go-proxy/internal/net/http/accesslog"
loadbalance "github.com/yusing/go-proxy/internal/net/http/loadbalancer/types"
"github.com/yusing/go-proxy/internal/route/rules"
"github.com/yusing/go-proxy/internal/route/types"
"github.com/yusing/go-proxy/internal/utils"
)
type (
Route struct {
_ U.NoCopy
impl
Type types.RouteType
Entry *RawEntry
}
Routes = F.Map[string, *Route]
_ utils.NoCopy
impl interface {
types.Route
task.TaskStarter
task.TaskFinisher
String() string
TargetURL() url.URL
Alias string `json:"alias"`
Scheme types.Scheme `json:"scheme,omitempty"`
Host string `json:"host,omitempty"`
Port types.Port `json:"port,omitempty"`
Root string `json:"root,omitempty"`
types.HTTPConfig
PathPatterns []string `json:"path_patterns,omitempty"`
Rules rules.Rules `json:"rules,omitempty" validate:"omitempty,unique=Name"`
HealthCheck *health.HealthCheckConfig `json:"healthcheck,omitempty"`
LoadBalance *loadbalance.Config `json:"load_balance,omitempty"`
Middlewares map[string]docker.LabelMap `json:"middlewares,omitempty"`
Homepage *homepage.Item `json:"homepage,omitempty"`
AccessLog *accesslog.Config `json:"access_log,omitempty"`
Metadata `deserialize:"-"`
}
RawEntry = types.RawEntry
RawEntries = types.RawEntries
Metadata struct {
/* Docker only */
Container *docker.Container `json:"container,omitempty"`
Provider string `json:"provider,omitempty"`
// private fields
LisURL *net.URL `json:"lurl,omitempty"`
ProxyURL *net.URL `json:"purl,omitempty"`
Idlewatcher *idlewatcher.Config `json:"idlewatcher,omitempty"`
impl types.Route
isValidated bool
}
Routes map[string]*Route
)
// function alias.
var (
NewRoutes = F.NewMap[Routes]
NewProxyEntries = types.NewProxyEntries
)
func (rt *Route) Container() *docker.Container {
if rt.Entry.Container == nil {
return docker.DummyContainer
}
return rt.Entry.Container
func (r Routes) Contains(alias string) bool {
_, ok := r[alias]
return ok
}
func NewRoute(raw *RawEntry) (*Route, E.Error) {
raw.Finalize()
en, err := entry.ValidateEntry(raw)
if err != nil {
return nil, err
func (r *Route) Validate() (err E.Error) {
if r.isValidated {
return nil
}
r.isValidated = true
r.Finalize()
var t types.RouteType
var rt impl
errs := E.NewBuilder("entry validation failed")
switch e := en.(type) {
case *entry.StreamEntry:
t = types.RouteTypeStream
rt, err = NewStreamRoute(e)
case *entry.ReverseProxyEntry:
t = types.RouteTypeReverseProxy
rt, err = NewHTTPRoute(e)
switch r.Scheme {
case types.SchemeFileServer:
r.impl, err = NewFileServer(r)
case types.SchemeHTTP, types.SchemeHTTPS:
if r.Port.Listening != 0 {
errs.Addf("unexpected listening port for %s scheme", r.Scheme)
}
fallthrough
case types.SchemeTCP, types.SchemeUDP:
r.LisURL = E.Collect(errs, net.ParseURL, fmt.Sprintf("%s://%s:%d", r.Scheme, r.Host, r.Port.Listening))
fallthrough
default:
panic("bug: should not reach here")
if r.Port.Proxy == 0 && !r.IsDocker() {
errs.Adds("missing proxy port")
}
if r.LoadBalance != nil && r.LoadBalance.Link == "" {
r.LoadBalance = nil
}
r.ProxyURL = E.Collect(errs, net.ParseURL, fmt.Sprintf("%s://%s:%d", r.Scheme, r.Host, r.Port.Proxy))
r.Idlewatcher = E.Collect(errs, idlewatcher.ValidateConfig, r.Container)
}
if err != nil {
return nil, err
if !r.UseHealthCheck() && (r.UseLoadBalance() || r.UseIdleWatcher()) {
errs.Adds("healthCheck.disable cannot be true when loadbalancer or idlewatcher is enabled")
}
return &Route{
impl: rt,
Type: t,
Entry: raw,
}, nil
if errs.HasError() {
return errs.Error()
}
switch r.Scheme {
case types.SchemeFileServer:
r.impl, err = NewFileServer(r)
case types.SchemeHTTP, types.SchemeHTTPS:
r.impl, err = NewReverseProxyRoute(r)
case types.SchemeTCP, types.SchemeUDP:
r.impl, err = NewStreamRoute(r)
default:
panic(fmt.Errorf("unexpected scheme %s for alias %s", r.Scheme, r.Alias))
}
return err
}
func FromEntries(provider string, entries RawEntries) (Routes, E.Error) {
b := E.NewBuilder("errors in routes")
func (r *Route) Start(parent task.Parent) (err E.Error) {
if r.impl == nil {
return E.New("route not initialized")
}
return r.impl.Start(parent)
}
routes := NewRoutes()
entries.RangeAllParallel(func(alias string, en *RawEntry) {
if en == nil {
en = new(RawEntry)
}
en.Alias = alias
en.Provider = provider
if strings.HasPrefix(alias, "x-") { // x properties
return
}
r, err := NewRoute(en)
func (r *Route) Finish(reason any) {
if r.impl == nil {
return
}
r.impl.Finish(reason)
r.impl = nil
}
func (r *Route) Started() bool {
return r.impl != nil
}
func (r *Route) ProviderName() string {
return r.Provider
}
func (r *Route) TargetName() string {
return r.Alias
}
func (r *Route) TargetURL() *net.URL {
return r.ProxyURL
}
func (r *Route) Type() types.RouteType {
switch r.Scheme {
case types.SchemeHTTP, types.SchemeHTTPS, types.SchemeFileServer:
return types.RouteTypeHTTP
case types.SchemeTCP, types.SchemeUDP:
return types.RouteTypeStream
}
panic(fmt.Errorf("unexpected scheme %s for alias %s", r.Scheme, r.Alias))
}
func (r *Route) HealthMonitor() health.HealthMonitor {
return r.impl.HealthMonitor()
}
func (r *Route) IdlewatcherConfig() *idlewatcher.Config {
return r.Idlewatcher
}
func (r *Route) HealthCheckConfig() *health.HealthCheckConfig {
return r.HealthCheck
}
func (r *Route) LoadBalanceConfig() *loadbalance.Config {
return r.LoadBalance
}
func (r *Route) HomepageConfig() *homepage.Item {
return r.Homepage
}
func (r *Route) ContainerInfo() *docker.Container {
return r.Container
}
func (r *Route) IsDocker() bool {
if r.Container == nil {
return false
}
return r.Container.ContainerID != ""
}
func (r *Route) IsZeroPort() bool {
return r.Port.Proxy == 0
}
func (r *Route) ShouldExclude() bool {
if r.Container != nil {
switch {
case err != nil:
b.Add(err.Subject(alias))
case entry.ShouldNotServe(r):
return
default:
routes.Store(alias, r)
case r.Container.IsExcluded:
logging.Debug().Str("container", r.Container.ContainerName).Msg("container excluded: explicitly excluded")
return true
case r.IsZeroPort() && !r.UseIdleWatcher():
logging.Debug().Str("container", r.Container.ContainerName).Msg("container excluded: zero port and no idle watcher")
return true
case r.Container.IsDatabase && !r.Container.IsExplicit:
logging.Debug().Str("container", r.Container.ContainerName).Msg("container excluded: database")
return true
case strings.HasPrefix(r.Container.ContainerName, "buildx_"):
logging.Debug().Str("container", r.Container.ContainerName).Msg("container excluded: buildx prefix")
return true
}
})
return routes, b.Error()
} else if r.IsZeroPort() {
logging.Debug().Str("container", r.Container.ContainerName).Msg("container excluded: zero port")
return true
}
if strings.HasPrefix(r.Alias, "x-") ||
strings.HasSuffix(r.Alias, "-old") {
logging.Debug().Str("container", r.Container.ContainerName).Msg("container excluded: alias")
return true
}
return false
}
func (r *Route) UseLoadBalance() bool {
return r.LoadBalance != nil && r.LoadBalance.Link != ""
}
func (r *Route) UseIdleWatcher() bool {
return r.Idlewatcher != nil && r.Idlewatcher.IdleTimeout > 0
}
func (r *Route) UseHealthCheck() bool {
return !r.HealthCheck.Disable
}
func (r *Route) UseAccessLog() bool {
return r.AccessLog != nil
}
func (r *Route) Finalize() {
isDocker := r.Container != nil
cont := r.Container
if r.Host == "" {
switch {
case !isDocker:
r.Host = "localhost"
case cont.PrivateIP != "":
r.Host = cont.PrivateIP
case cont.PublicIP != "":
r.Host = cont.PublicIP
}
}
lp, pp := r.Port.Listening, r.Port.Proxy
if isDocker {
if port, ok := common.ServiceNamePortMapTCP[cont.ImageName]; ok {
if pp == 0 {
pp = port
}
if r.Scheme == "" {
r.Scheme = "tcp"
}
} else if port, ok := common.ImageNamePortMap[cont.ImageName]; ok {
if pp == 0 {
pp = port
}
if r.Scheme == "" {
r.Scheme = "http"
}
}
}
if pp == 0 {
switch {
case r.Scheme == "https":
pp = 443
case !isDocker:
pp = 80
default:
pp = lowestPort(cont.PrivatePortMapping)
if pp == 0 {
pp = lowestPort(cont.PublicPortMapping)
}
}
}
if isDocker {
// replace private port with public port if using public IP.
if r.Host == cont.PublicIP {
if p, ok := cont.PrivatePortMapping[pp]; ok {
pp = int(p.PublicPort)
}
}
// replace public port with private port if using private IP.
if r.Host == cont.PrivateIP {
if p, ok := cont.PublicPortMapping[pp]; ok {
pp = int(p.PrivatePort)
}
}
if r.Scheme == "" {
switch {
case r.Host == cont.PublicIP && cont.PublicPortMapping[pp].Type == "udp":
r.Scheme = "udp"
case r.Host == cont.PrivateIP && cont.PrivatePortMapping[pp].Type == "udp":
r.Scheme = "udp"
}
}
}
if r.Scheme == "" {
switch {
case lp != 0:
r.Scheme = "tcp"
case strings.HasSuffix(strconv.Itoa(pp), "443"):
r.Scheme = "https"
default: // assume its http
r.Scheme = "http"
}
}
r.Port.Listening, r.Port.Proxy = lp, pp
if r.HealthCheck == nil {
r.HealthCheck = health.DefaultHealthConfig
}
if !r.HealthCheck.Disable {
if r.HealthCheck.Interval == 0 {
r.HealthCheck.Interval = common.HealthCheckIntervalDefault
}
if r.HealthCheck.Timeout == 0 {
r.HealthCheck.Timeout = common.HealthCheckTimeoutDefault
}
}
if isDocker && cont.IdleTimeout != "" {
if cont.WakeTimeout == "" {
cont.WakeTimeout = common.WakeTimeoutDefault
}
if cont.StopTimeout == "" {
cont.StopTimeout = common.StopTimeoutDefault
}
if cont.StopMethod == "" {
cont.StopMethod = common.StopMethodDefault
}
}
if r.Homepage.IsEmpty() {
r.Homepage = homepage.NewItem(r.Alias)
}
}
func lowestPort(ports map[int]dockertypes.Port) (res int) {
cmp := (uint16)(65535)
for port, v := range ports {
if v.PrivatePort < cmp {
cmp = v.PrivatePort
res = port
}
}
return
}