diff --git a/README.md b/README.md index 14a404772..58c6a787c 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ [![Microbadger](https://images.microbadger.com/badges/image/portainer/portainer.svg)](http://microbadger.com/images/portainer/portainer "Image size") [![Documentation Status](https://readthedocs.org/projects/portainer/badge/?version=stable)](http://portainer.readthedocs.io/en/stable/?badge=stable) [![Codefresh build status]( https://g.codefresh.io/api/badges/build?repoOwner=portainer&repoName=portainer&branch=develop&pipelineName=portainer-ci&accountName=deviantony&type=cf-1)]( https://g.codefresh.io/repositories/portainer/portainer/builds?filter=trigger:build;branch:develop;service:5922a08a3a1aab000116fcc6~portainer-ci) +[![Code Climate](https://codeclimate.com/github/portainer/portainer/badges/gpa.svg)](https://codeclimate.com/github/portainer/portainer) [![Slack](https://portainer.io/slack/badge.svg)](https://portainer.io/slack/) [![Gitter](https://badges.gitter.im/portainer/Lobby.svg)](https://gitter.im/portainer/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=YHXZJQNJQ36H6) diff --git a/api/bolt/migrate_dbversion2.go b/api/bolt/migrate_dbversion2.go new file mode 100644 index 000000000..86059736d --- /dev/null +++ b/api/bolt/migrate_dbversion2.go @@ -0,0 +1,25 @@ +package bolt + +import "github.com/portainer/portainer" + +func (m *Migrator) updateSettingsToVersion3() error { + legacySettings, err := m.SettingsService.Settings() + if err != nil { + return err + } + + legacySettings.AuthenticationMethod = portainer.AuthenticationInternal + legacySettings.LDAPSettings = portainer.LDAPSettings{ + TLSConfig: portainer.TLSConfiguration{}, + SearchSettings: []portainer.LDAPSearchSettings{ + portainer.LDAPSearchSettings{}, + }, + } + + err = m.SettingsService.StoreSettings(legacySettings) + if err != nil { + return err + } + + return nil +} diff --git a/api/bolt/migrator.go b/api/bolt/migrator.go index b6c4dd4df..297afb867 100644 --- a/api/bolt/migrator.go +++ b/api/bolt/migrator.go @@ -7,6 +7,7 @@ type Migrator struct { UserService *UserService EndpointService *EndpointService ResourceControlService *ResourceControlService + SettingsService *SettingsService VersionService *VersionService CurrentDBVersion int store *Store @@ -18,6 +19,7 @@ func NewMigrator(store *Store, version int) *Migrator { UserService: store.UserService, EndpointService: store.EndpointService, ResourceControlService: store.ResourceControlService, + SettingsService: store.SettingsService, VersionService: store.VersionService, CurrentDBVersion: version, store: store, @@ -47,6 +49,14 @@ func (m *Migrator) Migrate() error { } } + // Portainer 1.13.x + if m.CurrentDBVersion == 2 { + err := m.updateSettingsToVersion3() + if err != nil { + return err + } + } + err := m.VersionService.StoreDBVersion(portainer.DBVersion) if err != nil { return err diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index a3e265544..aced452a7 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -9,6 +9,7 @@ import ( "github.com/portainer/portainer/file" "github.com/portainer/portainer/http" "github.com/portainer/portainer/jwt" + "github.com/portainer/portainer/ldap" "log" ) @@ -68,6 +69,10 @@ func initCryptoService() portainer.CryptoService { return &crypto.Service{} } +func initLDAPService() portainer.LDAPService { + return &ldap.Service{} +} + func initEndpointWatcher(endpointService portainer.EndpointService, externalEnpointFile string, syncInterval string) bool { authorizeEndpointMgmt := true if externalEnpointFile != "" { @@ -113,6 +118,13 @@ func initSettings(settingsService portainer.SettingsService, flags *portainer.CL settings := &portainer.Settings{ LogoURL: *flags.Logo, DisplayExternalContributors: true, + AuthenticationMethod: portainer.AuthenticationInternal, + LDAPSettings: portainer.LDAPSettings{ + TLSConfig: portainer.TLSConfiguration{}, + SearchSettings: []portainer.LDAPSearchSettings{ + portainer.LDAPSearchSettings{}, + }, + }, } if *flags.Templates != "" { @@ -155,6 +167,8 @@ func main() { cryptoService := initCryptoService() + ldapService := initLDAPService() + authorizeEndpointMgmt := initEndpointWatcher(store.EndpointService, *flags.ExternalEndpoints, *flags.SyncInterval) err := initSettings(store.SettingsService, flags) @@ -225,6 +239,7 @@ func main() { CryptoService: cryptoService, JWTService: jwtService, FileService: fileService, + LDAPService: ldapService, SSL: *flags.SSL, SSLCert: *flags.SSLCert, SSLKey: *flags.SSLKey, diff --git a/api/crypto/tls.go b/api/crypto/tls.go index ff47d43dc..9c3f1f192 100644 --- a/api/crypto/tls.go +++ b/api/crypto/tls.go @@ -7,20 +7,28 @@ import ( ) // CreateTLSConfiguration initializes a tls.Config using a CA certificate, a certificate and a key -func CreateTLSConfiguration(caCertPath, certPath, keyPath string) (*tls.Config, error) { - cert, err := tls.LoadX509KeyPair(certPath, keyPath) - if err != nil { - return nil, err +func CreateTLSConfiguration(caCertPath, certPath, keyPath string, skipTLSVerify bool) (*tls.Config, error) { + + config := &tls.Config{} + + if certPath != "" && keyPath != "" { + cert, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + return nil, err + } + config.Certificates = []tls.Certificate{cert} } - caCert, err := ioutil.ReadFile(caCertPath) - if err != nil { - return nil, err - } - caCertPool := x509.NewCertPool() - caCertPool.AppendCertsFromPEM(caCert) - config := &tls.Config{ - Certificates: []tls.Certificate{cert}, - RootCAs: caCertPool, + + if caCertPath != "" { + caCert, err := ioutil.ReadFile(caCertPath) + if err != nil { + return nil, err + } + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCert) + config.RootCAs = caCertPool } + + config.InsecureSkipVerify = skipTLSVerify return config, nil } diff --git a/api/file/file.go b/api/file/file.go index 8337f3c15..75bcb99ec 100644 --- a/api/file/file.go +++ b/api/file/file.go @@ -6,12 +6,13 @@ import ( "io" "os" "path" - "strconv" ) const ( // TLSStorePath represents the subfolder where TLS files are stored in the file store folder. TLSStorePath = "tls" + // LDAPStorePath represents the subfolder where LDAP TLS files are stored in the TLSStorePath. + LDAPStorePath = "ldap" // TLSCACertFile represents the name on disk for a TLS CA file. TLSCACertFile = "ca.pem" // TLSCertFile represents the name on disk for a TLS certificate file. @@ -50,11 +51,10 @@ func NewService(dataStorePath, fileStorePath string) (*Service, error) { return service, nil } -// StoreTLSFile creates a subfolder in the TLSStorePath and stores a new file with the content from r. -func (service *Service) StoreTLSFile(endpointID portainer.EndpointID, fileType portainer.TLSFileType, r io.Reader) error { - ID := strconv.Itoa(int(endpointID)) - endpointStorePath := path.Join(TLSStorePath, ID) - err := service.createDirectoryInStoreIfNotExist(endpointStorePath) +// StoreTLSFile creates a folder in the TLSStorePath and stores a new file with the content from r. +func (service *Service) StoreTLSFile(folder string, fileType portainer.TLSFileType, r io.Reader) error { + storePath := path.Join(TLSStorePath, folder) + err := service.createDirectoryInStoreIfNotExist(storePath) if err != nil { return err } @@ -71,7 +71,7 @@ func (service *Service) StoreTLSFile(endpointID portainer.EndpointID, fileType p return portainer.ErrUndefinedTLSFileType } - tlsFilePath := path.Join(endpointStorePath, fileName) + tlsFilePath := path.Join(storePath, fileName) err = service.createFileInStore(tlsFilePath, r) if err != nil { return err @@ -80,7 +80,7 @@ func (service *Service) StoreTLSFile(endpointID portainer.EndpointID, fileType p } // GetPathForTLSFile returns the absolute path to a specific TLS file for an endpoint. -func (service *Service) GetPathForTLSFile(endpointID portainer.EndpointID, fileType portainer.TLSFileType) (string, error) { +func (service *Service) GetPathForTLSFile(folder string, fileType portainer.TLSFileType) (string, error) { var fileName string switch fileType { case portainer.TLSFileCA: @@ -92,15 +92,13 @@ func (service *Service) GetPathForTLSFile(endpointID portainer.EndpointID, fileT default: return "", portainer.ErrUndefinedTLSFileType } - ID := strconv.Itoa(int(endpointID)) - return path.Join(service.fileStorePath, TLSStorePath, ID, fileName), nil + return path.Join(service.fileStorePath, TLSStorePath, folder, fileName), nil } // DeleteTLSFiles deletes a folder containing the TLS files for an endpoint. -func (service *Service) DeleteTLSFiles(endpointID portainer.EndpointID) error { - ID := strconv.Itoa(int(endpointID)) - endpointPath := path.Join(service.fileStorePath, TLSStorePath, ID) - err := os.RemoveAll(endpointPath) +func (service *Service) DeleteTLSFiles(folder string) error { + storePath := path.Join(service.fileStorePath, TLSStorePath, folder) + err := os.RemoveAll(storePath) if err != nil { return err } diff --git a/api/http/error/error.go b/api/http/error/error.go index 03f5220a8..f94b924ed 100644 --- a/api/http/error/error.go +++ b/api/http/error/error.go @@ -4,7 +4,6 @@ import ( "encoding/json" "log" "net/http" - "strings" ) // errorResponse is a generic response for sending a error. @@ -21,10 +20,3 @@ func WriteErrorResponse(w http.ResponseWriter, err error, code int, logger *log. w.WriteHeader(code) json.NewEncoder(w).Encode(&errorResponse{Err: err.Error()}) } - -// WriteMethodNotAllowedResponse writes an error message to the response and sets the Allow header. -func WriteMethodNotAllowedResponse(w http.ResponseWriter, allowedMethods []string) { - w.Header().Set("Allow", strings.Join(allowedMethods, ", ")) - w.WriteHeader(http.StatusMethodNotAllowed) - json.NewEncoder(w).Encode(&errorResponse{Err: http.StatusText(http.StatusMethodNotAllowed)}) -} diff --git a/api/http/handler/auth.go b/api/http/handler/auth.go index 4c8218282..eb5e86c00 100644 --- a/api/http/handler/auth.go +++ b/api/http/handler/auth.go @@ -17,11 +17,13 @@ import ( // AuthHandler represents an HTTP API handler for managing authentication. type AuthHandler struct { *mux.Router - Logger *log.Logger - authDisabled bool - UserService portainer.UserService - CryptoService portainer.CryptoService - JWTService portainer.JWTService + Logger *log.Logger + authDisabled bool + UserService portainer.UserService + CryptoService portainer.CryptoService + JWTService portainer.JWTService + LDAPService portainer.LDAPService + SettingsService portainer.SettingsService } const ( @@ -42,17 +44,23 @@ func NewAuthHandler(bouncer *security.RequestBouncer, authDisabled bool) *AuthHa authDisabled: authDisabled, } h.Handle("/auth", - bouncer.PublicAccess(http.HandlerFunc(h.handlePostAuth))) + bouncer.PublicAccess(http.HandlerFunc(h.handlePostAuth))).Methods(http.MethodPost) return h } -func (handler *AuthHandler) handlePostAuth(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - httperror.WriteMethodNotAllowedResponse(w, []string{http.MethodPost}) - return +type ( + postAuthRequest struct { + Username string `valid:"required"` + Password string `valid:"required"` } + postAuthResponse struct { + JWT string `json:"jwt"` + } +) + +func (handler *AuthHandler) handlePostAuth(w http.ResponseWriter, r *http.Request) { if handler.authDisabled { httperror.WriteErrorResponse(w, ErrAuthDisabled, http.StatusServiceUnavailable, handler.Logger) return @@ -82,17 +90,32 @@ func (handler *AuthHandler) handlePostAuth(w http.ResponseWriter, r *http.Reques return } - err = handler.CryptoService.CompareHashAndData(u.Password, password) + settings, err := handler.SettingsService.Settings() if err != nil { - httperror.WriteErrorResponse(w, ErrInvalidCredentials, http.StatusUnprocessableEntity, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } + if settings.AuthenticationMethod == portainer.AuthenticationLDAP && u.ID != 1 { + err = handler.LDAPService.AuthenticateUser(username, password, &settings.LDAPSettings) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + } else { + err = handler.CryptoService.CompareHashAndData(u.Password, password) + if err != nil { + httperror.WriteErrorResponse(w, ErrInvalidCredentials, http.StatusUnprocessableEntity, handler.Logger) + return + } + } + tokenData := &portainer.TokenData{ ID: u.ID, Username: u.Username, Role: u.Role, } + token, err := handler.JWTService.GenerateToken(tokenData) if err != nil { httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) @@ -101,12 +124,3 @@ func (handler *AuthHandler) handlePostAuth(w http.ResponseWriter, r *http.Reques encodeJSON(w, &postAuthResponse{JWT: token}, handler.Logger) } - -type postAuthRequest struct { - Username string `valid:"required"` - Password string `valid:"required"` -} - -type postAuthResponse struct { - JWT string `json:"jwt"` -} diff --git a/api/http/handler/docker.go b/api/http/handler/docker.go index 6c4e23636..c823b3d20 100644 --- a/api/http/handler/docker.go +++ b/api/http/handler/docker.go @@ -30,7 +30,7 @@ func NewDockerHandler(bouncer *security.RequestBouncer) *DockerHandler { Router: mux.NewRouter(), Logger: log.New(os.Stderr, "", log.LstdFlags), } - h.PathPrefix("/{id}/").Handler( + h.PathPrefix("/{id}/docker").Handler( bouncer.AuthenticatedAccess(http.HandlerFunc(h.proxyRequestsToDockerAPI))) return h } @@ -90,5 +90,5 @@ func (handler *DockerHandler) proxyRequestsToDockerAPI(w http.ResponseWriter, r } } - http.StripPrefix("/"+id, proxy).ServeHTTP(w, r) + http.StripPrefix("/"+id+"/docker", proxy).ServeHTTP(w, r) } diff --git a/api/http/handler/dockerhub.go b/api/http/handler/dockerhub.go index 56b8eed4e..9da51c90e 100644 --- a/api/http/handler/dockerhub.go +++ b/api/http/handler/dockerhub.go @@ -22,20 +22,28 @@ type DockerHubHandler struct { DockerHubService portainer.DockerHubService } -// NewDockerHubHandler returns a new instance of OldDockerHubHandler. +// NewDockerHubHandler returns a new instance of NewDockerHubHandler. func NewDockerHubHandler(bouncer *security.RequestBouncer) *DockerHubHandler { h := &DockerHubHandler{ Router: mux.NewRouter(), Logger: log.New(os.Stderr, "", log.LstdFlags), } h.Handle("/dockerhub", - bouncer.PublicAccess(http.HandlerFunc(h.handleGetDockerHub))).Methods(http.MethodGet) + bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleGetDockerHub))).Methods(http.MethodGet) h.Handle("/dockerhub", bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutDockerHub))).Methods(http.MethodPut) return h } +type ( + putDockerHubRequest struct { + Authentication bool `valid:""` + Username string `valid:""` + Password string `valid:""` + } +) + // handleGetDockerHub handles GET requests on /dockerhub func (handler *DockerHubHandler) handleGetDockerHub(w http.ResponseWriter, r *http.Request) { dockerhub, err := handler.DockerHubService.DockerHub() @@ -79,9 +87,3 @@ func (handler *DockerHubHandler) handlePutDockerHub(w http.ResponseWriter, r *ht httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) } } - -type putDockerHubRequest struct { - Authentication bool `valid:""` - Username string `valid:""` - Password string `valid:""` -} diff --git a/api/http/handler/endpoint.go b/api/http/handler/endpoint.go index 7118a9d69..07950d790 100644 --- a/api/http/handler/endpoint.go +++ b/api/http/handler/endpoint.go @@ -55,6 +55,31 @@ func NewEndpointHandler(bouncer *security.RequestBouncer, authorizeEndpointManag return h } +type ( + postEndpointsRequest struct { + Name string `valid:"required"` + URL string `valid:"required"` + PublicURL string `valid:"-"` + TLS bool + } + + postEndpointsResponse struct { + ID int `json:"Id"` + } + + putEndpointAccessRequest struct { + AuthorizedUsers []int `valid:"-"` + AuthorizedTeams []int `valid:"-"` + } + + putEndpointsRequest struct { + Name string `valid:"-"` + URL string `valid:"-"` + PublicURL string `valid:"-"` + TLS bool `valid:"-"` + } +) + // handleGetEndpoints handles GET requests on /endpoints func (handler *EndpointHandler) handleGetEndpoints(w http.ResponseWriter, r *http.Request) { securityContext, err := security.RetrieveRestrictedRequestContext(r) @@ -113,11 +138,12 @@ func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *ht } if req.TLS { - caCertPath, _ := handler.FileService.GetPathForTLSFile(endpoint.ID, portainer.TLSFileCA) + folder := strconv.Itoa(int(endpoint.ID)) + caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA) endpoint.TLSCACertPath = caCertPath - certPath, _ := handler.FileService.GetPathForTLSFile(endpoint.ID, portainer.TLSFileCert) + certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert) endpoint.TLSCertPath = certPath - keyPath, _ := handler.FileService.GetPathForTLSFile(endpoint.ID, portainer.TLSFileKey) + keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey) endpoint.TLSKeyPath = keyPath err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) if err != nil { @@ -129,17 +155,6 @@ func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *ht encodeJSON(w, &postEndpointsResponse{ID: int(endpoint.ID)}, handler.Logger) } -type postEndpointsRequest struct { - Name string `valid:"required"` - URL string `valid:"required"` - PublicURL string `valid:"-"` - TLS bool -} - -type postEndpointsResponse struct { - ID int `json:"Id"` -} - // handleGetEndpoint handles GET requests on /endpoints/:id func (handler *EndpointHandler) handleGetEndpoint(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) @@ -218,11 +233,6 @@ func (handler *EndpointHandler) handlePutEndpointAccess(w http.ResponseWriter, r } } -type putEndpointAccessRequest struct { - AuthorizedUsers []int `valid:"-"` - AuthorizedTeams []int `valid:"-"` -} - // handlePutEndpoint handles PUT requests on /endpoints/:id func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http.Request) { if !handler.authorizeEndpointManagement { @@ -272,20 +282,21 @@ func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http endpoint.PublicURL = req.PublicURL } + folder := strconv.Itoa(int(endpoint.ID)) if req.TLS { endpoint.TLS = true - caCertPath, _ := handler.FileService.GetPathForTLSFile(endpoint.ID, portainer.TLSFileCA) + caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA) endpoint.TLSCACertPath = caCertPath - certPath, _ := handler.FileService.GetPathForTLSFile(endpoint.ID, portainer.TLSFileCert) + certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert) endpoint.TLSCertPath = certPath - keyPath, _ := handler.FileService.GetPathForTLSFile(endpoint.ID, portainer.TLSFileKey) + keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey) endpoint.TLSKeyPath = keyPath } else { endpoint.TLS = false endpoint.TLSCACertPath = "" endpoint.TLSCertPath = "" endpoint.TLSKeyPath = "" - err = handler.FileService.DeleteTLSFiles(endpoint.ID) + err = handler.FileService.DeleteTLSFiles(folder) if err != nil { httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return @@ -305,13 +316,6 @@ func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http } } -type putEndpointsRequest struct { - Name string `valid:"-"` - URL string `valid:"-"` - PublicURL string `valid:"-"` - TLS bool `valid:"-"` -} - // handleDeleteEndpoint handles DELETE requests on /endpoints/:id func (handler *EndpointHandler) handleDeleteEndpoint(w http.ResponseWriter, r *http.Request) { if !handler.authorizeEndpointManagement { @@ -347,7 +351,7 @@ func (handler *EndpointHandler) handleDeleteEndpoint(w http.ResponseWriter, r *h } if endpoint.TLS { - err = handler.FileService.DeleteTLSFiles(portainer.EndpointID(endpointID)) + err = handler.FileService.DeleteTLSFiles(id) if err != nil { httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index 7fcb58c56..4a83f6743 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -36,46 +36,48 @@ const ( ErrInvalidRequestFormat = portainer.Error("Invalid request data format") // ErrInvalidQueryFormat defines an error raised when the data sent in the query or the URL is invalid ErrInvalidQueryFormat = portainer.Error("Invalid query format") - // ErrEmptyResponseBody defines an error raised when portainer excepts to parse the body of a HTTP response and there is nothing to parse - // ErrEmptyResponseBody = portainer.Error("Empty response body") ) // ServeHTTP delegates a request to the appropriate subhandler. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if strings.HasPrefix(r.URL.Path, "/api/auth") { + + switch { + case strings.HasPrefix(r.URL.Path, "/api/auth"): http.StripPrefix("/api", h.AuthHandler).ServeHTTP(w, r) - } else if strings.HasPrefix(r.URL.Path, "/api/users") { - http.StripPrefix("/api", h.UserHandler).ServeHTTP(w, r) - } else if strings.HasPrefix(r.URL.Path, "/api/teams") { - http.StripPrefix("/api", h.TeamHandler).ServeHTTP(w, r) - } else if strings.HasPrefix(r.URL.Path, "/api/team_memberships") { - http.StripPrefix("/api", h.TeamMembershipHandler).ServeHTTP(w, r) - } else if strings.HasPrefix(r.URL.Path, "/api/endpoints") { - http.StripPrefix("/api", h.EndpointHandler).ServeHTTP(w, r) - } else if strings.HasPrefix(r.URL.Path, "/api/registries") { - http.StripPrefix("/api", h.RegistryHandler).ServeHTTP(w, r) - } else if strings.HasPrefix(r.URL.Path, "/api/dockerhub") { + case strings.HasPrefix(r.URL.Path, "/api/dockerhub"): http.StripPrefix("/api", h.DockerHubHandler).ServeHTTP(w, r) - } else if strings.HasPrefix(r.URL.Path, "/api/resource_controls") { + case strings.HasPrefix(r.URL.Path, "/api/endpoints"): + if strings.Contains(r.URL.Path, "/docker") { + http.StripPrefix("/api/endpoints", h.DockerHandler).ServeHTTP(w, r) + } else { + http.StripPrefix("/api", h.EndpointHandler).ServeHTTP(w, r) + } + case strings.HasPrefix(r.URL.Path, "/api/registries"): + http.StripPrefix("/api", h.RegistryHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/resource_controls"): http.StripPrefix("/api", h.ResourceHandler).ServeHTTP(w, r) - } else if strings.HasPrefix(r.URL.Path, "/api/settings") { + case strings.HasPrefix(r.URL.Path, "/api/settings"): http.StripPrefix("/api", h.SettingsHandler).ServeHTTP(w, r) - } else if strings.HasPrefix(r.URL.Path, "/api/status") { + case strings.HasPrefix(r.URL.Path, "/api/status"): http.StripPrefix("/api", h.StatusHandler).ServeHTTP(w, r) - } else if strings.HasPrefix(r.URL.Path, "/api/templates") { + case strings.HasPrefix(r.URL.Path, "/api/templates"): http.StripPrefix("/api", h.TemplatesHandler).ServeHTTP(w, r) - } else if strings.HasPrefix(r.URL.Path, "/api/upload") { + case strings.HasPrefix(r.URL.Path, "/api/upload"): http.StripPrefix("/api", h.UploadHandler).ServeHTTP(w, r) - } else if strings.HasPrefix(r.URL.Path, "/api/websocket") { + case strings.HasPrefix(r.URL.Path, "/api/users"): + http.StripPrefix("/api", h.UserHandler).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"): + http.StripPrefix("/api", h.TeamMembershipHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/websocket"): http.StripPrefix("/api", h.WebSocketHandler).ServeHTTP(w, r) - } else if strings.HasPrefix(r.URL.Path, "/api/docker") { - http.StripPrefix("/api/docker", h.DockerHandler).ServeHTTP(w, r) - } else if strings.HasPrefix(r.URL.Path, "/") { + case strings.HasPrefix(r.URL.Path, "/"): h.FileHandler.ServeHTTP(w, r) } } -// encodeJSON encodes v to w in JSON format. Error() is called if encoding fails. +// encodeJSON encodes v to w in JSON format. WriteErrorResponse() is called if encoding fails. func encodeJSON(w http.ResponseWriter, v interface{}, logger *log.Logger) { if err := json.NewEncoder(w).Encode(v); err != nil { httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, logger) diff --git a/api/http/handler/registry.go b/api/http/handler/registry.go index 164a5f3c1..9afeb1178 100644 --- a/api/http/handler/registry.go +++ b/api/http/handler/registry.go @@ -44,6 +44,33 @@ func NewRegistryHandler(bouncer *security.RequestBouncer) *RegistryHandler { return h } +type ( + postRegistriesRequest struct { + Name string `valid:"required"` + URL string `valid:"required"` + Authentication bool `valid:""` + Username string `valid:""` + Password string `valid:""` + } + + postRegistriesResponse struct { + ID int `json:"Id"` + } + + putRegistryAccessRequest struct { + AuthorizedUsers []int `valid:"-"` + AuthorizedTeams []int `valid:"-"` + } + + putRegistriesRequest struct { + Name string `valid:"required"` + URL string `valid:"required"` + Authentication bool `valid:""` + Username string `valid:""` + Password string `valid:""` + } +) + // handleGetRegistries handles GET requests on /registries func (handler *RegistryHandler) handleGetRegistries(w http.ResponseWriter, r *http.Request) { securityContext, err := security.RetrieveRestrictedRequestContext(r) @@ -112,18 +139,6 @@ func (handler *RegistryHandler) handlePostRegistries(w http.ResponseWriter, r *h encodeJSON(w, &postRegistriesResponse{ID: int(registry.ID)}, handler.Logger) } -type postRegistriesRequest struct { - Name string `valid:"required"` - URL string `valid:"required"` - Authentication bool `valid:""` - Username string `valid:""` - Password string `valid:""` -} - -type postRegistriesResponse struct { - ID int `json:"Id"` -} - // handleGetRegistry handles GET requests on /registries/:id func (handler *RegistryHandler) handleGetRegistry(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) @@ -202,11 +217,6 @@ func (handler *RegistryHandler) handlePutRegistryAccess(w http.ResponseWriter, r } } -type putRegistryAccessRequest struct { - AuthorizedUsers []int `valid:"-"` - AuthorizedTeams []int `valid:"-"` -} - // handlePutRegistry handles PUT requests on /registries/:id func (handler *RegistryHandler) handlePutRegistry(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) @@ -276,14 +286,6 @@ func (handler *RegistryHandler) handlePutRegistry(w http.ResponseWriter, r *http } } -type putRegistriesRequest struct { - Name string `valid:"required"` - URL string `valid:"required"` - Authentication bool `valid:""` - Username string `valid:""` - Password string `valid:""` -} - // handleDeleteRegistry handles DELETE requests on /registries/:id func (handler *RegistryHandler) handleDeleteRegistry(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) diff --git a/api/http/handler/resource_control.go b/api/http/handler/resource_control.go index 7952cfbda..7c35dec39 100644 --- a/api/http/handler/resource_control.go +++ b/api/http/handler/resource_control.go @@ -39,6 +39,23 @@ func NewResourceHandler(bouncer *security.RequestBouncer) *ResourceHandler { return h } +type ( + postResourcesRequest struct { + ResourceID string `valid:"required"` + Type string `valid:"required"` + AdministratorsOnly bool `valid:"-"` + Users []int `valid:"-"` + Teams []int `valid:"-"` + SubResourceIDs []string `valid:"-"` + } + + putResourcesRequest struct { + AdministratorsOnly bool `valid:"-"` + Users []int `valid:"-"` + Teams []int `valid:"-"` + } +) + // handlePostResources handles POST requests on /resources func (handler *ResourceHandler) handlePostResources(w http.ResponseWriter, r *http.Request) { var req postResourcesRequest @@ -121,22 +138,13 @@ func (handler *ResourceHandler) handlePostResources(w http.ResponseWriter, r *ht err = handler.ResourceControlService.CreateResourceControl(&resourceControl) if err != nil { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } return } -type postResourcesRequest struct { - ResourceID string `valid:"required"` - Type string `valid:"required"` - AdministratorsOnly bool `valid:"-"` - Users []int `valid:"-"` - Teams []int `valid:"-"` - SubResourceIDs []string `valid:"-"` -} - // handlePutResources handles PUT requests on /resources/:id func (handler *ResourceHandler) handlePutResources(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) @@ -210,12 +218,6 @@ func (handler *ResourceHandler) handlePutResources(w http.ResponseWriter, r *htt } } -type putResourcesRequest struct { - AdministratorsOnly bool `valid:"-"` - Users []int `valid:"-"` - Teams []int `valid:"-"` -} - // handleDeleteResources handles DELETE requests on /resources/:id func (handler *ResourceHandler) handleDeleteResources(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) diff --git a/api/http/handler/settings.go b/api/http/handler/settings.go index 26e1cfe92..52e957f6d 100644 --- a/api/http/handler/settings.go +++ b/api/http/handler/settings.go @@ -5,6 +5,7 @@ import ( "github.com/asaskevich/govalidator" "github.com/portainer/portainer" + "github.com/portainer/portainer/file" httperror "github.com/portainer/portainer/http/error" "github.com/portainer/portainer/http/security" @@ -20,6 +21,8 @@ type SettingsHandler struct { *mux.Router Logger *log.Logger SettingsService portainer.SettingsService + LDAPService portainer.LDAPService + FileService portainer.FileService } // NewSettingsHandler returns a new instance of OldSettingsHandler. @@ -29,13 +32,38 @@ func NewSettingsHandler(bouncer *security.RequestBouncer) *SettingsHandler { Logger: log.New(os.Stderr, "", log.LstdFlags), } h.Handle("/settings", - bouncer.PublicAccess(http.HandlerFunc(h.handleGetSettings))).Methods(http.MethodGet) + bouncer.AdministratorAccess(http.HandlerFunc(h.handleGetSettings))).Methods(http.MethodGet) h.Handle("/settings", bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutSettings))).Methods(http.MethodPut) + h.Handle("/settings/public", + bouncer.PublicAccess(http.HandlerFunc(h.handleGetPublicSettings))).Methods(http.MethodGet) + h.Handle("/settings/authentication/checkLDAP", + bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutSettingsLDAPCheck))).Methods(http.MethodPut) return h } +type ( + publicSettingsResponse struct { + LogoURL string `json:"LogoURL"` + DisplayExternalContributors bool `json:"DisplayExternalContributors"` + AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod"` + } + + putSettingsRequest struct { + TemplatesURL string `valid:"required"` + LogoURL string `valid:""` + BlackListedLabels []portainer.Pair `valid:""` + DisplayExternalContributors bool `valid:""` + AuthenticationMethod int `valid:"required"` + LDAPSettings portainer.LDAPSettings `valid:""` + } + + putSettingsLDAPCheckRequest struct { + LDAPSettings portainer.LDAPSettings `valid:""` + } +) + // handleGetSettings handles GET requests on /settings func (handler *SettingsHandler) handleGetSettings(w http.ResponseWriter, r *http.Request) { settings, err := handler.SettingsService.Settings() @@ -48,6 +76,24 @@ func (handler *SettingsHandler) handleGetSettings(w http.ResponseWriter, r *http return } +// handleGetPublicSettings handles GET requests on /settings/public +func (handler *SettingsHandler) handleGetPublicSettings(w http.ResponseWriter, r *http.Request) { + settings, err := handler.SettingsService.Settings() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + publicSettings := &publicSettingsResponse{ + LogoURL: settings.LogoURL, + DisplayExternalContributors: settings.DisplayExternalContributors, + AuthenticationMethod: settings.AuthenticationMethod, + } + + encodeJSON(w, publicSettings, handler.Logger) + return +} + // handlePutSettings handles PUT requests on /settings func (handler *SettingsHandler) handlePutSettings(w http.ResponseWriter, r *http.Request) { var req putSettingsRequest @@ -67,6 +113,27 @@ func (handler *SettingsHandler) handlePutSettings(w http.ResponseWriter, r *http LogoURL: req.LogoURL, BlackListedLabels: req.BlackListedLabels, DisplayExternalContributors: req.DisplayExternalContributors, + LDAPSettings: req.LDAPSettings, + } + + if req.AuthenticationMethod == 1 { + settings.AuthenticationMethod = portainer.AuthenticationInternal + } else if req.AuthenticationMethod == 2 { + settings.AuthenticationMethod = portainer.AuthenticationLDAP + } else { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + if (settings.LDAPSettings.TLSConfig.TLS || settings.LDAPSettings.StartTLS) && !settings.LDAPSettings.TLSConfig.TLSSkipVerify { + caCertPath, _ := handler.FileService.GetPathForTLSFile(file.LDAPStorePath, portainer.TLSFileCA) + settings.LDAPSettings.TLSConfig.TLSCACertPath = caCertPath + } else { + settings.LDAPSettings.TLSConfig.TLSCACertPath = "" + err := handler.FileService.DeleteTLSFiles(file.LDAPStorePath) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + } } err = handler.SettingsService.StoreSettings(settings) @@ -75,9 +142,28 @@ func (handler *SettingsHandler) handlePutSettings(w http.ResponseWriter, r *http } } -type putSettingsRequest struct { - TemplatesURL string `valid:"required"` - LogoURL string `valid:""` - BlackListedLabels []portainer.Pair `valid:""` - DisplayExternalContributors bool `valid:""` +// handlePutSettingsLDAPCheck handles PUT requests on /settings/ldap/check +func (handler *SettingsHandler) handlePutSettingsLDAPCheck(w http.ResponseWriter, r *http.Request) { + var req putSettingsLDAPCheckRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err := govalidator.ValidateStruct(req) + if err != nil { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + if (req.LDAPSettings.TLSConfig.TLS || req.LDAPSettings.StartTLS) && !req.LDAPSettings.TLSConfig.TLSSkipVerify { + caCertPath, _ := handler.FileService.GetPathForTLSFile(file.LDAPStorePath, portainer.TLSFileCA) + req.LDAPSettings.TLSConfig.TLSCACertPath = caCertPath + } + + err = handler.LDAPService.TestConnectivity(&req.LDAPSettings) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } } diff --git a/api/http/handler/team.go b/api/http/handler/team.go index 3f4d9fc50..1bf90e689 100644 --- a/api/http/handler/team.go +++ b/api/http/handler/team.go @@ -34,7 +34,7 @@ func NewTeamHandler(bouncer *security.RequestBouncer) *TeamHandler { h.Handle("/teams", bouncer.AdministratorAccess(http.HandlerFunc(h.handlePostTeams))).Methods(http.MethodPost) h.Handle("/teams", - bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleGetTeams))).Methods(http.MethodGet) + bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetTeams))).Methods(http.MethodGet) h.Handle("/teams/{id}", bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetTeam))).Methods(http.MethodGet) h.Handle("/teams/{id}", @@ -47,6 +47,20 @@ func NewTeamHandler(bouncer *security.RequestBouncer) *TeamHandler { return h } +type ( + postTeamsRequest struct { + Name string `valid:"required"` + } + + postTeamsResponse struct { + ID int `json:"Id"` + } + + putTeamRequest struct { + Name string `valid:"-"` + } +) + // handlePostTeams handles POST requests on /teams func (handler *TeamHandler) handlePostTeams(w http.ResponseWriter, r *http.Request) { var req postTeamsRequest @@ -84,23 +98,23 @@ func (handler *TeamHandler) handlePostTeams(w http.ResponseWriter, r *http.Reque encodeJSON(w, &postTeamsResponse{ID: int(team.ID)}, handler.Logger) } -type postTeamsResponse struct { - ID int `json:"Id"` -} - -type postTeamsRequest struct { - Name string `valid:"required"` -} - // handleGetTeams handles GET requests on /teams func (handler *TeamHandler) handleGetTeams(w http.ResponseWriter, r *http.Request) { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + teams, err := handler.TeamService.Teams() if err != nil { httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } - encodeJSON(w, teams, handler.Logger) + filteredTeams := security.FilterUserTeams(teams, securityContext) + + encodeJSON(w, filteredTeams, handler.Logger) } // handleGetTeam handles GET requests on /teams/:id @@ -181,10 +195,6 @@ func (handler *TeamHandler) handlePutTeam(w http.ResponseWriter, r *http.Request } } -type putTeamRequest struct { - Name string `valid:"-"` -} - // handleDeleteTeam handles DELETE requests on /teams/:id func (handler *TeamHandler) handleDeleteTeam(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) diff --git a/api/http/handler/team_membership.go b/api/http/handler/team_membership.go index e6c9075ef..c96f5c8ca 100644 --- a/api/http/handler/team_membership.go +++ b/api/http/handler/team_membership.go @@ -42,6 +42,24 @@ func NewTeamMembershipHandler(bouncer *security.RequestBouncer) *TeamMembershipH return h } +type ( + postTeamMembershipsRequest struct { + UserID int `valid:"required"` + TeamID int `valid:"required"` + Role int `valid:"required"` + } + + postTeamMembershipsResponse struct { + ID int `json:"Id"` + } + + putTeamMembershipRequest struct { + UserID int `valid:"required"` + TeamID int `valid:"required"` + Role int `valid:"required"` + } +) + // handlePostTeamMemberships handles POST requests on /team_memberships func (handler *TeamMembershipHandler) handlePostTeamMemberships(w http.ResponseWriter, r *http.Request) { securityContext, err := security.RetrieveRestrictedRequestContext(r) @@ -100,16 +118,6 @@ func (handler *TeamMembershipHandler) handlePostTeamMemberships(w http.ResponseW encodeJSON(w, &postTeamMembershipsResponse{ID: int(membership.ID)}, handler.Logger) } -type postTeamMembershipsResponse struct { - ID int `json:"Id"` -} - -type postTeamMembershipsRequest struct { - UserID int `valid:"required"` - TeamID int `valid:"required"` - Role int `valid:"required"` -} - // handleGetTeamsMemberships handles GET requests on /team_memberships func (handler *TeamMembershipHandler) handleGetTeamsMemberships(w http.ResponseWriter, r *http.Request) { securityContext, err := security.RetrieveRestrictedRequestContext(r) @@ -195,12 +203,6 @@ func (handler *TeamMembershipHandler) handlePutTeamMembership(w http.ResponseWri } } -type putTeamMembershipRequest struct { - UserID int `valid:"required"` - TeamID int `valid:"required"` - Role int `valid:"required"` -} - // handleDeleteTeamMembership handles DELETE requests on /team_memberships/:id func (handler *TeamMembershipHandler) handleDeleteTeamMembership(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) diff --git a/api/http/handler/templates.go b/api/http/handler/templates.go index 516fc892e..25e2e288b 100644 --- a/api/http/handler/templates.go +++ b/api/http/handler/templates.go @@ -20,7 +20,7 @@ type TemplatesHandler struct { } const ( - containerTemplatesURLLinuxServerIo = "http://tools.linuxserver.io/portainer.json" + containerTemplatesURLLinuxServerIo = "https://tools.linuxserver.io/portainer.json" ) // NewTemplatesHandler returns a new instance of TemplatesHandler. @@ -30,17 +30,12 @@ func NewTemplatesHandler(bouncer *security.RequestBouncer) *TemplatesHandler { Logger: log.New(os.Stderr, "", log.LstdFlags), } h.Handle("/templates", - bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleGetTemplates))) + bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleGetTemplates))).Methods(http.MethodGet) return h } // handleGetTemplates handles GET requests on /templates?key= func (handler *TemplatesHandler) handleGetTemplates(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - httperror.WriteMethodNotAllowedResponse(w, []string{http.MethodGet}) - return - } - key := r.FormValue("key") if key == "" { httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger) diff --git a/api/http/handler/upload.go b/api/http/handler/upload.go index d96a45c5a..7395fe888 100644 --- a/api/http/handler/upload.go +++ b/api/http/handler/upload.go @@ -8,7 +8,6 @@ import ( "log" "net/http" "os" - "strconv" "github.com/gorilla/mux" ) @@ -26,23 +25,19 @@ func NewUploadHandler(bouncer *security.RequestBouncer) *UploadHandler { Router: mux.NewRouter(), Logger: log.New(os.Stderr, "", log.LstdFlags), } - h.Handle("/upload/tls/{endpointID}/{certificate:(?:ca|cert|key)}", - bouncer.AuthenticatedAccess(http.HandlerFunc(h.handlePostUploadTLS))) + h.Handle("/upload/tls/{certificate:(?:ca|cert|key)}", + bouncer.AdministratorAccess(http.HandlerFunc(h.handlePostUploadTLS))).Methods(http.MethodPost) return h } +// handlePostUploadTLS handles POST requests on /upload/tls/{certificate:(?:ca|cert|key)}?folder=folder func (handler *UploadHandler) handlePostUploadTLS(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - httperror.WriteMethodNotAllowedResponse(w, []string{http.MethodPost}) - return - } - vars := mux.Vars(r) - endpointID := vars["endpointID"] certificate := vars["certificate"] - ID, err := strconv.Atoi(endpointID) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + + folder := r.FormValue("folder") + if folder == "" { + httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger) return } @@ -66,7 +61,7 @@ func (handler *UploadHandler) handlePostUploadTLS(w http.ResponseWriter, r *http return } - err = handler.FileService.StoreTLSFile(portainer.EndpointID(ID), fileType, file) + err = handler.FileService.StoreTLSFile(folder, fileType, file) if err != nil { httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return diff --git a/api/http/handler/user.go b/api/http/handler/user.go index 44c15495e..7aa4e11c9 100644 --- a/api/http/handler/user.go +++ b/api/http/handler/user.go @@ -26,6 +26,7 @@ type UserHandler struct { TeamMembershipService portainer.TeamMembershipService ResourceControlService portainer.ResourceControlService CryptoService portainer.CryptoService + SettingsService portainer.SettingsService } // NewUserHandler returns a new instance of UserHandler. @@ -46,18 +47,45 @@ func NewUserHandler(bouncer *security.RequestBouncer) *UserHandler { bouncer.AdministratorAccess(http.HandlerFunc(h.handleDeleteUser))).Methods(http.MethodDelete) h.Handle("/users/{id}/memberships", bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleGetMemberships))).Methods(http.MethodGet) - h.Handle("/users/{id}/teams", - bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetTeams))).Methods(http.MethodGet) h.Handle("/users/{id}/passwd", - bouncer.AuthenticatedAccess(http.HandlerFunc(h.handlePostUserPasswd))) + bouncer.AuthenticatedAccess(http.HandlerFunc(h.handlePostUserPasswd))).Methods(http.MethodPost) h.Handle("/users/admin/check", - bouncer.PublicAccess(http.HandlerFunc(h.handleGetAdminCheck))) + bouncer.PublicAccess(http.HandlerFunc(h.handleGetAdminCheck))).Methods(http.MethodGet) h.Handle("/users/admin/init", - bouncer.PublicAccess(http.HandlerFunc(h.handlePostAdminInit))) + bouncer.PublicAccess(http.HandlerFunc(h.handlePostAdminInit))).Methods(http.MethodPost) return h } +type ( + postUsersRequest struct { + Username string `valid:"required"` + Password string `valid:""` + Role int `valid:"required"` + } + + postUsersResponse struct { + ID int `json:"Id"` + } + + postUserPasswdRequest struct { + Password string `valid:"required"` + } + + postUserPasswdResponse struct { + Valid bool `json:"valid"` + } + + putUserRequest struct { + Password string `valid:"-"` + Role int `valid:"-"` + } + + postAdminInitRequest struct { + Password string `valid:"required"` + } +) + // handlePostUsers handles POST requests on /users func (handler *UserHandler) handlePostUsers(w http.ResponseWriter, r *http.Request) { var req postUsersRequest @@ -93,13 +121,6 @@ func (handler *UserHandler) handlePostUsers(w http.ResponseWriter, r *http.Reque return } - var role portainer.UserRole - if req.Role == 1 { - role = portainer.AdministratorRole - } else { - role = portainer.StandardUserRole - } - user, err := handler.UserService.UserByUsername(req.Username) if err != nil && err != portainer.ErrUserNotFound { httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) @@ -110,16 +131,32 @@ func (handler *UserHandler) handlePostUsers(w http.ResponseWriter, r *http.Reque return } + var role portainer.UserRole + if req.Role == 1 { + role = portainer.AdministratorRole + } else { + role = portainer.StandardUserRole + } + user = &portainer.User{ Username: req.Username, Role: role, } - user.Password, err = handler.CryptoService.Hash(req.Password) + + settings, err := handler.SettingsService.Settings() if err != nil { - httperror.WriteErrorResponse(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } + if settings.AuthenticationMethod == portainer.AuthenticationInternal { + user.Password, err = handler.CryptoService.Hash(req.Password) + if err != nil { + httperror.WriteErrorResponse(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger) + return + } + } + err = handler.UserService.CreateUser(user) if err != nil { httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) @@ -129,16 +166,6 @@ func (handler *UserHandler) handlePostUsers(w http.ResponseWriter, r *http.Reque encodeJSON(w, &postUsersResponse{ID: int(user.ID)}, handler.Logger) } -type postUsersResponse struct { - ID int `json:"Id"` -} - -type postUsersRequest struct { - Username string `valid:"required"` - Password string `valid:"required"` - Role int `valid:"required"` -} - // handleGetUsers handles GET requests on /users func (handler *UserHandler) handleGetUsers(w http.ResponseWriter, r *http.Request) { securityContext, err := security.RetrieveRestrictedRequestContext(r) @@ -164,11 +191,6 @@ func (handler *UserHandler) handleGetUsers(w http.ResponseWriter, r *http.Reques // handlePostUserPasswd handles POST requests on /users/:id/passwd func (handler *UserHandler) handlePostUserPasswd(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - httperror.WriteMethodNotAllowedResponse(w, []string{http.MethodPost}) - return - } - vars := mux.Vars(r) id := vars["id"] @@ -210,14 +232,6 @@ func (handler *UserHandler) handlePostUserPasswd(w http.ResponseWriter, r *http. encodeJSON(w, &postUserPasswdResponse{Valid: valid}, handler.Logger) } -type postUserPasswdRequest struct { - Password string `valid:"required"` -} - -type postUserPasswdResponse struct { - Valid bool `json:"valid"` -} - // handleGetUser handles GET requests on /users/:id func (handler *UserHandler) handleGetUser(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) @@ -317,18 +331,8 @@ func (handler *UserHandler) handlePutUser(w http.ResponseWriter, r *http.Request } } -type putUserRequest struct { - Password string `valid:"-"` - Role int `valid:"-"` -} - -// handlePostAdminInit handles GET requests on /users/admin/check +// handleGetAdminCheck handles GET requests on /users/admin/check func (handler *UserHandler) handleGetAdminCheck(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - httperror.WriteMethodNotAllowedResponse(w, []string{http.MethodGet}) - return - } - users, err := handler.UserService.UsersByRole(portainer.AdministratorRole) if err != nil { httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) @@ -342,11 +346,6 @@ func (handler *UserHandler) handleGetAdminCheck(w http.ResponseWriter, r *http.R // handlePostAdminInit handles POST requests on /users/admin/init func (handler *UserHandler) handlePostAdminInit(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - httperror.WriteMethodNotAllowedResponse(w, []string{http.MethodPost}) - return - } - var req postAdminInitRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) @@ -381,15 +380,11 @@ func (handler *UserHandler) handlePostAdminInit(w http.ResponseWriter, r *http.R return } if user != nil { - httperror.WriteErrorResponse(w, portainer.ErrAdminAlreadyInitialized, http.StatusForbidden, handler.Logger) + httperror.WriteErrorResponse(w, portainer.ErrAdminAlreadyInitialized, http.StatusConflict, handler.Logger) return } } -type postAdminInitRequest struct { - Password string `valid:"required"` -} - // handleDeleteUser handles DELETE requests on /users/:id func (handler *UserHandler) handleDeleteUser(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) @@ -454,37 +449,3 @@ func (handler *UserHandler) handleGetMemberships(w http.ResponseWriter, r *http. encodeJSON(w, memberships, handler.Logger) } - -// handleGetTeams handles GET requests on /users/:id/teams -func (handler *UserHandler) handleGetTeams(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - uid, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - userID := portainer.UserID(uid) - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if !security.AuthorizedUserManagement(userID, securityContext) { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) - return - } - - teams, err := handler.TeamService.Teams() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - filteredTeams := security.FilterUserTeams(teams, securityContext) - - encodeJSON(w, filteredTeams, handler.Logger) -} diff --git a/api/http/handler/websocket.go b/api/http/handler/websocket.go index 39f626a99..dbc4fd9f0 100644 --- a/api/http/handler/websocket.go +++ b/api/http/handler/websocket.go @@ -72,9 +72,7 @@ func (handler *WebSocketHandler) webSocketDockerExec(ws *websocket.Conn) { // Should not be managed here var tlsConfig *tls.Config if endpoint.TLS { - tlsConfig, err = crypto.CreateTLSConfiguration(endpoint.TLSCACertPath, - endpoint.TLSCertPath, - endpoint.TLSKeyPath) + tlsConfig, err = crypto.CreateTLSConfiguration(endpoint.TLSCACertPath, endpoint.TLSCertPath, endpoint.TLSKeyPath, false) if err != nil { log.Fatalf("Unable to create TLS configuration: %s", err) return diff --git a/api/http/proxy/factory.go b/api/http/proxy/factory.go index 3e0d71445..dc733149f 100644 --- a/api/http/proxy/factory.go +++ b/api/http/proxy/factory.go @@ -24,7 +24,7 @@ func (factory *proxyFactory) newHTTPProxy(u *url.URL) http.Handler { func (factory *proxyFactory) newHTTPSProxy(u *url.URL, endpoint *portainer.Endpoint) (http.Handler, error) { u.Scheme = "https" proxy := factory.createReverseProxy(u) - config, err := crypto.CreateTLSConfiguration(endpoint.TLSCACertPath, endpoint.TLSCertPath, endpoint.TLSKeyPath) + config, err := crypto.CreateTLSConfiguration(endpoint.TLSCACertPath, endpoint.TLSCertPath, endpoint.TLSKeyPath, false) if err != nil { return nil, err } diff --git a/api/http/security/bouncer.go b/api/http/security/bouncer.go index 9f7920c6c..e6a8fc962 100644 --- a/api/http/security/bouncer.go +++ b/api/http/security/bouncer.go @@ -50,7 +50,7 @@ func (bouncer *RequestBouncer) AuthenticatedAccess(h http.Handler) http.Handler return h } -// RestrictedAccess defines defines a security check for restricted endpoints. +// RestrictedAccess defines a security check for restricted endpoints. // Authentication is required to access these endpoints. // The request context will be enhanced with a RestrictedRequestContext object // that might be used later to authorize/filter access to resources. diff --git a/api/http/server.go b/api/http/server.go index 14a069eae..36344f764 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -27,6 +27,7 @@ type Server struct { FileService portainer.FileService RegistryService portainer.RegistryService DockerHubService portainer.DockerHubService + LDAPService portainer.LDAPService Handler *handler.Handler SSL bool SSLCert string @@ -42,12 +43,15 @@ func (server *Server) Start() error { authHandler.UserService = server.UserService authHandler.CryptoService = server.CryptoService authHandler.JWTService = server.JWTService + authHandler.LDAPService = server.LDAPService + authHandler.SettingsService = server.SettingsService var userHandler = handler.NewUserHandler(requestBouncer) userHandler.UserService = server.UserService userHandler.TeamService = server.TeamService userHandler.TeamMembershipService = server.TeamMembershipService userHandler.CryptoService = server.CryptoService userHandler.ResourceControlService = server.ResourceControlService + userHandler.SettingsService = server.SettingsService var teamHandler = handler.NewTeamHandler(requestBouncer) teamHandler.TeamService = server.TeamService teamHandler.TeamMembershipService = server.TeamMembershipService @@ -56,6 +60,8 @@ func (server *Server) Start() error { var statusHandler = handler.NewStatusHandler(requestBouncer, server.Status) var settingsHandler = handler.NewSettingsHandler(requestBouncer) settingsHandler.SettingsService = server.SettingsService + settingsHandler.LDAPService = server.LDAPService + settingsHandler.FileService = server.FileService var templatesHandler = handler.NewTemplatesHandler(requestBouncer) templatesHandler.SettingsService = server.SettingsService var dockerHandler = handler.NewDockerHandler(requestBouncer) diff --git a/api/ldap/ldap.go b/api/ldap/ldap.go new file mode 100644 index 000000000..786332c35 --- /dev/null +++ b/api/ldap/ldap.go @@ -0,0 +1,123 @@ +package ldap + +import ( + "fmt" + "strings" + + "github.com/portainer/portainer" + "github.com/portainer/portainer/crypto" + + "gopkg.in/ldap.v2" +) + +const ( + // ErrUserNotFound defines an error raised when the user is not found via LDAP search + // or that too many entries (> 1) are returned. + ErrUserNotFound = portainer.Error("User not found or too many entries returned") +) + +// Service represents a service used to authenticate users against a LDAP/AD. +type Service struct{} + +func searchUser(username string, conn *ldap.Conn, settings []portainer.LDAPSearchSettings) (string, error) { + var userDN string + found := false + for _, searchSettings := range settings { + searchRequest := ldap.NewSearchRequest( + searchSettings.BaseDN, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + fmt.Sprintf("(&%s(%s=%s))", searchSettings.Filter, searchSettings.UserNameAttribute, username), + []string{"dn"}, + nil, + ) + + // Deliberately skip errors on the search request so that we can jump to other search settings + // if any issue arise with the current one. + sr, _ := conn.Search(searchRequest) + + if len(sr.Entries) == 1 { + found = true + userDN = sr.Entries[0].DN + break + } + } + + if !found { + return "", ErrUserNotFound + } + + return userDN, nil +} + +func createConnection(settings *portainer.LDAPSettings) (*ldap.Conn, error) { + + if settings.TLSConfig.TLS || settings.StartTLS { + config, err := crypto.CreateTLSConfiguration(settings.TLSConfig.TLSCACertPath, "", "", settings.TLSConfig.TLSSkipVerify) + if err != nil { + return nil, err + } + config.ServerName = strings.Split(settings.URL, ":")[0] + + if settings.TLSConfig.TLS { + return ldap.DialTLS("tcp", settings.URL, config) + } + + conn, err := ldap.Dial("tcp", settings.URL) + if err != nil { + return nil, err + } + + err = conn.StartTLS(config) + if err != nil { + return nil, err + } + + return conn, nil + } + + return ldap.Dial("tcp", settings.URL) +} + +// AuthenticateUser is used to authenticate a user against a LDAP/AD. +func (*Service) AuthenticateUser(username, password string, settings *portainer.LDAPSettings) error { + + connection, err := createConnection(settings) + if err != nil { + return err + } + defer connection.Close() + + err = connection.Bind(settings.ReaderDN, settings.Password) + if err != nil { + return err + } + + userDN, err := searchUser(username, connection, settings.SearchSettings) + if err != nil { + return err + } + + err = connection.Bind(userDN, password) + if err != nil { + return err + } + + return nil +} + +// TestConnectivity is used to test a connection against the LDAP server using the credentials +// specified in the LDAPSettings. +func (*Service) TestConnectivity(settings *portainer.LDAPSettings) error { + + connection, err := createConnection(settings) + if err != nil { + return err + } + defer connection.Close() + + err = connection.Bind(settings.ReaderDN, settings.Password) + if err != nil { + return err + } + return nil +} diff --git a/api/portainer.go b/api/portainer.go index 6ed70e7ee..69d8e9290 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -41,12 +41,40 @@ type ( Version string `json:"Version"` } + // LDAPSettings represents the settings used to connect to a LDAP server. + LDAPSettings struct { + ReaderDN string `json:"ReaderDN"` + Password string `json:"Password"` + URL string `json:"URL"` + TLSConfig TLSConfiguration `json:"TLSConfig"` + StartTLS bool `json:"StartTLS"` + SearchSettings []LDAPSearchSettings `json:"SearchSettings"` + } + + // TLSConfiguration represents a TLS configuration. + TLSConfiguration struct { + TLS bool `json:"TLS"` + TLSSkipVerify bool `json:"TLSSkipVerify"` + TLSCACertPath string `json:"TLSCACert,omitempty"` + TLSCertPath string `json:"TLSCert,omitempty"` + TLSKeyPath string `json:"TLSKey,omitempty"` + } + + // LDAPSearchSettings represents settings used to search for users in a LDAP server. + LDAPSearchSettings struct { + BaseDN string `json:"BaseDN"` + Filter string `json:"Filter"` + UserNameAttribute string `json:"UserNameAttribute"` + } + // Settings represents the application settings. Settings struct { - TemplatesURL string `json:"TemplatesURL"` - LogoURL string `json:"LogoURL"` - BlackListedLabels []Pair `json:"BlackListedLabels"` - DisplayExternalContributors bool `json:"DisplayExternalContributors"` + TemplatesURL string `json:"TemplatesURL"` + LogoURL string `json:"LogoURL"` + BlackListedLabels []Pair `json:"BlackListedLabels"` + DisplayExternalContributors bool `json:"DisplayExternalContributors"` + AuthenticationMethod AuthenticationMethod `json:"AuthenticationMethod"` + LDAPSettings LDAPSettings `json:"LDAPSettings"` } // User represents a user account. @@ -64,6 +92,9 @@ type ( // or a regular user UserRole int + // AuthenticationMethod represents the authentication method used to authenticate a user. + AuthenticationMethod int + // Team represents a list of user accounts. Team struct { ID TeamID `json:"Id"` @@ -292,22 +323,28 @@ type ( // FileService represents a service for managing files. FileService interface { - StoreTLSFile(endpointID EndpointID, fileType TLSFileType, r io.Reader) error - GetPathForTLSFile(endpointID EndpointID, fileType TLSFileType) (string, error) - DeleteTLSFiles(endpointID EndpointID) error + StoreTLSFile(folder string, fileType TLSFileType, r io.Reader) error + GetPathForTLSFile(folder string, fileType TLSFileType) (string, error) + DeleteTLSFiles(folder string) error } // EndpointWatcher represents a service to synchronize the endpoints via an external source. EndpointWatcher interface { WatchEndpointFile(endpointFilePath string) error } + + // LDAPService represents a service used to authenticate users against a LDAP/AD. + LDAPService interface { + AuthenticateUser(username, password string, settings *LDAPSettings) error + TestConnectivity(settings *LDAPSettings) error + } ) const ( // APIVersion is the version number of the Portainer API. - APIVersion = "1.13.6" + APIVersion = "1.14.0" // DBVersion is the version number of the Portainer database. - DBVersion = 2 + DBVersion = 3 // DefaultTemplatesURL represents the default URL for the templates definitions. DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates.json" ) @@ -337,6 +374,14 @@ const ( StandardUserRole ) +const ( + _ AuthenticationMethod = iota + // AuthenticationInternal represents the internal authentication method (authentication against Portainer API) + AuthenticationInternal + // AuthenticationLDAP represents the LDAP authentication method (authentication against a LDAP server) + AuthenticationLDAP +) + const ( _ ResourceAccessLevel = iota // ReadWriteAccessLevel represents an access level with read-write permissions on a resource diff --git a/api/swagger.yaml b/api/swagger.yaml new file mode 100644 index 000000000..aa7713195 --- /dev/null +++ b/api/swagger.yaml @@ -0,0 +1,2471 @@ +--- +swagger: "2.0" +info: + description: "Portainer API is an HTTP API served by Portainer. It is used by the\ + \ Portainer UI and everything you can do with the UI can be done using the HTTP\ + \ API.\nYou can find out more about Portainer at [http://portainer.io](http://portainer.io)\ + \ and get some support on [Slack](http://portainer.io/slack/).\n\n# Authentication\n\ + \nMost of the API endpoints require to be authenticated as well as some level\ + \ of authorization to be used.\nPortainer API uses JSON Web Token to manage authentication\ + \ and thus requires you to provide a token in the **Authorization** header of\ + \ each request\nwith the **Bearer** authentication mechanism.\n\nExample:\n```\n\ + Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGUiOjEsImV4cCI6MTQ5OTM3NjE1NH0.NJ6vE8FY1WG6jsRQzfMqeatJ4vh2TWAeeYfDhP71YEE\n\ + ```\n\n# Security\n\nEach API endpoint has an associated access policy, it is\ + \ documented in the description of each endpoint.\n\nDifferent access policies\ + \ are available:\n* Public access\n* Authenticated access\n* Restricted access\n\ + * Administrator access\n\n### Public access\n\nNo authentication is required to\ + \ access the endpoints with this access policy.\n\n### Authenticated access\n\n\ + Authentication is required to access the endpoints with this access policy.\n\n\ + ### Restricted access\n\nAuthentication is required to access the endpoints with\ + \ this access policy.\nExtra-checks might be added to ensure access to the resource\ + \ is granted. Returned data might also be filtered.\n\n### Administrator access\n\ + \nAuthentication as well as an administrator role are required to access the endpoints\ + \ with this access policy.\n" + version: "1.14.0" + title: "Portainer API" + contact: + email: "info@portainer.io" +host: "portainer.domain" +basePath: "/api" +tags: +- name: "auth" + description: "Authenticate against Portainer HTTP API" +- name: "dockerhub" + description: "Manage how Portainer connects to the DockerHub" +- name: "endpoints" + description: "Manage Docker environments" +- name: "registries" + description: "Manage Docker registries" +- name: "resource_controls" + description: "Manage access control on Docker resources" +- name: "settings" + description: "Manage Portainer settings" +- name: "status" + description: "Information about the Portainer instance" +- name: "users" + description: "Manage users" +- name: "teams" + description: "Manage teams" +- name: "team_memberships" + description: "Manage team memberships" +- name: "templates" + description: "Manage App Templates" +- name: "upload" + description: "Upload files" +- name: "websocket" + description: "Create exec sessions using websockets" +schemes: +- "http" +- "https" +paths: + /auth: + post: + tags: + - "auth" + summary: "Authenticate a user" + description: "Use this endpoint to authenticate against Portainer using a username\ + \ and password. \n**Access policy**: public\n" + operationId: "AuthenticateUser" + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - in: "body" + name: "body" + description: "Credentials used for authentication" + required: true + schema: + $ref: "#/definitions/AuthenticateUserRequest" + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/AuthenticateUserResponse" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid credentials" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + 503: + description: "Authentication disabled" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Authentication is disabled" + /dockerhub: + get: + tags: + - "dockerhub" + summary: "Retrieve DockerHub information" + description: "Use this endpoint to retrieve the information used to connect\ + \ to the DockerHub \n**Access policy**: authenticated\n" + operationId: "DockerHubInspect" + produces: + - "application/json" + parameters: [] + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/DockerHubInspectResponse" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + put: + tags: + - "dockerhub" + summary: "Update DockerHub information" + description: "Use this endpoint to update the information used to connect to\ + \ the DockerHub \n**Access policy**: administrator\n" + operationId: "DockerHubUpdate" + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - in: "body" + name: "body" + description: "DockerHub information" + required: true + schema: + $ref: "#/definitions/DockerHubUpdateRequest" + responses: + 200: + description: "Success" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request data format" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + /endpoints: + get: + tags: + - "endpoints" + summary: "List endpoints" + description: "List all endpoints based on the current user authorizations. Will\n\ + return all endpoints if using an administrator account otherwise it will\n\ + only return authorized endpoints. \n**Access policy**: restricted \n" + operationId: "EndpointList" + produces: + - "application/json" + parameters: [] + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/EndpointListResponse" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + post: + tags: + - "endpoints" + summary: "Create a new endpoint" + description: "Create a new endpoint that will be used to manage a Docker environment.\ + \ \n**Access policy**: administrator\n" + operationId: "EndpointCreate" + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - in: "body" + name: "body" + description: "Endpoint details" + required: true + schema: + $ref: "#/definitions/EndpointCreateRequest" + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/EndpointCreateResponse" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request data format" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + 503: + description: "Endpoint management disabled" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Endpoint management is disabled" + /endpoints/{id}: + get: + tags: + - "endpoints" + summary: "Inspect an endpoint" + description: "Retrieve details abount an endpoint. \n**Access policy**: administrator\ + \ \n" + operationId: "EndpointInspect" + produces: + - "application/json" + parameters: + - name: "id" + in: "path" + description: "Endpoint identifier" + required: true + type: "integer" + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/Endpoint" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request" + 404: + description: "Endpoint not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Endpoint not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + put: + tags: + - "endpoints" + summary: "Update an endpoint" + description: "Update an endpoint. \n**Access policy**: administrator\n" + operationId: "EndpointUpdate" + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - name: "id" + in: "path" + description: "Endpoint identifier" + required: true + type: "integer" + - in: "body" + name: "body" + description: "Endpoint details" + required: true + schema: + $ref: "#/definitions/EndpointUpdateRequest" + responses: + 200: + description: "Success" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request data format" + 404: + description: "Endpoint not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Endpoint not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + 503: + description: "Endpoint management disabled" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Endpoint management is disabled" + delete: + tags: + - "endpoints" + summary: "Remove an endpoint" + description: "Remove an endpoint. \n**Access policy**: administrator \n" + operationId: "EndpointDelete" + parameters: + - name: "id" + in: "path" + description: "Endpoint identifier" + required: true + type: "integer" + responses: + 200: + description: "Success" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request" + 404: + description: "Endpoint not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Endpoint not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + 503: + description: "Endpoint management disabled" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Endpoint management is disabled" + /endpoints/{id}/access: + put: + tags: + - "endpoints" + summary: "Manage accesses to an endpoint" + description: "Manage user and team accesses to an endpoint. \n**Access policy**:\ + \ administrator \n" + operationId: "EndpointAccessUpdate" + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - name: "id" + in: "path" + description: "Endpoint identifier" + required: true + type: "integer" + - in: "body" + name: "body" + description: "Authorizations details" + required: true + schema: + $ref: "#/definitions/EndpointAccessUpdateRequest" + responses: + 200: + description: "Success" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request data format" + 404: + description: "Endpoint not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Endpoint not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + /registries: + get: + tags: + - "registries" + summary: "List registries" + description: "List all registries based on the current user authorizations.\n\ + Will return all registries if using an administrator account otherwise it\n\ + will only return authorized registries. \n**Access policy**: restricted \ + \ \n" + operationId: "RegistryList" + produces: + - "application/json" + parameters: [] + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/RegistryListResponse" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + post: + tags: + - "registries" + summary: "Create a new registry" + description: "Create a new registry. \n**Access policy**: administrator \ + \ \n" + operationId: "RegistryCreate" + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - in: "body" + name: "body" + description: "Registry details" + required: true + schema: + $ref: "#/definitions/RegistryCreateRequest" + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/RegistryCreateResponse" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request data format" + 409: + description: "Registry already exists" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "A registry is already defined for this URL" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + /registries/{id}: + get: + tags: + - "registries" + summary: "Inspect a registry" + description: "Retrieve details about a registry. \n**Access policy**: administrator\ + \ \n" + operationId: "RegistryInspect" + produces: + - "application/json" + parameters: + - name: "id" + in: "path" + description: "Registry identifier" + required: true + type: "integer" + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/Registry" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request" + 404: + description: "Registry not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Endpoint not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + put: + tags: + - "registries" + summary: "Update a registry" + description: "Update a registry. \n**Access policy**: administrator \n" + operationId: "RegistryUpdate" + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - name: "id" + in: "path" + description: "Registry identifier" + required: true + type: "integer" + - in: "body" + name: "body" + description: "Registry details" + required: true + schema: + $ref: "#/definitions/RegistryUpdateRequest" + responses: + 200: + description: "Success" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request data format" + 404: + description: "Registry not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Endpoint not found" + 409: + description: "Registry already exists" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "A registry is already defined for this URL" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + 503: + description: "Endpoint management disabled" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Endpoint management is disabled" + delete: + tags: + - "registries" + summary: "Remove a registry" + description: "Remove a registry. \n**Access policy**: administrator \ + \ \n" + operationId: "RegistryDelete" + parameters: + - name: "id" + in: "path" + description: "Registry identifier" + required: true + type: "integer" + responses: + 200: + description: "Success" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request" + 404: + description: "Registry not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Registry not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + /registries/{id}/access: + put: + tags: + - "registries" + summary: "Manage accesses to a registry" + description: "Manage user and team accesses to a registry. \n**Access policy**:\ + \ administrator \n" + operationId: "RegistryAccessUpdate" + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - name: "id" + in: "path" + description: "Registry identifier" + required: true + type: "integer" + - in: "body" + name: "body" + description: "Authorizations details" + required: true + schema: + $ref: "#/definitions/RegistryAccessUpdateRequest" + responses: + 200: + description: "Success" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request data format" + 404: + description: "Registry not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Registry not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + /resource_controls: + post: + tags: + - "resource_controls" + summary: "Create a new resource control" + description: "Create a new resource control to restrict access to a Docker resource.\ + \ \n**Access policy**: restricted \n" + operationId: "ResourceControlCreate" + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - in: "body" + name: "body" + description: "Resource control details" + required: true + schema: + $ref: "#/definitions/ResourceControlCreateRequest" + responses: + 200: + description: "Success" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request data format" + 403: + description: "Unauthorized" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Access denied to resource" + 409: + description: "Resource control already exists" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "A resource control is already applied on this resource" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + /resource_controls/{id}: + put: + tags: + - "resource_controls" + summary: "Update a resource control" + description: "Update a resource control. \n**Access policy**: restricted \ + \ \n" + operationId: "ResourceControlUpdate" + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - name: "id" + in: "path" + description: "Resource control identifier" + required: true + type: "integer" + - in: "body" + name: "body" + description: "Resource control details" + required: true + schema: + $ref: "#/definitions/ResourceControlUpdateRequest" + responses: + 200: + description: "Success" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request data format" + 403: + description: "Unauthorized" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Access denied to resource" + 404: + description: "Resource control not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Resource control not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + delete: + tags: + - "resource_controls" + summary: "Remove a resource control" + description: "Remove a resource control. \n**Access policy**: restricted \ + \ \n" + operationId: "ResourceControlDelete" + parameters: + - name: "id" + in: "path" + description: "Resource control identifier" + required: true + type: "integer" + responses: + 200: + description: "Success" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request" + 403: + description: "Unauthorized" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Access denied to resource" + 404: + description: "Resource control not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Resource control not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + /settings: + get: + tags: + - "settings" + summary: "Retrieve Portainer settings" + description: "Retrieve Portainer settings. \n**Access policy**: administrator\ + \ \n" + operationId: "SettingsInspect" + produces: + - "application/json" + parameters: [] + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/Settings" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + put: + tags: + - "settings" + summary: "Update Portainer settings" + description: "Update Portainer settings. \n**Access policy**: administrator\ + \ \n" + operationId: "SettingsUpdate" + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - in: "body" + name: "body" + description: "New settings" + required: true + schema: + $ref: "#/definitions/SettingsUpdateRequest" + responses: + 200: + description: "Success" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request data format" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + /settings/public: + get: + tags: + - "settings" + summary: "Retrieve Portainer public settings" + description: "Retrieve public settings. Returns a small set of settings that\ + \ are not reserved to administrators only. \n**Access policy**: public \ + \ \n" + operationId: "PublicSettingsInspect" + produces: + - "application/json" + parameters: [] + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/PublicSettingsInspectResponse" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + /settings/authentication/checkLDAP: + put: + tags: + - "settings" + summary: "Test LDAP connectivity" + description: "Test LDAP connectivity using LDAP details. \n**Access policy**:\ + \ administrator \n" + operationId: "SettingsLDAPCheck" + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - in: "body" + name: "body" + description: "LDAP settings" + required: true + schema: + $ref: "#/definitions/SettingsLDAPCheckRequest" + responses: + 200: + description: "Success" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request data format" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + /status: + get: + tags: + - "status" + summary: "Check Portainer status" + description: "Retrieve Portainer status. \n**Access policy**: public \ + \ \n" + operationId: "StatusInspect" + produces: + - "application/json" + parameters: [] + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/Status" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + /users: + get: + tags: + - "users" + summary: "List users" + description: "List Portainer users. Non-administrator users will only be able\ + \ to list other non-administrator user accounts. \n**Access policy**: restricted\ + \ \n" + operationId: "UserList" + produces: + - "application/json" + parameters: [] + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/UserListResponse" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + post: + tags: + - "users" + summary: "Create a new user" + description: "Create a new Portainer user. Only team leaders and administrators\ + \ can create users. Only administrators can\ncreate an administrator user\ + \ account. \n**Access policy**: restricted \n" + operationId: "UserCreate" + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - in: "body" + name: "body" + description: "User details" + required: true + schema: + $ref: "#/definitions/UserCreateRequest" + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/UserCreateResponse" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request data format" + 403: + description: "Unauthorized" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Access denied to resource" + 409: + description: "User already exists" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "User already exists" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + /users/{id}: + get: + tags: + - "users" + summary: "Inspect a user" + description: "Retrieve details about a user. \n**Access policy**: administrator\ + \ \n" + operationId: "UserInspect" + produces: + - "application/json" + parameters: + - name: "id" + in: "path" + description: "User identifier" + required: true + type: "integer" + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/User" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request" + 404: + description: "User not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "User not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + put: + tags: + - "users" + summary: "Update a user" + description: "Update user details. A regular user account can only update his\ + \ details. \n**Access policy**: authenticated \n" + operationId: "UserUpdate" + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - name: "id" + in: "path" + description: "User identifier" + required: true + type: "integer" + - in: "body" + name: "body" + description: "User details" + required: true + schema: + $ref: "#/definitions/UserUpdateRequest" + responses: + 200: + description: "Success" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request data format" + 403: + description: "Unauthorized" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Access denied to resource" + 404: + description: "User not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "User not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + delete: + tags: + - "users" + summary: "Remove a user" + description: "Remove a user. \n**Access policy**: administrator \n" + operationId: "UserDelete" + parameters: + - name: "id" + in: "path" + description: "User identifier" + required: true + type: "integer" + responses: + 200: + description: "Success" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request" + 404: + description: "User not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "User not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + /users/{id}/memberships: + get: + tags: + - "users" + summary: "Inspect a user memberships" + description: "Inspect a user memberships. \n**Access policy**: authenticated\ + \ \n" + operationId: "UserMembershipsInspect" + produces: + - "application/json" + parameters: + - name: "id" + in: "path" + description: "User identifier" + required: true + type: "integer" + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/UserMembershipsResponse" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request" + 403: + description: "Unauthorized" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Access denied to resource" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + /users/{id}/passwd: + post: + tags: + - "users" + summary: "Check password validity for a user" + description: "Check if the submitted password is valid for the specified user.\ + \ \n**Access policy**: authenticated \n" + operationId: "UserPasswordCheck" + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - name: "id" + in: "path" + description: "User identifier" + required: true + type: "integer" + - in: "body" + name: "body" + description: "User details" + required: true + schema: + $ref: "#/definitions/UserPasswordCheckRequest" + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/UserPasswordCheckResponse" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request data format" + 404: + description: "User not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "User not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + /users/admin/check: + get: + tags: + - "users" + summary: "Check administrator account existence" + description: "Check if an administrator account exists in the database.\n**Access\ + \ policy**: public \n" + operationId: "UserAdminCheck" + produces: + - "application/json" + parameters: [] + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/UserListResponse" + 404: + description: "User not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "User not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + /users/admin/init: + post: + tags: + - "users" + summary: "Initialize administrator account" + description: "Initialize the 'admin' user account.\n**Access policy**: public\ + \ \n" + operationId: "UserAdminInit" + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - in: "body" + name: "body" + description: "User details" + required: true + schema: + $ref: "#/definitions/UserAdminInitRequest" + responses: + 200: + description: "Success" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request data format" + 409: + description: "Admin user already initialized" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "User already exists" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + /upload/tls/{certificate}: + post: + tags: + - "upload" + summary: "Upload TLS files" + description: "Use this endpoint to upload TLS files. \n**Access policy**: administrator\n" + operationId: "UploadTLS" + consumes: + - "multipart/form-data" + produces: + - "application/json" + parameters: + - name: "certificate" + in: "path" + description: "TLS file type. Valid values are 'ca', 'cert' or 'key'." + required: true + type: "string" + - name: "folder" + in: "query" + description: "Folder where the TLS file will be stored. Will be created if\ + \ not existing." + required: true + type: "string" + - name: "file" + in: "formData" + description: "The file to upload." + required: false + type: "file" + responses: + 200: + description: "Success" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request data" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + /teams: + get: + tags: + - "teams" + summary: "List teams" + description: "List teams. For non-administrator users, will only list the teams\ + \ they are member of. \n**Access policy**: restricted \n" + operationId: "TeamList" + produces: + - "application/json" + parameters: [] + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/TeamListResponse" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + post: + tags: + - "teams" + summary: "Create a new team" + description: "Create a new team. \n**Access policy**: administrator \ + \ \n" + operationId: "TeamCreate" + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - in: "body" + name: "body" + description: "Team details" + required: true + schema: + $ref: "#/definitions/TeamCreateRequest" + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/TeamCreateResponse" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request data format" + 403: + description: "Unauthorized" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Access denied to resource" + 409: + description: "Team already exists" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Team already exists" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + /teams/{id}: + get: + tags: + - "teams" + summary: "Inspect a team" + description: "Retrieve details about a team. Access is only available for administrator\ + \ and leaders of that team. \n**Access policy**: restricted \n" + operationId: "TeamInspect" + produces: + - "application/json" + parameters: + - name: "id" + in: "path" + description: "Team identifier" + required: true + type: "integer" + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/Team" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request" + 403: + description: "Unauthorized" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Access denied to resource" + 404: + description: "Team not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Team not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + put: + tags: + - "teams" + summary: "Update a team" + description: "Update a team. \n**Access policy**: administrator \ + \ \n" + operationId: "TeamUpdate" + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - name: "id" + in: "path" + description: "Team identifier" + required: true + type: "integer" + - in: "body" + name: "body" + description: "Team details" + required: true + schema: + $ref: "#/definitions/TeamUpdateRequest" + responses: + 200: + description: "Success" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request data format" + 404: + description: "Team not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Team not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + delete: + tags: + - "teams" + summary: "Remove a team" + description: "Remove a team. \n**Access policy**: administrator \n" + operationId: "TeamDelete" + parameters: + - name: "id" + in: "path" + description: "Team identifier" + required: true + type: "integer" + responses: + 200: + description: "Success" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request" + 404: + description: "Team not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Team not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + /teams/{id}/memberships: + get: + tags: + - "teams" + summary: "Inspect a team memberships" + description: "Inspect a team memberships. Access is only available for administrator\ + \ and leaders of that team. \n**Access policy**: restricted \n" + operationId: "TeamMembershipsInspect" + produces: + - "application/json" + parameters: + - name: "id" + in: "path" + description: "Team identifier" + required: true + type: "integer" + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/TeamMembershipsResponse" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request" + 403: + description: "Unauthorized" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Access denied to resource" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + /team_memberships: + get: + tags: + - "team_memberships" + summary: "List team memberships" + description: "List team memberships. Access is only available to administrators\ + \ and team leaders. \n**Access policy**: restricted \n" + operationId: "TeamMembershipList" + produces: + - "application/json" + parameters: [] + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/TeamMembershipListResponse" + 403: + description: "Unauthorized" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Access denied to resource" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + post: + tags: + - "team_memberships" + summary: "Create a new team membership" + description: "Create a new team memberships. Access is only available to administrators\ + \ leaders of the associated team. \n**Access policy**: restricted \n" + operationId: "TeamMembershipCreate" + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - in: "body" + name: "body" + description: "Team membership details" + required: true + schema: + $ref: "#/definitions/TeamMembershipCreateRequest" + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/TeamMembershipCreateResponse" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request data format" + 403: + description: "Unauthorized" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Access denied to resource" + 409: + description: "Team membership already exists" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Team membership already exists for this user and team." + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + /team_memberships/{id}: + put: + tags: + - "team_memberships" + summary: "Update a team membership" + description: "Update a team membership. Access is only available to administrators\ + \ leaders of the associated team. \n**Access policy**: restricted \ + \ \n" + operationId: "TeamMembershipUpdate" + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - name: "id" + in: "path" + description: "Team membership identifier" + required: true + type: "integer" + - in: "body" + name: "body" + description: "Team membership details" + required: true + schema: + $ref: "#/definitions/TeamMembershipUpdateRequest" + responses: + 200: + description: "Success" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request data format" + 403: + description: "Unauthorized" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Access denied to resource" + 404: + description: "Team membership not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Team membership not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + delete: + tags: + - "team_memberships" + summary: "Remove a team membership" + description: "Remove a team membership. Access is only available to administrators\ + \ leaders of the associated team. \n**Access policy**: restricted \n" + operationId: "TeamMembershipDelete" + parameters: + - name: "id" + in: "path" + description: "TeamMembership identifier" + required: true + type: "integer" + responses: + 200: + description: "Success" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request" + 403: + description: "Unauthorized" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Access denied to resource" + 404: + description: "Team membership not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Team membership not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + /templates: + get: + tags: + - "templates" + summary: "Retrieve App templates" + description: "Retrieve App templates. \nYou can find more information about\ + \ the format at http://portainer.readthedocs.io/en/stable/templates.html \ + \ \n**Access policy**: authenticated \n" + operationId: "TemplateList" + produces: + - "application/json" + parameters: + - name: "key" + in: "query" + description: "Templates key. Valid values are 'container' or 'linuxserver.io'." + required: true + type: "string" + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/TemplateListResponse" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid query format" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" +securityDefinitions: + jwt: + type: "apiKey" + name: "Authorization" + in: "header" +definitions: + Team: + type: "object" + properties: + Id: + type: "integer" + example: 1 + description: "Team identifier" + Name: + type: "string" + example: "developers" + description: "Team name" + TeamMembership: + type: "object" + properties: + Id: + type: "integer" + example: 1 + description: "Membership identifier" + UserID: + type: "integer" + example: 1 + description: "User identifier" + TeamID: + type: "integer" + example: 1 + description: "Team identifier" + Role: + type: "integer" + example: 1 + description: "Team role (1 for team leader and 2 for team member)" + User: + type: "object" + properties: + Id: + type: "integer" + example: 1 + description: "User identifier" + Username: + type: "string" + example: "bob" + description: "Username" + Role: + type: "integer" + example: 1 + description: "User role (1 for administrator account and 2 for regular account)" + Status: + type: "object" + properties: + Authentication: + type: "boolean" + example: true + description: "Is authentication enabled" + EndpointManagement: + type: "boolean" + example: true + description: "Is endpoint management enabled" + Analytics: + type: "boolean" + example: true + description: "Is analytics enabled" + Version: + type: "string" + example: "1.14.0" + description: "Portainer API version" + PublicSettingsInspectResponse: + type: "object" + properties: + LogoURL: + type: "string" + example: "https://mycompany.mydomain.tld/logo.png" + description: "URL to a logo that will be displayed on the login page as well\ + \ as on top of the sidebar. Will use default Portainer logo when value is\ + \ empty string" + DisplayExternalContributors: + type: "boolean" + example: false + description: "Whether to display or not external templates contributions as\ + \ sub-menus in the UI." + AuthenticationMethod: + type: "integer" + example: 1 + description: "Active authentication method for the Portainer instance. Valid\ + \ values are: 1 for managed or 2 for LDAP." + TLSConfiguration: + type: "object" + properties: + TLS: + type: "boolean" + example: true + description: "Use TLS" + TLSSkipVerify: + type: "boolean" + example: false + description: "Skip the verification of the server TLS certificate" + TLSCACertPath: + type: "string" + example: "/data/tls/ca.pem" + description: "Path to the TLS CA certificate file" + TLSCertPath: + type: "string" + example: "/data/tls/cert.pem" + description: "Path to the TLS client certificate file" + TLSKeyPath: + type: "string" + example: "/data/tls/key.pem" + description: "Path to the TLS client key file" + LDAPSearchSettings: + type: "object" + properties: + BaseDN: + type: "string" + example: "dc=ldap,dc=domain,dc=tld" + description: "The distinguished name of the element from which the LDAP server\ + \ will search for users" + Filter: + type: "string" + example: "(objectClass=account)" + description: "Optional LDAP search filter used to select user elements" + UserNameAttribute: + type: "string" + example: "uid" + description: "LDAP attribute which denotes the username" + LDAPSettings: + type: "object" + properties: + ReaderDN: + type: "string" + example: "cn=readonly-account,dc=ldap,dc=domain,dc=tld" + description: "Account that will be used to search for users" + Password: + type: "string" + example: "readonly-password" + description: "Password of the account that will be used to search users" + URL: + type: "string" + example: "myldap.domain.tld:389" + description: "URL or IP address of the LDAP server" + TLSConfig: + $ref: "#/definitions/TLSConfiguration" + StartTLS: + type: "boolean" + example: true + description: "Whether LDAP connection should use StartTLS" + SearchSettings: + type: "array" + items: + $ref: "#/definitions/LDAPSearchSettings" + Settings: + type: "object" + properties: + TemplatesURL: + type: "string" + example: "https://raw.githubusercontent.com/portainer/templates/master/templates.json" + description: "URL to the templates that will be displayed in the UI when navigating\ + \ to App Templates" + LogoURL: + type: "string" + example: "https://mycompany.mydomain.tld/logo.png" + description: "URL to a logo that will be displayed on the login page as well\ + \ as on top of the sidebar. Will use default Portainer logo when value is\ + \ empty string" + BlackListedLabels: + type: "array" + description: "A list of label name & value that will be used to hide containers\ + \ when querying containers" + items: + $ref: "#/definitions/Settings_BlackListedLabels" + DisplayExternalContributors: + type: "boolean" + example: false + description: "Whether to display or not external templates contributions as\ + \ sub-menus in the UI." + AuthenticationMethod: + type: "integer" + example: 1 + description: "Active authentication method for the Portainer instance. Valid\ + \ values are: 1 for managed or 2 for LDAP." + LDAPSettings: + $ref: "#/definitions/LDAPSettings" + Settings_BlackListedLabels: + properties: + name: + type: "string" + example: "com.foo" + value: + type: "string" + example: "bar" + Registry: + type: "object" + properties: + Id: + type: "integer" + example: 1 + description: "Registry identifier" + Name: + type: "string" + example: "my-registry" + description: "Registry name" + URL: + type: "string" + example: "registry.mydomain.tld:2375" + description: "URL or IP address of the Docker registry" + Authentication: + type: "boolean" + example: true + description: "Is authentication against this registry enabled" + Username: + type: "string" + example: "registry_user" + description: "Username used to authenticate against this registry" + Password: + type: "string" + example: "registry_password" + description: "Password used to authenticate against this registry" + AuthorizedUsers: + type: "array" + description: "List of user identifiers authorized to use this registry" + items: + type: "integer" + example: 1 + description: "User identifier" + AuthorizedTeams: + type: "array" + description: "List of team identifiers authorized to use this registry" + items: + type: "integer" + example: 1 + description: "Team identifier" + Endpoint: + type: "object" + properties: + Id: + type: "integer" + example: 1 + description: "Endpoint identifier" + Name: + type: "string" + example: "my-endpoint" + description: "Endpoint name" + URL: + type: "string" + example: "docker.mydomain.tld:2375" + description: "URL or IP address of the Docker host associated to this endpoint" + AuthorizedUsers: + type: "array" + description: "List of user identifiers authorized to connect to this endpoint" + items: + type: "integer" + example: 1 + description: "User identifier" + AuthorizedTeams: + type: "array" + description: "List of team identifiers authorized to connect to this endpoint" + items: + type: "integer" + example: 1 + description: "Team identifier" + GenericError: + type: "object" + properties: + err: + type: "string" + example: "Something bad happened" + description: "Error message" + AuthenticateUserRequest: + type: "object" + required: + - "Password" + - "Username" + properties: + Username: + type: "string" + example: "admin" + description: "Username" + Password: + type: "string" + example: "mypassword" + description: "Password" + AuthenticateUserResponse: + type: "object" + properties: + jwt: + type: "string" + example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGUiOjEsImV4cCI6MTQ5OTM3NjE1NH0.NJ6vE8FY1WG6jsRQzfMqeatJ4vh2TWAeeYfDhP71YEE" + description: "JWT token used to authenticate against the API" + DockerHubInspectResponse: + type: "object" + properties: + Authentication: + type: "boolean" + example: true + description: "Is authentication against DockerHub enabled" + Username: + type: "string" + example: "hub_user" + description: "Username used to authenticate against the DockerHub" + Password: + type: "string" + example: "hub_password" + description: "Password used to authenticate against the DockerHub" + DockerHubUpdateRequest: + type: "object" + required: + - "Authentication" + - "Password" + - "Username" + properties: + Authentication: + type: "boolean" + example: true + description: "Enable authentication against DockerHub" + Username: + type: "string" + example: "hub_user" + description: "Username used to authenticate against the DockerHub" + Password: + type: "string" + example: "hub_password" + description: "Password used to authenticate against the DockerHub" + EndpointCreateRequest: + type: "object" + required: + - "Name" + - "URL" + properties: + Name: + type: "string" + example: "my-endpoint" + description: "Name that will be used to identify this endpoint" + URL: + type: "string" + example: "docker.mydomain.tld:2375" + description: "URL or IP address of a Docker host" + PublicURL: + type: "string" + example: "docker.mydomain.tld:2375" + description: "URL or IP address where exposed containers will be reachable.\ + \ Defaults to URL if not specified" + TLS: + type: "boolean" + example: true + description: "Require TLS to connect against this endpoint" + EndpointCreateResponse: + type: "object" + properties: + Id: + type: "integer" + example: 1 + description: "Id of the endpoint" + EndpointListResponse: + type: "array" + items: + $ref: "#/definitions/Endpoint" + EndpointUpdateRequest: + type: "object" + properties: + Name: + type: "string" + example: "my-endpoint" + description: "Name that will be used to identify this endpoint" + URL: + type: "string" + example: "docker.mydomain.tld:2375" + description: "URL or IP address of a Docker host" + PublicURL: + type: "string" + example: "docker.mydomain.tld:2375" + description: "URL or IP address where exposed containers will be reachable.\ + \ Defaults to URL if not specified" + TLS: + type: "boolean" + example: true + description: "Require TLS to connect against this endpoint" + EndpointAccessUpdateRequest: + type: "object" + properties: + AuthorizedUsers: + type: "array" + description: "List of user identifiers authorized to connect to this endpoint" + items: + type: "integer" + example: 1 + description: "User identifier" + AuthorizedTeams: + type: "array" + description: "List of team identifiers authorized to connect to this endpoint" + items: + type: "integer" + example: 1 + description: "Team identifier" + RegistryCreateRequest: + type: "object" + required: + - "Authentication" + - "Name" + - "Password" + - "URL" + - "Username" + properties: + Name: + type: "string" + example: "my-registry" + description: "Name that will be used to identify this registry" + URL: + type: "string" + example: "registry.mydomain.tld:2375" + description: "URL or IP address of the Docker registry" + Authentication: + type: "boolean" + example: true + description: "Is authentication against this registry enabled" + Username: + type: "string" + example: "registry_user" + description: "Username used to authenticate against this registry" + Password: + type: "string" + example: "registry_password" + description: "Password used to authenticate against this registry" + RegistryCreateResponse: + type: "object" + properties: + Id: + type: "integer" + example: 1 + description: "Id of the registry" + RegistryListResponse: + type: "array" + items: + $ref: "#/definitions/Registry" + RegistryUpdateRequest: + type: "object" + required: + - "Name" + - "URL" + properties: + Name: + type: "string" + example: "my-registry" + description: "Name that will be used to identify this registry" + URL: + type: "string" + example: "registry.mydomain.tld:2375" + description: "URL or IP address of the Docker registry" + Authentication: + type: "boolean" + example: true + description: "Is authentication against this registry enabled" + Username: + type: "string" + example: "registry_user" + description: "Username used to authenticate against this registry" + Password: + type: "string" + example: "registry_password" + description: "Password used to authenticate against this registry" + RegistryAccessUpdateRequest: + type: "object" + properties: + AuthorizedUsers: + type: "array" + description: "List of user identifiers authorized to use thi registry" + items: + type: "integer" + example: 1 + description: "User identifier" + AuthorizedTeams: + type: "array" + description: "List of team identifiers authorized to use thi registry" + items: + type: "integer" + example: 1 + description: "Team identifier" + ResourceControlCreateRequest: + type: "object" + required: + - "ResourceID" + - "Type" + properties: + ResourceID: + type: "string" + example: "617c5f22bb9b023d6daab7cba43a57576f83492867bc767d1c59416b065e5f08" + description: "Docker resource identifier on which access control will be applied" + Type: + type: "string" + example: "container" + description: "Type of Docker resource. Valid values are: container, volume\ + \ or service" + AdministratorsOnly: + type: "boolean" + example: true + description: "Restrict access to the associated resource to administrators\ + \ only" + Users: + type: "array" + description: "List of user identifiers with access to the associated resource" + items: + type: "integer" + example: 1 + description: "User identifier" + Teams: + type: "array" + description: "List of team identifiers with access to the associated resource" + items: + type: "integer" + example: 1 + description: "Team identifier" + SubResourceIDs: + type: "array" + description: "List of Docker resources that will inherit this access control" + items: + type: "string" + example: "617c5f22bb9b023d6daab7cba43a57576f83492867bc767d1c59416b065e5f08" + description: "Docker resource identifier" + ResourceControlUpdateRequest: + type: "object" + properties: + AdministratorsOnly: + type: "boolean" + example: false + description: "Restrict access to the associated resource to administrators\ + \ only" + Users: + type: "array" + description: "List of user identifiers with access to the associated resource" + items: + type: "integer" + example: 1 + description: "User identifier" + Teams: + type: "array" + description: "List of team identifiers with access to the associated resource" + items: + type: "integer" + example: 1 + description: "Team identifier" + SettingsUpdateRequest: + type: "object" + required: + - "AuthenticationMethod" + - "TemplatesURL" + properties: + TemplatesURL: + type: "string" + example: "https://raw.githubusercontent.com/portainer/templates/master/templates.json" + description: "URL to the templates that will be displayed in the UI when navigating\ + \ to App Templates" + LogoURL: + type: "string" + example: "https://mycompany.mydomain.tld/logo.png" + description: "URL to a logo that will be displayed on the login page as well\ + \ as on top of the sidebar. Will use default Portainer logo when value is\ + \ empty string" + BlackListedLabels: + type: "array" + description: "A list of label name & value that will be used to hide containers\ + \ when querying containers" + items: + $ref: "#/definitions/Settings_BlackListedLabels" + DisplayExternalContributors: + type: "boolean" + example: false + description: "Whether to display or not external templates contributions as\ + \ sub-menus in the UI." + AuthenticationMethod: + type: "integer" + example: 1 + description: "Active authentication method for the Portainer instance. Valid\ + \ values are: 1 for managed or 2 for LDAP." + LDAPSettings: + $ref: "#/definitions/LDAPSettings" + UserCreateRequest: + type: "object" + required: + - "Password" + - "Role" + - "Username" + properties: + Username: + type: "string" + example: "bob" + description: "Username" + Password: + type: "string" + example: "cg9Wgky3" + description: "Password" + Role: + type: "integer" + example: 1 + description: "User role (1 for administrator account and 2 for regular account)" + UserCreateResponse: + type: "object" + properties: + Id: + type: "integer" + example: 1 + description: "Id of the user" + UserListResponse: + type: "array" + items: + $ref: "#/definitions/User" + UserUpdateRequest: + type: "object" + properties: + Password: + type: "string" + example: "cg9Wgky3" + description: "Password" + Role: + type: "integer" + example: 1 + description: "User role (1 for administrator account and 2 for regular account)" + UserMembershipsResponse: + type: "array" + items: + $ref: "#/definitions/TeamMembership" + UserPasswordCheckRequest: + type: "object" + required: + - "Password" + properties: + Password: + type: "string" + example: "cg9Wgky3" + description: "Password" + UserPasswordCheckResponse: + type: "object" + properties: + valid: + type: "boolean" + example: true + description: "Is the password valid" + TeamCreateRequest: + type: "object" + required: + - "Name" + properties: + Name: + type: "string" + example: "developers" + description: "Name" + TeamCreateResponse: + type: "object" + properties: + Id: + type: "integer" + example: 1 + description: "Id of the team" + TeamListResponse: + type: "array" + items: + $ref: "#/definitions/Team" + TeamUpdateRequest: + type: "object" + required: + - "Name" + properties: + Name: + type: "string" + example: "developers" + description: "Name" + TeamMembershipsResponse: + type: "array" + items: + $ref: "#/definitions/TeamMembership" + TeamMembershipCreateRequest: + type: "object" + required: + - "Role" + - "TeamID" + - "UserID" + properties: + UserID: + type: "integer" + example: 1 + description: "User identifier" + TeamID: + type: "integer" + example: 1 + description: "Team identifier" + Role: + type: "integer" + example: 1 + description: "Role for the user inside the team (1 for leader and 2 for regular\ + \ member)" + TeamMembershipCreateResponse: + type: "object" + properties: + Id: + type: "integer" + example: 1 + description: "Id of the team membership" + TeamMembershipListResponse: + type: "array" + items: + $ref: "#/definitions/TeamMembership" + TeamMembershipUpdateRequest: + type: "object" + required: + - "Role" + - "TeamID" + - "UserID" + properties: + UserID: + type: "integer" + example: 1 + description: "User identifier" + TeamID: + type: "integer" + example: 1 + description: "Team identifier" + Role: + type: "integer" + example: 1 + description: "Role for the user inside the team (1 for leader and 2 for regular\ + \ member)" + SettingsLDAPCheckRequest: + type: "object" + properties: + LDAPSettings: + $ref: "#/definitions/LDAPSettings" + UserAdminInitRequest: + type: "object" + properties: + Password: + type: "string" + example: "admin-password" + description: "Password for the admin user" + TemplateListResponse: + type: "array" + items: + $ref: "#/definitions/Template" + Template: + type: "object" + properties: + title: + type: "string" + example: "Nginx" + description: "Title of the template" + description: + type: "string" + example: "High performance web server" + description: "Description of the template" + logo: + type: "string" + example: "https://cloudinovasi.id/assets/img/logos/nginx.png" + description: "URL of the template's logo" + image: + type: "string" + example: "nginx:latest" + description: "The Docker image associated to the template" diff --git a/app/app.js b/app/app.js index ab07489e5..d7774719f 100644 --- a/app/app.js +++ b/app/app.js @@ -51,6 +51,7 @@ angular.module('portainer', [ 'service', 'services', 'settings', + 'settingsAuthentication', 'sidebar', 'stats', 'swarm', @@ -243,7 +244,7 @@ angular.module('portainer', [ } }) .state('actions.create.container', { - url: '/container', + url: '/container/:from', views: { 'content@': { templateUrl: 'app/components/createContainer/createcontainer.html', @@ -563,6 +564,19 @@ angular.module('portainer', [ } } }) + .state('settings_authentication', { + url: '^/settings/authentication', + views: { + 'content@': { + templateUrl: 'app/components/settingsAuthentication/settingsAuthentication.html', + controller: 'SettingsAuthenticationController' + }, + 'sidebar@': { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) .state('task', { url: '^/task/:id', views: { @@ -744,18 +758,16 @@ angular.module('portainer', [ // This is your docker url that the api will use to make requests // You need to set this to the api endpoint without the port i.e. http://192.168.1.9 // .constant('DOCKER_PORT', '') // Docker port, leave as an empty string if no port is required. If you have a port, prefix it with a ':' i.e. :4243 - .constant('DOCKER_ENDPOINT', 'api/docker') - .constant('CONFIG_ENDPOINT', 'api/old_settings') - .constant('SETTINGS_ENDPOINT', 'api/settings') - .constant('STATUS_ENDPOINT', 'api/status') - .constant('AUTH_ENDPOINT', 'api/auth') - .constant('USERS_ENDPOINT', 'api/users') - .constant('TEAMS_ENDPOINT', 'api/teams') - .constant('TEAM_MEMBERSHIPS_ENDPOINT', 'api/team_memberships') - .constant('RESOURCE_CONTROL_ENDPOINT', 'api/resource_controls') - .constant('ENDPOINTS_ENDPOINT', 'api/endpoints') - .constant('DOCKERHUB_ENDPOINT', 'api/dockerhub') - .constant('REGISTRIES_ENDPOINT', 'api/registries') - .constant('TEMPLATES_ENDPOINT', 'api/templates') + .constant('API_ENDPOINT_AUTH', 'api/auth') + .constant('API_ENDPOINT_DOCKERHUB', 'api/dockerhub') + .constant('API_ENDPOINT_ENDPOINTS', 'api/endpoints') + .constant('API_ENDPOINT_REGISTRIES', 'api/registries') + .constant('API_ENDPOINT_RESOURCE_CONTROLS', 'api/resource_controls') + .constant('API_ENDPOINT_SETTINGS', 'api/settings') + .constant('API_ENDPOINT_STATUS', 'api/status') + .constant('API_ENDPOINT_USERS', 'api/users') + .constant('API_ENDPOINT_TEAMS', 'api/teams') + .constant('API_ENDPOINT_TEAM_MEMBERSHIPS', 'api/team_memberships') + .constant('API_ENDPOINT_TEMPLATES', 'api/templates') .constant('DEFAULT_TEMPLATES_URL', 'https://raw.githubusercontent.com/portainer/templates/master/templates.json') .constant('PAGINATION_MAX_ITEMS', 10); diff --git a/app/components/container/container.html b/app/components/container/container.html index 11b831635..b399a1eca 100644 --- a/app/components/container/container.html +++ b/app/components/container/container.html @@ -20,6 +20,8 @@ + + @@ -94,6 +96,7 @@ @@ -261,7 +264,7 @@ -
+
@@ -295,6 +298,9 @@ + + No networks connected. +
diff --git a/app/components/container/containerController.js b/app/components/container/containerController.js index 22acf056c..1908aa8d4 100644 --- a/app/components/container/containerController.js +++ b/app/components/container/containerController.js @@ -1,6 +1,6 @@ angular.module('container', []) -.controller('ContainerController', ['$scope', '$state','$stateParams', '$filter', 'Container', 'ContainerCommit', 'ContainerService', 'ImageHelper', 'Network', 'Notifications', 'Pagination', 'ModalService', -function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, ContainerService, ImageHelper, Network, Notifications, Pagination, ModalService) { +.controller('ContainerController', ['$q', '$scope', '$state','$stateParams', '$filter', 'Container', 'ContainerCommit', 'ContainerHelper', 'ContainerService', 'ImageHelper', 'Network', 'NetworkService', 'Notifications', 'Pagination', 'ModalService', 'ResourceControlService', 'RegistryService', 'ImageService', +function ($q, $scope, $state, $stateParams, $filter, Container, ContainerCommit, ContainerHelper, ContainerService, ImageHelper, Network, NetworkService, Notifications, Pagination, ModalService, ResourceControlService, RegistryService, ImageService) { $scope.activityTime = 0; $scope.portBindings = []; $scope.config = { @@ -196,6 +196,88 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Con }); }; + $scope.duplicate = function() { + ModalService.confirmExperimentalFeature(function (experimental) { + if(!experimental) { return; } + $state.go('actions.create.container', {from: $stateParams.id}, {reload: true}); + }); + }; + + $scope.confirmRemove = function () { + var title = 'You are about to remove a container.'; + if ($scope.container.State.Running) { + title = 'You are about to remove a running container.'; + } + ModalService.confirmContainerDeletion( + title, + function (result) { + if(!result) { return; } + var cleanAssociatedVolumes = false; + if (result[0]) { + cleanAssociatedVolumes = true; + } + $scope.remove(cleanAssociatedVolumes); + } + ); + }; + + function recreateContainer(pullImage) { + $('#loadingViewSpinner').show(); + var container = $scope.container; + var config = ContainerHelper.configFromContainer(container.Model); + ContainerService.remove(container, true) + .then(function success() { + return RegistryService.retrieveRegistryFromRepository(container.Config.Image); + }) + .then(function success(data) { + return $q.when(!pullImage || ImageService.pullImage(container.Config.Image, data, true)); + }) + .then(function success() { + return ContainerService.createAndStartContainer(config); + }) + .then(function success(data) { + if (!container.ResourceControl) { + return true; + } else { + var containerIdentifier = data.Id; + var resourceControl = container.ResourceControl; + var users = resourceControl.UserAccesses.map(function(u) { + return u.UserId; + }); + var teams = resourceControl.TeamAccesses.map(function(t) { + return t.TeamId; + }); + return ResourceControlService.createResourceControl(resourceControl.AdministratorsOnly, + users, teams, containerIdentifier, 'container', []); + } + }) + .then(function success(data) { + Notifications.success('Container successfully re-created'); + $state.go('containers', {}, {reload: true}); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to re-create container'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + } + + $scope.recreate = function() { + ModalService.confirmExperimentalFeature(function (experimental) { + if(!experimental) { return; } + + ModalService.confirmContainerRecreation(function (result) { + if(!result) { return; } + var pullImage = false; + if (result[0]) { + pullImage = true; + } + recreateContainer(pullImage); + }); + }); + }; + $scope.containerJoinNetwork = function containerJoinNetwork(container, networkId) { $('#joinNetworkSpinner').show(); Network.connect({id: networkId}, { Container: $stateParams.id }, function (d) { @@ -213,25 +295,21 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Con }); }; - Network.query({}, function (d) { - var networks = d; - if ($scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM' || $scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE') { - networks = d.filter(function (network) { - if (network.Scope === 'global') { - return network; - } - }); - networks.push({Name: 'bridge'}); - networks.push({Name: 'host'}); - networks.push({Name: 'none'}); - } - $scope.availableNetworks = networks; - if (!_.find(networks, {'Name': 'bridge'})) { - networks.push({Name: 'nat'}); - } - }, function (e) { - Notifications.error('Failure', e, 'Unable to retrieve networks'); - }); + var provider = $scope.applicationState.endpoint.mode.provider; + var apiVersion = $scope.applicationState.endpoint.apiVersion; + NetworkService.networks( + provider === 'DOCKER_STANDALONE' || provider === 'DOCKER_SWARM_MODE', + false, + provider === 'DOCKER_SWARM_MODE' && apiVersion >= 1.25, + provider === 'DOCKER_SWARM' + ) + .then(function success(data) { + var networks = data; + $scope.availableNetworks = networks; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve networks'); + }); update(); }]); diff --git a/app/components/containerConsole/containerConsoleController.js b/app/components/containerConsole/containerConsoleController.js index 2ff1214ad..aa37611f4 100644 --- a/app/components/containerConsole/containerConsoleController.js +++ b/app/components/containerConsole/containerConsoleController.js @@ -38,7 +38,7 @@ function ($scope, $stateParams, Container, Image, EndpointProvider, Notification $scope.connect = function() { $('#loadConsoleSpinner').show(); - var termWidth = Math.round($('#terminal-container').width() / 8.2); + var termWidth = Math.floor(($('#terminal-container').width() - 20) / 8.39); var termHeight = 30; var command = $scope.formValues.isCustomCommand ? $scope.formValues.customCommand : $scope.formValues.command; @@ -97,6 +97,11 @@ function ($scope, $stateParams, Container, Image, EndpointProvider, Notification term.open(document.getElementById('terminal-container'), true); term.resize(width, height); term.setOption('cursorBlink', true); + term.fit(); + + window.onresize = function() { + term.fit(); + }; socket.onmessage = function (e) { term.write(e.data); diff --git a/app/components/containers/containersController.js b/app/components/containers/containersController.js index b08d41705..82dd10b64 100644 --- a/app/components/containers/containersController.js +++ b/app/components/containers/containersController.js @@ -205,7 +205,8 @@ angular.module('containers', []) if(container.Status === 'paused') { $scope.state.noPausedItemsSelected = false; - } else if(container.Status === 'stopped') { + } else if(container.Status === 'stopped' || + container.Status === 'created') { $scope.state.noStoppedItemsSelected = false; } else if(container.Status === 'running') { $scope.state.noRunningItemsSelected = false; diff --git a/app/components/createContainer/createContainerController.js b/app/components/createContainer/createContainerController.js index 048e3f2ce..ecc556182 100644 --- a/app/components/createContainer/createContainerController.js +++ b/app/components/createContainer/createContainerController.js @@ -1,14 +1,13 @@ // @@OLD_SERVICE_CONTROLLER: this service should be rewritten to use services. // See app/components/templates/templatesController.js as a reference. angular.module('createContainer', []) -.controller('CreateContainerController', ['$q', '$scope', '$state', '$stateParams', '$filter', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'Network', 'ResourceControlService', 'Authentication', 'Notifications', 'ContainerService', 'ImageService', 'FormValidator', -function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper, Image, ImageHelper, Volume, Network, ResourceControlService, Authentication, Notifications, ContainerService, ImageService, FormValidator) { +.controller('CreateContainerController', ['$q', '$scope', '$state', '$stateParams', '$filter', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'NetworkService', 'ResourceControlService', 'Authentication', 'Notifications', 'ContainerService', 'ImageService', 'FormValidator', 'ModalService', 'RegistryService', +function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper, Image, ImageHelper, Volume, NetworkService, ResourceControlService, Authentication, Notifications, ContainerService, ImageService, FormValidator, ModalService, RegistryService) { $scope.formValues = { alwaysPull: true, Console: 'none', Volumes: [], - Registry: '', NetworkContainer: '', Labels: [], ExtraHosts: [], @@ -92,6 +91,8 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper, $scope.config.HostConfig.Devices.splice(index, 1); }; + $scope.fromContainerMultipleNetworks = false; + function prepareImageConfig(config) { var image = config.Image; var registry = $scope.formValues.Registry; @@ -179,6 +180,7 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper, var networkMode = mode; if (containerName) { networkMode += ':' + containerName; + config.Hostname = ''; } config.HostConfig.NetworkMode = networkMode; @@ -233,6 +235,213 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper, return config; } + function confirmCreateContainer() { + var deferred = $q.defer(); + Container.query({ all: 1, filters: {name: ['^/' + $scope.config.name + '$'] }}).$promise + .then(function success(data) { + var existingContainer = data[0]; + if (existingContainer) { + ModalService.confirm({ + title: 'Are you sure ?', + message: 'A container with the same name already exists. Portainer can automatically remove it and re-create one. Do you want to replace it?', + buttons: { + confirm: { + label: 'Replace', + className: 'btn-danger' + } + }, + callback: function onConfirm(confirmed) { + if(!confirmed) { deferred.resolve(false); } + else { + // Remove old container + ContainerService.remove(existingContainer, true) + .then(function success(data) { + Notifications.success('Container Removed', existingContainer.Id); + deferred.resolve(true); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to remove container', err: err }); + }); + } + } + }); + } else { + deferred.resolve(true); + } + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve containers'); + return undefined; + }); + return deferred.promise; + } + + function loadFromContainerCmd(d) { + if ($scope.config.Cmd) { + $scope.config.Cmd = ContainerHelper.commandArrayToString($scope.config.Cmd); + } else { + $scope.config.Cmd = ''; + } + } + + function loadFromContainerPortBindings(d) { + var bindings = []; + for (var p in $scope.config.HostConfig.PortBindings) { + if ({}.hasOwnProperty.call($scope.config.HostConfig.PortBindings, p)) { + var hostPort = ''; + if ($scope.config.HostConfig.PortBindings[p][0].HostIp) { + hostPort = $scope.config.HostConfig.PortBindings[p][0].HostIp + ':'; + } + hostPort += $scope.config.HostConfig.PortBindings[p][0].HostPort; + var b = { + 'hostPort': hostPort, + 'containerPort': p.split('/')[0], + 'protocol': p.split('/')[1] + }; + bindings.push(b); + } + } + $scope.config.HostConfig.PortBindings = bindings; + } + + function loadFromContainerVolumes(d) { + for (var v in d.Mounts) { + if ({}.hasOwnProperty.call(d.Mounts, v)) { + var mount = d.Mounts[v]; + var volume = { + 'type': mount.Type, + 'name': mount.Name || mount.Source, + 'containerPath': mount.Destination, + 'readOnly': mount.RW === false + }; + $scope.formValues.Volumes.push(volume); + } + } + } + + function loadFromContainerNetworkConfig(d) { + $scope.config.NetworkingConfig = { + EndpointsConfig: {} + }; + var networkMode = d.HostConfig.NetworkMode; + if (networkMode === 'default') { + $scope.config.HostConfig.NetworkMode = 'bridge'; + if (!_.find($scope.availableNetworks, {'Name': 'bridge'})) { + $scope.config.HostConfig.NetworkMode = 'nat'; + } + } + if ($scope.config.HostConfig.NetworkMode.indexOf('container:') === 0) { + var netContainer = $scope.config.HostConfig.NetworkMode.split(/^container:/)[1]; + $scope.config.HostConfig.NetworkMode = 'container'; + for (var c in $scope.runningContainers) { + if ($scope.runningContainers[c].Names && $scope.runningContainers[c].Names[0] === '/' + netContainer) { + $scope.formValues.NetworkContainer = $scope.runningContainers[c]; + } + } + } + $scope.fromContainerMultipleNetworks = Object.keys(d.NetworkSettings.Networks).length >= 2; + if (d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode]) { + if (d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].IPAMConfig) { + if (d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].IPAMConfig.IPv4Address) { + $scope.formValues.IPv4 = d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].IPAMConfig.IPv4Address; + } + if (d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].IPAMConfig.IPv6Address) { + $scope.formValues.IPv6 = d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].IPAMConfig.IPv6Address; + } + } + } + $scope.config.NetworkingConfig.EndpointsConfig[$scope.config.HostConfig.NetworkMode] = d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode]; + // ExtraHosts + for (var h in $scope.config.HostConfig.ExtraHosts) { + if ({}.hasOwnProperty.call($scope.config.HostConfig.ExtraHosts, h)) { + $scope.formValues.ExtraHosts.push({'value': $scope.config.HostConfig.ExtraHosts[h]}); + $scope.config.HostConfig.ExtraHosts = []; + } + } + } + + function loadFromContainerEnvrionmentVariables(d) { + var envArr = []; + for (var e in $scope.config.Env) { + if ({}.hasOwnProperty.call($scope.config.Env, e)) { + var arr = $scope.config.Env[e].split(/\=(.+)/); + envArr.push({'name': arr[0], 'value': arr[1]}); + } + } + $scope.config.Env = envArr; + } + + function loadFromContainerLabels(d) { + for (var l in $scope.config.Labels) { + if ({}.hasOwnProperty.call($scope.config.Labels, l)) { + $scope.formValues.Labels.push({ name: l, value: $scope.config.Labels[l]}); + } + } + } + + function loadFromContainerConsole(d) { + if ($scope.config.OpenStdin && $scope.config.Tty) { + $scope.formValues.Console = 'both'; + } else if (!$scope.config.OpenStdin && $scope.config.Tty) { + $scope.formValues.Console = 'tty'; + } else if ($scope.config.OpenStdin && !$scope.config.Tty) { + $scope.formValues.Console = 'interactive'; + } else if (!$scope.config.OpenStdin && !$scope.config.Tty) { + $scope.formValues.Console = 'none'; + } + } + + function loadFromContainerDevices(d) { + var path = []; + for (var dev in $scope.config.HostConfig.Devices) { + if ({}.hasOwnProperty.call($scope.config.HostConfig.Devices, dev)) { + var device = $scope.config.HostConfig.Devices[dev]; + path.push({'pathOnHost': device.PathOnHost, 'pathInContainer': device.PathInContainer}); + } + } + $scope.config.HostConfig.Devices = path; + } + + function loadFromContainerImageConfig(d) { + // If no registry found, we let default DockerHub and let full image path + var imageInfo = ImageHelper.extractImageAndRegistryFromRepository($scope.config.Image); + RegistryService.retrieveRegistryFromRepository($scope.config.Image) + .then(function success(data) { + if (data) { + $scope.config.Image = imageInfo.image; + $scope.formValues.Registry = data; + } + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrive registry'); + }); + } + + function loadFromContainerSpec() { + // Get container + Container.get({ id: $stateParams.from }).$promise + .then(function success(d) { + var fromContainer = new ContainerDetailsViewModel(d); + if (!fromContainer.ResourceControl) { + $scope.formValues.AccessControlData.AccessControlEnabled = false; + } + $scope.fromContainer = fromContainer; + $scope.config = ContainerHelper.configFromContainer(fromContainer.Model); + loadFromContainerCmd(d); + loadFromContainerPortBindings(d); + loadFromContainerVolumes(d); + loadFromContainerNetworkConfig(d); + loadFromContainerEnvrionmentVariables(d); + loadFromContainerLabels(d); + loadFromContainerConsole(d); + loadFromContainerDevices(d); + loadFromContainerImageConfig(d); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve container'); + }); + } + function initView() { Volume.query({}, function (d) { $scope.availableVolumes = d.Volumes; @@ -240,31 +449,36 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper, Notifications.error('Failure', e, 'Unable to retrieve volumes'); }); - Network.query({}, function (d) { - var networks = d; - if ($scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM' || $scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE') { - networks = d.filter(function (network) { - if (network.Scope === 'global') { - return network; - } - }); - $scope.globalNetworkCount = networks.length; - networks.push({Name: 'bridge'}); - networks.push({Name: 'host'}); - networks.push({Name: 'none'}); - } - networks.push({Name: 'container'}); + var provider = $scope.applicationState.endpoint.mode.provider; + var apiVersion = $scope.applicationState.endpoint.apiVersion; + NetworkService.networks( + provider === 'DOCKER_STANDALONE' || provider === 'DOCKER_SWARM_MODE', + false, + provider === 'DOCKER_SWARM_MODE' && apiVersion >= 1.25, + provider === 'DOCKER_SWARM' + ) + .then(function success(data) { + var networks = data; + networks.push({ Name: 'container' }); $scope.availableNetworks = networks; - if (!_.find(networks, {'Name': 'bridge'})) { + + if (_.find(networks, {'Name': 'nat'})) { $scope.config.HostConfig.NetworkMode = 'nat'; } - }, function (e) { - Notifications.error('Failure', e, 'Unable to retrieve networks'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve networks'); }); Container.query({}, function (d) { var containers = d; $scope.runningContainers = containers; + if ($stateParams.from !== '') { + loadFromContainerSpec(); + } else { + $scope.fromContainer = {}; + $scope.formValues.Registry = {}; + } }, function(e) { Notifications.error('Failure', e, 'Unable to retrieve running containers'); }); @@ -284,19 +498,27 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper, } $scope.create = function () { - $('#createContainerSpinner').show(); + confirmCreateContainer() + .then(function success(confirm) { + if (!confirm) { + return false; + } + $('#createContainerSpinner').show(); + var accessControlData = $scope.formValues.AccessControlData; + var userDetails = Authentication.getUserDetails(); + var isAdmin = userDetails.role === 1 ? true : false; - var accessControlData = $scope.formValues.AccessControlData; - var userDetails = Authentication.getUserDetails(); - var isAdmin = userDetails.role === 1 ? true : false; + if (!validateForm(accessControlData, isAdmin)) { + $('#createContainerSpinner').hide(); + return; + } - if (!validateForm(accessControlData, isAdmin)) { - $('#createContainerSpinner').hide(); - return; - } - - var config = prepareConfiguration(); - createContainer(config, accessControlData); + var config = prepareConfiguration(); + createContainer(config, accessControlData); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to create container'); + }); }; function createContainer(config, accessControlData) { diff --git a/app/components/createContainer/createcontainer.html b/app/components/createContainer/createcontainer.html index 356f244f5..fdae6b4de 100644 --- a/app/components/createContainer/createcontainer.html +++ b/app/components/createContainer/createcontainer.html @@ -23,7 +23,7 @@
- +
@@ -98,7 +98,7 @@
- +
@@ -110,6 +110,10 @@ Cancel {{ state.formValidationError }} + + + This container is connected to multiple networks, only one network will be kept at creation time. +
diff --git a/app/components/createService/createServiceController.js b/app/components/createService/createServiceController.js index 3ede157b1..d7723503d 100644 --- a/app/components/createService/createServiceController.js +++ b/app/components/createService/createServiceController.js @@ -25,6 +25,7 @@ function ($q, $scope, $state, Service, ServiceHelper, SecretHelper, SecretServic PlacementConstraints: [], PlacementPreferences: [], UpdateDelay: 0, + UpdateOrder: 'stop-first', FailureAction: 'pause', Secrets: [], AccessControlData: new AccessControlFormData() @@ -199,7 +200,8 @@ function ($q, $scope, $state, Service, ServiceHelper, SecretHelper, SecretServic config.UpdateConfig = { Parallelism: input.Parallelism || 0, Delay: input.UpdateDelay || 0, - FailureAction: input.FailureAction + FailureAction: input.FailureAction, + Order: input.UpdateOrder }; } @@ -302,10 +304,12 @@ function ($q, $scope, $state, Service, ServiceHelper, SecretHelper, SecretServic function initView() { $('#loadingViewSpinner').show(); + var apiVersion = $scope.applicationState.endpoint.apiVersion; + $q.all({ volumes: VolumeService.volumes(), - networks: NetworkService.retrieveSwarmNetworks(), - secrets: SecretService.secrets() + secrets: apiVersion >= 1.25 ? SecretService.secrets() : [], + networks: NetworkService.networks(true, true, false, false) }) .then(function success(data) { $scope.availableVolumes = data.volumes; diff --git a/app/components/createService/createservice.html b/app/components/createService/createservice.html index 49e796069..d34ad9bd1 100644 --- a/app/components/createService/createservice.html +++ b/app/components/createService/createservice.html @@ -101,7 +101,7 @@
- +
@@ -132,7 +132,7 @@
  • Network
  • Labels
  • Update config
  • -
  • Secrets
  • +
  • Secrets
  • Placement
  • @@ -377,12 +377,12 @@
    - -
    + +
    -
    -

    +

    +

    Maximum number of tasks to be updated simultaneously (0 to update all at once).

    @@ -390,12 +390,12 @@
    - -
    + +
    -
    -

    +

    +

    Amount of time between updates.

    @@ -403,15 +403,39 @@
    -
    - -
    + +
    +
    +
    +

    + Action taken on failure to start after update. +

    +
    +
    + +
    + + +
    +
    + + +
    +
    +
    +

    + Operation order on failure. +

    +
    + +
    +
    diff --git a/app/components/createVolume/createVolumeController.js b/app/components/createVolume/createVolumeController.js index 579b948d1..107042181 100644 --- a/app/components/createVolume/createVolumeController.js +++ b/app/components/createVolume/createVolumeController.js @@ -1,6 +1,6 @@ angular.module('createVolume', []) -.controller('CreateVolumeController', ['$scope', '$state', 'VolumeService', 'SystemService', 'ResourceControlService', 'Authentication', 'Notifications', 'FormValidator', -function ($scope, $state, VolumeService, SystemService, ResourceControlService, Authentication, Notifications, FormValidator) { +.controller('CreateVolumeController', ['$q', '$scope', '$state', 'VolumeService', 'PluginService', 'ResourceControlService', 'Authentication', 'Notifications', 'FormValidator', +function ($q, $scope, $state, VolumeService, PluginService, ResourceControlService, Authentication, Notifications, FormValidator) { $scope.formValues = { Driver: 'local', @@ -70,8 +70,10 @@ function ($scope, $state, VolumeService, SystemService, ResourceControlService, function initView() { $('#loadingViewSpinner').show(); - if ($scope.applicationState.endpoint.mode.provider !== 'DOCKER_SWARM') { - SystemService.getVolumePlugins() + var endpointProvider = $scope.applicationState.endpoint.mode.provider; + var apiVersion = $scope.applicationState.endpoint.apiVersion; + if (endpointProvider !== 'DOCKER_SWARM') { + PluginService.volumePlugins(apiVersion < 1.25 || endpointProvider === 'VMWARE_VIC') .then(function success(data) { $scope.availableVolumeDrivers = data; }) diff --git a/app/components/createVolume/createvolume.html b/app/components/createVolume/createvolume.html index 2ca08ec84..c9e795dd7 100644 --- a/app/components/createVolume/createvolume.html +++ b/app/components/createVolume/createvolume.html @@ -65,7 +65,7 @@
    - +
    diff --git a/app/components/image/imageController.js b/app/components/image/imageController.js index f5c0c6142..d76dfb356 100644 --- a/app/components/image/imageController.js +++ b/app/components/image/imageController.js @@ -1,6 +1,6 @@ angular.module('image', []) -.controller('ImageController', ['$scope', '$stateParams', '$state', '$timeout', 'ImageService', 'RegistryService', 'Notifications', -function ($scope, $stateParams, $state, $timeout, ImageService, RegistryService, Notifications) { +.controller('ImageController', ['$q', '$scope', '$stateParams', '$state', '$timeout', 'ImageService', 'RegistryService', 'Notifications', +function ($q, $scope, $stateParams, $state, $timeout, ImageService, RegistryService, Notifications) { $scope.formValues = { Image: '', Registry: '' @@ -109,11 +109,16 @@ function ($scope, $stateParams, $state, $timeout, ImageService, RegistryService, }); }; - function retrieveImageDetails() { + function initView() { $('#loadingViewSpinner').show(); - ImageService.image($stateParams.id) + var endpointProvider = $scope.applicationState.endpoint.mode.provider; + $q.all({ + image: ImageService.image($stateParams.id), + history: endpointProvider !== 'VMWARE_VIC' ? ImageService.history($stateParams.id) : [] + }) .then(function success(data) { - $scope.image = data; + $scope.image = data.image; + $scope.history = data.history; }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to retrieve image details'); @@ -122,19 +127,7 @@ function ($scope, $stateParams, $state, $timeout, ImageService, RegistryService, .finally(function final() { $('#loadingViewSpinner').hide(); }); - - $('#loadingViewSpinner').show(); - ImageService.history($stateParams.id) - .then(function success(data) { - $scope.history = data; - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to retrieve image history'); - }) - .finally(function final() { - $('#loadingViewSpinner').hide(); - }); } - retrieveImageDetails(); + initView(); }]); diff --git a/app/components/images/images.html b/app/components/images/images.html index 19dd4669b..5959c0bf2 100644 --- a/app/components/images/images.html +++ b/app/components/images/images.html @@ -70,7 +70,7 @@
    - + @@ -125,7 +125,11 @@ {{ image.Id|truncate:20}} - Unused + + Unused + + {{ tag }} @@ -135,7 +139,7 @@ Loading... - + No images available. diff --git a/app/components/images/imagesController.js b/app/components/images/imagesController.js index cbb723274..e8c9ca30f 100644 --- a/app/components/images/imagesController.js +++ b/app/components/images/imagesController.js @@ -94,7 +94,8 @@ function ($scope, $state, ImageService, Notifications, Pagination, ModalService) function fetchImages() { $('#loadImagesSpinner').show(); var endpointProvider = $scope.applicationState.endpoint.mode.provider; - ImageService.images(endpointProvider !== 'DOCKER_SWARM') + var apiVersion = $scope.applicationState.endpoint.apiVersion; + ImageService.images(apiVersion >= 1.25 && endpointProvider !== 'DOCKER_SWARM' && endpointProvider !== 'VMWARE_VIC') .then(function success(data) { $scope.images = data; }) diff --git a/app/components/network/network.html b/app/components/network/network.html index b2c1c9b6e..d5eacd7f1 100644 --- a/app/components/network/network.html +++ b/app/components/network/network.html @@ -67,7 +67,7 @@
    -
    +
    diff --git a/app/components/network/networkController.js b/app/components/network/networkController.js index 291b6ca9e..63f9cb4e8 100644 --- a/app/components/network/networkController.js +++ b/app/components/network/networkController.js @@ -51,8 +51,9 @@ function ($scope, $state, $stateParams, $filter, Network, Container, ContainerHe } function getContainersInNetwork(network) { + var apiVersion = $scope.applicationState.endpoint.apiVersion; if (network.Containers) { - if ($scope.applicationState.endpoint.apiVersion < 1.24) { + if (apiVersion < 1.24) { Container.query({}, function success(data) { var containersInNetwork = data.filter(function filter(container) { if (container.HostConfig.NetworkMode === network.Name) { @@ -81,12 +82,20 @@ function ($scope, $state, $stateParams, $filter, Network, Container, ContainerHe function initView() { $('#loadingViewSpinner').show(); - Network.get({id: $stateParams.id}, function success(data) { + Network.get({id: $stateParams.id}).$promise + .then(function success(data) { $scope.network = data; - getContainersInNetwork(data); - }, function error(err) { + var endpointProvider = $scope.applicationState.endpoint.mode.provider; + if (endpointProvider !== 'VMWARE_VIC') { + getContainersInNetwork(data); + } + }) + .catch(function error(err) { $('#loadingViewSpinner').hide(); Notifications.error('Failure', err, 'Unable to retrieve network info'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); }); } diff --git a/app/components/networks/networks.html b/app/components/networks/networks.html index 5d8c110be..7e8442cff 100644 --- a/app/components/networks/networks.html +++ b/app/components/networks/networks.html @@ -29,7 +29,7 @@ Note: The network will be created using the overlay driver and will allow containers to communicate across the hosts of your cluster.
    -
    +
    Note: The network will be created using the bridge driver.
    diff --git a/app/components/service/includes/placementPreferences.html b/app/components/service/includes/placementPreferences.html index 210556e72..d92fae5d9 100644 --- a/app/components/service/includes/placementPreferences.html +++ b/app/components/service/includes/placementPreferences.html @@ -1,4 +1,4 @@ -
    +
    diff --git a/app/components/service/includes/updateconfig.html b/app/components/service/includes/updateconfig.html index 0a0b9180d..469b715d2 100644 --- a/app/components/service/includes/updateconfig.html +++ b/app/components/service/includes/updateconfig.html @@ -47,18 +47,38 @@

    + + Order + +
    + + +
    + + +

    + Operation order on failure. +

    + +
    diff --git a/app/components/service/serviceController.js b/app/components/service/serviceController.js index b61dbb256..3b5f8e2a1 100644 --- a/app/components/service/serviceController.js +++ b/app/components/service/serviceController.js @@ -1,6 +1,6 @@ angular.module('service', []) -.controller('ServiceController', ['$q', '$scope', '$stateParams', '$state', '$location', '$timeout', '$anchorScroll', 'ServiceService', 'Secret', 'SecretHelper', 'Service', 'ServiceHelper', 'LabelHelper', 'TaskService', 'NodeService', 'Notifications', 'Pagination', 'ModalService', -function ($q, $scope, $stateParams, $state, $location, $timeout, $anchorScroll, ServiceService, Secret, SecretHelper, Service, ServiceHelper, LabelHelper, TaskService, NodeService, Notifications, Pagination, ModalService) { +.controller('ServiceController', ['$q', '$scope', '$stateParams', '$state', '$location', '$timeout', '$anchorScroll', 'ServiceService', 'SecretService', 'SecretHelper', 'Service', 'ServiceHelper', 'LabelHelper', 'TaskService', 'NodeService', 'Notifications', 'Pagination', 'ModalService', +function ($q, $scope, $stateParams, $state, $location, $timeout, $anchorScroll, ServiceService, SecretService, SecretHelper, Service, ServiceHelper, LabelHelper, TaskService, NodeService, Notifications, Pagination, ModalService) { $scope.state = {}; $scope.state.pagination_count = Pagination.getPaginationCount('service_tasks'); @@ -218,8 +218,10 @@ function ($q, $scope, $stateParams, $state, $location, $timeout, $anchorScroll, config.UpdateConfig = { Parallelism: service.UpdateParallelism, Delay: service.UpdateDelay, - FailureAction: service.UpdateFailureAction + FailureAction: service.UpdateFailureAction, + Order: service.UpdateOrder }; + config.TaskTemplate.RestartPolicy = { Condition: service.RestartCondition, Delay: service.RestartDelay, @@ -288,7 +290,7 @@ function ($q, $scope, $stateParams, $state, $location, $timeout, $anchorScroll, function initView() { $('#loadingViewSpinner').show(); - + var apiVersion = $scope.applicationState.endpoint.apiVersion; ServiceService.service($stateParams.id) .then(function success(data) { var service = data; @@ -304,21 +306,17 @@ function ($q, $scope, $stateParams, $state, $location, $timeout, $anchorScroll, return $q.all({ tasks: TaskService.serviceTasks(service.Name), nodes: NodeService.nodes(), - secrets: Secret.query({}).$promise + secrets: apiVersion >= 1.25 ? SecretService.secrets() : [] }); }) .then(function success(data) { $scope.tasks = data.tasks; $scope.nodes = data.nodes; - - $scope.secrets = data.secrets.map(function (secret) { - return new SecretViewModel(secret); - }); + $scope.secrets = data.secrets; $timeout(function() { $anchorScroll(); }); - }) .catch(function error(err) { $scope.secrets = []; @@ -329,20 +327,6 @@ function ($q, $scope, $stateParams, $state, $location, $timeout, $anchorScroll, }); } - function fetchSecrets() { - $('#loadSecretsSpinner').show(); - Secret.query({}, function (d) { - $scope.secrets = d.map(function (secret) { - return new SecretViewModel(secret); - }); - $('#loadSecretsSpinner').hide(); - }, function(e) { - $('#loadSecretsSpinner').hide(); - Notifications.error('Failure', e, 'Unable to retrieve secrets'); - $scope.secrets = []; - }); - } - $scope.updateServiceAttribute = function updateServiceAttribute(service, name) { if (service[name] !== originalService[name] || !(name in originalService)) { service.hasChanges = true; diff --git a/app/components/settingsAuthentication/settingsAuthentication.html b/app/components/settingsAuthentication/settingsAuthentication.html new file mode 100644 index 000000000..92fd050d7 --- /dev/null +++ b/app/components/settingsAuthentication/settingsAuthentication.html @@ -0,0 +1,254 @@ + + + + + + Settings > Authentication + + + +
    +
    + + + +
    +
    + Authentication method +
    +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    + Information +
    +
    + + When using internal authentication, Portainer will encrypt user passwords and store credentials locally. + +
    +
    + + When using LDAP authentication, Portainer will delegate user authentication to a LDAP server (exception for the admin user that always uses internal authentication). +

    + + Users still need to be created in Portainer beforehand. +

    +
    +
    + +
    +
    + LDAP configuration +
    + +
    + +
    + +
    +
    + +
    + +
    + +
    +
    + +
    + +
    + +
    +
    + +
    + +
    + + +
    +
    + +
    + LDAP security +
    + + +
    +
    + + +
    +
    + + + +
    +
    + + +
    +
    + + + +
    +
    + + +
    +
    + + + +
    + +
    + +
    + + + {{ formValues.TLSCACert.name }} + + + + +
    +
    + +
    + + +
    + +
    + + +
    +
    + +
    + User search configurations +
    + + +
    + +
    + + Extra search configuration + +
    + +
    + +
    + +
    + + +
    + +
    +
    +
    + +
    + +
    +
    + +
    +
    + +
    + + add search configuration + +
    + +
    + +
    + + +
    +
    + + + +
    +
    + + +
    +
    +
    +
    +
    diff --git a/app/components/settingsAuthentication/settingsAuthenticationController.js b/app/components/settingsAuthentication/settingsAuthenticationController.js new file mode 100644 index 000000000..faf16873b --- /dev/null +++ b/app/components/settingsAuthentication/settingsAuthenticationController.js @@ -0,0 +1,93 @@ +angular.module('settingsAuthentication', []) +.controller('SettingsAuthenticationController', ['$q', '$scope', 'Notifications', 'SettingsService', 'FileUploadService', +function ($q, $scope, Notifications, SettingsService, FileUploadService) { + + $scope.state = { + successfulConnectivityCheck: false, + failedConnectivityCheck: false, + uploadInProgress: false + }; + + $scope.formValues = { + TLSCACert: '' + }; + + $scope.addSearchConfiguration = function() { + $scope.LDAPSettings.SearchSettings.push({ BaseDN: '', UserNameAttribute: '', Filter: '' }); + }; + + $scope.removeSearchConfiguration = function(index) { + $scope.LDAPSettings.SearchSettings.splice(index, 1); + }; + + $scope.LDAPConnectivityCheck = function() { + $('#connectivityCheckSpinner').show(); + var settings = $scope.settings; + var TLSCAFile = $scope.formValues.TLSCACert !== settings.LDAPSettings.TLSConfig.TLSCACert ? $scope.formValues.TLSCACert : null; + + var uploadRequired = ($scope.LDAPSettings.TLSConfig.TLS || $scope.LDAPSettings.StartTLS) && !$scope.LDAPSettings.TLSConfig.TLSSkipVerify; + $scope.state.uploadInProgress = uploadRequired; + + $q.when(!uploadRequired || FileUploadService.uploadLDAPTLSFiles(TLSCAFile, null, null)) + .then(function success(data) { + return SettingsService.checkLDAPConnectivity(settings); + }) + .then(function success(data) { + $scope.state.failedConnectivityCheck = false; + $scope.state.successfulConnectivityCheck = true; + Notifications.success('Connection to LDAP successful'); + }) + .catch(function error(err) { + $scope.state.failedConnectivityCheck = true; + $scope.state.successfulConnectivityCheck = false; + Notifications.error('Failure', err, 'Connection to LDAP failed'); + }) + .finally(function final() { + $scope.state.uploadInProgress = false; + $('#connectivityCheckSpinner').hide(); + }); + }; + + $scope.saveSettings = function() { + $('#updateSettingsSpinner').show(); + var settings = $scope.settings; + var TLSCAFile = $scope.formValues.TLSCACert !== settings.LDAPSettings.TLSConfig.TLSCACert ? $scope.formValues.TLSCACert : null; + + var uploadRequired = ($scope.LDAPSettings.TLSConfig.TLS || $scope.LDAPSettings.StartTLS) && !$scope.LDAPSettings.TLSConfig.TLSSkipVerify; + $scope.state.uploadInProgress = uploadRequired; + + $q.when(!uploadRequired || FileUploadService.uploadLDAPTLSFiles(TLSCAFile, null, null)) + .then(function success(data) { + return SettingsService.update(settings); + }) + .then(function success(data) { + Notifications.success('Authentication settings updated'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to update authentication settings'); + }) + .finally(function final() { + $scope.state.uploadInProgress = false; + $('#updateSettingsSpinner').hide(); + }); + }; + + function initView() { + $('#loadingViewSpinner').show(); + SettingsService.settings() + .then(function success(data) { + var settings = data; + $scope.settings = settings; + $scope.LDAPSettings = settings.LDAPSettings; + $scope.formValues.TLSCACert = settings.LDAPSettings.TLSConfig.TLSCACert; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve application settings'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + } + + initView(); +}]); diff --git a/app/components/sidebar/sidebar.html b/app/components/sidebar/sidebar.html index 3d8d6a2c3..068dab150 100644 --- a/app/components/sidebar/sidebar.html +++ b/app/components/sidebar/sidebar.html @@ -25,7 +25,7 @@ LinuxServer.io
    - - - - -
    +
    diff --git a/app/components/stats/statsController.js b/app/components/stats/statsController.js index d5a76616e..1b8de729d 100644 --- a/app/components/stats/statsController.js +++ b/app/components/stats/statsController.js @@ -213,5 +213,8 @@ function (Pagination, $scope, Notifications, $timeout, Container, ContainerTop, }, function (e) { Notifications.error('Failure', e, 'Unable to retrieve container info'); }); - $scope.getTop(); + var endpointProvider = $scope.applicationState.endpoint.mode.provider; + if (endpointProvider !== 'VMWARE_VIC') { + $scope.getTop(); + } }]); diff --git a/app/components/team/team.html b/app/components/team/team.html index 727372a0d..d292f6fd8 100644 --- a/app/components/team/team.html +++ b/app/components/team/team.html @@ -65,7 +65,7 @@ - + Name @@ -125,14 +125,14 @@ - + Name - + Team Role diff --git a/app/components/teams/teams.html b/app/components/teams/teams.html index 2d082186d..6e42cb2c7 100644 --- a/app/components/teams/teams.html +++ b/app/components/teams/teams.html @@ -95,7 +95,7 @@ - + Name diff --git a/app/components/templates/templates.html b/app/components/templates/templates.html index c4a99b153..4d2e0dd71 100644 --- a/app/components/templates/templates.html +++ b/app/components/templates/templates.html @@ -68,7 +68,7 @@
    - +
    diff --git a/app/components/templates/templatesController.js b/app/components/templates/templatesController.js index 5b62b5b1b..9a8d90455 100644 --- a/app/components/templates/templatesController.js +++ b/app/components/templates/templatesController.js @@ -144,27 +144,20 @@ function ($scope, $q, $state, $stateParams, $anchorScroll, $filter, ContainerSer return containerMapping; } - function filterNetworksBasedOnProvider(networks) { - var endpointProvider = $scope.applicationState.endpoint.mode.provider; - if (endpointProvider === 'DOCKER_SWARM' || endpointProvider === 'DOCKER_SWARM_MODE') { - if (endpointProvider === 'DOCKER_SWARM') { - networks = NetworkService.filterGlobalNetworks(networks); - } else { - networks = NetworkService.filterSwarmModeAttachableNetworks(networks); - } - $scope.globalNetworkCount = networks.length; - NetworkService.addPredefinedLocalNetworks(networks); - } - return networks; - } - function initTemplates() { var templatesKey = $stateParams.key; + var provider = $scope.applicationState.endpoint.mode.provider; + var apiVersion = $scope.applicationState.endpoint.apiVersion; + $q.all({ templates: TemplateService.getTemplates(templatesKey), containers: ContainerService.getContainers(0), - networks: NetworkService.networks(), - volumes: VolumeService.getVolumes() + volumes: VolumeService.getVolumes(), + networks: NetworkService.networks( + provider === 'DOCKER_STANDALONE' || provider === 'DOCKER_SWARM_MODE', + false, + provider === 'DOCKER_SWARM_MODE' && apiVersion >= 1.25, + provider === 'DOCKER_SWARM') }) .then(function success(data) { $scope.templates = data.templates; @@ -174,8 +167,10 @@ function ($scope, $q, $state, $stateParams, $anchorScroll, $filter, ContainerSer }); $scope.availableCategories = _.sortBy(_.uniq(availableCategories)); $scope.runningContainers = data.containers; - $scope.availableNetworks = filterNetworksBasedOnProvider(data.networks); $scope.availableVolumes = data.volumes.Volumes; + var networks = data.networks; + $scope.availableNetworks = networks; + $scope.globalNetworkCount = networks.length; }) .catch(function error(err) { $scope.templates = []; diff --git a/app/components/user/user.html b/app/components/user/user.html index 62f0a1e75..b1a6d3478 100644 --- a/app/components/user/user.html +++ b/app/components/user/user.html @@ -32,29 +32,6 @@ - @@ -62,7 +39,7 @@
    -
    +
    diff --git a/app/components/user/userController.js b/app/components/user/userController.js index 348457c51..dfb3c0489 100644 --- a/app/components/user/userController.js +++ b/app/components/user/userController.js @@ -1,6 +1,6 @@ angular.module('user', []) -.controller('UserController', ['$q', '$scope', '$state', '$stateParams', 'UserService', 'ModalService', 'Notifications', -function ($q, $scope, $state, $stateParams, UserService, ModalService, Notifications) { +.controller('UserController', ['$q', '$scope', '$state', '$stateParams', 'UserService', 'ModalService', 'Notifications', 'SettingsService', +function ($q, $scope, $state, $stateParams, UserService, ModalService, Notifications, SettingsService) { $scope.state = { updatePasswordError: '' @@ -72,12 +72,14 @@ function ($q, $scope, $state, $stateParams, UserService, ModalService, Notificat function initView() { $('#loadingViewSpinner').show(); $q.all({ - user: UserService.user($stateParams.id) + user: UserService.user($stateParams.id), + settings: SettingsService.publicSettings() }) .then(function success(data) { var user = data.user; $scope.user = user; $scope.formValues.Administrator = user.Role === 1 ? true : false; + $scope.AuthenticationMethod = data.settings.AuthenticationMethod; }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to retrieve user information'); diff --git a/app/components/userSettings/userSettings.html b/app/components/userSettings/userSettings.html index 8a20d22f8..c7f5405f7 100644 --- a/app/components/userSettings/userSettings.html +++ b/app/components/userSettings/userSettings.html @@ -1,5 +1,6 @@ + User settings @@ -58,7 +59,11 @@
    - + + + + You cannot change your password when using LDAP authentication. +
    diff --git a/app/components/userSettings/userSettingsController.js b/app/components/userSettings/userSettingsController.js index d8e8f4d43..2146d58e0 100644 --- a/app/components/userSettings/userSettingsController.js +++ b/app/components/userSettings/userSettingsController.js @@ -1,6 +1,6 @@ angular.module('userSettings', []) -.controller('UserSettingsController', ['$scope', '$state', '$sanitize', 'Authentication', 'UserService', 'Notifications', -function ($scope, $state, $sanitize, Authentication, UserService, Notifications) { +.controller('UserSettingsController', ['$scope', '$state', '$sanitize', 'Authentication', 'UserService', 'Notifications', 'SettingsService', +function ($scope, $state, $sanitize, Authentication, UserService, Notifications, SettingsService) { $scope.formValues = { currentPassword: '', newPassword: '', @@ -26,4 +26,19 @@ function ($scope, $state, $sanitize, Authentication, UserService, Notifications) } }); }; + + function initView() { + SettingsService.publicSettings() + .then(function success(data) { + $scope.AuthenticationMethod = data.AuthenticationMethod; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve application settings'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + } + + initView(); }]); diff --git a/app/components/users/users.html b/app/components/users/users.html index 68e0ef9ea..79c9999d3 100644 --- a/app/components/users/users.html +++ b/app/components/users/users.html @@ -17,7 +17,10 @@
    - +
    @@ -27,8 +30,8 @@
    -
    - +
    +
    @@ -38,8 +41,8 @@
    -
    - +
    +
    @@ -95,7 +98,7 @@
    - + {{ state.userCreationError }} @@ -140,19 +143,26 @@ - + Name - + Role + + + Authentication + + + + @@ -166,6 +176,10 @@ {{ user.RoleName }} + + Internal + LDAP + Edit diff --git a/app/components/users/usersController.js b/app/components/users/usersController.js index bc595c2f6..0184e84f0 100644 --- a/app/components/users/usersController.js +++ b/app/components/users/usersController.js @@ -1,6 +1,6 @@ angular.module('users', []) -.controller('UsersController', ['$q', '$scope', '$state', '$sanitize', 'UserService', 'TeamService', 'TeamMembershipService', 'ModalService', 'Notifications', 'Pagination', 'Authentication', -function ($q, $scope, $state, $sanitize, UserService, TeamService, TeamMembershipService, ModalService, Notifications, Pagination, Authentication) { +.controller('UsersController', ['$q', '$scope', '$state', '$sanitize', 'UserService', 'TeamService', 'TeamMembershipService', 'ModalService', 'Notifications', 'Pagination', 'Authentication', 'SettingsService', +function ($q, $scope, $state, $sanitize, UserService, TeamService, TeamMembershipService, ModalService, Notifications, Pagination, Authentication, SettingsService) { $scope.state = { userCreationError: '', selectedItemCount: 0, @@ -140,13 +140,15 @@ function ($q, $scope, $state, $sanitize, UserService, TeamService, TeamMembershi $q.all({ users: UserService.users(true), teams: isAdmin ? TeamService.teams() : UserService.userLeadingTeams(userDetails.ID), - memberships: TeamMembershipService.memberships() + memberships: TeamMembershipService.memberships(), + settings: SettingsService.publicSettings() }) .then(function success(data) { var users = data.users; assignTeamLeaders(users, data.memberships); $scope.users = users; $scope.teams = data.teams; + $scope.AuthenticationMethod = data.settings.AuthenticationMethod; }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to retrieve users and teams'); diff --git a/app/components/volume/volume.html b/app/components/volume/volume.html index fcdc8f52b..15ebfc41e 100644 --- a/app/components/volume/volume.html +++ b/app/components/volume/volume.html @@ -50,6 +50,7 @@ diff --git a/app/components/volumes/volumes.html b/app/components/volumes/volumes.html index cfbb11252..c90e40945 100644 --- a/app/components/volumes/volumes.html +++ b/app/components/volumes/volumes.html @@ -38,7 +38,7 @@ Attached @@ -85,7 +85,7 @@ {{ volume.Id|truncate:25 }} - Dangling + Unused {{ volume.Driver }} {{ volume.Mountpoint | truncatelr }} diff --git a/app/directives/accessControlForm/porAccessControlFormController.js b/app/directives/accessControlForm/porAccessControlFormController.js index cd40cc10c..c567bf290 100644 --- a/app/directives/accessControlForm/porAccessControlFormController.js +++ b/app/directives/accessControlForm/porAccessControlFormController.js @@ -1,6 +1,6 @@ angular.module('portainer') -.controller('porAccessControlFormController', ['$q', 'UserService', 'Notifications', 'Authentication', 'ResourceControlService', -function ($q, UserService, Notifications, Authentication, ResourceControlService) { +.controller('porAccessControlFormController', ['$q', 'UserService', 'TeamService', 'Notifications', 'Authentication', 'ResourceControlService', +function ($q, UserService, TeamService, Notifications, Authentication, ResourceControlService) { var ctrl = this; ctrl.availableTeams = []; @@ -42,7 +42,7 @@ function ($q, UserService, Notifications, Authentication, ResourceControlService } $q.all({ - availableTeams: UserService.userTeams(userDetails.ID), + availableTeams: TeamService.teams(), availableUsers: isAdmin ? UserService.users(false) : [] }) .then(function success(data) { diff --git a/app/directives/accessControlPanel/por-access-control-panel.js b/app/directives/accessControlPanel/por-access-control-panel.js index 6bde5f128..afed037c2 100644 --- a/app/directives/accessControlPanel/por-access-control-panel.js +++ b/app/directives/accessControlPanel/por-access-control-panel.js @@ -2,6 +2,8 @@ angular.module('portainer').component('porAccessControlPanel', { templateUrl: 'app/directives/accessControlPanel/porAccessControlPanel.html', controller: 'porAccessControlPanelController', bindings: { + // The component will use this identifier when updating the resource control object. + resourceId: '<', // The component will display information about this resource control object. resourceControl: '=', // This component is usually displayed inside a resource-details view. diff --git a/app/directives/accessControlPanel/porAccessControlPanelController.js b/app/directives/accessControlPanel/porAccessControlPanelController.js index 13914606a..36cec2a97 100644 --- a/app/directives/accessControlPanel/porAccessControlPanelController.js +++ b/app/directives/accessControlPanel/porAccessControlPanelController.js @@ -1,6 +1,6 @@ angular.module('portainer') -.controller('porAccessControlPanelController', ['$q', '$state', 'UserService', 'ResourceControlService', 'Notifications', 'Authentication', 'ModalService', 'FormValidator', -function ($q, $state, UserService, ResourceControlService, Notifications, Authentication, ModalService, FormValidator) { +.controller('porAccessControlPanelController', ['$q', '$state', 'UserService', 'TeamService', 'ResourceControlService', 'Notifications', 'Authentication', 'ModalService', 'FormValidator', +function ($q, $state, UserService, TeamService, ResourceControlService, Notifications, Authentication, ModalService, FormValidator) { var ctrl = this; @@ -73,7 +73,7 @@ function ($q, $state, UserService, ResourceControlService, Notifications, Authen function updateOwnership() { $('#loadingViewSpinner').show(); - var resourceId = ctrl.resourceControl.ResourceId; + var resourceId = ctrl.resourceId; var ownershipParameters = processOwnershipFormValues(); ResourceControlService.applyResourceControlChange(ctrl.resourceType, resourceId, @@ -121,7 +121,7 @@ function ($q, $state, UserService, ResourceControlService, Notifications, Authen return $q.all({ availableUsers: isAdmin ? UserService.users(false) : [], - availableTeams: isAdmin || data.isPartOfRestrictedUsers ? UserService.userTeams(userId) : [] + availableTeams: isAdmin || data.isPartOfRestrictedUsers ? TeamService.teams() : [] }); }) .then(function success(data) { diff --git a/app/directives/imageRegistry/porImageRegistryController.js b/app/directives/imageRegistry/porImageRegistryController.js index 3eeb3d0bb..496209be7 100644 --- a/app/directives/imageRegistry/porImageRegistryController.js +++ b/app/directives/imageRegistry/porImageRegistryController.js @@ -12,7 +12,11 @@ function ($q, RegistryService, DockerHubService, Notifications) { var dockerhub = data.dockerhub; var registries = data.registries; ctrl.availableRegistries = [dockerhub].concat(registries); - ctrl.registry = dockerhub; + if (!ctrl.registry.Id) { + ctrl.registry = dockerhub; + } else { + ctrl.registry = _.find(ctrl.availableRegistries, { 'Id': ctrl.registry.Id }); + } }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to retrieve registries'); diff --git a/app/filters/filters.js b/app/filters/filters.js index b5573bcd5..b4cac3fe0 100644 --- a/app/filters/filters.js +++ b/app/filters/filters.js @@ -1,3 +1,9 @@ +function includeString(text, values) { + return values.some(function(val){ + return text.indexOf(val) !== -1; + }); +} + angular.module('portainer.filters', []) .filter('truncate', function () { 'use strict'; @@ -35,15 +41,13 @@ angular.module('portainer.filters', []) 'use strict'; return function (text) { var status = _.toLower(text); - if (status.indexOf('new') !== -1 || status.indexOf('allocated') !== -1 || - status.indexOf('assigned') !== -1 || status.indexOf('accepted') !== -1) { + if (includeString(status, ['new', 'allocated', 'assigned', 'accepted'])) { return 'info'; - } else if (status.indexOf('pending') !== -1) { + } else if (includeString(status, ['pending'])) { return 'warning'; - } else if (status.indexOf('shutdown') !== -1 || status.indexOf('failed') !== -1 || - status.indexOf('rejected') !== -1) { + } else if (includeString(status, ['shutdown', 'failed', 'rejected'])) { return 'danger'; - } else if (status.indexOf('complete') !== -1) { + } else if (includeString(status, ['complete'])) { return 'primary'; } return 'success'; @@ -53,11 +57,11 @@ angular.module('portainer.filters', []) 'use strict'; return function (text) { var status = _.toLower(text); - if (status.indexOf('paused') !== -1 || status.indexOf('starting') !== -1) { + if (includeString(status, ['paused', 'starting'])) { return 'warning'; - } else if (status.indexOf('created') !== -1) { + } else if (includeString(status, ['created'])) { return 'info'; - } else if (status.indexOf('stopped') !== -1 || status.indexOf('unhealthy') !== -1) { + } else if (includeString(status, ['stopped', 'unhealthy', 'dead'])) { return 'danger'; } return 'success'; @@ -67,17 +71,19 @@ angular.module('portainer.filters', []) 'use strict'; return function (text) { var status = _.toLower(text); - if (status.indexOf('paused') !== -1) { + if (includeString(status, ['paused'])) { return 'paused'; - } else if (status.indexOf('created') !== -1) { + } else if (includeString(status, ['dead'])) { + return 'dead'; + } else if (includeString(status, ['created'])) { return 'created'; - } else if (status.indexOf('exited') !== -1) { + } else if (includeString(status, ['exited'])) { return 'stopped'; - } else if (status.indexOf('(healthy)') !== -1) { + } else if (includeString(status, ['(healthy)'])) { return 'healthy'; - } else if (status.indexOf('(unhealthy)') !== -1) { + } else if (includeString(status, ['(unhealthy)'])) { return 'unhealthy'; - } else if (status.indexOf('(health: starting)') !== -1) { + } else if (includeString(status, ['(health: starting)'])) { return 'starting'; } return 'running'; @@ -113,6 +119,9 @@ angular.module('portainer.filters', []) if (state === undefined) { return ''; } + if (state.Dead) { + return 'Dead'; + } if (state.Ghost && state.Running) { return 'Ghost'; } diff --git a/app/helpers/containerHelper.js b/app/helpers/containerHelper.js index 6f74332c7..544bc1ac8 100644 --- a/app/helpers/containerHelper.js +++ b/app/helpers/containerHelper.js @@ -7,5 +7,56 @@ angular.module('portainer.helpers') return splitargs(command); }; + helper.commandArrayToString = function(array) { + return array.map(function(elem) { + return '\'' + elem + '\''; + }).join(' '); + }; + + helper.configFromContainer = function(container) { + var config = container.Config; + // HostConfig + config.HostConfig = container.HostConfig; + // Name + config.name = container.Name.replace(/^\//g, ''); + // Network + var mode = config.HostConfig.NetworkMode; + config.NetworkingConfig = { + 'EndpointsConfig': {} + }; + config.NetworkingConfig.EndpointsConfig = container.NetworkSettings.Networks; + if (mode.indexOf('container:') !== -1) { + delete config.Hostname; + delete config.ExposedPorts; + } + // Set volumes + var binds = []; + var volumes = {}; + for (var v in container.Mounts) { + if ({}.hasOwnProperty.call(container.Mounts, v)) { + var mount = container.Mounts[v]; + var volume = { + 'type': mount.Type, + 'name': mount.Name || mount.Source, + 'containerPath': mount.Destination, + 'readOnly': mount.RW === false + }; + var name = mount.Name || mount.Source; + var containerPath = mount.Destination; + if (name && containerPath) { + var bind = name + ':' + containerPath; + volumes[containerPath] = {}; + if (mount.RW === false) { + bind += ':ro'; + } + binds.push(bind); + } + } + } + config.HostConfig.Binds = binds; + config.Volumes = volumes; + return config; + }; + return helper; }]); diff --git a/app/helpers/imageHelper.js b/app/helpers/imageHelper.js index 7c3012fbc..324f28519 100644 --- a/app/helpers/imageHelper.js +++ b/app/helpers/imageHelper.js @@ -8,10 +8,15 @@ angular.module('portainer.helpers') var slashCount = _.countBy(repository)['/']; var registry = null; var image = repository; - if (slashCount > 1) { - // assume something/some/thing[/...] + if (slashCount >= 1) { + // assume something/something[/...] registry = repository.substr(0, repository.indexOf('/')); - image = repository.substr(repository.indexOf('/') + 1); + // assume valid DNS name or IP (contains at least one '.') + if (_.countBy(registry)['.'] > 0) { + image = repository.substr(repository.indexOf('/') + 1); + } else { + registry = null; + } } return { diff --git a/app/helpers/infoHelper.js b/app/helpers/infoHelper.js index fd3aa511a..c78e86bbc 100644 --- a/app/helpers/infoHelper.js +++ b/app/helpers/infoHelper.js @@ -16,7 +16,11 @@ angular.module('portainer.helpers') } } else { if (!info.Swarm || _.isEmpty(info.Swarm.NodeID)) { - mode.provider = 'DOCKER_STANDALONE'; + if (info.ID === 'vSphere Integrated Containers') { + mode.provider = 'VMWARE_VIC'; + } else { + mode.provider = 'DOCKER_STANDALONE'; + } } else { mode.provider = 'DOCKER_SWARM_MODE'; if (info.Swarm.ControlAvailable) { diff --git a/app/models/api/settings/ldapSettings.js b/app/models/api/settings/ldapSettings.js new file mode 100644 index 000000000..2da574598 --- /dev/null +++ b/app/models/api/settings/ldapSettings.js @@ -0,0 +1,12 @@ +function LDAPSettingsViewModel(data) { + this.ReaderDN = data.ReaderDN; + this.Password = data.Password; + this.URL = data.URL; + this.SearchSettings = data.SearchSettings; +} + +function LDAPSearchSettings(BaseDN, UsernameAttribute, Filter) { + this.BaseDN = BaseDN; + this.UsernameAttribute = UsernameAttribute; + this.Filter = Filter; +} diff --git a/app/models/api/settings.js b/app/models/api/settings/settings.js similarity index 70% rename from app/models/api/settings.js rename to app/models/api/settings/settings.js index 889d18ad1..b8d473172 100644 --- a/app/models/api/settings.js +++ b/app/models/api/settings/settings.js @@ -3,4 +3,6 @@ function SettingsViewModel(data) { this.LogoURL = data.LogoURL; this.BlackListedLabels = data.BlackListedLabels; this.DisplayExternalContributors = data.DisplayExternalContributors; + this.AuthenticationMethod = data.AuthenticationMethod; + this.LDAPSettings = data.LDAPSettings; } diff --git a/app/models/api/user.js b/app/models/api/user.js index 1177fc137..27afa4b67 100644 --- a/app/models/api/user.js +++ b/app/models/api/user.js @@ -7,5 +7,6 @@ function UserViewModel(data) { } else { this.RoleName = 'user'; } + this.AuthenticationMethod = data.AuthenticationMethod; this.Checked = false; } diff --git a/app/models/docker/container.js b/app/models/docker/container.js index 8041a24e3..552963b22 100644 --- a/app/models/docker/container.js +++ b/app/models/docker/container.js @@ -11,14 +11,18 @@ function ContainerViewModel(data) { this.Command = data.Command; this.Checked = false; this.Labels = data.Labels; - this.Ports = []; this.Mounts = data.Mounts; - for (var i = 0; i < data.Ports.length; ++i) { - var p = data.Ports[i]; - if (p.PublicPort) { - this.Ports.push({ host: p.IP, private: p.PrivatePort, public: p.PublicPort }); + + this.Ports = []; + if (data.Ports) { + for (var i = 0; i < data.Ports.length; ++i) { + var p = data.Ports[i]; + if (p.PublicPort) { + this.Ports.push({ host: p.IP, private: p.PrivatePort, public: p.PublicPort }); + } } } + if (data.Portainer) { if (data.Portainer.ResourceControl) { this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl); diff --git a/app/models/docker/containerDetails.js b/app/models/docker/containerDetails.js index 58ecd17d7..63945ff41 100644 --- a/app/models/docker/containerDetails.js +++ b/app/models/docker/containerDetails.js @@ -1,6 +1,8 @@ function ContainerDetailsViewModel(data) { + this.Model = data; this.Id = data.Id; this.State = data.State; + this.Created = data.Created; this.Name = data.Name; this.NetworkSettings = data.NetworkSettings; this.Args = data.Args; diff --git a/app/models/docker/plugin.js b/app/models/docker/plugin.js new file mode 100644 index 000000000..fde1ab840 --- /dev/null +++ b/app/models/docker/plugin.js @@ -0,0 +1,9 @@ +// This model is based on https://github.com/moby/moby/blob/0ac25dfc751fa4304ab45afd5cd8705c2235d101/api/types/plugin.go#L8-L31 +// instead of the official documentation. +// See: https://github.com/moby/moby/issues/34241 +function PluginViewModel(data) { + this.Id = data.Id; + this.Name = data.Name; + this.Enabled = data.Enabled; + this.Config = data.Config; +} diff --git a/app/models/docker/service.js b/app/models/docker/service.js index 5b2a7f53e..3c58cd9e3 100644 --- a/app/models/docker/service.js +++ b/app/models/docker/service.js @@ -79,10 +79,12 @@ function ServiceViewModel(data, runningTasks, nodes) { this.UpdateParallelism = (typeof data.Spec.UpdateConfig.Parallelism !== undefined) ? data.Spec.UpdateConfig.Parallelism || 0 : 1; this.UpdateDelay = data.Spec.UpdateConfig.Delay || 0; this.UpdateFailureAction = data.Spec.UpdateConfig.FailureAction || 'pause'; + this.UpdateOrder = data.Spec.UpdateConfig.Order || 'stop-first'; } else { this.UpdateParallelism = 1; this.UpdateDelay = 0; this.UpdateFailureAction = 'pause'; + this.UpdateOrder = 'stop-first'; } this.RollbackConfig = data.Spec.RollbackConfig; diff --git a/app/rest/api/auth.js b/app/rest/api/auth.js index c7ed49447..17d4dc22d 100644 --- a/app/rest/api/auth.js +++ b/app/rest/api/auth.js @@ -1,7 +1,7 @@ angular.module('portainer.rest') -.factory('Auth', ['$resource', 'AUTH_ENDPOINT', function AuthFactory($resource, AUTH_ENDPOINT) { +.factory('Auth', ['$resource', 'API_ENDPOINT_AUTH', function AuthFactory($resource, API_ENDPOINT_AUTH) { 'use strict'; - return $resource(AUTH_ENDPOINT, {}, { + return $resource(API_ENDPOINT_AUTH, {}, { login: { method: 'POST' } diff --git a/app/rest/api/dockerhub.js b/app/rest/api/dockerhub.js index 3a07d4aa2..5572be7f7 100644 --- a/app/rest/api/dockerhub.js +++ b/app/rest/api/dockerhub.js @@ -1,7 +1,7 @@ angular.module('portainer.rest') -.factory('DockerHub', ['$resource', 'DOCKERHUB_ENDPOINT', function DockerHubFactory($resource, DOCKERHUB_ENDPOINT) { +.factory('DockerHub', ['$resource', 'API_ENDPOINT_DOCKERHUB', function DockerHubFactory($resource, API_ENDPOINT_DOCKERHUB) { 'use strict'; - return $resource(DOCKERHUB_ENDPOINT, {}, { + return $resource(API_ENDPOINT_DOCKERHUB, {}, { get: { method: 'GET' }, update: { method: 'PUT' } }); diff --git a/app/rest/api/endpoint.js b/app/rest/api/endpoint.js index c2cf17fdf..bef6f960b 100644 --- a/app/rest/api/endpoint.js +++ b/app/rest/api/endpoint.js @@ -1,7 +1,7 @@ angular.module('portainer.rest') -.factory('Endpoints', ['$resource', 'ENDPOINTS_ENDPOINT', function EndpointsFactory($resource, ENDPOINTS_ENDPOINT) { +.factory('Endpoints', ['$resource', 'API_ENDPOINT_ENDPOINTS', function EndpointsFactory($resource, API_ENDPOINT_ENDPOINTS) { 'use strict'; - return $resource(ENDPOINTS_ENDPOINT + '/:id/:action', {}, { + return $resource(API_ENDPOINT_ENDPOINTS + '/:id/:action', {}, { create: { method: 'POST' }, query: { method: 'GET', isArray: true }, get: { method: 'GET', params: { id: '@id' } }, diff --git a/app/rest/api/registry.js b/app/rest/api/registry.js index 9ba68ee46..819e19061 100644 --- a/app/rest/api/registry.js +++ b/app/rest/api/registry.js @@ -1,7 +1,7 @@ angular.module('portainer.rest') -.factory('Registries', ['$resource', 'REGISTRIES_ENDPOINT', function RegistriesFactory($resource, REGISTRIES_ENDPOINT) { +.factory('Registries', ['$resource', 'API_ENDPOINT_REGISTRIES', function RegistriesFactory($resource, API_ENDPOINT_REGISTRIES) { 'use strict'; - return $resource(REGISTRIES_ENDPOINT + '/:id/:action', {}, { + return $resource(API_ENDPOINT_REGISTRIES + '/:id/:action', {}, { create: { method: 'POST' }, query: { method: 'GET', isArray: true }, get: { method: 'GET', params: { id: '@id' } }, diff --git a/app/rest/api/resourceControl.js b/app/rest/api/resourceControl.js index bcdebde65..5503ce0db 100644 --- a/app/rest/api/resourceControl.js +++ b/app/rest/api/resourceControl.js @@ -1,7 +1,7 @@ angular.module('portainer.rest') -.factory('ResourceControl', ['$resource', 'RESOURCE_CONTROL_ENDPOINT', function ResourceControlFactory($resource, RESOURCE_CONTROL_ENDPOINT) { +.factory('ResourceControl', ['$resource', 'API_ENDPOINT_RESOURCE_CONTROLS', function ResourceControlFactory($resource, API_ENDPOINT_RESOURCE_CONTROLS) { 'use strict'; - return $resource(RESOURCE_CONTROL_ENDPOINT + '/:id', {}, { + return $resource(API_ENDPOINT_RESOURCE_CONTROLS + '/:id', {}, { create: { method: 'POST' }, get: { method: 'GET', params: { id: '@id' } }, update: { method: 'PUT', params: { id: '@id' } }, diff --git a/app/rest/api/settings.js b/app/rest/api/settings.js index 5e7471ad9..9a70d8885 100644 --- a/app/rest/api/settings.js +++ b/app/rest/api/settings.js @@ -1,8 +1,10 @@ angular.module('portainer.rest') -.factory('Settings', ['$resource', 'SETTINGS_ENDPOINT', function SettingsFactory($resource, SETTINGS_ENDPOINT) { +.factory('Settings', ['$resource', 'API_ENDPOINT_SETTINGS', function SettingsFactory($resource, API_ENDPOINT_SETTINGS) { 'use strict'; - return $resource(SETTINGS_ENDPOINT, {}, { + return $resource(API_ENDPOINT_SETTINGS + '/:subResource/:action', {}, { get: { method: 'GET' }, - update: { method: 'PUT' } + update: { method: 'PUT' }, + publicSettings: { method: 'GET', params: { subResource: 'public' } }, + checkLDAPConnectivity: { method: 'PUT', params: { subResource: 'authentication', action: 'checkLDAP' } } }); }]); diff --git a/app/rest/api/status.js b/app/rest/api/status.js index b636ed283..285c67ef5 100644 --- a/app/rest/api/status.js +++ b/app/rest/api/status.js @@ -1,7 +1,7 @@ angular.module('portainer.rest') -.factory('Status', ['$resource', 'STATUS_ENDPOINT', function StatusFactory($resource, STATUS_ENDPOINT) { +.factory('Status', ['$resource', 'API_ENDPOINT_STATUS', function StatusFactory($resource, API_ENDPOINT_STATUS) { 'use strict'; - return $resource(STATUS_ENDPOINT, {}, { + return $resource(API_ENDPOINT_STATUS, {}, { get: { method: 'GET' } }); }]); diff --git a/app/rest/api/team.js b/app/rest/api/team.js index fd55e95b3..0c98e8742 100644 --- a/app/rest/api/team.js +++ b/app/rest/api/team.js @@ -1,7 +1,7 @@ angular.module('portainer.rest') -.factory('Teams', ['$resource', 'TEAMS_ENDPOINT', function TeamsFactory($resource, TEAMS_ENDPOINT) { +.factory('Teams', ['$resource', 'API_ENDPOINT_TEAMS', function TeamsFactory($resource, API_ENDPOINT_TEAMS) { 'use strict'; - return $resource(TEAMS_ENDPOINT + '/:id/:entity/:entityId', {}, { + return $resource(API_ENDPOINT_TEAMS + '/:id/:entity/:entityId', {}, { create: { method: 'POST' }, query: { method: 'GET', isArray: true }, get: { method: 'GET', params: { id: '@id' } }, diff --git a/app/rest/api/teamMembership.js b/app/rest/api/teamMembership.js index 39b49134c..51d503265 100644 --- a/app/rest/api/teamMembership.js +++ b/app/rest/api/teamMembership.js @@ -1,7 +1,7 @@ angular.module('portainer.rest') -.factory('TeamMemberships', ['$resource', 'TEAM_MEMBERSHIPS_ENDPOINT', function TeamMembershipsFactory($resource, TEAM_MEMBERSHIPS_ENDPOINT) { +.factory('TeamMemberships', ['$resource', 'API_ENDPOINT_TEAM_MEMBERSHIPS', function TeamMembershipsFactory($resource, API_ENDPOINT_TEAM_MEMBERSHIPS) { 'use strict'; - return $resource(TEAM_MEMBERSHIPS_ENDPOINT + '/:id/:action', {}, { + return $resource(API_ENDPOINT_TEAM_MEMBERSHIPS + '/:id/:action', {}, { create: { method: 'POST' }, query: { method: 'GET', isArray: true }, update: { method: 'PUT', params: { id: '@id' } }, diff --git a/app/rest/api/template.js b/app/rest/api/template.js index ea02b7ade..b01a95575 100644 --- a/app/rest/api/template.js +++ b/app/rest/api/template.js @@ -1,6 +1,6 @@ angular.module('portainer.rest') -.factory('Template', ['$resource', 'TEMPLATES_ENDPOINT', function TemplateFactory($resource, TEMPLATES_ENDPOINT) { - return $resource(TEMPLATES_ENDPOINT, {}, { +.factory('Template', ['$resource', 'API_ENDPOINT_TEMPLATES', function TemplateFactory($resource, API_ENDPOINT_TEMPLATES) { + return $resource(API_ENDPOINT_TEMPLATES, {}, { get: {method: 'GET', isArray: true} }); }]); diff --git a/app/rest/api/user.js b/app/rest/api/user.js index 6189130cc..12a8df34a 100644 --- a/app/rest/api/user.js +++ b/app/rest/api/user.js @@ -1,14 +1,13 @@ angular.module('portainer.rest') -.factory('Users', ['$resource', 'USERS_ENDPOINT', function UsersFactory($resource, USERS_ENDPOINT) { +.factory('Users', ['$resource', 'API_ENDPOINT_USERS', function UsersFactory($resource, API_ENDPOINT_USERS) { 'use strict'; - return $resource(USERS_ENDPOINT + '/:id/:entity/:entityId', {}, { + return $resource(API_ENDPOINT_USERS + '/:id/:entity/:entityId', {}, { create: { method: 'POST' }, query: { method: 'GET', isArray: true }, get: { method: 'GET', params: { id: '@id' } }, update: { method: 'PUT', params: { id: '@id' } }, remove: { method: 'DELETE', params: { id: '@id'} }, queryMemberships: { method: 'GET', isArray: true, params: { id: '@id', entity: 'memberships' } }, - queryTeams: { method: 'GET', isArray: true, params: { id: '@id', entity: 'teams' } }, // RPCs should be moved to a specific endpoint checkPassword: { method: 'POST', params: { id: '@id', entity: 'passwd' } }, checkAdminUser: { method: 'GET', params: { id: 'admin', entity: 'check' }, isArray: true }, diff --git a/app/rest/docker/container.js b/app/rest/docker/container.js index 511e61d5f..1bad5758f 100644 --- a/app/rest/docker/container.js +++ b/app/rest/docker/container.js @@ -1,7 +1,7 @@ angular.module('portainer.rest') -.factory('Container', ['$resource', 'DOCKER_ENDPOINT', 'EndpointProvider', function ContainerFactory($resource, DOCKER_ENDPOINT, EndpointProvider) { +.factory('Container', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function ContainerFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { 'use strict'; - return $resource(DOCKER_ENDPOINT + '/:endpointId/containers/:id/:action', { + return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/containers/:id/:action', { name: '@name', endpointId: EndpointProvider.endpointID }, diff --git a/app/rest/docker/containerCommit.js b/app/rest/docker/containerCommit.js index 5fff5912b..c8007f47d 100644 --- a/app/rest/docker/containerCommit.js +++ b/app/rest/docker/containerCommit.js @@ -1,7 +1,7 @@ angular.module('portainer.rest') -.factory('ContainerCommit', ['$resource', 'DOCKER_ENDPOINT', 'EndpointProvider', function ContainerCommitFactory($resource, DOCKER_ENDPOINT, EndpointProvider) { +.factory('ContainerCommit', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function ContainerCommitFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { 'use strict'; - return $resource(DOCKER_ENDPOINT + '/:endpointId/commit', { + return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/commit', { endpointId: EndpointProvider.endpointID }, { diff --git a/app/rest/docker/containerLogs.js b/app/rest/docker/containerLogs.js index a7dbf85b8..6b77fabb9 100644 --- a/app/rest/docker/containerLogs.js +++ b/app/rest/docker/containerLogs.js @@ -1,11 +1,11 @@ angular.module('portainer.rest') -.factory('ContainerLogs', ['$http', 'DOCKER_ENDPOINT', 'EndpointProvider', function ContainerLogsFactory($http, DOCKER_ENDPOINT, EndpointProvider) { +.factory('ContainerLogs', ['$http', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function ContainerLogsFactory($http, API_ENDPOINT_ENDPOINTS, EndpointProvider) { 'use strict'; return { get: function (id, params, callback) { $http({ method: 'GET', - url: DOCKER_ENDPOINT + '/' + EndpointProvider.endpointID() + '/containers/' + id + '/logs', + url: API_ENDPOINT_ENDPOINTS + '/' + EndpointProvider.endpointID() + '/docker/containers/' + id + '/logs', params: { 'stdout': params.stdout || 0, 'stderr': params.stderr || 0, @@ -13,7 +13,7 @@ angular.module('portainer.rest') 'tail': params.tail || 'all' } }).success(callback).error(function (data, status, headers, config) { - console.log(error, data); + console.log(data); }); } }; diff --git a/app/rest/docker/containerTop.js b/app/rest/docker/containerTop.js index edefb3e1d..57e51d51c 100644 --- a/app/rest/docker/containerTop.js +++ b/app/rest/docker/containerTop.js @@ -1,15 +1,17 @@ angular.module('portainer.rest') -.factory('ContainerTop', ['$http', 'DOCKER_ENDPOINT', 'EndpointProvider', function ($http, DOCKER_ENDPOINT, EndpointProvider) { +.factory('ContainerTop', ['$http', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function ($http, API_ENDPOINT_ENDPOINTS, EndpointProvider) { 'use strict'; return { get: function (id, params, callback, errorCallback) { $http({ method: 'GET', - url: DOCKER_ENDPOINT + '/' + EndpointProvider.endpointID() + '/containers/' + id + '/top', + url: API_ENDPOINT_ENDPOINTS + '/' + EndpointProvider.endpointID() + '/docker/containers/' + id + '/top', params: { ps_args: params.ps_args } - }).success(callback); + }).success(callback).error(function (data, status, headers, config) { + console.log(data); + }); } }; }]); diff --git a/app/rest/docker/exec.js b/app/rest/docker/exec.js index c29036197..530975678 100644 --- a/app/rest/docker/exec.js +++ b/app/rest/docker/exec.js @@ -1,7 +1,7 @@ angular.module('portainer.rest') -.factory('Exec', ['$resource', 'DOCKER_ENDPOINT', 'EndpointProvider', function ExecFactory($resource, DOCKER_ENDPOINT, EndpointProvider) { +.factory('Exec', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function ExecFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { 'use strict'; - return $resource(DOCKER_ENDPOINT + '/:endpointId/exec/:id/:action', { + return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/exec/:id/:action', { endpointId: EndpointProvider.endpointID }, { diff --git a/app/rest/docker/image.js b/app/rest/docker/image.js index c9ddd14bf..439a10861 100644 --- a/app/rest/docker/image.js +++ b/app/rest/docker/image.js @@ -1,8 +1,8 @@ angular.module('portainer.rest') -.factory('Image', ['$resource', 'DOCKER_ENDPOINT', 'EndpointProvider', 'HttpRequestHelper', function ImageFactory($resource, DOCKER_ENDPOINT, EndpointProvider, HttpRequestHelper) { +.factory('Image', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'HttpRequestHelper', function ImageFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, HttpRequestHelper) { 'use strict'; - return $resource(DOCKER_ENDPOINT + '/:endpointId/images/:id/:action', { + return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/images/:id/:action', { endpointId: EndpointProvider.endpointID }, { diff --git a/app/rest/docker/network.js b/app/rest/docker/network.js index 39e10b4b2..561b00a9d 100644 --- a/app/rest/docker/network.js +++ b/app/rest/docker/network.js @@ -1,7 +1,7 @@ angular.module('portainer.rest') -.factory('Network', ['$resource', 'DOCKER_ENDPOINT', 'EndpointProvider', function NetworkFactory($resource, DOCKER_ENDPOINT, EndpointProvider) { +.factory('Network', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function NetworkFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { 'use strict'; - return $resource(DOCKER_ENDPOINT + '/:endpointId/networks/:id/:action', { + return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/networks/:id/:action', { id: '@id', endpointId: EndpointProvider.endpointID }, diff --git a/app/rest/docker/node.js b/app/rest/docker/node.js index 1dc13f588..22f7b3543 100644 --- a/app/rest/docker/node.js +++ b/app/rest/docker/node.js @@ -1,7 +1,7 @@ angular.module('portainer.rest') -.factory('Node', ['$resource', 'DOCKER_ENDPOINT', 'EndpointProvider', function NodeFactory($resource, DOCKER_ENDPOINT, EndpointProvider) { +.factory('Node', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function NodeFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { 'use strict'; - return $resource(DOCKER_ENDPOINT + '/:endpointId/nodes/:id/:action', { + return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/nodes/:id/:action', { endpointId: EndpointProvider.endpointID }, { diff --git a/app/rest/docker/plugin.js b/app/rest/docker/plugin.js new file mode 100644 index 000000000..a0a342d2d --- /dev/null +++ b/app/rest/docker/plugin.js @@ -0,0 +1,9 @@ +angular.module('portainer.rest') +.factory('Plugin', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function PluginFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/plugins/:id/:action', { + endpointId: EndpointProvider.endpointID + }, { + query: { method: 'GET', isArray: true } + }); +}]); diff --git a/app/rest/docker/secret.js b/app/rest/docker/secret.js index 976b8053d..38a593248 100644 --- a/app/rest/docker/secret.js +++ b/app/rest/docker/secret.js @@ -1,7 +1,7 @@ angular.module('portainer.rest') -.factory('Secret', ['$resource', 'DOCKER_ENDPOINT', 'EndpointProvider', function SecretFactory($resource, DOCKER_ENDPOINT, EndpointProvider) { +.factory('Secret', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function SecretFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { 'use strict'; - return $resource(DOCKER_ENDPOINT + '/:endpointId/secrets/:id/:action', { + return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/secrets/:id/:action', { endpointId: EndpointProvider.endpointID }, { get: { method: 'GET', params: {id: '@id'} }, diff --git a/app/rest/docker/service.js b/app/rest/docker/service.js index 721b55a9c..e8ca55962 100644 --- a/app/rest/docker/service.js +++ b/app/rest/docker/service.js @@ -1,7 +1,7 @@ angular.module('portainer.rest') -.factory('Service', ['$resource', 'DOCKER_ENDPOINT', 'EndpointProvider', 'HttpRequestHelper' ,function ServiceFactory($resource, DOCKER_ENDPOINT, EndpointProvider, HttpRequestHelper) { +.factory('Service', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'HttpRequestHelper' ,function ServiceFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, HttpRequestHelper) { 'use strict'; - return $resource(DOCKER_ENDPOINT + '/:endpointId/services/:id/:action', { + return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/services/:id/:action', { endpointId: EndpointProvider.endpointID }, { diff --git a/app/rest/docker/serviceLogs.js b/app/rest/docker/serviceLogs.js index b165b2542..87e03e6b9 100644 --- a/app/rest/docker/serviceLogs.js +++ b/app/rest/docker/serviceLogs.js @@ -1,11 +1,11 @@ angular.module('portainer.rest') -.factory('ServiceLogs', ['$http', 'DOCKER_ENDPOINT', 'EndpointProvider', function ServiceLogsFactory($http, DOCKER_ENDPOINT, EndpointProvider) { +.factory('ServiceLogs', ['$http', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function ServiceLogsFactory($http, API_ENDPOINT_ENDPOINTS, EndpointProvider) { 'use strict'; return { get: function (id, params, callback) { $http({ method: 'GET', - url: DOCKER_ENDPOINT + '/' + EndpointProvider.endpointID() + '/services/' + id + '/logs', + url: API_ENDPOINT_ENDPOINTS + '/' + EndpointProvider.endpointID() + '/docker/services/' + id + '/logs', params: { 'stdout': params.stdout || 0, 'stderr': params.stderr || 0, @@ -13,7 +13,7 @@ angular.module('portainer.rest') 'tail': params.tail || 'all' } }).success(callback).error(function (data, status, headers, config) { - console.log(error, data); + console.log(data); }); } }; diff --git a/app/rest/docker/swarm.js b/app/rest/docker/swarm.js index cec81e85f..d365ea5d1 100644 --- a/app/rest/docker/swarm.js +++ b/app/rest/docker/swarm.js @@ -1,7 +1,7 @@ angular.module('portainer.rest') -.factory('Swarm', ['$resource', 'DOCKER_ENDPOINT', 'EndpointProvider', function SwarmFactory($resource, DOCKER_ENDPOINT, EndpointProvider) { +.factory('Swarm', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function SwarmFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { 'use strict'; - return $resource(DOCKER_ENDPOINT + '/:endpointId/swarm', { + return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/swarm', { endpointId: EndpointProvider.endpointID }, { diff --git a/app/rest/docker/system.js b/app/rest/docker/system.js index c0285070a..8636ef348 100644 --- a/app/rest/docker/system.js +++ b/app/rest/docker/system.js @@ -1,7 +1,7 @@ angular.module('portainer.rest') -.factory('System', ['$resource', 'DOCKER_ENDPOINT', 'EndpointProvider', function SystemFactory($resource, DOCKER_ENDPOINT, EndpointProvider) { +.factory('System', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function SystemFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { 'use strict'; - return $resource(DOCKER_ENDPOINT + '/:endpointId/:action/:subAction', { + return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/:action/:subAction', { name: '@name', endpointId: EndpointProvider.endpointID }, diff --git a/app/rest/docker/task.js b/app/rest/docker/task.js index 2ce993cef..2806b0852 100644 --- a/app/rest/docker/task.js +++ b/app/rest/docker/task.js @@ -1,7 +1,7 @@ angular.module('portainer.rest') -.factory('Task', ['$resource', 'DOCKER_ENDPOINT', 'EndpointProvider', function TaskFactory($resource, DOCKER_ENDPOINT, EndpointProvider) { +.factory('Task', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function TaskFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { 'use strict'; - return $resource(DOCKER_ENDPOINT + '/:endpointId/tasks/:id', { + return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/tasks/:id', { endpointId: EndpointProvider.endpointID }, { diff --git a/app/rest/docker/volume.js b/app/rest/docker/volume.js index 3bb900df7..1ae1264f9 100644 --- a/app/rest/docker/volume.js +++ b/app/rest/docker/volume.js @@ -1,7 +1,7 @@ angular.module('portainer.rest') -.factory('Volume', ['$resource', 'DOCKER_ENDPOINT', 'EndpointProvider', function VolumeFactory($resource, DOCKER_ENDPOINT, EndpointProvider) { +.factory('Volume', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function VolumeFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { 'use strict'; - return $resource(DOCKER_ENDPOINT + '/:endpointId/volumes/:id/:action', + return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/volumes/:id/:action', { endpointId: EndpointProvider.endpointID }, diff --git a/app/services/api/settingsService.js b/app/services/api/settingsService.js index f467ff844..4455abb97 100644 --- a/app/services/api/settingsService.js +++ b/app/services/api/settingsService.js @@ -22,5 +22,24 @@ angular.module('portainer.services') return Settings.update({}, settings).$promise; }; + service.publicSettings = function() { + var deferred = $q.defer(); + + Settings.publicSettings().$promise + .then(function success(data) { + var settings = new SettingsViewModel(data); + deferred.resolve(settings); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve application settings', err: err }); + }); + + return deferred.promise; + }; + + service.checkLDAPConnectivity = function(settings) { + return Settings.checkLDAPConnectivity({}, settings).$promise; + }; + return service; }]); diff --git a/app/services/api/userService.js b/app/services/api/userService.js index 24e0f97a2..7e3bf2b66 100644 --- a/app/services/api/userService.js +++ b/app/services/api/userService.js @@ -1,5 +1,5 @@ angular.module('portainer.services') -.factory('UserService', ['$q', 'Users', 'UserHelper', 'TeamMembershipService', function UserServiceFactory($q, Users, UserHelper, TeamMembershipService) { +.factory('UserService', ['$q', 'Users', 'UserHelper', 'TeamService', 'TeamMembershipService', function UserServiceFactory($q, Users, UserHelper, TeamService, TeamMembershipService) { 'use strict'; var service = {}; @@ -110,28 +110,11 @@ angular.module('portainer.services') return deferred.promise; }; - service.userTeams = function(id) { - var deferred = $q.defer(); - - Users.queryTeams({id: id}).$promise - .then(function success(data) { - var teams = data.map(function (item) { - return new TeamViewModel(item); - }); - deferred.resolve(teams); - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to retrieve user teams', err: err }); - }); - - return deferred.promise; - }; - service.userLeadingTeams = function(id) { var deferred = $q.defer(); $q.all({ - teams: service.userTeams(id), + teams: TeamService.teams(), memberships: service.userMemberships(id) }) .then(function success(data) { diff --git a/app/services/docker/networkService.js b/app/services/docker/networkService.js index b3bfc236a..013311cf0 100644 --- a/app/services/docker/networkService.js +++ b/app/services/docker/networkService.js @@ -3,21 +3,29 @@ angular.module('portainer.services') 'use strict'; var service = {}; - service.networks = function() { - return Network.query({}).$promise; - }; - - service.retrieveSwarmNetworks = function() { + service.networks = function(localNetworks, swarmNetworks, swarmAttachableNetworks, globalNetworks) { var deferred = $q.defer(); - service.networks() + Network.query({}).$promise .then(function success(data) { - var networks = data.filter(function (network) { - if (network.Scope === 'swarm') { + var networks = data; + + var filteredNetworks = networks.filter(function(network) { + if (localNetworks && network.Scope === 'local') { + return network; + } + if (swarmNetworks && network.Scope === 'swarm') { + return network; + } + if (swarmAttachableNetworks && network.Scope === 'swarm' && network.Attachable === true) { + return network; + } + if (globalNetworks && network.Scope === 'global') { return network; } }); - deferred.resolve(networks); + + deferred.resolve(filteredNetworks); }) .catch(function error(err) { deferred.reject({msg: 'Unable to retrieve networks', err: err}); @@ -26,27 +34,5 @@ angular.module('portainer.services') return deferred.promise; }; - service.filterGlobalNetworks = function(networks) { - return networks.filter(function (network) { - if (network.Scope === 'global') { - return network; - } - }); - }; - - service.filterSwarmModeAttachableNetworks = function(networks) { - return networks.filter(function (network) { - if (network.Scope === 'swarm' && network.Attachable === true) { - return network; - } - }); - }; - - service.addPredefinedLocalNetworks = function(networks) { - networks.push({Scope: 'local', Name: 'bridge'}); - networks.push({Scope: 'local', Name: 'host'}); - networks.push({Scope: 'local', Name: 'none'}); - }; - return service; }]); diff --git a/app/services/docker/pluginService.js b/app/services/docker/pluginService.js new file mode 100644 index 000000000..d6e3325e6 --- /dev/null +++ b/app/services/docker/pluginService.js @@ -0,0 +1,56 @@ +angular.module('portainer.services') +.factory('PluginService', ['$q', 'Plugin', 'SystemService', function PluginServiceFactory($q, Plugin, SystemService) { + 'use strict'; + var service = {}; + + service.plugins = function() { + var deferred = $q.defer(); + + Plugin.query({}).$promise + .then(function success(data) { + var plugins = data.map(function (item) { + return new PluginViewModel(item); + }); + deferred.resolve(plugins); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve plugins', err: err }); + }); + + return deferred.promise; + }; + + service.volumePlugins = function(systemOnly) { + var deferred = $q.defer(); + + $q.all({ + system: SystemService.plugins(), + plugins: systemOnly ? [] : service.plugins() + }) + .then(function success(data) { + var volumePlugins = []; + var systemPlugins = data.system; + var plugins = data.plugins; + + if (systemPlugins.Volume) { + volumePlugins = volumePlugins.concat(systemPlugins.Volume); + } + + for (var i = 0; i < plugins.length; i++) { + var plugin = plugins[i]; + if (plugin.Enabled && _.includes(plugin.Config.Interface.Types, 'docker.volumedriver/1.0')) { + volumePlugins.push(plugin.Name); + } + } + + deferred.resolve(volumePlugins); + }) + .catch(function error(err) { + deferred.reject({ msg: err.msg, err: err }); + }); + + return deferred.promise; + }; + + return service; +}]); diff --git a/app/services/docker/systemService.js b/app/services/docker/systemService.js index 4c017c0be..fcaed9e73 100644 --- a/app/services/docker/systemService.js +++ b/app/services/docker/systemService.js @@ -3,15 +3,15 @@ angular.module('portainer.services') 'use strict'; var service = {}; - service.getVolumePlugins = function() { + service.plugins = function() { var deferred = $q.defer(); System.info({}).$promise .then(function success(data) { - var plugins = data.Plugins.Volume; + var plugins = data.Plugins; deferred.resolve(plugins); }) .catch(function error(err) { - deferred.reject({msg: 'Unable to retrieve volume plugin information', err: err}); + deferred.reject({msg: 'Unable to retrieve plugins information from system', err: err}); }); return deferred.promise; }; @@ -40,7 +40,7 @@ angular.module('portainer.services') return deferred.promise; }; - + service.dataUsage = function () { return System.dataUsage().$promise; }; diff --git a/app/services/docker/volumeService.js b/app/services/docker/volumeService.js index 2a67bef4e..8ae9425c6 100644 --- a/app/services/docker/volumeService.js +++ b/app/services/docker/volumeService.js @@ -94,7 +94,7 @@ angular.module('portainer.services') service.createXAutoGeneratedLocalVolumes = function (x) { var createVolumeQueries = []; for (var i = 0; i < x; i++) { - createVolumeQueries.push(service.createVolume({})); + createVolumeQueries.push(service.createVolume({ Driver: 'local' })); } return $q.all(createVolumeQueries); }; diff --git a/app/services/fileUpload.js b/app/services/fileUpload.js index 7811afed4..8b256ca00 100644 --- a/app/services/fileUpload.js +++ b/app/services/fileUpload.js @@ -1,44 +1,44 @@ angular.module('portainer.services') .factory('FileUploadService', ['$q', 'Upload', function FileUploadFactory($q, Upload) { 'use strict'; - function uploadFile(url, file) { - var deferred = $q.defer(); - Upload.upload({ - url: url, - data: { file: file } - }).then(function success(data) { - deferred.resolve(data); - }, function error(e) { - deferred.reject(e); - }, function progress(evt) { - }); - return deferred.promise; - } - return { - uploadTLSFilesForEndpoint: function(endpointID, TLSCAFile, TLSCertFile, TLSKeyFile) { - var deferred = $q.defer(); - var queue = []; - if (TLSCAFile) { - var uploadTLSCA = uploadFile('api/upload/tls/' + endpointID + '/ca', TLSCAFile); - queue.push(uploadTLSCA); - } - if (TLSCertFile) { - var uploadTLSCert = uploadFile('api/upload/tls/' + endpointID + '/cert', TLSCertFile); - queue.push(uploadTLSCert); - } - if (TLSKeyFile) { - var uploadTLSKey = uploadFile('api/upload/tls/' + endpointID + '/key', TLSKeyFile); - queue.push(uploadTLSKey); - } - $q.all(queue).then(function (data) { - deferred.resolve(data); - }, function (err) { - deferred.reject(err); - }, function update(evt) { - deferred.notify(evt); - }); - return deferred.promise; + var service = {}; + + function uploadFile(url, file) { + return Upload.upload({ url: url, data: { file: file }}); + } + + service.uploadLDAPTLSFiles = function(TLSCAFile, TLSCertFile, TLSKeyFile) { + var queue = []; + + if (TLSCAFile) { + queue.push(uploadFile('api/upload/tls/ca?folder=ldap', TLSCAFile)); } + if (TLSCertFile) { + queue.push(uploadFile('api/upload/tls/cert?folder=ldap', TLSCertFile)); + } + if (TLSKeyFile) { + queue.push(uploadFile('api/upload/tls/key?folder=ldap', TLSKeyFile)); + } + + return $q.all(queue); }; + + service.uploadTLSFilesForEndpoint = function(endpointID, TLSCAFile, TLSCertFile, TLSKeyFile) { + var queue = []; + + if (TLSCAFile) { + queue.push(uploadFile('api/upload/tls/ca?folder=' + endpointID, TLSCAFile)); + } + if (TLSCertFile) { + queue.push(uploadFile('api/upload/tls/cert?folder=' + endpointID, TLSCertFile)); + } + if (TLSKeyFile) { + queue.push(uploadFile('api/upload/tls/key?folder=' + endpointID, TLSKeyFile)); + } + + return $q.all(queue); + }; + + return service; }]); diff --git a/app/services/modalService.js b/app/services/modalService.js index ad55be8ed..81b0b84a9 100644 --- a/app/services/modalService.js +++ b/app/services/modalService.js @@ -46,6 +46,19 @@ angular.module('portainer.services') applyBoxCSS(box); }; + service.customPrompt = function(options) { + var box = bootbox.prompt({ + title: options.title, + inputType: options.inputType, + inputOptions: options.inputOptions, + buttons: confirmButtons(options), + callback: options.callback + }); + applyBoxCSS(box); + box.find('.bootbox-body').prepend('

    ' + options.message + '

    '); + box.find('.bootbox-input-checkbox').prop('checked', true); + }; + service.confirmAccessControlUpdate = function(callback, msg) { service.confirm({ title: 'Are you sure ?', @@ -108,5 +121,40 @@ angular.module('portainer.services') }); }; + service.confirmContainerRecreation = function(callback) { + service.customPrompt({ + title: 'Are you sure?', + message: 'You\'re about to re-create this container, any non-persisted data will be lost. This container will be removed and another one will be created using the same configuration.', + inputType: 'checkbox', + inputOptions: [ + { + text: 'Pull latest image', + value: '1' + } + ], + buttons: { + confirm: { + label: 'Recreate', + className: 'btn-danger' + } + }, + callback: callback + }); + }; + + service.confirmExperimentalFeature = function(callback) { + service.confirm({ + title: 'Experimental feature', + message: 'This feature is currently experimental, please use with caution.', + buttons: { + confirm: { + label: 'Continue', + className: 'btn-danger' + } + }, + callback: callback + }); + }; + return service; }]); diff --git a/app/services/stateManager.js b/app/services/stateManager.js index ae7e7838e..9385237a7 100644 --- a/app/services/stateManager.js +++ b/app/services/stateManager.js @@ -44,7 +44,7 @@ angular.module('portainer.services') deferred.resolve(state); } else { $q.all({ - settings: SettingsService.settings(), + settings: SettingsService.publicSettings(), status: StatusService.status() }) .then(function success(data) { diff --git a/app/services/templateService.js b/app/services/templateService.js index 9a4efc9f1..a28e866ea 100644 --- a/app/services/templateService.js +++ b/app/services/templateService.js @@ -41,6 +41,7 @@ angular.module('portainer.services') configuration.HostConfig.Privileged = template.Privileged; configuration.HostConfig.RestartPolicy = { Name: template.RestartPolicy }; configuration.name = containerName; + configuration.Hostname = containerName; configuration.Image = template.Image; configuration.Env = TemplateHelper.EnvToStringArray(template.Env, containerMapping); configuration.Cmd = ContainerHelper.commandStringToArray(template.Command); diff --git a/assets/css/app.css b/assets/css/app.css index e9b61a130..2b2f4bb80 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -82,10 +82,6 @@ a[ng-click]{ margin-right: 5px; } -.fa.green-icon { - color: #23ae89; -} - .tooltip.portainer-tooltip .tooltip-inner { font-family: Montserrat; background-color: #ffffff; @@ -106,6 +102,10 @@ a[ng-click]{ color: #337ab7; } +.fa.green-icon { + color: #23ae89; +} + .fa.red-icon { color: #ae2323; } @@ -517,4 +517,4 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { .monospaced { font-family: monospace; font-weight: 600; -} \ No newline at end of file +} diff --git a/assets/images/logo.png b/assets/images/logo.png index f57218204..82f5a35d4 100644 Binary files a/assets/images/logo.png and b/assets/images/logo.png differ diff --git a/bower.json b/bower.json index fc93a251e..a61ae1b8c 100644 --- a/bower.json +++ b/bower.json @@ -1,6 +1,6 @@ { "name": "portainer", - "version": "1.13.6", + "version": "1.14.0", "homepage": "https://github.com/portainer/portainer", "authors": [ "Anthony Lapenna " diff --git a/build/linux/Dockerfile b/build/linux/Dockerfile index 299b9bd23..e16144acc 100644 --- a/build/linux/Dockerfile +++ b/build/linux/Dockerfile @@ -1,4 +1,4 @@ -FROM centurylink/ca-certs +FROM portainer/base COPY dist / diff --git a/gruntfile.js b/gruntfile.js index 844683369..95ee9184e 100644 --- a/gruntfile.js +++ b/gruntfile.js @@ -4,7 +4,7 @@ var loadGruntTasks = require('load-grunt-tasks'); module.exports = function (grunt) { - loadGruntTasks(grunt); + loadGruntTasks(grunt); grunt.registerTask('default', ['eslint', 'build']); grunt.registerTask('before-copy', [ @@ -180,7 +180,7 @@ module.exports = function (grunt) { run: { command: [ 'docker rm -f portainer', - 'docker run -d -p 9000:9000 -v $(pwd)/dist:/app -v /tmp/portainer:/data -v /var/run/docker.sock:/var/run/docker.sock:z --name portainer centurylink/ca-certs /app/portainer-linux-amd64 --no-analytics -a /app' + 'docker run -d -p 9000:9000 -v $(pwd)/dist:/app -v /tmp/portainer:/data -v /var/run/docker.sock:/var/run/docker.sock:z --name portainer portainer/base /app/portainer-linux-amd64 --no-analytics -a /app' ].join(';') } }, diff --git a/package.json b/package.json index dde247094..8c7dec2da 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "author": "Portainer.io", "name": "portainer", "homepage": "http://portainer.io", - "version": "1.13.6", + "version": "1.14.0", "repository": { "type": "git", "url": "git@github.com:portainer/portainer.git" diff --git a/vendor.yml b/vendor.yml index d2c3bac42..4c1e83937 100644 --- a/vendor.yml +++ b/vendor.yml @@ -12,6 +12,7 @@ js: - bower_components/splitargs/src/splitargs.js - bower_components/toastr/toastr.js - bower_components/xterm.js/dist/xterm.js + - bower_components/xterm.js/dist/addons/fit/fit.js - assets/js/legend.js minified: - bower_components/jquery/dist/jquery.min.js @@ -25,6 +26,7 @@ js: - bower_components/splitargs/src/splitargs.js - bower_components/toastr/toastr.min.js - bower_components/xterm.js/dist/xterm.js + - bower_components/xterm.js/dist/addons/fit/fit.js - assets/js/legend.js css: regular: