mirror of
https://github.com/yusing/godoxy.git
synced 2026-03-20 16:44:27 +01:00
Add `relay_proxy_protocol_header` configuration option for TCP routes that enables forwarding the original client IP address to upstream services via PROXY protocol v2 headers. This feature is only available for TCP routes and includes validation to prevent misuse on UDP routes. - Add RelayProxyProtocolHeader field to Route struct with JSON tag - Implement writeProxyProtocolHeader in stream package to craft v2 headers - Update TCPTCPStream to conditionally send PROXY header to upstream - Add validation ensuring feature is TCP-only - Include tests for both enabled/disabled states and incoming proxy header relay
1093 lines
28 KiB
Go
1093 lines
28 KiB
Go
package route
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/url"
|
|
"os"
|
|
"reflect"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog"
|
|
"github.com/rs/zerolog/log"
|
|
"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"
|
|
homepagecfg "github.com/yusing/godoxy/internal/homepage/types"
|
|
netutils "github.com/yusing/godoxy/internal/net"
|
|
nettypes "github.com/yusing/godoxy/internal/net/types"
|
|
"github.com/yusing/godoxy/internal/proxmox"
|
|
"github.com/yusing/godoxy/internal/serialization"
|
|
"github.com/yusing/godoxy/internal/types"
|
|
gperr "github.com/yusing/goutils/errs"
|
|
strutils "github.com/yusing/goutils/strings"
|
|
"github.com/yusing/goutils/task"
|
|
|
|
"github.com/yusing/godoxy/internal/common"
|
|
"github.com/yusing/godoxy/internal/logging/accesslog"
|
|
"github.com/yusing/godoxy/internal/route/rules"
|
|
rulepresets "github.com/yusing/godoxy/internal/route/rules/presets"
|
|
route "github.com/yusing/godoxy/internal/route/types"
|
|
)
|
|
|
|
type (
|
|
Route struct {
|
|
Alias string `json:"alias"`
|
|
Scheme route.Scheme `json:"scheme,omitempty" swaggertype:"string" enums:"http,https,h2c,tcp,udp,fileserver"`
|
|
Host string `json:"host,omitempty"`
|
|
Port route.Port `json:"port"`
|
|
|
|
Bind string `json:"bind,omitempty" validate:"omitempty,ip_addr" extensions:"x-nullable"`
|
|
|
|
Root string `json:"root,omitempty"`
|
|
SPA bool `json:"spa,omitempty"` // Single-page app mode: serves index for non-existent paths
|
|
Index string `json:"index,omitempty"` // Index file to serve for single-page app mode
|
|
|
|
route.HTTPConfig
|
|
PathPatterns []string `json:"path_patterns,omitempty" extensions:"x-nullable"`
|
|
Rules rules.Rules `json:"rules,omitempty" extensions:"x-nullable"`
|
|
RuleFile string `json:"rule_file,omitempty" extensions:"x-nullable"`
|
|
HealthCheck types.HealthCheckConfig `json:"healthcheck,omitzero" extensions:"x-nullable"` // null on load-balancer routes
|
|
LoadBalance *types.LoadBalancerConfig `json:"load_balance,omitempty" extensions:"x-nullable"`
|
|
Middlewares map[string]types.LabelMap `json:"middlewares,omitempty" extensions:"x-nullable"`
|
|
Homepage *homepage.ItemConfig `json:"homepage"`
|
|
AccessLog *accesslog.RequestLoggerConfig `json:"access_log,omitempty" extensions:"x-nullable"`
|
|
RelayProxyProtocolHeader bool `json:"relay_proxy_protocol_header,omitempty"` // TCP only: relay PROXY protocol header to the destination
|
|
Agent string `json:"agent,omitempty"`
|
|
|
|
Proxmox *proxmox.NodeConfig `json:"proxmox,omitempty" extensions:"x-nullable"`
|
|
|
|
Idlewatcher *types.IdlewatcherConfig `json:"idlewatcher,omitempty" extensions:"x-nullable"`
|
|
|
|
Metadata `deserialize:"-"`
|
|
} // @name Route
|
|
|
|
Metadata struct {
|
|
/* Docker only */
|
|
Container *types.Container `json:"container,omitempty" extensions:"x-nullable"`
|
|
|
|
Provider string `json:"provider,omitempty" extensions:"x-nullable"` // for backward compatibility
|
|
|
|
// private fields
|
|
LisURL *nettypes.URL `json:"lurl,omitempty" swaggertype:"string" extensions:"x-nullable"`
|
|
ProxyURL *nettypes.URL `json:"purl,omitempty" swaggertype:"string"`
|
|
|
|
Excluded bool `json:"excluded,omitempty" extensions:"x-nullable"`
|
|
ExcludedReason ExcludedReason `json:"excluded_reason,omitempty" swaggertype:"string" extensions:"x-nullable"`
|
|
|
|
HealthMon types.HealthMonitor `json:"health,omitempty" swaggerignore:"true"`
|
|
// for swagger
|
|
HealthJSON *types.HealthJSON `json:",omitempty" form:"health"`
|
|
|
|
impl types.Route
|
|
task *task.Task
|
|
|
|
// ensure err is read after validation or start
|
|
valErr lockedError
|
|
startErr lockedError
|
|
|
|
provider types.RouteProvider
|
|
|
|
agent *agentpool.Agent
|
|
|
|
started chan struct{}
|
|
onceStart sync.Once
|
|
onceValidate sync.Once
|
|
}
|
|
Routes map[string]*Route
|
|
Port = route.Port
|
|
)
|
|
|
|
type lockedError struct {
|
|
err error
|
|
lock sync.Mutex
|
|
}
|
|
|
|
func (le *lockedError) Get() error {
|
|
le.lock.Lock()
|
|
defer le.lock.Unlock()
|
|
return le.err
|
|
}
|
|
|
|
func (le *lockedError) Set(err error) {
|
|
le.lock.Lock()
|
|
defer le.lock.Unlock()
|
|
le.err = err
|
|
}
|
|
|
|
const DefaultHost = "localhost"
|
|
|
|
func (r Routes) Contains(alias string) bool {
|
|
_, ok := r[alias]
|
|
return ok
|
|
}
|
|
|
|
func (r *Route) Validate() error {
|
|
// wait for alias to be set
|
|
if r.Alias == "" {
|
|
return nil
|
|
}
|
|
// pcs := make([]uintptr, 1)
|
|
// runtime.Callers(2, pcs)
|
|
// f := runtime.FuncForPC(pcs[0])
|
|
// fname := f.Name()
|
|
r.onceValidate.Do(func() {
|
|
// filename, line := f.FileLine(pcs[0])
|
|
// if strings.HasPrefix(r.Alias, "godoxy") {
|
|
// log.Debug().Str("route", r.Alias).Str("caller", fname).Str("file", filename).Int("line", line).Msg("validating route")
|
|
// }
|
|
r.valErr.Set(r.validate())
|
|
})
|
|
return r.valErr.Get()
|
|
}
|
|
|
|
func (r *Route) validate() error {
|
|
// if strings.HasPrefix(r.Alias, "godoxy") {
|
|
// log.Debug().Any("route", r).Msg("validating route")
|
|
// }
|
|
if r.Agent != "" {
|
|
if r.Container != nil {
|
|
return errors.New("specifying agent is not allowed for docker container routes")
|
|
}
|
|
var ok bool
|
|
// by agent address
|
|
r.agent, ok = agentpool.Get(r.Agent)
|
|
if !ok {
|
|
// fallback to get agent by name
|
|
r.agent, ok = agentpool.GetAgent(r.Agent)
|
|
if !ok {
|
|
return fmt.Errorf("agent %s not found", r.Agent)
|
|
}
|
|
}
|
|
}
|
|
|
|
r.Finalize()
|
|
|
|
r.started = make(chan struct{})
|
|
// close the channel when the route is destroyed (if not closed yet).
|
|
runtime.AddCleanup(r, func(ch chan struct{}) {
|
|
select {
|
|
case <-ch:
|
|
default:
|
|
close(ch)
|
|
}
|
|
}, r.started)
|
|
|
|
if r.Proxmox != nil && r.Idlewatcher != nil {
|
|
r.Idlewatcher.Proxmox = &types.ProxmoxConfig{
|
|
Node: r.Proxmox.Node,
|
|
}
|
|
if r.Proxmox.VMID != nil {
|
|
r.Idlewatcher.Proxmox.VMID = *r.Proxmox.VMID
|
|
}
|
|
}
|
|
|
|
if r.Proxmox == nil && r.Idlewatcher != nil && r.Idlewatcher.Proxmox != nil {
|
|
r.Proxmox = &proxmox.NodeConfig{
|
|
Node: r.Idlewatcher.Proxmox.Node,
|
|
VMID: &r.Idlewatcher.Proxmox.VMID,
|
|
}
|
|
}
|
|
|
|
if (r.Proxmox == nil || r.Proxmox.Node == "" || r.Proxmox.VMID == nil) && r.Container == nil {
|
|
wasNotNil := r.Proxmox != nil
|
|
workingState := config.WorkingState.Load()
|
|
var proxmoxProviders []*proxmox.Config
|
|
if workingState != nil { // nil in tests
|
|
proxmoxProviders = workingState.Value().Providers.Proxmox
|
|
}
|
|
if len(proxmoxProviders) > 0 {
|
|
// it's fine if ip is nil
|
|
hostname := r.Host
|
|
ip := net.ParseIP(hostname)
|
|
for _, p := range proxmoxProviders {
|
|
// First check if hostname, IP, or alias matches a node (node-level route)
|
|
if nodeName := p.Client().ReverseLookupNode(hostname, ip, r.Alias); nodeName != "" {
|
|
zero := uint64(0)
|
|
if r.Proxmox == nil {
|
|
r.Proxmox = &proxmox.NodeConfig{}
|
|
}
|
|
r.Proxmox.Node = nodeName
|
|
r.Proxmox.VMID = &zero
|
|
r.Proxmox.VMName = ""
|
|
log.Info().EmbedObject(r).Msg("found proxmox node")
|
|
break
|
|
}
|
|
|
|
// Then check if hostname, IP, or alias matches a VM resource
|
|
resource, _ := p.Client().ReverseLookupResource(ip, hostname, r.Alias)
|
|
if resource != nil {
|
|
vmid := resource.VMID
|
|
if r.Proxmox == nil {
|
|
r.Proxmox = &proxmox.NodeConfig{}
|
|
}
|
|
r.Proxmox.Node = resource.Node
|
|
r.Proxmox.VMID = &vmid
|
|
r.Proxmox.VMName = resource.Name
|
|
log.Info().EmbedObject(r).Msg("found proxmox resource")
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if wasNotNil && (r.Proxmox.Node == "" || r.Proxmox.VMID == nil) {
|
|
log.Warn().EmbedObject(r).Msg("no proxmox node / resource found")
|
|
}
|
|
}
|
|
|
|
if r.Proxmox != nil {
|
|
r.validateProxmox()
|
|
}
|
|
|
|
if r.Container != nil && r.Container.IdlewatcherConfig != nil {
|
|
r.Idlewatcher = r.Container.IdlewatcherConfig
|
|
}
|
|
|
|
// return error if route is localhost:<godoxy_port> but route is not agent
|
|
if !r.IsAgent() && !r.ShouldExclude() {
|
|
switch r.Host {
|
|
case "localhost", "127.0.0.1":
|
|
switch r.Port.Proxy {
|
|
case common.ProxyHTTPPort, common.ProxyHTTPSPort, common.APIHTTPPort:
|
|
if r.Scheme.IsReverseProxy() || r.Scheme == route.SchemeTCP {
|
|
return fmt.Errorf("localhost:%d is reserved for godoxy", r.Port.Proxy)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
var errs gperr.Builder
|
|
if err := r.validateRules(); err != nil {
|
|
errs.Add(err)
|
|
}
|
|
|
|
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))))
|
|
} else {
|
|
switch r.Scheme {
|
|
case route.SchemeFileServer:
|
|
r.Host = ""
|
|
r.Port.Proxy = 0
|
|
r.LisURL = gperr.Collect(&errs, nettypes.ParseURL, "https://"+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, "https://"+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))))
|
|
case route.SchemeTCP, route.SchemeUDP:
|
|
bindIP := net.ParseIP(r.Bind)
|
|
remoteIP := net.ParseIP(r.Host)
|
|
toNetwork := func(ip net.IP, scheme route.Scheme) string {
|
|
if ip == nil { // hostname, indeterminate
|
|
return scheme.String()
|
|
}
|
|
if ip.To4() == nil {
|
|
if scheme == route.SchemeTCP {
|
|
return "tcp6"
|
|
}
|
|
return "udp6"
|
|
}
|
|
if scheme == route.SchemeTCP {
|
|
return "tcp4"
|
|
}
|
|
return "udp4"
|
|
}
|
|
lScheme := toNetwork(bindIP, r.Scheme)
|
|
rScheme := toNetwork(remoteIP, r.Scheme)
|
|
|
|
r.LisURL = gperr.Collect(&errs, nettypes.ParseURL, fmt.Sprintf("%s://%s", lScheme, net.JoinHostPort(r.Bind, strconv.Itoa(r.Port.Listening))))
|
|
r.ProxyURL = gperr.Collect(&errs, nettypes.ParseURL, fmt.Sprintf("%s://%s", rScheme, net.JoinHostPort(r.Host, strconv.Itoa(r.Port.Proxy))))
|
|
}
|
|
}
|
|
|
|
if !r.UseHealthCheck() && (r.UseLoadBalance() || r.UseIdleWatcher()) {
|
|
errs.Adds("cannot disable healthcheck when loadbalancer or idle watcher is enabled")
|
|
}
|
|
if r.RelayProxyProtocolHeader && r.Scheme != route.SchemeTCP {
|
|
errs.Adds("relay_proxy_protocol_header is only supported for tcp routes")
|
|
}
|
|
|
|
if errs.HasError() {
|
|
return errs.Error()
|
|
}
|
|
|
|
var impl types.Route
|
|
var err error
|
|
switch r.Scheme {
|
|
case route.SchemeFileServer:
|
|
impl, err = NewFileServer(r)
|
|
case route.SchemeHTTP, route.SchemeHTTPS, route.SchemeH2C:
|
|
impl, err = NewReverseProxyRoute(r)
|
|
case route.SchemeTCP, route.SchemeUDP:
|
|
impl, err = NewStreamRoute(r)
|
|
default:
|
|
panic(fmt.Errorf("unexpected scheme %s for alias %s", r.Scheme, r.Alias))
|
|
}
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
r.impl = impl
|
|
r.Excluded = r.ShouldExclude()
|
|
if r.Excluded {
|
|
r.ExcludedReason = r.findExcludedReason()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Route) validateRules() error {
|
|
// FIXME: hardcoded here as a workaround
|
|
// there's already a label "proxy.#1.rule_file=embed://webui.yml"
|
|
// but it's not working as expected sometimes.
|
|
// TODO: investigate why it's not working and fix it.
|
|
if cont := r.ContainerInfo(); cont != nil {
|
|
if cont.Image.Name == "godoxy-frontend" {
|
|
rules, ok := rulepresets.GetRulePreset("webui.yml")
|
|
if !ok {
|
|
return errors.New("rule preset `webui.yml` not found")
|
|
}
|
|
r.Rules = rules
|
|
return nil
|
|
}
|
|
}
|
|
|
|
if r.RuleFile != "" && len(r.Rules) > 0 {
|
|
return errors.New("`rule_file` and `rules` cannot be used together")
|
|
} else if r.RuleFile != "" {
|
|
src, err := url.Parse(r.RuleFile)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse rule file url %q: %w", r.RuleFile, err)
|
|
}
|
|
switch src.Scheme {
|
|
case "embed": // embed://<preset_file_name>
|
|
rules, ok := rulepresets.GetRulePreset(src.Host)
|
|
if !ok {
|
|
return fmt.Errorf("rule preset %q not found", src.Host)
|
|
} else {
|
|
r.Rules = rules
|
|
}
|
|
case "file", "":
|
|
if !strutils.IsValidFilename(src.Path) {
|
|
return fmt.Errorf("invalid rule file path %q", src.Path)
|
|
}
|
|
content, err := os.ReadFile(src.Path)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read rule file %q: %w", src.Path, err)
|
|
} else {
|
|
_, err = serialization.ConvertString(string(content), reflect.ValueOf(&r.Rules))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to unmarshal rule file %q: %w", src.Path, err)
|
|
}
|
|
}
|
|
default:
|
|
return fmt.Errorf("unsupported rule file scheme %q", src.Scheme)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Route) validateProxmox() {
|
|
l := log.With().EmbedObject(r).Logger()
|
|
|
|
nodeName := r.Proxmox.Node
|
|
vmid := r.Proxmox.VMID
|
|
if nodeName == "" || vmid == nil {
|
|
l.Error().Msg("node (proxmox node name) is required")
|
|
return
|
|
}
|
|
|
|
node, ok := proxmox.Nodes.Get(nodeName)
|
|
if !ok {
|
|
l.Error().Msgf("proxmox node %s not found in pool", nodeName)
|
|
return
|
|
}
|
|
|
|
// Node-level route (VMID = 0)
|
|
if *vmid == 0 {
|
|
r.Scheme = route.SchemeHTTPS
|
|
if r.Host == DefaultHost {
|
|
r.Host = node.Client().BaseURL.Hostname()
|
|
}
|
|
port, _ := strconv.Atoi(node.Client().BaseURL.Port())
|
|
if port == 0 {
|
|
port = 8006
|
|
}
|
|
r.Port.Proxy = port
|
|
} else {
|
|
res, err := node.Client().GetResource("lxc", *vmid)
|
|
if err != nil { // ErrResourceNotFound
|
|
l.Error().Err(err).Msgf("failed to get resource %d", *vmid)
|
|
return
|
|
}
|
|
|
|
r.Proxmox.VMName = res.Name
|
|
|
|
if r.Host == DefaultHost {
|
|
containerName := res.Name
|
|
// get ip addresses of the vmid
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
ips := res.IPs
|
|
if len(ips) == 0 {
|
|
l.Warn().Msgf("no ip addresses found for %s, make sure you have set static ip address for container instead of dhcp", containerName)
|
|
return
|
|
}
|
|
|
|
l.Info().Str("container", containerName).Msg("checking if container is running")
|
|
running, err := node.LXCIsRunning(ctx, *vmid)
|
|
if err != nil {
|
|
l.Error().Err(err).Msgf("failed to check container state")
|
|
return
|
|
}
|
|
|
|
if !running {
|
|
l.Info().Msg("starting container")
|
|
if err := node.LXCAction(ctx, *vmid, proxmox.LXCStart); err != nil {
|
|
l.Error().Err(err).Msg("failed to start container")
|
|
return
|
|
}
|
|
}
|
|
|
|
l.Info().Msg("finding reachable ip addresses")
|
|
errs := gperr.NewBuilder("failed to find reachable ip addresses")
|
|
for _, ip := range ips {
|
|
if err := netutils.PingTCP(ctx, ip, r.Port.Proxy); err != nil {
|
|
errs.Add(gperr.Unwrap(err).Subjectf("%s:%d", ip, r.Port.Proxy))
|
|
} else {
|
|
r.Host = ip.String()
|
|
l.Info().Msgf("using ip %s", r.Host)
|
|
break
|
|
}
|
|
}
|
|
if r.Host == DefaultHost {
|
|
l.Warn().Err(errs.Error()).Msgf("no reachable ip addresses found, tried %d IPs", len(ips))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *Route) Impl() types.Route {
|
|
return r.impl
|
|
}
|
|
|
|
func (r *Route) Task() *task.Task {
|
|
return r.task
|
|
}
|
|
|
|
func (r *Route) Start(parent task.Parent) error {
|
|
r.onceStart.Do(func() {
|
|
r.startErr.Set(r.start(parent))
|
|
})
|
|
return r.startErr.Get()
|
|
}
|
|
|
|
func (r *Route) start(parent task.Parent) error {
|
|
if r.impl == nil { // should not happen
|
|
return errors.New("route not initialized")
|
|
}
|
|
defer close(r.started)
|
|
|
|
// skip checking for excluded routes
|
|
excluded := r.ShouldExclude()
|
|
if !excluded {
|
|
if err := checkExists(parent.Context(), r); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if cont := r.ContainerInfo(); cont != nil {
|
|
docker.SetDockerCfgByContainerID(cont.ContainerID, cont.DockerCfg)
|
|
}
|
|
|
|
if !excluded {
|
|
if err := r.impl.Start(parent); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
ep := entrypoint.FromCtx(parent.Context())
|
|
if ep == nil {
|
|
return errors.New("entrypoint not initialized")
|
|
}
|
|
|
|
r.task = parent.Subtask("excluded."+r.Name(), false)
|
|
r.task.SetValue(monitor.DisplayNameKey{}, r.DisplayName())
|
|
ep.ExcludedRoutes().Add(r.impl)
|
|
r.task.OnCancel("remove_route_from_excluded", func() {
|
|
ep.ExcludedRoutes().Del(r.impl)
|
|
})
|
|
if r.UseHealthCheck() {
|
|
r.HealthMon = monitor.NewMonitor(r.impl)
|
|
err := r.HealthMon.Start(r.task)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Route) Finish(reason any) {
|
|
if cont := r.ContainerInfo(); cont != nil {
|
|
docker.DeleteDockerCfgByContainerID(cont.ContainerID)
|
|
}
|
|
r.FinishAndWait(reason)
|
|
}
|
|
|
|
func (r *Route) FinishAndWait(reason any) {
|
|
if r.impl == nil {
|
|
return
|
|
}
|
|
r.task.FinishAndWait(reason)
|
|
r.impl = nil
|
|
}
|
|
|
|
func (r *Route) Started() <-chan struct{} {
|
|
return r.started
|
|
}
|
|
|
|
func (r *Route) GetProvider() types.RouteProvider {
|
|
return r.provider
|
|
}
|
|
|
|
func (r *Route) SetProvider(p types.RouteProvider) {
|
|
r.provider = p
|
|
r.Provider = p.ShortName()
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func (r *Route) References() []string {
|
|
aliasRef, _, ok := strings.Cut(r.Alias, ".")
|
|
if !ok {
|
|
aliasRef = r.Alias
|
|
}
|
|
|
|
if r.Container != nil {
|
|
if r.Container.ContainerName != aliasRef {
|
|
return []string{r.Container.ContainerName, aliasRef, r.Container.Image.Name, r.Container.Image.Author}
|
|
}
|
|
return []string{r.Container.Image.Name, aliasRef, r.Container.Image.Author}
|
|
}
|
|
|
|
if r.Proxmox != nil {
|
|
if len(r.Proxmox.Services) > 0 && r.Proxmox.Services[0] != aliasRef {
|
|
if r.Proxmox.VMName != aliasRef {
|
|
return []string{r.Proxmox.VMName, aliasRef, r.Proxmox.Services[0]}
|
|
}
|
|
return []string{r.Proxmox.Services[0], aliasRef}
|
|
}
|
|
if r.Proxmox.VMName != aliasRef {
|
|
return []string{r.Proxmox.VMName, aliasRef}
|
|
}
|
|
}
|
|
return []string{aliasRef}
|
|
}
|
|
|
|
// Name implements pool.Object.
|
|
func (r *Route) Name() string {
|
|
return r.Alias
|
|
}
|
|
|
|
// Key implements pool.Object.
|
|
func (r *Route) Key() string {
|
|
if r.UseLoadBalance() || r.ShouldExclude() {
|
|
// for excluded routes and load balanced routes, use provider:alias[-container_id[:8]] as key to make them unique.
|
|
if r.Container != nil {
|
|
return r.Provider + ":" + r.Alias + "-" + r.Container.ContainerID[:8]
|
|
}
|
|
return r.Provider + ":" + r.Alias
|
|
}
|
|
// we need to use alias as key for non-excluded routes because it's being used for subdomain / fqdn lookup for http routes.
|
|
return r.Alias
|
|
}
|
|
|
|
func (r *Route) Type() route.RouteType {
|
|
switch r.Scheme {
|
|
case route.SchemeHTTP, route.SchemeHTTPS, route.SchemeFileServer:
|
|
return route.RouteTypeHTTP
|
|
case route.SchemeTCP, route.SchemeUDP:
|
|
return route.RouteTypeStream
|
|
}
|
|
panic(fmt.Errorf("unexpected scheme %s for alias %s", r.Scheme, r.Alias))
|
|
}
|
|
|
|
func (r *Route) GetAgent() *agentpool.Agent {
|
|
if r.Container != nil && r.Container.Agent != nil {
|
|
return r.Container.Agent
|
|
}
|
|
return r.agent
|
|
}
|
|
|
|
func (r *Route) IsAgent() bool {
|
|
return r.GetAgent() != nil
|
|
}
|
|
|
|
func (r *Route) HealthMonitor() types.HealthMonitor {
|
|
return r.HealthMon
|
|
}
|
|
|
|
func (r *Route) SetHealthMonitor(m types.HealthMonitor) {
|
|
if r.HealthMon != nil && r.HealthMon != m {
|
|
r.HealthMon.Finish("health monitor replaced")
|
|
}
|
|
r.HealthMon = m
|
|
}
|
|
|
|
func (r *Route) IdlewatcherConfig() *types.IdlewatcherConfig {
|
|
return r.Idlewatcher
|
|
}
|
|
|
|
func (r *Route) HealthCheckConfig() types.HealthCheckConfig {
|
|
return r.HealthCheck
|
|
}
|
|
|
|
func (r *Route) LoadBalanceConfig() *types.LoadBalancerConfig {
|
|
return r.LoadBalance
|
|
}
|
|
|
|
func (r *Route) HomepageItem() homepage.Item {
|
|
containerID := ""
|
|
if r.Container != nil {
|
|
containerID = r.Container.ContainerID
|
|
}
|
|
return homepage.Item{
|
|
Alias: r.Alias,
|
|
Provider: r.Provider,
|
|
ItemConfig: *r.Homepage,
|
|
ContainerID: containerID,
|
|
}.GetOverride()
|
|
}
|
|
|
|
func (r *Route) DisplayName() string {
|
|
if r.Homepage == nil { // should only happen in tests, Validate() should initialize it
|
|
return r.Alias
|
|
}
|
|
return r.Homepage.Name
|
|
}
|
|
|
|
func (r *Route) MarshalZerologObject(e *zerolog.Event) {
|
|
e.Str("alias", r.Alias)
|
|
switch r := r.impl.(type) {
|
|
case *ReverseProxyRoute:
|
|
e.Str("type", "reverse_proxy").
|
|
Str("scheme", r.Scheme.String()).
|
|
Str("bind", r.LisURL.Host).
|
|
Str("target", r.ProxyURL.URL.String())
|
|
case *FileServer:
|
|
e.Str("type", "file_server").
|
|
Str("root", r.Root)
|
|
case *StreamRoute:
|
|
e.Str("type", "stream").
|
|
Str("scheme", r.LisURL.Scheme+"->"+r.ProxyURL.Scheme)
|
|
if r.stream != nil {
|
|
// listening port could be zero (random),
|
|
// use LocalAddr() to get the actual listening host+port.
|
|
e.Str("bind", r.stream.LocalAddr().String())
|
|
} else {
|
|
// not yet started
|
|
e.Str("bind", r.LisURL.Host)
|
|
}
|
|
e.Str("target", r.ProxyURL.URL.String())
|
|
}
|
|
if r.Proxmox != nil {
|
|
e.Str("proxmox", r.Proxmox.Node)
|
|
if r.Proxmox.VMID != nil {
|
|
e.Uint64("vmid", *r.Proxmox.VMID)
|
|
}
|
|
if r.Proxmox.VMName != "" {
|
|
e.Str("vmname", r.Proxmox.VMName)
|
|
}
|
|
}
|
|
if r.Container != nil {
|
|
e.Str("container", r.Container.ContainerName)
|
|
}
|
|
}
|
|
|
|
// PreferOver implements pool.Preferable to resolve duplicate route keys deterministically.
|
|
// Preference policy:
|
|
// - Prefer routes with rules over routes without rules.
|
|
// - If rules tie, prefer non-docker routes (explicit config) over docker-discovered routes.
|
|
// - Otherwise, prefer the new route to preserve existing semantics.
|
|
func (r *Route) PreferOver(other any) bool {
|
|
// Try to get the underlying *Route of the other value
|
|
var or *Route
|
|
switch v := other.(type) {
|
|
case *Route:
|
|
or = v
|
|
case *ReverseProxyRoute:
|
|
or = v.Route
|
|
case *FileServer:
|
|
or = v.Route
|
|
case *StreamRoute:
|
|
or = v.Route
|
|
default:
|
|
// Unknown type, allow replacement
|
|
return true
|
|
}
|
|
|
|
// Prefer routes that have rules
|
|
if len(r.Rules) > 0 && len(or.Rules) == 0 {
|
|
return true
|
|
}
|
|
if len(r.Rules) == 0 && len(or.Rules) > 0 {
|
|
return false
|
|
}
|
|
|
|
// Prefer explicit (non-docker) over docker auto-discovered
|
|
if (r.Container == nil) != (or.Container == nil) {
|
|
return r.Container == nil
|
|
}
|
|
|
|
// Default: allow replacement
|
|
return true
|
|
}
|
|
|
|
func (r *Route) ContainerInfo() *types.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.ExcludedReason != ExcludedReasonNone {
|
|
return true
|
|
}
|
|
return r.findExcludedReason() != ExcludedReasonNone
|
|
}
|
|
|
|
type ExcludedReason uint8
|
|
|
|
const (
|
|
ExcludedReasonNone ExcludedReason = iota
|
|
ExcludedReasonError
|
|
ExcludedReasonManual
|
|
ExcludedReasonNoPortContainer
|
|
ExcludedReasonNoPortSpecified
|
|
ExcludedReasonBlacklisted
|
|
ExcludedReasonBuildx
|
|
ExcludedReasonYAMLAnchor
|
|
ExcludedReasonOld
|
|
)
|
|
|
|
func (re ExcludedReason) String() string {
|
|
switch re {
|
|
case ExcludedReasonNone:
|
|
return ""
|
|
case ExcludedReasonError:
|
|
return "Error"
|
|
case ExcludedReasonManual:
|
|
return "Manual exclusion"
|
|
case ExcludedReasonNoPortContainer:
|
|
return "No port exposed in container"
|
|
case ExcludedReasonNoPortSpecified:
|
|
return "No port specified"
|
|
case ExcludedReasonBlacklisted:
|
|
return "Blacklisted (backend service or database)"
|
|
case ExcludedReasonBuildx:
|
|
return "Buildx"
|
|
case ExcludedReasonYAMLAnchor:
|
|
return "YAML anchor or reference"
|
|
case ExcludedReasonOld:
|
|
return "Container renaming intermediate state"
|
|
default:
|
|
return "Unknown"
|
|
}
|
|
}
|
|
|
|
func (re ExcludedReason) MarshalJSON() ([]byte, error) {
|
|
return strconv.AppendQuote(nil, re.String()), nil
|
|
}
|
|
|
|
// no need to unmarshal json because we don't store this
|
|
|
|
func (r *Route) findExcludedReason() ExcludedReason {
|
|
if r.valErr.Get() != nil {
|
|
return ExcludedReasonError
|
|
}
|
|
if r.ExcludedReason != ExcludedReasonNone {
|
|
return r.ExcludedReason
|
|
}
|
|
if r.Container != nil {
|
|
switch {
|
|
case r.Container.IsExcluded:
|
|
return ExcludedReasonManual
|
|
case r.IsZeroPort() && !r.UseIdleWatcher():
|
|
return ExcludedReasonNoPortContainer
|
|
case !r.Container.IsExplicit && docker.IsBlacklisted(r.Container):
|
|
return ExcludedReasonBlacklisted
|
|
case strings.HasPrefix(r.Container.ContainerName, "buildx_"):
|
|
return ExcludedReasonBuildx
|
|
}
|
|
} else if r.IsZeroPort() && r.Scheme != route.SchemeFileServer {
|
|
return ExcludedReasonNoPortSpecified
|
|
}
|
|
// this should happen on validation API only,
|
|
// those routes are removed before validation.
|
|
// see removeXPrefix in provider/file.go
|
|
if strings.HasPrefix(r.Alias, "x-") { // for YAML anchors and references
|
|
return ExcludedReasonYAMLAnchor
|
|
}
|
|
if strings.HasSuffix(r.Alias, "-old") {
|
|
return ExcludedReasonOld
|
|
}
|
|
return ExcludedReasonNone
|
|
}
|
|
|
|
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 && r.Idlewatcher.ValErr() == nil
|
|
}
|
|
|
|
func (r *Route) UseHealthCheck() bool {
|
|
if r.Container != nil {
|
|
switch {
|
|
case r.Container.Image.Name == "godoxy-agent":
|
|
return false
|
|
case !r.Container.Running && !r.UseIdleWatcher():
|
|
return false
|
|
case strings.HasPrefix(r.Container.ContainerName, "buildx_"):
|
|
return false
|
|
}
|
|
}
|
|
return !r.HealthCheck.Disable
|
|
}
|
|
|
|
func (r *Route) UseAccessLog() bool {
|
|
return r.AccessLog != nil
|
|
}
|
|
|
|
func (r *Route) Finalize() {
|
|
r.Alias = strings.ToLower(strings.TrimSpace(r.Alias))
|
|
r.Host = strings.ToLower(strings.TrimSpace(r.Host))
|
|
|
|
isDocker := r.Container != nil
|
|
cont := r.Container
|
|
|
|
if r.Host == "" {
|
|
switch {
|
|
case !isDocker:
|
|
r.Host = "localhost"
|
|
case cont.PrivateHostname != "":
|
|
r.Host = cont.PrivateHostname
|
|
case cont.PublicHostname != "":
|
|
r.Host = cont.PublicHostname
|
|
}
|
|
}
|
|
|
|
lp, pp := r.Port.Listening, r.Port.Proxy
|
|
|
|
if isDocker {
|
|
scheme, port, ok := getSchemePortByImageName(cont.Image.Name)
|
|
if ok {
|
|
if r.Scheme == route.SchemeNone {
|
|
r.Scheme = scheme
|
|
}
|
|
if pp == 0 {
|
|
pp = port
|
|
}
|
|
}
|
|
}
|
|
|
|
if scheme, port, ok := getSchemePortByAlias(r.Alias); ok {
|
|
if r.Scheme == route.SchemeNone {
|
|
r.Scheme = scheme
|
|
}
|
|
if pp == 0 {
|
|
pp = port
|
|
}
|
|
}
|
|
|
|
if pp == 0 {
|
|
switch {
|
|
case isDocker:
|
|
if cont.IsHostNetworkMode {
|
|
pp = preferredPort(cont.PublicPortMapping)
|
|
} else {
|
|
pp = preferredPort(cont.PrivatePortMapping)
|
|
}
|
|
case r.Scheme == route.SchemeHTTPS:
|
|
pp = 443
|
|
default:
|
|
pp = 80
|
|
}
|
|
}
|
|
|
|
if isDocker {
|
|
if r.Scheme == route.SchemeNone {
|
|
for _, p := range cont.PublicPortMapping {
|
|
if int(p.PrivatePort) == pp && p.Type == "udp" {
|
|
r.Scheme = route.SchemeUDP
|
|
break
|
|
}
|
|
}
|
|
}
|
|
// replace private port with public port if using public IP.
|
|
if r.Host == cont.PublicHostname {
|
|
if p, ok := cont.PrivatePortMapping[pp]; ok {
|
|
pp = int(p.PublicPort)
|
|
}
|
|
} else {
|
|
// replace public port with private port if using private IP.
|
|
if p, ok := cont.PublicPortMapping[pp]; ok {
|
|
pp = int(p.PrivatePort)
|
|
}
|
|
}
|
|
}
|
|
|
|
if r.Scheme == route.SchemeNone {
|
|
switch {
|
|
case lp != 0:
|
|
r.Scheme = route.SchemeTCP
|
|
case pp%1000 == 443:
|
|
r.Scheme = route.SchemeHTTPS
|
|
default: // assume its http
|
|
r.Scheme = route.SchemeHTTP
|
|
}
|
|
}
|
|
|
|
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()
|
|
if workingState == nil {
|
|
if common.IsTest { // in tests, working state might be nil
|
|
return
|
|
}
|
|
panic("bug: working state is nil")
|
|
}
|
|
|
|
// TODO: default value from context
|
|
r.HealthCheck.ApplyDefaults(workingState.Value().Defaults.HealthCheck)
|
|
}
|
|
|
|
func (r *Route) FinalizeHomepageConfig() {
|
|
if r.Alias == "" {
|
|
panic("alias is empty")
|
|
}
|
|
|
|
isDocker := r.Container != nil
|
|
|
|
if r.Homepage == nil {
|
|
r.Homepage = &homepage.ItemConfig{
|
|
Show: true,
|
|
}
|
|
}
|
|
|
|
if r.ShouldExclude() && isDocker {
|
|
r.Homepage.Show = false
|
|
r.Homepage.Name = r.Container.ContainerName // still show container name in metrics page
|
|
return
|
|
}
|
|
|
|
hp := r.Homepage
|
|
refs := r.References()
|
|
for _, ref := range refs {
|
|
meta, ok := iconlist.GetMetadata(ref)
|
|
if ok {
|
|
if hp.Name == "" {
|
|
hp.Name = meta.DisplayName
|
|
}
|
|
if hp.Category == "" {
|
|
hp.Category = meta.Tag
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
if hp.Name == "" {
|
|
hp.Name = strutils.Title(
|
|
strings.ReplaceAll(
|
|
strings.ReplaceAll(refs[0], "-", " "),
|
|
"_", " ",
|
|
),
|
|
)
|
|
}
|
|
|
|
if hp.Category == "" {
|
|
if homepagecfg.ActiveConfig.Load().UseDefaultCategories {
|
|
for _, ref := range refs {
|
|
if category, ok := homepage.PredefinedCategories[ref]; ok {
|
|
hp.Category = category
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if hp.Category == "" {
|
|
switch {
|
|
case r.UseLoadBalance():
|
|
hp.Category = "Load-balanced"
|
|
case isDocker:
|
|
hp.Category = "Docker"
|
|
default:
|
|
hp.Category = "Others"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
var preferredPortOrder = []int{
|
|
80,
|
|
8080,
|
|
3000,
|
|
8000,
|
|
443,
|
|
8443,
|
|
}
|
|
|
|
func preferredPort(portMapping types.PortMapping) (res int) {
|
|
for _, port := range preferredPortOrder {
|
|
if _, ok := portMapping[port]; ok {
|
|
return port
|
|
}
|
|
}
|
|
// fallback to lowest port
|
|
cmp := (uint16)(65535)
|
|
for port, v := range portMapping {
|
|
if v.PrivatePort < cmp {
|
|
cmp = v.PrivatePort
|
|
res = port
|
|
}
|
|
}
|
|
return res
|
|
}
|