diff --git a/api/bolt/data_migration.go b/api/bolt/data_migration.go new file mode 100644 index 000000000..2cc094e24 --- /dev/null +++ b/api/bolt/data_migration.go @@ -0,0 +1,76 @@ +package bolt + +import ( + "github.com/boltdb/bolt" + "github.com/portainer/portainer" +) + +type Migrator struct { + UserService *UserService + EndpointService *EndpointService + ResourceControlService *ResourceControlService + VersionService *VersionService + CurrentDBVersion int + store *Store +} + +func NewMigrator(store *Store, version int) *Migrator { + return &Migrator{ + UserService: store.UserService, + EndpointService: store.EndpointService, + ResourceControlService: store.ResourceControlService, + VersionService: store.VersionService, + CurrentDBVersion: version, + store: store, + } +} + +func (m *Migrator) Migrate() error { + + // Portainer < 1.12 + if m.CurrentDBVersion == 0 { + err := m.updateAdminUser() + if err != nil { + return err + } + } + + err := m.VersionService.StoreDBVersion(portainer.DBVersion) + if err != nil { + return err + } + return nil +} + +func (m *Migrator) updateAdminUser() error { + u, err := m.UserService.UserByUsername("admin") + if err == nil { + admin := &portainer.User{ + Username: "admin", + Password: u.Password, + Role: portainer.AdministratorRole, + } + err = m.UserService.CreateUser(admin) + if err != nil { + return err + } + err = m.removeLegacyAdminUser() + if err != nil { + return err + } + } else if err != nil && err != portainer.ErrUserNotFound { + return err + } + return nil +} + +func (m *Migrator) removeLegacyAdminUser() error { + return m.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(userBucketName)) + err := bucket.Delete([]byte("admin")) + if err != nil { + return err + } + return nil + }) +} diff --git a/api/bolt/datastore.go b/api/bolt/datastore.go index d0321db21..79e859a5f 100644 --- a/api/bolt/datastore.go +++ b/api/bolt/datastore.go @@ -1,9 +1,12 @@ package bolt import ( + "log" + "os" "time" "github.com/boltdb/bolt" + "github.com/portainer/portainer" ) // Store defines the implementation of portainer.DataStore using @@ -13,29 +16,49 @@ type Store struct { Path string // Services - UserService *UserService - EndpointService *EndpointService + UserService *UserService + EndpointService *EndpointService + ResourceControlService *ResourceControlService + VersionService *VersionService - db *bolt.DB + db *bolt.DB + checkForDataMigration bool } const ( - databaseFileName = "portainer.db" - userBucketName = "users" - endpointBucketName = "endpoints" - activeEndpointBucketName = "activeEndpoint" + databaseFileName = "portainer.db" + versionBucketName = "version" + userBucketName = "users" + endpointBucketName = "endpoints" + containerResourceControlBucketName = "containerResourceControl" + serviceResourceControlBucketName = "serviceResourceControl" + volumeResourceControlBucketName = "volumeResourceControl" ) // NewStore initializes a new Store and the associated services -func NewStore(storePath string) *Store { +func NewStore(storePath string) (*Store, error) { store := &Store{ - Path: storePath, - UserService: &UserService{}, - EndpointService: &EndpointService{}, + Path: storePath, + UserService: &UserService{}, + EndpointService: &EndpointService{}, + ResourceControlService: &ResourceControlService{}, + VersionService: &VersionService{}, } store.UserService.store = store store.EndpointService.store = store - return store + store.ResourceControlService.store = store + store.VersionService.store = store + + _, err := os.Stat(storePath) + if err != nil && os.IsNotExist(err) { + store.checkForDataMigration = false + } else if err != nil { + return nil, err + } else { + store.checkForDataMigration = true + } + + return store, nil } // Open opens and initializes the BoltDB database. @@ -47,7 +70,11 @@ func (store *Store) Open() error { } store.db = db return db.Update(func(tx *bolt.Tx) error { - _, err := tx.CreateBucketIfNotExists([]byte(userBucketName)) + _, err := tx.CreateBucketIfNotExists([]byte(versionBucketName)) + if err != nil { + return err + } + _, err = tx.CreateBucketIfNotExists([]byte(userBucketName)) if err != nil { return err } @@ -55,7 +82,15 @@ func (store *Store) Open() error { if err != nil { return err } - _, err = tx.CreateBucketIfNotExists([]byte(activeEndpointBucketName)) + _, err = tx.CreateBucketIfNotExists([]byte(containerResourceControlBucketName)) + if err != nil { + return err + } + _, err = tx.CreateBucketIfNotExists([]byte(serviceResourceControlBucketName)) + if err != nil { + return err + } + _, err = tx.CreateBucketIfNotExists([]byte(volumeResourceControlBucketName)) if err != nil { return err } @@ -70,3 +105,32 @@ func (store *Store) Close() error { } return nil } + +// MigrateData automatically migrate the data based on the DBVersion. +func (store *Store) MigrateData() error { + if !store.checkForDataMigration { + err := store.VersionService.StoreDBVersion(portainer.DBVersion) + if err != nil { + return err + } + return nil + } + + version, err := store.VersionService.DBVersion() + if err == portainer.ErrDBVersionNotFound { + version = 0 + } else if err != nil { + return err + } + + if version < portainer.DBVersion { + log.Printf("Migrating database from version %v to %v.\n", version, portainer.DBVersion) + migrator := NewMigrator(store, version) + err = migrator.Migrate() + if err != nil { + return err + } + } + + return nil +} diff --git a/api/bolt/endpoint_service.go b/api/bolt/endpoint_service.go index cb8349076..d7c230e9a 100644 --- a/api/bolt/endpoint_service.go +++ b/api/bolt/endpoint_service.go @@ -12,10 +12,6 @@ type EndpointService struct { store *Store } -const ( - activeEndpointID = 0 -) - // Endpoint returns an endpoint by ID. func (service *EndpointService) Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, error) { var data []byte @@ -138,62 +134,6 @@ func (service *EndpointService) DeleteEndpoint(ID portainer.EndpointID) error { }) } -// GetActive returns the active endpoint. -func (service *EndpointService) GetActive() (*portainer.Endpoint, error) { - var data []byte - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(activeEndpointBucketName)) - value := bucket.Get(internal.Itob(activeEndpointID)) - if value == nil { - return portainer.ErrEndpointNotFound - } - - data = make([]byte, len(value)) - copy(data, value) - return nil - }) - if err != nil { - return nil, err - } - - var endpoint portainer.Endpoint - err = internal.UnmarshalEndpoint(data, &endpoint) - if err != nil { - return nil, err - } - return &endpoint, nil -} - -// SetActive saves an endpoint as active. -func (service *EndpointService) SetActive(endpoint *portainer.Endpoint) error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(activeEndpointBucketName)) - - data, err := internal.MarshalEndpoint(endpoint) - if err != nil { - return err - } - - err = bucket.Put(internal.Itob(activeEndpointID), data) - if err != nil { - return err - } - return nil - }) -} - -// DeleteActive deletes the active endpoint. -func (service *EndpointService) DeleteActive() error { - return service.store.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(activeEndpointBucketName)) - err := bucket.Delete(internal.Itob(activeEndpointID)) - if err != nil { - return err - } - return nil - }) -} - func marshalAndStoreEndpoint(endpoint *portainer.Endpoint, bucket *bolt.Bucket) error { data, err := internal.MarshalEndpoint(endpoint) if err != nil { @@ -210,6 +150,5 @@ func marshalAndStoreEndpoint(endpoint *portainer.Endpoint, bucket *bolt.Bucket) func storeNewEndpoint(endpoint *portainer.Endpoint, bucket *bolt.Bucket) error { id, _ := bucket.NextSequence() endpoint.ID = portainer.EndpointID(id) - return marshalAndStoreEndpoint(endpoint, bucket) } diff --git a/api/bolt/internal/internal.go b/api/bolt/internal/internal.go index e9d6416eb..351592963 100644 --- a/api/bolt/internal/internal.go +++ b/api/bolt/internal/internal.go @@ -27,6 +27,16 @@ func UnmarshalEndpoint(data []byte, endpoint *portainer.Endpoint) error { return json.Unmarshal(data, endpoint) } +// MarshalResourceControl encodes a resource control object to binary format. +func MarshalResourceControl(rc *portainer.ResourceControl) ([]byte, error) { + return json.Marshal(rc) +} + +// UnmarshalResourceControl decodes a resource control object from a binary data. +func UnmarshalResourceControl(data []byte, rc *portainer.ResourceControl) error { + return json.Unmarshal(data, rc) +} + // Itob returns an 8-byte big endian representation of v. // This function is typically used for encoding integer IDs to byte slices // so that they can be used as BoltDB keys. diff --git a/api/bolt/resourcecontrol_service.go b/api/bolt/resourcecontrol_service.go new file mode 100644 index 000000000..07b174616 --- /dev/null +++ b/api/bolt/resourcecontrol_service.go @@ -0,0 +1,110 @@ +package bolt + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +// ResourceControlService represents a service for managing resource controls. +type ResourceControlService struct { + store *Store +} + +func getBucketNameByResourceControlType(rcType portainer.ResourceControlType) string { + bucketName := containerResourceControlBucketName + if rcType == portainer.ServiceResourceControl { + bucketName = serviceResourceControlBucketName + } else if rcType == portainer.VolumeResourceControl { + bucketName = volumeResourceControlBucketName + } + return bucketName +} + +// ResourceControl returns a resource control object by resource ID +func (service *ResourceControlService) ResourceControl(resourceID string, rcType portainer.ResourceControlType) (*portainer.ResourceControl, error) { + var data []byte + bucketName := getBucketNameByResourceControlType(rcType) + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(bucketName)) + value := bucket.Get([]byte(resourceID)) + if value == nil { + return nil + } + + data = make([]byte, len(value)) + copy(data, value) + return nil + }) + if err != nil { + return nil, err + } + if data == nil { + return nil, nil + } + + var rc portainer.ResourceControl + err = internal.UnmarshalResourceControl(data, &rc) + if err != nil { + return nil, err + } + return &rc, nil +} + +// ResourceControls returns all resource control objects +func (service *ResourceControlService) ResourceControls(rcType portainer.ResourceControlType) ([]portainer.ResourceControl, error) { + var rcs = make([]portainer.ResourceControl, 0) + bucketName := getBucketNameByResourceControlType(rcType) + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(bucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var rc portainer.ResourceControl + err := internal.UnmarshalResourceControl(v, &rc) + if err != nil { + return err + } + rcs = append(rcs, rc) + } + + return nil + }) + if err != nil { + return nil, err + } + + return rcs, nil +} + +// CreateResourceControl creates a new resource control +func (service *ResourceControlService) CreateResourceControl(resourceID string, rc *portainer.ResourceControl, rcType portainer.ResourceControlType) error { + bucketName := getBucketNameByResourceControlType(rcType) + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(bucketName)) + data, err := internal.MarshalResourceControl(rc) + if err != nil { + return err + } + + err = bucket.Put([]byte(resourceID), data) + if err != nil { + return err + } + return nil + }) +} + +// DeleteResourceControl deletes a resource control object by resource ID +func (service *ResourceControlService) DeleteResourceControl(resourceID string, rcType portainer.ResourceControlType) error { + bucketName := getBucketNameByResourceControlType(rcType) + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(bucketName)) + err := bucket.Delete([]byte(resourceID)) + if err != nil { + return err + } + return nil + }) +} diff --git a/api/bolt/user_service.go b/api/bolt/user_service.go index 0171c3e33..1e1c68f40 100644 --- a/api/bolt/user_service.go +++ b/api/bolt/user_service.go @@ -12,12 +12,12 @@ type UserService struct { store *Store } -// User returns a user by username. -func (service *UserService) User(username string) (*portainer.User, error) { +// User returns a user by ID +func (service *UserService) User(ID portainer.UserID) (*portainer.User, error) { var data []byte err := service.store.db.View(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(userBucketName)) - value := bucket.Get([]byte(username)) + value := bucket.Get(internal.Itob(int(ID))) if value == nil { return portainer.ErrUserNotFound } @@ -38,8 +38,88 @@ func (service *UserService) User(username string) (*portainer.User, error) { return &user, nil } +// UserByUsername returns a user by username. +func (service *UserService) UserByUsername(username string) (*portainer.User, error) { + var user *portainer.User + + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(userBucketName)) + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var u portainer.User + err := internal.UnmarshalUser(v, &u) + if err != nil { + return err + } + if u.Username == username { + user = &u + } + } + + if user == nil { + return portainer.ErrUserNotFound + } + return nil + }) + if err != nil { + return nil, err + } + return user, nil +} + +// Users return an array containing all the users. +func (service *UserService) Users() ([]portainer.User, error) { + var users = make([]portainer.User, 0) + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(userBucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var user portainer.User + err := internal.UnmarshalUser(v, &user) + if err != nil { + return err + } + users = append(users, user) + } + + return nil + }) + if err != nil { + return nil, err + } + + return users, nil +} + +// UsersByRole return an array containing all the users with the specified role. +func (service *UserService) UsersByRole(role portainer.UserRole) ([]portainer.User, error) { + var users = make([]portainer.User, 0) + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(userBucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var user portainer.User + err := internal.UnmarshalUser(v, &user) + if err != nil { + return err + } + if user.Role == role { + users = append(users, user) + } + } + return nil + }) + if err != nil { + return nil, err + } + + return users, nil +} + // UpdateUser saves a user. -func (service *UserService) UpdateUser(user *portainer.User) error { +func (service *UserService) UpdateUser(ID portainer.UserID, user *portainer.User) error { data, err := internal.MarshalUser(user) if err != nil { return err @@ -47,7 +127,41 @@ func (service *UserService) UpdateUser(user *portainer.User) error { return service.store.db.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(userBucketName)) - err = bucket.Put([]byte(user.Username), data) + err = bucket.Put(internal.Itob(int(ID)), data) + + if err != nil { + return err + } + return nil + }) +} + +// CreateUser creates a new user. +func (service *UserService) CreateUser(user *portainer.User) error { + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(userBucketName)) + + id, _ := bucket.NextSequence() + user.ID = portainer.UserID(id) + + data, err := internal.MarshalUser(user) + if err != nil { + return err + } + + err = bucket.Put(internal.Itob(int(user.ID)), data) + if err != nil { + return err + } + return nil + }) +} + +// DeleteUser deletes a user. +func (service *UserService) DeleteUser(ID portainer.UserID) error { + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(userBucketName)) + err := bucket.Delete(internal.Itob(int(ID))) if err != nil { return err } diff --git a/api/bolt/version_service.go b/api/bolt/version_service.go new file mode 100644 index 000000000..2da511437 --- /dev/null +++ b/api/bolt/version_service.go @@ -0,0 +1,58 @@ +package bolt + +import ( + "strconv" + + "github.com/portainer/portainer" + + "github.com/boltdb/bolt" +) + +// EndpointService represents a service for managing users. +type VersionService struct { + store *Store +} + +const ( + DBVersionKey = "DB_VERSION" +) + +// DBVersion the stored database version. +func (service *VersionService) DBVersion() (int, error) { + var data []byte + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(versionBucketName)) + value := bucket.Get([]byte(DBVersionKey)) + if value == nil { + return portainer.ErrDBVersionNotFound + } + + data = make([]byte, len(value)) + copy(data, value) + return nil + }) + if err != nil { + return 0, err + } + + dbVersion, err := strconv.Atoi(string(data)) + if err != nil { + return 0, err + } + + return dbVersion, nil +} + +// StoreDBVersion store the database version. +func (service *VersionService) StoreDBVersion(version int) error { + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(versionBucketName)) + + data := []byte(strconv.Itoa(version)) + err := bucket.Put([]byte(DBVersionKey), data) + if err != nil { + return err + } + return nil + }) +} diff --git a/api/cli/cli.go b/api/cli/cli.go index ead424779..6148fdc95 100644 --- a/api/cli/cli.go +++ b/api/cli/cli.go @@ -29,14 +29,15 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) { flags := &portainer.CLIFlags{ Endpoint: kingpin.Flag("host", "Dockerd endpoint").Short('H').String(), Logo: kingpin.Flag("logo", "URL for the logo displayed in the UI").String(), + Labels: pairs(kingpin.Flag("hide-label", "Hide containers with a specific label in the UI").Short('l')), ExternalEndpoints: kingpin.Flag("external-endpoints", "Path to a file defining available endpoints").String(), SyncInterval: kingpin.Flag("sync-interval", "Duration between each synchronization via the external endpoints source").Default(defaultSyncInterval).String(), - Labels: pairs(kingpin.Flag("hide-label", "Hide containers with a specific label in the UI").Short('l')), Addr: kingpin.Flag("bind", "Address and port to serve Portainer").Default(defaultBindAddress).Short('p').String(), Assets: kingpin.Flag("assets", "Path to the assets").Default(defaultAssetsDirectory).Short('a').String(), Data: kingpin.Flag("data", "Path to the folder where the data is stored").Default(defaultDataDirectory).Short('d').String(), Templates: kingpin.Flag("templates", "URL to the templates (apps) definitions").Default(defaultTemplatesURL).Short('t').String(), NoAuth: kingpin.Flag("no-auth", "Disable authentication").Default(defaultNoAuth).Bool(), + NoAnalytics: kingpin.Flag("no-analytics", "Disable Analytics in app").Default(defaultNoAuth).Bool(), TLSVerify: kingpin.Flag("tlsverify", "TLS support").Default(defaultTLSVerify).Bool(), TLSCacert: kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).String(), TLSCert: kingpin.Flag("tlscert", "Path to the TLS certificate file").Default(defaultTLSCertPath).String(), diff --git a/api/cli/defaults.go b/api/cli/defaults.go index 4545ecf79..160b74808 100644 --- a/api/cli/defaults.go +++ b/api/cli/defaults.go @@ -8,6 +8,7 @@ const ( defaultAssetsDirectory = "." defaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates.json" defaultNoAuth = "false" + defaultNoAnalytics = "false" defaultTLSVerify = "false" defaultTLSCACertPath = "/certs/ca.pem" defaultTLSCertPath = "/certs/cert.pem" diff --git a/api/cli/defaults_windows.go b/api/cli/defaults_windows.go index 17c6c3776..cbd0555a8 100644 --- a/api/cli/defaults_windows.go +++ b/api/cli/defaults_windows.go @@ -6,6 +6,7 @@ const ( defaultAssetsDirectory = "." defaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates.json" defaultNoAuth = "false" + defaultNoAnalytics = "false" defaultTLSVerify = "false" defaultTLSCACertPath = "C:\\certs\\ca.pem" defaultTLSCertPath = "C:\\certs\\cert.pem" diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 423c554cc..6ebdf1ee1 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -36,8 +36,17 @@ func initFileService(dataStorePath string) portainer.FileService { } func initStore(dataStorePath string) *bolt.Store { - var store = bolt.NewStore(dataStorePath) - err := store.Open() + store, err := bolt.NewStore(dataStorePath) + if err != nil { + log.Fatal(err) + } + + err = store.Open() + if err != nil { + log.Fatal(err) + } + + err = store.MigrateData() if err != nil { log.Fatal(err) } @@ -77,6 +86,7 @@ func initSettings(authorizeEndpointMgmt bool, flags *portainer.CLIFlags) *portai return &portainer.Settings{ HiddenLabels: *flags.Labels, Logo: *flags.Logo, + Analytics: !*flags.NoAnalytics, Authentication: !*flags.NoAuth, EndpointManagement: authorizeEndpointMgmt, } @@ -90,31 +100,6 @@ func retrieveFirstEndpointFromDatabase(endpointService portainer.EndpointService return &endpoints[0] } -func initActiveEndpoint(endpointService portainer.EndpointService, flags *portainer.CLIFlags) *portainer.Endpoint { - activeEndpoint, err := endpointService.GetActive() - if err == portainer.ErrEndpointNotFound { - if *flags.Endpoint != "" { - activeEndpoint = &portainer.Endpoint{ - Name: "primary", - URL: *flags.Endpoint, - TLS: *flags.TLSVerify, - TLSCACertPath: *flags.TLSCacert, - TLSCertPath: *flags.TLSCert, - TLSKeyPath: *flags.TLSKey, - } - err = endpointService.CreateEndpoint(activeEndpoint) - if err != nil { - log.Fatal(err) - } - } else if *flags.ExternalEndpoints != "" { - activeEndpoint = retrieveFirstEndpointFromDatabase(endpointService) - } - } else if err != nil { - log.Fatal(err) - } - return activeEndpoint -} - func main() { flags := initCLI() @@ -131,21 +116,43 @@ func main() { settings := initSettings(authorizeEndpointMgmt, flags) - activeEndpoint := initActiveEndpoint(store.EndpointService, flags) + if *flags.Endpoint != "" { + var endpoints []portainer.Endpoint + endpoints, err := store.EndpointService.Endpoints() + if err != nil { + log.Fatal(err) + } + if len(endpoints) == 0 { + endpoint := &portainer.Endpoint{ + Name: "primary", + URL: *flags.Endpoint, + TLS: *flags.TLSVerify, + TLSCACertPath: *flags.TLSCacert, + TLSCertPath: *flags.TLSCert, + TLSKeyPath: *flags.TLSKey, + } + err = store.EndpointService.CreateEndpoint(endpoint) + if err != nil { + log.Fatal(err) + } + } else { + log.Println("Instance already has defined endpoints. Skipping the endpoint defined via CLI.") + } + } var server portainer.Server = &http.Server{ - BindAddress: *flags.Addr, - AssetsPath: *flags.Assets, - Settings: settings, - TemplatesURL: *flags.Templates, - AuthDisabled: *flags.NoAuth, - EndpointManagement: authorizeEndpointMgmt, - UserService: store.UserService, - EndpointService: store.EndpointService, - CryptoService: cryptoService, - JWTService: jwtService, - FileService: fileService, - ActiveEndpoint: activeEndpoint, + BindAddress: *flags.Addr, + AssetsPath: *flags.Assets, + Settings: settings, + TemplatesURL: *flags.Templates, + AuthDisabled: *flags.NoAuth, + EndpointManagement: authorizeEndpointMgmt, + UserService: store.UserService, + EndpointService: store.EndpointService, + ResourceControlService: store.ResourceControlService, + CryptoService: cryptoService, + JWTService: jwtService, + FileService: fileService, } log.Printf("Starting Portainer on %s", *flags.Addr) diff --git a/api/cron/endpoint_sync.go b/api/cron/endpoint_sync.go index 9dcd4e290..3401c6893 100644 --- a/api/cron/endpoint_sync.go +++ b/api/cron/endpoint_sync.go @@ -66,7 +66,10 @@ func endpointExists(endpoint *portainer.Endpoint, endpoints []portainer.Endpoint func mergeEndpointIfRequired(original, updated *portainer.Endpoint) *portainer.Endpoint { var endpoint *portainer.Endpoint - if original.URL != updated.URL || original.TLS != updated.TLS { + if original.URL != updated.URL || original.TLS != updated.TLS || + (updated.TLS && original.TLSCACertPath != updated.TLSCACertPath) || + (updated.TLS && original.TLSCertPath != updated.TLSCertPath) || + (updated.TLS && original.TLSKeyPath != updated.TLSKeyPath) { endpoint = original endpoint.URL = updated.URL if updated.TLS { diff --git a/api/errors.go b/api/errors.go index 48ea6ab75..14dbc3156 100644 --- a/api/errors.go +++ b/api/errors.go @@ -2,19 +2,26 @@ package portainer // General errors. const ( - ErrUnauthorized = Error("Unauthorized") + ErrUnauthorized = Error("Unauthorized") + ErrResourceAccessDenied = Error("Access denied to resource") ) // User errors. const ( ErrUserNotFound = Error("User not found") + ErrUserAlreadyExists = Error("User already exists") ErrAdminAlreadyInitialized = Error("Admin user already initialized") ) // Endpoint errors. const ( - ErrEndpointNotFound = Error("Endpoint not found") - ErrNoActiveEndpoint = Error("Undefined Docker endpoint") + ErrEndpointNotFound = Error("Endpoint not found") + ErrEndpointAccessDenied = Error("Access denied to endpoint") +) + +// Version errors. +const ( + ErrDBVersionNotFound = Error("DB version not found") ) // Crypto errors. @@ -24,8 +31,9 @@ const ( // JWT errors. const ( - ErrSecretGeneration = Error("Unable to generate secret key") - ErrInvalidJWTToken = Error("Invalid JWT token") + ErrSecretGeneration = Error("Unable to generate secret key") + ErrInvalidJWTToken = Error("Invalid JWT token") + ErrMissingContextData = Error("Unable to find JWT data in request context") ) // File errors. diff --git a/api/http/auth_handler.go b/api/http/auth_handler.go index b4c1af789..0eb0e7559 100644 --- a/api/http/auth_handler.go +++ b/api/http/auth_handler.go @@ -33,12 +33,14 @@ const ( ) // NewAuthHandler returns a new instance of AuthHandler. -func NewAuthHandler() *AuthHandler { +func NewAuthHandler(mw *middleWareService) *AuthHandler { h := &AuthHandler{ Router: mux.NewRouter(), Logger: log.New(os.Stderr, "", log.LstdFlags), } - h.HandleFunc("/auth", h.handlePostAuth) + h.Handle("/auth", + mw.public(http.HandlerFunc(h.handlePostAuth))) + return h } @@ -68,7 +70,7 @@ func (handler *AuthHandler) handlePostAuth(w http.ResponseWriter, r *http.Reques var username = req.Username var password = req.Password - u, err := handler.UserService.User(username) + u, err := handler.UserService.UserByUsername(username) if err == portainer.ErrUserNotFound { Error(w, err, http.StatusNotFound, handler.Logger) return @@ -84,7 +86,9 @@ func (handler *AuthHandler) handlePostAuth(w http.ResponseWriter, r *http.Reques } tokenData := &portainer.TokenData{ - username, + ID: u.ID, + Username: u.Username, + Role: u.Role, } token, err := handler.JWTService.GenerateToken(tokenData) if err != nil { diff --git a/api/http/docker_handler.go b/api/http/docker_handler.go index 9cffa4b26..5a8668a3a 100644 --- a/api/http/docker_handler.go +++ b/api/http/docker_handler.go @@ -1,16 +1,14 @@ package http import ( + "strconv" + "github.com/portainer/portainer" - "io" "log" - "net" "net/http" - "net/http/httputil" "net/url" "os" - "strings" "github.com/gorilla/mux" ) @@ -18,142 +16,95 @@ import ( // DockerHandler represents an HTTP API handler for proxying requests to the Docker API. type DockerHandler struct { *mux.Router - Logger *log.Logger - middleWareService *middleWareService - proxy http.Handler + Logger *log.Logger + EndpointService portainer.EndpointService + ProxyFactory ProxyFactory + proxies map[portainer.EndpointID]http.Handler } // NewDockerHandler returns a new instance of DockerHandler. -func NewDockerHandler(middleWareService *middleWareService) *DockerHandler { +func NewDockerHandler(mw *middleWareService, resourceControlService portainer.ResourceControlService) *DockerHandler { h := &DockerHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - middleWareService: middleWareService, + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + ProxyFactory: ProxyFactory{ + ResourceControlService: resourceControlService, + }, + proxies: make(map[portainer.EndpointID]http.Handler), } - h.PathPrefix("/").Handler(middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - h.proxyRequestsToDockerAPI(w, r) - }))) + h.PathPrefix("/{id}/").Handler( + mw.authenticated(http.HandlerFunc(h.proxyRequestsToDockerAPI))) return h } +func checkEndpointAccessControl(endpoint *portainer.Endpoint, userID portainer.UserID) bool { + for _, authorizedUserID := range endpoint.AuthorizedUsers { + if authorizedUserID == userID { + return true + } + } + return false +} + func (handler *DockerHandler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http.Request) { - if handler.proxy != nil { - handler.proxy.ServeHTTP(w, r) - } else { - Error(w, portainer.ErrNoActiveEndpoint, http.StatusNotFound, handler.Logger) - } -} + vars := mux.Vars(r) + id := vars["id"] -func (handler *DockerHandler) setupProxy(endpoint *portainer.Endpoint) error { - var proxy http.Handler - endpointURL, err := url.Parse(endpoint.URL) + parsedID, err := strconv.Atoi(id) if err != nil { - return err + Error(w, err, http.StatusBadRequest, handler.Logger) + return } - if endpointURL.Scheme == "tcp" { - if endpoint.TLS { - proxy, err = newHTTPSProxy(endpointURL, endpoint) - if err != nil { - return err - } - } else { - proxy = newHTTPProxy(endpointURL) - } - } else { - // Assume unix:// scheme - proxy = newSocketProxy(endpointURL.Path) - } - handler.proxy = proxy - return nil -} -// singleJoiningSlash from golang.org/src/net/http/httputil/reverseproxy.go -// included here for use in NewSingleHostReverseProxyWithHostHeader -// because its used in NewSingleHostReverseProxy from golang.org/src/net/http/httputil/reverseproxy.go -func singleJoiningSlash(a, b string) string { - aslash := strings.HasSuffix(a, "/") - bslash := strings.HasPrefix(b, "/") - switch { - case aslash && bslash: - return a + b[1:] - case !aslash && !bslash: - return a + "/" + b + endpointID := portainer.EndpointID(parsedID) + endpoint, err := handler.EndpointService.Endpoint(endpointID) + if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return } - return a + b -} -// NewSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy -// from golang.org/src/net/http/httputil/reverseproxy.go and merely sets the Host -// HTTP header, which NewSingleHostReverseProxy deliberately preserves -func NewSingleHostReverseProxyWithHostHeader(target *url.URL) *httputil.ReverseProxy { - targetQuery := target.RawQuery - director := func(req *http.Request) { - req.URL.Scheme = target.Scheme - req.URL.Host = target.Host - req.URL.Path = singleJoiningSlash(target.Path, req.URL.Path) - req.Host = req.URL.Host - if targetQuery == "" || req.URL.RawQuery == "" { - req.URL.RawQuery = targetQuery + req.URL.RawQuery - } else { - req.URL.RawQuery = targetQuery + "&" + req.URL.RawQuery - } - if _, ok := req.Header["User-Agent"]; !ok { - // explicitly disable User-Agent so it's not set to default value - req.Header.Set("User-Agent", "") + tokenData, err := extractTokenDataFromRequestContext(r) + if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + } + if tokenData.Role != portainer.AdministratorRole && !checkEndpointAccessControl(endpoint, tokenData.ID) { + Error(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger) + return + } + + proxy := handler.proxies[endpointID] + if proxy == nil { + proxy, err = handler.createAndRegisterEndpointProxy(endpoint) + if err != nil { + Error(w, err, http.StatusBadRequest, handler.Logger) + return } } - return &httputil.ReverseProxy{Director: director} + http.StripPrefix("/"+id, proxy).ServeHTTP(w, r) } -func newHTTPProxy(u *url.URL) http.Handler { - u.Scheme = "http" - return NewSingleHostReverseProxyWithHostHeader(u) -} +func (handler *DockerHandler) createAndRegisterEndpointProxy(endpoint *portainer.Endpoint) (http.Handler, error) { + var proxy http.Handler -func newHTTPSProxy(u *url.URL, endpoint *portainer.Endpoint) (http.Handler, error) { - u.Scheme = "https" - proxy := NewSingleHostReverseProxyWithHostHeader(u) - config, err := createTLSConfiguration(endpoint.TLSCACertPath, endpoint.TLSCertPath, endpoint.TLSKeyPath) + endpointURL, err := url.Parse(endpoint.URL) if err != nil { return nil, err } - proxy.Transport = &http.Transport{ - TLSClientConfig: config, + + if endpointURL.Scheme == "tcp" { + if endpoint.TLS { + proxy, err = handler.ProxyFactory.newHTTPSProxy(endpointURL, endpoint) + if err != nil { + return nil, err + } + } else { + proxy = handler.ProxyFactory.newHTTPProxy(endpointURL) + } + } else { + // Assume unix:// scheme + proxy = handler.ProxyFactory.newSocketProxy(endpointURL.Path) } + + handler.proxies[endpoint.ID] = proxy return proxy, nil } - -func newSocketProxy(path string) http.Handler { - return &unixSocketHandler{path} -} - -// unixSocketHandler represents a handler to proxy HTTP requests via a unix:// socket -type unixSocketHandler struct { - path string -} - -func (h *unixSocketHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - conn, err := net.Dial("unix", h.path) - if err != nil { - Error(w, err, http.StatusInternalServerError, nil) - return - } - c := httputil.NewClientConn(conn, nil) - defer c.Close() - - res, err := c.Do(r) - if err != nil { - Error(w, err, http.StatusInternalServerError, nil) - return - } - defer res.Body.Close() - - for k, vv := range res.Header { - for _, v := range vv { - w.Header().Add(k, v) - } - } - if _, err := io.Copy(w, res.Body); err != nil { - Error(w, err, http.StatusInternalServerError, nil) - } -} diff --git a/api/http/docker_proxy.go b/api/http/docker_proxy.go new file mode 100644 index 000000000..f4644dd36 --- /dev/null +++ b/api/http/docker_proxy.go @@ -0,0 +1,121 @@ +package http + +import ( + "io" + "net" + "net/http" + "net/http/httputil" + "net/url" + "strings" + + "github.com/portainer/portainer" +) + +// ProxyFactory is a factory to create reverse proxies to Docker endpoints +type ProxyFactory struct { + ResourceControlService portainer.ResourceControlService +} + +// singleJoiningSlash from golang.org/src/net/http/httputil/reverseproxy.go +// included here for use in NewSingleHostReverseProxyWithHostHeader +// because its used in NewSingleHostReverseProxy from golang.org/src/net/http/httputil/reverseproxy.go +func singleJoiningSlash(a, b string) string { + aslash := strings.HasSuffix(a, "/") + bslash := strings.HasPrefix(b, "/") + switch { + case aslash && bslash: + return a + b[1:] + case !aslash && !bslash: + return a + "/" + b + } + return a + b +} + +// NewSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy +// from golang.org/src/net/http/httputil/reverseproxy.go and merely sets the Host +// HTTP header, which NewSingleHostReverseProxy deliberately preserves. +// It also adds an extra Transport to the proxy to allow Portainer to rewrite the responses. +func (factory *ProxyFactory) newSingleHostReverseProxyWithHostHeader(target *url.URL) *httputil.ReverseProxy { + targetQuery := target.RawQuery + director := func(req *http.Request) { + req.URL.Scheme = target.Scheme + req.URL.Host = target.Host + req.URL.Path = singleJoiningSlash(target.Path, req.URL.Path) + req.Host = req.URL.Host + if targetQuery == "" || req.URL.RawQuery == "" { + req.URL.RawQuery = targetQuery + req.URL.RawQuery + } else { + req.URL.RawQuery = targetQuery + "&" + req.URL.RawQuery + } + if _, ok := req.Header["User-Agent"]; !ok { + // explicitly disable User-Agent so it's not set to default value + req.Header.Set("User-Agent", "") + } + } + transport := &proxyTransport{ + ResourceControlService: factory.ResourceControlService, + transport: &http.Transport{}, + } + return &httputil.ReverseProxy{Director: director, Transport: transport} +} + +func (factory *ProxyFactory) newHTTPProxy(u *url.URL) http.Handler { + u.Scheme = "http" + return factory.newSingleHostReverseProxyWithHostHeader(u) +} + +func (factory *ProxyFactory) newHTTPSProxy(u *url.URL, endpoint *portainer.Endpoint) (http.Handler, error) { + u.Scheme = "https" + proxy := factory.newSingleHostReverseProxyWithHostHeader(u) + config, err := createTLSConfiguration(endpoint.TLSCACertPath, endpoint.TLSCertPath, endpoint.TLSKeyPath) + if err != nil { + return nil, err + } + + proxy.Transport.(*proxyTransport).transport.TLSClientConfig = config + return proxy, nil +} + +func (factory *ProxyFactory) newSocketProxy(path string) http.Handler { + return &unixSocketHandler{path, &proxyTransport{ + ResourceControlService: factory.ResourceControlService, + }} +} + +// unixSocketHandler represents a handler to proxy HTTP requests via a unix:// socket +type unixSocketHandler struct { + path string + transport *proxyTransport +} + +func (h *unixSocketHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + conn, err := net.Dial("unix", h.path) + if err != nil { + Error(w, err, http.StatusInternalServerError, nil) + return + } + c := httputil.NewClientConn(conn, nil) + defer c.Close() + + res, err := c.Do(r) + if err != nil { + Error(w, err, http.StatusInternalServerError, nil) + return + } + defer res.Body.Close() + + err = h.transport.proxyDockerRequests(r, res) + if err != nil { + Error(w, err, http.StatusInternalServerError, nil) + return + } + + for k, vv := range res.Header { + for _, v := range vv { + w.Header().Add(k, v) + } + } + if _, err := io.Copy(w, res.Body); err != nil { + Error(w, err, http.StatusInternalServerError, nil) + } +} diff --git a/api/http/endpoint_handler.go b/api/http/endpoint_handler.go index 7c7d0f930..5fac67276 100644 --- a/api/http/endpoint_handler.go +++ b/api/http/endpoint_handler.go @@ -20,8 +20,7 @@ type EndpointHandler struct { authorizeEndpointManagement bool EndpointService portainer.EndpointService FileService portainer.FileService - server *Server - middleWareService *middleWareService + // server *Server } const ( @@ -31,30 +30,24 @@ const ( ) // NewEndpointHandler returns a new instance of EndpointHandler. -func NewEndpointHandler(middleWareService *middleWareService) *EndpointHandler { +func NewEndpointHandler(mw *middleWareService) *EndpointHandler { h := &EndpointHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - middleWareService: middleWareService, + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), } - h.Handle("/endpoints", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - h.handlePostEndpoints(w, r) - }))).Methods(http.MethodPost) - h.Handle("/endpoints", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - h.handleGetEndpoints(w, r) - }))).Methods(http.MethodGet) - h.Handle("/endpoints/{id}", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - h.handleGetEndpoint(w, r) - }))).Methods(http.MethodGet) - h.Handle("/endpoints/{id}", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - h.handlePutEndpoint(w, r) - }))).Methods(http.MethodPut) - h.Handle("/endpoints/{id}", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - h.handleDeleteEndpoint(w, r) - }))).Methods(http.MethodDelete) - h.Handle("/endpoints/{id}/active", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - h.handlePostEndpoint(w, r) - }))).Methods(http.MethodPost) + h.Handle("/endpoints", + mw.administrator(http.HandlerFunc(h.handlePostEndpoints))).Methods(http.MethodPost) + h.Handle("/endpoints", + mw.authenticated(http.HandlerFunc(h.handleGetEndpoints))).Methods(http.MethodGet) + h.Handle("/endpoints/{id}", + mw.administrator(http.HandlerFunc(h.handleGetEndpoint))).Methods(http.MethodGet) + h.Handle("/endpoints/{id}", + mw.administrator(http.HandlerFunc(h.handlePutEndpoint))).Methods(http.MethodPut) + h.Handle("/endpoints/{id}/access", + mw.administrator(http.HandlerFunc(h.handlePutEndpointAccess))).Methods(http.MethodPut) + h.Handle("/endpoints/{id}", + mw.administrator(http.HandlerFunc(h.handleDeleteEndpoint))).Methods(http.MethodDelete) + return h } @@ -65,12 +58,35 @@ func (handler *EndpointHandler) handleGetEndpoints(w http.ResponseWriter, r *htt Error(w, err, http.StatusInternalServerError, handler.Logger) return } - encodeJSON(w, endpoints, handler.Logger) + + tokenData, err := extractTokenDataFromRequestContext(r) + if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + } + if tokenData == nil { + Error(w, portainer.ErrInvalidJWTToken, http.StatusBadRequest, handler.Logger) + return + } + + var allowedEndpoints []portainer.Endpoint + if tokenData.Role != portainer.AdministratorRole { + allowedEndpoints = make([]portainer.Endpoint, 0) + for _, endpoint := range endpoints { + for _, authorizedUserID := range endpoint.AuthorizedUsers { + if authorizedUserID == tokenData.ID { + allowedEndpoints = append(allowedEndpoints, endpoint) + break + } + } + } + } else { + allowedEndpoints = endpoints + } + + encodeJSON(w, allowedEndpoints, handler.Logger) } // handlePostEndpoints handles POST requests on /endpoints -// if the active URL parameter is specified, will also define the new endpoint as the active endpoint. -// /endpoints(?active=true|false) func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *http.Request) { if !handler.authorizeEndpointManagement { Error(w, ErrEndpointManagementDisabled, http.StatusServiceUnavailable, handler.Logger) @@ -90,9 +106,10 @@ func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *ht } endpoint := &portainer.Endpoint{ - Name: req.Name, - URL: req.URL, - TLS: req.TLS, + Name: req.Name, + URL: req.URL, + TLS: req.TLS, + AuthorizedUsers: []portainer.UserID{}, } err = handler.EndpointService.CreateEndpoint(endpoint) @@ -115,22 +132,6 @@ func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *ht } } - activeEndpointParameter := r.FormValue("active") - if activeEndpointParameter != "" { - active, err := strconv.ParseBool(activeEndpointParameter) - if err != nil { - Error(w, err, http.StatusBadRequest, handler.Logger) - return - } - if active == true { - err = handler.server.updateActiveEndpoint(endpoint) - if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - return - } - } - } - encodeJSON(w, &postEndpointsResponse{ID: int(endpoint.ID)}, handler.Logger) } @@ -145,7 +146,6 @@ type postEndpointsResponse struct { } // handleGetEndpoint handles GET requests on /endpoints/:id -// GET /endpoints/0 returns active endpoint func (handler *EndpointHandler) handleGetEndpoint(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) id := vars["id"] @@ -156,48 +156,6 @@ func (handler *EndpointHandler) handleGetEndpoint(w http.ResponseWriter, r *http return } - var endpoint *portainer.Endpoint - if id == "0" { - endpoint, err = handler.EndpointService.GetActive() - if err == portainer.ErrEndpointNotFound { - Error(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - return - } - if handler.server.ActiveEndpoint == nil { - err = handler.server.updateActiveEndpoint(endpoint) - if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - return - } - } - } else { - endpoint, err = handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - if err == portainer.ErrEndpointNotFound { - Error(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - return - } - } - - encodeJSON(w, endpoint, handler.Logger) -} - -// handlePostEndpoint handles POST requests on /endpoints/:id/active -func (handler *EndpointHandler) handlePostEndpoint(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - endpointID, err := strconv.Atoi(id) - if err != nil { - Error(w, err, http.StatusBadRequest, handler.Logger) - return - } - endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) if err == portainer.ErrEndpointNotFound { Error(w, err, http.StatusNotFound, handler.Logger) @@ -207,12 +165,58 @@ func (handler *EndpointHandler) handlePostEndpoint(w http.ResponseWriter, r *htt return } - err = handler.server.updateActiveEndpoint(endpoint) + encodeJSON(w, endpoint, handler.Logger) +} + +// handlePutEndpointAccess handles PUT requests on /endpoints/:id/access +func (handler *EndpointHandler) handlePutEndpointAccess(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + endpointID, err := strconv.Atoi(id) + if err != nil { + Error(w, err, http.StatusBadRequest, handler.Logger) + return + } + + var req putEndpointAccessRequest + if err = json.NewDecoder(r.Body).Decode(&req); err != nil { + Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err = govalidator.ValidateStruct(req) + if err != nil { + Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrEndpointNotFound { + Error(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + authorizedUserIDs := []portainer.UserID{} + for _, value := range req.AuthorizedUsers { + authorizedUserIDs = append(authorizedUserIDs, portainer.UserID(value)) + } + endpoint.AuthorizedUsers = authorizedUserIDs + + err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) if err != nil { Error(w, err, http.StatusInternalServerError, handler.Logger) + return } } +type putEndpointAccessRequest struct { + AuthorizedUsers []int `valid:"required"` +} + // handlePutEndpoint handles PUT requests on /endpoints/:id func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http.Request) { if !handler.authorizeEndpointManagement { @@ -241,14 +245,25 @@ func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http return } - endpoint := &portainer.Endpoint{ - ID: portainer.EndpointID(endpointID), - Name: req.Name, - URL: req.URL, - TLS: req.TLS, + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrEndpointNotFound { + Error(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if req.Name != "" { + endpoint.Name = req.Name + } + + if req.URL != "" { + endpoint.URL = req.URL } if req.TLS { + endpoint.TLS = true caCertPath, _ := handler.FileService.GetPathForTLSFile(endpoint.ID, portainer.TLSFileCA) endpoint.TLSCACertPath = caCertPath certPath, _ := handler.FileService.GetPathForTLSFile(endpoint.ID, portainer.TLSFileCert) @@ -256,6 +271,10 @@ func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http keyPath, _ := handler.FileService.GetPathForTLSFile(endpoint.ID, portainer.TLSFileKey) endpoint.TLSKeyPath = keyPath } else { + endpoint.TLS = false + endpoint.TLSCACertPath = "" + endpoint.TLSCertPath = "" + endpoint.TLSKeyPath = "" err = handler.FileService.DeleteTLSFiles(endpoint.ID) if err != nil { Error(w, err, http.StatusInternalServerError, handler.Logger) @@ -271,13 +290,12 @@ func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http } type putEndpointsRequest struct { - Name string `valid:"required"` - URL string `valid:"required"` - TLS bool + Name string `valid:"-"` + URL string `valid:"-"` + TLS bool `valid:"-"` } // handleDeleteEndpoint handles DELETE requests on /endpoints/:id -// DELETE /endpoints/0 deletes the active endpoint func (handler *EndpointHandler) handleDeleteEndpoint(w http.ResponseWriter, r *http.Request) { if !handler.authorizeEndpointManagement { Error(w, ErrEndpointManagementDisabled, http.StatusServiceUnavailable, handler.Logger) @@ -293,13 +311,7 @@ func (handler *EndpointHandler) handleDeleteEndpoint(w http.ResponseWriter, r *h return } - var endpoint *portainer.Endpoint - if id == "0" { - endpoint, err = handler.EndpointService.GetActive() - endpointID = int(endpoint.ID) - } else { - endpoint, err = handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - } + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) if err == portainer.ErrEndpointNotFound { Error(w, err, http.StatusNotFound, handler.Logger) @@ -314,13 +326,6 @@ func (handler *EndpointHandler) handleDeleteEndpoint(w http.ResponseWriter, r *h Error(w, err, http.StatusInternalServerError, handler.Logger) return } - if id == "0" { - err = handler.EndpointService.DeleteActive() - if err != nil { - Error(w, err, http.StatusInternalServerError, handler.Logger) - return - } - } if endpoint.TLS { err = handler.FileService.DeleteTLSFiles(portainer.EndpointID(endpointID)) diff --git a/api/http/handler.go b/api/http/handler.go index efce098b5..7a27925c7 100644 --- a/api/http/handler.go +++ b/api/http/handler.go @@ -27,6 +27,10 @@ const ( ErrInvalidJSON = portainer.Error("Invalid JSON") // ErrInvalidRequestFormat defines an error raised when the format of the data sent in a request is not valid 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. diff --git a/api/http/middleware.go b/api/http/middleware.go index 891e7c610..4221a61f4 100644 --- a/api/http/middleware.go +++ b/api/http/middleware.go @@ -1,33 +1,61 @@ package http import ( + "context" + "github.com/portainer/portainer" "net/http" "strings" ) -// Service represents a service to manage HTTP middlewares -type middleWareService struct { - jwtService portainer.JWTService - authDisabled bool -} - -func addMiddleware(h http.Handler, middleware ...func(http.Handler) http.Handler) http.Handler { - for _, mw := range middleware { - h = mw(h) +type ( + // middleWareService represents a service to manage HTTP middlewares + middleWareService struct { + jwtService portainer.JWTService + authDisabled bool } + contextKey int +) + +const ( + contextAuthenticationKey contextKey = iota +) + +func extractTokenDataFromRequestContext(request *http.Request) (*portainer.TokenData, error) { + contextData := request.Context().Value(contextAuthenticationKey) + if contextData == nil { + return nil, portainer.ErrMissingContextData + } + + tokenData := contextData.(*portainer.TokenData) + return tokenData, nil +} + +// public defines a chain of middleware for public endpoints (no authentication required) +func (service *middleWareService) public(h http.Handler) http.Handler { + h = mwSecureHeaders(h) return h } -func (service *middleWareService) addMiddleWares(h http.Handler) http.Handler { - h = service.middleWareSecureHeaders(h) - h = service.middleWareAuthenticate(h) +// authenticated defines a chain of middleware for private endpoints (authentication required) +func (service *middleWareService) authenticated(h http.Handler) http.Handler { + h = service.mwCheckAuthentication(h) + h = mwSecureHeaders(h) return h } -// middleWareAuthenticate provides secure headers middleware for handlers -func (*middleWareService) middleWareSecureHeaders(next http.Handler) http.Handler { +// administrator defines a chain of middleware for private administrator restricted endpoints +// (authentication and role admin required) +func (service *middleWareService) administrator(h http.Handler) http.Handler { + h = mwCheckAdministratorRole(h) + h = service.mwCheckAuthentication(h) + h = mwSecureHeaders(h) + return h +} + +// mwSecureHeaders provides secure headers middleware for handlers +func mwSecureHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Add("X-Content-Type-Options", "nosniff") w.Header().Add("X-Frame-Options", "DENY") @@ -35,9 +63,28 @@ func (*middleWareService) middleWareSecureHeaders(next http.Handler) http.Handle }) } -// middleWareAuthenticate provides Authentication middleware for handlers -func (service *middleWareService) middleWareAuthenticate(next http.Handler) http.Handler { +// mwCheckAdministratorRole check the role of the user associated to the request +func mwCheckAdministratorRole(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tokenData, err := extractTokenDataFromRequestContext(r) + if err != nil { + Error(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil) + return + } + + if tokenData.Role != portainer.AdministratorRole { + Error(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil) + return + } + + next.ServeHTTP(w, r) + }) +} + +// mwCheckAuthentication provides Authentication middleware for handlers +func (service *middleWareService) mwCheckAuthentication(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var tokenData *portainer.TokenData if !service.authDisabled { var token string @@ -53,14 +100,20 @@ func (service *middleWareService) middleWareAuthenticate(next http.Handler) http return } - err := service.jwtService.VerifyToken(token) + var err error + tokenData, err = service.jwtService.ParseAndVerifyToken(token) if err != nil { Error(w, err, http.StatusUnauthorized, nil) return } + } else { + tokenData = &portainer.TokenData{ + Role: portainer.AdministratorRole, + } } - next.ServeHTTP(w, r) + ctx := context.WithValue(r.Context(), contextAuthenticationKey, tokenData) + next.ServeHTTP(w, r.WithContext(ctx)) return }) } diff --git a/api/http/proxy_transport.go b/api/http/proxy_transport.go new file mode 100644 index 000000000..34130979a --- /dev/null +++ b/api/http/proxy_transport.go @@ -0,0 +1,664 @@ +package http + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "net/http" + "path" + "strconv" + "strings" + + "github.com/portainer/portainer" +) + +type ( + proxyTransport struct { + transport *http.Transport + ResourceControlService portainer.ResourceControlService + } + resourceControlMetadata struct { + OwnerID portainer.UserID `json:"OwnerId"` + } +) + +func (p *proxyTransport) RoundTrip(req *http.Request) (*http.Response, error) { + response, err := p.transport.RoundTrip(req) + if err != nil { + return response, err + } + + err = p.proxyDockerRequests(req, response) + return response, err +} + +func (p *proxyTransport) proxyDockerRequests(request *http.Request, response *http.Response) error { + path := request.URL.Path + + if strings.HasPrefix(path, "/containers") { + return p.handleContainerRequests(request, response) + } else if strings.HasPrefix(path, "/services") { + return p.handleServiceRequests(request, response) + } else if strings.HasPrefix(path, "/volumes") { + return p.handleVolumeRequests(request, response) + } + + return nil +} + +func (p *proxyTransport) handleContainerRequests(request *http.Request, response *http.Response) error { + requestPath := request.URL.Path + + tokenData, err := extractTokenDataFromRequestContext(request) + if err != nil { + return err + } + + if requestPath == "/containers/prune" && tokenData.Role != portainer.AdministratorRole { + return writeAccessDeniedResponse(response) + } + if requestPath == "/containers/json" { + if tokenData.Role == portainer.AdministratorRole { + return p.decorateContainerResponse(response) + } + return p.proxyContainerResponseWithResourceControl(response, tokenData.ID) + } + // /containers/{id}/action + if match, _ := path.Match("/containers/*/*", requestPath); match { + if tokenData.Role != portainer.AdministratorRole { + resourceID := path.Base(path.Dir(requestPath)) + return p.proxyContainerResponseWithAccessControl(response, tokenData.ID, resourceID) + } + } + + return nil +} + +func (p *proxyTransport) handleServiceRequests(request *http.Request, response *http.Response) error { + requestPath := request.URL.Path + + tokenData, err := extractTokenDataFromRequestContext(request) + if err != nil { + return err + } + + if requestPath == "/services" { + if tokenData.Role == portainer.AdministratorRole { + return p.decorateServiceResponse(response) + } + return p.proxyServiceResponseWithResourceControl(response, tokenData.ID) + } + // /services/{id} + if match, _ := path.Match("/services/*", requestPath); match { + if tokenData.Role != portainer.AdministratorRole { + resourceID := path.Base(requestPath) + return p.proxyServiceResponseWithAccessControl(response, tokenData.ID, resourceID) + } + } + // /services/{id}/action + if match, _ := path.Match("/services/*/*", requestPath); match { + if tokenData.Role != portainer.AdministratorRole { + resourceID := path.Base(path.Dir(requestPath)) + return p.proxyServiceResponseWithAccessControl(response, tokenData.ID, resourceID) + } + } + + return nil +} + +func (p *proxyTransport) handleVolumeRequests(request *http.Request, response *http.Response) error { + requestPath := request.URL.Path + + tokenData, err := extractTokenDataFromRequestContext(request) + if err != nil { + return err + } + + if requestPath == "/volumes" { + if tokenData.Role == portainer.AdministratorRole { + return p.decorateVolumeResponse(response) + } + return p.proxyVolumeResponseWithResourceControl(response, tokenData.ID) + } + if requestPath == "/volumes/prune" && tokenData.Role != portainer.AdministratorRole { + return writeAccessDeniedResponse(response) + } + // /volumes/{name} + if match, _ := path.Match("/volumes/*", requestPath); match { + if tokenData.Role != portainer.AdministratorRole { + resourceID := path.Base(requestPath) + return p.proxyVolumeResponseWithAccessControl(response, tokenData.ID, resourceID) + } + } + return nil +} + +func (p *proxyTransport) proxyContainerResponseWithAccessControl(response *http.Response, userID portainer.UserID, resourceID string) error { + rcs, err := p.ResourceControlService.ResourceControls(portainer.ContainerResourceControl) + if err != nil { + return err + } + + userOwnedResources, err := getResourceIDsOwnedByUser(userID, rcs) + if err != nil { + return err + } + + if !isStringInArray(resourceID, userOwnedResources) && isResourceIDInRCs(resourceID, rcs) { + return writeAccessDeniedResponse(response) + } + + return nil +} + +func (p *proxyTransport) proxyServiceResponseWithAccessControl(response *http.Response, userID portainer.UserID, resourceID string) error { + rcs, err := p.ResourceControlService.ResourceControls(portainer.ServiceResourceControl) + if err != nil { + return err + } + + userOwnedResources, err := getResourceIDsOwnedByUser(userID, rcs) + if err != nil { + return err + } + + if !isStringInArray(resourceID, userOwnedResources) && isResourceIDInRCs(resourceID, rcs) { + return writeAccessDeniedResponse(response) + } + return nil +} + +func (p *proxyTransport) proxyVolumeResponseWithAccessControl(response *http.Response, userID portainer.UserID, resourceID string) error { + rcs, err := p.ResourceControlService.ResourceControls(portainer.VolumeResourceControl) + if err != nil { + return err + } + + userOwnedResources, err := getResourceIDsOwnedByUser(userID, rcs) + if err != nil { + return err + } + + if !isStringInArray(resourceID, userOwnedResources) && isResourceIDInRCs(resourceID, rcs) { + return writeAccessDeniedResponse(response) + } + return nil +} + +func (p *proxyTransport) decorateContainerResponse(response *http.Response) error { + responseData, err := getResponseData(response) + if err != nil { + return err + } + + containers, err := p.decorateContainers(responseData) + if err != nil { + return err + } + + err = rewriteContainerResponse(response, containers) + if err != nil { + return err + } + + return nil +} + +func (p *proxyTransport) proxyContainerResponseWithResourceControl(response *http.Response, userID portainer.UserID) error { + responseData, err := getResponseData(response) + if err != nil { + return err + } + + containers, err := p.filterContainers(userID, responseData) + if err != nil { + return err + } + + err = rewriteContainerResponse(response, containers) + if err != nil { + return err + } + + return nil +} + +func (p *proxyTransport) decorateServiceResponse(response *http.Response) error { + responseData, err := getResponseData(response) + if err != nil { + return err + } + + services, err := p.decorateServices(responseData) + if err != nil { + return err + } + + err = rewriteServiceResponse(response, services) + if err != nil { + return err + } + + return nil +} + +func (p *proxyTransport) proxyServiceResponseWithResourceControl(response *http.Response, userID portainer.UserID) error { + responseData, err := getResponseData(response) + if err != nil { + return err + } + + volumes, err := p.filterServices(userID, responseData) + if err != nil { + return err + } + + err = rewriteServiceResponse(response, volumes) + if err != nil { + return err + } + + return nil +} + +func (p *proxyTransport) decorateVolumeResponse(response *http.Response) error { + responseData, err := getResponseData(response) + if err != nil { + return err + } + + volumes, err := p.decorateVolumes(responseData) + if err != nil { + return err + } + + err = rewriteVolumeResponse(response, volumes) + if err != nil { + return err + } + + return nil +} + +func (p *proxyTransport) proxyVolumeResponseWithResourceControl(response *http.Response, userID portainer.UserID) error { + responseData, err := getResponseData(response) + if err != nil { + return err + } + + volumes, err := p.filterVolumes(userID, responseData) + if err != nil { + return err + } + + err = rewriteVolumeResponse(response, volumes) + if err != nil { + return err + } + + return nil +} + +func (p *proxyTransport) decorateContainers(responseData interface{}) ([]interface{}, error) { + responseDataArray := responseData.([]interface{}) + + containerRCs, err := p.ResourceControlService.ResourceControls(portainer.ContainerResourceControl) + if err != nil { + return nil, err + } + + serviceRCs, err := p.ResourceControlService.ResourceControls(portainer.ServiceResourceControl) + if err != nil { + return nil, err + } + + decoratedResources := make([]interface{}, 0) + + for _, container := range responseDataArray { + jsonObject := container.(map[string]interface{}) + containerID := jsonObject["Id"].(string) + containerRC := getRCByResourceID(containerID, containerRCs) + if containerRC != nil { + decoratedObject := decorateWithResourceControlMetadata(jsonObject, containerRC.OwnerID) + decoratedResources = append(decoratedResources, decoratedObject) + continue + } + + containerLabels := jsonObject["Labels"] + if containerLabels != nil { + jsonLabels := containerLabels.(map[string]interface{}) + serviceID := jsonLabels["com.docker.swarm.service.id"] + if serviceID != nil { + serviceRC := getRCByResourceID(serviceID.(string), serviceRCs) + if serviceRC != nil { + decoratedObject := decorateWithResourceControlMetadata(jsonObject, serviceRC.OwnerID) + decoratedResources = append(decoratedResources, decoratedObject) + continue + } + } + } + decoratedResources = append(decoratedResources, container) + } + + return decoratedResources, nil +} + +func (p *proxyTransport) filterContainers(userID portainer.UserID, responseData interface{}) ([]interface{}, error) { + responseDataArray := responseData.([]interface{}) + + containerRCs, err := p.ResourceControlService.ResourceControls(portainer.ContainerResourceControl) + if err != nil { + return nil, err + } + + serviceRCs, err := p.ResourceControlService.ResourceControls(portainer.ServiceResourceControl) + if err != nil { + return nil, err + } + + userOwnedContainerIDs, err := getResourceIDsOwnedByUser(userID, containerRCs) + if err != nil { + return nil, err + } + + userOwnedServiceIDs, err := getResourceIDsOwnedByUser(userID, serviceRCs) + if err != nil { + return nil, err + } + + publicContainers := getPublicContainers(responseDataArray, containerRCs, serviceRCs) + + filteredResources := make([]interface{}, 0) + + for _, container := range responseDataArray { + jsonObject := container.(map[string]interface{}) + containerID := jsonObject["Id"].(string) + if isStringInArray(containerID, userOwnedContainerIDs) { + decoratedObject := decorateWithResourceControlMetadata(jsonObject, userID) + filteredResources = append(filteredResources, decoratedObject) + continue + } + + containerLabels := jsonObject["Labels"] + if containerLabels != nil { + jsonLabels := containerLabels.(map[string]interface{}) + serviceID := jsonLabels["com.docker.swarm.service.id"] + if serviceID != nil && isStringInArray(serviceID.(string), userOwnedServiceIDs) { + decoratedObject := decorateWithResourceControlMetadata(jsonObject, userID) + filteredResources = append(filteredResources, decoratedObject) + } + } + } + + filteredResources = append(filteredResources, publicContainers...) + return filteredResources, nil +} + +func decorateWithResourceControlMetadata(object map[string]interface{}, userID portainer.UserID) map[string]interface{} { + metadata := make(map[string]interface{}) + metadata["ResourceControl"] = resourceControlMetadata{ + OwnerID: userID, + } + object["Portainer"] = metadata + return object +} + +func (p *proxyTransport) decorateServices(responseData interface{}) ([]interface{}, error) { + responseDataArray := responseData.([]interface{}) + + rcs, err := p.ResourceControlService.ResourceControls(portainer.ServiceResourceControl) + if err != nil { + return nil, err + } + + decoratedResources := make([]interface{}, 0) + + for _, service := range responseDataArray { + jsonResource := service.(map[string]interface{}) + resourceID := jsonResource["ID"].(string) + serviceRC := getRCByResourceID(resourceID, rcs) + if serviceRC != nil { + decoratedObject := decorateWithResourceControlMetadata(jsonResource, serviceRC.OwnerID) + decoratedResources = append(decoratedResources, decoratedObject) + continue + } + decoratedResources = append(decoratedResources, service) + } + + return decoratedResources, nil +} + +func (p *proxyTransport) filterServices(userID portainer.UserID, responseData interface{}) ([]interface{}, error) { + responseDataArray := responseData.([]interface{}) + + rcs, err := p.ResourceControlService.ResourceControls(portainer.ServiceResourceControl) + if err != nil { + return nil, err + } + + userOwnedServiceIDs, err := getResourceIDsOwnedByUser(userID, rcs) + if err != nil { + return nil, err + } + + publicServices := getPublicResources(responseDataArray, rcs, "ID") + + filteredResources := make([]interface{}, 0) + + for _, res := range responseDataArray { + jsonResource := res.(map[string]interface{}) + resourceID := jsonResource["ID"].(string) + if isStringInArray(resourceID, userOwnedServiceIDs) { + decoratedObject := decorateWithResourceControlMetadata(jsonResource, userID) + filteredResources = append(filteredResources, decoratedObject) + } + } + + filteredResources = append(filteredResources, publicServices...) + return filteredResources, nil +} + +func (p *proxyTransport) decorateVolumes(responseData interface{}) ([]interface{}, error) { + var responseDataArray []interface{} + jsonObject := responseData.(map[string]interface{}) + if jsonObject["Volumes"] != nil { + responseDataArray = jsonObject["Volumes"].([]interface{}) + } + + rcs, err := p.ResourceControlService.ResourceControls(portainer.VolumeResourceControl) + if err != nil { + return nil, err + } + + decoratedResources := make([]interface{}, 0) + + for _, volume := range responseDataArray { + jsonResource := volume.(map[string]interface{}) + resourceID := jsonResource["Name"].(string) + volumeRC := getRCByResourceID(resourceID, rcs) + if volumeRC != nil { + decoratedObject := decorateWithResourceControlMetadata(jsonResource, volumeRC.OwnerID) + decoratedResources = append(decoratedResources, decoratedObject) + continue + } + decoratedResources = append(decoratedResources, volume) + } + + return decoratedResources, nil +} + +func (p *proxyTransport) filterVolumes(userID portainer.UserID, responseData interface{}) ([]interface{}, error) { + var responseDataArray []interface{} + jsonObject := responseData.(map[string]interface{}) + if jsonObject["Volumes"] != nil { + responseDataArray = jsonObject["Volumes"].([]interface{}) + } + + rcs, err := p.ResourceControlService.ResourceControls(portainer.VolumeResourceControl) + if err != nil { + return nil, err + } + + userOwnedVolumeIDs, err := getResourceIDsOwnedByUser(userID, rcs) + if err != nil { + return nil, err + } + + publicVolumes := getPublicResources(responseDataArray, rcs, "Name") + + filteredResources := make([]interface{}, 0) + + for _, res := range responseDataArray { + jsonResource := res.(map[string]interface{}) + resourceID := jsonResource["Name"].(string) + if isStringInArray(resourceID, userOwnedVolumeIDs) { + decoratedObject := decorateWithResourceControlMetadata(jsonResource, userID) + filteredResources = append(filteredResources, decoratedObject) + } + } + + filteredResources = append(filteredResources, publicVolumes...) + return filteredResources, nil +} + +func getResourceIDsOwnedByUser(userID portainer.UserID, rcs []portainer.ResourceControl) ([]string, error) { + ownedResources := make([]string, 0) + for _, rc := range rcs { + if rc.OwnerID == userID { + ownedResources = append(ownedResources, rc.ResourceID) + } + } + return ownedResources, nil +} + +func getOwnedServiceContainers(responseData []interface{}, serviceRCs []portainer.ResourceControl) []interface{} { + ownedContainers := make([]interface{}, 0) + for _, res := range responseData { + jsonResource := res.(map[string]map[string]interface{}) + swarmServiceID := jsonResource["Labels"]["com.docker.swarm.service.id"] + if swarmServiceID != nil { + resourceID := swarmServiceID.(string) + if isResourceIDInRCs(resourceID, serviceRCs) { + ownedContainers = append(ownedContainers, res) + } + } + } + return ownedContainers +} + +func getPublicContainers(responseData []interface{}, containerRCs []portainer.ResourceControl, serviceRCs []portainer.ResourceControl) []interface{} { + publicContainers := make([]interface{}, 0) + for _, container := range responseData { + jsonObject := container.(map[string]interface{}) + containerID := jsonObject["Id"].(string) + if !isResourceIDInRCs(containerID, containerRCs) { + containerLabels := jsonObject["Labels"] + if containerLabels != nil { + jsonLabels := containerLabels.(map[string]interface{}) + serviceID := jsonLabels["com.docker.swarm.service.id"] + if serviceID == nil { + publicContainers = append(publicContainers, container) + } else if serviceID != nil && !isResourceIDInRCs(serviceID.(string), serviceRCs) { + publicContainers = append(publicContainers, container) + } + } else { + publicContainers = append(publicContainers, container) + } + } + } + + return publicContainers +} + +func getPublicResources(responseData []interface{}, rcs []portainer.ResourceControl, resourceIDKey string) []interface{} { + publicResources := make([]interface{}, 0) + for _, res := range responseData { + jsonResource := res.(map[string]interface{}) + resourceID := jsonResource[resourceIDKey].(string) + if !isResourceIDInRCs(resourceID, rcs) { + publicResources = append(publicResources, res) + } + } + return publicResources +} + +func isStringInArray(target string, array []string) bool { + for _, element := range array { + if element == target { + return true + } + } + return false +} + +func isResourceIDInRCs(resourceID string, rcs []portainer.ResourceControl) bool { + for _, rc := range rcs { + if resourceID == rc.ResourceID { + return true + } + } + return false +} + +func getRCByResourceID(resourceID string, rcs []portainer.ResourceControl) *portainer.ResourceControl { + for _, rc := range rcs { + if resourceID == rc.ResourceID { + return &rc + } + } + return nil +} + +func getResponseData(response *http.Response) (interface{}, error) { + var data interface{} + if response.Body != nil { + body, err := ioutil.ReadAll(response.Body) + if err != nil { + return nil, err + } + + err = response.Body.Close() + if err != nil { + return nil, err + } + + err = json.Unmarshal(body, &data) + if err != nil { + return nil, err + } + + return data, nil + } + return nil, ErrEmptyResponseBody +} + +func writeAccessDeniedResponse(response *http.Response) error { + return rewriteResponse(response, portainer.ErrResourceAccessDenied, 403) +} + +func rewriteContainerResponse(response *http.Response, responseData interface{}) error { + return rewriteResponse(response, responseData, 200) +} + +func rewriteServiceResponse(response *http.Response, responseData interface{}) error { + return rewriteResponse(response, responseData, 200) +} + +func rewriteVolumeResponse(response *http.Response, responseData interface{}) error { + data := map[string]interface{}{} + data["Volumes"] = responseData + return rewriteResponse(response, data, 200) +} + +func rewriteResponse(response *http.Response, newContent interface{}, statusCode int) error { + jsonData, err := json.Marshal(newContent) + if err != nil { + return err + } + body := ioutil.NopCloser(bytes.NewReader(jsonData)) + response.StatusCode = statusCode + response.Body = body + response.ContentLength = int64(len(jsonData)) + response.Header.Set("Content-Length", strconv.Itoa(len(jsonData))) + return nil +} diff --git a/api/http/server.go b/api/http/server.go index dc09db888..046e58ee9 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -8,35 +8,19 @@ import ( // Server implements the portainer.Server interface type Server struct { - BindAddress string - AssetsPath string - AuthDisabled bool - EndpointManagement bool - UserService portainer.UserService - EndpointService portainer.EndpointService - CryptoService portainer.CryptoService - JWTService portainer.JWTService - FileService portainer.FileService - Settings *portainer.Settings - TemplatesURL string - ActiveEndpoint *portainer.Endpoint - Handler *Handler -} - -func (server *Server) updateActiveEndpoint(endpoint *portainer.Endpoint) error { - if endpoint != nil { - server.ActiveEndpoint = endpoint - server.Handler.WebSocketHandler.endpoint = endpoint - err := server.Handler.DockerHandler.setupProxy(endpoint) - if err != nil { - return err - } - err = server.EndpointService.SetActive(endpoint) - if err != nil { - return err - } - } - return nil + BindAddress string + AssetsPath string + AuthDisabled bool + EndpointManagement bool + UserService portainer.UserService + EndpointService portainer.EndpointService + ResourceControlService portainer.ResourceControlService + CryptoService portainer.CryptoService + JWTService portainer.JWTService + FileService portainer.FileService + Settings *portainer.Settings + TemplatesURL string + Handler *Handler } // Start starts the HTTP server @@ -46,7 +30,7 @@ func (server *Server) Start() error { authDisabled: server.AuthDisabled, } - var authHandler = NewAuthHandler() + var authHandler = NewAuthHandler(middleWareService) authHandler.UserService = server.UserService authHandler.CryptoService = server.CryptoService authHandler.JWTService = server.JWTService @@ -54,18 +38,19 @@ func (server *Server) Start() error { var userHandler = NewUserHandler(middleWareService) userHandler.UserService = server.UserService userHandler.CryptoService = server.CryptoService + userHandler.ResourceControlService = server.ResourceControlService var settingsHandler = NewSettingsHandler(middleWareService) settingsHandler.settings = server.Settings var templatesHandler = NewTemplatesHandler(middleWareService) templatesHandler.templatesURL = server.TemplatesURL - var dockerHandler = NewDockerHandler(middleWareService) + var dockerHandler = NewDockerHandler(middleWareService, server.ResourceControlService) + dockerHandler.EndpointService = server.EndpointService var websocketHandler = NewWebSocketHandler() - // EndpointHandler requires a reference to the server to be able to update the active endpoint. + websocketHandler.EndpointService = server.EndpointService var endpointHandler = NewEndpointHandler(middleWareService) endpointHandler.authorizeEndpointManagement = server.EndpointManagement endpointHandler.EndpointService = server.EndpointService endpointHandler.FileService = server.FileService - endpointHandler.server = server var uploadHandler = NewUploadHandler(middleWareService) uploadHandler.FileService = server.FileService var fileHandler = newFileHandler(server.AssetsPath) @@ -81,10 +66,6 @@ func (server *Server) Start() error { FileHandler: fileHandler, UploadHandler: uploadHandler, } - err := server.updateActiveEndpoint(server.ActiveEndpoint) - if err != nil { - return err - } return http.ListenAndServe(server.BindAddress, server.Handler) } diff --git a/api/http/settings_handler.go b/api/http/settings_handler.go index fab77aaa7..db426c071 100644 --- a/api/http/settings_handler.go +++ b/api/http/settings_handler.go @@ -13,19 +13,19 @@ import ( // SettingsHandler represents an HTTP API handler for managing settings. type SettingsHandler struct { *mux.Router - Logger *log.Logger - middleWareService *middleWareService - settings *portainer.Settings + Logger *log.Logger + settings *portainer.Settings } // NewSettingsHandler returns a new instance of SettingsHandler. -func NewSettingsHandler(middleWareService *middleWareService) *SettingsHandler { +func NewSettingsHandler(mw *middleWareService) *SettingsHandler { h := &SettingsHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - middleWareService: middleWareService, + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), } - h.HandleFunc("/settings", h.handleGetSettings) + h.Handle("/settings", + mw.public(http.HandlerFunc(h.handleGetSettings))) + return h } diff --git a/api/http/templates_handler.go b/api/http/templates_handler.go index a72a6a4c1..510605021 100644 --- a/api/http/templates_handler.go +++ b/api/http/templates_handler.go @@ -12,21 +12,18 @@ import ( // TemplatesHandler represents an HTTP API handler for managing templates. type TemplatesHandler struct { *mux.Router - Logger *log.Logger - middleWareService *middleWareService - templatesURL string + Logger *log.Logger + templatesURL string } // NewTemplatesHandler returns a new instance of TemplatesHandler. -func NewTemplatesHandler(middleWareService *middleWareService) *TemplatesHandler { +func NewTemplatesHandler(mw *middleWareService) *TemplatesHandler { h := &TemplatesHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - middleWareService: middleWareService, + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), } - h.Handle("/templates", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - h.handleGetTemplates(w, r) - }))) + h.Handle("/templates", + mw.authenticated(http.HandlerFunc(h.handleGetTemplates))) return h } diff --git a/api/http/upload_handler.go b/api/http/upload_handler.go index a0d53c36b..a89bf03a4 100644 --- a/api/http/upload_handler.go +++ b/api/http/upload_handler.go @@ -14,21 +14,18 @@ import ( // UploadHandler represents an HTTP API handler for managing file uploads. type UploadHandler struct { *mux.Router - Logger *log.Logger - FileService portainer.FileService - middleWareService *middleWareService + Logger *log.Logger + FileService portainer.FileService } // NewUploadHandler returns a new instance of UploadHandler. -func NewUploadHandler(middleWareService *middleWareService) *UploadHandler { +func NewUploadHandler(mw *middleWareService) *UploadHandler { h := &UploadHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - middleWareService: middleWareService, + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), } - h.Handle("/upload/tls/{endpointID}/{certificate:(?:ca|cert|key)}", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - h.handlePostUploadTLS(w, r) - }))) + h.Handle("/upload/tls/{endpointID}/{certificate:(?:ca|cert|key)}", + mw.authenticated(http.HandlerFunc(h.handlePostUploadTLS))) return h } diff --git a/api/http/user_handler.go b/api/http/user_handler.go index 38243b0a6..b1ba8f684 100644 --- a/api/http/user_handler.go +++ b/api/http/user_handler.go @@ -1,6 +1,8 @@ package http import ( + "strconv" + "github.com/portainer/portainer" "encoding/json" @@ -15,43 +17,44 @@ import ( // UserHandler represents an HTTP API handler for managing users. type UserHandler struct { *mux.Router - Logger *log.Logger - UserService portainer.UserService - CryptoService portainer.CryptoService - middleWareService *middleWareService + Logger *log.Logger + UserService portainer.UserService + ResourceControlService portainer.ResourceControlService + CryptoService portainer.CryptoService } // NewUserHandler returns a new instance of UserHandler. -func NewUserHandler(middleWareService *middleWareService) *UserHandler { +func NewUserHandler(mw *middleWareService) *UserHandler { h := &UserHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - middleWareService: middleWareService, + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), } - h.Handle("/users", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - h.handlePostUsers(w, r) - }))) - h.Handle("/users/{username}", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - h.handleGetUser(w, r) - }))).Methods(http.MethodGet) - h.Handle("/users/{username}", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - h.handlePutUser(w, r) - }))).Methods(http.MethodPut) - h.Handle("/users/{username}/passwd", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - h.handlePostUserPasswd(w, r) - }))) - h.HandleFunc("/users/admin/check", h.handleGetAdminCheck) - h.HandleFunc("/users/admin/init", h.handlePostAdminInit) + h.Handle("/users", + mw.administrator(http.HandlerFunc(h.handlePostUsers))).Methods(http.MethodPost) + h.Handle("/users", + mw.administrator(http.HandlerFunc(h.handleGetUsers))).Methods(http.MethodGet) + h.Handle("/users/{id}", + mw.administrator(http.HandlerFunc(h.handleGetUser))).Methods(http.MethodGet) + h.Handle("/users/{id}", + mw.authenticated(http.HandlerFunc(h.handlePutUser))).Methods(http.MethodPut) + h.Handle("/users/{id}", + mw.administrator(http.HandlerFunc(h.handleDeleteUser))).Methods(http.MethodDelete) + h.Handle("/users/{id}/passwd", + mw.authenticated(http.HandlerFunc(h.handlePostUserPasswd))) + h.Handle("/users/{userId}/resources/{resourceType}", + mw.authenticated(http.HandlerFunc(h.handlePostUserResource))).Methods(http.MethodPost) + h.Handle("/users/{userId}/resources/{resourceType}/{resourceId}", + mw.authenticated(http.HandlerFunc(h.handleDeleteUserResource))).Methods(http.MethodDelete) + h.Handle("/users/admin/check", + mw.public(http.HandlerFunc(h.handleGetAdminCheck))) + h.Handle("/users/admin/init", + mw.public(http.HandlerFunc(h.handlePostAdminInit))) + return h } // handlePostUsers handles POST requests on /users func (handler *UserHandler) handlePostUsers(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - handleNotAllowed(w, []string{http.MethodPost}) - return - } - var req postUsersRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) @@ -64,8 +67,26 @@ func (handler *UserHandler) handlePostUsers(w http.ResponseWriter, r *http.Reque return } - user := &portainer.User{ + 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 { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } + if user != nil { + Error(w, portainer.ErrUserAlreadyExists, http.StatusConflict, handler.Logger) + return + } + + user = &portainer.User{ Username: req.Username, + Role: role, } user.Password, err = handler.CryptoService.Hash(req.Password) if err != nil { @@ -73,7 +94,7 @@ func (handler *UserHandler) handlePostUsers(w http.ResponseWriter, r *http.Reque return } - err = handler.UserService.UpdateUser(user) + err = handler.UserService.CreateUser(user) if err != nil { Error(w, err, http.StatusInternalServerError, handler.Logger) return @@ -83,9 +104,24 @@ func (handler *UserHandler) handlePostUsers(w http.ResponseWriter, r *http.Reque type postUsersRequest struct { Username string `valid:"alphanum,required"` Password string `valid:"required"` + Role int `valid:"required"` } -// handlePostUserPasswd handles POST requests on /users/:username/passwd +// handleGetUsers handles GET requests on /users +func (handler *UserHandler) handleGetUsers(w http.ResponseWriter, r *http.Request) { + users, err := handler.UserService.Users() + if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + for i := range users { + users[i].Password = "" + } + encodeJSON(w, users, handler.Logger) +} + +// handlePostUserPasswd handles POST requests on /users/:id/passwd func (handler *UserHandler) handlePostUserPasswd(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { handleNotAllowed(w, []string{http.MethodPost}) @@ -93,15 +129,21 @@ func (handler *UserHandler) handlePostUserPasswd(w http.ResponseWriter, r *http. } vars := mux.Vars(r) - username := vars["username"] + id := vars["id"] + + userID, err := strconv.Atoi(id) + if err != nil { + Error(w, err, http.StatusBadRequest, handler.Logger) + return + } var req postUserPasswdRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if err = json.NewDecoder(r.Body).Decode(&req); err != nil { Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) return } - _, err := govalidator.ValidateStruct(req) + _, err = govalidator.ValidateStruct(req) if err != nil { Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) return @@ -109,7 +151,7 @@ func (handler *UserHandler) handlePostUserPasswd(w http.ResponseWriter, r *http. var password = req.Password - u, err := handler.UserService.User(username) + u, err := handler.UserService.User(portainer.UserID(userID)) if err == portainer.ErrUserNotFound { Error(w, err, http.StatusNotFound, handler.Logger) return @@ -135,12 +177,18 @@ type postUserPasswdResponse struct { Valid bool `json:"valid"` } -// handleGetUser handles GET requests on /users/:username +// handleGetUser handles GET requests on /users/:id func (handler *UserHandler) handleGetUser(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) - username := vars["username"] + id := vars["id"] - user, err := handler.UserService.User(username) + userID, err := strconv.Atoi(id) + if err != nil { + Error(w, err, http.StatusBadRequest, handler.Logger) + return + } + + user, err := handler.UserService.User(portainer.UserID(userID)) if err == portainer.ErrUserNotFound { Error(w, err, http.StatusNotFound, handler.Logger) return @@ -153,30 +201,74 @@ func (handler *UserHandler) handleGetUser(w http.ResponseWriter, r *http.Request encodeJSON(w, &user, handler.Logger) } -// handlePutUser handles PUT requests on /users/:username +// handlePutUser handles PUT requests on /users/:id func (handler *UserHandler) handlePutUser(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + userID, err := strconv.Atoi(id) + if err != nil { + Error(w, err, http.StatusBadRequest, handler.Logger) + return + } + + tokenData, err := extractTokenDataFromRequestContext(r) + if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + } + + if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(userID) { + Error(w, portainer.ErrUnauthorized, http.StatusForbidden, handler.Logger) + return + } + var req putUserRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if err = json.NewDecoder(r.Body).Decode(&req); err != nil { Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) return } - _, err := govalidator.ValidateStruct(req) + _, err = govalidator.ValidateStruct(req) if err != nil { Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) return } - user := &portainer.User{ - Username: req.Username, - } - user.Password, err = handler.CryptoService.Hash(req.Password) - if err != nil { - Error(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger) + if req.Password == "" && req.Role == 0 { + Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) return } - err = handler.UserService.UpdateUser(user) + user, err := handler.UserService.User(portainer.UserID(userID)) + if err == portainer.ErrUserNotFound { + Error(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if req.Password != "" { + user.Password, err = handler.CryptoService.Hash(req.Password) + if err != nil { + Error(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger) + return + } + } + + if req.Role != 0 { + if tokenData.Role != portainer.AdministratorRole { + Error(w, portainer.ErrUnauthorized, http.StatusForbidden, handler.Logger) + return + } + if req.Role == 1 { + user.Role = portainer.AdministratorRole + } else { + user.Role = portainer.StandardUserRole + } + } + + err = handler.UserService.UpdateUser(user.ID, user) if err != nil { Error(w, err, http.StatusInternalServerError, handler.Logger) return @@ -184,8 +276,8 @@ func (handler *UserHandler) handlePutUser(w http.ResponseWriter, r *http.Request } type putUserRequest struct { - Username string `valid:"alphanum,required"` - Password string `valid:"required"` + Password string `valid:"-"` + Role int `valid:"-"` } // handlePostAdminInit handles GET requests on /users/admin/check @@ -195,17 +287,15 @@ func (handler *UserHandler) handleGetAdminCheck(w http.ResponseWriter, r *http.R return } - user, err := handler.UserService.User("admin") - if err == portainer.ErrUserNotFound { - Error(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { + users, err := handler.UserService.UsersByRole(portainer.AdministratorRole) + if err != nil { Error(w, err, http.StatusInternalServerError, handler.Logger) return } - - user.Password = "" - encodeJSON(w, &user, handler.Logger) + if len(users) == 0 { + Error(w, portainer.ErrUserNotFound, http.StatusNotFound, handler.Logger) + return + } } // handlePostAdminInit handles POST requests on /users/admin/init @@ -227,10 +317,11 @@ func (handler *UserHandler) handlePostAdminInit(w http.ResponseWriter, r *http.R return } - user, err := handler.UserService.User("admin") + user, err := handler.UserService.UserByUsername("admin") if err == portainer.ErrUserNotFound { user := &portainer.User{ Username: "admin", + Role: portainer.AdministratorRole, } user.Password, err = handler.CryptoService.Hash(req.Password) if err != nil { @@ -238,7 +329,7 @@ func (handler *UserHandler) handlePostAdminInit(w http.ResponseWriter, r *http.R return } - err = handler.UserService.UpdateUser(user) + err = handler.UserService.CreateUser(user) if err != nil { Error(w, err, http.StatusInternalServerError, handler.Logger) return @@ -256,3 +347,134 @@ func (handler *UserHandler) handlePostAdminInit(w http.ResponseWriter, r *http.R 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) + id := vars["id"] + + userID, err := strconv.Atoi(id) + if err != nil { + Error(w, err, http.StatusBadRequest, handler.Logger) + return + } + + _, err = handler.UserService.User(portainer.UserID(userID)) + + if err == portainer.ErrUserNotFound { + Error(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + err = handler.UserService.DeleteUser(portainer.UserID(userID)) + if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } +} + +// handlePostUserResource handles POST requests on /users/:userId/resources/:resourceType +func (handler *UserHandler) handlePostUserResource(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + userID := vars["userId"] + resourceType := vars["resourceType"] + + uid, err := strconv.Atoi(userID) + if err != nil { + Error(w, err, http.StatusBadRequest, handler.Logger) + return + } + + var rcType portainer.ResourceControlType + if resourceType == "container" { + rcType = portainer.ContainerResourceControl + } else if resourceType == "service" { + rcType = portainer.ServiceResourceControl + } else if resourceType == "volume" { + rcType = portainer.VolumeResourceControl + } else { + Error(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger) + return + } + + tokenData, err := extractTokenDataFromRequestContext(r) + if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + } + if tokenData.ID != portainer.UserID(uid) { + Error(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) + return + } + + var req postUserResourceRequest + if err = json.NewDecoder(r.Body).Decode(&req); err != nil { + Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err = govalidator.ValidateStruct(req) + if err != nil { + Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + resource := portainer.ResourceControl{ + OwnerID: portainer.UserID(uid), + ResourceID: req.ResourceID, + AccessLevel: portainer.RestrictedResourceAccessLevel, + } + + err = handler.ResourceControlService.CreateResourceControl(req.ResourceID, &resource, rcType) + if err != nil { + Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } +} + +type postUserResourceRequest struct { + ResourceID string `valid:"required"` +} + +// handleDeleteUserResource handles DELETE requests on /users/:userId/resources/:resourceType/:resourceId +func (handler *UserHandler) handleDeleteUserResource(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + userID := vars["userId"] + resourceID := vars["resourceId"] + resourceType := vars["resourceType"] + + uid, err := strconv.Atoi(userID) + if err != nil { + Error(w, err, http.StatusBadRequest, handler.Logger) + return + } + + var rcType portainer.ResourceControlType + if resourceType == "container" { + rcType = portainer.ContainerResourceControl + } else if resourceType == "service" { + rcType = portainer.ServiceResourceControl + } else if resourceType == "volume" { + rcType = portainer.VolumeResourceControl + } else { + Error(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger) + return + } + + tokenData, err := extractTokenDataFromRequestContext(r) + if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + } + if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(uid) { + Error(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) + return + } + + err = handler.ResourceControlService.DeleteResourceControl(resourceID, rcType) + if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } +} diff --git a/api/http/websocket_handler.go b/api/http/websocket_handler.go index c217404a0..126fcbed0 100644 --- a/api/http/websocket_handler.go +++ b/api/http/websocket_handler.go @@ -1,8 +1,6 @@ package http import ( - "github.com/portainer/portainer" - "bytes" "crypto/tls" "encoding/json" @@ -14,18 +12,19 @@ import ( "net/http/httputil" "net/url" "os" + "strconv" "time" "github.com/gorilla/mux" + "github.com/portainer/portainer" "golang.org/x/net/websocket" ) // WebSocketHandler represents an HTTP API handler for proxying requests to a web socket. type WebSocketHandler struct { *mux.Router - Logger *log.Logger - middleWareService *middleWareService - endpoint *portainer.Endpoint + Logger *log.Logger + EndpointService portainer.EndpointService } // NewWebSocketHandler returns a new instance of WebSocketHandler. @@ -41,34 +40,47 @@ func NewWebSocketHandler() *WebSocketHandler { func (handler *WebSocketHandler) webSocketDockerExec(ws *websocket.Conn) { qry := ws.Request().URL.Query() execID := qry.Get("id") + edpID := qry.Get("endpointId") - // Should not be managed here - endpoint, err := url.Parse(handler.endpoint.URL) + parsedID, err := strconv.Atoi(edpID) if err != nil { - log.Fatalf("Unable to parse endpoint URL: %s", err) + log.Printf("Unable to parse endpoint ID: %s", err) + return + } + + endpointID := portainer.EndpointID(parsedID) + endpoint, err := handler.EndpointService.Endpoint(endpointID) + if err != nil { + log.Printf("Unable to retrieve endpoint: %s", err) + return + } + + endpointURL, err := url.Parse(endpoint.URL) + if err != nil { + log.Printf("Unable to parse endpoint URL: %s", err) return } var host string - if endpoint.Scheme == "tcp" { - host = endpoint.Host - } else if endpoint.Scheme == "unix" { - host = endpoint.Path + if endpointURL.Scheme == "tcp" { + host = endpointURL.Host + } else if endpointURL.Scheme == "unix" { + host = endpointURL.Path } // Should not be managed here var tlsConfig *tls.Config - if handler.endpoint.TLS { - tlsConfig, err = createTLSConfiguration(handler.endpoint.TLSCACertPath, - handler.endpoint.TLSCertPath, - handler.endpoint.TLSKeyPath) + if endpoint.TLS { + tlsConfig, err = createTLSConfiguration(endpoint.TLSCACertPath, + endpoint.TLSCertPath, + endpoint.TLSKeyPath) if err != nil { log.Fatalf("Unable to create TLS configuration: %s", err) return } } - if err := hijack(host, endpoint.Scheme, "POST", "/exec/"+execID+"/start", tlsConfig, true, ws, ws, ws, nil, nil); err != nil { + if err := hijack(host, endpointURL.Scheme, "POST", "/exec/"+execID+"/start", tlsConfig, true, ws, ws, ws, nil, nil); err != nil { log.Fatalf("error during hijack: %s", err) return } diff --git a/api/jwt/jwt.go b/api/jwt/jwt.go index 0971ee5f7..9d14be4bc 100644 --- a/api/jwt/jwt.go +++ b/api/jwt/jwt.go @@ -4,9 +4,10 @@ import ( "github.com/portainer/portainer" "fmt" + "time" + "github.com/dgrijalva/jwt-go" "github.com/gorilla/securecookie" - "time" ) // Service represents a service for managing JWT tokens. @@ -15,7 +16,9 @@ type Service struct { } type claims struct { + UserID int `json:"id"` Username string `json:"username"` + Role int `json:"role"` jwt.StandardClaims } @@ -35,7 +38,9 @@ func NewService() (*Service, error) { func (service *Service) GenerateToken(data *portainer.TokenData) (string, error) { expireToken := time.Now().Add(time.Hour * 8).Unix() cl := claims{ + int(data.ID), data.Username, + int(data.Role), jwt.StandardClaims{ ExpiresAt: expireToken, }, @@ -50,17 +55,25 @@ func (service *Service) GenerateToken(data *portainer.TokenData) (string, error) return signedToken, nil } -// VerifyToken parses a JWT token and verify its validity. It returns an error if token is invalid. -func (service *Service) VerifyToken(token string) error { - parsedToken, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) { +// ParseAndVerifyToken parses a JWT token and verify its validity. It returns an error if token is invalid. +func (service *Service) ParseAndVerifyToken(token string) (*portainer.TokenData, error) { + parsedToken, err := jwt.ParseWithClaims(token, &claims{}, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { msg := fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) return nil, msg } return service.secret, nil }) - if err != nil || parsedToken == nil || !parsedToken.Valid { - return portainer.ErrInvalidJWTToken + if err == nil && parsedToken != nil { + if cl, ok := parsedToken.Claims.(*claims); ok && parsedToken.Valid { + tokenData := &portainer.TokenData{ + ID: portainer.UserID(cl.UserID), + Username: cl.Username, + Role: portainer.UserRole(cl.Role), + } + return tokenData, nil + } } - return nil + + return nil, portainer.ErrInvalidJWTToken } diff --git a/api/portainer.go b/api/portainer.go index ee84c4404..14032e931 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -23,6 +23,7 @@ type ( Logo *string Templates *string NoAuth *bool + NoAnalytics *bool TLSVerify *bool TLSCacert *string TLSCert *string @@ -34,18 +35,30 @@ type ( HiddenLabels []Pair `json:"hiddenLabels"` Logo string `json:"logo"` Authentication bool `json:"authentication"` + Analytics bool `json:"analytics"` EndpointManagement bool `json:"endpointManagement"` } // User represent a user account. User struct { - Username string `json:"Username"` - Password string `json:"Password,omitempty"` + ID UserID `json:"Id"` + Username string `json:"Username"` + Password string `json:"Password,omitempty"` + Role UserRole `json:"Role"` } + // UserID represents a user identifier + UserID int + + // UserRole represents the role of a user. It can be either an administrator + // or a regular user. + UserRole int + // TokenData represents the data embedded in a JWT token. TokenData struct { + ID UserID Username string + Role UserRole } // EndpointID represents an endpoint identifier. @@ -54,15 +67,31 @@ type ( // Endpoint represents a Docker endpoint with all the info required // to connect to it. Endpoint struct { - ID EndpointID `json:"Id"` - Name string `json:"Name"` - URL string `json:"URL"` - TLS bool `json:"TLS"` - TLSCACertPath string `json:"TLSCACert,omitempty"` - TLSCertPath string `json:"TLSCert,omitempty"` - TLSKeyPath string `json:"TLSKey,omitempty"` + ID EndpointID `json:"Id"` + Name string `json:"Name"` + URL string `json:"URL"` + TLS bool `json:"TLS"` + TLSCACertPath string `json:"TLSCACert,omitempty"` + TLSCertPath string `json:"TLSCert,omitempty"` + TLSKeyPath string `json:"TLSKey,omitempty"` + AuthorizedUsers []UserID `json:"AuthorizedUsers"` } + // ResourceControl represent a reference to a Docker resource with specific controls + ResourceControl struct { + OwnerID UserID `json:"OwnerId"` + ResourceID string `json:"ResourceId"` + AccessLevel ResourceAccessLevel `json:"AccessLevel"` + } + + // ResourceControlType represents a type of resource control. + // Can be one of: container, service or volume. + ResourceControlType int + + // ResourceAccessLevel represents the level of control associated to a resource for a specific owner. + // Can be one of: full, restricted, limited. + ResourceAccessLevel int + // TLSFileType represents a type of TLS file required to connect to a Docker endpoint. // It can be either a TLS CA file, a TLS certificate file or a TLS key file. TLSFileType int @@ -77,32 +106,49 @@ type ( DataStore interface { Open() error Close() error + MigrateData() error } - // Server defines the interface to serve the data. + // Server defines the interface to serve the API. Server interface { Start() error } - // UserService represents a service for managing users. + // UserService represents a service for managing user data. UserService interface { - User(username string) (*User, error) - UpdateUser(user *User) error + User(ID UserID) (*User, error) + UserByUsername(username string) (*User, error) + Users() ([]User, error) + UsersByRole(role UserRole) ([]User, error) + CreateUser(user *User) error + UpdateUser(ID UserID, user *User) error + DeleteUser(ID UserID) error } - // EndpointService represents a service for managing endpoints. + // EndpointService represents a service for managing endpoint data. EndpointService interface { Endpoint(ID EndpointID) (*Endpoint, error) Endpoints() ([]Endpoint, error) CreateEndpoint(endpoint *Endpoint) error UpdateEndpoint(ID EndpointID, endpoint *Endpoint) error DeleteEndpoint(ID EndpointID) error - GetActive() (*Endpoint, error) - SetActive(endpoint *Endpoint) error - DeleteActive() error Synchronize(toCreate, toUpdate, toDelete []*Endpoint) error } + // VersionService represents a service for managing version data. + VersionService interface { + DBVersion() (int, error) + StoreDBVersion(version int) error + } + + // ResourceControlService represents a service for managing resource control data. + ResourceControlService interface { + ResourceControl(resourceID string, rcType ResourceControlType) (*ResourceControl, error) + ResourceControls(rcType ResourceControlType) ([]ResourceControl, error) + CreateResourceControl(resourceID string, rc *ResourceControl, rcType ResourceControlType) error + DeleteResourceControl(resourceID string, rcType ResourceControlType) error + } + // CryptoService represents a service for encrypting/hashing data. CryptoService interface { Hash(data string) (string, error) @@ -112,7 +158,7 @@ type ( // JWTService represents a service for managing JWT tokens. JWTService interface { GenerateToken(data *TokenData) (string, error) - VerifyToken(token string) error + ParseAndVerifyToken(token string) (*TokenData, error) } // FileService represents a service for managing files. @@ -129,8 +175,10 @@ type ( ) const ( - // APIVersion is the version number of portainer API. - APIVersion = "1.11.4" + // APIVersion is the version number of Portainer API. + APIVersion = "1.12.0" + // DBVersion is the version number of Portainer database. + DBVersion = 1 ) const ( @@ -141,3 +189,27 @@ const ( // TLSFileKey represents a TLS key file. TLSFileKey ) + +const ( + _ UserRole = iota + // AdministratorRole represents an administrator user role + AdministratorRole + // StandardUserRole represents a regular user role + StandardUserRole +) + +const ( + _ ResourceControlType = iota + // ContainerResourceControl represents a resource control for a container + ContainerResourceControl + // ServiceResourceControl represents a resource control for a service + ServiceResourceControl + // VolumeResourceControl represents a resource control for a volume + VolumeResourceControl +) + +const ( + _ ResourceAccessLevel = iota + // RestrictedResourceAccessLevel represents a restricted access level on a resource (private ownership) + RestrictedResourceAccessLevel +) diff --git a/app/app.js b/app/app.js index e819fd0aa..7805c060b 100644 --- a/app/app.js +++ b/app/app.js @@ -1,71 +1,78 @@ -angular.module('portainer.filters', []); -angular.module('portainer.rest', ['ngResource']); -angular.module('portainer.services', []); -angular.module('portainer.helpers', []); -angular.module('portainer', [ - 'ui.bootstrap', - 'ui.router', - 'ui.select', - 'ngCookies', - 'ngSanitize', - 'ngFileUpload', - 'angularUtils.directives.dirPagination', - 'LocalStorageModule', - 'angular-jwt', - 'portainer.templates', - 'portainer.filters', - 'portainer.rest', - 'portainer.helpers', - 'portainer.services', - 'auth', - 'dashboard', - 'container', - 'containerConsole', - 'containerLogs', - 'containers', - 'createContainer', - 'createNetwork', - 'createService', - 'createVolume', - 'docker', - 'endpoint', - 'endpointInit', - 'endpoints', - 'events', - 'image', - 'images', - 'main', - 'network', - 'networks', - 'node', - 'service', - 'services', - 'settings', - 'sidebar', - 'stats', - 'swarm', - 'task', - 'templates', - 'volumes']) - .config(['$stateProvider', '$urlRouterProvider', '$httpProvider', 'localStorageServiceProvider', 'jwtOptionsProvider', function ($stateProvider, $urlRouterProvider, $httpProvider, localStorageServiceProvider, jwtOptionsProvider) { - 'use strict'; - - localStorageServiceProvider - .setStorageType('sessionStorage') - .setPrefix('portainer'); - - jwtOptionsProvider.config({ - tokenGetter: ['LocalStorage', function(LocalStorage) { - return LocalStorage.getJWT(); - }], - unauthenticatedRedirector: ['$state', function($state) { - $state.go('auth', {error: 'Your session has expired'}); - }] - }); - $httpProvider.interceptors.push('jwtInterceptor'); - - $urlRouterProvider.otherwise('/auth'); - +angular.module('portainer.filters', []); +angular.module('portainer.rest', ['ngResource']); +angular.module('portainer.services', []); +angular.module('portainer.helpers', []); +angular.module('portainer', [ + 'ui.bootstrap', + 'ui.router', + 'ui.select', + 'ngCookies', + 'ngSanitize', + 'ngFileUpload', + 'angularUtils.directives.dirPagination', + 'LocalStorageModule', + 'angular-jwt', + 'angular-google-analytics', + 'portainer.templates', + 'portainer.filters', + 'portainer.rest', + 'portainer.helpers', + 'portainer.services', + 'auth', + 'dashboard', + 'container', + 'containerConsole', + 'containerLogs', + 'containers', + 'createContainer', + 'createNetwork', + 'createService', + 'createVolume', + 'docker', + 'endpoint', + 'endpointAccess', + 'endpointInit', + 'endpoints', + 'events', + 'image', + 'images', + 'main', + 'network', + 'networks', + 'node', + 'service', + 'services', + 'settings', + 'sidebar', + 'stats', + 'swarm', + 'task', + 'templates', + 'user', + 'users', + 'volumes']) + .config(['$stateProvider', '$urlRouterProvider', '$httpProvider', 'localStorageServiceProvider', 'jwtOptionsProvider', 'AnalyticsProvider', function ($stateProvider, $urlRouterProvider, $httpProvider, localStorageServiceProvider, jwtOptionsProvider, AnalyticsProvider) { + 'use strict'; + + localStorageServiceProvider + .setStorageType('sessionStorage') + .setPrefix('portainer'); + + jwtOptionsProvider.config({ + tokenGetter: ['LocalStorage', function(LocalStorage) { + return LocalStorage.getJWT(); + }], + unauthenticatedRedirector: ['$state', function($state) { + $state.go('auth', {error: 'Your session has expired'}); + }] + }); + $httpProvider.interceptors.push('jwtInterceptor'); + + AnalyticsProvider.setAccount('@@CONFIG_GA_ID'); + AnalyticsProvider.startOffline(true); + + $urlRouterProvider.otherwise('/auth'); + $stateProvider .state('root', { abstract: true, @@ -79,435 +86,484 @@ angular.module('portainer', [ .state('auth', { parent: 'root', url: '/auth', - params: { - logout: false, - error: '' - }, - views: { - "content@": { - templateUrl: 'app/components/auth/auth.html', - controller: 'AuthenticationController' - } - }, - data: { - requiresLogin: false - } - }) - .state('containers', { - parent: 'root', - url: '/containers/', - views: { - "content@": { - templateUrl: 'app/components/containers/containers.html', - controller: 'ContainersController' - }, - "sidebar@": { - templateUrl: 'app/components/sidebar/sidebar.html', - controller: 'SidebarController' - } - } - }) - .state('container', { - url: "^/containers/:id", - views: { - "content@": { - templateUrl: 'app/components/container/container.html', - controller: 'ContainerController' - }, - "sidebar@": { - templateUrl: 'app/components/sidebar/sidebar.html', - controller: 'SidebarController' - } - } - }) - .state('stats', { - url: "^/containers/:id/stats", - views: { - "content@": { - templateUrl: 'app/components/stats/stats.html', - controller: 'StatsController' - }, - "sidebar@": { - templateUrl: 'app/components/sidebar/sidebar.html', - controller: 'SidebarController' - } - } - }) - .state('logs', { - url: "^/containers/:id/logs", - views: { - "content@": { - templateUrl: 'app/components/containerLogs/containerlogs.html', - controller: 'ContainerLogsController' - }, - "sidebar@": { - templateUrl: 'app/components/sidebar/sidebar.html', - controller: 'SidebarController' - } - } - }) - .state('console', { - url: "^/containers/:id/console", - views: { - "content@": { - templateUrl: 'app/components/containerConsole/containerConsole.html', - controller: 'ContainerConsoleController' - }, - "sidebar@": { - templateUrl: 'app/components/sidebar/sidebar.html', - controller: 'SidebarController' - } - } - }) - .state('dashboard', { - parent: 'root', - url: '/dashboard', - views: { - "content@": { - templateUrl: 'app/components/dashboard/dashboard.html', - controller: 'DashboardController' - }, - "sidebar@": { - templateUrl: 'app/components/sidebar/sidebar.html', - controller: 'SidebarController' - } - } - }) - .state('actions', { - abstract: true, - url: "/actions", - views: { - "content@": { - template: '
' - }, - "sidebar@": { - template: '
' - } - } - }) - .state('actions.create', { - abstract: true, - url: "/create", - views: { - "content@": { - template: '
' - }, - "sidebar@": { - template: '
' - } - } - }) - .state('actions.create.container', { - url: "/container", - views: { - "content@": { - templateUrl: 'app/components/createContainer/createcontainer.html', - controller: 'CreateContainerController' - }, - "sidebar@": { - templateUrl: 'app/components/sidebar/sidebar.html', - controller: 'SidebarController' - } - } - }) - .state('actions.create.network', { - url: "/network", - views: { - "content@": { - templateUrl: 'app/components/createNetwork/createnetwork.html', - controller: 'CreateNetworkController' - }, - "sidebar@": { - templateUrl: 'app/components/sidebar/sidebar.html', - controller: 'SidebarController' - } - } - }) - .state('actions.create.service', { - url: "/service", - views: { - "content@": { - templateUrl: 'app/components/createService/createservice.html', - controller: 'CreateServiceController' - }, - "sidebar@": { - templateUrl: 'app/components/sidebar/sidebar.html', - controller: 'SidebarController' - } - } - }) - .state('actions.create.volume', { - url: "/volume", - views: { - "content@": { - templateUrl: 'app/components/createVolume/createvolume.html', - controller: 'CreateVolumeController' - }, - "sidebar@": { - templateUrl: 'app/components/sidebar/sidebar.html', - controller: 'SidebarController' - } - } - }) - .state('docker', { - url: '/docker/', - views: { - "content@": { - templateUrl: 'app/components/docker/docker.html', - controller: 'DockerController' - }, - "sidebar@": { - templateUrl: 'app/components/sidebar/sidebar.html', - controller: 'SidebarController' - } - } - }) - .state('endpoints', { - url: '/endpoints/', - views: { - "content@": { - templateUrl: 'app/components/endpoints/endpoints.html', - controller: 'EndpointsController' - }, - "sidebar@": { - templateUrl: 'app/components/sidebar/sidebar.html', - controller: 'SidebarController' - } - } - }) - .state('endpoint', { - url: '^/endpoints/:id', - views: { - "content@": { - templateUrl: 'app/components/endpoint/endpoint.html', - controller: 'EndpointController' - }, - "sidebar@": { - templateUrl: 'app/components/sidebar/sidebar.html', - controller: 'SidebarController' - } - } - }) - .state('endpointInit', { - url: '/init/endpoint', - views: { - "content@": { - templateUrl: 'app/components/endpointInit/endpointInit.html', - controller: 'EndpointInitController' - } - } - }) - .state('events', { - url: '/events/', - views: { - "content@": { - templateUrl: 'app/components/events/events.html', - controller: 'EventsController' - }, - "sidebar@": { - templateUrl: 'app/components/sidebar/sidebar.html', - controller: 'SidebarController' - } - } - }) - .state('images', { - url: '/images/', - views: { - "content@": { - templateUrl: 'app/components/images/images.html', - controller: 'ImagesController' - }, - "sidebar@": { - templateUrl: 'app/components/sidebar/sidebar.html', - controller: 'SidebarController' - } - } - }) - .state('image', { - url: '^/images/:id/', - views: { - "content@": { - templateUrl: 'app/components/image/image.html', - controller: 'ImageController' - }, - "sidebar@": { - templateUrl: 'app/components/sidebar/sidebar.html', - controller: 'SidebarController' - } - } - }) - .state('networks', { - url: '/networks/', - views: { - "content@": { - templateUrl: 'app/components/networks/networks.html', - controller: 'NetworksController' - }, - "sidebar@": { - templateUrl: 'app/components/sidebar/sidebar.html', - controller: 'SidebarController' - } - } - }) - .state('network', { - url: '^/networks/:id/', - views: { - "content@": { - templateUrl: 'app/components/network/network.html', - controller: 'NetworkController' - }, - "sidebar@": { - templateUrl: 'app/components/sidebar/sidebar.html', - controller: 'SidebarController' - } - } - }) - .state('node', { - url: '^/nodes/:id/', - views: { - "content@": { - templateUrl: 'app/components/node/node.html', - controller: 'NodeController' - }, - "sidebar@": { - templateUrl: 'app/components/sidebar/sidebar.html', - controller: 'SidebarController' - } - } - }) - .state('services', { - url: '/services/', - views: { - "content@": { - templateUrl: 'app/components/services/services.html', - controller: 'ServicesController' - }, - "sidebar@": { - templateUrl: 'app/components/sidebar/sidebar.html', - controller: 'SidebarController' - } - } - }) - .state('service', { - url: '^/service/:id/', - views: { - "content@": { - templateUrl: 'app/components/service/service.html', - controller: 'ServiceController' - }, - "sidebar@": { - templateUrl: 'app/components/sidebar/sidebar.html', - controller: 'SidebarController' - } - } - }) - .state('settings', { - url: '/settings/', - views: { - "content@": { - templateUrl: 'app/components/settings/settings.html', - controller: 'SettingsController' - }, - "sidebar@": { - templateUrl: 'app/components/sidebar/sidebar.html', - controller: 'SidebarController' - } - } - }) - .state('task', { - url: '^/task/:id', - views: { - "content@": { - templateUrl: 'app/components/task/task.html', - controller: 'TaskController' - }, - "sidebar@": { - templateUrl: 'app/components/sidebar/sidebar.html', - controller: 'SidebarController' - } - } - }) - .state('templates', { - url: '/templates/', - views: { - "content@": { - templateUrl: 'app/components/templates/templates.html', - controller: 'TemplatesController' - }, - "sidebar@": { - templateUrl: 'app/components/sidebar/sidebar.html', - controller: 'SidebarController' - } - } - }) - .state('volumes', { - url: '/volumes/', - views: { - "content@": { - templateUrl: 'app/components/volumes/volumes.html', - controller: 'VolumesController' - }, - "sidebar@": { - templateUrl: 'app/components/sidebar/sidebar.html', - controller: 'SidebarController' - } - } - }) - .state('swarm', { - url: '/swarm/', - views: { - "content@": { - templateUrl: 'app/components/swarm/swarm.html', - controller: 'SwarmController' - }, - "sidebar@": { - templateUrl: 'app/components/sidebar/sidebar.html', - controller: 'SidebarController' - } - } - }); - - // The Docker API likes to return plaintext errors, this catches them and disp - $httpProvider.interceptors.push(function() { - return { - 'response': function(response) { - if (typeof(response.data) === 'string' && - (response.data.startsWith('Conflict.') || response.data.startsWith('conflict:'))) { - $.gritter.add({ - title: 'Error', - text: $('
').text(response.data).html(), - time: 10000 - }); - } - return response; - } - }; - }); - }]) - .run(['$rootScope', '$state', 'Authentication', 'authManager', 'StateManager', 'Messages', function ($rootScope, $state, Authentication, authManager, StateManager, Messages) { - StateManager.initialize().then(function success(state) { - if (state.application.authentication) { - authManager.checkAuthOnRefresh(); - authManager.redirectWhenUnauthenticated(); - Authentication.init(); - $rootScope.$on('tokenHasExpired', function($state) { - $state.go('auth', {error: 'Your session has expired'}); - }); - } - }, function error(err) { - Messages.error("Failure", err, 'Unable to retrieve application settings'); - }); - - $rootScope.$state = $state; - }]) - // 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/settings') + params: { + logout: false, + error: '' + }, + views: { + "content@": { + templateUrl: 'app/components/auth/auth.html', + controller: 'AuthenticationController' + } + }, + data: { + requiresLogin: false + } + }) + .state('containers', { + parent: 'root', + url: '/containers/', + views: { + "content@": { + templateUrl: 'app/components/containers/containers.html', + controller: 'ContainersController' + }, + "sidebar@": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) + .state('container', { + url: "^/containers/:id", + views: { + "content@": { + templateUrl: 'app/components/container/container.html', + controller: 'ContainerController' + }, + "sidebar@": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) + .state('stats', { + url: "^/containers/:id/stats", + views: { + "content@": { + templateUrl: 'app/components/stats/stats.html', + controller: 'StatsController' + }, + "sidebar@": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) + .state('logs', { + url: "^/containers/:id/logs", + views: { + "content@": { + templateUrl: 'app/components/containerLogs/containerlogs.html', + controller: 'ContainerLogsController' + }, + "sidebar@": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) + .state('console', { + url: "^/containers/:id/console", + views: { + "content@": { + templateUrl: 'app/components/containerConsole/containerConsole.html', + controller: 'ContainerConsoleController' + }, + "sidebar@": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) + .state('dashboard', { + parent: 'root', + url: '/dashboard', + views: { + "content@": { + templateUrl: 'app/components/dashboard/dashboard.html', + controller: 'DashboardController' + }, + "sidebar@": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) + .state('actions', { + abstract: true, + url: "/actions", + views: { + "content@": { + template: '
' + }, + "sidebar@": { + template: '
' + } + } + }) + .state('actions.create', { + abstract: true, + url: "/create", + views: { + "content@": { + template: '
' + }, + "sidebar@": { + template: '
' + } + } + }) + .state('actions.create.container', { + url: "/container", + views: { + "content@": { + templateUrl: 'app/components/createContainer/createcontainer.html', + controller: 'CreateContainerController' + }, + "sidebar@": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) + .state('actions.create.network', { + url: "/network", + views: { + "content@": { + templateUrl: 'app/components/createNetwork/createnetwork.html', + controller: 'CreateNetworkController' + }, + "sidebar@": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) + .state('actions.create.service', { + url: "/service", + views: { + "content@": { + templateUrl: 'app/components/createService/createservice.html', + controller: 'CreateServiceController' + }, + "sidebar@": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) + .state('actions.create.volume', { + url: "/volume", + views: { + "content@": { + templateUrl: 'app/components/createVolume/createvolume.html', + controller: 'CreateVolumeController' + }, + "sidebar@": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) + .state('docker', { + url: '/docker/', + views: { + "content@": { + templateUrl: 'app/components/docker/docker.html', + controller: 'DockerController' + }, + "sidebar@": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) + .state('endpoints', { + url: '/endpoints/', + views: { + "content@": { + templateUrl: 'app/components/endpoints/endpoints.html', + controller: 'EndpointsController' + }, + "sidebar@": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) + .state('endpoint', { + url: '^/endpoints/:id', + views: { + "content@": { + templateUrl: 'app/components/endpoint/endpoint.html', + controller: 'EndpointController' + }, + "sidebar@": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) + .state('endpoint.access', { + url: '^/endpoints/:id/access', + views: { + "content@": { + templateUrl: 'app/components/endpointAccess/endpointAccess.html', + controller: 'EndpointAccessController' + }, + "sidebar@": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) + .state('endpointInit', { + url: '/init/endpoint', + views: { + "content@": { + templateUrl: 'app/components/endpointInit/endpointInit.html', + controller: 'EndpointInitController' + } + } + }) + .state('events', { + url: '/events/', + views: { + "content@": { + templateUrl: 'app/components/events/events.html', + controller: 'EventsController' + }, + "sidebar@": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) + .state('images', { + url: '/images/', + views: { + "content@": { + templateUrl: 'app/components/images/images.html', + controller: 'ImagesController' + }, + "sidebar@": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) + .state('image', { + url: '^/images/:id/', + views: { + "content@": { + templateUrl: 'app/components/image/image.html', + controller: 'ImageController' + }, + "sidebar@": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) + .state('networks', { + url: '/networks/', + views: { + "content@": { + templateUrl: 'app/components/networks/networks.html', + controller: 'NetworksController' + }, + "sidebar@": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) + .state('network', { + url: '^/networks/:id/', + views: { + "content@": { + templateUrl: 'app/components/network/network.html', + controller: 'NetworkController' + }, + "sidebar@": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) + .state('node', { + url: '^/nodes/:id/', + views: { + "content@": { + templateUrl: 'app/components/node/node.html', + controller: 'NodeController' + }, + "sidebar@": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) + .state('services', { + url: '/services/', + views: { + "content@": { + templateUrl: 'app/components/services/services.html', + controller: 'ServicesController' + }, + "sidebar@": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) + .state('service', { + url: '^/service/:id/', + views: { + "content@": { + templateUrl: 'app/components/service/service.html', + controller: 'ServiceController' + }, + "sidebar@": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) + .state('settings', { + url: '/settings/', + views: { + "content@": { + templateUrl: 'app/components/settings/settings.html', + controller: 'SettingsController' + }, + "sidebar@": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) + .state('task', { + url: '^/task/:id', + views: { + "content@": { + templateUrl: 'app/components/task/task.html', + controller: 'TaskController' + }, + "sidebar@": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) + .state('templates', { + url: '/templates/', + views: { + "content@": { + templateUrl: 'app/components/templates/templates.html', + controller: 'TemplatesController' + }, + "sidebar@": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) + .state('volumes', { + url: '/volumes/', + views: { + "content@": { + templateUrl: 'app/components/volumes/volumes.html', + controller: 'VolumesController' + }, + "sidebar@": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) + .state('users', { + url: '/users/', + views: { + "content@": { + templateUrl: 'app/components/users/users.html', + controller: 'UsersController' + }, + "sidebar@": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) + .state('user', { + url: '^/users/:id', + views: { + "content@": { + templateUrl: 'app/components/user/user.html', + controller: 'UserController' + }, + "sidebar@": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) + .state('swarm', { + url: '/swarm/', + views: { + "content@": { + templateUrl: 'app/components/swarm/swarm.html', + controller: 'SwarmController' + }, + "sidebar@": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }); + + // The Docker API likes to return plaintext errors, this catches them and disp + $httpProvider.interceptors.push(function() { + return { + 'response': function(response) { + if (typeof(response.data) === 'string' && + (_.startsWith(response.data, 'Conflict.') || _.startsWith(response.data, 'conflict:'))) { + $.gritter.add({ + title: 'Error', + text: $('
').text(response.data).html(), + time: 10000 + }); + } + return response; + } + }; + }); + }]) + .run(['$rootScope', '$state', 'Authentication', 'authManager', 'StateManager', 'EndpointProvider', 'Messages', 'Analytics', function ($rootScope, $state, Authentication, authManager, StateManager, EndpointProvider, Messages, Analytics) { + EndpointProvider.initialize(); + StateManager.initialize().then(function success(state) { + if (state.application.authentication) { + authManager.checkAuthOnRefresh(); + authManager.redirectWhenUnauthenticated(); + Authentication.init(); + $rootScope.$on('tokenHasExpired', function($state) { + $state.go('auth', {error: 'Your session has expired'}); + }); + } + if (state.application.analytics) { + Analytics.offline(false); + Analytics.registerScriptTags(); + Analytics.registerTrackers(); + $rootScope.$on('$stateChangeSuccess', function (event, toState, toParams, fromState, fromParams) { + Analytics.trackPage(toState.url); + Analytics.pageView(); + }); + } + }, function error(err) { + Messages.error("Failure", err, 'Unable to retrieve application settings'); + }); + + $rootScope.$state = $state; + }]) + // 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/settings') .constant('AUTH_ENDPOINT', 'api/auth') .constant('USERS_ENDPOINT', 'api/users') .constant('ENDPOINTS_ENDPOINT', 'api/endpoints') .constant('TEMPLATES_ENDPOINT', 'api/templates') .constant('PAGINATION_MAX_ITEMS', 10) - .constant('UI_VERSION', 'v1.11.4'); + .constant('UI_VERSION', 'v1.12.0'); diff --git a/app/components/auth/authController.js b/app/components/auth/authController.js index b46e8537a..7d5026039 100644 --- a/app/components/auth/authController.js +++ b/app/components/auth/authController.js @@ -1,6 +1,6 @@ angular.module('auth', []) -.controller('AuthenticationController', ['$scope', '$state', '$stateParams', '$window', '$timeout', '$sanitize', 'Config', 'Authentication', 'Users', 'EndpointService', 'StateManager', 'Messages', -function ($scope, $state, $stateParams, $window, $timeout, $sanitize, Config, Authentication, Users, EndpointService, StateManager, Messages) { +.controller('AuthenticationController', ['$scope', '$state', '$stateParams', '$window', '$timeout', '$sanitize', 'Config', 'Authentication', 'Users', 'EndpointService', 'StateManager', 'EndpointProvider', 'Messages', +function ($scope, $state, $stateParams, $window, $timeout, $sanitize, Config, Authentication, Users, EndpointService, StateManager, EndpointProvider, Messages) { $scope.authData = { username: 'admin', @@ -14,18 +14,34 @@ function ($scope, $state, $stateParams, $window, $timeout, $sanitize, Config, Au }; if (!$scope.applicationState.application.authentication) { - EndpointService.getActive().then(function success(data) { - StateManager.updateEndpointState(true) - .then(function success() { - $state.go('dashboard'); - }, function error(err) { - Messages.error("Failure", err, 'Unable to connect to the Docker endpoint'); - }); - }, function error(err) { - if (err.status === 404) { + EndpointService.endpoints() + .then(function success(data) { + if (data.length > 0) { + endpointID = EndpointProvider.endpointID(); + if (!endpointID) { + endpointID = data[0].Id; + EndpointProvider.setEndpointID(endpointID); + } + StateManager.updateEndpointState(true) + .then(function success() { + $state.go('dashboard'); + }, function error(err) { + Messages.error("Failure", err, 'Unable to connect to the Docker endpoint'); + }); + } + else { $state.go('endpointInit'); + } + }, function error(err) { + Messages.error("Failure", err, 'Unable to retrieve endpoints'); + }); + } else { + Users.checkAdminUser({}, function () {}, + function (e) { + if (e.status === 404) { + $scope.initPassword = true; } else { - Messages.error("Failure", err, 'Unable to verify Docker endpoint existence'); + Messages.error("Failure", e, 'Unable to verify administrator account existence'); } }); } @@ -47,15 +63,6 @@ function ($scope, $state, $stateParams, $window, $timeout, $sanitize, Config, Au $scope.logo = c.logo; }); - Users.checkAdminUser({}, function (d) {}, - function (e) { - if (e.status === 404) { - $scope.initPassword = true; - } else { - Messages.error("Failure", e, 'Unable to verify administrator account existence'); - } - }); - $scope.createAdminUser = function() { var password = $sanitize($scope.initPasswordData.password); Users.initAdminUser({password: password}, function (d) { @@ -75,23 +82,33 @@ function ($scope, $state, $stateParams, $window, $timeout, $sanitize, Config, Au $scope.authenticationError = false; var username = $sanitize($scope.authData.username); var password = $sanitize($scope.authData.password); - Authentication.login(username, password).then(function success() { - EndpointService.getActive().then(function success(data) { + Authentication.login(username, password) + .then(function success(data) { + return EndpointService.endpoints(); + }) + .then(function success(data) { + var userDetails = Authentication.getUserDetails(); + if (data.length > 0) { + endpointID = EndpointProvider.endpointID(); + if (!endpointID) { + endpointID = data[0].Id; + EndpointProvider.setEndpointID(endpointID); + } StateManager.updateEndpointState(true) .then(function success() { $state.go('dashboard'); }, function error(err) { Messages.error("Failure", err, 'Unable to connect to the Docker endpoint'); }); - }, function error(err) { - if (err.status === 404) { - $state.go('endpointInit'); - } else { - Messages.error("Failure", err, 'Unable to verify Docker endpoint existence'); - } - }); - }, function error() { - $scope.authData.error = 'Invalid credentials'; + } + else if (data.length === 0 && userDetails.role === 1) { + $state.go('endpointInit'); + } else if (data.length === 0 && userDetails.role === 2) { + $scope.authData.error = 'User not allowed. Please contact your administrator.'; + } + }) + .catch(function error(err) { + $scope.authData.error = 'Authentication error'; }); }; }]); diff --git a/app/components/containerConsole/containerConsoleController.js b/app/components/containerConsole/containerConsoleController.js index d81bff349..4ef8cc3d6 100644 --- a/app/components/containerConsole/containerConsoleController.js +++ b/app/components/containerConsole/containerConsoleController.js @@ -1,6 +1,6 @@ angular.module('containerConsole', []) -.controller('ContainerConsoleController', ['$scope', '$stateParams', 'Settings', 'Container', 'Image', 'Exec', '$timeout', 'Messages', -function ($scope, $stateParams, Settings, Container, Image, Exec, $timeout, Messages) { +.controller('ContainerConsoleController', ['$scope', '$stateParams', 'Settings', 'Container', 'Image', 'Exec', '$timeout', 'EndpointProvider', 'Messages', +function ($scope, $stateParams, Settings, Container, Image, Exec, $timeout, EndpointProvider, Messages) { $scope.state = {}; $scope.state.loaded = false; $scope.state.connected = false; @@ -55,7 +55,7 @@ function ($scope, $stateParams, Settings, Container, Image, Exec, $timeout, Mess } else { var execId = d.Id; resizeTTY(execId, termHeight, termWidth); - var url = window.location.href.split('#')[0] + 'api/websocket/exec?id=' + execId; + var url = window.location.href.split('#')[0] + 'api/websocket/exec?id=' + execId + '&endpointId=' + EndpointProvider.endpointID(); if (url.indexOf('https') > -1) { url = url.replace('https://', 'wss://'); } else { diff --git a/app/components/containers/containers.html b/app/components/containers/containers.html index 1da516551..39a754168 100644 --- a/app/components/containers/containers.html +++ b/app/components/containers/containers.html @@ -90,6 +90,13 @@ + + + Ownership + + + + @@ -98,7 +105,7 @@ {{ container.Status }} {{ container|swarmcontainername}} {{ container|containername}} - {{ container.Image }} + {{ container.Image | hideshasum }} {{ container.IP ? container.IP : '-' }} {{ container.hostIP }} @@ -107,12 +114,43 @@ - + + + + + Public service + + + Public + + + + + + Private service + + + Private + Switch to public + + + + + + Private service (owner: {{ container.Owner }}) + + + Private (owner: {{ container.Owner }}) + Switch to public + + + - Loading... + Loading... - No containers available. + No containers available. diff --git a/app/components/containers/containersController.js b/app/components/containers/containersController.js index 59f8caa6b..eea0140e0 100644 --- a/app/components/containers/containersController.js +++ b/app/components/containers/containersController.js @@ -1,6 +1,6 @@ angular.module('containers', []) -.controller('ContainersController', ['$scope', '$filter', 'Container', 'ContainerHelper', 'Info', 'Settings', 'Messages', 'Config', 'Pagination', -function ($scope, $filter, Container, ContainerHelper, Info, Settings, Messages, Config, Pagination) { + .controller('ContainersController', ['$q', '$scope', '$filter', 'Container', 'ContainerHelper', 'Info', 'Settings', 'Messages', 'Config', 'Pagination', 'EntityListService', 'ModalService', 'Authentication', 'ResourceControlService', 'UserService', + function ($q, $scope, $filter, Container, ContainerHelper, Info, Settings, Messages, Config, Pagination, EntityListService, ModalService, Authentication, ResourceControlService, UserService) { $scope.state = {}; $scope.state.pagination_count = Pagination.getPaginationCount('containers'); $scope.state.displayAll = Settings.displayAll; @@ -17,8 +17,51 @@ function ($scope, $filter, Container, ContainerHelper, Info, Settings, Messages, Pagination.setPaginationCount('containers', $scope.state.pagination_count); }; + function removeContainerResourceControl(container) { + volumeResourceControlQueries = []; + angular.forEach(container.Mounts, function (volume) { + volumeResourceControlQueries.push(ResourceControlService.removeVolumeResourceControl(container.Metadata.ResourceControl.OwnerId, volume.Name)); + }); + + $q.all(volumeResourceControlQueries) + .then(function success() { + return ResourceControlService.removeContainerResourceControl(container.Metadata.ResourceControl.OwnerId, container.Id); + }) + .then(function success() { + delete container.Metadata.ResourceControl; + Messages.send('Ownership changed to public', container.Id); + }) + .catch(function error(err) { + Messages.error("Failure", err, "Unable to change container ownership"); + }); + } + + $scope.switchOwnership = function(container) { + ModalService.confirmContainerOwnershipChange(function (confirmed) { + if(!confirmed) { return; } + removeContainerResourceControl(container); + }); + }; + + function mapUsersToContainers(users) { + angular.forEach($scope.containers, function (container) { + if (container.Metadata) { + var containerRC = container.Metadata.ResourceControl; + if (containerRC && containerRC.OwnerId != $scope.user.ID) { + angular.forEach(users, function (user) { + if (containerRC.OwnerId === user.Id) { + container.Owner = user.Username; + } + }); + } + } + }); + } + var update = function (data) { $('#loadContainersSpinner').show(); + var userDetails = Authentication.getUserDetails(); + $scope.user = userDetails; $scope.state.selectedItemCount = 0; Container.query(data, function (d) { var containers = d; @@ -29,6 +72,10 @@ function ($scope, $filter, Container, ContainerHelper, Info, Settings, Messages, var model = new ContainerViewModel(container); model.Status = $filter('containerstatus')(model.Status); + EntityListService.rememberPreviousSelection($scope.containers, model, function onSelect(model){ + $scope.selectItem(model); + }); + if (model.IP) { $scope.state.displayIP = true; } @@ -37,7 +84,20 @@ function ($scope, $filter, Container, ContainerHelper, Info, Settings, Messages, } return model; }); - $('#loadContainersSpinner').hide(); + if (userDetails.role === 1) { + UserService.users() + .then(function success(data) { + mapUsersToContainers(data); + }) + .catch(function error(err) { + Messages.error("Failure", err, "Unable to retrieve users"); + }) + .finally(function final() { + $('#loadContainersSpinner').hide(); + }); + } else { + $('#loadContainersSpinner').hide(); + } }, function (e) { $('#loadContainersSpinner').hide(); Messages.error("Failure", e, "Unable to retrieve containers"); @@ -73,7 +133,17 @@ function ($scope, $filter, Container, ContainerHelper, Info, Settings, Messages, Messages.send("Error", d.message); } else { - Messages.send("Container " + msg, c.Id); + if (c.Metadata && c.Metadata.ResourceControl) { + ResourceControlService.removeContainerResourceControl(c.Metadata.ResourceControl.OwnerId, c.Id) + .then(function success() { + Messages.send("Container " + msg, c.Id); + }) + .catch(function error(err) { + Messages.error("Failure", err, "Unable to remove container ownership"); + }); + } else { + Messages.send("Container " + msg, c.Id); + } } complete(); }, function (e) { diff --git a/app/components/createContainer/createContainerController.js b/app/components/createContainer/createContainerController.js index 606e84d04..7ca69caa0 100644 --- a/app/components/createContainer/createContainerController.js +++ b/app/components/createContainer/createContainerController.js @@ -1,14 +1,18 @@ +// @@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', ['$scope', '$state', '$stateParams', '$filter', 'Config', 'Info', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'Network', 'Messages', -function ($scope, $state, $stateParams, $filter, Config, Info, Container, ContainerHelper, Image, ImageHelper, Volume, Network, Messages) { +.controller('CreateContainerController', ['$scope', '$state', '$stateParams', '$filter', 'Config', 'Info', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'Network', 'ResourceControlService', 'Authentication', 'Messages', +function ($scope, $state, $stateParams, $filter, Config, Info, Container, ContainerHelper, Image, ImageHelper, Volume, Network, ResourceControlService, Authentication, Messages) { $scope.formValues = { + Ownership: $scope.applicationState.application.authentication ? 'private' : '', alwaysPull: true, Console: 'none', Volumes: [], Registry: '', NetworkContainer: '', - Labels: [] + Labels: [], + ExtraHosts: [] }; $scope.imageConfig = {}; @@ -16,15 +20,18 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai $scope.config = { Image: '', Env: [], + Cmd: '', ExposedPorts: {}, HostConfig: { RestartPolicy: { Name: 'no' }, PortBindings: [], + PublishAllPorts: false, Binds: [], NetworkMode: 'bridge', - Privileged: false + Privileged: false, + ExtraHosts: [] }, Labels: {} }; @@ -61,6 +68,15 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai $scope.formValues.Labels.splice(index, 1); }; + $scope.addExtraHost = function() { + $scope.formValues.ExtraHosts.push({ value: '' }); + }; + + $scope.removeExtraHost = function(index) { + $scope.formValues.ExtraHosts.splice(index, 1); + }; + + Config.$promise.then(function (c) { var containersToHideLabels = c.hiddenLabels; @@ -103,26 +119,40 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai }); }); - // TODO: centralize, already present in templatesController + function startContainer(containerID) { + Container.start({id: containerID}, {}, function (cd) { + if (cd.message) { + $('#createContainerSpinner').hide(); + Messages.error('Error', {}, cd.message); + } else { + $('#createContainerSpinner').hide(); + Messages.send('Container Started', containerID); + $state.go('containers', {}, {reload: true}); + } + }, function (e) { + $('#createContainerSpinner').hide(); + Messages.error("Failure", e, 'Unable to start container'); + }); + } + function createContainer(config) { Container.create(config, function (d) { if (d.message) { $('#createContainerSpinner').hide(); Messages.error('Error', {}, d.message); } else { - Container.start({id: d.Id}, {}, function (cd) { - if (cd.message) { + if ($scope.formValues.Ownership === 'private') { + ResourceControlService.setContainerResourceControl(Authentication.getUserDetails().ID, d.Id) + .then(function success() { + startContainer(d.Id); + }) + .catch(function error(err) { $('#createContainerSpinner').hide(); - Messages.error('Error', {}, cd.message); - } else { - $('#createContainerSpinner').hide(); - Messages.send('Container Started', d.Id); - $state.go('containers', {}, {reload: true}); - } - }, function (e) { - $('#createContainerSpinner').hide(); - Messages.error("Failure", e, 'Unable to start container'); - }); + Messages.error("Failure", err, 'Unable to apply resource control on container'); + }); + } else { + startContainer(d.Id); + } } }, function (e) { $('#createContainerSpinner').hide(); @@ -130,7 +160,6 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai }); } - // TODO: centralize, already present in templatesController function pullImageAndCreateContainer(config) { Image.create($scope.imageConfig, function (data) { createContainer(config); @@ -229,6 +258,12 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai networkMode += ':' + containerName; } config.HostConfig.NetworkMode = networkMode; + + $scope.formValues.ExtraHosts.forEach(function (v) { + if (v.value) { + config.HostConfig.ExtraHosts.push(v.value); + } + }); } function prepareLabels(config) { @@ -243,6 +278,7 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai function prepareConfiguration() { var config = angular.copy($scope.config); + config.Cmd = ContainerHelper.commandStringToArray(config.Cmd); prepareNetworkConfig(config); prepareImageConfig(config); preparePortBindings(config); diff --git a/app/components/createContainer/createcontainer.html b/app/components/createContainer/createcontainer.html index 9a27c741a..8302d29a5 100644 --- a/app/components/createContainer/createcontainer.html +++ b/app/components/createContainer/createcontainer.html @@ -64,6 +64,11 @@
+
+ +
map port @@ -95,6 +100,26 @@
+ +
+
+ +
+ + +
+
+
+ @@ -304,6 +329,31 @@
+ +
+ +
+ + extra host + +
+ +
+
+
+ value + + + + +
+
+
+ +
+
diff --git a/app/components/createService/createServiceController.js b/app/components/createService/createServiceController.js index 8497463ab..ed3d3dce5 100644 --- a/app/components/createService/createServiceController.js +++ b/app/components/createService/createServiceController.js @@ -1,8 +1,11 @@ +// @@OLD_SERVICE_CONTROLLER: this service should be rewritten to use services. +// See app/components/templates/templatesController.js as a reference. angular.module('createService', []) -.controller('CreateServiceController', ['$scope', '$state', 'Service', 'Volume', 'Network', 'ImageHelper', 'Messages', -function ($scope, $state, Service, Volume, Network, ImageHelper, Messages) { +.controller('CreateServiceController', ['$scope', '$state', 'Service', 'Volume', 'Network', 'ImageHelper', 'Authentication', 'ResourceControlService', 'Messages', +function ($scope, $state, Service, Volume, Network, ImageHelper, Authentication, ResourceControlService, Messages) { $scope.formValues = { + Ownership: $scope.applicationState.application.authentication ? 'private' : '', Name: '', Image: '', Registry: '', @@ -205,9 +208,22 @@ function ($scope, $state, Service, Volume, Network, ImageHelper, Messages) { function createNewService(config) { Service.create(config, function (d) { - $('#createServiceSpinner').hide(); - Messages.send('Service created', d.ID); - $state.go('services', {}, {reload: true}); + if ($scope.formValues.Ownership === 'private') { + ResourceControlService.setServiceResourceControl(Authentication.getUserDetails().ID, d.ID) + .then(function success() { + $('#createServiceSpinner').hide(); + Messages.send('Service created', d.ID); + $state.go('services', {}, {reload: true}); + }) + .catch(function error(err) { + $('#createContainerSpinner').hide(); + Messages.error("Failure", err, 'Unable to apply resource control on service'); + }); + } else { + $('#createServiceSpinner').hide(); + Messages.send('Service created', d.ID); + $state.go('services', {}, {reload: true}); + } }, function (e) { $('#createServiceSpinner').hide(); Messages.error("Failure", e, 'Unable to create service'); diff --git a/app/components/createService/createservice.html b/app/components/createService/createservice.html index c38c606e4..87853badb 100644 --- a/app/components/createService/createservice.html +++ b/app/components/createService/createservice.html @@ -87,6 +87,26 @@ + +
+
+ +
+ + +
+
+
+ diff --git a/app/components/createVolume/createVolumeController.js b/app/components/createVolume/createVolumeController.js index def0b5dcc..61874d1a7 100644 --- a/app/components/createVolume/createVolumeController.js +++ b/app/components/createVolume/createVolumeController.js @@ -1,8 +1,9 @@ angular.module('createVolume', []) -.controller('CreateVolumeController', ['$scope', '$state', 'Volume', 'Messages', -function ($scope, $state, Volume, Messages) { +.controller('CreateVolumeController', ['$scope', '$state', 'Volume', 'ResourceControlService', 'Authentication', 'Messages', +function ($scope, $state, Volume, ResourceControlService, Authentication, Messages) { $scope.formValues = { + Ownership: $scope.applicationState.application.authentication ? 'private' : '', DriverOptions: [] }; @@ -25,9 +26,22 @@ function ($scope, $state, Volume, Messages) { $('#createVolumeSpinner').hide(); Messages.error('Unable to create volume', {}, d.message); } else { - Messages.send("Volume created", d.Name); - $('#createVolumeSpinner').hide(); - $state.go('volumes', {}, {reload: true}); + if ($scope.formValues.Ownership === 'private') { + ResourceControlService.setVolumeResourceControl(Authentication.getUserDetails().ID, d.Name) + .then(function success() { + Messages.send("Volume created", d.Name); + $('#createVolumeSpinner').hide(); + $state.go('volumes', {}, {reload: true}); + }) + .catch(function error(err) { + $('#createVolumeSpinner').hide(); + Messages.error("Failure", err, 'Unable to apply resource control on volume'); + }); + } else { + Messages.send("Volume created", d.Name); + $('#createVolumeSpinner').hide(); + $state.go('volumes', {}, {reload: true}); + } } }, function (e) { $('#createVolumeSpinner').hide(); diff --git a/app/components/createVolume/createvolume.html b/app/components/createVolume/createvolume.html index 5213f4dd6..e55d30a4b 100644 --- a/app/components/createVolume/createvolume.html +++ b/app/components/createVolume/createvolume.html @@ -55,6 +55,26 @@ + +
+
+ +
+ + +
+
+
+ diff --git a/app/components/endpoint/endpointController.js b/app/components/endpoint/endpointController.js index e6e4eb526..b221a4f14 100644 --- a/app/components/endpoint/endpointController.js +++ b/app/components/endpoint/endpointController.js @@ -18,14 +18,18 @@ function ($scope, $state, $stateParams, $filter, EndpointService, Messages) { $scope.updateEndpoint = function() { var ID = $scope.endpoint.Id; - var name = $scope.endpoint.Name; - var URL = $scope.endpoint.URL; - var TLS = $scope.endpoint.TLS; - var TLSCACert = $scope.formValues.TLSCACert !== $scope.endpoint.TLSCACert ? $scope.formValues.TLSCACert : null; - var TLSCert = $scope.formValues.TLSCert !== $scope.endpoint.TLSCert ? $scope.formValues.TLSCert : null; - var TLSKey = $scope.formValues.TLSKey !== $scope.endpoint.TLSKey ? $scope.formValues.TLSKey : null; - var type = $scope.endpointType; - EndpointService.updateEndpoint(ID, name, URL, TLS, TLSCACert, TLSCert, TLSKey, type).then(function success(data) { + var endpointParams = { + name: $scope.endpoint.Name, + URL: $scope.endpoint.URL, + TLS: $scope.endpoint.TLS, + TLSCACert: $scope.formValues.TLSCACert !== $scope.endpoint.TLSCACert ? $scope.formValues.TLSCACert : null, + TLSCert: $scope.formValues.TLSCert !== $scope.endpoint.TLSCert ? $scope.formValues.TLSCert : null, + TLSKey: $scope.formValues.TLSKey !== $scope.endpoint.TLSKey ? $scope.formValues.TLSKey : null, + type: $scope.endpointType + }; + + EndpointService.updateEndpoint(ID, endpointParams) + .then(function success(data) { Messages.send("Endpoint updated", $scope.endpoint.Name); $state.go('endpoints'); }, function error(err) { diff --git a/app/components/endpointAccess/endpointAccess.html b/app/components/endpointAccess/endpointAccess.html new file mode 100644 index 000000000..042280431 --- /dev/null +++ b/app/components/endpointAccess/endpointAccess.html @@ -0,0 +1,177 @@ + + + + + + Endpoints > {{ endpoint.Name }} > Access management + + + +
+
+ + + + + + + + + + + + + + + + + +
Name + {{ endpoint.Name }} +
URL + {{ endpoint.URL | stripprotocol }} +
+ + You can select which user can access this endpoint by moving them to the authorized users table. Simply click + on a user entry to move it from one table to the other. + +
+
+
+
+
+ +
+
+ + +
+ Items per page: + +
+
+ +
+ +
+
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + +
+ + Name + + + + + + Role + + + +
{{ user.Username }} + {{ user.RoleName }} + +
Loading...
No users.
+
+ +
+
+
+
+
+
+ + +
+ Items per page: + +
+
+ +
+ +
+
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + +
+ + Name + + + + + + Role + + + +
{{ user.Username }} + {{ user.RoleName }} + +
Loading...
No authorized users.
+
+ +
+
+
+
+
+
diff --git a/app/components/endpointAccess/endpointAccessController.js b/app/components/endpointAccess/endpointAccessController.js new file mode 100644 index 000000000..44ebb9c2c --- /dev/null +++ b/app/components/endpointAccess/endpointAccessController.js @@ -0,0 +1,148 @@ +angular.module('endpointAccess', []) +.controller('EndpointAccessController', ['$q', '$scope', '$state', '$stateParams', '$filter', 'EndpointService', 'UserService', 'Pagination', 'Messages', +function ($q, $scope, $state, $stateParams, $filter, EndpointService, UserService, Pagination, Messages) { + + $scope.state = { + pagination_count_users: Pagination.getPaginationCount('endpoint_access_users'), + pagination_count_authorizedUsers: Pagination.getPaginationCount('endpoint_access_authorizedUsers') + }; + + $scope.sortTypeUsers = 'Username'; + $scope.sortReverseUsers = true; + + $scope.orderUsers = function(sortType) { + $scope.sortReverseUsers = ($scope.sortTypeUsers === sortType) ? !$scope.sortReverseUsers : false; + $scope.sortTypeUsers = sortType; + }; + + $scope.changePaginationCountUsers = function() { + Pagination.setPaginationCount('endpoint_access_users', $scope.state.pagination_count_users); + }; + + $scope.sortTypeAuthorizedUsers = 'Username'; + $scope.sortReverseAuthorizedUsers = true; + + $scope.orderAuthorizedUsers = function(sortType) { + $scope.sortReverseAuthorizedUsers = ($scope.sortTypeAuthorizedUsers === sortType) ? !$scope.sortReverseAuthorizedUsers : false; + $scope.sortTypeAuthorizedUsers = sortType; + }; + + $scope.changePaginationCountAuthorizedUsers = function() { + Pagination.setPaginationCount('endpoint_access_authorizedUsers', $scope.state.pagination_count_authorizedUsers); + }; + + $scope.authorizeAllUsers = function() { + var authorizedUserIDs = []; + angular.forEach($scope.authorizedUsers, function (user) { + authorizedUserIDs.push(user.Id); + }); + angular.forEach($scope.users, function (user) { + authorizedUserIDs.push(user.Id); + }); + EndpointService.updateAuthorizedUsers($stateParams.id, authorizedUserIDs) + .then(function success(data) { + $scope.authorizedUsers = $scope.authorizedUsers.concat($scope.users); + $scope.users = []; + Messages.send('Access granted for all users'); + }) + .catch(function error(err) { + Messages.error("Failure", err, "Unable to update endpoint permissions"); + }); + }; + + $scope.unauthorizeAllUsers = function() { + EndpointService.updateAuthorizedUsers($stateParams.id, []) + .then(function success(data) { + $scope.users = $scope.users.concat($scope.authorizedUsers); + $scope.authorizedUsers = []; + Messages.send('Access removed for all users'); + }) + .catch(function error(err) { + Messages.error("Failure", err, "Unable to update endpoint permissions"); + }); + }; + + $scope.authorizeUser = function(user) { + var authorizedUserIDs = []; + angular.forEach($scope.authorizedUsers, function (u) { + authorizedUserIDs.push(u.Id); + }); + authorizedUserIDs.push(user.Id); + EndpointService.updateAuthorizedUsers($stateParams.id, authorizedUserIDs) + .then(function success(data) { + removeUserFromArray(user.Id, $scope.users); + $scope.authorizedUsers.push(user); + Messages.send('Access granted for user', user.Username); + }) + .catch(function error(err) { + Messages.error("Failure", err, "Unable to update endpoint permissions"); + }); + }; + + $scope.unauthorizeUser = function(user) { + var authorizedUserIDs = $scope.authorizedUsers.filter(function (u) { + if (u.Id !== user.Id) { + return u; + } + }).map(function (u) { + return u.Id; + }); + EndpointService.updateAuthorizedUsers($stateParams.id, authorizedUserIDs) + .then(function success(data) { + removeUserFromArray(user.Id, $scope.authorizedUsers); + $scope.users.push(user); + Messages.send('Access removed for user', user.Username); + }) + .catch(function error(err) { + Messages.error("Failure", err, "Unable to update endpoint permissions"); + }); + }; + + function getEndpointAndUsers(endpointID) { + $('#loadingViewSpinner').show(); + $q.all({ + endpoint: EndpointService.endpoint($stateParams.id), + users: UserService.users(), + }) + .then(function success(data) { + $scope.endpoint = data.endpoint; + $scope.users = data.users.filter(function (user) { + if (user.Role !== 1) { + return user; + } + }).map(function (user) { + return new UserViewModel(user); + }); + $scope.authorizedUsers = []; + angular.forEach($scope.endpoint.AuthorizedUsers, function(userID) { + for (var i = 0, l = $scope.users.length; i < l; i++) { + if ($scope.users[i].Id === userID) { + $scope.authorizedUsers.push($scope.users[i]); + $scope.users.splice(i, 1); + return; + } + } + }); + }) + .catch(function error(err) { + $scope.templates = []; + $scope.users = []; + $scope.authorizedUsers = []; + Messages.error("Failure", err, "Unable to retrieve endpoint details"); + }) + .finally(function final(){ + $('#loadingViewSpinner').hide(); + }); + } + + function removeUserFromArray(id, users) { + for (var i = 0, l = users.length; i < l; i++) { + if (users[i].Id === id) { + users.splice(i, 1); + return; + } + } + } + + getEndpointAndUsers($stateParams.id); +}]); diff --git a/app/components/endpointInit/endpointInit.html b/app/components/endpointInit/endpointInit.html index 5993ec833..19c72a009 100644 --- a/app/components/endpointInit/endpointInit.html +++ b/app/components/endpointInit/endpointInit.html @@ -32,8 +32,8 @@
- This feature is not available with Docker on Windows yet. -
On Linux / Mac, ensure that you have started Portainer container with the following Docker flag -v "/var/run/docker.sock:/var/run/docker.sock"
+ This feature is not yet available for native Docker Windows containers. +
On Linux and when using Docker for Mac or Docker for Windows or Docker Toolbox, ensure that you have started Portainer container with the following Docker flag -v "/var/run/docker.sock:/var/run/docker.sock"
diff --git a/app/components/endpointInit/endpointInitController.js b/app/components/endpointInit/endpointInitController.js index b6aa99cdd..911f6c994 100644 --- a/app/components/endpointInit/endpointInitController.js +++ b/app/components/endpointInit/endpointInitController.js @@ -1,6 +1,6 @@ angular.module('endpointInit', []) -.controller('EndpointInitController', ['$scope', '$state', 'EndpointService', 'StateManager', 'Messages', -function ($scope, $state, EndpointService, StateManager, Messages) { +.controller('EndpointInitController', ['$scope', '$state', 'EndpointService', 'StateManager', 'EndpointProvider', 'Messages', +function ($scope, $state, EndpointService, StateManager, EndpointProvider, Messages) { $scope.state = { error: '', uploadInProgress: false @@ -29,20 +29,28 @@ function ($scope, $state, EndpointService, StateManager, Messages) { var name = "local"; var URL = "unix:///var/run/docker.sock"; var TLS = false; - EndpointService.createLocalEndpoint(name, URL, TLS, true).then(function success(data) { - StateManager.updateEndpointState(false) - .then(function success() { + + EndpointService.createLocalEndpoint(name, URL, TLS, true) + .then( + function success(data) { + var endpointID = data.Id; + EndpointProvider.setEndpointID(endpointID); + StateManager.updateEndpointState(false).then( + function success() { $state.go('dashboard'); - }, function error(err) { - EndpointService.deleteEndpoint(0) + }, + function error(err) { + EndpointService.deleteEndpoint(endpointID) .then(function success() { - $('#initEndpointSpinner').hide(); $scope.state.error = 'Unable to connect to the Docker endpoint'; }); }); - }, function error(err) { - $('#initEndpointSpinner').hide(); + }, + function error() { $scope.state.error = 'Unable to create endpoint'; + }) + .finally(function final() { + $('#initEndpointSpinner').hide(); }); }; @@ -57,11 +65,13 @@ function ($scope, $state, EndpointService, StateManager, Messages) { var TLSKeyFile = $scope.formValues.TLSKey; EndpointService.createRemoteEndpoint(name, URL, TLS, TLSCAFile, TLSCertFile, TLSKeyFile, TLS ? false : true) .then(function success(data) { + var endpointID = data.Id; + EndpointProvider.setEndpointID(endpointID); StateManager.updateEndpointState(false) .then(function success() { $state.go('dashboard'); }, function error(err) { - EndpointService.deleteEndpoint(0) + EndpointService.deleteEndpoint(endpointID) .then(function success() { $('#initEndpointSpinner').hide(); $scope.state.error = 'Unable to connect to the Docker endpoint'; diff --git a/app/components/endpoints/endpoints.html b/app/components/endpoints/endpoints.html index 41501eaa5..cbce6c15a 100644 --- a/app/components/endpoints/endpoints.html +++ b/app/components/endpoints/endpoints.html @@ -14,7 +14,7 @@ - Portainer has been started using the --external-endpoints flag. Endpoint management via the UI is disabled. + Portainer has been started using the --external-endpoints flag. Endpoint management via the UI is disabled. You can still manage endpoint access.
@@ -137,7 +137,9 @@ - + - - + @@ -167,13 +162,17 @@ - - diff --git a/app/components/endpoints/endpointsController.js b/app/components/endpoints/endpointsController.js index 879ecbc1e..ba50144d4 100644 --- a/app/components/endpoints/endpointsController.js +++ b/app/components/endpoints/endpointsController.js @@ -1,6 +1,6 @@ angular.module('endpoints', []) -.controller('EndpointsController', ['$scope', '$state', 'EndpointService', 'Messages', 'Pagination', -function ($scope, $state, EndpointService, Messages, Pagination) { +.controller('EndpointsController', ['$scope', '$state', 'EndpointService', 'EndpointProvider', 'Messages', 'Pagination', +function ($scope, $state, EndpointService, EndpointProvider, Messages, Pagination) { $scope.state = { error: '', uploadInProgress: false, @@ -28,6 +28,15 @@ function ($scope, $state, EndpointService, Messages, Pagination) { Pagination.setPaginationCount('endpoints', $scope.state.pagination_count); }; + $scope.selectItems = function (allSelected) { + angular.forEach($scope.state.filteredEndpoints, function (endpoint) { + if (endpoint.Checked !== allSelected) { + endpoint.Checked = allSelected; + $scope.selectItem(endpoint); + } + }); + }; + $scope.selectItem = function (item) { if (item.Checked) { $scope.state.selectedItemCount++; @@ -84,19 +93,17 @@ function ($scope, $state, EndpointService, Messages, Pagination) { function fetchEndpoints() { $('#loadEndpointsSpinner').show(); - EndpointService.endpoints().then(function success(data) { + EndpointService.endpoints() + .then(function success(data) { $scope.endpoints = data; - EndpointService.getActive().then(function success(data) { - $scope.activeEndpoint = data; - $('#loadEndpointsSpinner').hide(); - }, function error(err) { - $('#loadEndpointsSpinner').hide(); - Messages.error("Failure", err, "Unable to retrieve active endpoint"); - }); - }, function error(err) { - $('#loadEndpointsSpinner').hide(); + $scope.activeEndpointID = EndpointProvider.endpointID(); + }) + .catch(function error(err) { Messages.error("Failure", err, "Unable to retrieve endpoints"); $scope.endpoints = []; + }) + .finally(function final() { + $('#loadEndpointsSpinner').hide(); }); } diff --git a/app/components/images/images.html b/app/components/images/images.html index 972b0602b..5d494bb77 100644 --- a/app/components/images/images.html +++ b/app/components/images/images.html @@ -63,7 +63,16 @@
- +
+ + + +
diff --git a/app/components/images/imagesController.js b/app/components/images/imagesController.js index 7000cddaa..44752330e 100644 --- a/app/components/images/imagesController.js +++ b/app/components/images/imagesController.js @@ -1,6 +1,6 @@ angular.module('images', []) -.controller('ImagesController', ['$scope', '$state', 'Config', 'Image', 'ImageHelper', 'Messages', 'Pagination', -function ($scope, $state, Config, Image, ImageHelper, Messages, Pagination) { +.controller('ImagesController', ['$scope', '$state', 'Config', 'Image', 'ImageHelper', 'Messages', 'Pagination', 'ModalService', +function ($scope, $state, Config, Image, ImageHelper, Messages, Pagination, ModalService) { $scope.state = {}; $scope.state.pagination_count = Pagination.getPaginationCount('images'); $scope.sortType = 'RepoTags'; @@ -59,7 +59,15 @@ function ($scope, $state, Config, Image, ImageHelper, Messages, Pagination) { }); }; - $scope.removeAction = function () { + $scope.confirmRemovalAction = function (force) { + ModalService.confirmImageForceRemoval(function (confirmed) { + if(!confirmed) { return; } + $scope.removeAction(force); + }); + }; + + $scope.removeAction = function (force) { + force = !!force; $('#loadImagesSpinner').show(); var counter = 0; var complete = function () { @@ -71,7 +79,7 @@ function ($scope, $state, Config, Image, ImageHelper, Messages, Pagination) { angular.forEach($scope.images, function (i) { if (i.Checked) { counter = counter + 1; - Image.remove({id: i.Id}, function (d) { + Image.remove({id: i.Id, force: force}, function (d) { if (d[0].message) { $('#loadImagesSpinner').hide(); Messages.error("Unable to remove image", {}, d[0].message); diff --git a/app/components/services/services.html b/app/components/services/services.html index 0e958cecc..6eb427ac6 100644 --- a/app/components/services/services.html +++ b/app/components/services/services.html @@ -58,16 +58,25 @@ +
- + + - + - +
+ + Name @@ -152,14 +154,7 @@ - - TLS - - - -
{{ endpoint.Name }} {{ endpoint.URL | stripprotocol }} - - Edit + + + + Edit + + + You cannot edit the active endpoint + - - You cannot edit the active endpoint + + Manage access
+ + Ownership + + + +
{{ service.Name }}{{ service.Image }}{{ service.Image | hideshasum }} {{ service.Mode }} + {{ service.Running }} + / + {{ service.Replicas }} - {{ service.Replicas }} Scale @@ -76,12 +85,33 @@ + + + + Private + + + Private (owner: {{ service.Owner }}) + + Switch to public + + + + Private + Switch to public + + + + Public + +
Loading...Loading...
No services available.No services available.
diff --git a/app/components/services/servicesController.js b/app/components/services/servicesController.js index 1fb5c1dbb..13f63fff6 100644 --- a/app/components/services/servicesController.js +++ b/app/components/services/servicesController.js @@ -1,12 +1,40 @@ angular.module('services', []) -.controller('ServicesController', ['$scope', '$stateParams', '$state', 'Service', 'ServiceHelper', 'Messages', 'Pagination', -function ($scope, $stateParams, $state, Service, ServiceHelper, Messages, Pagination) { +.controller('ServicesController', ['$q', '$scope', '$stateParams', '$state', 'Service', 'ServiceHelper', 'Messages', 'Pagination', 'Task', 'Node', 'Authentication', 'UserService', 'ModalService', 'ResourceControlService', +function ($q, $scope, $stateParams, $state, Service, ServiceHelper, Messages, Pagination, Task, Node, Authentication, UserService, ModalService, ResourceControlService) { $scope.state = {}; $scope.state.selectedItemCount = 0; $scope.state.pagination_count = Pagination.getPaginationCount('services'); $scope.sortType = 'Name'; $scope.sortReverse = false; + function removeServiceResourceControl(service) { + volumeResourceControlQueries = []; + angular.forEach(service.Mounts, function (mount) { + if (mount.Type === 'volume') { + volumeResourceControlQueries.push(ResourceControlService.removeVolumeResourceControl(service.Metadata.ResourceControl.OwnerId, mount.Source)); + } + }); + + $q.all(volumeResourceControlQueries) + .then(function success() { + return ResourceControlService.removeServiceResourceControl(service.Metadata.ResourceControl.OwnerId, service.Id); + }) + .then(function success() { + delete service.Metadata.ResourceControl; + Messages.send('Ownership changed to public', service.Id); + }) + .catch(function error(err) { + Messages.error("Failure", err, "Unable to change service ownership"); + }); + } + + $scope.switchOwnership = function(volume) { + ModalService.confirmServiceOwnershipChange(function (confirmed) { + if(!confirmed) { return; } + removeServiceResourceControl(volume); + }); + }; + $scope.changePaginationCount = function() { Pagination.setPaginationCount('services', $scope.state.pagination_count); }; @@ -57,9 +85,21 @@ function ($scope, $stateParams, $state, Service, ServiceHelper, Messages, Pagina $('#loadServicesSpinner').hide(); Messages.error("Unable to remove service", {}, d[0].message); } else { - Messages.send("Service deleted", service.Id); - var index = $scope.services.indexOf(service); - $scope.services.splice(index, 1); + if (service.Metadata && service.Metadata.ResourceControl) { + ResourceControlService.removeServiceResourceControl(service.Metadata.ResourceControl.OwnerId, service.Id) + .then(function success() { + Messages.send("Service deleted", service.Id); + var index = $scope.services.indexOf(service); + $scope.services.splice(index, 1); + }) + .catch(function error(err) { + Messages.error("Failure", err, "Unable to remove service ownership"); + }); + } else { + Messages.send("Service deleted", service.Id); + var index = $scope.services.indexOf(service); + $scope.services.splice(index, 1); + } } complete(); }, function (e) { @@ -70,17 +110,58 @@ function ($scope, $stateParams, $state, Service, ServiceHelper, Messages, Pagina }); }; + function mapUsersToServices(users) { + angular.forEach($scope.services, function (service) { + if (service.Metadata) { + var serviceRC = service.Metadata.ResourceControl; + if (serviceRC && serviceRC.OwnerId != $scope.user.ID) { + angular.forEach(users, function (user) { + if (serviceRC.OwnerId === user.Id) { + service.Owner = user.Username; + } + }); + } + } + }); + } + function fetchServices() { $('#loadServicesSpinner').show(); - Service.query({}, function (d) { - $scope.services = d.map(function (service) { - return new ServiceViewModel(service); + + var userDetails = Authentication.getUserDetails(); + $scope.user = userDetails; + + $q.all({ + services: Service.query({}).$promise, + tasks: Task.query({filters: {'desired-state': ['running']}}).$promise, + nodes: Node.query({}).$promise, + }) + .then(function success(data) { + $scope.services = data.services.map(function (service) { + var serviceTasks = data.tasks.filter(function (task) { + return task.ServiceID === service.ID; + }); + var taskNodes = data.nodes.filter(function (node) { + return node.Spec.Availability === 'active' && node.Status.State === 'ready'; + }); + return new ServiceViewModel(service, serviceTasks, taskNodes); }); - $('#loadServicesSpinner').hide(); - }, function(e) { - $('#loadServicesSpinner').hide(); - Messages.error("Failure", e, "Unable to retrieve services"); + if (userDetails.role === 1) { + UserService.users() + .then(function success(data) { + mapUsersToServices(data); + }) + .finally(function final() { + $('#loadServicesSpinner').hide(); + }); + } + }) + .catch(function error(err) { $scope.services = []; + Messages.error("Failure", err, "Unable to retrieve services"); + }) + .finally(function final() { + $('#loadServicesSpinner').hide(); }); } diff --git a/app/components/settings/settingsController.js b/app/components/settings/settingsController.js index d79dcc2e0..eb20ec9bb 100644 --- a/app/components/settings/settingsController.js +++ b/app/components/settings/settingsController.js @@ -1,6 +1,6 @@ angular.module('settings', []) -.controller('SettingsController', ['$scope', '$state', '$sanitize', 'Users', 'Messages', -function ($scope, $state, $sanitize, Users, Messages) { +.controller('SettingsController', ['$scope', '$state', '$sanitize', 'Authentication', 'UserService', 'Messages', +function ($scope, $state, $sanitize, Authentication, UserService, Messages) { $scope.formValues = { currentPassword: '', newPassword: '', @@ -9,22 +9,21 @@ function ($scope, $state, $sanitize, Users, Messages) { $scope.updatePassword = function() { $scope.invalidPassword = false; - $scope.error = false; + var userID = Authentication.getUserDetails().ID; var currentPassword = $sanitize($scope.formValues.currentPassword); - Users.checkPassword({ username: $scope.username, password: currentPassword }, function (d) { - if (d.valid) { - var newPassword = $sanitize($scope.formValues.newPassword); - Users.update({ username: $scope.username, password: newPassword }, function (d) { - Messages.send("Success", "Password successfully updated"); - $state.reload(); - }, function (e) { - Messages.error("Failure", e, "Unable to update password"); - }); - } else { + var newPassword = $sanitize($scope.formValues.newPassword); + + UserService.updateUserPassword(userID, currentPassword, newPassword) + .then(function success() { + Messages.send("Success", "Password successfully updated"); + $state.reload(); + }) + .catch(function error(err) { + if (err.invalidPassword) { $scope.invalidPassword = true; + } else { + Messages.error("Failure", err, err.msg); } - }, function (e) { - Messages.error("Failure", e, "Unable to check password validity"); }); }; }]); diff --git a/app/components/sidebar/sidebar.html b/app/components/sidebar/sidebar.html index 1d29b8f8d..5515fa69e 100644 --- a/app/components/sidebar/sidebar.html +++ b/app/components/sidebar/sidebar.html @@ -50,7 +50,10 @@ - + diff --git a/app/components/sidebar/sidebarController.js b/app/components/sidebar/sidebarController.js index f58d8399a..9b040af7f 100644 --- a/app/components/sidebar/sidebarController.js +++ b/app/components/sidebar/sidebarController.js @@ -1,39 +1,41 @@ angular.module('sidebar', []) -.controller('SidebarController', ['$scope', '$state', 'Settings', 'Config', 'EndpointService', 'StateManager', 'Messages', -function ($scope, $state, Settings, Config, EndpointService, StateManager, Messages) { +.controller('SidebarController', ['$scope', '$state', 'Settings', 'Config', 'EndpointService', 'StateManager', 'EndpointProvider', 'Messages', 'Authentication', +function ($scope, $state, Settings, Config, EndpointService, StateManager, EndpointProvider, Messages, Authentication) { Config.$promise.then(function (c) { $scope.logo = c.logo; }); $scope.uiVersion = Settings.uiVersion; + $scope.userRole = Authentication.getUserDetails().role; $scope.switchEndpoint = function(endpoint) { - EndpointService.setActive(endpoint.Id).then(function success(data) { + var activeEndpointID = EndpointProvider.endpointID(); + EndpointProvider.setEndpointID(endpoint.Id); + StateManager.updateEndpointState(true) + .then(function success() { + $state.go('dashboard'); + }) + .catch(function error(err) { + Messages.error("Failure", err, "Unable to connect to the Docker endpoint"); + EndpointProvider.setEndpointID(activeEndpointID); StateManager.updateEndpointState(true) - .then(function success() { - $state.reload(); - }, function error(err) { - Messages.error("Failure", err, "Unable to connect to the Docker endpoint"); - }); - }, function error(err) { - Messages.error("Failure", err, "Unable to switch to new endpoint"); + .then(function success() {}); }); }; function fetchEndpoints() { - EndpointService.endpoints().then(function success(data) { + EndpointService.endpoints() + .then(function success(data) { $scope.endpoints = data; - EndpointService.getActive().then(function success(data) { - angular.forEach($scope.endpoints, function (endpoint) { - if (endpoint.Id === data.Id) { - $scope.activeEndpoint = endpoint; - } - }); - }, function error(err) { - Messages.error("Failure", err, "Unable to retrieve active endpoint"); + var activeEndpointID = EndpointProvider.endpointID(); + angular.forEach($scope.endpoints, function (endpoint) { + if (endpoint.Id === activeEndpointID) { + $scope.activeEndpoint = endpoint; + } }); - }, function error(err) { + }) + .catch(function error(err) { $scope.endpoints = []; }); } diff --git a/app/components/stats/statsController.js b/app/components/stats/statsController.js index 475af604c..4329d0a05 100644 --- a/app/components/stats/statsController.js +++ b/app/components/stats/statsController.js @@ -22,6 +22,8 @@ function (Pagination, $scope, Messages, $timeout, Container, ContainerTop, $stat $scope.containerTop = data; }); }; + var destroyed = false; + var timeout; $document.ready(function(){ var cpuLabels = []; var cpuData = []; @@ -117,11 +119,6 @@ function (Pagination, $scope, Messages, $timeout, Container, ContainerTop, $stat }); $scope.networkLegend = $sce.trustAsHtml(networkChart.generateLegend()); - function setUpdateStatsTimeout() { - if(!destroyed) { - timeout = $timeout(updateStats, 5000); - } - } function updateStats() { Container.stats({id: $stateParams.id}, function (d) { @@ -145,8 +142,7 @@ function (Pagination, $scope, Messages, $timeout, Container, ContainerTop, $stat }); } - var destroyed = false; - var timeout; + $scope.$on('$destroy', function () { destroyed = true; $timeout.cancel(timeout); @@ -204,6 +200,12 @@ function (Pagination, $scope, Messages, $timeout, Container, ContainerTop, $stat } return cpuPercent; } + + function setUpdateStatsTimeout() { + if(!destroyed) { + timeout = $timeout(updateStats, 5000); + } + } }); Container.get({id: $stateParams.id}, function (d) { diff --git a/app/components/swarm/swarm.html b/app/components/swarm/swarm.html index 661dbd710..d3aac8e75 100644 --- a/app/components/swarm/swarm.html +++ b/app/components/swarm/swarm.html @@ -163,57 +163,65 @@ - + Name - - + + - + Role - - + + - + CPU - - + + - + Memory - - + + - + Engine - - + + + + + + + IP Address + + - + Status - - + + - {{ node.Description.Hostname }} - {{ node.Spec.Role }} - {{ node.Description.Resources.NanoCPUs / 1000000000 }} - {{ node.Description.Resources.MemoryBytes|humansize }} - {{ node.Description.Engine.EngineVersion }} - {{ node.Status.State }} + {{ node.Hostname }} + {{ node.Role }} + {{ node.CPUs / 1000000000 }} + {{ node.Memory|humansize }} + {{ node.EngineVersion }} + {{ node.Addr }} + {{ node.Status }} diff --git a/app/components/swarm/swarmController.js b/app/components/swarm/swarmController.js index 713db07ef..5d844c92c 100644 --- a/app/components/swarm/swarmController.js +++ b/app/components/swarm/swarmController.js @@ -28,7 +28,9 @@ function ($scope, Info, Version, Node, Pagination) { $scope.info = d; if ($scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE') { Node.query({}, function(d) { - $scope.nodes = d; + $scope.nodes = d.map(function (node) { + return new NodeViewModel(node); + }); var CPU = 0, memory = 0; angular.forEach(d, function(node) { CPU += node.Description.Resources.NanoCPUs; @@ -73,7 +75,7 @@ function ($scope, Info, Version, Node, Pagination) { var node = {}; node.name = info[offset][0]; node.ip = info[offset][1]; - node.id = info[offset + 1][1]; + node.Id = info[offset + 1][1]; node.status = info[offset + 2][1]; node.containers = info[offset + 3][1]; node.cpu = info[offset + 4][1].split('/')[1]; diff --git a/app/components/templates/templates.html b/app/components/templates/templates.html index 57ea3286f..513b55580 100644 --- a/app/components/templates/templates.html +++ b/app/components/templates/templates.html @@ -10,7 +10,7 @@
- +
@@ -27,19 +27,20 @@
- -
- + +
+
- -
+ +
-
+ +
+ + +
+
+ +
+ + +
+
+
+ -
- -
- - map additional port - + +
+ +
+
+ + + map additional port + +
+
+ Portainer will automatically assign a port if you leave the host port empty. +
+ + +
+
+
+ +
+ host + +
+ + + + + +
+ container + +
+ + +
+
+ + +
+ +
+ +
+
+
+
- -
-
-
- host - -
-
- container - -
-
- - - - + + +
+
+ + + map additional volume + +
+
+ Portainer will automatically create and map a local volume when using the auto option. +
+
+
+ +
+ +
+ container + +
+ + +
+
+ + + +
+ +
+ +
+ + +
+ + +
+ volume + +
+ + +
+ host + +
+ + +
+
+ + +
+
+ +
+
- +
- +
@@ -125,9 +226,9 @@
- -
{{ tpl.title }}
-
{{ tpl.description }}
+ +
{{ tpl.Title }}
+
{{ tpl.Description }}
Loading... diff --git a/app/components/templates/templatesController.js b/app/components/templates/templatesController.js index 4619979b9..a56996ec0 100644 --- a/app/components/templates/templatesController.js +++ b/app/components/templates/templatesController.js @@ -1,241 +1,153 @@ angular.module('templates', []) -.controller('TemplatesController', ['$scope', '$q', '$state', '$filter', '$anchorScroll', 'Config', 'Info', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'Network', 'Templates', 'TemplateHelper', 'Messages', 'Pagination', -function ($scope, $q, $state, $filter, $anchorScroll, Config, Info, Container, ContainerHelper, Image, ImageHelper, Volume, Network, Templates, TemplateHelper, Messages, Pagination) { +.controller('TemplatesController', ['$scope', '$q', '$state', '$anchorScroll', 'Config', 'ContainerService', 'ContainerHelper', 'ImageService', 'NetworkService', 'TemplateService', 'TemplateHelper', 'VolumeService', 'Messages', 'Pagination', 'ResourceControlService', 'Authentication', +function ($scope, $q, $state, $anchorScroll, Config, ContainerService, ContainerHelper, ImageService, NetworkService, TemplateService, TemplateHelper, VolumeService, Messages, Pagination, ResourceControlService, Authentication) { $scope.state = { selectedTemplate: null, showAdvancedOptions: false, pagination_count: Pagination.getPaginationCount('templates') }; $scope.formValues = { + Ownership: $scope.applicationState.application.authentication ? 'private' : '', network: "", name: "", - ports: [] }; - var selectedItem = -1; - $scope.changePaginationCount = function() { Pagination.setPaginationCount('templates', $scope.state.pagination_count); }; + $scope.addVolume = function () { + $scope.state.selectedTemplate.Volumes.push({ containerPath: '', name: '', readOnly: false, type: 'auto' }); + }; + + $scope.removeVolume = function(index) { + $scope.state.selectedTemplate.Volumes.splice(index, 1); + }; + $scope.addPortBinding = function() { - $scope.formValues.ports.push({ hostPort: '', containerPort: '', protocol: 'tcp' }); + $scope.state.selectedTemplate.Ports.push({ hostPort: '', containerPort: '', protocol: 'tcp' }); }; $scope.removePortBinding = function(index) { - $scope.formValues.ports.splice(index, 1); + $scope.state.selectedTemplate.Ports.splice(index, 1); }; - // TODO: centralize, already present in createContainerController - function createContainer(config) { - Container.create(config, function (d) { - if (d.message) { - $('#createContainerSpinner').hide(); - Messages.error('Error', {}, d.message); - } else { - Container.start({id: d.Id}, {}, function (cd) { - if (cd.message) { - $('#createContainerSpinner').hide(); - Messages.error('Error', {}, cd.message); - } else { - $('#createContainerSpinner').hide(); - Messages.send('Container Started', d.Id); - $state.go('containers', {}, {reload: true}); - } - }, function (e) { - $('#createContainerSpinner').hide(); - Messages.error("Failure", e, 'Unable to start container'); - }); - } - }, function (e) { - $('#createContainerSpinner').hide(); - Messages.error("Failure", e, 'Unable to create container'); - }); - } - - - // TODO: centralize, already present in createContainerController - function pullImageAndCreateContainer(imageConfig, containerConfig) { - Image.create(imageConfig, function (data) { - var err = data.length > 0 && data[data.length - 1].hasOwnProperty('error'); - if (err) { - var detail = data[data.length - 1]; - $('#createContainerSpinner').hide(); - Messages.error("Error", {}, detail.error); - } else { - createContainer(containerConfig); - } - }, function (e) { - $('#createContainerSpinner').hide(); - Messages.error("Failure", e, "Unable to pull image"); - }); - } - - function getInitialConfiguration() { - return { - Env: [], - OpenStdin: false, - Tty: false, - ExposedPorts: {}, - HostConfig: { - RestartPolicy: { - Name: 'no' - }, - PortBindings: {}, - Binds: [], - NetworkMode: $scope.formValues.network.Name, - Privileged: false - }, - Volumes: {}, - name: $scope.formValues.name - }; - } - - function preparePortBindings(config, ports) { - var bindings = {}; - ports.forEach(function (portBinding) { - if (portBinding.containerPort) { - var key = portBinding.containerPort + "/" + portBinding.protocol; - var binding = {}; - if (portBinding.hostPort && portBinding.hostPort.indexOf(':') > -1) { - var hostAndPort = portBinding.hostPort.split(':'); - binding.HostIp = hostAndPort[0]; - binding.HostPort = hostAndPort[1]; - } else { - binding.HostPort = portBinding.hostPort; - } - bindings[key] = [binding]; - config.ExposedPorts[key] = {}; - } - }); - config.HostConfig.PortBindings = bindings; - } - - function createConfigFromTemplate(template) { - var containerConfig = getInitialConfiguration(); - containerConfig.Image = template.image; - if (template.env) { - template.env.forEach(function (v) { - if (v.value || v.set) { - var val; - if (v.type && v.type === 'container') { - if ($scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM' && $scope.formValues.network.Scope === 'global') { - val = $filter('swarmcontainername')(v.value); - } else { - var container = v.value; - val = container.NetworkSettings.Networks[Object.keys(container.NetworkSettings.Networks)[0]].IPAddress; - } - } else { - val = v.set ? v.set : v.value; - } - containerConfig.Env.push(v.name + "=" + val); - } - }); - } - preparePortBindings(containerConfig, $scope.formValues.ports); - prepareImageConfig(containerConfig, template); - return containerConfig; - } - - function prepareImageConfig(config, template) { - var image = template.image; - var registry = template.registry || ''; - var imageConfig = ImageHelper.createImageConfigForContainer(image, registry); - config.Image = imageConfig.fromImage + ':' + imageConfig.tag; - $scope.imageConfig = imageConfig; - } - - function prepareVolumeQueries(template, containerConfig) { - var volumeQueries = []; - if (template.volumes) { - template.volumes.forEach(function (vol) { - volumeQueries.push( - Volume.create({}, function (d) { - if (d.message) { - Messages.error("Unable to create volume", {}, d.message); - } else { - Messages.send("Volume created", d.Name); - containerConfig.Volumes[vol] = {}; - containerConfig.HostConfig.Binds.push(d.Name + ':' + vol); - } - }, function (e) { - Messages.error("Failure", e, "Unable to create volume"); - }).$promise - ); - }); - } - return volumeQueries; - } - $scope.createTemplate = function() { $('#createContainerSpinner').show(); var template = $scope.state.selectedTemplate; - var containerConfig = createConfigFromTemplate(template); - var createVolumeQueries = prepareVolumeQueries(template, containerConfig); - $q.all(createVolumeQueries).then(function (d) { - pullImageAndCreateContainer($scope.imageConfig, containerConfig); + var templateConfiguration = createTemplateConfiguration(template); + var generatedVolumeCount = TemplateHelper.determineRequiredGeneratedVolumeCount(template.Volumes); + VolumeService.createXAutoGeneratedLocalVolumes(generatedVolumeCount) + .then(function success(data) { + var volumeResourceControlQueries = []; + if ($scope.formValues.Ownership === 'private') { + angular.forEach(data, function (volume) { + volumeResourceControlQueries.push(ResourceControlService.setVolumeResourceControl(Authentication.getUserDetails().ID, volume.Name)); + }); + } + TemplateService.updateContainerConfigurationWithVolumes(templateConfiguration.container, template, data); + return $q.all(volumeResourceControlQueries).then(ImageService.pullImage(templateConfiguration.image)); + }) + .then(function success(data) { + return ContainerService.createAndStartContainer(templateConfiguration.container); + }) + .then(function success(data) { + Messages.send('Container Started', data.Id); + if ($scope.formValues.Ownership === 'private') { + ResourceControlService.setContainerResourceControl(Authentication.getUserDetails().ID, data.Id) + .then(function success(data) { + $state.go('containers', {}, {reload: true}); + }); + } else { + $state.go('containers', {}, {reload: true}); + } + }) + .catch(function error(err) { + Messages.error('Failure', err, err.msg); + }) + .finally(function final() { + $('#createContainerSpinner').hide(); }); }; - $scope.selectTemplate = function(id) { - $('#template_' + id).toggleClass("container-template--selected"); - if (selectedItem === id) { - selectedItem = -1; - $scope.state.selectedTemplate = null; + var selectedItem = -1; + $scope.selectTemplate = function(idx) { + $('#template_' + idx).toggleClass("container-template--selected"); + if (selectedItem === idx) { + unselectTemplate(); } else { - $('#template_' + selectedItem).toggleClass("container-template--selected"); - selectedItem = id; - var selectedTemplate = $scope.templates[id]; - $scope.state.selectedTemplate = selectedTemplate; - $scope.formValues.ports = selectedTemplate.ports ? TemplateHelper.getPortBindings(selectedTemplate.ports) : []; - $anchorScroll('selectedTemplate'); + selectTemplate(idx); } }; + function unselectTemplate() { + selectedItem = -1; + $scope.state.selectedTemplate = null; + } + + function selectTemplate(idx) { + $('#template_' + selectedItem).toggleClass("container-template--selected"); + selectedItem = idx; + var selectedTemplate = $scope.templates[idx]; + $scope.state.selectedTemplate = selectedTemplate; + if (selectedTemplate.Network) { + $scope.formValues.network = _.find($scope.availableNetworks, function(o) { return o.Name === selectedTemplate.Network; }); + } else { + $scope.formValues.network = _.find($scope.availableNetworks, function(o) { return o.Name === "bridge"; }); + } + $anchorScroll('selectedTemplate'); + } + + function createTemplateConfiguration(template) { + var network = $scope.formValues.network; + var name = $scope.formValues.name; + var containerMapping = determineContainerMapping(network); + return TemplateService.createTemplateConfiguration(template, name, network, containerMapping); + } + + function determineContainerMapping(network) { + var endpointProvider = $scope.applicationState.endpoint.mode.provider; + var containerMapping = 'BY_CONTAINER_IP'; + if (endpointProvider === 'DOCKER_SWARM' && network.Scope === 'global') { + containerMapping = 'BY_SWARM_CONTAINER_NAME'; + } else if (network.Name !== "bridge") { + containerMapping = 'BY_CONTAINER_NAME'; + } + } + + function filterNetworksBasedOnProvider(networks) { + var endpointProvider = $scope.applicationState.endpoint.mode.provider; + if (endpointProvider === 'DOCKER_SWARM' || endpointProvider === 'DOCKER_SWARM_MODE') { + networks = NetworkService.filterGlobalNetworks(networks); + $scope.globalNetworkCount = networks.length; + NetworkService.addPredefinedLocalNetworks(networks); + } + return networks; + } + function initTemplates() { - Templates.get(function (data) { - $scope.templates = data.map(function(tpl,index){ - tpl.index = index; - return tpl; + Config.$promise.then(function (c) { + $q.all({ + templates: TemplateService.getTemplates(), + containers: ContainerService.getContainers(0, c.hiddenLabels), + networks: NetworkService.getNetworks(), + volumes: VolumeService.getVolumes() + }) + .then(function success(data) { + $scope.templates = data.templates; + $scope.runningContainers = data.containers; + $scope.availableNetworks = filterNetworksBasedOnProvider(data.networks); + $scope.availableVolumes = data.volumes.Volumes; + }) + .catch(function error(err) { + $scope.templates = []; + Messages.error("Failure", err, "An error occured during apps initialization."); + }) + .finally(function final(){ + $('#loadTemplatesSpinner').hide(); }); - $('#loadTemplatesSpinner').hide(); - }, function (e) { - $('#loadTemplatesSpinner').hide(); - Messages.error("Failure", e, "Unable to retrieve apps list"); - $scope.templates = []; }); } - Config.$promise.then(function (c) { - var containersToHideLabels = c.hiddenLabels; - 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({Scope: "local", Name: "bridge"}); - networks.push({Scope: "local", Name: "host"}); - networks.push({Scope: "local", Name: "none"}); - } else { - $scope.formValues.network = _.find(networks, function(o) { return o.Name === "bridge"; }); - } - $scope.availableNetworks = networks; - }, function (e) { - Messages.error("Failure", e, "Unable to retrieve networks"); - }); - Container.query({all: 0}, function (d) { - var containers = d; - if (containersToHideLabels) { - containers = ContainerHelper.hideContainers(d, containersToHideLabels); - } - $scope.runningContainers = containers; - }, function (e) { - Messages.error("Failure", e, "Unable to retrieve running containers"); - }); - initTemplates(); - }); + initTemplates(); }]); diff --git a/app/components/user/user.html b/app/components/user/user.html new file mode 100644 index 000000000..8182ff84f --- /dev/null +++ b/app/components/user/user.html @@ -0,0 +1,88 @@ + + + + + + Users > {{ user.Username }} + + + +
+
+ + + + + + + + + + + + + +
Name + {{ user.Username }} + +
Permissions +
+ + +
+
+
+
+
+
+ +
+
+ + + + + +
+ +
+
+ + +
+
+
+ + +
+ +
+
+ + + +
+
+
+ +
+
+ +
+
+

+ {{ state.updatePasswordError }} +

+
+
+ +
+
+
+
diff --git a/app/components/user/userController.js b/app/components/user/userController.js new file mode 100644 index 000000000..eca3c5293 --- /dev/null +++ b/app/components/user/userController.js @@ -0,0 +1,85 @@ +angular.module('user', []) +.controller('UserController', ['$scope', '$state', '$stateParams', 'UserService', 'ModalService', 'Messages', +function ($scope, $state, $stateParams, UserService, ModalService, Messages) { + + $scope.state = { + updatePasswordError: '', + }; + + $scope.formValues = { + newPassword: '', + confirmPassword: '' + }; + + $scope.deleteUser = function() { + ModalService.confirmDeletion( + 'Do you want to delete this user? This user will not be able to login into Portainer anymore.', + function onConfirm(confirmed) { + if(!confirmed) { return; } + deleteUser(); + } + ); + }; + + $scope.updatePermissions = function() { + $('#loadingViewSpinner').show(); + UserService.updateUser($scope.user.Id, undefined, $scope.user.RoleId) + .then(function success(data) { + var newRole = $scope.user.RoleId === 1 ? 'administrator' : 'user'; + Messages.send('Permissions successfully updated', $scope.user.Username + ' is now ' + newRole); + $state.reload(); + }) + .catch(function error(err) { + Messages.error("Failure", err, 'Unable to update user permissions'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + }; + + $scope.updatePassword = function() { + $('#loadingViewSpinner').show(); + UserService.updateUser($scope.user.Id, $scope.formValues.newPassword, undefined) + .then(function success(data) { + Messages.send('Password successfully updated'); + $state.reload(); + }) + .catch(function error(err) { + $scope.state.updatePasswordError = 'Unable to update password'; + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + }; + + function deleteUser() { + $('#loadingViewSpinner').show(); + UserService.deleteUser($scope.user.Id) + .then(function success(data) { + Messages.send('User successfully deleted', $scope.user.Username); + $state.go('users'); + }) + .catch(function error(err) { + Messages.error("Failure", err, 'Unable to remove user'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + } + + function getUser() { + $('#loadingViewSpinner').show(); + UserService.user($stateParams.id) + .then(function success(data) { + $scope.user = new UserViewModel(data); + }) + .catch(function error(err) { + Messages.error("Failure", err, 'Unable to retrieve user information'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + } + + getUser(); +}]); diff --git a/app/components/users/users.html b/app/components/users/users.html new file mode 100644 index 000000000..799f70552 --- /dev/null +++ b/app/components/users/users.html @@ -0,0 +1,159 @@ + + + + + + + + User management + + +
+
+ + + + +
+ +
+ +
+
+ + +
+
+
+ + +
+ +
+
+ + +
+
+
+ + +
+ +
+
+ + + +
+
+
+ + +
+ +
+
+ + +
+
+
+ +
+
+ + + + {{ state.userCreationError }} + +
+
+
+
+
+
+
+ +
+
+ + +
+ Items per page: + +
+
+ +
+ +
+
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Name + + + + + + Role + + + +
{{ user.Username }} + {{ user.RoleName }} + + + Edit +
Loading...
No users available.
+
+ +
+
+
+ +
+
diff --git a/app/components/users/usersController.js b/app/components/users/usersController.js new file mode 100644 index 000000000..5fd53e84e --- /dev/null +++ b/app/components/users/usersController.js @@ -0,0 +1,132 @@ +angular.module('users', []) +.controller('UsersController', ['$scope', '$state', 'UserService', 'ModalService', 'Messages', 'Pagination', +function ($scope, $state, UserService, ModalService, Messages, Pagination) { + $scope.state = { + userCreationError: '', + selectedItemCount: 0, + validUsername: false, + pagination_count: Pagination.getPaginationCount('users') + }; + $scope.sortType = 'RoleName'; + $scope.sortReverse = false; + + $scope.formValues = { + Username: '', + Password: '', + ConfirmPassword: '', + Role: 2, + }; + + $scope.order = function(sortType) { + $scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false; + $scope.sortType = sortType; + }; + + $scope.changePaginationCount = function() { + Pagination.setPaginationCount('endpoints', $scope.state.pagination_count); + }; + + $scope.selectItems = function (allSelected) { + angular.forEach($scope.state.filteredUsers, function (user) { + if (user.Checked !== allSelected) { + user.Checked = allSelected; + $scope.selectItem(user); + } + }); + }; + + $scope.selectItem = function (item) { + if (item.Checked) { + $scope.state.selectedItemCount++; + } else { + $scope.state.selectedItemCount--; + } + }; + + $scope.checkUsernameValidity = function() { + var valid = true; + for (var i = 0; i < $scope.users.length; i++) { + if ($scope.formValues.Username === $scope.users[i].Username) { + valid = false; + break; + } + } + $scope.state.validUsername = valid; + $scope.state.userCreationError = valid ? '' : 'Username already taken'; + }; + + $scope.addUser = function() { + $scope.state.userCreationError = ''; + var username = $scope.formValues.Username; + var password = $scope.formValues.Password; + var role = $scope.formValues.Role; + UserService.createUser(username, password, role) + .then(function success(data) { + Messages.send("User created", username); + $state.reload(); + }) + .catch(function error(err) { + $scope.state.userCreationError = err.msg; + }) + .finally(function final() { + + }); + }; + + function deleteSelectedUsers() { + $('#loadUsersSpinner').show(); + var counter = 0; + var complete = function () { + counter = counter - 1; + if (counter === 0) { + $('#loadUsersSpinner').hide(); + } + }; + angular.forEach($scope.users, function (user) { + if (user.Checked) { + counter = counter + 1; + UserService.deleteUser(user.Id) + .then(function success(data) { + var index = $scope.users.indexOf(user); + $scope.users.splice(index, 1); + Messages.send('User successfully deleted', user.Username); + }) + .catch(function error(err) { + Messages.error("Failure", err, 'Unable to remove user'); + }) + .finally(function final() { + complete(); + }); + } + }); + } + + $scope.removeAction = function () { + ModalService.confirmDeletion( + 'Do you want to delete the selected users? They will not be able to login into Portainer anymore.', + function onConfirm(confirmed) { + if(!confirmed) { return; } + deleteSelectedUsers(); + } + ); + }; + + function fetchUsers() { + $('#loadUsersSpinner').show(); + UserService.users() + .then(function success(data) { + $scope.users = data.map(function(user) { + return new UserViewModel(user); + }); + }) + .catch(function error(err) { + Messages.error("Failure", err, "Unable to retrieve users"); + $scope.users = []; + }) + .finally(function final() { + $('#loadUsersSpinner').hide(); + }); + } + + fetchUsers(); +}]); diff --git a/app/components/volumes/volumes.html b/app/components/volumes/volumes.html index 530e24a7b..24a3cd550 100644 --- a/app/components/volumes/volumes.html +++ b/app/components/volumes/volumes.html @@ -60,6 +60,13 @@ + + + Ownership + + + + @@ -68,12 +75,33 @@ {{ volume.Name|truncate:50 }} {{ volume.Driver }} {{ volume.Mountpoint }} + + + + + Private + + + Private (owner: {{ volume.Owner }}) + + Switch to public + + + + Private + Switch to public + + + + Public + + - Loading... + Loading... - No volumes available. + No volumes available. diff --git a/app/components/volumes/volumesController.js b/app/components/volumes/volumesController.js index 6bbc9c27c..e020f075b 100644 --- a/app/components/volumes/volumesController.js +++ b/app/components/volumes/volumesController.js @@ -1,6 +1,6 @@ angular.module('volumes', []) -.controller('VolumesController', ['$scope', '$state', 'Volume', 'Messages', 'Pagination', -function ($scope, $state, Volume, Messages, Pagination) { +.controller('VolumesController', ['$scope', '$state', 'Volume', 'Messages', 'Pagination', 'ModalService', 'Authentication', 'ResourceControlService', 'UserService', +function ($scope, $state, Volume, Messages, Pagination, ModalService, Authentication, ResourceControlService, UserService) { $scope.state = {}; $scope.state.pagination_count = Pagination.getPaginationCount('volumes'); $scope.state.selectedItemCount = 0; @@ -10,6 +10,24 @@ function ($scope, $state, Volume, Messages, Pagination) { Name: '' }; + function removeVolumeResourceControl(volume) { + ResourceControlService.removeVolumeResourceControl(volume.Metadata.ResourceControl.OwnerId, volume.Name) + .then(function success() { + delete volume.Metadata.ResourceControl; + Messages.send('Ownership changed to public', volume.Name); + }) + .catch(function error(err) { + Messages.error("Failure", err, "Unable to change volume ownership"); + }); + } + + $scope.switchOwnership = function(volume) { + ModalService.confirmVolumeOwnershipChange(function (confirmed) { + if(!confirmed) { return; } + removeVolumeResourceControl(volume); + }); + }; + $scope.changePaginationCount = function() { Pagination.setPaginationCount('volumes', $scope.state.pagination_count); }; @@ -52,9 +70,21 @@ function ($scope, $state, Volume, Messages, Pagination) { if (d.message) { Messages.error("Unable to remove volume", {}, d.message); } else { - Messages.send("Volume deleted", volume.Name); - var index = $scope.volumes.indexOf(volume); - $scope.volumes.splice(index, 1); + if (volume.Metadata && volume.Metadata.ResourceControl) { + ResourceControlService.removeVolumeResourceControl(volume.Metadata.ResourceControl.OwnerId, volume.Name) + .then(function success() { + Messages.send("Volume deleted", volume.Name); + var index = $scope.volumes.indexOf(volume); + $scope.volumes.splice(index, 1); + }) + .catch(function error(err) { + Messages.error("Failure", err, "Unable to remove volume ownership"); + }); + } else { + Messages.send("Volume deleted", volume.Name); + var index = $scope.volumes.indexOf(volume); + $scope.volumes.splice(index, 1); + } } complete(); }, function (e) { @@ -65,11 +95,45 @@ function ($scope, $state, Volume, Messages, Pagination) { }); }; + function mapUsersToVolumes(users) { + angular.forEach($scope.volumes, function (volume) { + if (volume.Metadata) { + var volumeRC = volume.Metadata.ResourceControl; + if (volumeRC && volumeRC.OwnerId != $scope.user.ID) { + angular.forEach(users, function (user) { + if (volumeRC.OwnerId === user.Id) { + volume.Owner = user.Username; + } + }); + } + } + }); + } + function fetchVolumes() { $('#loadVolumesSpinner').show(); + var userDetails = Authentication.getUserDetails(); + $scope.user = userDetails; + Volume.query({}, function (d) { - $scope.volumes = d.Volumes || []; - $('#loadVolumesSpinner').hide(); + var volumes = d.Volumes || []; + $scope.volumes = volumes.map(function (v) { + return new VolumeViewModel(v); + }); + if (userDetails.role === 1) { + UserService.users() + .then(function success(data) { + mapUsersToVolumes(data); + }) + .catch(function error(err) { + Messages.error("Failure", err, "Unable to retrieve users"); + }) + .finally(function final() { + $('#loadVolumesSpinner').hide(); + }); + } else { + $('#loadVolumesSpinner').hide(); + } }, function (e) { $('#loadVolumesSpinner').hide(); Messages.error("Failure", e, "Unable to retrieve volumes"); diff --git a/app/directives/header-content.js b/app/directives/header-content.js index 173a2331b..e372e28a4 100644 --- a/app/directives/header-content.js +++ b/app/directives/header-content.js @@ -5,7 +5,7 @@ angular requires: '^rdHeader', transclude: true, link: function (scope, iElement, iAttrs) { - scope.username = Authentication.getCredentials().username; + scope.username = Authentication.getUserDetails().username; }, template: '', restrict: 'E' diff --git a/app/directives/header-title.js b/app/directives/header-title.js index 1c03c5a12..3001d4f34 100644 --- a/app/directives/header-title.js +++ b/app/directives/header-title.js @@ -7,7 +7,7 @@ angular title: '@' }, link: function (scope, iElement, iAttrs) { - scope.username = Authentication.getCredentials().username; + scope.username = Authentication.getUserDetails().username; }, transclude: true, template: '
{{title}} {{username}}
', diff --git a/app/directives/tooltip.js b/app/directives/tooltip.js new file mode 100644 index 000000000..2293fbf72 --- /dev/null +++ b/app/directives/tooltip.js @@ -0,0 +1,13 @@ +angular +.module('portainer') +.directive('portainerTooltip', [function portainerTooltip() { + var directive = { + scope: { + message: '@', + position: '@' + }, + template: '', + restrict: 'E' + }; + return directive; +}]); diff --git a/app/directives/widget-header.js b/app/directives/widget-header.js index 4106fbc79..9e047aaa1 100644 --- a/app/directives/widget-header.js +++ b/app/directives/widget-header.js @@ -5,10 +5,11 @@ angular requires: '^rdWidget', scope: { title: '@', - icon: '@' + icon: '@', + classes: '@?' }, transclude: true, - template: '
{{title}}
', + template: '
{{title}}
', restrict: 'E' }; return directive; diff --git a/app/filters/filters.js b/app/filters/filters.js index 3e6881cbe..075fa5902 100644 --- a/app/filters/filters.js +++ b/app/filters/filters.js @@ -1,231 +1,240 @@ -angular.module('portainer.filters', []) -.filter('truncate', function () { - 'use strict'; - return function (text, length, end) { - if (isNaN(length)) { - length = 10; - } - - if (end === undefined) { - end = '...'; - } - - if (text.length <= length || text.length - end.length <= length) { - return text; - } - else { - return String(text).substring(0, length - end.length) + end; - } - }; -}) -.filter('taskstatusbadge', function () { - '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) { - return 'info'; - } else if (status.indexOf('pending') !== -1) { - return 'warning'; - } else if (status.indexOf('shutdown') !== -1 || status.indexOf('failed') !== -1 || - status.indexOf('rejected') !== -1) { - return 'danger'; - } else if (status.indexOf('complete') !== -1) { - return 'primary'; - } - return 'success'; - }; -}) -.filter('containerstatusbadge', function () { - 'use strict'; - return function (text) { - var status = _.toLower(text); - if (status.indexOf('paused') !== -1) { - return 'warning'; - } else if (status.indexOf('created') !== -1) { - return 'info'; - } else if (status.indexOf('stopped') !== -1) { - return 'danger'; - } - return 'success'; - }; -}) -.filter('containerstatus', function () { - 'use strict'; - return function (text) { - var status = _.toLower(text); - if (status.indexOf('paused') !== -1) { - return 'paused'; - } else if (status.indexOf('created') !== -1) { - return 'created'; - } else if (status.indexOf('exited') !== -1) { - return 'stopped'; - } - return 'running'; - }; -}) -.filter('nodestatusbadge', function () { - 'use strict'; - return function (text) { - if (text === 'down' || text === 'Unhealthy') { - return 'danger'; - } - return 'success'; - }; -}) -.filter('trimcontainername', function () { - 'use strict'; - return function (name) { - if (name) { - return (name.indexOf('/') === 0 ? name.replace('/','') : name); - } - return ''; - }; -}) -.filter('capitalize', function () { - 'use strict'; - return function (text) { - return _.capitalize(text); - }; -}) -.filter('getstatetext', function () { - 'use strict'; - return function (state) { - if (state === undefined) { - return ''; - } - if (state.Ghost && state.Running) { - return 'Ghost'; - } - if (state.Running && state.Paused) { - return 'Running (Paused)'; - } - if (state.Running) { - return 'Running'; - } - return 'Stopped'; - }; -}) -.filter('stripprotocol', function() { - 'use strict'; - return function (url) { - return url.replace(/.*?:\/\//g, ''); - }; -}) -.filter('getstatelabel', function () { - 'use strict'; - return function (state) { - if (state === undefined) { - return 'label-default'; - } - if (state.Ghost && state.Running) { - return 'label-important'; - } - if (state.Running) { - return 'label-success'; - } - return 'label-default'; - }; -}) -.filter('humansize', function () { - 'use strict'; - return function (bytes, round) { - if (!round) { - round = 1; - } - if (bytes || bytes === 0) { - return filesize(bytes, {base: 10, round: round}); - } - }; -}) -.filter('containername', function () { - 'use strict'; - return function (container) { - var name = container.Names[0]; - return name.substring(1, name.length); - }; -}) -.filter('swarmcontainername', function () { - 'use strict'; - return function (container) { - return _.split(container.Names[0], '/')[2]; - }; -}) -.filter('swarmversion', function () { - 'use strict'; - return function (text) { - return _.split(text, '/')[1]; - }; -}) -.filter('swarmhostname', function () { - 'use strict'; - return function (container) { - return _.split(container.Names[0], '/')[1]; - }; -}) -.filter('repotags', function () { - 'use strict'; - return function (image) { - if (image.RepoTags && image.RepoTags.length > 0) { - var tag = image.RepoTags[0]; - if (tag === ':') { - return []; - } - return image.RepoTags; - } - return []; - }; -}) -.filter('getisodatefromtimestamp', function () { - 'use strict'; - return function (timestamp) { - return moment.unix(timestamp).format('YYYY-MM-DD HH:mm:ss'); - }; -}) -.filter('getisodate', function () { - 'use strict'; - return function (date) { - return moment(date).format('YYYY-MM-DD HH:mm:ss'); - }; -}) -.filter('command', function () { - 'use strict'; - return function (command) { - if (command) { - return command.join(' '); - } - }; -}) -.filter('key', function () { - 'use strict'; - return function (pair, separator) { - return pair.slice(0, pair.indexOf(separator)); - }; -}) -.filter('value', function () { - 'use strict'; - return function (pair, separator) { - return pair.slice(pair.indexOf(separator) + 1); - }; -}) -.filter('emptyobject', function () { - 'use strict'; - return function (obj) { - return _.isEmpty(obj); - }; -}) -.filter('ipaddress', function () { - 'use strict'; - return function (ip) { - return ip.slice(0, ip.indexOf('/')); - }; -}) -.filter('arraytostr', function () { - 'use strict'; - return function (arr, separator) { - if (arr) { - return _.join(arr, separator); - } - return ''; - }; -}); +angular.module('portainer.filters', []) +.filter('truncate', function () { + 'use strict'; + return function (text, length, end) { + if (isNaN(length)) { + length = 10; + } + + if (end === undefined) { + end = '...'; + } + + if (text.length <= length || text.length - end.length <= length) { + return text; + } + else { + return String(text).substring(0, length - end.length) + end; + } + }; +}) +.filter('taskstatusbadge', function () { + '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) { + return 'info'; + } else if (status.indexOf('pending') !== -1) { + return 'warning'; + } else if (status.indexOf('shutdown') !== -1 || status.indexOf('failed') !== -1 || + status.indexOf('rejected') !== -1) { + return 'danger'; + } else if (status.indexOf('complete') !== -1) { + return 'primary'; + } + return 'success'; + }; +}) +.filter('containerstatusbadge', function () { + 'use strict'; + return function (text) { + var status = _.toLower(text); + if (status.indexOf('paused') !== -1) { + return 'warning'; + } else if (status.indexOf('created') !== -1) { + return 'info'; + } else if (status.indexOf('stopped') !== -1) { + return 'danger'; + } + return 'success'; + }; +}) +.filter('containerstatus', function () { + 'use strict'; + return function (text) { + var status = _.toLower(text); + if (status.indexOf('paused') !== -1) { + return 'paused'; + } else if (status.indexOf('created') !== -1) { + return 'created'; + } else if (status.indexOf('exited') !== -1) { + return 'stopped'; + } + return 'running'; + }; +}) +.filter('nodestatusbadge', function () { + 'use strict'; + return function (text) { + if (text === 'down' || text === 'Unhealthy') { + return 'danger'; + } + return 'success'; + }; +}) +.filter('trimcontainername', function () { + 'use strict'; + return function (name) { + if (name) { + return (name.indexOf('/') === 0 ? name.replace('/','') : name); + } + return ''; + }; +}) +.filter('capitalize', function () { + 'use strict'; + return function (text) { + return _.capitalize(text); + }; +}) +.filter('getstatetext', function () { + 'use strict'; + return function (state) { + if (state === undefined) { + return ''; + } + if (state.Ghost && state.Running) { + return 'Ghost'; + } + if (state.Running && state.Paused) { + return 'Running (Paused)'; + } + if (state.Running) { + return 'Running'; + } + return 'Stopped'; + }; +}) +.filter('stripprotocol', function() { + 'use strict'; + return function (url) { + return url.replace(/.*?:\/\//g, ''); + }; +}) +.filter('getstatelabel', function () { + 'use strict'; + return function (state) { + if (state === undefined) { + return 'label-default'; + } + if (state.Ghost && state.Running) { + return 'label-important'; + } + if (state.Running) { + return 'label-success'; + } + return 'label-default'; + }; +}) +.filter('humansize', function () { + 'use strict'; + return function (bytes, round) { + if (!round) { + round = 1; + } + if (bytes || bytes === 0) { + return filesize(bytes, {base: 10, round: round}); + } + }; +}) +.filter('containername', function () { + 'use strict'; + return function (container) { + var name = container.Names[0]; + return name.substring(1, name.length); + }; +}) +.filter('swarmcontainername', function () { + 'use strict'; + return function (container) { + return _.split(container.Names[0], '/')[2]; + }; +}) +.filter('swarmversion', function () { + 'use strict'; + return function (text) { + return _.split(text, '/')[1]; + }; +}) +.filter('swarmhostname', function () { + 'use strict'; + return function (container) { + return _.split(container.Names[0], '/')[1]; + }; +}) +.filter('repotags', function () { + 'use strict'; + return function (image) { + if (image.RepoTags && image.RepoTags.length > 0) { + var tag = image.RepoTags[0]; + if (tag === ':') { + return []; + } + return image.RepoTags; + } + return []; + }; +}) +.filter('getisodatefromtimestamp', function () { + 'use strict'; + return function (timestamp) { + return moment.unix(timestamp).format('YYYY-MM-DD HH:mm:ss'); + }; +}) +.filter('getisodate', function () { + 'use strict'; + return function (date) { + return moment(date).format('YYYY-MM-DD HH:mm:ss'); + }; +}) +.filter('command', function () { + 'use strict'; + return function (command) { + if (command) { + return command.join(' '); + } + }; +}) +.filter('key', function () { + 'use strict'; + return function (pair, separator) { + return pair.slice(0, pair.indexOf(separator)); + }; +}) +.filter('value', function () { + 'use strict'; + return function (pair, separator) { + return pair.slice(pair.indexOf(separator) + 1); + }; +}) +.filter('emptyobject', function () { + 'use strict'; + return function (obj) { + return _.isEmpty(obj); + }; +}) +.filter('ipaddress', function () { + 'use strict'; + return function (ip) { + return ip.slice(0, ip.indexOf('/')); + }; +}) +.filter('arraytostr', function () { + 'use strict'; + return function (arr, separator) { + if (arr) { + return _.join(arr, separator); + } + return ''; + }; +}) +.filter('hideshasum', function () { + 'use strict'; + return function (imageName) { + if (imageName) { + return imageName.split('@sha')[0]; + } + return ''; + }; +}); diff --git a/app/helpers/containerHelper.js b/app/helpers/containerHelper.js index 101d9a108..3e806e24c 100644 --- a/app/helpers/containerHelper.js +++ b/app/helpers/containerHelper.js @@ -1,20 +1,26 @@ angular.module('portainer.helpers') .factory('ContainerHelper', [function ContainerHelperFactory() { 'use strict'; - return { - hideContainers: function(containers, containersToHideLabels) { - return containers.filter(function (container) { - var filterContainer = false; - containersToHideLabels.forEach(function(label, index) { - if (_.has(container.Labels, label.name) && - container.Labels[label.name] === label.value) { - filterContainer = true; - } - }); - if (!filterContainer) { - return container; + var helper = {}; + + helper.commandStringToArray = function(command) { + return splitargs(command); + }; + + helper.hideContainers = function(containers, containersToHideLabels) { + return containers.filter(function (container) { + var filterContainer = false; + containersToHideLabels.forEach(function(label, index) { + if (_.has(container.Labels, label.name) && + container.Labels[label.name] === label.value) { + filterContainer = true; } }); - } + if (!filterContainer) { + return container; + } + }); }; + + return helper; }]); diff --git a/app/helpers/templateHelper.js b/app/helpers/templateHelper.js index c52ed25c7..0df432495 100644 --- a/app/helpers/templateHelper.js +++ b/app/helpers/templateHelper.js @@ -1,40 +1,97 @@ angular.module('portainer.helpers') -.factory('TemplateHelper', [function TemplateHelperFactory() { +.factory('TemplateHelper', ['$filter', function TemplateHelperFactory($filter) { 'use strict'; - return { - getPortBindings: function(ports) { - var bindings = []; - ports.forEach(function (port) { - var portAndProtocol = _.split(port, '/'); - var binding = { - containerPort: portAndProtocol[0], - protocol: portAndProtocol[1] - }; - bindings.push(binding); - }); - return bindings; - }, - //Not used atm, may prove useful later - getVolumeBindings: function(volumes) { - var bindings = []; - volumes.forEach(function (volume) { - bindings.push({ containerPath: volume }); - }); - return bindings; - }, - //Not used atm, may prove useful later - getEnvBindings: function(env) { - var bindings = []; - env.forEach(function (envvar) { - var binding = { - name: envvar.name - }; - if (envvar.set) { - binding.value = envvar.set; - } - bindings.push(binding); - }); - return bindings; - } + var helper = {}; + + helper.getDefaultContainerConfiguration = function() { + return { + Env: [], + OpenStdin: false, + Tty: false, + ExposedPorts: {}, + HostConfig: { + RestartPolicy: { + Name: 'no' + }, + PortBindings: {}, + Binds: [], + Privileged: false + }, + Volumes: {} + }; }; + + helper.portArrayToPortConfiguration = function(ports) { + var portConfiguration = { + bindings: {}, + exposedPorts: {} + }; + ports.forEach(function (p) { + if (p.containerPort) { + var key = p.containerPort + "/" + p.protocol; + var binding = {}; + if (p.hostPort) { + binding.HostPort = p.hostPort; + if (p.hostPort.indexOf(':') > -1) { + var hostAndPort = p.hostPort.split(':'); + binding.HostIp = hostAndPort[0]; + binding.HostPort = hostAndPort[1]; + } + } + portConfiguration.bindings[key] = [binding]; + portConfiguration.exposedPorts[key] = {}; + } + }); + return portConfiguration; + }; + + helper.EnvToStringArray = function(templateEnvironment, containerMapping) { + var env = []; + templateEnvironment.forEach(function(envvar) { + if (envvar.value || envvar.set) { + var value = envvar.set ? envvar.set : envvar.value; + if (envvar.type && envvar.type === 'container') { + if (containerMapping === 'BY_CONTAINER_IP') { + var container = envvar.value; + value = container.NetworkSettings.Networks[Object.keys(container.NetworkSettings.Networks)[0]].IPAddress; + } else if (containerMapping === 'BY_CONTAINER_NAME') { + value = $filter('containername')(envvar.value); + } else if (containerMapping === 'BY_SWARM_CONTAINER_NAME') { + value = $filter('swarmcontainername')(envvar.value); + } + } + env.push(envvar.name + "=" + value); + } + }); + return env; + }; + + helper.createVolumeBindings = function(volumes, generatedVolumesPile) { + volumes.forEach(function (volume) { + if (volume.containerPath) { + var binding; + if (volume.type === 'auto') { + binding = generatedVolumesPile.pop().Name + ':' + volume.containerPath; + } else if (volume.type !== 'auto' && volume.name) { + binding = volume.name + ':' + volume.containerPath; + } + if (volume.readOnly) { + binding += ':ro'; + } + volume.binding = binding; + } + }); + }; + + helper.determineRequiredGeneratedVolumeCount = function(volumes) { + var count = 0; + volumes.forEach(function (volume) { + if (volume.type === 'auto') { + ++count; + } + }); + return count; + }; + + return helper; }]); diff --git a/app/models/container.js b/app/models/container.js index 2df1912a6..bb4a183f8 100644 --- a/app/models/container.js +++ b/app/models/container.js @@ -10,11 +10,21 @@ function ContainerViewModel(data) { this.Image = data.Image; 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 }); } } + if (data.Portainer) { + this.Metadata = {}; + if (data.Portainer.ResourceControl) { + this.Metadata.ResourceControl = { + OwnerId: data.Portainer.ResourceControl.OwnerId + }; + } + } } diff --git a/app/models/node.js b/app/models/node.js index 49aab6a13..1362d0885 100644 --- a/app/models/node.js +++ b/app/models/node.js @@ -27,6 +27,10 @@ function NodeViewModel(data) { this.Plugins = data.Description.Engine.Plugins; this.Status = data.Status.State; + if (data.Status.Addr) { + this.Addr = data.Status.Addr; + } + if (data.ManagerStatus) { this.Leader = data.ManagerStatus.Leader; this.Reachability = data.ManagerStatus.Reachability; diff --git a/app/models/service.js b/app/models/service.js index ab182761d..156faefef 100644 --- a/app/models/service.js +++ b/app/models/service.js @@ -1,4 +1,4 @@ -function ServiceViewModel(data) { +function ServiceViewModel(data, runningTasks, nodes) { this.Model = data; this.Id = data.ID; this.Name = data.Spec.Name; @@ -9,6 +9,12 @@ function ServiceViewModel(data) { this.Replicas = data.Spec.Mode.Replicated.Replicas; } else { this.Mode = 'global'; + if (nodes) { + this.Replicas = nodes.length; + } + } + if (runningTasks) { + this.Running = runningTasks.length; } this.Labels = data.Spec.Labels; if (data.Spec.TaskTemplate.ContainerSpec) { @@ -17,6 +23,10 @@ function ServiceViewModel(data) { if (data.Spec.TaskTemplate.ContainerSpec.Env) { this.Env = data.Spec.TaskTemplate.ContainerSpec.Env; } + this.Mounts = []; + if (data.Spec.TaskTemplate.ContainerSpec.Mounts) { + this.Mounts = data.Spec.TaskTemplate.ContainerSpec.Mounts; + } if (data.Endpoint.Ports) { this.Ports = data.Endpoint.Ports; } @@ -33,4 +43,13 @@ function ServiceViewModel(data) { this.Checked = false; this.Scale = false; this.EditName = false; + + if (data.Portainer) { + this.Metadata = {}; + if (data.Portainer.ResourceControl) { + this.Metadata.ResourceControl = { + OwnerId: data.Portainer.ResourceControl.OwnerId + }; + } + } } diff --git a/app/models/template.js b/app/models/template.js new file mode 100644 index 000000000..e7e5ae4a6 --- /dev/null +++ b/app/models/template.js @@ -0,0 +1,31 @@ +function TemplateViewModel(data) { + this.Title = data.title; + this.Description = data.description; + this.Logo = data.logo; + this.Image = data.image; + this.Registry = data.registry ? data.registry : ''; + this.Command = data.command ? data.command : ''; + this.Network = data.network ? data.network : ''; + this.Env = data.env ? data.env : []; + this.Privileged = data.privileged ? data.privileged : false; + this.Volumes = []; + if (data.volumes) { + this.Volumes = data.volumes.map(function (v) { + return { + readOnly: false, + containerPath: v, + type: 'auto' + }; + }); + } + this.Ports = []; + if (data.ports) { + this.Ports = data.ports.map(function (p) { + var portAndProtocol = _.split(p, '/'); + return { + containerPort: portAndProtocol[0], + protocol: portAndProtocol[1] + }; + }); + } +} diff --git a/app/models/user.js b/app/models/user.js new file mode 100644 index 000000000..7b021284b --- /dev/null +++ b/app/models/user.js @@ -0,0 +1,11 @@ +function UserViewModel(data) { + this.Id = data.Id; + this.Username = data.Username; + this.RoleId = data.Role; + if (data.Role === 1) { + this.RoleName = "administrator"; + } else { + this.RoleName = "user"; + } + this.Checked = false; +} diff --git a/app/models/volume.js b/app/models/volume.js new file mode 100644 index 000000000..f46356ce5 --- /dev/null +++ b/app/models/volume.js @@ -0,0 +1,14 @@ +function VolumeViewModel(data) { + this.Id = data.Id; + this.Name = data.Name; + this.Driver = data.Driver; + this.Mountpoint = data.Mountpoint; + if (data.Portainer) { + this.Metadata = {}; + if (data.Portainer.ResourceControl) { + this.Metadata.ResourceControl = { + OwnerId: data.Portainer.ResourceControl.OwnerId + }; + } + } +} diff --git a/app/rest/container.js b/app/rest/container.js index a3e4d3874..4130a5592 100644 --- a/app/rest/container.js +++ b/app/rest/container.js @@ -1,9 +1,11 @@ angular.module('portainer.rest') -.factory('Container', ['$resource', 'Settings', function ContainerFactory($resource, Settings) { +.factory('Container', ['$resource', 'Settings', 'EndpointProvider', function ContainerFactory($resource, Settings, EndpointProvider) { 'use strict'; - return $resource(Settings.url + '/containers/:id/:action', { - name: '@name' - }, { + return $resource(Settings.url + '/:endpointId/containers/:id/:action', { + name: '@name', + endpointId: EndpointProvider.endpointID + }, + { query: {method: 'GET', params: {all: 0, action: 'json', filters: '@filters' }, isArray: true}, get: {method: 'GET', params: {action: 'json'}}, stop: {method: 'POST', params: {id: '@id', t: 5, action: 'stop'}}, @@ -11,7 +13,6 @@ angular.module('portainer.rest') kill: {method: 'POST', params: {id: '@id', action: 'kill'}}, pause: {method: 'POST', params: {id: '@id', action: 'pause'}}, unpause: {method: 'POST', params: {id: '@id', action: 'unpause'}}, - changes: {method: 'GET', params: {action: 'changes'}, isArray: true}, stats: {method: 'GET', params: {id: '@id', stream: false, action: 'stats'}, timeout: 5000}, start: { method: 'POST', params: {id: '@id', action: 'start'}, diff --git a/app/rest/containerCommit.js b/app/rest/containerCommit.js index 3fdb0c83d..a3e13331a 100644 --- a/app/rest/containerCommit.js +++ b/app/rest/containerCommit.js @@ -1,7 +1,10 @@ angular.module('portainer.rest') -.factory('ContainerCommit', ['$resource', 'Settings', function ContainerCommitFactory($resource, Settings) { +.factory('ContainerCommit', ['$resource', 'Settings', 'EndpointProvider', function ContainerCommitFactory($resource, Settings, EndpointProvider) { 'use strict'; - return $resource(Settings.url + '/commit', {}, { + return $resource(Settings.url + '/:endpointId/commit', { + endpointId: EndpointProvider.endpointID + }, + { commit: {method: 'POST', params: {container: '@id', repo: '@repo', tag: '@tag'}} }); }]); diff --git a/app/rest/containerLogs.js b/app/rest/containerLogs.js index a9c07f583..cc4f8408e 100644 --- a/app/rest/containerLogs.js +++ b/app/rest/containerLogs.js @@ -1,11 +1,11 @@ angular.module('portainer.rest') -.factory('ContainerLogs', ['$http', 'Settings', function ContainerLogsFactory($http, Settings) { +.factory('ContainerLogs', ['$http', 'Settings', 'EndpointProvider', function ContainerLogsFactory($http, Settings, EndpointProvider) { 'use strict'; return { get: function (id, params, callback) { $http({ method: 'GET', - url: Settings.url + '/containers/' + id + '/logs', + url: Settings.url + '/' + EndpointProvider.endpointID() + '/containers/' + id + '/logs', params: { 'stdout': params.stdout || 0, 'stderr': params.stderr || 0, diff --git a/app/rest/containerTop.js b/app/rest/containerTop.js index 7515452a8..050f5653f 100644 --- a/app/rest/containerTop.js +++ b/app/rest/containerTop.js @@ -1,11 +1,11 @@ angular.module('portainer.rest') -.factory('ContainerTop', ['$http', 'Settings', function ($http, Settings) { +.factory('ContainerTop', ['$http', 'Settings', 'EndpointProvider', function ($http, Settings, EndpointProvider) { 'use strict'; return { get: function (id, params, callback, errorCallback) { $http({ method: 'GET', - url: Settings.url + '/containers/' + id + '/top', + url: Settings.url + '/' + EndpointProvider.endpointID() + '/containers/' + id + '/top', params: { ps_args: params.ps_args } diff --git a/app/rest/endpoint.js b/app/rest/endpoint.js index d5eb432a3..5919c0b61 100644 --- a/app/rest/endpoint.js +++ b/app/rest/endpoint.js @@ -6,8 +6,7 @@ angular.module('portainer.rest') query: { method: 'GET', isArray: true }, get: { method: 'GET', params: { id: '@id' } }, update: { method: 'PUT', params: { id: '@id' } }, + updateAccess: { method: 'PUT', params: { id: '@id', action: 'access' } }, remove: { method: 'DELETE', params: { id: '@id'} }, - getActiveEndpoint: { method: 'GET', params: { id: '0' } }, - setActiveEndpoint: { method: 'POST', params: { id: '@id', action: 'active' } } }); }]); diff --git a/app/rest/event.js b/app/rest/event.js index 9dbb80e3b..ddc5aa436 100644 --- a/app/rest/event.js +++ b/app/rest/event.js @@ -1,7 +1,10 @@ angular.module('portainer.rest') -.factory('Events', ['$resource', 'Settings', function EventFactory($resource, Settings) { +.factory('Events', ['$resource', 'Settings', 'EndpointProvider', function EventFactory($resource, Settings, EndpointProvider) { 'use strict'; - return $resource(Settings.url + '/events', {}, { + return $resource(Settings.url + '/:endpointId/events', { + endpointId: EndpointProvider.endpointID + }, + { query: { method: 'GET', params: {since: '@since', until: '@until'}, isArray: true, transformResponse: jsonObjectsToArrayHandler diff --git a/app/rest/exec.js b/app/rest/exec.js index 25ed67b47..c354090ba 100644 --- a/app/rest/exec.js +++ b/app/rest/exec.js @@ -1,7 +1,10 @@ angular.module('portainer.rest') -.factory('Exec', ['$resource', 'Settings', function ExecFactory($resource, Settings) { +.factory('Exec', ['$resource', 'Settings', 'EndpointProvider', function ExecFactory($resource, Settings, EndpointProvider) { 'use strict'; - return $resource(Settings.url + '/exec/:id/:action', {}, { + return $resource(Settings.url + '/:endpointId/exec/:id/:action', { + endpointId: EndpointProvider.endpointID + }, + { resize: { method: 'POST', params: {id: '@id', action: 'resize', h: '@height', w: '@width'}, transformResponse: genericHandler diff --git a/app/rest/image.js b/app/rest/image.js index 9361136fc..41a76c803 100644 --- a/app/rest/image.js +++ b/app/rest/image.js @@ -1,7 +1,10 @@ angular.module('portainer.rest') -.factory('Image', ['$resource', 'Settings', function ImageFactory($resource, Settings) { +.factory('Image', ['$resource', 'Settings', 'EndpointProvider', function ImageFactory($resource, Settings, EndpointProvider) { 'use strict'; - return $resource(Settings.url + '/images/:id/:action', {}, { + return $resource(Settings.url + '/:endpointId/images/:id/:action', { + endpointId: EndpointProvider.endpointID + }, + { query: {method: 'GET', params: {all: 0, action: 'json'}, isArray: true}, get: {method: 'GET', params: {action: 'json'}}, search: {method: 'GET', params: {action: 'search'}}, @@ -18,7 +21,7 @@ angular.module('portainer.rest') isArray: true, transformResponse: jsonObjectsToArrayHandler }, remove: { - method: 'DELETE', params: {id: '@id'}, + method: 'DELETE', params: {id: '@id', force: '@force'}, isArray: true, transformResponse: deleteImageHandler } }); diff --git a/app/rest/info.js b/app/rest/info.js index d138a341a..007a4444a 100644 --- a/app/rest/info.js +++ b/app/rest/info.js @@ -1,5 +1,7 @@ angular.module('portainer.rest') -.factory('Info', ['$resource', 'Settings', function InfoFactory($resource, Settings) { +.factory('Info', ['$resource', 'Settings', 'EndpointProvider', function InfoFactory($resource, Settings, EndpointProvider) { 'use strict'; - return $resource(Settings.url + '/info', {}); + return $resource(Settings.url + '/:endpointId/info', { + endpointId: EndpointProvider.endpointID + }); }]); diff --git a/app/rest/network.js b/app/rest/network.js index 793463d12..030c23753 100644 --- a/app/rest/network.js +++ b/app/rest/network.js @@ -1,7 +1,11 @@ angular.module('portainer.rest') -.factory('Network', ['$resource', 'Settings', function NetworkFactory($resource, Settings) { +.factory('Network', ['$resource', 'Settings', 'EndpointProvider', function NetworkFactory($resource, Settings, EndpointProvider) { 'use strict'; - return $resource(Settings.url + '/networks/:id/:action', {id: '@id'}, { + return $resource(Settings.url + '/:endpointId/networks/:id/:action', { + id: '@id', + endpointId: EndpointProvider.endpointID + }, + { query: {method: 'GET', isArray: true}, get: {method: 'GET'}, create: {method: 'POST', params: {action: 'create'}, transformResponse: genericHandler}, diff --git a/app/rest/node.js b/app/rest/node.js index e1896059a..6d300f3fe 100644 --- a/app/rest/node.js +++ b/app/rest/node.js @@ -1,7 +1,10 @@ angular.module('portainer.rest') -.factory('Node', ['$resource', 'Settings', function NodeFactory($resource, Settings) { +.factory('Node', ['$resource', 'Settings', 'EndpointProvider', function NodeFactory($resource, Settings, EndpointProvider) { 'use strict'; - return $resource(Settings.url + '/nodes/:id/:action', {}, { + return $resource(Settings.url + '/:endpointId/nodes/:id/:action', { + endpointId: EndpointProvider.endpointID + }, + { query: {method: 'GET', isArray: true}, get: {method: 'GET', params: {id: '@id'}}, update: { method: 'POST', params: {id: '@id', action: 'update', version: '@version'} }, diff --git a/app/rest/resourceControl.js b/app/rest/resourceControl.js new file mode 100644 index 000000000..7734429e6 --- /dev/null +++ b/app/rest/resourceControl.js @@ -0,0 +1,8 @@ +angular.module('portainer.rest') +.factory('ResourceControl', ['$resource', 'USERS_ENDPOINT', function ResourceControlFactory($resource, USERS_ENDPOINT) { + 'use strict'; + return $resource(USERS_ENDPOINT + '/:userId/resources/:resourceType/:resourceId', {}, { + create: { method: 'POST', params: { userId: '@userId', resourceType: '@resourceType' } }, + remove: { method: 'DELETE', params: { userId: '@userId', resourceId: '@resourceId', resourceType: '@resourceType' } }, + }); +}]); diff --git a/app/rest/service.js b/app/rest/service.js index 904998438..2a5ca1f30 100644 --- a/app/rest/service.js +++ b/app/rest/service.js @@ -1,7 +1,10 @@ angular.module('portainer.rest') -.factory('Service', ['$resource', 'Settings', function ServiceFactory($resource, Settings) { +.factory('Service', ['$resource', 'Settings', 'EndpointProvider', function ServiceFactory($resource, Settings, EndpointProvider) { 'use strict'; - return $resource(Settings.url + '/services/:id/:action', {}, { + return $resource(Settings.url + '/:endpointId/services/:id/:action', { + endpointId: EndpointProvider.endpointID + }, + { get: { method: 'GET', params: {id: '@id'} }, query: { method: 'GET', isArray: true }, create: { method: 'POST', params: {action: 'create'} }, diff --git a/app/rest/swarm.js b/app/rest/swarm.js index e0cc40e6b..134827a0f 100644 --- a/app/rest/swarm.js +++ b/app/rest/swarm.js @@ -1,7 +1,10 @@ angular.module('portainer.rest') -.factory('Swarm', ['$resource', 'Settings', function SwarmFactory($resource, Settings) { +.factory('Swarm', ['$resource', 'Settings', 'EndpointProvider', function SwarmFactory($resource, Settings, EndpointProvider) { 'use strict'; - return $resource(Settings.url + '/swarm', {}, { + return $resource(Settings.url + '/:endpointId/swarm', { + endpointId: EndpointProvider.endpointID + }, + { get: {method: 'GET'} }); }]); diff --git a/app/rest/task.js b/app/rest/task.js index 91a8c2a93..2fe03b502 100644 --- a/app/rest/task.js +++ b/app/rest/task.js @@ -1,7 +1,10 @@ angular.module('portainer.rest') -.factory('Task', ['$resource', 'Settings', function TaskFactory($resource, Settings) { +.factory('Task', ['$resource', 'Settings', 'EndpointProvider', function TaskFactory($resource, Settings, EndpointProvider) { 'use strict'; - return $resource(Settings.url + '/tasks/:id', {}, { + return $resource(Settings.url + '/:endpointId/tasks/:id', { + endpointId: EndpointProvider.endpointID + }, + { get: { method: 'GET', params: {id: '@id'} }, query: { method: 'GET', isArray: true, params: {filters: '@filters'} } }); diff --git a/app/rest/templates.js b/app/rest/template.js similarity index 52% rename from app/rest/templates.js rename to app/rest/template.js index 412e5a9f5..ea02b7ade 100644 --- a/app/rest/templates.js +++ b/app/rest/template.js @@ -1,5 +1,5 @@ angular.module('portainer.rest') -.factory('Templates', ['$resource', 'TEMPLATES_ENDPOINT', function TemplatesFactory($resource, TEMPLATES_ENDPOINT) { +.factory('Template', ['$resource', 'TEMPLATES_ENDPOINT', function TemplateFactory($resource, TEMPLATES_ENDPOINT) { return $resource(TEMPLATES_ENDPOINT, {}, { get: {method: 'GET', isArray: true} }); diff --git a/app/rest/user.js b/app/rest/user.js index a67d040a9..cc55f448d 100644 --- a/app/rest/user.js +++ b/app/rest/user.js @@ -1,12 +1,15 @@ angular.module('portainer.rest') .factory('Users', ['$resource', 'USERS_ENDPOINT', function UsersFactory($resource, USERS_ENDPOINT) { 'use strict'; - return $resource(USERS_ENDPOINT + '/:username/:action', {}, { + return $resource(USERS_ENDPOINT + '/:id/:action', {}, { create: { method: 'POST' }, - get: { method: 'GET', params: { username: '@username' } }, - update: { method: 'PUT', params: { username: '@username' } }, - checkPassword: { method: 'POST', params: { username: '@username', action: 'passwd' } }, - checkAdminUser: { method: 'GET', params: { username: 'admin', action: 'check' } }, - initAdminUser: { method: 'POST', params: { username: 'admin', action: 'init' } } + query: { method: 'GET', isArray: true }, + get: { method: 'GET', params: { id: '@id' } }, + update: { method: 'PUT', params: { id: '@id' } }, + remove: { method: 'DELETE', params: { id: '@id'} }, + // RPCs should be moved to a specific endpoint + checkPassword: { method: 'POST', params: { id: '@id', action: 'passwd' } }, + checkAdminUser: { method: 'GET', params: { id: 'admin', action: 'check' }, isArray: true }, + initAdminUser: { method: 'POST', params: { id: 'admin', action: 'init' } } }); }]); diff --git a/app/rest/version.js b/app/rest/version.js index 29ff766f0..d6bd6fb37 100644 --- a/app/rest/version.js +++ b/app/rest/version.js @@ -1,5 +1,7 @@ angular.module('portainer.rest') -.factory('Version', ['$resource', 'Settings', function VersionFactory($resource, Settings) { +.factory('Version', ['$resource', 'Settings', 'EndpointProvider', function VersionFactory($resource, Settings, EndpointProvider) { 'use strict'; - return $resource(Settings.url + '/version', {}); + return $resource(Settings.url + '/:endpointId/version', { + endpointId: EndpointProvider.endpointID + }); }]); diff --git a/app/rest/volume.js b/app/rest/volume.js index dd3f94ad0..132fdec01 100644 --- a/app/rest/volume.js +++ b/app/rest/volume.js @@ -1,7 +1,12 @@ angular.module('portainer.rest') -.factory('Volume', ['$resource', 'Settings', function VolumeFactory($resource, Settings) { +.factory('Volume', ['$resource', 'Settings', 'EndpointProvider', function VolumeFactory($resource, Settings, EndpointProvider) { 'use strict'; - return $resource(Settings.url + '/volumes/:name/:action', {name: '@name'}, { + return $resource(Settings.url + '/:endpointId/volumes/:name/:action', + { + name: '@name', + endpointId: EndpointProvider.endpointID + }, + { query: {method: 'GET'}, get: {method: 'GET'}, create: {method: 'POST', params: {action: 'create'}, transformResponse: genericHandler}, diff --git a/app/services/authentication.js b/app/services/authentication.js index d9f58b844..9fee8e7de 100644 --- a/app/services/authentication.js +++ b/app/services/authentication.js @@ -1,14 +1,16 @@ angular.module('portainer.services') -.factory('Authentication', ['$q', 'Auth', 'jwtHelper', 'LocalStorage', 'StateManager', function AuthenticationFactory($q, Auth, jwtHelper, LocalStorage, StateManager) { +.factory('Authentication', ['$q', 'Auth', 'jwtHelper', 'LocalStorage', 'StateManager', 'EndpointProvider', function AuthenticationFactory($q, Auth, jwtHelper, LocalStorage, StateManager, EndpointProvider) { 'use strict'; - var credentials = {}; + var user = {}; return { init: function() { var jwt = LocalStorage.getJWT(); if (jwt) { var tokenPayload = jwtHelper.decodeToken(jwt); - credentials.username = tokenPayload.username; + user.username = tokenPayload.username; + user.ID = tokenPayload.id; + user.role = tokenPayload.role; } }, login: function(username, password) { @@ -16,7 +18,10 @@ angular.module('portainer.services') Auth.login({username: username, password: password}).$promise .then(function(data) { LocalStorage.storeJWT(data.jwt); - credentials.username = username; + var tokenPayload = jwtHelper.decodeToken(data.jwt); + user.username = username; + user.ID = tokenPayload.id; + user.role = tokenPayload.role; resolve(); }, function() { reject(); @@ -25,14 +30,15 @@ angular.module('portainer.services') }, logout: function() { StateManager.clean(); + EndpointProvider.clean(); LocalStorage.clean(); }, isAuthenticated: function() { var jwt = LocalStorage.getJWT(); return jwt && !jwtHelper.isTokenExpired(jwt); }, - getCredentials: function() { - return credentials; + getUserDetails: function() { + return user; } }; }]); diff --git a/app/services/containerService.js b/app/services/containerService.js new file mode 100644 index 000000000..c3316c549 --- /dev/null +++ b/app/services/containerService.js @@ -0,0 +1,71 @@ +angular.module('portainer.services') +.factory('ContainerService', ['$q', 'Container', 'ContainerHelper', function ContainerServiceFactory($q, Container, ContainerHelper) { + 'use strict'; + var service = {}; + + service.getContainers = function (all, hiddenLabels) { + var deferred = $q.defer(); + Container.query({ all: all }).$promise + .then(function success(data) { + var containers = data; + if (hiddenLabels) { + containers = ContainerHelper.hideContainers(d, hiddenLabels); + } + deferred.resolve(data); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retriever containers', err: err }); + }); + return deferred.promise; + }; + + service.createContainer = function(configuration) { + var deferred = $q.defer(); + Container.create(configuration).$promise + .then(function success(data) { + if (data.message) { + deferred.reject({ msg: data.message }); + } else { + deferred.resolve(data); + } + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to create container', err: err }); + }); + return deferred.promise; + }; + + service.startContainer = function(containerID) { + var deferred = $q.defer(); + Container.start({ id: containerID }, {}).$promise + .then(function success(data) { + if (data.message) { + deferred.reject({ msg: data.message }); + } else { + deferred.resolve(data); + } + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to start container', err: err }); + }); + return deferred.promise; + }; + + service.createAndStartContainer = function(configuration) { + var deferred = $q.defer(); + var containerID; + service.createContainer(configuration) + .then(function success(data) { + containerID = data.Id; + return service.startContainer(containerID); + }) + .then(function success() { + deferred.resolve({ Id: containerID }); + }) + .catch(function error(err) { + deferred.reject(err); + }); + return deferred.promise; + }; + return service; +}]); diff --git a/app/services/endpointProvider.js b/app/services/endpointProvider.js new file mode 100644 index 000000000..e6d43f604 --- /dev/null +++ b/app/services/endpointProvider.js @@ -0,0 +1,23 @@ +angular.module('portainer.services') +.factory('EndpointProvider', ['LocalStorage', function EndpointProviderFactory(LocalStorage) { + 'use strict'; + var endpoint = {}; + var service = {}; + service.initialize = function() { + var endpointID = LocalStorage.getEndpointID(); + if (endpointID) { + endpoint.ID = endpointID; + } + }; + service.clean = function() { + endpoint = {}; + }; + service.endpointID = function() { + return endpoint.ID; + }; + service.setEndpointID = function(id) { + endpoint.ID = id; + LocalStorage.storeEndpointID(id); + }; + return service; +}]); diff --git a/app/services/endpointService.js b/app/services/endpointService.js index 511004b90..9531cceff 100644 --- a/app/services/endpointService.js +++ b/app/services/endpointService.js @@ -1,84 +1,92 @@ angular.module('portainer.services') .factory('EndpointService', ['$q', 'Endpoints', 'FileUploadService', function EndpointServiceFactory($q, Endpoints, FileUploadService) { 'use strict'; - return { - getActive: function() { - return Endpoints.getActiveEndpoint().$promise; - }, - setActive: function(endpointID) { - return Endpoints.setActiveEndpoint({id: endpointID}).$promise; - }, - endpoint: function(endpointID) { - return Endpoints.get({id: endpointID}).$promise; - }, - endpoints: function() { - return Endpoints.query({}).$promise; - }, - updateEndpoint: function(ID, name, URL, TLS, TLSCAFile, TLSCertFile, TLSKeyFile, type) { - var endpoint = { - id: ID, - Name: name, - URL: type === 'local' ? ("unix://" + URL) : ("tcp://" + URL), - TLS: TLS - }; - var deferred = $q.defer(); - Endpoints.update({}, endpoint, function success(data) { - FileUploadService.uploadTLSFilesForEndpoint(ID, TLSCAFile, TLSCertFile, TLSKeyFile).then(function success(data) { + var service = {}; + + service.endpoint = function(endpointID) { + return Endpoints.get({id: endpointID}).$promise; + }; + + service.endpoints = function() { + return Endpoints.query({}).$promise; + }; + + service.updateAuthorizedUsers = function(id, authorizedUserIDs) { + return Endpoints.updateAccess({id: id}, {authorizedUsers: authorizedUserIDs}).$promise; + }; + + service.updateEndpoint = function(id, endpointParams) { + var query = { + name: endpointParams.name, + TLS: endpointParams.TLS, + authorizedUsers: endpointParams.authorizedUsers + }; + if (endpointParams.type && endpointParams.URL) { + query.URL = endpointParams.type === 'local' ? ("unix://" + endpointParams.URL) : ("tcp://" + endpointParams.URL); + } + var deferred = $q.defer(); + Endpoints.update({id: id}, query).$promise + .then(function success() { + return FileUploadService.uploadTLSFilesForEndpoint(id, endpointParams.TLSCAFile, endpointParams.TLSCertFile, endpointParams.TLSKeyFile); + }) + .then(function success(data) { + deferred.notify({upload: false}); + deferred.resolve(data); + }) + .catch(function error(err) { + deferred.notify({upload: false}); + deferred.reject({msg: 'Unable to update endpoint', err: err}); + }); + return deferred.promise; + }; + + service.deleteEndpoint = function(endpointID) { + return Endpoints.remove({id: endpointID}).$promise; + }; + + service.createLocalEndpoint = function(name, URL, TLS, active) { + var endpoint = { + Name: "local", + URL: "unix:///var/run/docker.sock", + TLS: false + }; + return Endpoints.create({}, endpoint).$promise; + }; + + service.createRemoteEndpoint = function(name, URL, TLS, TLSCAFile, TLSCertFile, TLSKeyFile, active) { + var endpoint = { + Name: name, + URL: 'tcp://' + URL, + TLS: TLS + }; + var deferred = $q.defer(); + Endpoints.create({active: active}, endpoint, function success(data) { + var endpointID = data.Id; + if (TLS) { + deferred.notify({upload: true}); + FileUploadService.uploadTLSFilesForEndpoint(endpointID, TLSCAFile, TLSCertFile, TLSKeyFile).then(function success(data) { deferred.notify({upload: false}); - deferred.resolve(data); + if (active) { + Endpoints.setActiveEndpoint({}, {id: endpointID}, function success(data) { + deferred.resolve(data); + }, function error(err) { + deferred.reject({msg: 'Unable to create endpoint', err: err}); + }); + } else { + deferred.resolve(data); + } }, function error(err) { deferred.notify({upload: false}); deferred.reject({msg: 'Unable to upload TLS certs', err: err}); }); - }, function error(err) { - deferred.reject({msg: 'Unable to update endpoint', err: err}); - }); - return deferred.promise; - }, - deleteEndpoint: function(endpointID) { - return Endpoints.remove({id: endpointID}).$promise; - }, - createLocalEndpoint: function(name, URL, TLS, active) { - var endpoint = { - Name: "local", - URL: "unix:///var/run/docker.sock", - TLS: false - }; - return Endpoints.create({active: active}, endpoint).$promise; - }, - createRemoteEndpoint: function(name, URL, TLS, TLSCAFile, TLSCertFile, TLSKeyFile, active) { - var endpoint = { - Name: name, - URL: 'tcp://' + URL, - TLS: TLS - }; - var deferred = $q.defer(); - Endpoints.create({active: active}, endpoint, function success(data) { - var endpointID = data.Id; - if (TLS) { - deferred.notify({upload: true}); - FileUploadService.uploadTLSFilesForEndpoint(endpointID, TLSCAFile, TLSCertFile, TLSKeyFile).then(function success(data) { - deferred.notify({upload: false}); - if (active) { - Endpoints.setActiveEndpoint({}, {id: endpointID}, function success(data) { - deferred.resolve(data); - }, function error(err) { - deferred.reject({msg: 'Unable to create endpoint', err: err}); - }); - } else { - deferred.resolve(data); - } - }, function error(err) { - deferred.notify({upload: false}); - deferred.reject({msg: 'Unable to upload TLS certs', err: err}); - }); - } else { - deferred.resolve(data); - } - }, function error(err) { - deferred.reject({msg: 'Unable to create endpoint', err: err}); - }); - return deferred.promise; - } + } else { + deferred.resolve(data); + } + }, function error(err) { + deferred.reject({msg: 'Unable to create endpoint', err: err}); + }); + return deferred.promise; }; + + return service; }]); diff --git a/app/services/entityListService.js b/app/services/entityListService.js new file mode 100644 index 000000000..f3eeb86f8 --- /dev/null +++ b/app/services/entityListService.js @@ -0,0 +1,15 @@ +angular.module('portainer.services') +.factory('EntityListService', [function EntityListServiceFactory() { + 'use strict'; + return { + rememberPreviousSelection: function(oldContainerList, model, onSelectCallback) { + var oldModel = _.find(oldContainerList, function(item){ + return item.Id === model.Id; + }); + if (oldModel && oldModel.Checked) { + model.Checked = true; + onSelectCallback(model); + } + } + }; +}]); diff --git a/app/services/fileUpload.js b/app/services/fileUpload.js index 89eddafc2..7811afed4 100644 --- a/app/services/fileUpload.js +++ b/app/services/fileUpload.js @@ -19,15 +19,15 @@ angular.module('portainer.services') var deferred = $q.defer(); var queue = []; - if (TLSCAFile !== null) { + if (TLSCAFile) { var uploadTLSCA = uploadFile('api/upload/tls/' + endpointID + '/ca', TLSCAFile); queue.push(uploadTLSCA); } - if (TLSCertFile !== null) { + if (TLSCertFile) { var uploadTLSCert = uploadFile('api/upload/tls/' + endpointID + '/cert', TLSCertFile); queue.push(uploadTLSCert); } - if (TLSKeyFile !== null) { + if (TLSKeyFile) { var uploadTLSKey = uploadFile('api/upload/tls/' + endpointID + '/key', TLSKeyFile); queue.push(uploadTLSKey); } diff --git a/app/services/imageService.js b/app/services/imageService.js new file mode 100644 index 000000000..bba649998 --- /dev/null +++ b/app/services/imageService.js @@ -0,0 +1,24 @@ +angular.module('portainer.services') +.factory('ImageService', ['$q', 'Image', function ImageServiceFactory($q, Image) { + 'use strict'; + var service = {}; + + service.pullImage = function(imageConfiguration) { + var deferred = $q.defer(); + Image.create(imageConfiguration).$promise + .then(function success(data) { + var err = data.length > 0 && data[data.length - 1].hasOwnProperty('error'); + if (err) { + var detail = data[data.length - 1]; + deferred.reject({ msg: detail.error }); + } else { + deferred.resolve(data); + } + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to pull image', err: err }); + }); + return deferred.promise; + }; + return service; +}]); diff --git a/app/services/localStorage.js b/app/services/localStorage.js index 5d8e413fd..acc6fe378 100644 --- a/app/services/localStorage.js +++ b/app/services/localStorage.js @@ -2,6 +2,12 @@ angular.module('portainer.services') .factory('LocalStorage', ['localStorageService', function LocalStorageFactory(localStorageService) { 'use strict'; return { + storeEndpointID: function(id) { + localStorageService.set('ENDPOINT_ID', id); + }, + getEndpointID: function() { + return localStorageService.get('ENDPOINT_ID'); + }, storeEndpointState: function(state) { localStorageService.set('ENDPOINT_STATE', state); }, diff --git a/app/services/modalService.js b/app/services/modalService.js new file mode 100644 index 000000000..b9d9453bb --- /dev/null +++ b/app/services/modalService.js @@ -0,0 +1,86 @@ +angular.module('portainer.services') +.factory('ModalService', [function ModalServiceFactory() { + 'use strict'; + var service = {}; + + service.confirm = function(options){ + var box = bootbox.confirm({ + title: options.title, + message: options.message, + buttons: { + confirm: { + label: options.buttons.confirm.label, + className: options.buttons.confirm.className + }, + cancel: { + label: options.buttons.cancel && options.buttons.cancel.label ? options.buttons.cancel.label : 'Cancel' + } + }, + callback: options.callback + }); + box.css({ + 'top': '50%', + 'margin-top': function () { + return -(box.height() / 2); + } + }); + }; + + service.confirmOwnershipChange = function(callback, msg) { + service.confirm({ + title: 'Are you sure ?', + message: msg, + buttons: { + confirm: { + label: 'Change ownership', + className: 'btn-primary' + } + }, + callback: callback, + }); + }; + + service.confirmContainerOwnershipChange = function(callback) { + var msg = 'You can change the ownership of a container one way only. You will not be able to make this container private again. Changing ownership on this container will also change the ownership on any attached volume.'; + service.confirmOwnershipChange(callback, msg); + }; + + service.confirmServiceOwnershipChange = function(callback) { + var msg = 'You can change the ownership of a service one way only. You will not be able to make this service private again. Changing ownership on this service will also change the ownership on any attached volume.'; + service.confirmOwnershipChange(callback, msg); + }; + + service.confirmVolumeOwnershipChange = function(callback) { + var msg = 'You can change the ownership of a volume one way only. You will not be able to make this volume private again.'; + service.confirmOwnershipChange(callback, msg); + }; + + service.confirmImageForceRemoval = function(callback) { + service.confirm({ + title: "Are you sure?", + message: "Forcing the removal of the image will remove the image even if it has multiple tags or if it is used by stopped containers.", + buttons: { + confirm: { + label: 'Remove the image', + className: 'btn-danger' + } + }, + callback: callback, + }); + }; + + service.confirmDeletion = function(message, callback) { + service.confirm({ + title: 'Are you sure ?', + message: message, + buttons: { + confirm: { + label: 'Delete', + className: 'btn-danger' + } + }, + callback: callback, + }); + }; + return service; +}]); diff --git a/app/services/networkService.js b/app/services/networkService.js new file mode 100644 index 000000000..256be1141 --- /dev/null +++ b/app/services/networkService.js @@ -0,0 +1,25 @@ +angular.module('portainer.services') +.factory('NetworkService', ['$q', 'Network', function NetworkServiceFactory($q, Network) { + 'use strict'; + var service = {}; + + service.getNetworks = function() { + return Network.query({}).$promise; + }; + + service.filterGlobalNetworks = function(networks) { + return networks.filter(function (network) { + if (network.Scope === 'global') { + 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/resourceControlService.js b/app/services/resourceControlService.js new file mode 100644 index 000000000..3b30c2680 --- /dev/null +++ b/app/services/resourceControlService.js @@ -0,0 +1,31 @@ +angular.module('portainer.services') +.factory('ResourceControlService', ['$q', 'ResourceControl', function ResourceControlServiceFactory($q, ResourceControl) { + 'use strict'; + var service = {}; + + service.setContainerResourceControl = function(userID, resourceID) { + return ResourceControl.create({ userId: userID, resourceType: 'container' }, { ResourceID: resourceID }).$promise; + }; + + service.removeContainerResourceControl = function(userID, resourceID) { + return ResourceControl.remove({ userId: userID, resourceId: resourceID, resourceType: 'container' }).$promise; + }; + + service.setServiceResourceControl = function(userID, resourceID) { + return ResourceControl.create({ userId: userID, resourceType: 'service' }, { ResourceID: resourceID }).$promise; + }; + + service.removeServiceResourceControl = function(userID, resourceID) { + return ResourceControl.remove({ userId: userID, resourceId: resourceID, resourceType: 'service' }).$promise; + }; + + service.setVolumeResourceControl = function(userID, resourceID) { + return ResourceControl.create({ userId: userID, resourceType: 'volume' }, { ResourceID: resourceID }).$promise; + }; + + service.removeVolumeResourceControl = function(userID, resourceID) { + return ResourceControl.remove({ userId: userID, resourceId: resourceID, resourceType: 'volume' }).$promise; + }; + + return service; +}]); diff --git a/app/services/stateManager.js b/app/services/stateManager.js index 573747984..c17dae338 100644 --- a/app/services/stateManager.js +++ b/app/services/stateManager.js @@ -24,6 +24,7 @@ angular.module('portainer.services') } else { Config.$promise.then(function success(data) { state.application.authentication = data.authentication; + state.application.analytics = data.analytics; state.application.endpointManagement = data.endpointManagement; state.application.logo = data.logo; LocalStorage.storeApplicationState(state.application); diff --git a/app/services/templateService.js b/app/services/templateService.js new file mode 100644 index 000000000..79958eb1c --- /dev/null +++ b/app/services/templateService.js @@ -0,0 +1,63 @@ +angular.module('portainer.services') +.factory('TemplateService', ['$q', 'Template', 'TemplateHelper', 'ImageHelper', 'ContainerHelper', function TemplateServiceFactory($q, Template, TemplateHelper, ImageHelper, ContainerHelper) { + 'use strict'; + var service = {}; + + service.getTemplates = function() { + var deferred = $q.defer(); + Template.get().$promise + .then(function success(data) { + var templates = data.map(function (tpl, idx) { + var template = new TemplateViewModel(tpl); + template.index = idx; + return template; + }); + deferred.resolve(templates); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve templates', err: err }); + }); + return deferred.promise; + }; + + service.createTemplateConfiguration = function(template, containerName, network, containerMapping) { + var imageConfiguration = service.createImageConfiguration(template); + var containerConfiguration = service.createContainerConfiguration(template, containerName, network, containerMapping); + containerConfiguration.Image = imageConfiguration.fromImage + ':' + imageConfiguration.tag; + return { + container: containerConfiguration, + image: imageConfiguration + }; + }; + + service.createImageConfiguration = function(template) { + return ImageHelper.createImageConfigForContainer(template.Image, template.Registry); + }; + + service.createContainerConfiguration = function(template, containerName, network, containerMapping) { + var configuration = TemplateHelper.getDefaultContainerConfiguration(); + configuration.HostConfig.NetworkMode = network.Name; + configuration.HostConfig.Privileged = template.Privileged; + configuration.name = containerName; + configuration.Image = template.Image; + configuration.Env = TemplateHelper.EnvToStringArray(template.Env, containerMapping); + configuration.Cmd = ContainerHelper.commandStringToArray(template.Command); + var portConfiguration = TemplateHelper.portArrayToPortConfiguration(template.Ports); + configuration.HostConfig.PortBindings = portConfiguration.bindings; + configuration.ExposedPorts = portConfiguration.exposedPorts; + return configuration; + }; + + service.updateContainerConfigurationWithVolumes = function(configuration, template, generatedVolumesPile) { + var volumes = template.Volumes; + TemplateHelper.createVolumeBindings(volumes, generatedVolumesPile); + volumes.forEach(function (volume) { + if (volume.binding) { + configuration.Volumes[volume.containerPath] = {}; + configuration.HostConfig.Binds.push(volume.binding); + } + }); + }; + + return service; +}]); diff --git a/app/services/userService.js b/app/services/userService.js new file mode 100644 index 000000000..a836d298d --- /dev/null +++ b/app/services/userService.js @@ -0,0 +1,48 @@ +angular.module('portainer.services') +.factory('UserService', ['$q', 'Users', function UserServiceFactory($q, Users) { + 'use strict'; + var service = {}; + service.users = function() { + return Users.query({}).$promise; + }; + + service.user = function(id) { + return Users.get({id: id}).$promise; + }; + + service.createUser = function(username, password, role) { + return Users.create({}, {username: username, password: password, role: role}).$promise; + }; + + service.deleteUser = function(id) { + return Users.remove({id: id}).$promise; + }; + + service.updateUser = function(id, password, role) { + var query = { + password: password, + role: role + }; + return Users.update({id: id}, query).$promise; + }; + + service.updateUserPassword = function(id, currentPassword, newPassword) { + var deferred = $q.defer(); + Users.checkPassword({id: id}, {password: currentPassword}).$promise + .then(function success(data) { + if (!data.valid) { + deferred.reject({invalidPassword: true}); + } + return service.updateUser(id, newPassword, undefined); + }) + .then(function success(data) { + deferred.resolve(); + }) + .catch(function error(err) { + deferred.reject({msg: 'Unable to update user password', err: err}); + }); + return deferred.promise; + }; + + return service; +}]); diff --git a/app/services/volumeService.js b/app/services/volumeService.js new file mode 100644 index 000000000..d889ce493 --- /dev/null +++ b/app/services/volumeService.js @@ -0,0 +1,64 @@ +angular.module('portainer.services') +.factory('VolumeService', ['$q', 'Volume', function VolumeServiceFactory($q, Volume) { + 'use strict'; + var service = {}; + + service.getVolumes = function() { + return Volume.query({}).$promise; + }; + + function prepareVolumeQueries(template, containerConfig) { + var volumeQueries = []; + if (template.volumes) { + template.volumes.forEach(function (vol) { + volumeQueries.push( + Volume.create({}, function (d) { + if (d.message) { + Messages.error("Unable to create volume", {}, d.message); + } else { + Messages.send("Volume created", d.Name); + containerConfig.Volumes[vol] = {}; + containerConfig.HostConfig.Binds.push(d.Name + ':' + vol); + } + }, function (e) { + Messages.error("Failure", e, "Unable to create volume"); + }).$promise + ); + }); + } + return volumeQueries; + } + + service.createVolume = function(volumeConfiguration) { + var deferred = $q.defer(); + Volume.create(volumeConfiguration).$promise + .then(function success(data) { + if (data.message) { + deferred.reject({ msg: data.message }); + } else { + deferred.resolve(data); + } + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to create volume', err: err }); + }); + return deferred.promise; + }; + + service.createVolumes = function(volumes) { + var createVolumeQueries = volumes.map(function(volume) { + return service.createVolume(volume); + }); + return $q.all(createVolumeQueries); + }; + + service.createXAutoGeneratedLocalVolumes = function (x) { + var createVolumeQueries = []; + for (var i = 0; i < x; i++) { + createVolumeQueries.push(service.createVolume({})); + } + return $q.all(createVolumeQueries); + }; + + return service; +}]); diff --git a/assets/css/app.css b/assets/css/app.css index cce7e440b..6e3b3db30 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -72,6 +72,10 @@ input[type="radio"] { vertical-align: middle; } +a[ng-click]{ + cursor: pointer; +} + .space-right { margin-right: 5px; } @@ -80,6 +84,26 @@ input[type="radio"] { color: #23ae89; } +.tooltip.portainer-tooltip .tooltip-inner { + font-family: Montserrat; + background-color: #ffffff; + padding: .833em 1em; + color: #333333; + border: 1px solid #d4d4d5; + border-radius: .14285714rem; + box-shadow: 0 2px 4px 0 rgba(34,36,38,.12),0 2px 10px 0 rgba(34,36,38,.15); +} + +.tooltip.portainer-tooltip .tooltip-arrow { + display: none; +} + +.fa.tooltip-icon { + margin-left: 5px; + font-size: 1.3em; + color: #337ab7; +} + .fa.red-icon { color: #ae2323; } @@ -268,3 +292,40 @@ ul.sidebar .sidebar-list a.active { border-left: 3px solid #fff; background: #2d3e63; } + +@media (min-width: 768px) { + .pull-sm-left { + float: left !important; + } + .pull-sm-right { + float: right !important; + } + .pull-sm-none { + float: none !important; + } +} +@media (min-width: 992px) { + .pull-md-left { + float: left !important; + } + .pull-md-right { + float: right !important; + } + .pull-md-none { + float: none !important; + } +} +@media (min-width: 1200px) { + .pull-lg-left { + float: left !important; + } + .pull-lg-right { + float: right !important; + } + .pull-lg-none { + float: none !important; + } +} +.pull-none { + float: none !important; +} diff --git a/bower.json b/bower.json index 5fb8e2982..c17158184 100644 --- a/bower.json +++ b/bower.json @@ -1,6 +1,6 @@ { "name": "portainer", - "version": "1.11.4", + "version": "1.12.0", "homepage": "https://github.com/portainer/portainer", "authors": [ "Anthony Lapenna " @@ -12,7 +12,8 @@ "api", "portainer", "uifordocker", - "dockerui" + "dockerui", + "swarm" ], "license": "MIT", "ignore": [ @@ -26,7 +27,7 @@ "Chart.js": "1.0.2", "angular": "~1.5.0", "angular-cookies": "~1.5.0", - "angular-bootstrap": "~1.0.3", + "angular-bootstrap": "~2.5.0", "angular-ui-router": "^0.2.15", "angular-sanitize": "~1.5.0", "angular-mocks": "~1.5.0", @@ -35,6 +36,7 @@ "angular-utils-pagination": "~0.11.1", "angular-local-storage": "~0.5.2", "angular-jwt": "~0.1.8", + "angular-google-analytics": "~1.1.9", "bootstrap": "~3.3.6", "filesize": "~3.3.0", "jquery": "1.11.1", @@ -44,7 +46,9 @@ "moment": "~2.14.1", "xterm.js": "~2.0.1", "font-awesome": "~4.7.0", - "ng-file-upload": "~12.2.13" + "ng-file-upload": "~12.2.13", + "splitargs": "~0.2.0", + "bootbox.js": "bootbox#^4.4.0" }, "resolutions": { "angular": "1.5.5" diff --git a/build.sh b/build.sh index 5b976fe9c..4e78cf178 100755 --- a/build.sh +++ b/build.sh @@ -7,37 +7,51 @@ if [[ $# -ne 1 ]] ; then exit 1 fi -grunt release -rm -rf /tmp/portainer-build-unix && mkdir -pv /tmp/portainer-build-unix/portainer -mv dist/* /tmp/portainer-build-unix/portainer -cd /tmp/portainer-build-unix && tar cvpfz portainer-${VERSION}-linux-amd64.tar.gz portainer -cd - +mkdir -pv /tmp/portainer-builds -grunt release-win -rm -rf /tmp/portainer-build-win && mkdir -pv /tmp/portainer-build-win/portainer -mv dist/* /tmp/portainer-build-win/portainer -cd /tmp/portainer-build-win -tar cvpfz portainer-${VERSION}-windows-amd64.tar.gz portainer +grunt release +docker build -t portainer/portainer:linux-amd64-${VERSION} -f build/linux/Dockerfile . +docker build -t portainer/portainer:linux-amd64 -f build/linux/Dockerfile . +rm -rf /tmp/portainer-builds/unix && mkdir -pv /tmp/portainer-builds/unix/portainer +mv dist/* /tmp/portainer-builds/unix/portainer +cd /tmp/portainer-builds/unix +tar cvpfz portainer-${VERSION}-linux-amd64.tar.gz portainer +mv portainer-${VERSION}-linux-amd64.tar.gz /tmp/portainer-builds/ cd - grunt release-arm -rm -rf /tmp/portainer-build-arm && mkdir -pv /tmp/portainer-build-arm/portainer -mv dist/* /tmp/portainer-build-arm/portainer -cd /tmp/portainer-build-arm +docker build -t portainer/portainer:linux-arm-${VERSION} -f build/linux/Dockerfile . +docker build -t portainer/portainer:linux-arm -f build/linux/Dockerfile . +rm -rf /tmp/portainer-builds/arm && mkdir -pv /tmp/portainer-builds/arm/portainer +mv dist/* /tmp/portainer-builds/arm/portainer +cd /tmp/portainer-builds/arm tar cvpfz portainer-${VERSION}-linux-arm.tar.gz portainer +mv portainer-${VERSION}-linux-arm.tar.gz /tmp/portainer-builds/ cd - grunt release-arm64 -rm -rf /tmp/portainer-build-arm64 && mkdir -pv /tmp/portainer-build-arm64/portainer -mv dist/* /tmp/portainer-build-arm64/portainer -cd /tmp/portainer-build-arm64 +docker build -t portainer/portainer:linux-arm64-${VERSION} -f build/linux/Dockerfile . +docker build -t portainer/portainer:linux-arm64 -f build/linux/Dockerfile . +rm -rf /tmp/portainer-builds/arm64 && mkdir -pv /tmp/portainer-builds/arm64/portainer +mv dist/* /tmp/portainer-builds/arm64/portainer +cd /tmp/portainer-builds/arm64 tar cvpfz portainer-${VERSION}-linux-arm64.tar.gz portainer +mv portainer-${VERSION}-linux-arm64.tar.gz /tmp/portainer-builds/ cd - grunt release-macos -rm -rf /tmp/portainer-build-darwin && mkdir -pv /tmp/portainer-build-darwin/portainer -mv dist/* /tmp/portainer-build-darwin/portainer -cd /tmp/portainer-build-darwin +rm -rf /tmp/portainer-builds/darwin && mkdir -pv /tmp/portainer-builds/darwin/portainer +mv dist/* /tmp/portainer-builds/darwin/portainer +cd /tmp/portainer-builds/darwin tar cvpfz portainer-${VERSION}-darwin-amd64.tar.gz portainer +mv portainer-${VERSION}-darwin-amd64.tar.gz /tmp/portainer-builds/ +cd - + +grunt release-win +rm -rf /tmp/portainer-builds/win && mkdir -pv /tmp/portainer-builds/win/portainer +mv dist/* /tmp/portainer-builds/win/portainer +cd /tmp/portainer-builds/win +tar cvpfz portainer-${VERSION}-windows-amd64.tar.gz portainer +mv portainer-${VERSION}-windows-amd64.tar.gz /tmp/portainer-builds/ exit 0 diff --git a/gruntfile.js b/gruntfile.js index 825749762..4abb9f753 100644 --- a/gruntfile.js +++ b/gruntfile.js @@ -13,6 +13,7 @@ module.exports = function (grunt) { grunt.loadNpmTasks('grunt-filerev'); grunt.loadNpmTasks('grunt-contrib-cssmin'); grunt.loadNpmTasks('grunt-usemin'); + grunt.loadNpmTasks('grunt-replace'); // Default task. grunt.registerTask('default', ['jshint', 'build']); @@ -42,7 +43,8 @@ module.exports = function (grunt) { 'copy:assets', 'filerev', 'usemin', - 'clean:tmp' + 'clean:tmp', + 'replace' ]); grunt.registerTask('release-win', [ 'clean:all', @@ -57,7 +59,8 @@ module.exports = function (grunt) { 'copy', 'filerev', 'usemin', - 'clean:tmp' + 'clean:tmp', + 'replace' ]); grunt.registerTask('release-arm', [ 'clean:all', @@ -72,7 +75,8 @@ module.exports = function (grunt) { 'copy', 'filerev', 'usemin', - 'clean:tmp' + 'clean:tmp', + 'replace' ]); grunt.registerTask('release-arm64', [ 'clean:all', @@ -87,7 +91,8 @@ module.exports = function (grunt) { 'copy', 'filerev', 'usemin', - 'clean:tmp' + 'clean:tmp', + 'replace' ]); grunt.registerTask('release-macos', [ 'clean:all', @@ -102,7 +107,8 @@ module.exports = function (grunt) { 'copy', 'filerev', 'usemin', - 'clean:tmp' + 'clean:tmp', + 'replace' ]); grunt.registerTask('lint', ['jshint']); grunt.registerTask('run', ['if:unixBinaryNotExist', 'build', 'shell:buildImage', 'shell:run']); @@ -129,9 +135,11 @@ module.exports = function (grunt) { 'bower_components/bootstrap/dist/js/bootstrap.min.js', 'bower_components/Chart.js/Chart.min.js', 'bower_components/lodash/dist/lodash.min.js', + 'bower_components/splitargs/src/splitargs.js', 'bower_components/filesize/lib/filesize.min.js', 'bower_components/moment/min/moment.min.js', 'bower_components/xterm.js/dist/xterm.js', + 'bower_components/bootbox.js/bootbox.js', 'assets/js/jquery.gritter.js', // Using custom version to fix error in minified build due to "use strict" 'assets/js/legend.js' // Not a bower package ], @@ -259,6 +267,7 @@ module.exports = function (grunt) { 'bower_components/angular-bootstrap/ui-bootstrap-tpls.min.js', 'bower_components/ng-file-upload/ng-file-upload.min.js', 'bower_components/angular-utils-pagination/dirPagination.js', + 'bower_components/angular-google-analytics/dist/angular-google-analytics.min.js', 'bower_components/angular-ui-select/dist/select.min.js'], dest: '<%= distdir %>/js/angular.js' } @@ -338,7 +347,8 @@ module.exports = function (grunt) { curly: true, eqeqeq: true, immed: true, - latedef: true, + indent: 2, + latedef: 'nofunc', newcap: true, noarg: true, sub: true, @@ -398,28 +408,28 @@ module.exports = function (grunt) { command: [ 'docker stop portainer', 'docker rm portainer', - 'docker run --privileged -d -p 9000:9000 -v /tmp/portainer:/data -v /var/run/docker.sock:/var/run/docker.sock --name portainer portainer' + 'docker run --privileged -d -p 9000:9000 -v /tmp/portainer:/data -v /var/run/docker.sock:/var/run/docker.sock --name portainer portainer --no-analytics' ].join(';') }, runSwarm: { command: [ 'docker stop portainer', 'docker rm portainer', - 'docker run -d -p 9000:9000 --name portainer portainer -H tcp://10.0.7.10:2375' + 'docker run -d -p 9000:9000 --name portainer portainer -H tcp://10.0.7.10:2375 --no-analytics' ].join(';') }, runSwarmLocal: { command: [ 'docker stop portainer', 'docker rm portainer', - 'docker run -d -p 9000:9000 -v /var/run/docker.sock:/var/run/docker.sock --name portainer portainer' + 'docker run -d -p 9000:9000 -v /var/run/docker.sock:/var/run/docker.sock --name portainer portainer --no-analytics' ].join(';') }, runSsl: { command: [ 'docker stop portainer', 'docker rm portainer', - 'docker run -d -p 9000:9000 -v /tmp/portainer:/data -v /tmp/docker-ssl:/certs --name portainer portainer -H tcp://10.0.7.10:2376 --tlsverify' + 'docker run -d -p 9000:9000 -v /tmp/portainer:/data -v /tmp/docker-ssl:/certs --name portainer portainer -H tcp://10.0.7.10:2376 --tlsverify --no-analytics' ].join(';') }, cleanImages: { @@ -457,6 +467,26 @@ module.exports = function (grunt) { }, ifFalse: ['shell:buildWindowsBinary'] } + }, + replace: { + dist: { + options: { + patterns: [ + { + match: 'CONFIG_GA_ID', + replacement: '<%= pkg.config.GA_ID %>' + } + ] + }, + files: [ + { + expand: true, + flatten: true, + src: ['dist/js/**.js'], + dest: 'dist/js/' + } + ] + } } }); }; diff --git a/index.html b/index.html index 06ca19d56..3219c5fbd 100644 --- a/index.html +++ b/index.html @@ -48,7 +48,7 @@
- Connecting to the Docker enpoint... + Connecting to the Docker endpoint...
diff --git a/package.json b/package.json index 641ca76ae..90a566de0 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "author": "Portainer.io", "name": "portainer", "homepage": "http://portainer.io", - "version": "1.11.4", + "version": "1.12.0", "repository": { "type": "git", "url": "git@github.com:portainer/portainer.git" @@ -10,9 +10,12 @@ "bugs": { "url": "https://github.com/portainer/portainer/issues" }, + "config": { + "GA_ID": "UA-84944922-2" + }, "licenses": [ { - "type": "MIT", + "type": "Zlib", "url": "https://raw.githubusercontent.com/portainer/portainer/develop/LICENSE" } ], @@ -27,7 +30,7 @@ "grunt-contrib-concat": "~0.1.3", "grunt-contrib-copy": "~0.4.0", "grunt-contrib-cssmin": "^1.0.2", - "grunt-contrib-jshint": "~0.2.0", + "grunt-contrib-jshint": "^1.1.0", "grunt-contrib-uglify": "^0.9.2", "grunt-contrib-watch": "~0.3.1", "grunt-filerev": "^2.3.1", @@ -35,6 +38,7 @@ "grunt-if": "^0.1.5", "grunt-karma": "~0.4.4", "grunt-recess": "~0.3", + "grunt-replace": "^1.0.1", "grunt-shell": "^1.1.2", "grunt-usemin": "^3.1.1" },