diff --git a/api/cli/cli.go b/api/cli/cli.go index f04804cd3..96481b53e 100644 --- a/api/cli/cli.go +++ b/api/cli/cli.go @@ -47,6 +47,7 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) { HTTPDisabled: kingpin.Flag("http-disabled", "Serve portainer only on https").Default(defaultHTTPDisabled).Bool(), HTTPEnabled: kingpin.Flag("http-enabled", "Serve portainer on http").Default(defaultHTTPEnabled).Bool(), SSL: kingpin.Flag("ssl", "Secure Portainer instance using SSL (deprecated)").Default(defaultSSL).Bool(), + SSLCacert: kingpin.Flag("sslcacert", "Path to the SSL CA certificate used to validate the edge agent cert").String(), SSLCert: kingpin.Flag("sslcert", "Path to the SSL certificate used to secure the Portainer instance").String(), SSLKey: kingpin.Flag("sslkey", "Path to the SSL key used to secure the Portainer instance").String(), Rollback: kingpin.Flag("rollback", "Rollback the database store to the previous version").Bool(), diff --git a/api/cli/defaults.go b/api/cli/defaults.go index e7891cf3f..d91cfa7a8 100644 --- a/api/cli/defaults.go +++ b/api/cli/defaults.go @@ -18,6 +18,7 @@ const ( defaultHTTPDisabled = "false" defaultHTTPEnabled = "false" defaultSSL = "false" + defaultSSLCacertPath = "/certs/portainer-ca.crt" defaultSSLCertPath = "/certs/portainer.crt" defaultSSLKeyPath = "/certs/portainer.key" defaultBaseURL = "/" diff --git a/api/cli/defaults_windows.go b/api/cli/defaults_windows.go index 249082fb5..54271185b 100644 --- a/api/cli/defaults_windows.go +++ b/api/cli/defaults_windows.go @@ -15,6 +15,7 @@ const ( defaultHTTPDisabled = "false" defaultHTTPEnabled = "false" defaultSSL = "false" + defaultSSLCacertPath = "C:\\certs\\portainer-ca.crt" defaultSSLCertPath = "C:\\certs\\portainer.crt" defaultSSLKeyPath = "C:\\certs\\portainer.key" defaultSnapshotInterval = "5m" diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 588301c3a..245bf5a4b 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -208,7 +208,7 @@ func initGitService() portainer.GitService { return git.NewService() } -func initSSLService(addr, dataPath, certPath, keyPath string, fileService portainer.FileService, dataStore dataservices.DataStore, shutdownTrigger context.CancelFunc) (*ssl.Service, error) { +func initSSLService(addr, dataPath, certPath, keyPath, cacertPath string, fileService portainer.FileService, dataStore dataservices.DataStore, shutdownTrigger context.CancelFunc) (*ssl.Service, error) { slices := strings.Split(addr, ":") host := slices[0] if host == "" { @@ -217,7 +217,7 @@ func initSSLService(addr, dataPath, certPath, keyPath string, fileService portai sslService := ssl.NewService(fileService, dataStore, shutdownTrigger) - err := sslService.Init(host, certPath, keyPath) + err := sslService.Init(host, certPath, keyPath, cacertPath) if err != nil { return nil, err } @@ -568,7 +568,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { cryptoService := initCryptoService() digitalSignatureService := initDigitalSignatureService() - sslService, err := initSSLService(*flags.AddrHTTPS, *flags.Data, *flags.SSLCert, *flags.SSLKey, fileService, dataStore, shutdownTrigger) + sslService, err := initSSLService(*flags.AddrHTTPS, *flags.Data, *flags.SSLCert, *flags.SSLKey, *flags.SSLCacert, fileService, dataStore, shutdownTrigger) if err != nil { logrus.Fatal(err) } diff --git a/api/filesystem/filesystem.go b/api/filesystem/filesystem.go index 8e9226937..12b13b56c 100644 --- a/api/filesystem/filesystem.go +++ b/api/filesystem/filesystem.go @@ -60,6 +60,8 @@ const ( DefaultSSLCertFilename = "cert.pem" // DefaultSSLKeyFilename represents the default ssl key file name DefaultSSLKeyFilename = "key.pem" + // DefaultSSLCacertFilename represents the default CA ssl certificate file name for mTLS + DefaultSSLCacertFilename = "ca-cert.pem" ) // ErrUndefinedTLSFileType represents an error returned on undefined TLS file type @@ -161,7 +163,7 @@ func (service *Service) Copy(fromFilePath string, toFilePath string, deleteIfExi } if !exists { - return errors.New("File doesn't exist") + return errors.New(fmt.Sprintf("File (%s) doesn't exist", fromFilePath)) } finput, err := os.Open(fromFilePath) @@ -627,6 +629,25 @@ func (service *Service) CopySSLCertPair(certPath, keyPath string) (string, strin return defCertPath, defKeyPath, nil } +// GetDefaultSSLCacertsPath returns the ssl cacert path +func (service *Service) GetDefaultSSLCacertsPath() string { + cacertPath := JoinPaths(SSLCertPath, DefaultSSLCacertFilename) + + return service.wrapFileStore(cacertPath) +} + +// CopySSLCacert copies the specified cacert pem file +func (service *Service) CopySSLCacert(cacertPath string) (string, error) { + defCacertPath := service.GetDefaultSSLCacertsPath() + + err := service.Copy(cacertPath, defCacertPath, true) + if err != nil { + return "", err + } + + return defCacertPath, nil +} + // FileExists checks for the existence of the specified file. func FileExists(filePath string) (bool, error) { if _, err := os.Stat(filePath); err != nil { diff --git a/api/http/handler/endpoints/endpoint_status_inspect.go b/api/http/handler/endpoints/endpoint_status_inspect.go index afefd9f25..40aa0ff11 100644 --- a/api/http/handler/endpoints/endpoint_status_inspect.go +++ b/api/http/handler/endpoints/endpoint_status_inspect.go @@ -11,6 +11,7 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" + "github.com/sirupsen/logrus" ) type stackStatusResponse struct { @@ -70,6 +71,7 @@ func (handler *Handler) endpointStatusInspect(w http.ResponseWriter, r *http.Req endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) if handler.DataStore.IsErrObjectNotFound(err) { + logrus.WithError(err).WithField("env", endpointID).WithField("remote", r.RemoteAddr).Error("Unable to find an environment with the specified identifier inside the database") return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database", err} diff --git a/api/http/security/bouncer.go b/api/http/security/bouncer.go index 3b42d0414..895719866 100644 --- a/api/http/security/bouncer.go +++ b/api/http/security/bouncer.go @@ -14,6 +14,7 @@ import ( httperrors "github.com/portainer/portainer/api/http/errors" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/sirupsen/logrus" ) type ( @@ -145,6 +146,17 @@ func (bouncer *RequestBouncer) AuthorizedEndpointOperation(r *http.Request, endp // AuthorizedEdgeEndpointOperation verifies that the request was received from a valid Edge environment(endpoint) func (bouncer *RequestBouncer) AuthorizedEdgeEndpointOperation(r *http.Request, endpoint *portainer.Endpoint) error { + // TODO: if we're using "require cacert edge requests", reject any that are not signed by the cacert + // tls.RequireAndVerifyClientCert would be nice, but that would require the same certs for browser and api use + if len(r.TLS.PeerCertificates) > 0 { + logrus. + WithField("tls DNSNames", r.TLS.PeerCertificates[0].DNSNames). + WithField("tls Issuer", r.TLS.PeerCertificates[0].Issuer.String()). + // ATM, i'm thinking Subject CN= could be the default endpoint name + WithField("tls Subject", r.TLS.PeerCertificates[0].Subject.String()). + Debugf("TLS client request") + } + if endpoint.Type != portainer.EdgeAgentOnKubernetesEnvironment && endpoint.Type != portainer.EdgeAgentOnDockerEnvironment { agentApiProcessed.With(prometheus.Labels{"path": r.RequestURI, "permission": "edge_error"}).Inc() return errors.New("Invalid environment type") diff --git a/api/http/server.go b/api/http/server.go index b0ec93922..10714ed31 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -3,6 +3,7 @@ package http import ( "context" "crypto/tls" + "crypto/x509" "fmt" "log" "net/http" @@ -62,6 +63,7 @@ import ( "github.com/portainer/portainer/api/kubernetes/cli" "github.com/portainer/portainer/api/scheduler" stackdeployer "github.com/portainer/portainer/api/stacks" + "github.com/sirupsen/logrus" ) // Server implements the portainer.Server interface @@ -337,6 +339,17 @@ func (server *Server) Start() error { return server.SSLService.GetRawCertificate(), nil } + if caCert := server.SSLService.GetCacertificatePem(); len(caCert) > 0 { + logrus.Debugf("using CA certificate for %s", server.BindAddressHTTPS) + certPool := x509.NewCertPool() + certPool.AppendCertsFromPEM(caCert) + + httpsServer.TLSConfig.ClientCAs = certPool + // can't use tls.RequireAndVerifyClientCert, and this port is also used for the browser (though it would be a strong feature to allow the user to enable) + httpsServer.TLSConfig.ClientAuth = tls.VerifyClientCertIfGiven + httpsServer.TLSConfig.BuildNameToCertificate() + } + go shutdown(server.ShutdownCtx, httpsServer) return httpsServer.ListenAndServeTLS("", "") } diff --git a/api/internal/ssl/ssl.go b/api/internal/ssl/ssl.go index ffaa32137..e39a0be3b 100644 --- a/api/internal/ssl/ssl.go +++ b/api/internal/ssl/ssl.go @@ -3,6 +3,7 @@ package ssl import ( "context" "crypto/tls" + "io/ioutil" "log" "os" "time" @@ -31,7 +32,7 @@ func NewService(fileService portainer.FileService, dataStore dataservices.DataSt } // Init initializes the service -func (service *Service) Init(host, certPath, keyPath string) error { +func (service *Service) Init(host, certPath, keyPath, cacertPath string) error { pathSupplied := certPath != "" && keyPath != "" if pathSupplied { newCertPath, newKeyPath, err := service.fileService.CopySSLCertPair(certPath, keyPath) @@ -39,7 +40,19 @@ func (service *Service) Init(host, certPath, keyPath string) error { return errors.Wrap(err, "failed copying supplied certs") } - return service.cacheInfo(newCertPath, newKeyPath, false) + newCacertPath := "" + if cacertPath != "" { + newCacertPath, err = service.fileService.CopySSLCacert(cacertPath) + if err != nil { + return errors.Wrap(err, "failed copying supplied cacert") + } + } + + return service.cacheInfo(newCertPath, newKeyPath, newCacertPath, false) + } + if cacertPath != "" { + return errors.Errorf("supplying a CA cert path (%s) requires an SSL cert and key file", cacertPath) + } settings, err := service.GetSSLSettings() @@ -68,10 +81,24 @@ func (service *Service) Init(host, certPath, keyPath string) error { return errors.Wrap(err, "failed generating self signed certs") } - return service.cacheInfo(certPath, keyPath, true) + return service.cacheInfo(certPath, keyPath, "", true) } +// GetRawCertificate gets the raw certificate +func (service *Service) GetCacertificatePem() (pemData []byte) { + settings, _ := service.GetSSLSettings() + if settings.CacertPath == "" { + return pemData + } + caCert, err := ioutil.ReadFile(settings.CacertPath) + if err != nil { + log.Printf("reading ca cert: %s", err) + return pemData + } + return caCert +} + // GetRawCertificate gets the raw certificate func (service *Service) GetRawCertificate() *tls.Certificate { return service.rawCert @@ -98,7 +125,13 @@ func (service *Service) SetCertificates(certData, keyData []byte) error { return err } - service.cacheInfo(certPath, keyPath, false) + settings, err := service.dataStore.SSLSettings().Settings() + if err != nil { + return err + } + // Don't unset the settings.CacertPath when uploading a new cert from the UI + // TODO: should also add UI to update thecacert, or to disable it.. + service.cacheInfo(certPath, keyPath, settings.CacertPath, false) service.shutdownTrigger() @@ -127,6 +160,7 @@ func (service *Service) SetHTTPEnabled(httpEnabled bool) error { return nil } +//TODO: why is this being cached in memory? is it actually loaded more than once? func (service *Service) cacheCertificate(certPath, keyPath string) error { rawCert, err := tls.LoadX509KeyPair(certPath, keyPath) if err != nil { @@ -138,7 +172,7 @@ func (service *Service) cacheCertificate(certPath, keyPath string) error { return nil } -func (service *Service) cacheInfo(certPath, keyPath string, selfSigned bool) error { +func (service *Service) cacheInfo(certPath, keyPath, cacertPath string, selfSigned bool) error { err := service.cacheCertificate(certPath, keyPath) if err != nil { return err @@ -151,6 +185,7 @@ func (service *Service) cacheInfo(certPath, keyPath string, selfSigned bool) err settings.CertPath = certPath settings.KeyPath = keyPath + settings.CacertPath = cacertPath settings.SelfSigned = selfSigned err = service.dataStore.SSLSettings().UpdateSettings(settings) diff --git a/api/portainer.go b/api/portainer.go index eec5ef25f..0ec2e28bb 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -116,6 +116,7 @@ type ( HTTPDisabled *bool HTTPEnabled *bool SSL *bool + SSLCacert *string SSLCert *string SSLKey *string Rollback *bool @@ -837,6 +838,7 @@ type ( SSLSettings struct { CertPath string `json:"certPath"` KeyPath string `json:"keyPath"` + CacertPath string `json:"cacertPath"` SelfSigned bool `json:"selfSigned"` HTTPEnabled bool `json:"httpEnabled"` } @@ -1236,6 +1238,7 @@ type ( GetDefaultSSLCertsPath() (string, string) StoreSSLCertPair(cert, key []byte) (string, string, error) CopySSLCertPair(certPath, keyPath string) (string, string, error) + CopySSLCacert(cacertPath string) (string, error) StoreFDOProfileFileFromBytes(fdoProfileIdentifier string, data []byte) (string, error) } diff --git a/build/mtlscerts.sh b/build/mtlscerts.sh new file mode 100755 index 000000000..1b24e2639 --- /dev/null +++ b/build/mtlscerts.sh @@ -0,0 +1,50 @@ +#!/bin/bash + +# very much just copied from https://lemariva.com/blog/2019/12/portainer-managing-docker-engine-remotely +# production use should involve a real external certificate management system + +export HOST=portainer.p1.alho.st +export CERTDIR=~/.config/portainer/certs/ + +mkdir -p ${CERTDIR} +cd ${CERTDIR} +echo "Generating example mTLS certs into $(pwd)" + +if [[ ! -f "ca.pem" ]]; then + echo "Generate the CA Cert" + openssl genrsa -aes256 -out ca-key.pem 4096 + # enter a pass phrase to protect the ca-key + + openssl req -new -x509 -days 365 -key ca-key.pem -sha256 -out ca.pem +else + echo "ca.pem ca cert already exists" +fi + +if [[ ! -f "server-cert.pem" ]]; then + echo "Generate the Portainer server cert" + openssl genrsa -out server-key.pem 4096 + + openssl req -subj "/CN=$HOST" -sha256 -new -key server-key.pem -out server.csr + echo subjectAltName = DNS:$HOST,IP:10.0.0.200,IP:127.0.0.1 >> extfile.cnf + echo extendedKeyUsage = serverAuth >> extfile.cnf + + openssl x509 -req -days 365 -sha256 -in server.csr -CA ca.pem -CAkey ca-key.pem \ + -CAcreateserial -out server-cert.pem -extfile extfile.cnf +else + echo "server-cert.pem ca cert already exists" +fi + +if [[ ! -f "agent-cert.pem" ]]; then + echo "Generate an Agent cert" + openssl genrsa -out agent-key.pem 4096 + + openssl req -subj '/CN=client' -new -key agent-key.pem -out agent-client.csr + echo extendedKeyUsage = clientAuth > agent-extfile.cnf + + openssl x509 -req -days 365 -sha256 -in agent-client.csr -CA ca.pem -CAkey ca-key.pem \ + -CAcreateserial -out agent-cert.pem -extfile agent-extfile.cnf +else + echo "agent-cert.pem ca cert already exists" +fi + +echo "done: Generated example mTLS certs into $(pwd)"