Fixed a few issues:

- Incorrect name being shown on dashboard "Proxies page"
- Apps being shown when homepage.show is false
- Load balanced routes are shown on homepage instead of the load balancer
- Route with idlewatcher will now be removed on container destroy
- Idlewatcher panic
- Performance improvement
- Idlewatcher infinitely loading
- Reload stucked / not working properly
- Streams stuck on shutdown / reload
- etc...
Added:
- support idlewatcher for loadbalanced routes
- partial implementation for stream type idlewatcher
Issues:
- graceful shutdown
This commit is contained in:
yusing
2024-10-18 16:47:01 +08:00
parent c0c61709ca
commit 53557e38b6
69 changed files with 2368 additions and 1654 deletions

View File

@@ -1,177 +0,0 @@
package proxy
import (
"fmt"
"net/url"
"time"
D "github.com/yusing/go-proxy/internal/docker"
E "github.com/yusing/go-proxy/internal/error"
"github.com/yusing/go-proxy/internal/net/http/loadbalancer"
net "github.com/yusing/go-proxy/internal/net/types"
T "github.com/yusing/go-proxy/internal/proxy/fields"
"github.com/yusing/go-proxy/internal/types"
"github.com/yusing/go-proxy/internal/watcher/health"
)
type (
ReverseProxyEntry struct { // real model after validation
Raw *types.RawEntry `json:"raw"`
Alias T.Alias `json:"alias,omitempty"`
Scheme T.Scheme `json:"scheme,omitempty"`
URL net.URL `json:"url,omitempty"`
NoTLSVerify bool `json:"no_tls_verify,omitempty"`
PathPatterns T.PathPatterns `json:"path_patterns,omitempty"`
HealthCheck *health.HealthCheckConfig `json:"healthcheck,omitempty"`
LoadBalance *loadbalancer.Config `json:"load_balance,omitempty"`
Middlewares D.NestedLabelMap `json:"middlewares,omitempty"`
/* Docker only */
IdleTimeout time.Duration `json:"idle_timeout,omitempty"`
WakeTimeout time.Duration `json:"wake_timeout,omitempty"`
StopMethod T.StopMethod `json:"stop_method,omitempty"`
StopTimeout int `json:"stop_timeout,omitempty"`
StopSignal T.Signal `json:"stop_signal,omitempty"`
DockerHost string `json:"docker_host,omitempty"`
ContainerName string `json:"container_name,omitempty"`
ContainerID string `json:"container_id,omitempty"`
ContainerRunning bool `json:"container_running,omitempty"`
}
StreamEntry struct {
Raw *types.RawEntry `json:"raw"`
Alias T.Alias `json:"alias,omitempty"`
Scheme T.StreamScheme `json:"scheme,omitempty"`
Host T.Host `json:"host,omitempty"`
Port T.StreamPort `json:"port,omitempty"`
Healthcheck *health.HealthCheckConfig `json:"healthcheck,omitempty"`
}
)
func (rp *ReverseProxyEntry) UseIdleWatcher() bool {
return rp.IdleTimeout > 0 && rp.IsDocker()
}
func (rp *ReverseProxyEntry) UseLoadBalance() bool {
return rp.LoadBalance != nil && rp.LoadBalance.Link != ""
}
func (rp *ReverseProxyEntry) IsDocker() bool {
return rp.DockerHost != ""
}
func (rp *ReverseProxyEntry) IsZeroPort() bool {
return rp.URL.Port() == "0"
}
func (rp *ReverseProxyEntry) ShouldNotServe() bool {
return rp.IsZeroPort() && !rp.UseIdleWatcher()
}
func ValidateEntry(m *types.RawEntry) (any, E.NestedError) {
m.FillMissingFields()
scheme, err := T.NewScheme(m.Scheme)
if err != nil {
return nil, err
}
var entry any
e := E.NewBuilder("error validating entry")
if scheme.IsStream() {
entry = validateStreamEntry(m, e)
} else {
entry = validateRPEntry(m, scheme, e)
}
if err := e.Build(); err != nil {
return nil, err
}
return entry, nil
}
func validateRPEntry(m *types.RawEntry, s T.Scheme, b E.Builder) *ReverseProxyEntry {
var stopTimeOut time.Duration
cont := m.Container
if cont == nil {
cont = D.DummyContainer
}
host, err := T.ValidateHost(m.Host)
b.Add(err)
port, err := T.ValidatePort(m.Port)
b.Add(err)
pathPatterns, err := T.ValidatePathPatterns(m.PathPatterns)
b.Add(err)
url, err := E.Check(url.Parse(fmt.Sprintf("%s://%s:%d", s, host, port)))
b.Add(err)
idleTimeout, err := T.ValidateDurationPostitive(cont.IdleTimeout)
b.Add(err)
wakeTimeout, err := T.ValidateDurationPostitive(cont.WakeTimeout)
b.Add(err)
stopMethod, err := T.ValidateStopMethod(cont.StopMethod)
b.Add(err)
if stopMethod == T.StopMethodStop {
stopTimeOut, err = T.ValidateDurationPostitive(cont.StopTimeout)
b.Add(err)
}
stopSignal, err := T.ValidateSignal(cont.StopSignal)
b.Add(err)
if err != nil {
return nil
}
return &ReverseProxyEntry{
Raw: m,
Alias: T.NewAlias(m.Alias),
Scheme: s,
URL: net.NewURL(url),
NoTLSVerify: m.NoTLSVerify,
PathPatterns: pathPatterns,
HealthCheck: &m.HealthCheck,
LoadBalance: &m.LoadBalance,
Middlewares: m.Middlewares,
IdleTimeout: idleTimeout,
WakeTimeout: wakeTimeout,
StopMethod: stopMethod,
StopTimeout: int(stopTimeOut.Seconds()), // docker api takes integer seconds for timeout argument
StopSignal: stopSignal,
DockerHost: cont.DockerHost,
ContainerName: cont.ContainerName,
ContainerID: cont.ContainerID,
ContainerRunning: cont.Running,
}
}
func validateStreamEntry(m *types.RawEntry, b E.Builder) *StreamEntry {
host, err := T.ValidateHost(m.Host)
b.Add(err)
port, err := T.ValidateStreamPort(m.Port)
b.Add(err)
scheme, err := T.ValidateStreamScheme(m.Scheme)
b.Add(err)
if b.HasError() {
return nil
}
return &StreamEntry{
Raw: m,
Alias: T.NewAlias(m.Alias),
Scheme: *scheme,
Host: host,
Port: port,
Healthcheck: &m.HealthCheck,
}
}

View File

@@ -0,0 +1,68 @@
package entry
import (
idlewatcher "github.com/yusing/go-proxy/internal/docker/idlewatcher/config"
E "github.com/yusing/go-proxy/internal/error"
"github.com/yusing/go-proxy/internal/net/http/loadbalancer"
net "github.com/yusing/go-proxy/internal/net/types"
T "github.com/yusing/go-proxy/internal/proxy/fields"
"github.com/yusing/go-proxy/internal/watcher/health"
)
type Entry interface {
TargetName() string
TargetURL() net.URL
RawEntry() *RawEntry
LoadBalanceConfig() *loadbalancer.Config
HealthCheckConfig() *health.HealthCheckConfig
IdlewatcherConfig() *idlewatcher.Config
}
func ValidateEntry(m *RawEntry) (Entry, E.NestedError) {
m.FillMissingFields()
scheme, err := T.NewScheme(m.Scheme)
if err != nil {
return nil, err
}
var entry Entry
e := E.NewBuilder("error validating entry")
if scheme.IsStream() {
entry = validateStreamEntry(m, e)
} else {
entry = validateRPEntry(m, scheme, e)
}
if err := e.Build(); err != nil {
return nil, err
}
return entry, nil
}
func IsDocker(entry Entry) bool {
iw := entry.IdlewatcherConfig()
return iw != nil && iw.ContainerID != ""
}
func IsZeroPort(entry Entry) bool {
return entry.TargetURL().Port() == "0"
}
func ShouldNotServe(entry Entry) bool {
return IsZeroPort(entry) && !UseIdleWatcher(entry)
}
func UseLoadBalance(entry Entry) bool {
lb := entry.LoadBalanceConfig()
return lb != nil && lb.Link != ""
}
func UseIdleWatcher(entry Entry) bool {
iw := entry.IdlewatcherConfig()
return iw != nil && iw.IdleTimeout > 0
}
func UseHealthCheck(entry Entry) bool {
hc := entry.HealthCheckConfig()
return hc != nil && !hc.Disabled
}

201
internal/proxy/entry/raw.go Normal file
View File

@@ -0,0 +1,201 @@
package entry
import (
"strconv"
"strings"
"github.com/docker/docker/api/types"
"github.com/sirupsen/logrus"
"github.com/yusing/go-proxy/internal/common"
"github.com/yusing/go-proxy/internal/docker"
"github.com/yusing/go-proxy/internal/homepage"
"github.com/yusing/go-proxy/internal/net/http/loadbalancer"
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"
)
type (
RawEntry struct {
_ U.NoCopy
// raw entry object before validation
// loaded from docker labels or yaml file
Alias string `json:"-" yaml:"-"`
Scheme string `json:"scheme,omitempty" yaml:"scheme"`
Host string `json:"host,omitempty" yaml:"host"`
Port string `json:"port,omitempty" yaml:"port"`
NoTLSVerify bool `json:"no_tls_verify,omitempty" yaml:"no_tls_verify"` // https proxy only
PathPatterns []string `json:"path_patterns,omitempty" yaml:"path_patterns"` // http(s) proxy only
HealthCheck *health.HealthCheckConfig `json:"healthcheck,omitempty" yaml:"healthcheck"`
LoadBalance *loadbalancer.Config `json:"load_balance,omitempty" yaml:"load_balance"`
Middlewares docker.NestedLabelMap `json:"middlewares,omitempty" yaml:"middlewares"`
Homepage *homepage.Item `json:"homepage,omitempty" yaml:"homepage"`
/* Docker only */
Container *docker.Container `json:"container,omitempty" yaml:"-"`
}
RawEntries = F.Map[string, *RawEntry]
)
var NewProxyEntries = F.NewMapOf[string, *RawEntry]
func (e *RawEntry) FillMissingFields() {
isDocker := e.Container != nil
cont := e.Container
if !isDocker {
cont = docker.DummyContainer
}
if e.Host == "" {
switch {
case cont.PrivateIP != "":
e.Host = cont.PrivateIP
case cont.PublicIP != "":
e.Host = cont.PublicIP
case !isDocker:
e.Host = "localhost"
}
}
lp, pp, extra := e.splitPorts()
if port, ok := common.ServiceNamePortMapTCP[cont.ImageName]; ok {
if pp == "" {
pp = strconv.Itoa(port)
}
if e.Scheme == "" {
e.Scheme = "tcp"
}
} else if port, ok := common.ImageNamePortMap[cont.ImageName]; ok {
if pp == "" {
pp = strconv.Itoa(port)
}
if e.Scheme == "" {
e.Scheme = "http"
}
} else if pp == "" && e.Scheme == "https" {
pp = "443"
} else if pp == "" {
if p := lowestPort(cont.PrivatePortMapping); p != "" {
pp = p
} else if p := lowestPort(cont.PublicPortMapping); p != "" {
pp = p
} else if !isDocker {
pp = "80"
} else {
logrus.Debugf("no port found for %s", e.Alias)
}
}
// replace private port with public port if using public IP.
if e.Host == cont.PublicIP {
if p, ok := cont.PrivatePortMapping[pp]; ok {
pp = U.PortString(p.PublicPort)
}
}
// replace public port with private port if using private IP.
if e.Host == cont.PrivateIP {
if p, ok := cont.PublicPortMapping[pp]; ok {
pp = U.PortString(p.PrivatePort)
}
}
if e.Scheme == "" && isDocker {
switch {
case e.Host == cont.PublicIP && cont.PublicPortMapping[pp].Type == "udp":
e.Scheme = "udp"
case e.Host == cont.PrivateIP && cont.PrivatePortMapping[pp].Type == "udp":
e.Scheme = "udp"
}
}
if e.Scheme == "" {
switch {
case lp != "":
e.Scheme = "tcp"
case strings.HasSuffix(pp, "443"):
e.Scheme = "https"
default: // assume its http
e.Scheme = "http"
}
}
if e.HealthCheck == nil {
e.HealthCheck = new(health.HealthCheckConfig)
}
if e.HealthCheck.Disabled {
e.HealthCheck = nil
} else {
if e.HealthCheck.Interval == 0 {
e.HealthCheck.Interval = common.HealthCheckIntervalDefault
}
if e.HealthCheck.Timeout == 0 {
e.HealthCheck.Timeout = common.HealthCheckTimeoutDefault
}
}
if cont.IdleTimeout != "" {
if cont.WakeTimeout == "" {
cont.WakeTimeout = common.WakeTimeoutDefault
}
if cont.StopTimeout == "" {
cont.StopTimeout = common.StopTimeoutDefault
}
if cont.StopMethod == "" {
cont.StopMethod = common.StopMethodDefault
}
}
e.Port = joinPorts(lp, pp, extra)
if e.Port == "" || e.Host == "" {
if lp != "" {
e.Port = lp + ":0"
} else {
e.Port = "0"
}
}
}
func (e *RawEntry) splitPorts() (lp string, pp string, extra string) {
portSplit := strings.Split(e.Port, ":")
if len(portSplit) == 1 {
pp = portSplit[0]
} else {
lp = portSplit[0]
pp = portSplit[1]
}
if len(portSplit) > 2 {
extra = strings.Join(portSplit[2:], ":")
}
return
}
func joinPorts(lp string, pp string, extra string) string {
s := make([]string, 0, 3)
if lp != "" {
s = append(s, lp)
}
if pp != "" {
s = append(s, pp)
}
if extra != "" {
s = append(s, extra)
}
return strings.Join(s, ":")
}
func lowestPort(ports map[string]types.Port) string {
var cmp uint16
var res string
for port, v := range ports {
if v.PrivatePort < cmp || cmp == 0 {
cmp = v.PrivatePort
res = port
}
}
return res
}

View File

@@ -0,0 +1,98 @@
package entry
import (
"fmt"
"net/url"
"github.com/yusing/go-proxy/internal/docker"
idlewatcher "github.com/yusing/go-proxy/internal/docker/idlewatcher/config"
E "github.com/yusing/go-proxy/internal/error"
"github.com/yusing/go-proxy/internal/net/http/loadbalancer"
net "github.com/yusing/go-proxy/internal/net/types"
"github.com/yusing/go-proxy/internal/proxy/fields"
"github.com/yusing/go-proxy/internal/watcher/health"
)
type ReverseProxyEntry struct { // real model after validation
Raw *RawEntry `json:"raw"`
Alias fields.Alias `json:"alias,omitempty"`
Scheme fields.Scheme `json:"scheme,omitempty"`
URL net.URL `json:"url,omitempty"`
NoTLSVerify bool `json:"no_tls_verify,omitempty"`
PathPatterns fields.PathPatterns `json:"path_patterns,omitempty"`
HealthCheck *health.HealthCheckConfig `json:"healthcheck,omitempty"`
LoadBalance *loadbalancer.Config `json:"load_balance,omitempty"`
Middlewares docker.NestedLabelMap `json:"middlewares,omitempty"`
/* Docker only */
Idlewatcher *idlewatcher.Config `json:"idlewatcher,omitempty"`
}
func (rp *ReverseProxyEntry) TargetName() string {
return string(rp.Alias)
}
func (rp *ReverseProxyEntry) TargetURL() net.URL {
return rp.URL
}
func (rp *ReverseProxyEntry) RawEntry() *RawEntry {
return rp.Raw
}
func (rp *ReverseProxyEntry) LoadBalanceConfig() *loadbalancer.Config {
return rp.LoadBalance
}
func (rp *ReverseProxyEntry) HealthCheckConfig() *health.HealthCheckConfig {
return rp.HealthCheck
}
func (rp *ReverseProxyEntry) IdlewatcherConfig() *idlewatcher.Config {
return rp.Idlewatcher
}
func validateRPEntry(m *RawEntry, s fields.Scheme, b E.Builder) *ReverseProxyEntry {
cont := m.Container
if cont == nil {
cont = docker.DummyContainer
}
lb := m.LoadBalance
if lb != nil && lb.Link == "" {
lb = nil
}
host, err := fields.ValidateHost(m.Host)
b.Add(err)
port, err := fields.ValidatePort(m.Port)
b.Add(err)
pathPatterns, err := fields.ValidatePathPatterns(m.PathPatterns)
b.Add(err)
url, err := E.Check(url.Parse(fmt.Sprintf("%s://%s:%d", s, host, port)))
b.Add(err)
idleWatcherCfg, err := idlewatcher.ValidateConfig(m.Container)
b.Add(err)
if err != nil {
return nil
}
return &ReverseProxyEntry{
Raw: m,
Alias: fields.NewAlias(m.Alias),
Scheme: s,
URL: net.NewURL(url),
NoTLSVerify: m.NoTLSVerify,
PathPatterns: pathPatterns,
HealthCheck: m.HealthCheck,
LoadBalance: lb,
Middlewares: m.Middlewares,
Idlewatcher: idleWatcherCfg,
}
}

View File

@@ -0,0 +1,89 @@
package entry
import (
"fmt"
"github.com/yusing/go-proxy/internal/docker"
idlewatcher "github.com/yusing/go-proxy/internal/docker/idlewatcher/config"
E "github.com/yusing/go-proxy/internal/error"
"github.com/yusing/go-proxy/internal/net/http/loadbalancer"
net "github.com/yusing/go-proxy/internal/net/types"
"github.com/yusing/go-proxy/internal/proxy/fields"
"github.com/yusing/go-proxy/internal/watcher/health"
)
type StreamEntry struct {
Raw *RawEntry `json:"raw"`
Alias fields.Alias `json:"alias,omitempty"`
Scheme fields.StreamScheme `json:"scheme,omitempty"`
URL net.URL `json:"url,omitempty"`
Host fields.Host `json:"host,omitempty"`
Port fields.StreamPort `json:"port,omitempty"`
HealthCheck *health.HealthCheckConfig `json:"healthcheck,omitempty"`
/* Docker only */
Idlewatcher *idlewatcher.Config `json:"idlewatcher,omitempty"`
}
func (s *StreamEntry) TargetName() string {
return string(s.Alias)
}
func (s *StreamEntry) TargetURL() net.URL {
return s.URL
}
func (s *StreamEntry) RawEntry() *RawEntry {
return s.Raw
}
func (s *StreamEntry) LoadBalanceConfig() *loadbalancer.Config {
// TODO: support stream load balance
return nil
}
func (s *StreamEntry) HealthCheckConfig() *health.HealthCheckConfig {
return s.HealthCheck
}
func (s *StreamEntry) IdlewatcherConfig() *idlewatcher.Config {
return s.Idlewatcher
}
func validateStreamEntry(m *RawEntry, b E.Builder) *StreamEntry {
cont := m.Container
if cont == nil {
cont = docker.DummyContainer
}
host, err := fields.ValidateHost(m.Host)
b.Add(err)
port, err := fields.ValidateStreamPort(m.Port)
b.Add(err)
scheme, err := fields.ValidateStreamScheme(m.Scheme)
b.Add(err)
url, err := E.Check(net.ParseURL(fmt.Sprintf("%s://%s:%d", scheme.ProxyScheme, m.Host, port.ProxyPort)))
b.Add(err)
idleWatcherCfg, err := idlewatcher.ValidateConfig(m.Container)
b.Add(err)
if b.HasError() {
return nil
}
return &StreamEntry{
Raw: m,
Alias: fields.NewAlias(m.Alias),
Scheme: *scheme,
URL: url,
Host: host,
Port: port,
HealthCheck: m.HealthCheck,
Idlewatcher: idleWatcherCfg,
}
}

View File

@@ -1,17 +0,0 @@
package fields
import (
E "github.com/yusing/go-proxy/internal/error"
)
type Signal string
func ValidateSignal(s string) (Signal, E.NestedError) {
switch s {
case "", "SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT",
"INT", "TERM", "HUP", "QUIT":
return Signal(s), nil
}
return "", E.Invalid("signal", s)
}

View File

@@ -1,23 +0,0 @@
package fields
import (
E "github.com/yusing/go-proxy/internal/error"
)
type StopMethod string
const (
StopMethodPause StopMethod = "pause"
StopMethodStop StopMethod = "stop"
StopMethodKill StopMethod = "kill"
)
func ValidateStopMethod(s string) (StopMethod, E.NestedError) {
sm := StopMethod(s)
switch sm {
case StopMethodPause, StopMethodStop, StopMethodKill:
return sm, nil
default:
return "", E.Invalid("stop_method", sm)
}
}

View File

@@ -1,18 +0,0 @@
package fields
import (
"time"
E "github.com/yusing/go-proxy/internal/error"
)
func ValidateDurationPostitive(value string) (time.Duration, E.NestedError) {
d, err := time.ParseDuration(value)
if err != nil {
return 0, E.Invalid("duration", value)
}
if d < 0 {
return 0, E.Invalid("duration", "negative value")
}
return d, nil
}

View File

@@ -1,248 +0,0 @@
package provider
import (
"regexp"
"strconv"
"strings"
"github.com/docker/docker/client"
"github.com/sirupsen/logrus"
"github.com/yusing/go-proxy/internal/common"
D "github.com/yusing/go-proxy/internal/docker"
E "github.com/yusing/go-proxy/internal/error"
R "github.com/yusing/go-proxy/internal/route"
"github.com/yusing/go-proxy/internal/types"
W "github.com/yusing/go-proxy/internal/watcher"
"github.com/yusing/go-proxy/internal/watcher/events"
)
type DockerProvider struct {
name, dockerHost string
ExplicitOnly bool
}
var (
AliasRefRegex = regexp.MustCompile(`#\d+`)
AliasRefRegexOld = regexp.MustCompile(`\$\d+`)
)
func DockerProviderImpl(name, dockerHost string, explicitOnly bool) (ProviderImpl, E.NestedError) {
if dockerHost == common.DockerHostFromEnv {
dockerHost = common.GetEnv("DOCKER_HOST", client.DefaultDockerHost)
}
return &DockerProvider{name, dockerHost, explicitOnly}, nil
}
func (p *DockerProvider) String() string {
return "docker: " + p.name
}
func (p *DockerProvider) NewWatcher() W.Watcher {
return W.NewDockerWatcher(p.dockerHost)
}
func (p *DockerProvider) LoadRoutesImpl() (routes R.Routes, err E.NestedError) {
routes = R.NewRoutes()
entries := types.NewProxyEntries()
info, err := D.GetClientInfo(p.dockerHost, true)
if err != nil {
return routes, E.FailWith("connect to docker", err)
}
errors := E.NewBuilder("errors in docker labels")
for _, c := range info.Containers {
container := D.FromDocker(&c, p.dockerHost)
if container.IsExcluded {
continue
}
newEntries, err := p.entriesFromContainerLabels(container)
if err != nil {
errors.Add(err)
}
// although err is not nil
// there may be some valid entries in `en`
dups := entries.MergeFrom(newEntries)
// add the duplicate proxy entries to the error
dups.RangeAll(func(k string, v *types.RawEntry) {
errors.Addf("duplicate alias %s", k)
})
}
entries.RangeAll(func(_ string, e *types.RawEntry) {
e.Container.DockerHost = p.dockerHost
})
routes, err = R.FromEntries(entries)
errors.Add(err)
return routes, errors.Build()
}
func (p *DockerProvider) shouldIgnore(container *D.Container) bool {
return container.IsExcluded ||
!container.IsExplicit && p.ExplicitOnly ||
!container.IsExplicit && container.IsDatabase ||
strings.HasSuffix(container.ContainerName, "-old")
}
func (p *DockerProvider) OnEvent(event W.Event, oldRoutes R.Routes) (res EventResult) {
b := E.NewBuilder("event %s error", event)
defer b.To(&res.err)
matches := R.NewRoutes()
oldRoutes.RangeAllParallel(func(k string, v *R.Route) {
if v.Entry.Container.ContainerID == event.ActorID ||
v.Entry.Container.ContainerName == event.ActorName {
matches.Store(k, v)
}
})
//FIXME: docker event die stuck
var newRoutes R.Routes
var err E.NestedError
switch {
// id & container name changed
case matches.Size() == 0:
matches = oldRoutes
newRoutes, err = p.LoadRoutesImpl()
b.Add(err)
case event.Action == events.ActionContainerDestroy:
// stop all old routes
matches.RangeAllParallel(func(_ string, v *R.Route) {
oldRoutes.Delete(v.Entry.Alias)
b.Add(v.Stop())
res.nRemoved++
})
return
default:
cont, err := D.Inspect(p.dockerHost, event.ActorID)
if err != nil {
b.Add(E.FailWith("inspect container", err))
return
}
if p.shouldIgnore(cont) {
// stop all old routes
matches.RangeAllParallel(func(_ string, v *R.Route) {
b.Add(v.Stop())
res.nRemoved++
})
return
}
entries, err := p.entriesFromContainerLabels(cont)
b.Add(err)
newRoutes, err = R.FromEntries(entries)
b.Add(err)
}
matches.RangeAll(func(k string, v *R.Route) {
if !newRoutes.Has(k) && !oldRoutes.Has(k) {
b.Add(v.Stop())
matches.Delete(k)
res.nRemoved++
}
})
newRoutes.RangeAll(func(alias string, newRoute *R.Route) {
oldRoute, exists := oldRoutes.Load(alias)
if exists {
b.Add(oldRoute.Stop())
res.nReloaded++
} else {
res.nAdded++
}
b.Add(newRoute.Start())
oldRoutes.Store(alias, newRoute)
})
return
}
// Returns a list of proxy entries for a container.
// Always non-nil.
func (p *DockerProvider) entriesFromContainerLabels(container *D.Container) (entries types.RawEntries, _ E.NestedError) {
entries = types.NewProxyEntries()
if p.shouldIgnore(container) {
return
}
// init entries map for all aliases
for _, a := range container.Aliases {
entries.Store(a, &types.RawEntry{
Alias: a,
Container: container,
})
}
errors := E.NewBuilder("failed to apply label")
for key, val := range container.Labels {
errors.Add(p.applyLabel(container, entries, key, val))
}
// remove all entries that failed to fill in missing fields
entries.RangeAll(func(_ string, re *types.RawEntry) {
re.FillMissingFields()
})
return entries, errors.Build().Subject(container.ContainerName)
}
func (p *DockerProvider) applyLabel(container *D.Container, entries types.RawEntries, key, val string) (res E.NestedError) {
b := E.NewBuilder("errors in label %s", key)
defer b.To(&res)
refErr := E.NewBuilder("errors in alias references")
replaceIndexRef := func(ref string) string {
index, err := strconv.Atoi(ref[1:])
if err != nil {
refErr.Add(E.Invalid("integer", ref))
return ref
}
if index < 1 || index > len(container.Aliases) {
refErr.Add(E.OutOfRange("index", ref))
return ref
}
return container.Aliases[index-1]
}
lbl, err := D.ParseLabel(key, val)
if err != nil {
b.Add(err.Subject(key))
}
if lbl.Namespace != D.NSProxy {
return
}
if lbl.Target == D.WildcardAlias {
// apply label for all aliases
entries.RangeAll(func(a string, e *types.RawEntry) {
if err = D.ApplyLabel(e, lbl); err != nil {
b.Add(err)
}
})
} else {
lbl.Target = AliasRefRegex.ReplaceAllStringFunc(lbl.Target, replaceIndexRef)
lbl.Target = AliasRefRegexOld.ReplaceAllStringFunc(lbl.Target, func(s string) string {
logrus.Warnf("%q should now be %q, old syntax will be removed in a future version", lbl, strings.ReplaceAll(lbl.String(), "$", "#"))
return replaceIndexRef(s)
})
if refErr.HasError() {
b.Add(refErr.Build())
return
}
config, ok := entries.Load(lbl.Target)
if !ok {
b.Add(E.NotExist("alias", lbl.Target))
return
}
if err = D.ApplyLabel(config, lbl); err != nil {
b.Add(err)
}
}
return
}

View File

@@ -1,370 +0,0 @@
package provider
import (
"strings"
"testing"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/client"
"github.com/yusing/go-proxy/internal/common"
D "github.com/yusing/go-proxy/internal/docker"
E "github.com/yusing/go-proxy/internal/error"
P "github.com/yusing/go-proxy/internal/proxy"
T "github.com/yusing/go-proxy/internal/proxy/fields"
. "github.com/yusing/go-proxy/internal/utils/testing"
)
var (
dummyNames = []string{"/a"}
p DockerProvider
)
func TestApplyLabelWildcard(t *testing.T) {
pathPatterns := `
- /
- POST /upload/{$}
- GET /static
`[1:]
pathPatternsExpect := []string{
"/",
"POST /upload/{$}",
"GET /static",
}
middlewaresExpect := D.NestedLabelMap{
"middleware1": {
"prop1": "value1",
"prop2": "value2",
},
"middleware2": {
"prop3": "value3",
"prop4": "value4",
},
}
var p DockerProvider
entries, err := p.entriesFromContainerLabels(D.FromDocker(&types.Container{
Names: dummyNames,
Labels: map[string]string{
D.LabelAliases: "a,b",
D.LabelIdleTimeout: common.IdleTimeoutDefault,
D.LabelStopMethod: common.StopMethodDefault,
D.LabelStopSignal: "SIGTERM",
D.LabelStopTimeout: common.StopTimeoutDefault,
D.LabelWakeTimeout: common.WakeTimeoutDefault,
"proxy.*.no_tls_verify": "true",
"proxy.*.scheme": "https",
"proxy.*.host": "app",
"proxy.*.port": "4567",
"proxy.a.no_tls_verify": "true",
"proxy.a.path_patterns": pathPatterns,
"proxy.a.middlewares.middleware1.prop1": "value1",
"proxy.a.middlewares.middleware1.prop2": "value2",
"proxy.a.middlewares.middleware2.prop3": "value3",
"proxy.a.middlewares.middleware2.prop4": "value4",
},
}, ""))
ExpectNoError(t, err.Error())
a, ok := entries.Load("a")
ExpectTrue(t, ok)
b, ok := entries.Load("b")
ExpectTrue(t, ok)
ExpectEqual(t, a.Scheme, "https")
ExpectEqual(t, b.Scheme, "https")
ExpectEqual(t, a.Host, "app")
ExpectEqual(t, b.Host, "app")
ExpectEqual(t, a.Port, "4567")
ExpectEqual(t, b.Port, "4567")
ExpectTrue(t, a.NoTLSVerify)
ExpectTrue(t, b.NoTLSVerify)
ExpectDeepEqual(t, a.PathPatterns, pathPatternsExpect)
ExpectEqual(t, len(b.PathPatterns), 0)
ExpectDeepEqual(t, a.Middlewares, middlewaresExpect)
ExpectEqual(t, len(b.Middlewares), 0)
ExpectEqual(t, a.Container.IdleTimeout, common.IdleTimeoutDefault)
ExpectEqual(t, b.Container.IdleTimeout, common.IdleTimeoutDefault)
ExpectEqual(t, a.Container.StopTimeout, common.StopTimeoutDefault)
ExpectEqual(t, b.Container.StopTimeout, common.StopTimeoutDefault)
ExpectEqual(t, a.Container.StopMethod, common.StopMethodDefault)
ExpectEqual(t, b.Container.StopMethod, common.StopMethodDefault)
ExpectEqual(t, a.Container.WakeTimeout, common.WakeTimeoutDefault)
ExpectEqual(t, b.Container.WakeTimeout, common.WakeTimeoutDefault)
ExpectEqual(t, a.Container.StopSignal, "SIGTERM")
ExpectEqual(t, b.Container.StopSignal, "SIGTERM")
}
func TestApplyLabelWithAlias(t *testing.T) {
entries, err := p.entriesFromContainerLabels(D.FromDocker(&types.Container{
Names: dummyNames,
Labels: map[string]string{
D.LabelAliases: "a,b,c",
"proxy.a.no_tls_verify": "true",
"proxy.a.port": "3333",
"proxy.b.port": "1234",
"proxy.c.scheme": "https",
},
}, ""))
a, ok := entries.Load("a")
ExpectTrue(t, ok)
b, ok := entries.Load("b")
ExpectTrue(t, ok)
c, ok := entries.Load("c")
ExpectTrue(t, ok)
ExpectNoError(t, err.Error())
ExpectEqual(t, a.Scheme, "http")
ExpectEqual(t, a.Port, "3333")
ExpectEqual(t, a.NoTLSVerify, true)
ExpectEqual(t, b.Scheme, "http")
ExpectEqual(t, b.Port, "1234")
ExpectEqual(t, c.Scheme, "https")
}
func TestApplyLabelWithRef(t *testing.T) {
entries := Must(p.entriesFromContainerLabels(D.FromDocker(&types.Container{
Names: dummyNames,
Labels: map[string]string{
D.LabelAliases: "a,b,c",
"proxy.#1.host": "localhost",
"proxy.#1.port": "4444",
"proxy.#2.port": "9999",
"proxy.#3.port": "1111",
"proxy.#3.scheme": "https",
},
}, "")))
a, ok := entries.Load("a")
ExpectTrue(t, ok)
b, ok := entries.Load("b")
ExpectTrue(t, ok)
c, ok := entries.Load("c")
ExpectTrue(t, ok)
ExpectEqual(t, a.Scheme, "http")
ExpectEqual(t, a.Host, "localhost")
ExpectEqual(t, a.Port, "4444")
ExpectEqual(t, b.Port, "9999")
ExpectEqual(t, c.Scheme, "https")
ExpectEqual(t, c.Port, "1111")
}
func TestApplyLabelWithRefIndexError(t *testing.T) {
c := D.FromDocker(&types.Container{
Names: dummyNames,
Labels: map[string]string{
D.LabelAliases: "a,b",
"proxy.#1.host": "localhost",
"proxy.#4.scheme": "https",
},
}, "")
_, err := p.entriesFromContainerLabels(c)
ExpectError(t, E.ErrOutOfRange, err.Error())
ExpectTrue(t, strings.Contains(err.String(), "index out of range"))
_, err = p.entriesFromContainerLabels(D.FromDocker(&types.Container{
Names: dummyNames,
Labels: map[string]string{
D.LabelAliases: "a,b",
"proxy.#0.host": "localhost",
},
}, ""))
ExpectError(t, E.ErrOutOfRange, err.Error())
ExpectTrue(t, strings.Contains(err.String(), "index out of range"))
}
func TestPublicIPLocalhost(t *testing.T) {
c := D.FromDocker(&types.Container{Names: dummyNames}, client.DefaultDockerHost)
raw, ok := Must(p.entriesFromContainerLabels(c)).Load("a")
ExpectTrue(t, ok)
ExpectEqual(t, raw.Container.PublicIP, "127.0.0.1")
ExpectEqual(t, raw.Host, raw.Container.PublicIP)
}
func TestPublicIPRemote(t *testing.T) {
c := D.FromDocker(&types.Container{Names: dummyNames}, "tcp://1.2.3.4:2375")
raw, ok := Must(p.entriesFromContainerLabels(c)).Load("a")
ExpectTrue(t, ok)
ExpectEqual(t, raw.Container.PublicIP, "1.2.3.4")
ExpectEqual(t, raw.Host, raw.Container.PublicIP)
}
func TestPrivateIPLocalhost(t *testing.T) {
c := D.FromDocker(&types.Container{
Names: dummyNames,
NetworkSettings: &types.SummaryNetworkSettings{
Networks: map[string]*network.EndpointSettings{
"network": {
IPAddress: "172.17.0.123",
},
},
},
}, client.DefaultDockerHost)
raw, ok := Must(p.entriesFromContainerLabels(c)).Load("a")
ExpectTrue(t, ok)
ExpectEqual(t, raw.Container.PrivateIP, "172.17.0.123")
ExpectEqual(t, raw.Host, raw.Container.PrivateIP)
}
func TestPrivateIPRemote(t *testing.T) {
c := D.FromDocker(&types.Container{
Names: dummyNames,
NetworkSettings: &types.SummaryNetworkSettings{
Networks: map[string]*network.EndpointSettings{
"network": {
IPAddress: "172.17.0.123",
},
},
},
}, "tcp://1.2.3.4:2375")
raw, ok := Must(p.entriesFromContainerLabels(c)).Load("a")
ExpectTrue(t, ok)
ExpectEqual(t, raw.Container.PrivateIP, "")
ExpectEqual(t, raw.Container.PublicIP, "1.2.3.4")
ExpectEqual(t, raw.Host, raw.Container.PublicIP)
}
func TestStreamDefaultValues(t *testing.T) {
privPort := uint16(1234)
pubPort := uint16(4567)
privIP := "172.17.0.123"
cont := &types.Container{
Names: []string{"a"},
NetworkSettings: &types.SummaryNetworkSettings{
Networks: map[string]*network.EndpointSettings{
"network": {
IPAddress: privIP,
},
},
},
Ports: []types.Port{
{Type: "udp", PrivatePort: privPort, PublicPort: pubPort},
},
}
t.Run("local", func(t *testing.T) {
c := D.FromDocker(cont, client.DefaultDockerHost)
raw, ok := Must(p.entriesFromContainerLabels(c)).Load("a")
ExpectTrue(t, ok)
entry := Must(P.ValidateEntry(raw))
a := ExpectType[*P.StreamEntry](t, entry)
ExpectEqual(t, a.Scheme.ListeningScheme, T.Scheme("udp"))
ExpectEqual(t, a.Scheme.ProxyScheme, T.Scheme("udp"))
ExpectEqual(t, a.Host, T.Host(privIP))
ExpectEqual(t, a.Port.ListeningPort, 0)
ExpectEqual(t, a.Port.ProxyPort, T.Port(privPort))
})
t.Run("remote", func(t *testing.T) {
c := D.FromDocker(cont, "tcp://1.2.3.4:2375")
raw, ok := Must(p.entriesFromContainerLabels(c)).Load("a")
ExpectTrue(t, ok)
entry := Must(P.ValidateEntry(raw))
a := ExpectType[*P.StreamEntry](t, entry)
ExpectEqual(t, a.Scheme.ListeningScheme, T.Scheme("udp"))
ExpectEqual(t, a.Scheme.ProxyScheme, T.Scheme("udp"))
ExpectEqual(t, a.Host, "1.2.3.4")
ExpectEqual(t, a.Port.ListeningPort, 0)
ExpectEqual(t, a.Port.ProxyPort, T.Port(pubPort))
})
}
func TestExplicitExclude(t *testing.T) {
_, ok := Must(p.entriesFromContainerLabels(D.FromDocker(&types.Container{
Names: dummyNames,
Labels: map[string]string{
D.LabelAliases: "a",
D.LabelExclude: "true",
"proxy.a.no_tls_verify": "true",
},
}, ""))).Load("a")
ExpectFalse(t, ok)
}
func TestImplicitExcludeDatabase(t *testing.T) {
t.Run("mount path detection", func(t *testing.T) {
_, ok := Must(p.entriesFromContainerLabels(D.FromDocker(&types.Container{
Names: dummyNames,
Mounts: []types.MountPoint{
{Source: "/data", Destination: "/var/lib/postgresql/data"},
},
}, ""))).Load("a")
ExpectFalse(t, ok)
})
t.Run("exposed port detection", func(t *testing.T) {
_, ok := Must(p.entriesFromContainerLabels(D.FromDocker(&types.Container{
Names: dummyNames,
Ports: []types.Port{
{Type: "tcp", PrivatePort: 5432, PublicPort: 5432},
},
}, ""))).Load("a")
ExpectFalse(t, ok)
})
}
// func TestImplicitExcludeNoExposedPort(t *testing.T) {
// var p DockerProvider
// entries, err := p.entriesFromContainerLabels(D.FromDocker(&types.Container{
// Image: "redis",
// Names: []string{"redis"},
// Ports: []types.Port{
// {Type: "tcp", PrivatePort: 6379, PublicPort: 0}, // not exposed
// },
// State: "running",
// }, ""))
// ExpectNoError(t, err.Error())
// _, ok := entries.Load("redis")
// ExpectFalse(t, ok)
// }
// func TestNotExcludeSpecifiedPort(t *testing.T) {
// var p DockerProvider
// entries, err := p.entriesFromContainerLabels(D.FromDocker(&types.Container{
// Image: "redis",
// Names: []string{"redis"},
// Ports: []types.Port{
// {Type: "tcp", PrivatePort: 6379, PublicPort: 0}, // not exposed
// },
// Labels: map[string]string{
// "proxy.redis.port": "6379:6379", // but specified in label
// },
// }, ""))
// ExpectNoError(t, err.Error())
// _, ok := entries.Load("redis")
// ExpectTrue(t, ok)
// }
// func TestNotExcludeNonExposedPortHostNetwork(t *testing.T) {
// var p DockerProvider
// cont := &types.Container{
// Image: "redis",
// Names: []string{"redis"},
// Ports: []types.Port{
// {Type: "tcp", PrivatePort: 6379, PublicPort: 0}, // not exposed
// },
// Labels: map[string]string{
// "proxy.redis.port": "6379:6379",
// },
// }
// cont.HostConfig.NetworkMode = "host"
// entries, err := p.entriesFromContainerLabels(D.FromDocker(cont, ""))
// ExpectNoError(t, err.Error())
// _, ok := entries.Load("redis")
// ExpectTrue(t, ok)
// }

View File

@@ -1,94 +0,0 @@
package provider
import (
"errors"
"os"
"path"
"github.com/yusing/go-proxy/internal/common"
E "github.com/yusing/go-proxy/internal/error"
R "github.com/yusing/go-proxy/internal/route"
"github.com/yusing/go-proxy/internal/types"
U "github.com/yusing/go-proxy/internal/utils"
W "github.com/yusing/go-proxy/internal/watcher"
)
type FileProvider struct {
fileName string
path string
}
func FileProviderImpl(filename string) (ProviderImpl, E.NestedError) {
impl := &FileProvider{
fileName: filename,
path: path.Join(common.ConfigBasePath, filename),
}
_, err := os.Stat(impl.path)
switch {
case err == nil:
return impl, nil
case errors.Is(err, os.ErrNotExist):
return nil, E.NotExist("file", impl.path)
default:
return nil, E.UnexpectedError(err)
}
}
func Validate(data []byte) E.NestedError {
return U.ValidateYaml(U.GetSchema(common.FileProviderSchemaPath), data)
}
func (p FileProvider) String() string {
return p.fileName
}
func (p FileProvider) OnEvent(event W.Event, routes R.Routes) (res EventResult) {
b := E.NewBuilder("event %s error", event)
defer b.To(&res.err)
newRoutes, err := p.LoadRoutesImpl()
if err != nil {
b.Add(err)
return
}
res.nRemoved = newRoutes.Size()
routes.RangeAllParallel(func(_ string, v *R.Route) {
b.Add(v.Stop())
})
routes.Clear()
newRoutes.RangeAllParallel(func(_ string, v *R.Route) {
b.Add(v.Start())
})
res.nAdded = newRoutes.Size()
routes.MergeFrom(newRoutes)
return
}
func (p *FileProvider) LoadRoutesImpl() (routes R.Routes, res E.NestedError) {
routes = R.NewRoutes()
b := E.NewBuilder("file %q validation failure", p.fileName)
defer b.To(&res)
entries := types.NewProxyEntries()
data, err := E.Check(os.ReadFile(p.path))
if err != nil {
b.Add(E.FailWith("read file", err))
return
}
if err = entries.UnmarshalFromYAML(data); err != nil {
b.Add(err)
return
}
return R.FromEntries(entries)
}
func (p *FileProvider) NewWatcher() W.Watcher {
return W.NewConfigFileWatcher(p.fileName)
}

View File

@@ -1,209 +0,0 @@
package provider
import (
"context"
"path"
"github.com/sirupsen/logrus"
"github.com/yusing/go-proxy/internal/common"
E "github.com/yusing/go-proxy/internal/error"
R "github.com/yusing/go-proxy/internal/route"
W "github.com/yusing/go-proxy/internal/watcher"
)
type (
Provider struct {
ProviderImpl `json:"-"`
name string
t ProviderType
routes R.Routes
watcher W.Watcher
watcherTask common.Task
watcherCancel context.CancelFunc
l *logrus.Entry
}
ProviderImpl interface {
NewWatcher() W.Watcher
// even returns error, routes must be non-nil
LoadRoutesImpl() (R.Routes, E.NestedError)
OnEvent(event W.Event, routes R.Routes) EventResult
String() string
}
ProviderType string
ProviderStats struct {
NumRPs int `json:"num_reverse_proxies"`
NumStreams int `json:"num_streams"`
Type ProviderType `json:"type"`
}
EventResult struct {
nAdded int
nRemoved int
nReloaded int
err E.NestedError
}
)
const (
ProviderTypeDocker ProviderType = "docker"
ProviderTypeFile ProviderType = "file"
)
func newProvider(name string, t ProviderType) *Provider {
p := &Provider{
name: name,
t: t,
routes: R.NewRoutes(),
}
p.l = logrus.WithField("provider", p)
return p
}
func NewFileProvider(filename string) (p *Provider, err E.NestedError) {
name := path.Base(filename)
if name == "" {
return nil, E.Invalid("file name", "empty")
}
p = newProvider(name, ProviderTypeFile)
p.ProviderImpl, err = FileProviderImpl(filename)
if err != nil {
return nil, err
}
p.watcher = p.NewWatcher()
return
}
func NewDockerProvider(name string, dockerHost string) (p *Provider, err E.NestedError) {
if name == "" {
return nil, E.Invalid("provider name", "empty")
}
p = newProvider(name, ProviderTypeDocker)
p.ProviderImpl, err = DockerProviderImpl(name, dockerHost, p.IsExplicitOnly())
if err != nil {
return nil, err
}
p.watcher = p.NewWatcher()
return
}
func (p *Provider) IsExplicitOnly() bool {
return p.name[len(p.name)-1] == '!'
}
func (p *Provider) GetName() string {
return p.name
}
func (p *Provider) GetType() ProviderType {
return p.t
}
// to work with json marshaller.
func (p *Provider) MarshalText() ([]byte, error) {
return []byte(p.String()), nil
}
func (p *Provider) StartAllRoutes() (res E.NestedError) {
errors := E.NewBuilder("errors starting routes")
defer errors.To(&res)
// start watcher no matter load success or not
go p.watchEvents()
p.routes.RangeAllParallel(func(alias string, r *R.Route) {
errors.Add(r.Start().Subject(r))
})
return
}
func (p *Provider) StopAllRoutes() (res E.NestedError) {
if p.watcherCancel != nil {
p.watcherCancel()
p.watcherCancel = nil
}
errors := E.NewBuilder("errors stopping routes")
defer errors.To(&res)
p.routes.RangeAllParallel(func(alias string, r *R.Route) {
errors.Add(r.Stop().Subject(r))
})
p.routes.Clear()
return
}
func (p *Provider) RangeRoutes(do func(string, *R.Route)) {
p.routes.RangeAll(do)
}
func (p *Provider) GetRoute(alias string) (*R.Route, bool) {
return p.routes.Load(alias)
}
func (p *Provider) LoadRoutes() E.NestedError {
var err E.NestedError
p.routes, err = p.LoadRoutesImpl()
if p.routes.Size() > 0 {
p.l.Infof("loaded %d routes", p.routes.Size())
return err
}
if err == nil {
return nil
}
return E.FailWith("loading routes", err)
}
func (p *Provider) Statistics() ProviderStats {
numRPs := 0
numStreams := 0
p.routes.RangeAll(func(_ string, r *R.Route) {
if !r.Started() {
return
}
switch r.Type {
case R.RouteTypeReverseProxy:
numRPs++
case R.RouteTypeStream:
numStreams++
}
})
return ProviderStats{
NumRPs: numRPs,
NumStreams: numStreams,
Type: p.t,
}
}
func (p *Provider) watchEvents() {
p.watcherTask, p.watcherCancel = common.NewTaskWithCancel("Watcher for provider %s", p.name)
defer p.watcherTask.Finished()
events, errs := p.watcher.Events(p.watcherTask.Context())
l := p.l.WithField("module", "watcher")
for {
select {
case <-p.watcherTask.Context().Done():
return
case event := <-events:
task := p.watcherTask.Subtask("%s event %s", event.Type, event)
l.Infof("%s event %q", event.Type, event)
res := p.OnEvent(event, p.routes)
task.Finished()
if res.nAdded+res.nRemoved+res.nReloaded > 0 {
l.Infof("| %d NEW | %d REMOVED | %d RELOADED |", res.nAdded, res.nRemoved, res.nReloaded)
}
if res.err != nil {
l.Error(res.err)
}
case err := <-errs:
if err == nil || err.Is(context.Canceled) {
continue
}
l.Errorf("watcher error: %s", err)
}
}
}