From a66977c76043fcff4a8f69c4b65988272d27c01f Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Wed, 12 Jan 2022 08:59:42 +0200 Subject: [PATCH 1/7] feat(server): stop if not associated --- agent.go | 3 +++ cmd/agent/main.go | 7 ++++++- crypto/ecdsa.go | 6 ++++++ http/server.go | 22 ++++++++++++++++++++++ 4 files changed, 37 insertions(+), 1 deletion(-) diff --git a/agent.go b/agent.go index b69900ce..029b7530 100644 --- a/agent.go +++ b/agent.go @@ -122,6 +122,7 @@ type ( // DigitalSignatureService is used to validate digital signatures. DigitalSignatureService interface { + IsAssociated() bool VerifySignature(signature, key string) (bool, error) } @@ -178,6 +179,8 @@ const ( DefaultAgentPort = "9001" // DefaultLogLevel is the default logging level. DefaultLogLevel = "INFO" + // DefaultAgentSecurityShutdown is the default time after which the API server will shutdown if not associated with a Portainer instance + DefaultAgentSecurityShutdown = "3d" // DefaultEdgeSecurityShutdown is the default time after which the Edge server will shutdown if no key is specified DefaultEdgeSecurityShutdown = 15 // DefaultEdgeServerAddr is the default address used by the Edge server. diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 1a471478..4b2f66fa 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -1,8 +1,11 @@ package main import ( + "errors" "fmt" "log" + gohttp "net/http" + "runtime" "time" "github.com/portainer/agent" @@ -232,10 +235,12 @@ func main() { } err = startAPIServer(config) - if err != nil { + if err != nil && !errors.Is(err, gohttp.ErrServerClosed) { log.Fatalf("[ERROR] [main,http] [message: Unable to start Agent API server] [error: %s]", err) } + runtime.Goexit() + // !API } diff --git a/crypto/ecdsa.go b/crypto/ecdsa.go index 2f37ca00..7ed3b616 100644 --- a/crypto/ecdsa.go +++ b/crypto/ecdsa.go @@ -26,6 +26,12 @@ func NewECDSAService(secret string) *ECDSAService { } } +// IsAssociated tells if the service is associated with a public key +// or if it's secured behind a secret +func (service *ECDSAService) IsAssociated() bool { + return service.publicKey != nil || service.secret != "" +} + // VerifySignature is used to verify a digital signature using a specified public // key. The public key specified as a parameter must be hexadecimal encoded. // The public key will be decoded and parsed as DER data. If the service is not diff --git a/http/server.go b/http/server.go index d79cf51f..f0de56e0 100644 --- a/http/server.go +++ b/http/server.go @@ -1,6 +1,7 @@ package http import ( + "context" "crypto/tls" "log" "net/http" @@ -134,5 +135,26 @@ func (server *APIServer) StartSecured() error { WriteTimeout: 30 * time.Minute, } + go func() { + agentSecurityShutdown := 10 + + timer1 := time.NewTimer(time.Duration(agentSecurityShutdown) * time.Second) + <-timer1.C + + if !server.signatureService.IsAssociated() { + log.Printf("[INFO] [main,http] [message: Shutting down API server as no client was associated after %d minutes , keeping alive to prevent restart by docker/kubernetes]", agentSecurityShutdown) + + err := httpServer.Shutdown(context.Background()) + if err != nil { + log.Fatalf("[ERROR] [server] [message: failed shutting down server] [error: %s]", err) + } + + // Keep alive + for { + time.Sleep(time.Second) + } + } + }() + return httpServer.ListenAndServeTLS(agent.TLSCertPath, agent.TLSKeyPath) } From 9e860a67298991f6d92ab437db192a109da19028 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Wed, 12 Jan 2022 09:46:17 +0200 Subject: [PATCH 2/7] refactor(agent): get duration from env var --- agent.go | 6 +++++- http/server.go | 7 +++---- os/options.go | 22 ++++++++++++++++++++++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/agent.go b/agent.go index 029b7530..c5ae915e 100644 --- a/agent.go +++ b/agent.go @@ -1,6 +1,9 @@ package agent -import "context" +import ( + "context" + "time" +) type ( // ClusterMember is the representation of an agent inside a cluster. @@ -55,6 +58,7 @@ type ( Options struct { AgentServerAddr string AgentServerPort string + AgentSecurityShutdown time.Duration ClusterAddress string HostManagementEnabled bool SharedSecret string diff --git a/http/server.go b/http/server.go index f0de56e0..f9717b58 100644 --- a/http/server.go +++ b/http/server.go @@ -136,13 +136,12 @@ func (server *APIServer) StartSecured() error { } go func() { - agentSecurityShutdown := 10 - - timer1 := time.NewTimer(time.Duration(agentSecurityShutdown) * time.Second) + securityShutdown := config.AgentOptions.AgentSecurityShutdown + timer1 := time.NewTimer(securityShutdown) <-timer1.C if !server.signatureService.IsAssociated() { - log.Printf("[INFO] [main,http] [message: Shutting down API server as no client was associated after %d minutes , keeping alive to prevent restart by docker/kubernetes]", agentSecurityShutdown) + log.Printf("[INFO] [main,http] [message: Shutting down API server as no client was associated after %s, keeping alive to prevent restart by docker/kubernetes]", securityShutdown) err := httpServer.Shutdown(context.Background()) if err != nil { diff --git a/os/options.go b/os/options.go index 18ded3fb..0eb35f15 100644 --- a/os/options.go +++ b/os/options.go @@ -15,6 +15,7 @@ const ( EnvKeyAgentPort = "AGENT_PORT" EnvKeyClusterAddr = "AGENT_CLUSTER_ADDR" EnvKeyAgentSecret = "AGENT_SECRET" + EnvKeyAgentSecurityShutdown = "AGENT_SECURITY_SHUTDOWN" EnvKeyCapHostManagement = "CAP_HOST_MANAGEMENT" EnvKeyEdge = "EDGE" EnvKeyEdgeKey = "EDGE_KEY" @@ -48,6 +49,13 @@ func (parser *EnvOptionParser) Options() (*agent.Options, error) { LogLevel: agent.DefaultLogLevel, } + agentSecurityShutdown, err := parseAgentSecurityShutdown() + if err != nil { + return nil, err + } + + options.AgentSecurityShutdown = agentSecurityShutdown + if os.Getenv(EnvKeyCapHostManagement) != "" { log.Println("[WARN] [os,options] [message: the CAP_HOST_MANAGEMENT environment variable is deprecated and will likely be removed in a future version of Portainer agent]") } @@ -113,3 +121,17 @@ func (parser *EnvOptionParser) Options() (*agent.Options, error) { return options, nil } + +func parseAgentSecurityShutdown() (time.Duration, error) { + agentSecurityShutdownStr := agent.DefaultAgentSecurityShutdown + if value := os.Getenv(EnvKeyAgentSecurityShutdown); value != "" { + agentSecurityShutdownStr = value + } + + duration, err := time.ParseDuration(agentSecurityShutdownStr) + if err != nil { + return time.Second, errors.New("invalid time duration format in " + EnvKeyAgentSecurityShutdown + " environment variable") + } + + return duration, nil +} From 9c49616041284117804a48f2d960a9ad17dd9d55 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Wed, 12 Jan 2022 09:47:10 +0200 Subject: [PATCH 3/7] chore(docs): document `AGENT_SECURITY_SHUTDOWN` --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 5d13f37d..df1255a3 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,8 @@ This mode will allow multiple instances of Portainer to connect to a single agen Note: Due to the fact that the agent will now decode and parse the public key associated to each request, this mode might be less performant than the default mode. +If `AGENT_SECRET` isn't supplied, the agent will turn off after 3 days if not associated to any Portainer instance. This duration can be changed by supplying `AGENT_SECURITY_SHUTDOWN` environment variable in the format "3d5h2s" + ## Deployment options The behavior of the agent can be tuned via a set of mandatory and optional options available as environment variables: @@ -234,6 +236,7 @@ we can leverage the internal Docker DNS to automatically join existing agents or * AGENT_HOST (*optional*): address on which the agent API will be exposed (default to `0.0.0.0`) * AGENT_PORT (*optional*): port on which the agent API will be exposed (default to `9001`) * AGENT_SECRET (*optional*): shared secret used in the signature verification process +* AGENT_SECURITY_SHUTDOWN (*optional*): the duration after which the agent will be shutdown if not associated or secured by `AGENT_SECRET`. (defaults to `3d`) * LOG_LEVEL (*optional*): defines the log output verbosity (default to `INFO`) * EDGE (*optional*): enable Edge mode. Disabled by default, set to `1` to enable it * EDGE_KEY (*optional*): specify an Edge key to use at startup From eeccfb94cddc278bfc1cbd728f669ad2268e118f Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Sun, 16 Jan 2022 09:09:39 +0200 Subject: [PATCH 4/7] fix(security): change default shutdown to 72h --- agent.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent.go b/agent.go index c5ae915e..d9c061e8 100644 --- a/agent.go +++ b/agent.go @@ -184,7 +184,7 @@ const ( // DefaultLogLevel is the default logging level. DefaultLogLevel = "INFO" // DefaultAgentSecurityShutdown is the default time after which the API server will shutdown if not associated with a Portainer instance - DefaultAgentSecurityShutdown = "3d" + DefaultAgentSecurityShutdown = "72h" // DefaultEdgeSecurityShutdown is the default time after which the Edge server will shutdown if no key is specified DefaultEdgeSecurityShutdown = 15 // DefaultEdgeServerAddr is the default address used by the Edge server. From 0a166972d7b0c36f7cbce124158f8b5f6171115c Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Mon, 17 Jan 2022 07:30:28 +0200 Subject: [PATCH 5/7] refactor(options): rename to AGENT_SECRET_TIMEOUT --- README.md | 4 ++-- os/options.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index df1255a3..4411a6e4 100644 --- a/README.md +++ b/README.md @@ -225,7 +225,7 @@ This mode will allow multiple instances of Portainer to connect to a single agen Note: Due to the fact that the agent will now decode and parse the public key associated to each request, this mode might be less performant than the default mode. -If `AGENT_SECRET` isn't supplied, the agent will turn off after 3 days if not associated to any Portainer instance. This duration can be changed by supplying `AGENT_SECURITY_SHUTDOWN` environment variable in the format "3d5h2s" +If `AGENT_SECRET` isn't supplied, the agent will turn off after 3 days if not associated to any Portainer instance. This duration can be changed by supplying `AGENT_SECRET_TIMEOUT` environment variable in the format "20h2s" (https://pkg.go.dev/time#ParseDuration) ## Deployment options @@ -236,7 +236,7 @@ we can leverage the internal Docker DNS to automatically join existing agents or * AGENT_HOST (*optional*): address on which the agent API will be exposed (default to `0.0.0.0`) * AGENT_PORT (*optional*): port on which the agent API will be exposed (default to `9001`) * AGENT_SECRET (*optional*): shared secret used in the signature verification process -* AGENT_SECURITY_SHUTDOWN (*optional*): the duration after which the agent will be shutdown if not associated or secured by `AGENT_SECRET`. (defaults to `3d`) +* AGENT_SECRET_TIMEOUT (*optional*): the duration after which the agent will be shutdown if not associated or secured by `AGENT_SECRET`. (defaults to `72h`) * LOG_LEVEL (*optional*): defines the log output verbosity (default to `INFO`) * EDGE (*optional*): enable Edge mode. Disabled by default, set to `1` to enable it * EDGE_KEY (*optional*): specify an Edge key to use at startup diff --git a/os/options.go b/os/options.go index 0eb35f15..299f0af2 100644 --- a/os/options.go +++ b/os/options.go @@ -15,7 +15,7 @@ const ( EnvKeyAgentPort = "AGENT_PORT" EnvKeyClusterAddr = "AGENT_CLUSTER_ADDR" EnvKeyAgentSecret = "AGENT_SECRET" - EnvKeyAgentSecurityShutdown = "AGENT_SECURITY_SHUTDOWN" + EnvKeyAgentSecurityShutdown = "AGENT_SECRET_TIMEOUT" EnvKeyCapHostManagement = "CAP_HOST_MANAGEMENT" EnvKeyEdge = "EDGE" EnvKeyEdgeKey = "EDGE_KEY" From 7b1e82389cd4b49b24d9136fa14760813b3c0106 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Mon, 17 Jan 2022 07:32:54 +0200 Subject: [PATCH 6/7] refactor(server): use sleep instead of timer --- http/server.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/http/server.go b/http/server.go index f9717b58..4a70a38b 100644 --- a/http/server.go +++ b/http/server.go @@ -137,8 +137,7 @@ func (server *APIServer) StartSecured() error { go func() { securityShutdown := config.AgentOptions.AgentSecurityShutdown - timer1 := time.NewTimer(securityShutdown) - <-timer1.C + time.Sleep(securityShutdown) if !server.signatureService.IsAssociated() { log.Printf("[INFO] [main,http] [message: Shutting down API server as no client was associated after %s, keeping alive to prevent restart by docker/kubernetes]", securityShutdown) From 336ea37910d3f7199db21d7d368b9cdc452d4c88 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Mon, 17 Jan 2022 11:45:00 +0200 Subject: [PATCH 7/7] fix(server): wait for signal to shutdown --- cmd/agent/main.go | 7 +++++-- http/server.go | 4 ---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 4b2f66fa..d3eb6323 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -5,7 +5,8 @@ import ( "fmt" "log" gohttp "net/http" - "runtime" + goos "os" + "os/signal" "time" "github.com/portainer/agent" @@ -239,7 +240,9 @@ func main() { log.Fatalf("[ERROR] [main,http] [message: Unable to start Agent API server] [error: %s]", err) } - runtime.Goexit() + sigs := make(chan goos.Signal, 1) + signal.Notify(sigs) + <-sigs // !API } diff --git a/http/server.go b/http/server.go index 4a70a38b..be29c80e 100644 --- a/http/server.go +++ b/http/server.go @@ -147,10 +147,6 @@ func (server *APIServer) StartSecured() error { log.Fatalf("[ERROR] [server] [message: failed shutting down server] [error: %s]", err) } - // Keep alive - for { - time.Sleep(time.Second) - } } }()