Files
godoxy/internal/route/route.go
yusing 93263eedbf feat(route): add support for relaying PROXY protocol header to TCP upstreams
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
2026-03-10 12:04:07 +08:00

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
}