diff --git a/api/bolt/datastore.go b/api/bolt/datastore.go index 09fcf89c7..c739aa9e6 100644 --- a/api/bolt/datastore.go +++ b/api/bolt/datastore.go @@ -25,6 +25,7 @@ import ( "github.com/portainer/portainer/api/bolt/role" "github.com/portainer/portainer/api/bolt/schedule" "github.com/portainer/portainer/api/bolt/settings" + "github.com/portainer/portainer/api/bolt/ssl" "github.com/portainer/portainer/api/bolt/stack" "github.com/portainer/portainer/api/bolt/tag" "github.com/portainer/portainer/api/bolt/team" @@ -61,6 +62,7 @@ type Store struct { RoleService *role.Service ScheduleService *schedule.Service SettingsService *settings.Service + SSLSettingsService *ssl.Service StackService *stack.Service TagService *tag.Service TeamMembershipService *teammembership.Service @@ -114,6 +116,7 @@ func (store *Store) Open() error { } // Close closes the BoltDB database. +// Safe to being called multiple times. func (store *Store) Close() error { if store.connection.DB != nil { return store.connection.Close() diff --git a/api/bolt/init.go b/api/bolt/init.go index a91a6b2e6..9209cfc3a 100644 --- a/api/bolt/init.go +++ b/api/bolt/init.go @@ -55,6 +55,22 @@ func (store *Store) Init() error { return err } + _, err = store.SSLSettings().Settings() + if err != nil { + if err != errors.ErrObjectNotFound { + return err + } + + defaultSSLSettings := &portainer.SSLSettings{ + HTTPEnabled: true, + } + + err = store.SSLSettings().UpdateSettings(defaultSSLSettings) + if err != nil { + return err + } + } + groups, err := store.EndpointGroupService.EndpointGroups() if err != nil { return err diff --git a/api/bolt/services.go b/api/bolt/services.go index ec4c8ecc6..54b1bd9ce 100644 --- a/api/bolt/services.go +++ b/api/bolt/services.go @@ -16,6 +16,7 @@ import ( "github.com/portainer/portainer/api/bolt/role" "github.com/portainer/portainer/api/bolt/schedule" "github.com/portainer/portainer/api/bolt/settings" + "github.com/portainer/portainer/api/bolt/ssl" "github.com/portainer/portainer/api/bolt/stack" "github.com/portainer/portainer/api/bolt/tag" "github.com/portainer/portainer/api/bolt/team" @@ -105,6 +106,12 @@ func (store *Store) initServices() error { } store.SettingsService = settingsService + sslSettingsService, err := ssl.NewService(store.connection) + if err != nil { + return err + } + store.SSLSettingsService = sslSettingsService + stackService, err := stack.NewService(store.connection) if err != nil { return err @@ -217,6 +224,11 @@ func (store *Store) Settings() portainer.SettingsService { return store.SettingsService } +// SSLSettings gives access to the SSL Settings data management layer +func (store *Store) SSLSettings() portainer.SSLSettingsService { + return store.SSLSettingsService +} + // Stack gives access to the Stack data management layer func (store *Store) Stack() portainer.StackService { return store.StackService diff --git a/api/bolt/ssl/ssl.go b/api/bolt/ssl/ssl.go new file mode 100644 index 000000000..c71c9234e --- /dev/null +++ b/api/bolt/ssl/ssl.go @@ -0,0 +1,46 @@ +package ssl + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/internal" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "ssl" + key = "SSL" +) + +// Service represents a service for managing ssl data. +type Service struct { + connection *internal.DbConnection +} + +// NewService creates a new instance of a service. +func NewService(connection *internal.DbConnection) (*Service, error) { + err := internal.CreateBucket(connection, BucketName) + if err != nil { + return nil, err + } + + return &Service{ + connection: connection, + }, nil +} + +// Settings retrieve the ssl settings object. +func (service *Service) Settings() (*portainer.SSLSettings, error) { + var settings portainer.SSLSettings + + err := internal.GetObject(service.connection, BucketName, []byte(key), &settings) + if err != nil { + return nil, err + } + + return &settings, nil +} + +// UpdateSettings persists a SSLSettings object. +func (service *Service) UpdateSettings(settings *portainer.SSLSettings) error { + return internal.UpdateObject(service.connection, BucketName, []byte(key), settings) +} diff --git a/api/cli/cli.go b/api/cli/cli.go index 3a85b7676..1da82bf63 100644 --- a/api/cli/cli.go +++ b/api/cli/cli.go @@ -30,6 +30,7 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) { flags := &portainer.CLIFlags{ Addr: kingpin.Flag("bind", "Address and port to serve Portainer").Default(defaultBindAddress).Short('p').String(), + AddrHTTPS: kingpin.Flag("bind-https", "Address and port to serve Portainer via https").Default(defaultHTTPSBindAddress).String(), TunnelAddr: kingpin.Flag("tunnel-addr", "Address to serve the tunnel server").Default(defaultTunnelServerAddress).String(), TunnelPort: kingpin.Flag("tunnel-port", "Port to serve the tunnel server").Default(defaultTunnelServerPort).String(), Assets: kingpin.Flag("assets", "Path to the assets").Default(defaultAssetsDirectory).Short('a').String(), @@ -42,9 +43,10 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) { TLSCacert: kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).String(), TLSCert: kingpin.Flag("tlscert", "Path to the TLS certificate file").Default(defaultTLSCertPath).String(), TLSKey: kingpin.Flag("tlskey", "Path to the TLS key").Default(defaultTLSKeyPath).String(), - SSL: kingpin.Flag("ssl", "Secure Portainer instance using SSL").Default(defaultSSL).Bool(), - SSLCert: kingpin.Flag("sslcert", "Path to the SSL certificate used to secure the Portainer instance").Default(defaultSSLCertPath).String(), - SSLKey: kingpin.Flag("sslkey", "Path to the SSL key used to secure the Portainer instance").Default(defaultSSLKeyPath).String(), + HTTPDisabled: kingpin.Flag("http-disabled", "Serve portainer only on https").Default(defaultHTTPDisabled).Bool(), + SSL: kingpin.Flag("ssl", "Secure Portainer instance using SSL (deprecated)").Default(defaultSSL).Bool(), + 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(), SnapshotInterval: kingpin.Flag("snapshot-interval", "Duration between each endpoint snapshot job").Default(defaultSnapshotInterval).String(), AdminPassword: kingpin.Flag("admin-password", "Hashed admin password").String(), AdminPasswordFile: kingpin.Flag("admin-password-file", "Path to the file containing the password for the admin user").String(), @@ -92,6 +94,10 @@ func displayDeprecationWarnings(flags *portainer.CLIFlags) { if *flags.NoAnalytics { log.Println("Warning: The --no-analytics flag has been kept to allow migration of instances running a previous version of Portainer with this flag enabled, to version 2.0 where enabling this flag will have no effect.") } + + if *flags.SSL { + log.Println("Warning: SSL is enabled by default and there is no need for the --ssl flag. It has been kept to allow migration of instances running a previous version of Portainer with this flag enabled") + } } func validateEndpointURL(endpointURL string) error { diff --git a/api/cli/defaults.go b/api/cli/defaults.go index e52240ebf..be27b4a93 100644 --- a/api/cli/defaults.go +++ b/api/cli/defaults.go @@ -4,6 +4,7 @@ package cli const ( defaultBindAddress = ":9000" + defaultHTTPSBindAddress = ":9443" defaultTunnelServerAddress = "0.0.0.0" defaultTunnelServerPort = "8000" defaultDataDirectory = "/data" @@ -13,6 +14,7 @@ const ( defaultTLSCACertPath = "/certs/ca.pem" defaultTLSCertPath = "/certs/cert.pem" defaultTLSKeyPath = "/certs/key.pem" + defaultHTTPDisabled = "false" defaultSSL = "false" defaultSSLCertPath = "/certs/portainer.crt" defaultSSLKeyPath = "/certs/portainer.key" diff --git a/api/cli/defaults_windows.go b/api/cli/defaults_windows.go index c7e10f685..89d77ed14 100644 --- a/api/cli/defaults_windows.go +++ b/api/cli/defaults_windows.go @@ -2,6 +2,7 @@ package cli const ( defaultBindAddress = ":9000" + defaultHTTPSBindAddress = ":9443" defaultTunnelServerAddress = "0.0.0.0" defaultTunnelServerPort = "8000" defaultDataDirectory = "C:\\data" @@ -11,6 +12,7 @@ const ( defaultTLSCACertPath = "C:\\certs\\ca.pem" defaultTLSCertPath = "C:\\certs\\cert.pem" defaultTLSKeyPath = "C:\\certs\\key.pem" + defaultHTTPDisabled = "false" defaultSSL = "false" defaultSSLCertPath = "C:\\certs\\portainer.crt" defaultSSLKeyPath = "C:\\certs\\portainer.key" diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 322a58245..1bc372976 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -24,6 +24,7 @@ import ( "github.com/portainer/portainer/api/internal/authorization" "github.com/portainer/portainer/api/internal/edge" "github.com/portainer/portainer/api/internal/snapshot" + "github.com/portainer/portainer/api/internal/ssl" "github.com/portainer/portainer/api/jwt" "github.com/portainer/portainer/api/kubernetes" kubecli "github.com/portainer/portainer/api/kubernetes/cli" @@ -54,7 +55,7 @@ func initFileService(dataStorePath string) portainer.FileService { return fileService } -func initDataStore(dataStorePath string, fileService portainer.FileService) portainer.DataStore { +func initDataStore(dataStorePath string, fileService portainer.FileService, shutdownCtx context.Context) portainer.DataStore { store, err := bolt.NewStore(dataStorePath, fileService) if err != nil { log.Fatalf("failed creating data store: %v", err) @@ -74,9 +75,16 @@ func initDataStore(dataStorePath string, fileService portainer.FileService) port if err != nil { log.Fatalf("failed migration: %v", err) } + + go shutdownDatastore(shutdownCtx, store) return store } +func shutdownDatastore(shutdownCtx context.Context, datastore portainer.DataStore) { + <-shutdownCtx.Done() + datastore.Close() +} + func initComposeStackManager(assetsPath string, dataStorePath string, reverseTunnelService portainer.ReverseTunnelService, proxyManager *proxy.Manager) portainer.ComposeStackManager { composeWrapper, err := exec.NewComposeStackManager(assetsPath, dataStorePath, proxyManager) if err != nil { @@ -136,6 +144,23 @@ func initGitService() portainer.GitService { return git.NewService() } +func initSSLService(addr, dataPath, certPath, keyPath string, fileService portainer.FileService, dataStore portainer.DataStore, shutdownTrigger context.CancelFunc) (*ssl.Service, error) { + slices := strings.Split(addr, ":") + host := slices[0] + if host == "" { + host = "0.0.0.0" + } + + sslService := ssl.NewService(fileService, dataStore, shutdownTrigger) + + err := sslService.Init(host, certPath, keyPath) + if err != nil { + return nil, err + } + + return sslService, nil +} + func initDockerClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService) *docker.ClientFactory { return docker.NewClientFactory(signatureService, reverseTunnelService) } @@ -182,7 +207,26 @@ func updateSettingsFromFlags(dataStore portainer.DataStore, flags *portainer.CLI settings.BlackListedLabels = *flags.Labels } - return dataStore.Settings().UpdateSettings(settings) + err = dataStore.Settings().UpdateSettings(settings) + if err != nil { + return err + } + + httpEnabled := !*flags.HTTPDisabled + + sslSettings, err := dataStore.SSLSettings().Settings() + if err != nil { + return err + } + + sslSettings.HTTPEnabled = httpEnabled + + err = dataStore.SSLSettings().UpdateSettings(sslSettings) + if err != nil { + return err + } + + return nil } func loadAndParseKeyPair(fileService portainer.FileService, signatureService portainer.DigitalSignatureService) error { @@ -354,7 +398,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { fileService := initFileService(*flags.Data) - dataStore := initDataStore(*flags.Data, fileService) + dataStore := initDataStore(*flags.Data, fileService, shutdownCtx) if err := dataStore.CheckCurrentEdition(); err != nil { log.Fatal(err) @@ -375,6 +419,11 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { digitalSignatureService := initDigitalSignatureService() + sslService, err := initSSLService(*flags.AddrHTTPS, *flags.Data, *flags.SSLCert, *flags.SSLKey, fileService, dataStore, shutdownTrigger) + if err != nil { + log.Fatal(err) + } + err = initKeyPair(fileService, digitalSignatureService) if err != nil { log.Fatalf("failed initializing key pai: %v", err) @@ -467,7 +516,12 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { err = reverseTunnelService.StartTunnelServer(*flags.TunnelAddr, *flags.TunnelPort, snapshotService) if err != nil { - log.Fatalf("failed starting license service: %s", err) + log.Fatalf("failed starting tunnel server: %s", err) + } + + sslSettings, err := dataStore.SSLSettings().Settings() + if err != nil { + log.Fatalf("failed to fetch ssl settings from DB") } return &http.Server{ @@ -475,6 +529,8 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { ReverseTunnelService: reverseTunnelService, Status: applicationStatus, BindAddress: *flags.Addr, + BindAddressHTTPS: *flags.AddrHTTPS, + HTTPEnabled: sslSettings.HTTPEnabled, AssetsPath: *flags.Assets, DataStore: dataStore, SwarmStackManager: swarmStackManager, @@ -490,9 +546,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { KubernetesTokenCacheManager: kubernetesTokenCacheManager, SignatureService: digitalSignatureService, SnapshotService: snapshotService, - SSL: *flags.SSL, - SSLCert: *flags.SSLCert, - SSLKey: *flags.SSLKey, + SSLService: sslService, DockerClientFactory: dockerClientFactory, KubernetesClientFactory: kubernetesClientFactory, ShutdownCtx: shutdownCtx, @@ -507,8 +561,8 @@ func main() { for { server := buildServer(flags) - log.Printf("Starting Portainer %s on %s\n", portainer.APIVersion, *flags.Addr) + log.Printf("[INFO] [cmd,main] Starting Portainer version %s\n", portainer.APIVersion) err := server.Start() - log.Printf("Http server exited: %s\n", err) + log.Printf("[INFO] [cmd,main] Http server exited: %s\n", err) } } diff --git a/api/filesystem/filesystem.go b/api/filesystem/filesystem.go index 0a0b6dbbd..416798ad7 100644 --- a/api/filesystem/filesystem.go +++ b/api/filesystem/filesystem.go @@ -50,6 +50,12 @@ const ( CustomTemplateStorePath = "custom_templates" // TempPath represent the subfolder where temporary files are saved TempPath = "tmp" + // SSLCertPath represents the default ssl certificates path + SSLCertPath = "certs" + // DefaultSSLCertFilename represents the default ssl certificate file name + DefaultSSLCertFilename = "cert.pem" + // DefaultSSLKeyFilename represents the default ssl key file name + DefaultSSLKeyFilename = "key.pem" ) // ErrUndefinedTLSFileType represents an error returned on undefined TLS file type @@ -74,6 +80,11 @@ func NewService(dataStorePath, fileStorePath string) (*Service, error) { return nil, err } + err = service.createDirectoryInStore(SSLCertPath) + if err != nil { + return nil, err + } + err = service.createDirectoryInStore(TLSStorePath) if err != nil { return nil, err @@ -108,6 +119,66 @@ func (service *Service) GetStackProjectPath(stackIdentifier string) string { return path.Join(service.fileStorePath, ComposeStorePath, stackIdentifier) } +// Copy copies the file on fromFilePath to toFilePath +// if toFilePath exists func will fail unless deleteIfExists is true +func (service *Service) Copy(fromFilePath string, toFilePath string, deleteIfExists bool) error { + exists, err := service.FileExists(fromFilePath) + if err != nil { + return err + } + + if !exists { + return errors.New("File doesn't exist") + } + + finput, err := os.Open(fromFilePath) + if err != nil { + return err + } + + defer finput.Close() + + exists, err = service.FileExists(toFilePath) + if err != nil { + return err + } + + if exists { + if !deleteIfExists { + return errors.New("Destination file exists") + } + + err := os.Remove(toFilePath) + if err != nil { + return err + } + } + + foutput, err := os.Create(toFilePath) + if err != nil { + return err + } + + defer foutput.Close() + + buf := make([]byte, 1024) + for { + n, err := finput.Read(buf) + if err != nil && err != io.EOF { + return err + } + if n == 0 { + break + } + + if _, err := foutput.Write(buf[:n]); err != nil { + return err + } + } + + return nil +} + // StoreStackFileFromBytes creates a subfolder in the ComposeStorePath and stores a new file from bytes. // It returns the path to the folder where the file is stored. func (service *Service) StoreStackFileFromBytes(stackIdentifier, fileName string, data []byte) (string, error) { @@ -507,6 +578,58 @@ func (service *Service) GetDatastorePath() string { return service.dataStorePath } +func (service *Service) wrapFileStore(filepath string) string { + return path.Join(service.fileStorePath, filepath) +} + +func defaultCertPathUnderFileStore() (string, string) { + certPath := path.Join(SSLCertPath, DefaultSSLCertFilename) + keyPath := path.Join(SSLCertPath, DefaultSSLKeyFilename) + return certPath, keyPath +} + +// GetDefaultSSLCertsPath returns the ssl certs path +func (service *Service) GetDefaultSSLCertsPath() (string, string) { + certPath, keyPath := defaultCertPathUnderFileStore() + return service.wrapFileStore(certPath), service.wrapFileStore(keyPath) +} + +// StoreSSLCertPair stores a ssl certificate pair +func (service *Service) StoreSSLCertPair(cert, key []byte) (string, string, error) { + certPath, keyPath := defaultCertPathUnderFileStore() + + r := bytes.NewReader(cert) + err := service.createFileInStore(certPath, r) + if err != nil { + return "", "", err + } + + r = bytes.NewReader(key) + err = service.createFileInStore(keyPath, r) + if err != nil { + return "", "", err + } + + return service.wrapFileStore(certPath), service.wrapFileStore(keyPath), nil +} + +// CopySSLCertPair copies a ssl certificate pair +func (service *Service) CopySSLCertPair(certPath, keyPath string) (string, string, error) { + defCertPath, defKeyPath := service.GetDefaultSSLCertsPath() + + err := service.Copy(certPath, defCertPath, false) + if err != nil { + return "", "", err + } + + err = service.Copy(keyPath, defKeyPath, false) + if err != nil { + return "", "", err + } + + return defCertPath, defKeyPath, 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/go.mod b/api/go.mod index cac0c358e..4859e6120 100644 --- a/api/go.mod +++ b/api/go.mod @@ -29,7 +29,7 @@ require ( github.com/pkg/errors v0.9.1 github.com/portainer/docker-compose-wrapper v0.0.0-20210527221011-0a1418224b92 github.com/portainer/libcompose v0.5.3 - github.com/portainer/libcrypto v0.0.0-20190723020515-23ebe86ab2c2 + github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a github.com/portainer/libhttp v0.0.0-20190806161843-ba068f58be33 github.com/sirupsen/logrus v1.8.1 github.com/stretchr/testify v1.7.0 diff --git a/api/go.sum b/api/go.sum index 7428864df..e966dc90a 100644 --- a/api/go.sum +++ b/api/go.sum @@ -245,8 +245,8 @@ github.com/portainer/docker-compose-wrapper v0.0.0-20210527221011-0a1418224b92 h github.com/portainer/docker-compose-wrapper v0.0.0-20210527221011-0a1418224b92/go.mod h1:PF2O2O4UNYWdtPcp6n/mIKpKk+f1jhFTezS8txbf+XM= github.com/portainer/libcompose v0.5.3 h1:tE4WcPuGvo+NKeDkDWpwNavNLZ5GHIJ4RvuZXsI9uI8= github.com/portainer/libcompose v0.5.3/go.mod h1:7SKd/ho69rRKHDFSDUwkbMcol2TMKU5OslDsajr8Ro8= -github.com/portainer/libcrypto v0.0.0-20190723020515-23ebe86ab2c2 h1:0PfgGLys9yHr4rtnirg0W0Cjvv6/DzxBIZk5sV59208= -github.com/portainer/libcrypto v0.0.0-20190723020515-23ebe86ab2c2/go.mod h1:/wIeGwJOMYc1JplE/OvYMO5korce39HddIfI8VKGyAM= +github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a h1:qY8TbocN75n5PDl16o0uVr5MevtM5IhdwSelXEd4nFM= +github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a/go.mod h1:n54EEIq+MM0NNtqLeCby8ljL+l275VpolXO0ibHegLE= github.com/portainer/libhttp v0.0.0-20190806161843-ba068f58be33 h1:H8HR2dHdBf8HANSkUyVw4o8+4tegGcd+zyKZ3e599II= github.com/portainer/libhttp v0.0.0-20190806161843-ba068f58be33/go.mod h1:Y2TfgviWI4rT2qaOTHr+hq6MdKIE5YjgQAu7qwptTV0= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index 1e65fae85..b924ad660 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -22,6 +22,7 @@ import ( "github.com/portainer/portainer/api/http/handler/resourcecontrols" "github.com/portainer/portainer/api/http/handler/roles" "github.com/portainer/portainer/api/http/handler/settings" + "github.com/portainer/portainer/api/http/handler/ssl" "github.com/portainer/portainer/api/http/handler/stacks" "github.com/portainer/portainer/api/http/handler/status" "github.com/portainer/portainer/api/http/handler/tags" @@ -54,6 +55,7 @@ type Handler struct { ResourceControlHandler *resourcecontrols.Handler RoleHandler *roles.Handler SettingsHandler *settings.Handler + SSLHandler *ssl.Handler StackHandler *stacks.Handler StatusHandler *status.Handler TagHandler *tags.Handler @@ -130,6 +132,8 @@ type Handler struct { // @tag.description Manage App Templates // @tag.name stacks // @tag.description Manage stacks +// @tag.name ssl +// @tag.description Manage ssl settings // @tag.name upload // @tag.description Upload files // @tag.name webhooks @@ -199,6 +203,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.StripPrefix("/api", h.UploadHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/users"): http.StripPrefix("/api", h.UserHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/ssl"): + http.StripPrefix("/api", h.SSLHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/teams"): http.StripPrefix("/api", h.TeamHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/team_memberships"): diff --git a/api/http/handler/ssl/handler.go b/api/http/handler/ssl/handler.go new file mode 100644 index 000000000..8a82f4995 --- /dev/null +++ b/api/http/handler/ssl/handler.go @@ -0,0 +1,29 @@ +package ssl + +import ( + "net/http" + + "github.com/gorilla/mux" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/ssl" +) + +// Handler is the HTTP handler used to handle MOTD operations. +type Handler struct { + *mux.Router + SSLService *ssl.Service +} + +// NewHandler returns a new Handler +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/ssl", + bouncer.AdminAccess(httperror.LoggerHandler(h.sslInspect))).Methods(http.MethodGet) + h.Handle("/ssl", + bouncer.AdminAccess(httperror.LoggerHandler(h.sslUpdate))).Methods(http.MethodPut) + + return h +} diff --git a/api/http/handler/ssl/ssl_inspect.go b/api/http/handler/ssl/ssl_inspect.go new file mode 100644 index 000000000..b41faa6c5 --- /dev/null +++ b/api/http/handler/ssl/ssl_inspect.go @@ -0,0 +1,29 @@ +package ssl + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/response" +) + +// @id SSLInspect +// @summary Inspect the ssl settings +// @description Retrieve the ssl settings. +// @description **Access policy**: administrator +// @tags ssl +// @security jwt +// @produce json +// @success 200 {object} portainer.SSLSettings "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied to access settings" +// @failure 500 "Server error" +// @router /ssl [get] +func (handler *Handler) sslInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + settings, err := handler.SSLService.GetSSLSettings() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Failed to fetch certificate info", err} + } + + return response.JSON(w, settings) +} diff --git a/api/http/handler/ssl/ssl_update.go b/api/http/handler/ssl/ssl_update.go new file mode 100644 index 000000000..34dd74171 --- /dev/null +++ b/api/http/handler/ssl/ssl_update.go @@ -0,0 +1,62 @@ +package ssl + +import ( + "errors" + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" +) + +type sslUpdatePayload struct { + Cert *string + Key *string + HTTPEnabled *bool +} + +func (payload *sslUpdatePayload) Validate(r *http.Request) error { + if (payload.Cert == nil || payload.Key == nil) && payload.Cert != payload.Key { + return errors.New("both certificate and key files should be provided") + } + + return nil +} + +// @id SSLUpdate +// @summary Update the ssl settings +// @description Update the ssl settings. +// @description **Access policy**: administrator +// @tags ssl +// @security jwt +// @accept json +// @produce json +// @param body body sslUpdatePayload true "SSL Settings" +// @success 204 "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied to access settings" +// @failure 500 "Server error" +// @router /ssl [put] +func (handler *Handler) sslUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload sslUpdatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + if payload.Cert != nil { + err = handler.SSLService.SetCertificates([]byte(*payload.Cert), []byte(*payload.Key)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Failed to save certificate", err} + } + } + + if payload.HTTPEnabled != nil { + err = handler.SSLService.SetHTTPEnabled(*payload.HTTPEnabled) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Failed to force https", err} + } + } + + return response.Empty(w) +} diff --git a/api/http/server.go b/api/http/server.go index 9d0f81225..13ab4dcda 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -2,6 +2,7 @@ package http import ( "context" + "crypto/tls" "fmt" "log" "net/http" @@ -31,6 +32,7 @@ import ( "github.com/portainer/portainer/api/http/handler/resourcecontrols" "github.com/portainer/portainer/api/http/handler/roles" "github.com/portainer/portainer/api/http/handler/settings" + sslhandler "github.com/portainer/portainer/api/http/handler/ssl" "github.com/portainer/portainer/api/http/handler/stacks" "github.com/portainer/portainer/api/http/handler/status" "github.com/portainer/portainer/api/http/handler/tags" @@ -46,13 +48,16 @@ import ( "github.com/portainer/portainer/api/http/proxy/factory/kubernetes" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/internal/ssl" "github.com/portainer/portainer/api/kubernetes/cli" ) // Server implements the portainer.Server interface type Server struct { - AuthorizationService *authorization.Service + AuthorizationService *authorization.Service BindAddress string + BindAddressHTTPS string + HTTPEnabled bool AssetsPath string Status *portainer.Status ReverseTunnelService portainer.ReverseTunnelService @@ -70,9 +75,7 @@ type Server struct { ProxyManager *proxy.Manager KubernetesTokenCacheManager *kubernetes.TokenCacheManager Handler *handler.Handler - SSL bool - SSLCert string - SSLKey string + SSLService *ssl.Service DockerClientFactory *docker.ClientFactory KubernetesClientFactory *cli.ClientFactory KubernetesDeployer portainer.KubernetesDeployer @@ -175,6 +178,9 @@ func (server *Server) Start() error { settingsHandler.LDAPService = server.LDAPService settingsHandler.SnapshotService = server.SnapshotService + var sslHandler = sslhandler.NewHandler(requestBouncer) + sslHandler.SSLService = server.SSLService + var stackHandler = stacks.NewHandler(requestBouncer) stackHandler.DataStore = server.DataStore stackHandler.DockerClientFactory = server.DockerClientFactory @@ -236,6 +242,7 @@ func (server *Server) Start() error { RegistryHandler: registryHandler, ResourceControlHandler: resourceControlHandler, SettingsHandler: settingsHandler, + SSLHandler: sslHandler, StatusHandler: statusHandler, StackHandler: stackHandler, TagHandler: tagHandler, @@ -248,31 +255,48 @@ func (server *Server) Start() error { WebhookHandler: webhookHandler, } - httpServer := &http.Server{ - Addr: server.BindAddress, - Handler: server.Handler, - } - httpServer.Handler = offlineGate.WaitingMiddleware(time.Minute, httpServer.Handler) + handler := offlineGate.WaitingMiddleware(time.Minute, server.Handler) - if server.SSL { - httpServer.TLSConfig = crypto.CreateServerTLSConfiguration() - return httpServer.ListenAndServeTLS(server.SSLCert, server.SSLKey) + if server.HTTPEnabled { + go func() { + log.Printf("[INFO] [http,server] [message: starting HTTP server on port %s]", server.BindAddress) + httpServer := &http.Server{ + Addr: server.BindAddress, + Handler: handler, + } + + go shutdown(server.ShutdownCtx, httpServer) + err := httpServer.ListenAndServe() + if err != nil && err != http.ErrServerClosed { + log.Printf("[ERROR] [message: http server failed] [error: %s]", err) + } + }() } - go server.shutdown(httpServer) + log.Printf("[INFO] [http,server] [message: starting HTTPS server on port %s]", server.BindAddressHTTPS) + httpsServer := &http.Server{ + Addr: server.BindAddressHTTPS, + Handler: handler, + } - return httpServer.ListenAndServe() + httpsServer.TLSConfig = crypto.CreateServerTLSConfiguration() + httpsServer.TLSConfig.GetCertificate = func(*tls.ClientHelloInfo) (*tls.Certificate, error) { + return server.SSLService.GetRawCertificate(), nil + } + + go shutdown(server.ShutdownCtx, httpsServer) + return httpsServer.ListenAndServeTLS("", "") } -func (server *Server) shutdown(httpServer *http.Server) { - <-server.ShutdownCtx.Done() +func shutdown(shutdownCtx context.Context, httpServer *http.Server) { + <-shutdownCtx.Done() - log.Println("[DEBUG] Shutting down http server") + log.Println("[DEBUG] [http,server] [message: shutting down http server]") shutdownTimeout, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() err := httpServer.Shutdown(shutdownTimeout) if err != nil { - fmt.Printf("Failed shutdown http server: %s \n", err) + fmt.Printf("[ERROR] [http,server] [message: failed shutdown http server] [error: %s]", err) } } diff --git a/api/internal/ssl/ssl.go b/api/internal/ssl/ssl.go new file mode 100644 index 000000000..7062858f2 --- /dev/null +++ b/api/internal/ssl/ssl.go @@ -0,0 +1,170 @@ +package ssl + +import ( + "context" + "crypto/tls" + "log" + "os" + "time" + + "github.com/pkg/errors" + "github.com/portainer/libcrypto" + portainer "github.com/portainer/portainer/api" +) + +// Service represents a service to manage SSL certificates +type Service struct { + fileService portainer.FileService + dataStore portainer.DataStore + rawCert *tls.Certificate + shutdownTrigger context.CancelFunc +} + +// NewService returns a pointer to a new Service +func NewService(fileService portainer.FileService, dataStore portainer.DataStore, shutdownTrigger context.CancelFunc) *Service { + return &Service{ + fileService: fileService, + dataStore: dataStore, + shutdownTrigger: shutdownTrigger, + } +} + +// Init initializes the service +func (service *Service) Init(host, certPath, keyPath string) error { + settings, err := service.GetSSLSettings() + if err != nil { + return errors.Wrap(err, "failed fetching ssl settings") + } + + // certificates already exist + if settings.CertPath != "" && settings.KeyPath != "" { + err := service.cacheCertificate(settings.CertPath, settings.KeyPath) + if err != nil && !os.IsNotExist(err) { + return err + } + + // continue if certs don't exist + if err == nil { + return nil + } + } + + pathSupplied := certPath != "" && keyPath != "" + if pathSupplied { + newCertPath, newKeyPath, err := service.fileService.CopySSLCertPair(certPath, keyPath) + if err != nil { + return errors.Wrap(err, "failed copying supplied certs") + } + + return service.cacheInfo(newCertPath, newKeyPath, false) + } + + // path not supplied and certificates doesn't exist - generate self signed + certPath, keyPath = service.fileService.GetDefaultSSLCertsPath() + + err = service.generateSelfSignedCertificates(host, certPath, keyPath) + if err != nil { + return errors.Wrap(err, "failed generating self signed certs") + } + + return service.cacheInfo(certPath, keyPath, true) + +} + +// GetRawCertificate gets the raw certificate +func (service *Service) GetRawCertificate() *tls.Certificate { + return service.rawCert +} + +// GetSSLSettings gets the certificate info +func (service *Service) GetSSLSettings() (*portainer.SSLSettings, error) { + return service.dataStore.SSLSettings().Settings() +} + +// SetCertificates sets the certificates +func (service *Service) SetCertificates(certData, keyData []byte) error { + if len(certData) == 0 || len(keyData) == 0 { + return errors.New("missing certificate files") + } + + _, err := tls.X509KeyPair(certData, keyData) + if err != nil { + return err + } + + certPath, keyPath, err := service.fileService.StoreSSLCertPair(certData, keyData) + if err != nil { + return err + } + + service.cacheInfo(certPath, keyPath, false) + + service.shutdownTrigger() + + return nil +} + +func (service *Service) SetHTTPEnabled(httpEnabled bool) error { + settings, err := service.dataStore.SSLSettings().Settings() + if err != nil { + return err + } + + if settings.HTTPEnabled == httpEnabled { + return nil + } + + settings.HTTPEnabled = httpEnabled + + err = service.dataStore.SSLSettings().UpdateSettings(settings) + if err != nil { + return err + } + + service.shutdownTrigger() + + return nil +} + +func (service *Service) cacheCertificate(certPath, keyPath string) error { + rawCert, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + return err + } + + service.rawCert = &rawCert + + return nil +} + +func (service *Service) cacheInfo(certPath, keyPath string, selfSigned bool) error { + err := service.cacheCertificate(certPath, keyPath) + if err != nil { + return err + } + + settings, err := service.dataStore.SSLSettings().Settings() + if err != nil { + return err + } + + settings.CertPath = certPath + settings.KeyPath = keyPath + settings.SelfSigned = selfSigned + + err = service.dataStore.SSLSettings().UpdateSettings(settings) + if err != nil { + return err + } + + return nil +} + +func (service *Service) generateSelfSignedCertificates(ip, certPath, keyPath string) error { + if ip == "" { + return errors.New("host can't be empty") + } + + log.Printf("[INFO] [internal,ssl] [message: no cert files found, generating self signed ssl certificates]") + return libcrypto.GenerateCertsForHost("localhost", ip, certPath, keyPath, time.Now().AddDate(5, 0, 0)) +} diff --git a/api/internal/testhelpers/datastore.go b/api/internal/testhelpers/datastore.go index 970184d86..635508d9b 100644 --- a/api/internal/testhelpers/datastore.go +++ b/api/internal/testhelpers/datastore.go @@ -17,6 +17,7 @@ type datastore struct { registry portainer.RegistryService resourceControl portainer.ResourceControlService role portainer.RoleService + sslSettings portainer.SSLSettingsService settings portainer.SettingsService stack portainer.StackService tag portainer.TagService @@ -47,6 +48,7 @@ func (d *datastore) Registry() portainer.RegistryService { retur func (d *datastore) ResourceControl() portainer.ResourceControlService { return d.resourceControl } func (d *datastore) Role() portainer.RoleService { return d.role } func (d *datastore) Settings() portainer.SettingsService { return d.settings } +func (d *datastore) SSLSettings() portainer.SSLSettingsService { return d.sslSettings } func (d *datastore) Stack() portainer.StackService { return d.stack } func (d *datastore) Tag() portainer.TagService { return d.tag } func (d *datastore) TeamMembership() portainer.TeamMembershipService { return d.teamMembership } diff --git a/api/portainer.go b/api/portainer.go index b12d574bb..ed3d61d15 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -44,6 +44,7 @@ type ( // CLIFlags represents the available flags on the CLI CLIFlags struct { Addr *string + AddrHTTPS *string TunnelAddr *string TunnelPort *string AdminPassword *string @@ -61,6 +62,7 @@ type ( TLSCacert *string TLSCert *string TLSKey *string + HTTPDisabled *bool SSL *bool SSLCert *string SSLKey *string @@ -704,6 +706,14 @@ type ( // SoftwareEdition represents an edition of Portainer SoftwareEdition int + // SSLSettings represents a pair of SSL certificate and key + SSLSettings struct { + CertPath string `json:"certPath"` + KeyPath string `json:"keyPath"` + SelfSigned bool `json:"selfSigned"` + HTTPEnabled bool `json:"httpEnabled"` + } + // Stack represents a Docker stack created via docker stack deploy Stack struct { // Stack Identifier @@ -1056,6 +1066,7 @@ type ( ResourceControl() ResourceControlService Role() RoleService Settings() SettingsService + SSLSettings() SSLSettingsService Stack() StackService Tag() TagService TeamMembership() TeamMembershipService @@ -1166,6 +1177,9 @@ type ( GetCustomTemplateProjectPath(identifier string) string GetTemporaryPath() (string, error) GetDatastorePath() string + GetDefaultSSLCertsPath() (string, string) + StoreSSLCertPair(cert, key []byte) (string, string, error) + CopySSLCertPair(certPath, keyPath string) (string, string, error) } // GitService represents a service for managing Git @@ -1271,6 +1285,12 @@ type ( Start() error } + // SSLSettingsService represents a service for managing application settings + SSLSettingsService interface { + Settings() (*SSLSettings, error) + UpdateSettings(settings *SSLSettings) error + } + // StackService represents a service for managing stack data StackService interface { Stack(ID StackID) (*Stack, error) diff --git a/app/portainer/__module.js b/app/portainer/__module.js index 2724c8d6d..d5a29f75e 100644 --- a/app/portainer/__module.js +++ b/app/portainer/__module.js @@ -1,6 +1,7 @@ import _ from 'lodash-es'; import componentsModule from './components'; +import settingsModule from './settings'; async function initAuthentication(authManager, Authentication, $rootScope, $state) { authManager.checkAuthOnRefresh(); @@ -17,7 +18,7 @@ async function initAuthentication(authManager, Authentication, $rootScope, $stat return await Authentication.init(); } -angular.module('portainer.app', ['portainer.oauth', componentsModule]).config([ +angular.module('portainer.app', ['portainer.oauth', componentsModule, settingsModule]).config([ '$stateRegistryProvider', function ($stateRegistryProvider) { 'use strict'; diff --git a/app/portainer/rest/ssl.js b/app/portainer/rest/ssl.js new file mode 100644 index 000000000..d70be50f8 --- /dev/null +++ b/app/portainer/rest/ssl.js @@ -0,0 +1,17 @@ +import angular from 'angular'; + +const API_ENDPOINT_SSL = 'api/ssl'; + +angular.module('portainer.app').factory('SSL', SSLFactory); + +/* @ngInject */ +function SSLFactory($resource) { + return $resource( + API_ENDPOINT_SSL, + {}, + { + get: { method: 'GET' }, + upload: { method: 'PUT' }, + } + ); +} diff --git a/app/portainer/services/api/sslService.js b/app/portainer/services/api/sslService.js new file mode 100644 index 000000000..ed3a20ae9 --- /dev/null +++ b/app/portainer/services/api/sslService.js @@ -0,0 +1,19 @@ +import angular from 'angular'; + +angular.module('portainer.app').service('SSLService', SSLServiceFactory); + +/* @ngInject */ +function SSLServiceFactory(SSL) { + return { + upload, + get, + }; + + function get() { + return SSL.get().$promise; + } + + function upload(httpEnabled, cert, key) { + return SSL.upload({ httpEnabled, cert, key }).$promise; + } +} diff --git a/app/portainer/settings/general/index.js b/app/portainer/settings/general/index.js new file mode 100644 index 000000000..ab9158702 --- /dev/null +++ b/app/portainer/settings/general/index.js @@ -0,0 +1,5 @@ +import angular from 'angular'; + +import { sslCertificate } from './ssl-certificate'; + +export default angular.module('portainer.settings.general', []).component('sslCertificateSettings', sslCertificate).name; diff --git a/app/portainer/settings/general/ssl-certificate/index.js b/app/portainer/settings/general/ssl-certificate/index.js new file mode 100644 index 000000000..0d5d62af7 --- /dev/null +++ b/app/portainer/settings/general/ssl-certificate/index.js @@ -0,0 +1,6 @@ +import controller from './ssl-certificate.controller.js'; + +export const sslCertificate = { + templateUrl: './ssl-certificate.html', + controller, +}; diff --git a/app/portainer/settings/general/ssl-certificate/ssl-certificate.controller.js b/app/portainer/settings/general/ssl-certificate/ssl-certificate.controller.js new file mode 100644 index 000000000..52fd30ce6 --- /dev/null +++ b/app/portainer/settings/general/ssl-certificate/ssl-certificate.controller.js @@ -0,0 +1,68 @@ +class SslCertificateController { + /* @ngInject */ + constructor($async, $state, SSLService, Notifications) { + Object.assign(this, { $async, $state, SSLService, Notifications }); + + this.cert = null; + this.originalValues = { + forceHTTPS: false, + certFile: null, + keyFile: null, + }; + + this.formValues = { + certFile: null, + keyFile: null, + forceHTTPS: false, + }; + + this.state = { + actionInProgress: false, + reloadingPage: false, + }; + + const pemPattern = '.pem'; + this.certFilePattern = `${pemPattern},.crt,.cer,.cert`; + this.keyFilePattern = `${pemPattern},.key`; + + this.save = this.save.bind(this); + } + + isFormChanged() { + return Object.entries(this.originalValues).some(([key, value]) => value != this.formValues[key]); + } + + async save() { + return this.$async(async () => { + this.state.actionInProgress = true; + try { + const cert = this.formValues.certFile ? await this.formValues.certFile.text() : null; + const key = this.formValues.keyFile ? await this.formValues.keyFile.text() : null; + const httpEnabled = !this.formValues.forceHTTPS; + await this.SSLService.upload(httpEnabled, cert, key); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + location.reload(); + this.state.reloadingPage = true; + } catch (err) { + this.Notifications.error('Failure', err, 'Failed applying changes'); + } + this.state.actionInProgress = false; + }); + } + + async $onInit() { + return this.$async(async () => { + try { + const certInfo = await this.SSLService.get(); + + this.formValues.forceHTTPS = !certInfo.httpEnabled; + this.originalValues.forceHTTPS = this.formValues.forceHTTPS; + } catch (err) { + this.Notifications.error('Failure', err, 'Failed loading certificate info'); + } + }); + } +} + +export default SslCertificateController; diff --git a/app/portainer/settings/general/ssl-certificate/ssl-certificate.html b/app/portainer/settings/general/ssl-certificate/ssl-certificate.html new file mode 100644 index 000000000..83fa54aa5 --- /dev/null +++ b/app/portainer/settings/general/ssl-certificate/ssl-certificate.html @@ -0,0 +1,95 @@ +
curl https://downloads.portainer.io/portainer-edge-agent-setup.sh | sudo bash -s -- {{ randomEdgeID }} {{ endpoint.EdgeKey }}
+
+ {{ dockerCommands[state.deploymentTab][state.platformType](randomEdgeID, endpoint.EdgeKey, state.allowSelfSignedCerts) }}
+
{{ dockerCommands.linuxSwarm }}
- {{ dockerCommands.windowsSwarm }}
+
+ {{ dockerCommands[state.deploymentTab][state.platformType](randomEdgeID, endpoint.EdgeKey, state.allowSelfSignedCerts) }}
+
{{ dockerCommands.linuxStandalone }}
- {{ dockerCommands.windowsStandalone }}
+
+ {{ dockerCommands[state.deploymentTab][state.platformType](randomEdgeID, endpoint.EdgeKey, state.allowSelfSignedCerts) }}
+