From c6a37cca8a1c7d80c477724a6cbba5f99e9fd9fd Mon Sep 17 00:00:00 2001 From: Peter Olds Date: Tue, 7 Jan 2025 18:01:02 -0800 Subject: [PATCH] feat: Add optional StartEndpoint support for idle watcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optionally allow a user to specify a “warm-up” endpoint to start the container, returning a 403 if the endpoint isn’t hit and the container has been stopped. This can help prevent bots from starting random containers, or allow health check systems to run some probes. --- internal/docker/container.go | 42 +++++++++-------- internal/docker/idlewatcher/types/config.go | 43 +++++++++++++---- .../docker/idlewatcher/types/config_test.go | 47 +++++++++++++++++++ internal/docker/idlewatcher/waker_http.go | 6 +++ internal/docker/labels.go | 15 +++--- 5 files changed, 116 insertions(+), 37 deletions(-) create mode 100644 internal/docker/idlewatcher/types/config_test.go diff --git a/internal/docker/container.go b/internal/docker/container.go index 129d289..b805d98 100644 --- a/internal/docker/container.go +++ b/internal/docker/container.go @@ -28,16 +28,17 @@ type ( PrivateIP string `json:"private_ip"` NetworkMode string `json:"network_mode"` - Aliases []string `json:"aliases"` - IsExcluded bool `json:"is_excluded"` - IsExplicit bool `json:"is_explicit"` - IsDatabase bool `json:"is_database"` - IdleTimeout string `json:"idle_timeout,omitempty"` - WakeTimeout string `json:"wake_timeout,omitempty"` - StopMethod string `json:"stop_method,omitempty"` - StopTimeout string `json:"stop_timeout,omitempty"` // stop_method = "stop" only - StopSignal string `json:"stop_signal,omitempty"` // stop_method = "stop" | "kill" only - Running bool `json:"running"` + Aliases []string `json:"aliases"` + IsExcluded bool `json:"is_excluded"` + IsExplicit bool `json:"is_explicit"` + IsDatabase bool `json:"is_database"` + IdleTimeout string `json:"idle_timeout,omitempty"` + WakeTimeout string `json:"wake_timeout,omitempty"` + StopMethod string `json:"stop_method,omitempty"` + StopTimeout string `json:"stop_timeout,omitempty"` // stop_method = "stop" only + StopSignal string `json:"stop_signal,omitempty"` // stop_method = "stop" | "kill" only + StartEndpoint string `json:"start_endpoint,omitempty"` + Running bool `json:"running"` } ) @@ -58,16 +59,17 @@ func FromDocker(c *types.Container, dockerHost string) (res *Container) { PrivatePortMapping: helper.getPrivatePortMapping(), NetworkMode: c.HostConfig.NetworkMode, - Aliases: helper.getAliases(), - IsExcluded: strutils.ParseBool(helper.getDeleteLabel(LabelExclude)), - IsExplicit: isExplicit, - IsDatabase: helper.isDatabase(), - IdleTimeout: helper.getDeleteLabel(LabelIdleTimeout), - WakeTimeout: helper.getDeleteLabel(LabelWakeTimeout), - StopMethod: helper.getDeleteLabel(LabelStopMethod), - StopTimeout: helper.getDeleteLabel(LabelStopTimeout), - StopSignal: helper.getDeleteLabel(LabelStopSignal), - Running: c.Status == "running" || c.State == "running", + Aliases: helper.getAliases(), + IsExcluded: strutils.ParseBool(helper.getDeleteLabel(LabelExclude)), + IsExplicit: isExplicit, + IsDatabase: helper.isDatabase(), + IdleTimeout: helper.getDeleteLabel(LabelIdleTimeout), + WakeTimeout: helper.getDeleteLabel(LabelWakeTimeout), + StopMethod: helper.getDeleteLabel(LabelStopMethod), + StopTimeout: helper.getDeleteLabel(LabelStopTimeout), + StopSignal: helper.getDeleteLabel(LabelStopSignal), + StartEndpoint: helper.getDeleteLabel(LabelStartEndpoint), + Running: c.Status == "running" || c.State == "running", } res.setPrivateIP(helper) res.setPublicIP() diff --git a/internal/docker/idlewatcher/types/config.go b/internal/docker/idlewatcher/types/config.go index cb8f491..a813cec 100644 --- a/internal/docker/idlewatcher/types/config.go +++ b/internal/docker/idlewatcher/types/config.go @@ -2,6 +2,8 @@ package types import ( "errors" + "net/url" + "strings" "time" "github.com/yusing/go-proxy/internal/docker" @@ -10,11 +12,12 @@ import ( type ( Config struct { - IdleTimeout time.Duration `json:"idle_timeout,omitempty"` - WakeTimeout time.Duration `json:"wake_timeout,omitempty"` - StopTimeout int `json:"stop_timeout,omitempty"` // docker api takes integer seconds for timeout argument - StopMethod StopMethod `json:"stop_method,omitempty"` - StopSignal Signal `json:"stop_signal,omitempty"` + IdleTimeout time.Duration `json:"idle_timeout,omitempty"` + WakeTimeout time.Duration `json:"wake_timeout,omitempty"` + StopTimeout int `json:"stop_timeout,omitempty"` // docker api takes integer seconds for timeout argument + StopMethod StopMethod `json:"stop_method,omitempty"` + StopSignal Signal `json:"stop_signal,omitempty"` + StartEndpoint string `json:"start_endpoint,omitempty"` // Optional path that must be hit to start container DockerHost string `json:"docker_host,omitempty"` ContainerName string `json:"container_name,omitempty"` @@ -58,17 +61,19 @@ func ValidateConfig(cont *docker.Container) (*Config, E.Error) { stopTimeout := E.Collect(errs, validateDurationPostitive, cont.StopTimeout) stopMethod := E.Collect(errs, validateStopMethod, cont.StopMethod) signal := E.Collect(errs, validateSignal, cont.StopSignal) + startEndpoint := E.Collect(errs, validateStartEndpoint, cont.StartEndpoint) if errs.HasError() { return nil, errs.Error() } return &Config{ - IdleTimeout: idleTimeout, - WakeTimeout: wakeTimeout, - StopTimeout: int(stopTimeout.Seconds()), - StopMethod: stopMethod, - StopSignal: signal, + IdleTimeout: idleTimeout, + WakeTimeout: wakeTimeout, + StopTimeout: int(stopTimeout.Seconds()), + StopMethod: stopMethod, + StopSignal: signal, + StartEndpoint: startEndpoint, DockerHost: cont.DockerHost, ContainerName: cont.ContainerName, @@ -104,3 +109,21 @@ func validateStopMethod(s string) (StopMethod, error) { return "", errors.New("invalid stop method " + s) } } + +func validateStartEndpoint(s string) (string, error) { + if s == "" { + return "", nil + } + // checks needed as of Go 1.6 because of change https://github.com/golang/go/commit/617c93ce740c3c3cc28cdd1a0d712be183d0b328#diff-6c2d018290e298803c0c9419d8739885L195 + // emulate browser and strip the '#' suffix prior to validation. see issue-#237 + if i := strings.Index(s, "#"); i > -1 { + s = s[:i] + } + if len(s) == 0 { + return "", errors.New("start endpoint must not be empty if defined") + } + if _, err := url.ParseRequestURI(s); err != nil { + return "", err + } + return s, nil +} diff --git a/internal/docker/idlewatcher/types/config_test.go b/internal/docker/idlewatcher/types/config_test.go new file mode 100644 index 0000000..730c0c7 --- /dev/null +++ b/internal/docker/idlewatcher/types/config_test.go @@ -0,0 +1,47 @@ +package types + +import ( + "testing" + + . "github.com/yusing/go-proxy/internal/utils/testing" +) + +func TestValidateStartEndpoint(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + }{ + { + name: "valid", + input: "/start", + wantErr: false, + }, + { + name: "invalid", + input: "../foo", + wantErr: true, + }, + { + name: "single fragment", + input: "#", + wantErr: true, + }, + { + name: "empty", + input: "", + wantErr: false, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + s, err := validateStartEndpoint(tc.input) + if err == nil { + ExpectEqual(t, s, tc.input) + } + if (err != nil) != tc.wantErr { + t.Errorf("validateStartEndpoint() error = %v, wantErr %t", err, tc.wantErr) + } + }) + } +} diff --git a/internal/docker/idlewatcher/waker_http.go b/internal/docker/idlewatcher/waker_http.go index 9f8085d..cb95f10 100644 --- a/internal/docker/idlewatcher/waker_http.go +++ b/internal/docker/idlewatcher/waker_http.go @@ -34,6 +34,12 @@ func (w *Watcher) wakeFromHTTP(rw http.ResponseWriter, r *http.Request) (shouldN return true } + // Check if start endpoint is configured and request path matches + if w.StartEndpoint != "" && r.URL.Path != w.StartEndpoint { + http.Error(rw, "Forbidden: Container can only be started via configured start endpoint", http.StatusForbidden) + return false + } + if r.Body != nil { defer r.Body.Close() } diff --git a/internal/docker/labels.go b/internal/docker/labels.go index 8c78e79..0a9e0a5 100644 --- a/internal/docker/labels.go +++ b/internal/docker/labels.go @@ -5,11 +5,12 @@ const ( NSProxy = "proxy" - LabelAliases = NSProxy + ".aliases" - LabelExclude = NSProxy + ".exclude" - LabelIdleTimeout = NSProxy + ".idle_timeout" - LabelWakeTimeout = NSProxy + ".wake_timeout" - LabelStopMethod = NSProxy + ".stop_method" - LabelStopTimeout = NSProxy + ".stop_timeout" - LabelStopSignal = NSProxy + ".stop_signal" + LabelAliases = NSProxy + ".aliases" + LabelExclude = NSProxy + ".exclude" + LabelIdleTimeout = NSProxy + ".idle_timeout" + LabelWakeTimeout = NSProxy + ".wake_timeout" + LabelStopMethod = NSProxy + ".stop_method" + LabelStopTimeout = NSProxy + ".stop_timeout" + LabelStopSignal = NSProxy + ".stop_signal" + LabelStartEndpoint = NSProxy + ".start_endpoint" )