diff --git a/codefresh.yml b/.codefresh/codefresh_develop.yml similarity index 83% rename from codefresh.yml rename to .codefresh/codefresh_develop.yml index fc22891ed..7a17aee3e 100644 --- a/codefresh.yml +++ b/.codefresh/codefresh_develop.yml @@ -17,10 +17,17 @@ steps: - yarn grunt build-webapp - mv api/cmd/portainer/portainer dist/ + get_docker_version: + image: alpine + working_directory: ${{build_frontend}} + commands: + - cf_export DOCKER_VERSION=`cat gruntfile.js | grep -m 1 'shippedDockerVersion' | cut -d\' -f2` + download_docker_binary: image: busybox working_directory: ${{build_frontend}} commands: + - echo ${{DOCKER_VERSION}} - wget -O /tmp/docker-binaries.tgz https://download.docker.com/linux/static/stable/x86_64/docker-${{DOCKER_VERSION}}.tgz - tar -xf /tmp/docker-binaries.tgz -C /tmp - mv /tmp/docker/docker dist/ diff --git a/.codefresh/codefresh_pullrequest.yml b/.codefresh/codefresh_pullrequest.yml new file mode 100644 index 000000000..e3e2652c0 --- /dev/null +++ b/.codefresh/codefresh_pullrequest.yml @@ -0,0 +1,46 @@ +version: '1.0' +steps: + + build_backend: + image: portainer/golang-builder:ci + working_directory: ${{main_clone}} + commands: + - mkdir -p /go/src/github.com/${{CF_REPO_OWNER}} + - ln -s /codefresh/volume/${{CF_REPO_NAME}}/api /go/src/github.com/${{CF_REPO_OWNER}}/${{CF_REPO_NAME}} + - /build.sh api/cmd/portainer + + build_frontend: + image: portainer/angular-builder:latest + working_directory: ${{build_backend}} + commands: + - yarn + - yarn grunt build-webapp + - mv api/cmd/portainer/portainer dist/ + + get_docker_version: + image: alpine + working_directory: ${{build_frontend}} + commands: + - cf_export DOCKER_VERSION=`cat gruntfile.js | grep -m 1 'shippedDockerVersion' | cut -d\' -f2` + + download_docker_binary: + image: busybox + working_directory: ${{build_frontend}} + commands: + - echo ${{DOCKER_VERSION}} + - wget -O /tmp/docker-binaries.tgz https://download.docker.com/linux/static/stable/x86_64/docker-${{DOCKER_VERSION}}.tgz + - tar -xf /tmp/docker-binaries.tgz -C /tmp + - mv /tmp/docker/docker dist/ + + build_image: + type: build + working_directory: ${{download_docker_binary}} + dockerfile: ./build/linux/Dockerfile + image_name: portainer/portainer + tag: ${{CF_BRANCH}} + + push_image: + type: push + candidate: '${{build_image}}' + tag: 'pr${{CF_PULL_REQUEST_NUMBER}}' + registry: dockerhub diff --git a/.github/ISSUE_TEMPLATE/Bug_report.md b/.github/ISSUE_TEMPLATE/Bug_report.md new file mode 100644 index 000000000..986b47357 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/Bug_report.md @@ -0,0 +1,47 @@ +--- +name: Bug report +about: Create a bug report + +--- + + + +**Bug description** + +A clear and concise description of what the bug is. + +**Expected behavior** +A clear and concise description of what you expected to happen. + +Briefly describe what you were expecting. + +**Steps to reproduce the issue:** + +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Technical details:** + +* Portainer version: +* Docker version (managed by Portainer): +* Platform (windows/linux): +* Command used to start Portainer (`docker run -p 9000:9000 portainer/portainer`): +* Browser: + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/Custom.md b/.github/ISSUE_TEMPLATE/Custom.md new file mode 100644 index 000000000..e43b1e6f7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/Custom.md @@ -0,0 +1,15 @@ +--- +name: Question +about: Ask us a question about Portainer usage or deployment + +--- + + + +**Question**: +How can I deploy Portainer on... ? diff --git a/.github/ISSUE_TEMPLATE/Feature_request.md b/.github/ISSUE_TEMPLATE/Feature_request.md new file mode 100644 index 000000000..76a157fe0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/Feature_request.md @@ -0,0 +1,31 @@ +--- +name: Feature request +about: Suggest a feature/enhancement that should be added in Portainer + +--- + + + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/api/bolt/datastore.go b/api/bolt/datastore.go index 9511bdbc3..8017d8add 100644 --- a/api/bolt/datastore.go +++ b/api/bolt/datastore.go @@ -20,6 +20,7 @@ type Store struct { TeamService *TeamService TeamMembershipService *TeamMembershipService EndpointService *EndpointService + EndpointGroupService *EndpointGroupService ResourceControlService *ResourceControlService VersionService *VersionService SettingsService *SettingsService @@ -38,6 +39,7 @@ const ( teamBucketName = "teams" teamMembershipBucketName = "team_membership" endpointBucketName = "endpoints" + endpointGroupBucketName = "endpoint_groups" resourceControlBucketName = "resource_control" settingsBucketName = "settings" registryBucketName = "registries" @@ -53,6 +55,7 @@ func NewStore(storePath string) (*Store, error) { TeamService: &TeamService{}, TeamMembershipService: &TeamMembershipService{}, EndpointService: &EndpointService{}, + EndpointGroupService: &EndpointGroupService{}, ResourceControlService: &ResourceControlService{}, VersionService: &VersionService{}, SettingsService: &SettingsService{}, @@ -64,6 +67,7 @@ func NewStore(storePath string) (*Store, error) { store.TeamService.store = store store.TeamMembershipService.store = store store.EndpointService.store = store + store.EndpointGroupService.store = store store.ResourceControlService.store = store store.VersionService.store = store store.SettingsService.store = store @@ -94,7 +98,7 @@ func (store *Store) Open() error { store.db = db bucketsToCreate := []string{versionBucketName, userBucketName, teamBucketName, endpointBucketName, - resourceControlBucketName, teamMembershipBucketName, settingsBucketName, + endpointGroupBucketName, resourceControlBucketName, teamMembershipBucketName, settingsBucketName, registryBucketName, dockerhubBucketName, stackBucketName} return db.Update(func(tx *bolt.Tx) error { @@ -110,6 +114,28 @@ func (store *Store) Open() error { }) } +// Init creates the default data set. +func (store *Store) Init() error { + groups, err := store.EndpointGroupService.EndpointGroups() + if err != nil { + return err + } + + if len(groups) == 0 { + unassignedGroup := &portainer.EndpointGroup{ + Name: "Unassigned", + Description: "Unassigned endpoints", + Labels: []portainer.Pair{}, + AuthorizedUsers: []portainer.UserID{}, + AuthorizedTeams: []portainer.TeamID{}, + } + + return store.EndpointGroupService.CreateEndpointGroup(unassignedGroup) + } + + return nil +} + // Close closes the BoltDB database. func (store *Store) Close() error { if store.db != nil { diff --git a/api/bolt/endpoint_group_service.go b/api/bolt/endpoint_group_service.go new file mode 100644 index 000000000..c52e95dac --- /dev/null +++ b/api/bolt/endpoint_group_service.go @@ -0,0 +1,114 @@ +package bolt + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +// EndpointGroupService represents a service for managing endpoint groups. +type EndpointGroupService struct { + store *Store +} + +// EndpointGroup returns an endpoint group by ID. +func (service *EndpointGroupService) EndpointGroup(ID portainer.EndpointGroupID) (*portainer.EndpointGroup, error) { + var data []byte + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(endpointGroupBucketName)) + value := bucket.Get(internal.Itob(int(ID))) + if value == nil { + return portainer.ErrEndpointGroupNotFound + } + + data = make([]byte, len(value)) + copy(data, value) + return nil + }) + if err != nil { + return nil, err + } + + var endpointGroup portainer.EndpointGroup + err = internal.UnmarshalEndpointGroup(data, &endpointGroup) + if err != nil { + return nil, err + } + return &endpointGroup, nil +} + +// EndpointGroups return an array containing all the endpoint groups. +func (service *EndpointGroupService) EndpointGroups() ([]portainer.EndpointGroup, error) { + var endpointGroups = make([]portainer.EndpointGroup, 0) + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(endpointGroupBucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var endpointGroup portainer.EndpointGroup + err := internal.UnmarshalEndpointGroup(v, &endpointGroup) + if err != nil { + return err + } + endpointGroups = append(endpointGroups, endpointGroup) + } + + return nil + }) + if err != nil { + return nil, err + } + + return endpointGroups, nil +} + +// CreateEndpointGroup assign an ID to a new endpoint group and saves it. +func (service *EndpointGroupService) CreateEndpointGroup(endpointGroup *portainer.EndpointGroup) error { + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(endpointGroupBucketName)) + + id, _ := bucket.NextSequence() + endpointGroup.ID = portainer.EndpointGroupID(id) + + data, err := internal.MarshalEndpointGroup(endpointGroup) + if err != nil { + return err + } + + err = bucket.Put(internal.Itob(int(endpointGroup.ID)), data) + if err != nil { + return err + } + return nil + }) +} + +// UpdateEndpointGroup updates an endpoint group. +func (service *EndpointGroupService) UpdateEndpointGroup(ID portainer.EndpointGroupID, endpointGroup *portainer.EndpointGroup) error { + data, err := internal.MarshalEndpointGroup(endpointGroup) + if err != nil { + return err + } + + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(endpointGroupBucketName)) + err = bucket.Put(internal.Itob(int(ID)), data) + if err != nil { + return err + } + return nil + }) +} + +// DeleteEndpointGroup deletes an endpoint group. +func (service *EndpointGroupService) DeleteEndpointGroup(ID portainer.EndpointGroupID) error { + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(endpointGroupBucketName)) + err := bucket.Delete(internal.Itob(int(ID))) + if err != nil { + return err + } + return nil + }) +} diff --git a/api/bolt/internal/internal.go b/api/bolt/internal/internal.go index 2ee1027b5..b247268ee 100644 --- a/api/bolt/internal/internal.go +++ b/api/bolt/internal/internal.go @@ -47,6 +47,16 @@ func UnmarshalEndpoint(data []byte, endpoint *portainer.Endpoint) error { return json.Unmarshal(data, endpoint) } +// MarshalEndpointGroup encodes an endpoint group to binary format. +func MarshalEndpointGroup(group *portainer.EndpointGroup) ([]byte, error) { + return json.Marshal(group) +} + +// UnmarshalEndpointGroup decodes an endpoint group from a binary data. +func UnmarshalEndpointGroup(data []byte, group *portainer.EndpointGroup) error { + return json.Unmarshal(data, group) +} + // MarshalStack encodes a stack to binary format. func MarshalStack(stack *portainer.Stack) ([]byte, error) { return json.Marshal(stack) diff --git a/api/bolt/migrate_dbversion8.go b/api/bolt/migrate_dbversion8.go new file mode 100644 index 000000000..7ef77806d --- /dev/null +++ b/api/bolt/migrate_dbversion8.go @@ -0,0 +1,20 @@ +package bolt + +import "github.com/portainer/portainer" + +func (m *Migrator) updateEndpointsToVersion9() error { + legacyEndpoints, err := m.EndpointService.Endpoints() + if err != nil { + return err + } + + for _, endpoint := range legacyEndpoints { + endpoint.GroupID = portainer.EndpointGroupID(1) + err = m.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) + if err != nil { + return err + } + } + + return nil +} diff --git a/api/bolt/migrate_dbversion9.go b/api/bolt/migrate_dbversion9.go new file mode 100644 index 000000000..1882f55c7 --- /dev/null +++ b/api/bolt/migrate_dbversion9.go @@ -0,0 +1,20 @@ +package bolt + +import "github.com/portainer/portainer" + +func (m *Migrator) updateEndpointsToVersion10() error { + legacyEndpoints, err := m.EndpointService.Endpoints() + if err != nil { + return err + } + + for _, endpoint := range legacyEndpoints { + endpoint.Type = portainer.DockerEnvironment + err = m.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) + if err != nil { + return err + } + } + + return nil +} diff --git a/api/bolt/migrator.go b/api/bolt/migrator.go index 9b3aacdec..66566ae8d 100644 --- a/api/bolt/migrator.go +++ b/api/bolt/migrator.go @@ -96,6 +96,22 @@ func (m *Migrator) Migrate() error { } } + // https: //github.com/portainer/portainer/issues/1396 + if m.CurrentDBVersion < 9 { + err := m.updateEndpointsToVersion9() + if err != nil { + return err + } + } + + // https://github.com/portainer/portainer/issues/461 + if m.CurrentDBVersion < 10 { + err := m.updateEndpointsToVersion10() + if err != nil { + return err + } + } + err := m.VersionService.StoreDBVersion(portainer.DBVersion) if err != nil { return err diff --git a/api/cli/cli.go b/api/cli/cli.go index db0d80603..f2307718b 100644 --- a/api/cli/cli.go +++ b/api/cli/cli.go @@ -38,6 +38,7 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) { NoAuth: kingpin.Flag("no-auth", "Disable authentication").Default(defaultNoAuth).Bool(), NoAnalytics: kingpin.Flag("no-analytics", "Disable Analytics in app").Default(defaultNoAnalytics).Bool(), TLSVerify: kingpin.Flag("tlsverify", "TLS support").Default(defaultTLSVerify).Bool(), + TLSSkipVerify: kingpin.Flag("tlsskipverify", "Disable TLS server verification").Default(defaultTLSSkipVerify).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(), TLSKey: kingpin.Flag("tlskey", "Path to the TLS key").Default(defaultTLSKeyPath).String(), diff --git a/api/cli/defaults.go b/api/cli/defaults.go index 2d350c1c7..b2e40c969 100644 --- a/api/cli/defaults.go +++ b/api/cli/defaults.go @@ -9,6 +9,7 @@ const ( defaultNoAuth = "false" defaultNoAnalytics = "false" defaultTLSVerify = "false" + defaultTLSSkipVerify = "false" defaultTLSCACertPath = "/certs/ca.pem" defaultTLSCertPath = "/certs/cert.pem" defaultTLSKeyPath = "/certs/key.pem" diff --git a/api/cli/defaults_windows.go b/api/cli/defaults_windows.go index a94657258..6fead127e 100644 --- a/api/cli/defaults_windows.go +++ b/api/cli/defaults_windows.go @@ -7,6 +7,7 @@ const ( defaultNoAuth = "false" defaultNoAnalytics = "false" defaultTLSVerify = "false" + defaultTLSSkipVerify = "false" defaultTLSCACertPath = "C:\\certs\\ca.pem" defaultTLSCertPath = "C:\\certs\\cert.pem" defaultTLSKeyPath = "C:\\certs\\key.pem" diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 50b0591e4..0e30cf276 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -10,6 +10,7 @@ import ( "github.com/portainer/portainer/filesystem" "github.com/portainer/portainer/git" "github.com/portainer/portainer/http" + "github.com/portainer/portainer/http/client" "github.com/portainer/portainer/jwt" "github.com/portainer/portainer/ldap" @@ -49,6 +50,11 @@ func initStore(dataStorePath string) *bolt.Store { log.Fatal(err) } + err = store.Init() + if err != nil { + log.Fatal(err) + } + err = store.MigrateData() if err != nil { log.Fatal(err) @@ -56,8 +62,8 @@ func initStore(dataStorePath string) *bolt.Store { return store } -func initStackManager(assetsPath string) portainer.StackManager { - return exec.NewStackManager(assetsPath) +func initStackManager(assetsPath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService) (portainer.StackManager, error) { + return exec.NewStackManager(assetsPath, signatureService, fileService) } func initJWTService(authenticationEnabled bool) portainer.JWTService { @@ -71,6 +77,10 @@ func initJWTService(authenticationEnabled bool) portainer.JWTService { return nil } +func initDigitalSignatureService() portainer.DigitalSignatureService { + return &crypto.ECDSAService{} +} + func initCryptoService() portainer.CryptoService { return &crypto.Service{} } @@ -168,6 +178,35 @@ func retrieveFirstEndpointFromDatabase(endpointService portainer.EndpointService return &endpoints[0] } +func loadAndParseKeyPair(fileService portainer.FileService, signatureService portainer.DigitalSignatureService) error { + private, public, err := fileService.LoadKeyPair() + if err != nil { + return err + } + return signatureService.ParseKeyPair(private, public) +} + +func generateAndStoreKeyPair(fileService portainer.FileService, signatureService portainer.DigitalSignatureService) error { + private, public, err := signatureService.GenerateKeyPair() + if err != nil { + return err + } + privateHeader, publicHeader := signatureService.PEMHeaders() + return fileService.StoreKeyPair(private, public, privateHeader, publicHeader) +} + +func initKeyPair(fileService portainer.FileService, signatureService portainer.DigitalSignatureService) error { + existingKeyPair, err := fileService.KeyPairFilesExist() + if err != nil { + log.Fatal(err) + } + + if existingKeyPair { + return loadAndParseKeyPair(fileService, signatureService) + } + return generateAndStoreKeyPair(fileService, signatureService) +} + func main() { flags := initCLI() @@ -176,19 +215,29 @@ func main() { store := initStore(*flags.Data) defer store.Close() - stackManager := initStackManager(*flags.Assets) - jwtService := initJWTService(!*flags.NoAuth) cryptoService := initCryptoService() + digitalSignatureService := initDigitalSignatureService() + ldapService := initLDAPService() gitService := initGitService() authorizeEndpointMgmt := initEndpointWatcher(store.EndpointService, *flags.ExternalEndpoints, *flags.SyncInterval) - err := initSettings(store.SettingsService, flags) + err := initKeyPair(fileService, digitalSignatureService) + if err != nil { + log.Fatal(err) + } + + stackManager, err := initStackManager(*flags.Assets, digitalSignatureService, fileService) + if err != nil { + log.Fatal(err) + } + + err = initSettings(store.SettingsService, flags) if err != nil { log.Fatal(err) } @@ -209,9 +258,10 @@ func main() { endpoint := &portainer.Endpoint{ Name: "primary", URL: *flags.Endpoint, + Type: portainer.DockerEnvironment, TLSConfig: portainer.TLSConfiguration{ TLS: *flags.TLSVerify, - TLSSkipVerify: false, + TLSSkipVerify: *flags.TLSSkipVerify, TLSCACertPath: *flags.TLSCacert, TLSCertPath: *flags.TLSCert, TLSKeyPath: *flags.TLSKey, @@ -220,6 +270,16 @@ func main() { AuthorizedTeams: []portainer.TeamID{}, Extensions: []portainer.EndpointExtension{}, } + + agentOnDockerEnvironment, err := client.ExecutePingOperationFromEndpoint(endpoint) + if err != nil { + log.Fatal(err) + } + + if agentOnDockerEnvironment { + endpoint.Type = portainer.AgentOnDockerEnvironment + } + err = store.EndpointService.CreateEndpoint(endpoint) if err != nil { log.Fatal(err) @@ -275,6 +335,7 @@ func main() { TeamService: store.TeamService, TeamMembershipService: store.TeamMembershipService, EndpointService: store.EndpointService, + EndpointGroupService: store.EndpointGroupService, ResourceControlService: store.ResourceControlService, SettingsService: store.SettingsService, RegistryService: store.RegistryService, @@ -286,6 +347,7 @@ func main() { FileService: fileService, LDAPService: ldapService, GitService: gitService, + SignatureService: digitalSignatureService, SSL: *flags.SSL, SSLCert: *flags.SSLCert, SSLKey: *flags.SSLKey, diff --git a/api/cron/endpoint_sync.go b/api/cron/endpoint_sync.go index 6b9926665..8d55b4a0e 100644 --- a/api/cron/endpoint_sync.go +++ b/api/cron/endpoint_sync.go @@ -142,8 +142,6 @@ func (job endpointSyncJob) prepareSyncData(storedEndpoints, fileEndpoints []port if endpoint != nil { job.logger.Printf("New definition for a stored endpoint found in file, updating database. [name: %v] [url: %v]\n", endpoint.Name, endpoint.URL) endpointsToUpdate = append(endpointsToUpdate, endpoint) - } else { - job.logger.Printf("No change detected for a stored endpoint. [name: %v] [url: %v]\n", storedEndpoints[idx].Name, storedEndpoints[idx].URL) } } else { job.logger.Printf("Stored endpoint not found in file (definition might be invalid), removing from database. [name: %v] [url: %v]", storedEndpoints[idx].Name, storedEndpoints[idx].URL) diff --git a/api/crypto/ecdsa.go b/api/crypto/ecdsa.go new file mode 100644 index 000000000..1b453dd3e --- /dev/null +++ b/api/crypto/ecdsa.go @@ -0,0 +1,125 @@ +package crypto + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/md5" + "crypto/rand" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "math/big" +) + +const ( + // PrivateKeyPemHeader represents the header that is appended to the PEM file when + // storing the private key. + PrivateKeyPemHeader = "EC PRIVATE KEY" + // PublicKeyPemHeader represents the header that is appended to the PEM file when + // storing the public key. + PublicKeyPemHeader = "ECDSA PUBLIC KEY" +) + +// ECDSAService is a service used to create digital signatures when communicating with +// an agent based environment. It will automatically generates a key pair using ECDSA or +// can also reuse an existing ECDSA key pair. +type ECDSAService struct { + privateKey *ecdsa.PrivateKey + publicKey *ecdsa.PublicKey + encodedPubKey string +} + +// EncodedPublicKey returns the encoded version of the public that can be used +// to be shared with other services. It's the hexadecimal encoding of the public key +// content. +func (service *ECDSAService) EncodedPublicKey() string { + return service.encodedPubKey +} + +// PEMHeaders returns the ECDSA PEM headers. +func (service *ECDSAService) PEMHeaders() (string, string) { + return PrivateKeyPemHeader, PublicKeyPemHeader +} + +// ParseKeyPair parses existing private/public key pair content and associate +// the parsed keys to the service. +func (service *ECDSAService) ParseKeyPair(private, public []byte) error { + privateKey, err := x509.ParseECPrivateKey(private) + if err != nil { + return err + } + + service.privateKey = privateKey + + encodedKey := hex.EncodeToString(public) + service.encodedPubKey = encodedKey + + publicKey, err := x509.ParsePKIXPublicKey(public) + if err != nil { + return err + } + + service.publicKey = publicKey.(*ecdsa.PublicKey) + + return nil +} + +// GenerateKeyPair will create a new key pair using ECDSA. +func (service *ECDSAService) GenerateKeyPair() ([]byte, []byte, error) { + pubkeyCurve := elliptic.P256() + + privatekey, err := ecdsa.GenerateKey(pubkeyCurve, rand.Reader) + if err != nil { + return nil, nil, err + } + + service.privateKey = privatekey + service.publicKey = &privatekey.PublicKey + + private, err := x509.MarshalECPrivateKey(service.privateKey) + if err != nil { + return nil, nil, err + } + + public, err := x509.MarshalPKIXPublicKey(service.publicKey) + if err != nil { + return nil, nil, err + } + + encodedKey := hex.EncodeToString(public) + service.encodedPubKey = encodedKey + + return private, public, nil +} + +// Sign creates a signature from a message. +// It automatically hash the message using MD5 and creates a signature from +// that hash. +// It then encodes the generated signature in base64. +func (service *ECDSAService) Sign(message string) (string, error) { + digest := md5.New() + digest.Write([]byte(message)) + hash := digest.Sum(nil) + + r := big.NewInt(0) + s := big.NewInt(0) + + r, s, err := ecdsa.Sign(rand.Reader, service.privateKey, hash) + if err != nil { + return "", err + } + + keyBytes := service.privateKey.Params().BitSize / 8 + + rBytes := r.Bytes() + rBytesPadded := make([]byte, keyBytes) + copy(rBytesPadded[keyBytes-len(rBytes):], rBytes) + + sBytes := s.Bytes() + sBytesPadded := make([]byte, keyBytes) + copy(sBytesPadded[keyBytes-len(sBytes):], sBytes) + + signature := append(rBytesPadded, sBytesPadded...) + + return base64.RawStdEncoding.EncodeToString(signature), nil +} diff --git a/api/crypto/tls.go b/api/crypto/tls.go index 976e1a075..ae6bac867 100644 --- a/api/crypto/tls.go +++ b/api/crypto/tls.go @@ -8,11 +8,32 @@ import ( "github.com/portainer/portainer" ) +func CreateTLSConfig(caCert, cert, key []byte, skipClientVerification, skipServerVerification bool) (*tls.Config, error) { + config := &tls.Config{} + config.InsecureSkipVerify = skipServerVerification + + if !skipClientVerification { + certificate, err := tls.X509KeyPair(cert, key) + if err != nil { + return nil, err + } + config.Certificates = []tls.Certificate{certificate} + } + + if !skipServerVerification { + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCert) + config.RootCAs = caCertPool + } + + return config, nil +} + // CreateTLSConfiguration initializes a tls.Config using a CA certificate, a certificate and a key func CreateTLSConfiguration(config *portainer.TLSConfiguration) (*tls.Config, error) { TLSConfig := &tls.Config{} - if config.TLSCertPath != "" && config.TLSKeyPath != "" { + if config.TLS && config.TLSCertPath != "" && config.TLSKeyPath != "" { cert, err := tls.LoadX509KeyPair(config.TLSCertPath, config.TLSKeyPath) if err != nil { return nil, err @@ -21,7 +42,7 @@ func CreateTLSConfiguration(config *portainer.TLSConfiguration) (*tls.Config, er TLSConfig.Certificates = []tls.Certificate{cert} } - if !config.TLSSkipVerify { + if config.TLS && !config.TLSSkipVerify { caCert, err := ioutil.ReadFile(config.TLSCACertPath) if err != nil { return nil, err diff --git a/api/errors.go b/api/errors.go index 8e4c07d9e..7cd39f445 100644 --- a/api/errors.go +++ b/api/errors.go @@ -28,7 +28,7 @@ const ( // TeamMembership errors. const ( ErrTeamMembershipNotFound = Error("Team membership not found") - ErrTeamMembershipAlreadyExists = Error("Team membership already exists for this user and team.") + ErrTeamMembershipAlreadyExists = Error("Team membership already exists for this user and team") ) // ResourceControl errors. @@ -44,6 +44,12 @@ const ( ErrEndpointAccessDenied = Error("Access denied to endpoint") ) +// Endpoint group errors. +const ( + ErrEndpointGroupNotFound = Error("Endpoint group not found") + ErrCannotRemoveDefaultGroup = Error("Cannot remove the default endpoint group") +) + // Registry errors. const ( ErrRegistryNotFound = Error("Registry not found") diff --git a/api/exec/stack_manager.go b/api/exec/stack_manager.go index b7cf6df86..d8da84df1 100644 --- a/api/exec/stack_manager.go +++ b/api/exec/stack_manager.go @@ -12,14 +12,34 @@ import ( // StackManager represents a service for managing stacks. type StackManager struct { - binaryPath string + binaryPath string + signatureService portainer.DigitalSignatureService + fileService portainer.FileService +} + +type dockerCLIConfiguration struct { + HTTPHeaders struct { + ManagerOperationHeader string `json:"X-PortainerAgent-ManagerOperation"` + SignatureHeader string `json:"X-PortainerAgent-Signature"` + PublicKey string `json:"X-PortainerAgent-PublicKey"` + } `json:"HttpHeaders"` } // NewStackManager initializes a new StackManager service. -func NewStackManager(binaryPath string) *StackManager { - return &StackManager{ - binaryPath: binaryPath, +// It also updates the configuration of the Docker CLI binary. +func NewStackManager(binaryPath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService) (*StackManager, error) { + manager := &StackManager{ + binaryPath: binaryPath, + signatureService: signatureService, + fileService: fileService, } + + err := manager.updateDockerCLIConfiguration(binaryPath) + if err != nil { + return nil, err + } + + return manager, nil } // Login executes the docker login command against a list of registries (including DockerHub). @@ -61,7 +81,8 @@ func (manager *StackManager) Deploy(stack *portainer.Stack, prune bool, endpoint env = append(env, envvar.Name+"="+envvar.Value) } - return runCommandAndCaptureStdErr(command, args, env, stack.ProjectPath) + stackFolder := path.Dir(stackFilePath) + return runCommandAndCaptureStdErr(command, args, env, stackFolder) } // Remove executes the docker stack rm command. @@ -99,9 +120,12 @@ func prepareDockerCommandAndArgs(binaryPath string, endpoint *portainer.Endpoint } args := make([]string, 0) + args = append(args, "--config", binaryPath) args = append(args, "-H", endpoint.URL) - if endpoint.TLSConfig.TLS { + if !endpoint.TLSConfig.TLS && endpoint.TLSConfig.TLSSkipVerify { + args = append(args, "--tls") + } else if endpoint.TLSConfig.TLS { args = append(args, "--tls") if !endpoint.TLSConfig.TLSSkipVerify { @@ -115,3 +139,22 @@ func prepareDockerCommandAndArgs(binaryPath string, endpoint *portainer.Endpoint return command, args } + +func (manager *StackManager) updateDockerCLIConfiguration(binaryPath string) error { + config := dockerCLIConfiguration{} + config.HTTPHeaders.ManagerOperationHeader = "1" + + signature, err := manager.signatureService.Sign(portainer.PortainerAgentSignatureMessage) + if err != nil { + return err + } + config.HTTPHeaders.SignatureHeader = signature + config.HTTPHeaders.PublicKey = manager.signatureService.EncodedPublicKey() + + err = manager.fileService.WriteJSONToFile(path.Join(binaryPath, "config.json"), config) + if err != nil { + return err + } + + return nil +} diff --git a/api/filesystem/filesystem.go b/api/filesystem/filesystem.go index 1f59fd0c8..770b8af76 100644 --- a/api/filesystem/filesystem.go +++ b/api/filesystem/filesystem.go @@ -2,6 +2,8 @@ package filesystem import ( "bytes" + "encoding/json" + "encoding/pem" "io/ioutil" "github.com/portainer/portainer" @@ -26,6 +28,10 @@ const ( ComposeStorePath = "compose" // ComposeFileDefaultName represents the default name of a compose file. ComposeFileDefaultName = "docker-compose.yml" + // PrivateKeyFile represents the name on disk of the file containing the private key. + PrivateKeyFile = "portainer.key" + // PublicKeyFile represents the name on disk of the file containing the public key. + PublicKeyFile = "portainer.pub" ) // Service represents a service for managing files and directories. @@ -198,6 +204,69 @@ func (service *Service) GetFileContent(filePath string) (string, error) { return string(content), nil } +// WriteJSONToFile writes JSON to the specified file. +func (service *Service) WriteJSONToFile(path string, content interface{}) error { + jsonContent, err := json.Marshal(content) + if err != nil { + return err + } + + return ioutil.WriteFile(path, jsonContent, 0644) +} + +// KeyPairFilesExist checks for the existence of the key files. +func (service *Service) KeyPairFilesExist() (bool, error) { + privateKeyPath := path.Join(service.dataStorePath, PrivateKeyFile) + exists, err := fileExists(privateKeyPath) + if err != nil { + return false, err + } + if !exists { + return false, nil + } + + publicKeyPath := path.Join(service.dataStorePath, PublicKeyFile) + exists, err = fileExists(publicKeyPath) + if err != nil { + return false, err + } + if !exists { + return false, nil + } + + return true, nil +} + +// StoreKeyPair store the specified keys content as PEM files on disk. +func (service *Service) StoreKeyPair(private, public []byte, privatePEMHeader, publicPEMHeader string) error { + err := service.createPEMFileInStore(private, privatePEMHeader, PrivateKeyFile) + if err != nil { + return err + } + + err = service.createPEMFileInStore(public, publicPEMHeader, PublicKeyFile) + if err != nil { + return err + } + + return nil +} + +// LoadKeyPair retrieve the content of both key files on disk. +func (service *Service) LoadKeyPair() ([]byte, []byte, error) { + privateKey, err := service.getContentFromPEMFile(PrivateKeyFile) + if err != nil { + return nil, nil, err + } + + publicKey, err := service.getContentFromPEMFile(PublicKeyFile) + if err != nil { + return nil, nil, err + } + + return privateKey, publicKey, nil +} + // createDirectoryInStore creates a new directory in the file store func (service *Service) createDirectoryInStore(name string) error { path := path.Join(service.fileStorePath, name) @@ -221,3 +290,43 @@ func (service *Service) createFileInStore(filePath string, r io.Reader) error { return nil } + +func (service *Service) createPEMFileInStore(content []byte, fileType, filePath string) error { + path := path.Join(service.fileStorePath, filePath) + block := &pem.Block{Type: fileType, Bytes: content} + + out, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return err + } + defer out.Close() + + err = pem.Encode(out, block) + if err != nil { + return err + } + + return nil +} + +func (service *Service) getContentFromPEMFile(filePath string) ([]byte, error) { + path := path.Join(service.fileStorePath, filePath) + + fileContent, err := ioutil.ReadFile(path) + if err != nil { + return nil, err + } + + block, _ := pem.Decode(fileContent) + return block.Bytes, nil +} + +func fileExists(filePath string) (bool, error) { + if _, err := os.Stat(filePath); err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + return true, nil +} diff --git a/api/http/client/client.go b/api/http/client/client.go new file mode 100644 index 000000000..b9e3f318b --- /dev/null +++ b/api/http/client/client.go @@ -0,0 +1,77 @@ +package client + +import ( + "crypto/tls" + "net/http" + "strings" + "time" + + "github.com/portainer/portainer" + "github.com/portainer/portainer/crypto" +) + +// ExecutePingOperationFromEndpoint will send a SystemPing operation HTTP request to a Docker environment +// using the specified endpoint configuration. It is used exclusively when +// specifying an endpoint from the CLI via the -H flag. +func ExecutePingOperationFromEndpoint(endpoint *portainer.Endpoint) (bool, error) { + if strings.HasPrefix(endpoint.URL, "unix://") { + return false, nil + } + + transport := &http.Transport{} + + scheme := "http" + + if endpoint.TLSConfig.TLS || endpoint.TLSConfig.TLSSkipVerify { + tlsConfig, err := crypto.CreateTLSConfiguration(&endpoint.TLSConfig) + if err != nil { + return false, err + } + scheme = "https" + transport.TLSClientConfig = tlsConfig + } + + client := &http.Client{ + Timeout: time.Second * 3, + Transport: transport, + } + + target := strings.Replace(endpoint.URL, "tcp://", scheme+"://", 1) + return pingOperation(client, target) +} + +// ExecutePingOperation will send a SystemPing operation HTTP request to a Docker environment +// using the specified host and optional TLS configuration. +func ExecutePingOperation(host string, tlsConfig *tls.Config) (bool, error) { + transport := &http.Transport{} + + scheme := "http" + if tlsConfig != nil { + transport.TLSClientConfig = tlsConfig + scheme = "https" + } + + client := &http.Client{ + Timeout: time.Second * 3, + Transport: transport, + } + + target := strings.Replace(host, "tcp://", scheme+"://", 1) + return pingOperation(client, target) +} + +func pingOperation(client *http.Client, target string) (bool, error) { + pingOperationURL := target + "/_ping" + + response, err := client.Get(pingOperationURL) + if err != nil { + return false, err + } + + agentOnDockerEnvironment := false + if response.Header.Get(portainer.PortainerAgentHeader) != "" { + agentOnDockerEnvironment = true + } + + return agentOnDockerEnvironment, nil +} diff --git a/api/http/error/error.go b/api/http/error/error.go index f94b924ed..7a2715c8a 100644 --- a/api/http/error/error.go +++ b/api/http/error/error.go @@ -17,6 +17,7 @@ func WriteErrorResponse(w http.ResponseWriter, err error, code int, logger *log. logger.Printf("http error: %s (code=%d)", err, code) } + w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) json.NewEncoder(w).Encode(&errorResponse{Err: err.Error()}) } diff --git a/api/http/handler/auth.go b/api/http/handler/auth.go index eb5e86c00..4b75967e9 100644 --- a/api/http/handler/auth.go +++ b/api/http/handler/auth.go @@ -37,14 +37,14 @@ const ( ) // NewAuthHandler returns a new instance of AuthHandler. -func NewAuthHandler(bouncer *security.RequestBouncer, authDisabled bool) *AuthHandler { +func NewAuthHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimiter, authDisabled bool) *AuthHandler { h := &AuthHandler{ Router: mux.NewRouter(), Logger: log.New(os.Stderr, "", log.LstdFlags), authDisabled: authDisabled, } h.Handle("/auth", - bouncer.PublicAccess(http.HandlerFunc(h.handlePostAuth))).Methods(http.MethodPost) + rateLimiter.LimitAccess(bouncer.PublicAccess(http.HandlerFunc(h.handlePostAuth)))).Methods(http.MethodPost) return h } diff --git a/api/http/handler/docker.go b/api/http/handler/docker.go index 5992d7065..24cf0831d 100644 --- a/api/http/handler/docker.go +++ b/api/http/handler/docker.go @@ -20,6 +20,7 @@ type DockerHandler struct { *mux.Router Logger *log.Logger EndpointService portainer.EndpointService + EndpointGroupService portainer.EndpointGroupService TeamMembershipService portainer.TeamMembershipService ProxyManager *proxy.Manager } @@ -64,9 +65,17 @@ func (handler *DockerHandler) proxyRequestsToDockerAPI(w http.ResponseWriter, r return } - if tokenData.Role != portainer.AdministratorRole && !security.AuthorizedEndpointAccess(endpoint, tokenData.ID, memberships) { - httperror.WriteErrorResponse(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger) - return + if tokenData.Role != portainer.AdministratorRole { + group, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if !security.AuthorizedEndpointAccess(endpoint, group, tokenData.ID, memberships) { + httperror.WriteErrorResponse(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger) + return + } } var proxy http.Handler diff --git a/api/http/handler/endpoint.go b/api/http/handler/endpoint.go index cd345c1d9..141f8d29d 100644 --- a/api/http/handler/endpoint.go +++ b/api/http/handler/endpoint.go @@ -1,7 +1,12 @@ package handler import ( + "bytes" + "strings" + "github.com/portainer/portainer" + "github.com/portainer/portainer/crypto" + "github.com/portainer/portainer/http/client" httperror "github.com/portainer/portainer/http/error" "github.com/portainer/portainer/http/proxy" "github.com/portainer/portainer/http/security" @@ -22,6 +27,7 @@ type EndpointHandler struct { Logger *log.Logger authorizeEndpointManagement bool EndpointService portainer.EndpointService + EndpointGroupService portainer.EndpointGroupService FileService portainer.FileService ProxyManager *proxy.Manager } @@ -56,19 +62,6 @@ func NewEndpointHandler(bouncer *security.RequestBouncer, authorizeEndpointManag } type ( - postEndpointsRequest struct { - Name string `valid:"required"` - URL string `valid:"required"` - PublicURL string `valid:"-"` - TLS bool - TLSSkipVerify bool - TLSSkipClientVerify bool - } - - postEndpointsResponse struct { - ID int `json:"Id"` - } - putEndpointAccessRequest struct { AuthorizedUsers []int `valid:"-"` AuthorizedTeams []int `valid:"-"` @@ -78,10 +71,24 @@ type ( Name string `valid:"-"` URL string `valid:"-"` PublicURL string `valid:"-"` + GroupID int `valid:"-"` TLS bool `valid:"-"` TLSSkipVerify bool `valid:"-"` TLSSkipClientVerify bool `valid:"-"` } + + postEndpointPayload struct { + name string + url string + publicURL string + groupID int + useTLS bool + skipTLSServerVerification bool + skipTLSClientVerification bool + caCert []byte + cert []byte + key []byte + } ) // handleGetEndpoints handles GET requests on /endpoints @@ -98,7 +105,13 @@ func (handler *EndpointHandler) handleGetEndpoints(w http.ResponseWriter, r *htt return } - filteredEndpoints, err := security.FilterEndpoints(endpoints, securityContext) + groups, err := handler.EndpointGroupService.EndpointGroups() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + filteredEndpoints, err := security.FilterEndpoints(endpoints, groups, securityContext) if err != nil { httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return @@ -107,6 +120,180 @@ func (handler *EndpointHandler) handleGetEndpoints(w http.ResponseWriter, r *htt encodeJSON(w, filteredEndpoints, handler.Logger) } +func (handler *EndpointHandler) createTLSSecuredEndpoint(payload *postEndpointPayload) (*portainer.Endpoint, error) { + tlsConfig, err := crypto.CreateTLSConfig(payload.caCert, payload.cert, payload.key, payload.skipTLSClientVerification, payload.skipTLSServerVerification) + if err != nil { + return nil, err + } + + agentOnDockerEnvironment, err := client.ExecutePingOperation(payload.url, tlsConfig) + if err != nil { + return nil, err + } + + endpointType := portainer.DockerEnvironment + if agentOnDockerEnvironment { + endpointType = portainer.AgentOnDockerEnvironment + } + + endpoint := &portainer.Endpoint{ + Name: payload.name, + URL: payload.url, + Type: endpointType, + GroupID: portainer.EndpointGroupID(payload.groupID), + PublicURL: payload.publicURL, + TLSConfig: portainer.TLSConfiguration{ + TLS: payload.useTLS, + TLSSkipVerify: payload.skipTLSServerVerification, + }, + AuthorizedUsers: []portainer.UserID{}, + AuthorizedTeams: []portainer.TeamID{}, + Extensions: []portainer.EndpointExtension{}, + } + + err = handler.EndpointService.CreateEndpoint(endpoint) + if err != nil { + return nil, err + } + + folder := strconv.Itoa(int(endpoint.ID)) + + if !payload.skipTLSServerVerification { + r := bytes.NewReader(payload.caCert) + // TODO: review the API exposed by the FileService to store + // a file from a byte slice and return the path to the stored file instead + // of using multiple legacy calls (StoreTLSFile, GetPathForTLSFile) here. + err = handler.FileService.StoreTLSFile(folder, portainer.TLSFileCA, r) + if err != nil { + handler.EndpointService.DeleteEndpoint(endpoint.ID) + return nil, err + } + caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA) + endpoint.TLSConfig.TLSCACertPath = caCertPath + } + + if !payload.skipTLSClientVerification { + r := bytes.NewReader(payload.cert) + err = handler.FileService.StoreTLSFile(folder, portainer.TLSFileCert, r) + if err != nil { + handler.EndpointService.DeleteEndpoint(endpoint.ID) + return nil, err + } + certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert) + endpoint.TLSConfig.TLSCertPath = certPath + + r = bytes.NewReader(payload.key) + err = handler.FileService.StoreTLSFile(folder, portainer.TLSFileKey, r) + if err != nil { + handler.EndpointService.DeleteEndpoint(endpoint.ID) + return nil, err + } + keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey) + endpoint.TLSConfig.TLSKeyPath = keyPath + } + + err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) + if err != nil { + return nil, err + } + + return endpoint, nil +} + +func (handler *EndpointHandler) createUnsecuredEndpoint(payload *postEndpointPayload) (*portainer.Endpoint, error) { + endpointType := portainer.DockerEnvironment + + if !strings.HasPrefix(payload.url, "unix://") { + agentOnDockerEnvironment, err := client.ExecutePingOperation(payload.url, nil) + if err != nil { + return nil, err + } + if agentOnDockerEnvironment { + endpointType = portainer.AgentOnDockerEnvironment + } + } + + endpoint := &portainer.Endpoint{ + Name: payload.name, + URL: payload.url, + Type: endpointType, + GroupID: portainer.EndpointGroupID(payload.groupID), + PublicURL: payload.publicURL, + TLSConfig: portainer.TLSConfiguration{ + TLS: false, + }, + AuthorizedUsers: []portainer.UserID{}, + AuthorizedTeams: []portainer.TeamID{}, + Extensions: []portainer.EndpointExtension{}, + } + + err := handler.EndpointService.CreateEndpoint(endpoint) + if err != nil { + return nil, err + } + + return endpoint, nil +} + +func (handler *EndpointHandler) createEndpoint(payload *postEndpointPayload) (*portainer.Endpoint, error) { + if payload.useTLS { + return handler.createTLSSecuredEndpoint(payload) + } + return handler.createUnsecuredEndpoint(payload) +} + +func convertPostEndpointRequestToPayload(r *http.Request) (*postEndpointPayload, error) { + payload := &postEndpointPayload{} + payload.name = r.FormValue("Name") + payload.url = r.FormValue("URL") + payload.publicURL = r.FormValue("PublicURL") + + if payload.name == "" || payload.url == "" { + return nil, ErrInvalidRequestFormat + } + + rawGroupID := r.FormValue("GroupID") + if rawGroupID == "" { + payload.groupID = 1 + } else { + groupID, err := strconv.Atoi(rawGroupID) + if err != nil { + return nil, err + } + payload.groupID = groupID + } + + payload.useTLS = r.FormValue("TLS") == "true" + + if payload.useTLS { + payload.skipTLSServerVerification = r.FormValue("TLSSkipVerify") == "true" + payload.skipTLSClientVerification = r.FormValue("TLSSkipClientVerify") == "true" + + if !payload.skipTLSServerVerification { + caCert, err := getUploadedFileContent(r, "TLSCACertFile") + if err != nil { + return nil, err + } + payload.caCert = caCert + } + + if !payload.skipTLSClientVerification { + cert, err := getUploadedFileContent(r, "TLSCertFile") + if err != nil { + return nil, err + } + payload.cert = cert + key, err := getUploadedFileContent(r, "TLSKeyFile") + if err != nil { + return nil, err + } + payload.key = key + } + } + + return payload, nil +} + // handlePostEndpoints handles POST requests on /endpoints func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *http.Request) { if !handler.authorizeEndpointManagement { @@ -114,60 +301,19 @@ func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *ht return } - var req postEndpointsRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) - return - } - - _, err := govalidator.ValidateStruct(req) + payload, err := convertPostEndpointRequestToPayload(r) if err != nil { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) return } - endpoint := &portainer.Endpoint{ - Name: req.Name, - URL: req.URL, - PublicURL: req.PublicURL, - TLSConfig: portainer.TLSConfiguration{ - TLS: req.TLS, - TLSSkipVerify: req.TLSSkipVerify, - }, - AuthorizedUsers: []portainer.UserID{}, - AuthorizedTeams: []portainer.TeamID{}, - Extensions: []portainer.EndpointExtension{}, - } - - err = handler.EndpointService.CreateEndpoint(endpoint) + endpoint, err := handler.createEndpoint(payload) if err != nil { httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } - if req.TLS { - folder := strconv.Itoa(int(endpoint.ID)) - - if !req.TLSSkipVerify { - caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA) - endpoint.TLSConfig.TLSCACertPath = caCertPath - } - - if !req.TLSSkipClientVerify { - certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert) - endpoint.TLSConfig.TLSCertPath = certPath - keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey) - endpoint.TLSConfig.TLSKeyPath = keyPath - } - - err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - } - - encodeJSON(w, &postEndpointsResponse{ID: int(endpoint.ID)}, handler.Logger) + encodeJSON(w, &endpoint, handler.Logger) } // handleGetEndpoint handles GET requests on /endpoints/:id @@ -297,6 +443,10 @@ func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http endpoint.PublicURL = req.PublicURL } + if req.GroupID != 0 { + endpoint.GroupID = portainer.EndpointGroupID(req.GroupID) + } + folder := strconv.Itoa(int(endpoint.ID)) if req.TLS { endpoint.TLSConfig.TLS = true diff --git a/api/http/handler/endpoint_group.go b/api/http/handler/endpoint_group.go new file mode 100644 index 000000000..064f0dff1 --- /dev/null +++ b/api/http/handler/endpoint_group.go @@ -0,0 +1,364 @@ +package handler + +import ( + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" + + "encoding/json" + "log" + "net/http" + "os" + "strconv" + + "github.com/asaskevich/govalidator" + "github.com/gorilla/mux" +) + +// EndpointGroupHandler represents an HTTP API handler for managing endpoint groups. +type EndpointGroupHandler struct { + *mux.Router + Logger *log.Logger + EndpointService portainer.EndpointService + EndpointGroupService portainer.EndpointGroupService +} + +// NewEndpointGroupHandler returns a new instance of EndpointGroupHandler. +func NewEndpointGroupHandler(bouncer *security.RequestBouncer) *EndpointGroupHandler { + h := &EndpointGroupHandler{ + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + } + h.Handle("/endpoint_groups", + bouncer.AdministratorAccess(http.HandlerFunc(h.handlePostEndpointGroups))).Methods(http.MethodPost) + h.Handle("/endpoint_groups", + bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetEndpointGroups))).Methods(http.MethodGet) + h.Handle("/endpoint_groups/{id}", + bouncer.AdministratorAccess(http.HandlerFunc(h.handleGetEndpointGroup))).Methods(http.MethodGet) + h.Handle("/endpoint_groups/{id}", + bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutEndpointGroup))).Methods(http.MethodPut) + h.Handle("/endpoint_groups/{id}/access", + bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutEndpointGroupAccess))).Methods(http.MethodPut) + h.Handle("/endpoint_groups/{id}", + bouncer.AdministratorAccess(http.HandlerFunc(h.handleDeleteEndpointGroup))).Methods(http.MethodDelete) + + return h +} + +type ( + postEndpointGroupsResponse struct { + ID int `json:"Id"` + } + + postEndpointGroupsRequest struct { + Name string `valid:"required"` + Description string `valid:"-"` + Labels []portainer.Pair `valid:""` + AssociatedEndpoints []portainer.EndpointID `valid:""` + } + + putEndpointGroupAccessRequest struct { + AuthorizedUsers []int `valid:"-"` + AuthorizedTeams []int `valid:"-"` + } + + putEndpointGroupsRequest struct { + Name string `valid:"-"` + Description string `valid:"-"` + Labels []portainer.Pair `valid:""` + AssociatedEndpoints []portainer.EndpointID `valid:""` + } +) + +// handleGetEndpointGroups handles GET requests on /endpoint_groups +func (handler *EndpointGroupHandler) handleGetEndpointGroups(w http.ResponseWriter, r *http.Request) { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + endpointGroups, err := handler.EndpointGroupService.EndpointGroups() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + filteredEndpointGroups, err := security.FilterEndpointGroups(endpointGroups, securityContext) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + encodeJSON(w, filteredEndpointGroups, handler.Logger) +} + +// handlePostEndpointGroups handles POST requests on /endpoint_groups +func (handler *EndpointGroupHandler) handlePostEndpointGroups(w http.ResponseWriter, r *http.Request) { + var req postEndpointGroupsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err := govalidator.ValidateStruct(req) + if err != nil { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + endpointGroup := &portainer.EndpointGroup{ + Name: req.Name, + Description: req.Description, + Labels: req.Labels, + AuthorizedUsers: []portainer.UserID{}, + AuthorizedTeams: []portainer.TeamID{}, + } + + err = handler.EndpointGroupService.CreateEndpointGroup(endpointGroup) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + endpoints, err := handler.EndpointService.Endpoints() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + for _, endpoint := range endpoints { + if endpoint.GroupID == portainer.EndpointGroupID(1) { + err = handler.checkForGroupAssignment(endpoint, endpointGroup.ID, req.AssociatedEndpoints) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + } + } + + encodeJSON(w, &postEndpointGroupsResponse{ID: int(endpointGroup.ID)}, handler.Logger) +} + +// handleGetEndpointGroup handles GET requests on /endpoint_groups/:id +func (handler *EndpointGroupHandler) handleGetEndpointGroup(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + endpointGroupID, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + endpointGroup, err := handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) + if err == portainer.ErrEndpointGroupNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + encodeJSON(w, endpointGroup, handler.Logger) +} + +// handlePutEndpointGroupAccess handles PUT requests on /endpoint_groups/:id/access +func (handler *EndpointGroupHandler) handlePutEndpointGroupAccess(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + endpointGroupID, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + var req putEndpointGroupAccessRequest + if err = json.NewDecoder(r.Body).Decode(&req); err != nil { + httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err = govalidator.ValidateStruct(req) + if err != nil { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + endpointGroup, err := handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) + if err == portainer.ErrEndpointGroupNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if req.AuthorizedUsers != nil { + authorizedUserIDs := []portainer.UserID{} + for _, value := range req.AuthorizedUsers { + authorizedUserIDs = append(authorizedUserIDs, portainer.UserID(value)) + } + endpointGroup.AuthorizedUsers = authorizedUserIDs + } + + if req.AuthorizedTeams != nil { + authorizedTeamIDs := []portainer.TeamID{} + for _, value := range req.AuthorizedTeams { + authorizedTeamIDs = append(authorizedTeamIDs, portainer.TeamID(value)) + } + endpointGroup.AuthorizedTeams = authorizedTeamIDs + } + + err = handler.EndpointGroupService.UpdateEndpointGroup(endpointGroup.ID, endpointGroup) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } +} + +// handlePutEndpointGroup handles PUT requests on /endpoint_groups/:id +func (handler *EndpointGroupHandler) handlePutEndpointGroup(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + endpointGroupID, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + var req putEndpointGroupsRequest + if err = json.NewDecoder(r.Body).Decode(&req); err != nil { + httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err = govalidator.ValidateStruct(req) + if err != nil { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + groupID := portainer.EndpointGroupID(endpointGroupID) + endpointGroup, err := handler.EndpointGroupService.EndpointGroup(groupID) + if err == portainer.ErrEndpointGroupNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if req.Name != "" { + endpointGroup.Name = req.Name + } + + if req.Description != "" { + endpointGroup.Description = req.Description + } + + endpointGroup.Labels = req.Labels + + err = handler.EndpointGroupService.UpdateEndpointGroup(endpointGroup.ID, endpointGroup) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + endpoints, err := handler.EndpointService.Endpoints() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + for _, endpoint := range endpoints { + err = handler.updateEndpointGroup(endpoint, groupID, req.AssociatedEndpoints) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + } +} + +func (handler *EndpointGroupHandler) updateEndpointGroup(endpoint portainer.Endpoint, groupID portainer.EndpointGroupID, associatedEndpoints []portainer.EndpointID) error { + if endpoint.GroupID == groupID { + return handler.checkForGroupUnassignment(endpoint, associatedEndpoints) + } else if endpoint.GroupID == portainer.EndpointGroupID(1) { + return handler.checkForGroupAssignment(endpoint, groupID, associatedEndpoints) + } + return nil +} + +func (handler *EndpointGroupHandler) checkForGroupUnassignment(endpoint portainer.Endpoint, associatedEndpoints []portainer.EndpointID) error { + for _, id := range associatedEndpoints { + if id == endpoint.ID { + return nil + } + } + + endpoint.GroupID = portainer.EndpointGroupID(1) + return handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) +} + +func (handler *EndpointGroupHandler) checkForGroupAssignment(endpoint portainer.Endpoint, groupID portainer.EndpointGroupID, associatedEndpoints []portainer.EndpointID) error { + for _, id := range associatedEndpoints { + + if id == endpoint.ID { + endpoint.GroupID = groupID + return handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) + } + } + return nil +} + +// handleDeleteEndpointGroup handles DELETE requests on /endpoint_groups/:id +func (handler *EndpointGroupHandler) handleDeleteEndpointGroup(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + endpointGroupID, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + if endpointGroupID == 1 { + httperror.WriteErrorResponse(w, portainer.ErrCannotRemoveDefaultGroup, http.StatusForbidden, handler.Logger) + return + } + + groupID := portainer.EndpointGroupID(endpointGroupID) + _, err = handler.EndpointGroupService.EndpointGroup(groupID) + if err == portainer.ErrEndpointGroupNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + err = handler.EndpointGroupService.DeleteEndpointGroup(portainer.EndpointGroupID(endpointGroupID)) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + endpoints, err := handler.EndpointService.Endpoints() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + for _, endpoint := range endpoints { + if endpoint.GroupID == groupID { + endpoint.GroupID = portainer.EndpointGroupID(1) + err = handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + } + } +} diff --git a/api/http/handler/extensions/storidge.go b/api/http/handler/extensions/storidge.go index 1977705e5..bee1cd7b3 100644 --- a/api/http/handler/extensions/storidge.go +++ b/api/http/handler/extensions/storidge.go @@ -20,6 +20,7 @@ type StoridgeHandler struct { *mux.Router Logger *log.Logger EndpointService portainer.EndpointService + EndpointGroupService portainer.EndpointGroupService TeamMembershipService portainer.TeamMembershipService ProxyManager *proxy.Manager } @@ -64,9 +65,17 @@ func (handler *StoridgeHandler) proxyRequestsToStoridgeAPI(w http.ResponseWriter return } - if tokenData.Role != portainer.AdministratorRole && !security.AuthorizedEndpointAccess(endpoint, tokenData.ID, memberships) { - httperror.WriteErrorResponse(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger) - return + if tokenData.Role != portainer.AdministratorRole { + group, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if !security.AuthorizedEndpointAccess(endpoint, group, tokenData.ID, memberships) { + httperror.WriteErrorResponse(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger) + return + } } var storidgeExtension *portainer.EndpointExtension diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index 05eea9ef5..77c6ce478 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -2,6 +2,7 @@ package handler import ( "encoding/json" + "io/ioutil" "log" "net/http" "strings" @@ -18,6 +19,7 @@ type Handler struct { TeamHandler *TeamHandler TeamMembershipHandler *TeamMembershipHandler EndpointHandler *EndpointHandler + EndpointGroupHandler *EndpointGroupHandler RegistryHandler *RegistryHandler DockerHubHandler *DockerHubHandler ExtensionHandler *ExtensionHandler @@ -50,6 +52,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.StripPrefix("/api", h.AuthHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/dockerhub"): http.StripPrefix("/api", h.DockerHubHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/endpoint_groups"): + http.StripPrefix("/api", h.EndpointGroupHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/endpoints"): switch { case strings.Contains(r.URL.Path, "/docker/"): @@ -90,7 +94,24 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // encodeJSON encodes v to w in JSON format. WriteErrorResponse() is called if encoding fails. func encodeJSON(w http.ResponseWriter, v interface{}, logger *log.Logger) { + w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(v); err != nil { httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, logger) } } + +// getUploadedFileContent retrieve the content of a file uploaded in the request. +// Uses requestParameter as the key to retrieve the file in the request payload. +func getUploadedFileContent(request *http.Request, requestParameter string) ([]byte, error) { + file, _, err := request.FormFile(requestParameter) + if err != nil { + return nil, err + } + defer file.Close() + + fileContent, err := ioutil.ReadAll(file) + if err != nil { + return nil, err + } + return fileContent, nil +} diff --git a/api/http/handler/websocket.go b/api/http/handler/websocket.go index 70fc77d9d..3e624d3b4 100644 --- a/api/http/handler/websocket.go +++ b/api/http/handler/websocket.go @@ -1,11 +1,11 @@ package handler import ( + "bufio" "bytes" "crypto/tls" "encoding/json" "fmt" - "io" "log" "net" "net/http" @@ -16,119 +16,135 @@ import ( "time" "github.com/gorilla/mux" + "github.com/gorilla/websocket" + "github.com/koding/websocketproxy" "github.com/portainer/portainer" "github.com/portainer/portainer/crypto" - "golang.org/x/net/websocket" + httperror "github.com/portainer/portainer/http/error" ) -// WebSocketHandler represents an HTTP API handler for proxying requests to a web socket. -type WebSocketHandler struct { - *mux.Router - Logger *log.Logger - EndpointService portainer.EndpointService -} +type ( + // WebSocketHandler represents an HTTP API handler for proxying requests to a web socket. + WebSocketHandler struct { + *mux.Router + Logger *log.Logger + EndpointService portainer.EndpointService + SignatureService portainer.DigitalSignatureService + connectionUpgrader websocket.Upgrader + } + + webSocketExecRequestParams struct { + execID string + nodeName string + endpoint *portainer.Endpoint + } + + execStartOperationPayload struct { + Tty bool + Detach bool + } +) // NewWebSocketHandler returns a new instance of WebSocketHandler. func NewWebSocketHandler() *WebSocketHandler { h := &WebSocketHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + connectionUpgrader: websocket.Upgrader{}, } - h.Handle("/websocket/exec", websocket.Handler(h.webSocketDockerExec)) + h.HandleFunc("/websocket/exec", h.handleWebsocketExec).Methods(http.MethodGet) return h } -func (handler *WebSocketHandler) webSocketDockerExec(ws *websocket.Conn) { - qry := ws.Request().URL.Query() - execID := qry.Get("id") - edpID := qry.Get("endpointId") - - parsedID, err := strconv.Atoi(edpID) - if err != nil { - log.Printf("Unable to parse endpoint ID: %s", err) +// handleWebsocketExec handles GET requests on /websocket/exec?id=&endpointId=&nodeName= +// If the nodeName query parameter is present, the request will be proxied to the underlying agent endpoint. +// If the nodeName query parameter is not specified, the request will be upgraded to the websocket protocol and +// an ExecStart operation HTTP request will be created and hijacked. +func (handler *WebSocketHandler) handleWebsocketExec(w http.ResponseWriter, r *http.Request) { + paramExecID := r.FormValue("id") + paramEndpointID := r.FormValue("endpointId") + if paramExecID == "" || paramEndpointID == "" { + httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger) return } - endpointID := portainer.EndpointID(parsedID) - endpoint, err := handler.EndpointService.Endpoint(endpointID) + endpointID, err := strconv.Atoi(paramEndpointID) if err != nil { - log.Printf("Unable to retrieve endpoint: %s", err) + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) return } - endpointURL, err := url.Parse(endpoint.URL) + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) if err != nil { - log.Printf("Unable to parse endpoint URL: %s", err) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } - var host string - if endpointURL.Scheme == "tcp" { - host = endpointURL.Host - } else if endpointURL.Scheme == "unix" { - host = endpointURL.Path + params := &webSocketExecRequestParams{ + endpoint: endpoint, + execID: paramExecID, + nodeName: r.FormValue("nodeName"), } - // TODO: Should not be managed here - var tlsConfig *tls.Config - if endpoint.TLSConfig.TLS { - tlsConfig, err = crypto.CreateTLSConfiguration(&endpoint.TLSConfig) - if err != nil { - log.Fatalf("Unable to create TLS configuration: %s", err) - return + err = handler.handleRequest(w, r, params) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } +} + +func (handler *WebSocketHandler) handleRequest(w http.ResponseWriter, r *http.Request, params *webSocketExecRequestParams) error { + if params.nodeName != "" { + return handler.proxyWebsocketRequest(w, r, params) + } + + websocketConn, err := handler.connectionUpgrader.Upgrade(w, r, nil) + if err != nil { + return err + } + defer websocketConn.Close() + + return hijackExecStartOperation(websocketConn, params.endpoint, params.execID) +} + +func (handler *WebSocketHandler) proxyWebsocketRequest(w http.ResponseWriter, r *http.Request, params *webSocketExecRequestParams) error { + agentURL, err := url.Parse(params.endpoint.URL) + if err != nil { + return err + } + + agentURL.Scheme = "ws" + proxy := websocketproxy.NewProxy(agentURL) + + if params.endpoint.TLSConfig.TLS || params.endpoint.TLSConfig.TLSSkipVerify { + agentURL.Scheme = "wss" + proxy.Dialer = &websocket.Dialer{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: params.endpoint.TLSConfig.TLSSkipVerify, + }, } } - 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 + signature, err := handler.SignatureService.Sign(portainer.PortainerAgentSignatureMessage) + if err != nil { + return err } + + proxy.Director = func(incoming *http.Request, out http.Header) { + out.Set(portainer.PortainerAgentSignatureHeader, signature) + out.Set(portainer.PortainerAgentTargetHeader, params.nodeName) + } + + r.Header.Del("Origin") + proxy.ServeHTTP(w, r) + + return nil } -type execConfig struct { - Tty bool - Detach bool -} - -// hijack allows to upgrade an HTTP connection to a TCP connection -// It redirects IO streams for stdin, stdout and stderr to a websocket -func hijack(addr, scheme, method, path string, tlsConfig *tls.Config, setRawTerminal bool, in io.ReadCloser, stdout, stderr io.Writer, started chan io.Closer, data interface{}) error { - execConfig := &execConfig{ - Tty: true, - Detach: false, - } - - buf, err := json.Marshal(execConfig) +func hijackExecStartOperation(websocketConn *websocket.Conn, endpoint *portainer.Endpoint, execID string) error { + dial, err := createDial(endpoint) if err != nil { - return fmt.Errorf("error marshaling exec config: %s", err) - } - - rdr := bytes.NewReader(buf) - - req, err := http.NewRequest(method, path, rdr) - if err != nil { - return fmt.Errorf("error during hijack request: %s", err) - } - - req.Header.Set("User-Agent", "Docker-Client") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Connection", "Upgrade") - req.Header.Set("Upgrade", "tcp") - req.Host = addr - - var ( - dial net.Conn - dialErr error - ) - - if tlsConfig == nil { - dial, dialErr = net.Dial(scheme, addr) - } else { - dial, dialErr = tls.Dial(scheme, addr, tlsConfig) - } - - if dialErr != nil { - return dialErr + return err } // When we set up a TCP connection for hijack, there could be long periods @@ -140,57 +156,128 @@ func hijack(addr, scheme, method, path string, tlsConfig *tls.Config, setRawTerm tcpConn.SetKeepAlive(true) tcpConn.SetKeepAlivePeriod(30 * time.Second) } + + httpConn := httputil.NewClientConn(dial, nil) + defer httpConn.Close() + + execStartRequest, err := createExecStartRequest(execID) if err != nil { return err } - clientconn := httputil.NewClientConn(dial, nil) - defer clientconn.Close() - // Server hijacks the connection, error 'connection closed' expected - clientconn.Do(req) - - rwc, br := clientconn.Hijack() - defer rwc.Close() - - if started != nil { - started <- rwc + err = hijackRequest(websocketConn, httpConn, execStartRequest) + if err != nil { + return err } - var receiveStdout chan error - - if stdout != nil || stderr != nil { - go func() (err error) { - if setRawTerminal && stdout != nil { - _, err = io.Copy(stdout, br) - } - return err - }() - } - - go func() error { - if in != nil { - io.Copy(rwc, in) - } - - if conn, ok := rwc.(interface { - CloseWrite() error - }); ok { - if err := conn.CloseWrite(); err != nil { - } - } - return nil - }() - - if stdout != nil || stderr != nil { - if err := <-receiveStdout; err != nil { - return err - } - } - go func() { - for { - fmt.Println(br) - } - }() - return nil } + +func createDial(endpoint *portainer.Endpoint) (net.Conn, error) { + url, err := url.Parse(endpoint.URL) + if err != nil { + return nil, err + } + + var host string + if url.Scheme == "tcp" { + host = url.Host + } else if url.Scheme == "unix" { + host = url.Path + } + + if endpoint.TLSConfig.TLS { + tlsConfig, err := crypto.CreateTLSConfiguration(&endpoint.TLSConfig) + if err != nil { + return nil, err + } + return tls.Dial(url.Scheme, host, tlsConfig) + } + + return net.Dial(url.Scheme, host) +} + +func createExecStartRequest(execID string) (*http.Request, error) { + execStartOperationPayload := &execStartOperationPayload{ + Tty: true, + Detach: false, + } + + encodedBody := bytes.NewBuffer(nil) + err := json.NewEncoder(encodedBody).Encode(execStartOperationPayload) + if err != nil { + return nil, err + } + + request, err := http.NewRequest("POST", "/exec/"+execID+"/start", encodedBody) + if err != nil { + return nil, err + } + + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Connection", "Upgrade") + request.Header.Set("Upgrade", "tcp") + + return request, nil +} + +func hijackRequest(websocketConn *websocket.Conn, httpConn *httputil.ClientConn, request *http.Request) error { + // Server hijacks the connection, error 'connection closed' expected + resp, err := httpConn.Do(request) + if err != httputil.ErrPersistEOF { + if err != nil { + return err + } + if resp.StatusCode != http.StatusSwitchingProtocols { + resp.Body.Close() + return fmt.Errorf("unable to upgrade to tcp, received %d", resp.StatusCode) + } + } + + tcpConn, brw := httpConn.Hijack() + defer tcpConn.Close() + + errorChan := make(chan error, 1) + go streamFromTCPConnToWebsocketConn(websocketConn, brw, errorChan) + go streamFromWebsocketConnToTCPConn(websocketConn, tcpConn, errorChan) + + err = <-errorChan + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) { + return err + } + + return nil +} + +func streamFromWebsocketConnToTCPConn(websocketConn *websocket.Conn, tcpConn net.Conn, errorChan chan error) { + for { + _, in, err := websocketConn.ReadMessage() + if err != nil { + errorChan <- err + break + } + + _, err = tcpConn.Write(in) + if err != nil { + errorChan <- err + break + } + } +} + +func streamFromTCPConnToWebsocketConn(websocketConn *websocket.Conn, br *bufio.Reader, errorChan chan error) { + for { + out := make([]byte, 1024) + _, err := br.Read(out) + if err != nil { + errorChan <- err + break + } + + err = websocketConn.WriteMessage(websocket.TextMessage, out) + if err != nil { + errorChan <- err + break + } + } +} diff --git a/api/http/proxy/access_control.go b/api/http/proxy/access_control.go index eece43d9c..408ff8c7d 100644 --- a/api/http/proxy/access_control.go +++ b/api/http/proxy/access_control.go @@ -98,9 +98,12 @@ func canUserAccessResource(userID portainer.UserID, userTeamIDs []portainer.Team } func decorateObject(object map[string]interface{}, resourceControl *portainer.ResourceControl) map[string]interface{} { - metadata := make(map[string]interface{}) - metadata["ResourceControl"] = resourceControl - object["Portainer"] = metadata + if object["Portainer"] == nil { + object["Portainer"] = make(map[string]interface{}) + } + + portainerMetadata := object["Portainer"].(map[string]interface{}) + portainerMetadata["ResourceControl"] = resourceControl return object } diff --git a/api/http/proxy/configs.go b/api/http/proxy/configs.go index 16904c6c2..fc05dd74c 100644 --- a/api/http/proxy/configs.go +++ b/api/http/proxy/configs.go @@ -14,7 +14,7 @@ const ( // configListOperation extracts the response as a JSON object, loop through the configs array // decorate and/or filter the configs based on resource controls before rewriting the response -func configListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { +func configListOperation(response *http.Response, executor *operationExecutor) error { var err error // ConfigList response is a JSON array @@ -39,7 +39,7 @@ func configListOperation(request *http.Request, response *http.Response, executo // configInspectOperation extracts the response as a JSON object, verify that the user // has access to the config based on resource control (check are done based on the configID and optional Swarm service ID) // and either rewrite an access denied response or a decorated config. -func configInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { +func configInspectOperation(response *http.Response, executor *operationExecutor) error { // ConfigInspect response is a JSON object // https://docs.docker.com/engine/api/v1.30/#operation/ConfigInspect responseObject, err := getResponseAsJSONOBject(response) diff --git a/api/http/proxy/containers.go b/api/http/proxy/containers.go index 6992ae623..b05149940 100644 --- a/api/http/proxy/containers.go +++ b/api/http/proxy/containers.go @@ -16,7 +16,7 @@ const ( // containerListOperation extracts the response as a JSON object, loop through the containers array // decorate and/or filter the containers based on resource controls before rewriting the response -func containerListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { +func containerListOperation(response *http.Response, executor *operationExecutor) error { var err error // ContainerList response is a JSON array // https://docs.docker.com/engine/api/v1.28/#operation/ContainerList @@ -47,7 +47,7 @@ func containerListOperation(request *http.Request, response *http.Response, exec // containerInspectOperation extracts the response as a JSON object, verify that the user // has access to the container based on resource control (check are done based on the containerID and optional Swarm service ID) // and either rewrite an access denied response or a decorated container. -func containerInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { +func containerInspectOperation(response *http.Response, executor *operationExecutor) error { // ContainerInspect response is a JSON object // https://docs.docker.com/engine/api/v1.28/#operation/ContainerInspect responseObject, err := getResponseAsJSONOBject(response) diff --git a/api/http/proxy/factory.go b/api/http/proxy/factory.go index 91015367c..9df363332 100644 --- a/api/http/proxy/factory.go +++ b/api/http/proxy/factory.go @@ -17,6 +17,7 @@ type proxyFactory struct { SettingsService portainer.SettingsService RegistryService portainer.RegistryService DockerHubService portainer.DockerHubService + SignatureService portainer.DigitalSignatureService } func (factory *proxyFactory) newExtensionHTTPPRoxy(u *url.URL) http.Handler { @@ -24,10 +25,11 @@ func (factory *proxyFactory) newExtensionHTTPPRoxy(u *url.URL) http.Handler { return newSingleHostReverseProxyWithHostHeader(u) } -func (factory *proxyFactory) newDockerHTTPSProxy(u *url.URL, endpoint *portainer.Endpoint) (http.Handler, error) { +func (factory *proxyFactory) newDockerHTTPSProxy(u *url.URL, tlsConfig *portainer.TLSConfiguration, enableSignature bool) (http.Handler, error) { u.Scheme = "https" - proxy := factory.createDockerReverseProxy(u) - config, err := crypto.CreateTLSConfiguration(&endpoint.TLSConfig) + + proxy := factory.createDockerReverseProxy(u, enableSignature) + config, err := crypto.CreateTLSConfiguration(tlsConfig) if err != nil { return nil, err } @@ -36,14 +38,15 @@ func (factory *proxyFactory) newDockerHTTPSProxy(u *url.URL, endpoint *portainer return proxy, nil } -func (factory *proxyFactory) newDockerHTTPProxy(u *url.URL) http.Handler { +func (factory *proxyFactory) newDockerHTTPProxy(u *url.URL, enableSignature bool) http.Handler { u.Scheme = "http" - return factory.createDockerReverseProxy(u) + return factory.createDockerReverseProxy(u, enableSignature) } func (factory *proxyFactory) newDockerSocketProxy(path string) http.Handler { proxy := &socketProxy{} transport := &proxyTransport{ + enableSignature: false, ResourceControlService: factory.ResourceControlService, TeamMembershipService: factory.TeamMembershipService, SettingsService: factory.SettingsService, @@ -55,9 +58,10 @@ func (factory *proxyFactory) newDockerSocketProxy(path string) http.Handler { return proxy } -func (factory *proxyFactory) createDockerReverseProxy(u *url.URL) *httputil.ReverseProxy { +func (factory *proxyFactory) createDockerReverseProxy(u *url.URL, enableSignature bool) *httputil.ReverseProxy { proxy := newSingleHostReverseProxyWithHostHeader(u) transport := &proxyTransport{ + enableSignature: enableSignature, ResourceControlService: factory.ResourceControlService, TeamMembershipService: factory.TeamMembershipService, SettingsService: factory.SettingsService, @@ -65,6 +69,11 @@ func (factory *proxyFactory) createDockerReverseProxy(u *url.URL) *httputil.Reve DockerHubService: factory.DockerHubService, dockerTransport: &http.Transport{}, } + + if enableSignature { + transport.SignatureService = factory.SignatureService + } + proxy.Transport = transport return proxy } diff --git a/api/http/proxy/manager.go b/api/http/proxy/manager.go index 747c7870a..c1906e45b 100644 --- a/api/http/proxy/manager.go +++ b/api/http/proxy/manager.go @@ -9,24 +9,37 @@ import ( "github.com/portainer/portainer" ) -// Manager represents a service used to manage Docker proxies. -type Manager struct { - proxyFactory *proxyFactory - proxies cmap.ConcurrentMap - extensionProxies cmap.ConcurrentMap -} +type ( + // Manager represents a service used to manage Docker proxies. + Manager struct { + proxyFactory *proxyFactory + proxies cmap.ConcurrentMap + extensionProxies cmap.ConcurrentMap + } + + // ManagerParams represents the required parameters to create a new Manager instance. + ManagerParams struct { + ResourceControlService portainer.ResourceControlService + TeamMembershipService portainer.TeamMembershipService + SettingsService portainer.SettingsService + RegistryService portainer.RegistryService + DockerHubService portainer.DockerHubService + SignatureService portainer.DigitalSignatureService + } +) // NewManager initializes a new proxy Service -func NewManager(resourceControlService portainer.ResourceControlService, teamMembershipService portainer.TeamMembershipService, settingsService portainer.SettingsService, registryService portainer.RegistryService, dockerHubService portainer.DockerHubService) *Manager { +func NewManager(parameters *ManagerParams) *Manager { return &Manager{ proxies: cmap.New(), extensionProxies: cmap.New(), proxyFactory: &proxyFactory{ - ResourceControlService: resourceControlService, - TeamMembershipService: teamMembershipService, - SettingsService: settingsService, - RegistryService: registryService, - DockerHubService: dockerHubService, + ResourceControlService: parameters.ResourceControlService, + TeamMembershipService: parameters.TeamMembershipService, + SettingsService: parameters.SettingsService, + RegistryService: parameters.RegistryService, + DockerHubService: parameters.DockerHubService, + SignatureService: parameters.SignatureService, }, } } @@ -41,14 +54,19 @@ func (manager *Manager) CreateAndRegisterProxy(endpoint *portainer.Endpoint) (ht return nil, err } + enableSignature := false + if endpoint.Type == portainer.AgentOnDockerEnvironment { + enableSignature = true + } + if endpointURL.Scheme == "tcp" { - if endpoint.TLSConfig.TLS { - proxy, err = manager.proxyFactory.newDockerHTTPSProxy(endpointURL, endpoint) + if endpoint.TLSConfig.TLS || endpoint.TLSConfig.TLSSkipVerify { + proxy, err = manager.proxyFactory.newDockerHTTPSProxy(endpointURL, &endpoint.TLSConfig, enableSignature) if err != nil { return nil, err } } else { - proxy = manager.proxyFactory.newDockerHTTPProxy(endpointURL) + proxy = manager.proxyFactory.newDockerHTTPProxy(endpointURL, enableSignature) } } else { // Assume unix:// scheme diff --git a/api/http/proxy/networks.go b/api/http/proxy/networks.go index aa1bcea70..7e40d385a 100644 --- a/api/http/proxy/networks.go +++ b/api/http/proxy/networks.go @@ -15,7 +15,7 @@ const ( // networkListOperation extracts the response as a JSON object, loop through the networks array // decorate and/or filter the networks based on resource controls before rewriting the response -func networkListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { +func networkListOperation(response *http.Response, executor *operationExecutor) error { var err error // NetworkList response is a JSON array // https://docs.docker.com/engine/api/v1.28/#operation/NetworkList @@ -39,7 +39,7 @@ func networkListOperation(request *http.Request, response *http.Response, execut // networkInspectOperation extracts the response as a JSON object, verify that the user // has access to the network based on resource control and either rewrite an access denied response // or a decorated network. -func networkInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { +func networkInspectOperation(response *http.Response, executor *operationExecutor) error { // NetworkInspect response is a JSON object // https://docs.docker.com/engine/api/v1.28/#operation/NetworkInspect responseObject, err := getResponseAsJSONOBject(response) diff --git a/api/http/proxy/response.go b/api/http/proxy/response.go index 4208f438c..751e3df31 100644 --- a/api/http/proxy/response.go +++ b/api/http/proxy/response.go @@ -13,6 +13,8 @@ import ( const ( // 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") + // ErrInvalidResponseContent defines an error raised when Portainer excepts a JSON array and get something else. + ErrInvalidResponseContent = portainer.Error("Invalid Docker response") ) func extractJSONField(jsonObject map[string]interface{}, key string) map[string]interface{} { @@ -39,8 +41,17 @@ func getResponseAsJSONArray(response *http.Response) ([]interface{}, error) { return nil, err } - responseObject := responseData.([]interface{}) - return responseObject, nil + switch responseObject := responseData.(type) { + case []interface{}: + return responseObject, nil + case map[string]interface{}: + if responseObject["message"] != nil { + return nil, portainer.Error(responseObject["message"].(string)) + } + return nil, ErrInvalidResponseContent + default: + return nil, ErrInvalidResponseContent + } } func getResponseBodyAsGenericJSON(response *http.Response) (interface{}, error) { diff --git a/api/http/proxy/secrets.go b/api/http/proxy/secrets.go index cbef6ac84..9c4d8b093 100644 --- a/api/http/proxy/secrets.go +++ b/api/http/proxy/secrets.go @@ -14,7 +14,7 @@ const ( // secretListOperation extracts the response as a JSON object, loop through the secrets array // decorate and/or filter the secrets based on resource controls before rewriting the response -func secretListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { +func secretListOperation(response *http.Response, executor *operationExecutor) error { var err error // SecretList response is a JSON array @@ -39,7 +39,7 @@ func secretListOperation(request *http.Request, response *http.Response, executo // secretInspectOperation extracts the response as a JSON object, verify that the user // has access to the secret based on resource control (check are done based on the secretID and optional Swarm service ID) // and either rewrite an access denied response or a decorated secret. -func secretInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { +func secretInspectOperation(response *http.Response, executor *operationExecutor) error { // SecretInspect response is a JSON object // https://docs.docker.com/engine/api/v1.28/#operation/SecretInspect responseObject, err := getResponseAsJSONOBject(response) diff --git a/api/http/proxy/services.go b/api/http/proxy/services.go index fa010b022..9934cf381 100644 --- a/api/http/proxy/services.go +++ b/api/http/proxy/services.go @@ -15,7 +15,7 @@ const ( // serviceListOperation extracts the response as a JSON array, loop through the service array // decorate and/or filter the services based on resource controls before rewriting the response -func serviceListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { +func serviceListOperation(response *http.Response, executor *operationExecutor) error { var err error // ServiceList response is a JSON array // https://docs.docker.com/engine/api/v1.28/#operation/ServiceList @@ -39,7 +39,7 @@ func serviceListOperation(request *http.Request, response *http.Response, execut // serviceInspectOperation extracts the response as a JSON object, verify that the user // has access to the service based on resource control and either rewrite an access denied response // or a decorated service. -func serviceInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { +func serviceInspectOperation(response *http.Response, executor *operationExecutor) error { // ServiceInspect response is a JSON object // https://docs.docker.com/engine/api/v1.28/#operation/ServiceInspect responseObject, err := getResponseAsJSONOBject(response) diff --git a/api/http/proxy/tasks.go b/api/http/proxy/tasks.go index 9187a0530..daea6b926 100644 --- a/api/http/proxy/tasks.go +++ b/api/http/proxy/tasks.go @@ -15,7 +15,7 @@ const ( // taskListOperation extracts the response as a JSON object, loop through the tasks array // and filter the tasks based on resource controls before rewriting the response -func taskListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { +func taskListOperation(response *http.Response, executor *operationExecutor) error { var err error // TaskList response is a JSON array diff --git a/api/http/proxy/transport.go b/api/http/proxy/transport.go index 0e61dc1a5..9effb54d8 100644 --- a/api/http/proxy/transport.go +++ b/api/http/proxy/transport.go @@ -5,20 +5,25 @@ import ( "encoding/json" "net/http" "path" + "regexp" "strings" "github.com/portainer/portainer" "github.com/portainer/portainer/http/security" ) +var apiVersionRe = regexp.MustCompile(`(/v[0-9]\.[0-9]*)?`) + type ( proxyTransport struct { dockerTransport *http.Transport + enableSignature bool ResourceControlService portainer.ResourceControlService TeamMembershipService portainer.TeamMembershipService RegistryService portainer.RegistryService DockerHubService portainer.DockerHubService SettingsService portainer.SettingsService + SignatureService portainer.DigitalSignatureService } restrictedOperationContext struct { isAdmin bool @@ -42,7 +47,7 @@ type ( operationContext *restrictedOperationContext labelBlackList []portainer.Pair } - restrictedOperationRequest func(*http.Request, *http.Response, *operationExecutor) error + restrictedOperationRequest func(*http.Response, *operationExecutor) error operationRequest func(*http.Request) error ) @@ -55,7 +60,18 @@ func (p *proxyTransport) executeDockerRequest(request *http.Request) (*http.Resp } func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Response, error) { - path := request.URL.Path + path := apiVersionRe.ReplaceAllString(request.URL.Path, "") + request.URL.Path = path + + if p.enableSignature { + signature, err := p.SignatureService.Sign(portainer.PortainerAgentSignatureMessage) + if err != nil { + return nil, err + } + + request.Header.Set(portainer.PortainerAgentPublicKeyHeader, p.SignatureService.EncodedPublicKey()) + request.Header.Set(portainer.PortainerAgentSignatureHeader, signature) + } switch { case strings.HasPrefix(path, "/configs"): @@ -258,7 +274,7 @@ func (p *proxyTransport) proxyImageRequest(request *http.Request) (*http.Respons case "/images/create": return p.replaceRegistryAuthenticationHeader(request) default: - if match, _ := path.Match("/images/*/push", requestPath); match { + if path.Base(requestPath) == "push" && request.Method == http.MethodPost { return p.replaceRegistryAuthenticationHeader(request) } return p.executeDockerRequest(request) @@ -388,7 +404,7 @@ func (p *proxyTransport) executeRequestAndRewriteResponse(request *http.Request, return response, err } - err = operation(request, response, executor) + err = operation(response, executor) return response, err } diff --git a/api/http/proxy/volumes.go b/api/http/proxy/volumes.go index a8bc25ccf..c0582a6bd 100644 --- a/api/http/proxy/volumes.go +++ b/api/http/proxy/volumes.go @@ -15,7 +15,7 @@ const ( // volumeListOperation extracts the response as a JSON object, loop through the volume array // decorate and/or filter the volumes based on resource controls before rewriting the response -func volumeListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { +func volumeListOperation(response *http.Response, executor *operationExecutor) error { var err error // VolumeList response is a JSON object // https://docs.docker.com/engine/api/v1.28/#operation/VolumeList @@ -48,7 +48,7 @@ func volumeListOperation(request *http.Request, response *http.Response, executo // volumeInspectOperation extracts the response as a JSON object, verify that the user // has access to the volume based on any existing resource control and either rewrite an access denied response // or a decorated volume. -func volumeInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { +func volumeInspectOperation(response *http.Response, executor *operationExecutor) error { // VolumeInspect response is a JSON object // https://docs.docker.com/engine/api/v1.28/#operation/VolumeInspect responseObject, err := getResponseAsJSONOBject(response) diff --git a/api/http/security/authorization.go b/api/http/security/authorization.go index 54932f673..a2efe216a 100644 --- a/api/http/security/authorization.go +++ b/api/http/security/authorization.go @@ -124,34 +124,37 @@ func AuthorizedUserManagement(userID portainer.UserID, context *RestrictedReques // AuthorizedEndpointAccess ensure that the user can access the specified endpoint. // It will check if the user is part of the authorized users or part of a team that is +// listed in the authorized teams of the endpoint and the associated group. +func AuthorizedEndpointAccess(endpoint *portainer.Endpoint, endpointGroup *portainer.EndpointGroup, userID portainer.UserID, memberships []portainer.TeamMembership) bool { + groupAccess := authorizedAccess(userID, memberships, endpointGroup.AuthorizedUsers, endpointGroup.AuthorizedTeams) + if !groupAccess { + return authorizedAccess(userID, memberships, endpoint.AuthorizedUsers, endpoint.AuthorizedTeams) + } + return true +} + +// AuthorizedEndpointGroupAccess ensure that the user can access the specified endpoint group. +// It will check if the user is part of the authorized users or part of a team that is // listed in the authorized teams. -func AuthorizedEndpointAccess(endpoint *portainer.Endpoint, userID portainer.UserID, memberships []portainer.TeamMembership) bool { - for _, authorizedUserID := range endpoint.AuthorizedUsers { - if authorizedUserID == userID { - return true - } - } - for _, membership := range memberships { - for _, authorizedTeamID := range endpoint.AuthorizedTeams { - if membership.TeamID == authorizedTeamID { - return true - } - } - } - return false +func AuthorizedEndpointGroupAccess(endpointGroup *portainer.EndpointGroup, userID portainer.UserID, memberships []portainer.TeamMembership) bool { + return authorizedAccess(userID, memberships, endpointGroup.AuthorizedUsers, endpointGroup.AuthorizedTeams) } // AuthorizedRegistryAccess ensure that the user can access the specified registry. // It will check if the user is part of the authorized users or part of a team that is // listed in the authorized teams. func AuthorizedRegistryAccess(registry *portainer.Registry, userID portainer.UserID, memberships []portainer.TeamMembership) bool { - for _, authorizedUserID := range registry.AuthorizedUsers { + return authorizedAccess(userID, memberships, registry.AuthorizedUsers, registry.AuthorizedTeams) +} + +func authorizedAccess(userID portainer.UserID, memberships []portainer.TeamMembership, authorizedUsers []portainer.UserID, authorizedTeams []portainer.TeamID) bool { + for _, authorizedUserID := range authorizedUsers { if authorizedUserID == userID { return true } } for _, membership := range memberships { - for _, authorizedTeamID := range registry.AuthorizedTeams { + for _, authorizedTeamID := range authorizedTeams { if membership.TeamID == authorizedTeamID { return true } diff --git a/api/http/security/filter.go b/api/http/security/filter.go index ffe5e1c49..5c1b0774f 100644 --- a/api/http/security/filter.go +++ b/api/http/security/filter.go @@ -79,15 +79,17 @@ func FilterRegistries(registries []portainer.Registry, context *RestrictedReques } // FilterEndpoints filters endpoints based on user role and team memberships. -// Non administrator users only have access to authorized endpoints. -func FilterEndpoints(endpoints []portainer.Endpoint, context *RestrictedRequestContext) ([]portainer.Endpoint, error) { +// Non administrator users only have access to authorized endpoints (can be inherited via endoint groups). +func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.EndpointGroup, context *RestrictedRequestContext) ([]portainer.Endpoint, error) { filteredEndpoints := endpoints if !context.IsAdmin { filteredEndpoints = make([]portainer.Endpoint, 0) for _, endpoint := range endpoints { - if AuthorizedEndpointAccess(&endpoint, context.UserID, context.UserMemberships) { + endpointGroup := getAssociatedGroup(&endpoint, groups) + + if AuthorizedEndpointAccess(&endpoint, endpointGroup, context.UserID, context.UserMemberships) { filteredEndpoints = append(filteredEndpoints, endpoint) } } @@ -95,3 +97,30 @@ func FilterEndpoints(endpoints []portainer.Endpoint, context *RestrictedRequestC return filteredEndpoints, nil } + +// FilterEndpointGroups filters endpoint groups based on user role and team memberships. +// Non administrator users only have access to authorized endpoint groups. +func FilterEndpointGroups(endpointGroups []portainer.EndpointGroup, context *RestrictedRequestContext) ([]portainer.EndpointGroup, error) { + filteredEndpointGroups := endpointGroups + + if !context.IsAdmin { + filteredEndpointGroups = make([]portainer.EndpointGroup, 0) + + for _, group := range endpointGroups { + if AuthorizedEndpointGroupAccess(&group, context.UserID, context.UserMemberships) { + filteredEndpointGroups = append(filteredEndpointGroups, group) + } + } + } + + return filteredEndpointGroups, nil +} + +func getAssociatedGroup(endpoint *portainer.Endpoint, groups []portainer.EndpointGroup) *portainer.EndpointGroup { + for _, group := range groups { + if group.ID == endpoint.GroupID { + return &group + } + } + return nil +} diff --git a/api/http/security/rate_limiter.go b/api/http/security/rate_limiter.go new file mode 100644 index 000000000..0eb89e0c1 --- /dev/null +++ b/api/http/security/rate_limiter.go @@ -0,0 +1,47 @@ +package security + +import ( + "net/http" + "strings" + "time" + + "github.com/g07cha/defender" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" +) + +// RateLimiter represents an entity that manages request rate limiting +type RateLimiter struct { + *defender.Defender +} + +// NewRateLimiter initializes a new RateLimiter +func NewRateLimiter(maxRequests int, duration time.Duration, banDuration time.Duration) *RateLimiter { + messages := make(chan struct{}) + limiter := defender.New(maxRequests, duration, banDuration) + go limiter.CleanupTask(messages) + return &RateLimiter{ + limiter, + } +} + +// LimitAccess wraps current request with check if remote address does not goes above the defined limits +func (limiter *RateLimiter) LimitAccess(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ip := StripAddrPort(r.RemoteAddr) + if banned := limiter.Inc(ip); banned == true { + httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil) + return + } + next.ServeHTTP(w, r) + }) +} + +// StripAddrPort removes port from IP address +func StripAddrPort(addr string) string { + portIndex := strings.LastIndex(addr, ":") + if portIndex != -1 { + addr = addr[:portIndex] + } + return addr +} diff --git a/api/http/security/rate_limiter_test.go b/api/http/security/rate_limiter_test.go new file mode 100644 index 000000000..49fc79030 --- /dev/null +++ b/api/http/security/rate_limiter_test.go @@ -0,0 +1,69 @@ +package security + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestLimitAccess(t *testing.T) { + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + t.Run("Request below the limit", func(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + rr := httptest.NewRecorder() + rateLimiter := NewRateLimiter(10, 1*time.Second, 1*time.Hour) + handler := rateLimiter.LimitAccess(testHandler) + + handler.ServeHTTP(rr, req) + + if status := rr.Code; status != http.StatusOK { + t.Errorf("handler returned wrong status code: got %v want %v", + status, http.StatusOK) + } + }) + + t.Run("Request above the limit", func(t *testing.T) { + rateLimiter := NewRateLimiter(1, 1*time.Second, 1*time.Hour) + handler := rateLimiter.LimitAccess(testHandler) + + ts := httptest.NewServer(handler) + defer ts.Close() + http.Get(ts.URL) + resp, err := http.Get(ts.URL) + if err != nil { + t.Fatal(err) + } + + if status := resp.StatusCode; status != http.StatusForbidden { + t.Errorf("handler returned wrong status code: got %v want %v", + status, http.StatusForbidden) + } + }) +} + +func TestStripAddrPort(t *testing.T) { + t.Run("IP with port", func(t *testing.T) { + result := StripAddrPort("127.0.0.1:1000") + if result != "127.0.0.1" { + t.Errorf("Expected IP with address to be '127.0.0.1', but it was %s instead", result) + } + }) + + t.Run("IP without port", func(t *testing.T) { + result := StripAddrPort("127.0.0.1") + if result != "127.0.0.1" { + t.Errorf("Expected IP with address to be '127.0.0.1', but it was %s instead", result) + } + }) + + t.Run("Local IP", func(t *testing.T) { + result := StripAddrPort("[::1]:1000") + if result != "[::1]" { + t.Errorf("Expected IP with address to be '[::1]', but it was %s instead", result) + } + }) +} diff --git a/api/http/server.go b/api/http/server.go index 536344f68..5e85f8efa 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -1,6 +1,8 @@ package http import ( + "time" + "github.com/portainer/portainer" "github.com/portainer/portainer/http/handler" "github.com/portainer/portainer/http/handler/extensions" @@ -22,6 +24,7 @@ type Server struct { TeamService portainer.TeamService TeamMembershipService portainer.TeamMembershipService EndpointService portainer.EndpointService + EndpointGroupService portainer.EndpointGroupService ResourceControlService portainer.ResourceControlService SettingsService portainer.SettingsService CryptoService portainer.CryptoService @@ -33,6 +36,7 @@ type Server struct { StackManager portainer.StackManager LDAPService portainer.LDAPService GitService portainer.GitService + SignatureService portainer.DigitalSignatureService Handler *handler.Handler SSL bool SSLCert string @@ -42,10 +46,19 @@ type Server struct { // Start starts the HTTP server func (server *Server) Start() error { requestBouncer := security.NewRequestBouncer(server.JWTService, server.UserService, server.TeamMembershipService, server.AuthDisabled) - proxyManager := proxy.NewManager(server.ResourceControlService, server.TeamMembershipService, server.SettingsService, server.RegistryService, server.DockerHubService) + proxyManagerParameters := &proxy.ManagerParams{ + ResourceControlService: server.ResourceControlService, + TeamMembershipService: server.TeamMembershipService, + SettingsService: server.SettingsService, + RegistryService: server.RegistryService, + DockerHubService: server.DockerHubService, + SignatureService: server.SignatureService, + } + proxyManager := proxy.NewManager(proxyManagerParameters) + rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour) var fileHandler = handler.NewFileHandler(filepath.Join(server.AssetsPath, "public")) - var authHandler = handler.NewAuthHandler(requestBouncer, server.AuthDisabled) + var authHandler = handler.NewAuthHandler(requestBouncer, rateLimiter, server.AuthDisabled) authHandler.UserService = server.UserService authHandler.CryptoService = server.CryptoService authHandler.JWTService = server.JWTService @@ -72,14 +85,20 @@ func (server *Server) Start() error { templatesHandler.SettingsService = server.SettingsService var dockerHandler = handler.NewDockerHandler(requestBouncer) dockerHandler.EndpointService = server.EndpointService + dockerHandler.EndpointGroupService = server.EndpointGroupService dockerHandler.TeamMembershipService = server.TeamMembershipService dockerHandler.ProxyManager = proxyManager var websocketHandler = handler.NewWebSocketHandler() websocketHandler.EndpointService = server.EndpointService + websocketHandler.SignatureService = server.SignatureService var endpointHandler = handler.NewEndpointHandler(requestBouncer, server.EndpointManagement) endpointHandler.EndpointService = server.EndpointService + endpointHandler.EndpointGroupService = server.EndpointGroupService endpointHandler.FileService = server.FileService endpointHandler.ProxyManager = proxyManager + var endpointGroupHandler = handler.NewEndpointGroupHandler(requestBouncer) + endpointGroupHandler.EndpointGroupService = server.EndpointGroupService + endpointGroupHandler.EndpointService = server.EndpointService var registryHandler = handler.NewRegistryHandler(requestBouncer) registryHandler.RegistryService = server.RegistryService var dockerHubHandler = handler.NewDockerHubHandler(requestBouncer) @@ -102,6 +121,7 @@ func (server *Server) Start() error { extensionHandler.ProxyManager = proxyManager var storidgeHandler = extensions.NewStoridgeHandler(requestBouncer) storidgeHandler.EndpointService = server.EndpointService + storidgeHandler.EndpointGroupService = server.EndpointGroupService storidgeHandler.TeamMembershipService = server.TeamMembershipService storidgeHandler.ProxyManager = proxyManager @@ -111,6 +131,7 @@ func (server *Server) Start() error { TeamHandler: teamHandler, TeamMembershipHandler: teamMembershipHandler, EndpointHandler: endpointHandler, + EndpointGroupHandler: endpointGroupHandler, RegistryHandler: registryHandler, DockerHubHandler: dockerHubHandler, ResourceHandler: resourceHandler, diff --git a/api/portainer.go b/api/portainer.go index b3a5bc63d..c502a5b15 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -24,6 +24,7 @@ type ( NoAnalytics *bool Templates *string TLSVerify *bool + TLSSkipVerify *bool TLSCacert *string TLSCert *string TLSKey *string @@ -168,12 +169,17 @@ type ( // EndpointID represents an endpoint identifier. EndpointID int + // EndpointType represents the type of an endpoint. + EndpointType int + // Endpoint represents a Docker endpoint with all the info required // to connect to it. Endpoint struct { ID EndpointID `json:"Id"` Name string `json:"Name"` + Type EndpointType `json:"Type"` URL string `json:"URL"` + GroupID EndpointGroupID `json:"GroupId"` PublicURL string `json:"PublicURL"` TLSConfig TLSConfiguration `json:"TLSConfig"` AuthorizedUsers []UserID `json:"AuthorizedUsers"` @@ -188,6 +194,19 @@ type ( TLSKeyPath string `json:"TLSKey,omitempty"` } + // EndpointGroupID represents an endpoint group identifier. + EndpointGroupID int + + // EndpointGroup represents a group of endpoints. + EndpointGroup struct { + ID EndpointGroupID `json:"Id"` + Name string `json:"Name"` + Description string `json:"Description"` + AuthorizedUsers []UserID `json:"AuthorizedUsers"` + AuthorizedTeams []TeamID `json:"AuthorizedTeams"` + Labels []Pair `json:"Labels"` + } + // EndpointExtension represents a extension associated to an endpoint. EndpointExtension struct { Type EndpointExtensionType `json:"Type"` @@ -248,6 +267,7 @@ type ( // DataStore defines the interface to manage the data. DataStore interface { Open() error + Init() error Close() error MigrateData() error } @@ -301,6 +321,15 @@ type ( Synchronize(toCreate, toUpdate, toDelete []*Endpoint) error } + // EndpointGroupService represents a service for managing endpoint group data. + EndpointGroupService interface { + EndpointGroup(ID EndpointGroupID) (*EndpointGroup, error) + EndpointGroups() ([]EndpointGroup, error) + CreateEndpointGroup(group *EndpointGroup) error + UpdateEndpointGroup(ID EndpointGroupID, group *EndpointGroup) error + DeleteEndpointGroup(ID EndpointGroupID) error + } + // RegistryService represents a service for managing registry data. RegistryService interface { Registry(ID RegistryID) (*Registry, error) @@ -354,6 +383,15 @@ type ( CompareHashAndData(hash string, data string) error } + // DigitalSignatureService represents a service to manage digital signatures. + DigitalSignatureService interface { + ParseKeyPair(private, public []byte) error + GenerateKeyPair() ([]byte, []byte, error) + EncodedPublicKey() string + PEMHeaders() (string, string) + Sign(message string) (string, error) + } + // JWTService represents a service for managing JWT tokens. JWTService interface { GenerateToken(data *TokenData) (string, error) @@ -371,6 +409,10 @@ type ( GetStackProjectPath(stackIdentifier string) string StoreStackFileFromString(stackIdentifier, fileName, stackFileContent string) (string, error) StoreStackFileFromReader(stackIdentifier, fileName string, r io.Reader) (string, error) + KeyPairFilesExist() (bool, error) + StoreKeyPair(private, public []byte, privatePEMHeader, publicPEMHeader string) error + LoadKeyPair() ([]byte, []byte, error) + WriteJSONToFile(path string, content interface{}) error } // GitService represents a service for managing Git. @@ -401,11 +443,22 @@ type ( const ( // APIVersion is the version number of the Portainer API. - APIVersion = "1.16.5" + APIVersion = "1.17.0" // DBVersion is the version number of the Portainer database. - DBVersion = 8 + DBVersion = 10 // DefaultTemplatesURL represents the default URL for the templates definitions. DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates.json" + // PortainerAgentHeader represents the name of the header available in any agent response + PortainerAgentHeader = "Portainer-Agent" + // PortainerAgentTargetHeader represent the name of the header containing the target node name. + PortainerAgentTargetHeader = "X-PortainerAgent-Target" + // PortainerAgentSignatureHeader represent the name of the header containing the digital signature + PortainerAgentSignatureHeader = "X-PortainerAgent-Signature" + // PortainerAgentPublicKeyHeader represent the name of the header containing the public key + PortainerAgentPublicKeyHeader = "X-PortainerAgent-PublicKey" + // PortainerAgentSignatureMessage represents the message used to create a digital signature + // to be used when communicating with an agent + PortainerAgentSignatureMessage = "Portainer-App" ) const ( @@ -470,3 +523,11 @@ const ( // StoridgeEndpointExtension represents the Storidge extension StoridgeEndpointExtension ) + +const ( + _ EndpointType = iota + // DockerEnvironment represents an endpoint connected to a Docker environment + DockerEnvironment + // AgentOnDockerEnvironment represents an endpoint connected to a Portainer agent deployed on a Docker environment + AgentOnDockerEnvironment +) diff --git a/api/swagger.yaml b/api/swagger.yaml index 1d36febde..fea616110 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -56,7 +56,7 @@ info: **NOTE**: You can find more information on how to query the Docker API in the [Docker official documentation](https://docs.docker.com/engine/api/v1.30/) as well as in [this Portainer example](https://gist.github.com/deviantony/77026d402366b4b43fa5918d41bc42f8). - version: "1.16.5" + version: "1.17.0" title: "Portainer API" contact: email: "info@portainer.io" @@ -224,7 +224,7 @@ paths: **Access policy**: administrator operationId: "EndpointCreate" consumes: - - "application/json" + - "multipart/form-data" produces: - "application/json" parameters: @@ -238,7 +238,7 @@ paths: 200: description: "Success" schema: - $ref: "#/definitions/EndpointCreateResponse" + $ref: "#/definitions/Endpoint" 400: description: "Invalid request" schema: @@ -2143,7 +2143,7 @@ definitions: description: "Is analytics enabled" Version: type: "string" - example: "1.16.5" + example: "1.17.0" description: "Portainer API version" PublicSettingsInspectResponse: type: "object" @@ -2455,6 +2455,15 @@ definitions: type: "boolean" example: false description: "Skip client verification when using TLS" + TLSCACertFile: + type: "file" + description: "TLS CA certificate file" + TLSCertFile: + type: "file" + description: "TLS client certificate file" + TLSKeyFile: + type: "file" + description: "TLS client key file" EndpointCreateResponse: type: "object" properties: @@ -2603,12 +2612,13 @@ definitions: ResourceID: type: "string" example: "617c5f22bb9b023d6daab7cba43a57576f83492867bc767d1c59416b065e5f08" - description: "Docker resource identifier on which access control will be applied" + description: "Docker resource identifier on which access control will be applied.\ + \ In the case of a resource control applied to a stack, use the stack name as identifier" Type: type: "string" example: "container" description: "Type of Docker resource. Valid values are: container, volume\ - \ or service" + \ service, secret, config or stack" AdministratorsOnly: type: "boolean" example: true @@ -2923,7 +2933,7 @@ definitions: RepositoryPassword: type: "string" example: "myGitPassword" - description: "Password used in basic authentication. Required when RepositoryAuthentication is true." + description: "Password used in basic authentication. Required when RepositoryAuthentication is true." Env: type: "array" description: "A list of environment variables used during stack deployment" diff --git a/app/__module.js b/app/__module.js index 98591b7e6..f5ebe25a2 100644 --- a/app/__module.js +++ b/app/__module.js @@ -17,6 +17,7 @@ angular.module('portainer', [ 'luegg.directives', 'portainer.templates', 'portainer.app', + 'portainer.agent', 'portainer.docker', 'extension.storidge', 'rzModule']); diff --git a/app/agent/_module.js b/app/agent/_module.js new file mode 100644 index 000000000..c74f4eda2 --- /dev/null +++ b/app/agent/_module.js @@ -0,0 +1 @@ +angular.module('portainer.agent', []); diff --git a/app/agent/components/node-selector/node-selector.js b/app/agent/components/node-selector/node-selector.js new file mode 100644 index 000000000..8dfb46ce6 --- /dev/null +++ b/app/agent/components/node-selector/node-selector.js @@ -0,0 +1,7 @@ +angular.module('portainer.agent').component('nodeSelector', { + templateUrl: 'app/agent/components/node-selector/nodeSelector.html', + controller: 'NodeSelectorController', + bindings: { + model: '=' + } +}); diff --git a/app/agent/components/node-selector/nodeSelector.html b/app/agent/components/node-selector/nodeSelector.html new file mode 100644 index 000000000..1a910396c --- /dev/null +++ b/app/agent/components/node-selector/nodeSelector.html @@ -0,0 +1,8 @@ +
+ +
+ +
+
diff --git a/app/agent/components/node-selector/nodeSelectorController.js b/app/agent/components/node-selector/nodeSelectorController.js new file mode 100644 index 000000000..be2f6ce92 --- /dev/null +++ b/app/agent/components/node-selector/nodeSelectorController.js @@ -0,0 +1,18 @@ +angular.module('portainer.agent') +.controller('NodeSelectorController', ['AgentService', 'Notifications', function (AgentService, Notifications) { + var ctrl = this; + + this.$onInit = function() { + AgentService.agents() + .then(function success(data) { + ctrl.agents = data; + if (!ctrl.model) { + ctrl.model = data[0].NodeName; + } + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to load agents'); + }); + }; + +}]); diff --git a/app/agent/models/agent.js b/app/agent/models/agent.js new file mode 100644 index 000000000..9f7ed8b43 --- /dev/null +++ b/app/agent/models/agent.js @@ -0,0 +1,5 @@ +function AgentViewModel(data) { + this.IPAddress = data.IPAddress; + this.NodeName = data.NodeName; + this.NodeRole = data.NodeRole; +} diff --git a/app/agent/rest/agent.js b/app/agent/rest/agent.js new file mode 100644 index 000000000..04d974780 --- /dev/null +++ b/app/agent/rest/agent.js @@ -0,0 +1,10 @@ +angular.module('portainer.agent') +.factory('Agent', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function AgentFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/agents', { + endpointId: EndpointProvider.endpointID + }, + { + query: {method: 'GET', isArray: true} + }); +}]); diff --git a/app/agent/services/agentService.js b/app/agent/services/agentService.js new file mode 100644 index 000000000..5f011d01c --- /dev/null +++ b/app/agent/services/agentService.js @@ -0,0 +1,24 @@ +angular.module('portainer.agent') +.factory('AgentService', ['$q', 'Agent', function AgentServiceFactory($q, Agent) { + 'use strict'; + var service = {}; + + service.agents = function() { + var deferred = $q.defer(); + + Agent.query({}).$promise + .then(function success(data) { + var agents = data.map(function (item) { + return new AgentViewModel(item); + }); + deferred.resolve(agents); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve agents', err: err }); + }); + + return deferred.promise; + }; + + return service; +}]); diff --git a/app/app.js b/app/app.js index 7ae0688b9..0ed1166fe 100644 --- a/app/app.js +++ b/app/app.js @@ -1,5 +1,6 @@ angular.module('portainer') -.run(['$rootScope', '$state', 'Authentication', 'authManager', 'StateManager', 'EndpointProvider', 'Notifications', 'Analytics', 'cfpLoadingBar', function ($rootScope, $state, Authentication, authManager, StateManager, EndpointProvider, Notifications, Analytics, cfpLoadingBar) { +.run(['$rootScope', '$state', 'Authentication', 'authManager', 'StateManager', 'EndpointProvider', 'Notifications', 'Analytics', 'cfpLoadingBar', '$transitions', 'HttpRequestHelper', +function ($rootScope, $state, Authentication, authManager, StateManager, EndpointProvider, Notifications, Analytics, cfpLoadingBar, $transitions, HttpRequestHelper) { 'use strict'; EndpointProvider.initialize(); @@ -27,6 +28,10 @@ angular.module('portainer') originalSet.apply(cfpLoadingBar, arguments); } }; + + $transitions.onBefore({ to: 'docker.**' }, function() { + HttpRequestHelper.resetAgentTargetQueue(); + }); }]); diff --git a/app/config.js b/app/config.js index 85a9937db..8e3366856 100644 --- a/app/config.js +++ b/app/config.js @@ -20,6 +20,20 @@ angular.module('portainer') }] }); $httpProvider.interceptors.push('jwtInterceptor'); + $httpProvider.defaults.headers.post['Content-Type'] = 'application/json'; + $httpProvider.defaults.headers.put['Content-Type'] = 'application/json'; + $httpProvider.defaults.headers.patch['Content-Type'] = 'application/json'; + + $httpProvider.interceptors.push(['HttpRequestHelper', function(HttpRequestHelper) { + return { + request: function(config) { + if (config.url.indexOf('/docker/') > -1) { + config.headers['X-PortainerAgent-Target'] = HttpRequestHelper.portainerAgentTargetHeader(); + } + return config; + } + }; + }]); AnalyticsProvider.setAccount('@@CONFIG_GA_ID'); AnalyticsProvider.startOffline(true); diff --git a/app/constants.js b/app/constants.js index 4062f9dd3..8a16181f7 100644 --- a/app/constants.js +++ b/app/constants.js @@ -2,6 +2,7 @@ angular.module('portainer') .constant('API_ENDPOINT_AUTH', 'api/auth') .constant('API_ENDPOINT_DOCKERHUB', 'api/dockerhub') .constant('API_ENDPOINT_ENDPOINTS', 'api/endpoints') +.constant('API_ENDPOINT_ENDPOINT_GROUPS', 'api/endpoint_groups') .constant('API_ENDPOINT_REGISTRIES', 'api/registries') .constant('API_ENDPOINT_RESOURCE_CONTROLS', 'api/resource_controls') .constant('API_ENDPOINT_SETTINGS', 'api/settings') diff --git a/app/docker/__module.js b/app/docker/__module.js index 0ede8298d..28ad5e52d 100644 --- a/app/docker/__module.js +++ b/app/docker/__module.js @@ -57,7 +57,7 @@ angular.module('portainer.docker', ['portainer.app']) var container = { name: 'docker.containers.container', - url: '/:id', + url: '/:id?nodeName', views: { 'content@': { templateUrl: 'app/docker/views/containers/edit/container.html', @@ -79,15 +79,12 @@ angular.module('portainer.docker', ['portainer.app']) var containerCreation = { name: 'docker.containers.new', - url: '/new', + url: '/new?nodeName&from', views: { 'content@': { templateUrl: 'app/docker/views/containers/create/createcontainer.html', controller: 'CreateContainerController' } - }, - params: { - from: '' } }; @@ -170,7 +167,7 @@ angular.module('portainer.docker', ['portainer.app']) var image = { name: 'docker.images.image', - url: '/:id', + url: '/:id?nodeName', views: { 'content@': { templateUrl: 'app/docker/views/images/edit/image.html', @@ -203,7 +200,7 @@ angular.module('portainer.docker', ['portainer.app']) var network = { name: 'docker.networks.network', - url: '/:id', + url: '/:id?nodeName', views: { 'content@': { templateUrl: 'app/docker/views/networks/edit/network.html', @@ -443,7 +440,7 @@ angular.module('portainer.docker', ['portainer.app']) var volume = { name: 'docker.volumes.volume', - url: '/:id', + url: '/:id?nodeName', views: { 'content@': { templateUrl: 'app/docker/views/volumes/edit/volume.html', diff --git a/app/docker/components/dashboard-cluster-agent-info/dashboard-cluster-agent-info.js b/app/docker/components/dashboard-cluster-agent-info/dashboard-cluster-agent-info.js new file mode 100644 index 000000000..54dc0caf6 --- /dev/null +++ b/app/docker/components/dashboard-cluster-agent-info/dashboard-cluster-agent-info.js @@ -0,0 +1,4 @@ +angular.module('portainer.docker').component('dashboardClusterAgentInfo', { + templateUrl: 'app/docker/components/dashboard-cluster-agent-info/dashboardClusterAgentInfo.html', + controller: 'DashboardClusterAgentInfoController' +}); diff --git a/app/docker/components/dashboard-cluster-agent-info/dashboardClusterAgentInfo.html b/app/docker/components/dashboard-cluster-agent-info/dashboardClusterAgentInfo.html new file mode 100644 index 000000000..7012bb15c --- /dev/null +++ b/app/docker/components/dashboard-cluster-agent-info/dashboardClusterAgentInfo.html @@ -0,0 +1,20 @@ + + + + + + + + + + + + + +
Nodes in the cluster{{ $ctrl.agentCount }}
+ +
+
+
diff --git a/app/docker/components/dashboard-cluster-agent-info/dashboardClusterAgentInfoController.js b/app/docker/components/dashboard-cluster-agent-info/dashboardClusterAgentInfoController.js new file mode 100644 index 000000000..7ba6f7f08 --- /dev/null +++ b/app/docker/components/dashboard-cluster-agent-info/dashboardClusterAgentInfoController.js @@ -0,0 +1,16 @@ +angular.module('portainer.docker') +.controller('DashboardClusterAgentInfoController', ['AgentService', 'Notifications', +function (AgentService, Notifications) { + var ctrl = this; + + this.$onInit = function() { + AgentService.agents() + .then(function success(data) { + ctrl.agentCount = data.length; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve agent information'); + }); + }; + +}]); diff --git a/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.html b/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.html index d33805ce6..5eddcae40 100644 --- a/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.html +++ b/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.html @@ -38,7 +38,7 @@ - {{ key }} + {{ key }} {{ value.IPAddress || '-' }} {{ value.Gateway || '-' }} {{ value.MacAddress || '-' }} diff --git a/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.js b/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.js index 9447a0e1a..2b294141b 100644 --- a/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.js +++ b/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.js @@ -11,6 +11,7 @@ angular.module('portainer.docker').component('containerNetworksDatatable', { joinNetworkAction: '<', joinNetworkActionInProgress: '<', leaveNetworkActionInProgress: '<', - leaveNetworkAction: '<' + leaveNetworkAction: '<', + nodeName: '<' } }); diff --git a/app/docker/components/datatables/containers-datatable/containersDatatable.html b/app/docker/components/datatables/containers-datatable/containersDatatable.html index 973c1c5e6..9ce58d5f7 100644 --- a/app/docker/components/datatables/containers-datatable/containersDatatable.html +++ b/app/docker/components/datatables/containers-datatable/containersDatatable.html @@ -156,11 +156,11 @@ - - - Host IP - - + + + Host + + @@ -186,8 +186,7 @@ - {{ item | containername | truncate: $ctrl.settings.containerNameTruncateSize }} - {{ item | swarmcontainername | truncate: $ctrl.settings.containerNameTruncateSize }} + {{ item | containername | truncate: $ctrl.settings.containerNameTruncateSize }} {{ item.Status }} @@ -195,16 +194,16 @@
- - - - + + + +
{{ item.StackName ? item.StackName : '-' }} {{ item.Image | trimshasum }} {{ item.IP ? item.IP : '-' }} - {{ item.hostIP }} + {{ item.NodeName ? item.NodeName : '-' }} {{ p.public }}:{{ p.private }} @@ -219,10 +218,10 @@ - Loading... + Loading... - No container available. + No container available. diff --git a/app/docker/components/datatables/containers-datatable/containersDatatable.js b/app/docker/components/datatables/containers-datatable/containersDatatable.js index 2be1f84ff..4e806b415 100644 --- a/app/docker/components/datatables/containers-datatable/containersDatatable.js +++ b/app/docker/components/datatables/containers-datatable/containersDatatable.js @@ -10,7 +10,7 @@ angular.module('portainer.docker').component('containersDatatable', { reverseOrder: '<', showTextFilter: '<', showOwnershipColumn: '<', - swarmContainers: '<', + showHostColumn: '<', publicUrl: '<', containerNameTruncateSize: '<', startAction: '<', diff --git a/app/docker/components/datatables/images-datatable/imagesDatatable.html b/app/docker/components/datatables/images-datatable/imagesDatatable.html index 57d947e07..bded973b1 100644 --- a/app/docker/components/datatables/images-datatable/imagesDatatable.html +++ b/app/docker/components/datatables/images-datatable/imagesDatatable.html @@ -93,6 +93,13 @@ + + + Host + + + + @@ -102,7 +109,7 @@ - {{ item.Id | truncate:20 }} + {{ item.Id | truncate:20 }} Unused @@ -110,12 +117,13 @@ {{ item.VirtualSize | humansize }} {{ item.Created | getisodatefromtimestamp }} + {{ item.NodeName ? item.NodeName : '-' }} - Loading... + Loading... - No image available. + No image available. diff --git a/app/docker/components/datatables/images-datatable/imagesDatatable.js b/app/docker/components/datatables/images-datatable/imagesDatatable.js index 6a7349d3d..d2e5305d4 100644 --- a/app/docker/components/datatables/images-datatable/imagesDatatable.js +++ b/app/docker/components/datatables/images-datatable/imagesDatatable.js @@ -9,6 +9,7 @@ angular.module('portainer.docker').component('imagesDatatable', { orderBy: '@', reverseOrder: '<', showTextFilter: '<', + showHostColumn: '<', removeAction: '<', forceRemoveAction: '<' } diff --git a/app/docker/components/datatables/networks-datatable/networksDatatable.html b/app/docker/components/datatables/networks-datatable/networksDatatable.html index 9e9c1da26..6ccf496f6 100644 --- a/app/docker/components/datatables/networks-datatable/networksDatatable.html +++ b/app/docker/components/datatables/networks-datatable/networksDatatable.html @@ -81,6 +81,13 @@ + + + Host + + + + Ownership @@ -97,7 +104,7 @@ - {{ item.Name | truncate:40 }} + {{ item.Name | truncate:40 }} {{ item.StackName ? item.StackName : '-' }} {{ item.Scope }} @@ -105,6 +112,7 @@ {{ item.IPAM.Driver }} {{ item.IPAM.Config[0].Subnet ? item.IPAM.Config[0].Subnet : '-' }} {{ item.IPAM.Config[0].Gateway ? item.IPAM.Config[0].Gateway : '-' }} + {{ item.NodeName ? item.NodeName : '-' }} @@ -113,10 +121,10 @@ - Loading... + Loading... - No network available. + No network available. diff --git a/app/docker/components/datatables/networks-datatable/networksDatatable.js b/app/docker/components/datatables/networks-datatable/networksDatatable.js index 3d86651bc..1f7527890 100644 --- a/app/docker/components/datatables/networks-datatable/networksDatatable.js +++ b/app/docker/components/datatables/networks-datatable/networksDatatable.js @@ -10,6 +10,7 @@ angular.module('portainer.docker').component('networksDatatable', { reverseOrder: '<', showTextFilter: '<', showOwnershipColumn: '<', + showHostColumn: '<', removeAction: '<' } }); diff --git a/app/docker/components/datatables/nodes-ss-datatable/nodesSSDatatable.html b/app/docker/components/datatables/nodes-ss-datatable/nodesSSDatatable.html deleted file mode 100644 index fd94a7726..000000000 --- a/app/docker/components/datatables/nodes-ss-datatable/nodesSSDatatable.html +++ /dev/null @@ -1,105 +0,0 @@ -
- - -
-
- {{ $ctrl.title }} -
-
- - Search - -
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - Name - - - - - - CPU - - - - - - Memory - - - - - - IP Address - - - - - - Engine - - - - - - Status - - - -
{{ item.name }}{{ item.cpu }}{{ item.memory }}{{ item.ip }}{{ item.version }}{{ item.status }}
Loading...
No node available.
-
- -
-
-
diff --git a/app/docker/components/datatables/nodes-ss-datatable/nodesSSDatatable.js b/app/docker/components/datatables/nodes-ss-datatable/nodesSSDatatable.js deleted file mode 100644 index 2b61854ff..000000000 --- a/app/docker/components/datatables/nodes-ss-datatable/nodesSSDatatable.js +++ /dev/null @@ -1,13 +0,0 @@ -angular.module('portainer.docker').component('nodesSsDatatable', { - templateUrl: 'app/docker/components/datatables/nodes-ss-datatable/nodesSSDatatable.html', - controller: 'GenericDatatableController', - bindings: { - title: '@', - titleIcon: '@', - dataset: '<', - tableKey: '@', - orderBy: '@', - reverseOrder: '<', - showTextFilter: '<' - } -}); diff --git a/app/docker/components/datatables/tasks-datatable/tasksDatatable.html b/app/docker/components/datatables/tasks-datatable/tasksDatatable.html index e1ba5886a..dc151b5ca 100644 --- a/app/docker/components/datatables/tasks-datatable/tasksDatatable.html +++ b/app/docker/components/datatables/tasks-datatable/tasksDatatable.html @@ -54,20 +54,26 @@ - Actions + Actions - {{ item.Id }} + + {{ item.Service.Name }}{{ item.Slot ? '.' + item.Slot : '' }}{{ '.' + item.Id }} + {{ item.Service.Name }}{{ item.Slot ? '.' + item.Slot : '' }}{{ '.' + item.Id }} + {{ item.Status.State }} {{ item.Slot ? item.Slot : '-' }} {{ item.NodeId | tasknodename: $ctrl.nodes }} {{ item.Updated | getisodate }} - - + + View logs + + Console + diff --git a/app/docker/components/datatables/tasks-datatable/tasksDatatable.js b/app/docker/components/datatables/tasks-datatable/tasksDatatable.js index b560bdade..991c328ec 100644 --- a/app/docker/components/datatables/tasks-datatable/tasksDatatable.js +++ b/app/docker/components/datatables/tasks-datatable/tasksDatatable.js @@ -11,6 +11,7 @@ angular.module('portainer.docker').component('tasksDatatable', { nodes: '<', showTextFilter: '<', showSlotColumn: '<', - showLogsButton: '<' + showLogsButton: '<', + agentProxy: '<' } }); diff --git a/app/docker/components/datatables/volumes-datatable/volumesDatatable.html b/app/docker/components/datatables/volumes-datatable/volumesDatatable.html index 760085b3e..3d896409c 100644 --- a/app/docker/components/datatables/volumes-datatable/volumesDatatable.html +++ b/app/docker/components/datatables/volumes-datatable/volumesDatatable.html @@ -84,6 +84,13 @@ + + + Host + + + + Ownership @@ -100,12 +107,13 @@
- {{ item.Id | truncate:25 }} + {{ item.Id | truncate:25 }} Unused {{ item.StackName ? item.StackName : '-' }} {{ item.Driver }} {{ item.Mountpoint | truncatelr }} + {{ item.NodeName ? item.NodeName : '-' }} @@ -114,10 +122,10 @@ - Loading... + Loading... - No volume available. + No volume available. diff --git a/app/docker/components/datatables/volumes-datatable/volumesDatatable.js b/app/docker/components/datatables/volumes-datatable/volumesDatatable.js index daa6f5b47..8d1707fe5 100644 --- a/app/docker/components/datatables/volumes-datatable/volumesDatatable.js +++ b/app/docker/components/datatables/volumes-datatable/volumesDatatable.js @@ -10,6 +10,7 @@ angular.module('portainer.docker').component('volumesDatatable', { reverseOrder: '<', showTextFilter: '<', showOwnershipColumn: '<', + showHostColumn: '<', removeAction: '<' } }); diff --git a/app/docker/filters/filters.js b/app/docker/filters/filters.js index 7e03b5e7d..6c63f3c8f 100644 --- a/app/docker/filters/filters.js +++ b/app/docker/filters/filters.js @@ -162,12 +162,6 @@ angular.module('portainer.docker') 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) { diff --git a/app/docker/helpers/infoHelper.js b/app/docker/helpers/infoHelper.js index 092439eb3..3d84d9523 100644 --- a/app/docker/helpers/infoHelper.js +++ b/app/docker/helpers/infoHelper.js @@ -1,36 +1,39 @@ angular.module('portainer.docker') .factory('InfoHelper', [function InfoHelperFactory() { 'use strict'; - return { - determineEndpointMode: function(info) { - var mode = { - provider: '', - role: '' - }; - if (_.startsWith(info.ServerVersion, 'swarm')) { - mode.provider = 'DOCKER_SWARM'; - if (info.SystemStatus[0][1] === 'primary') { - mode.role = 'PRIMARY'; - } else { - mode.role = 'REPLICA'; - } - } else { - if (!info.Swarm || _.isEmpty(info.Swarm.NodeID)) { - if (info.ID === 'vSphere Integrated Containers') { - mode.provider = 'VMWARE_VIC'; - } else { - mode.provider = 'DOCKER_STANDALONE'; - } - } else { - mode.provider = 'DOCKER_SWARM_MODE'; - if (info.Swarm.ControlAvailable) { - mode.role = 'MANAGER'; - } else { - mode.role = 'WORKER'; - } - } - } + + var helper = {}; + + helper.determineEndpointMode = function(info, type) { + var mode = { + provider: '', + role: '', + agentProxy: false + }; + + if (type === 2) { + mode.provider = 'DOCKER_SWARM_MODE'; + mode.role = 'MANAGER'; + mode.agentProxy = true; return mode; } + + if (!info.Swarm || _.isEmpty(info.Swarm.NodeID)) { + if (info.ID === 'vSphere Integrated Containers') { + mode.provider = 'VMWARE_VIC'; + } else { + mode.provider = 'DOCKER_STANDALONE'; + } + } else { + mode.provider = 'DOCKER_SWARM_MODE'; + if (info.Swarm.ControlAvailable) { + mode.role = 'MANAGER'; + } else { + mode.role = 'WORKER'; + } + } + return mode; }; + + return helper; }]); diff --git a/app/docker/helpers/logHelper.js b/app/docker/helpers/logHelper.js new file mode 100644 index 000000000..47f903056 --- /dev/null +++ b/app/docker/helpers/logHelper.js @@ -0,0 +1,22 @@ +angular.module('portainer.docker') +.factory('LogHelper', [function LogHelperFactory() { + 'use strict'; + var helper = {}; + + // Return an array with each line being an entry. + // It will also remove any ANSI code related character sequences. + // If the skipHeaders param is specified, it will strip the 8 first characters of each line. + helper.formatLogs = function(logs, skipHeaders) { + logs = logs.replace( + /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, ''); + + if (skipHeaders) { + logs = logs.substring(8); + logs = logs.replace(/\n(.{8})/g, '\n\r'); + } + + return logs.split('\n'); + }; + + return helper; +}]); diff --git a/app/docker/helpers/serviceHelper.js b/app/docker/helpers/serviceHelper.js index 551706d37..3a170556a 100644 --- a/app/docker/helpers/serviceHelper.js +++ b/app/docker/helpers/serviceHelper.js @@ -11,6 +11,7 @@ angular.module('portainer.docker') var task = tasks[i]; if (task.ServiceId === service.Id) { service.Tasks.push(task); + task.Service = service; } else { otherServicesTasks.push(task); } diff --git a/app/docker/helpers/taskHelper.js b/app/docker/helpers/taskHelper.js new file mode 100644 index 000000000..5526c3c32 --- /dev/null +++ b/app/docker/helpers/taskHelper.js @@ -0,0 +1,18 @@ +angular.module('portainer.docker') +.factory('TaskHelper', [function TaskHelperFactory() { + 'use strict'; + + var helper = {}; + + helper.associateContainerToTask = function(task, containers) { + for (var i = 0; i < containers.length; i++) { + var container = containers[i]; + if (task.ContainerId === container.Id) { + task.Container = container; + break; + } + } + }; + + return helper; +}]); diff --git a/app/docker/models/container.js b/app/docker/models/container.js index 64d4d7047..e4f64f6f5 100644 --- a/app/docker/models/container.js +++ b/app/docker/models/container.js @@ -34,6 +34,9 @@ function ContainerViewModel(data) { if (data.Portainer.ResourceControl) { this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl); } + if (data.Portainer.Agent && data.Portainer.Agent.NodeName) { + this.NodeName = data.Portainer.Agent.NodeName; + } } } diff --git a/app/docker/models/image.js b/app/docker/models/image.js index 60e7b0c11..129d1f90b 100644 --- a/app/docker/models/image.js +++ b/app/docker/models/image.js @@ -7,6 +7,10 @@ function ImageViewModel(data) { this.RepoTags = data.RepoTags; this.VirtualSize = data.VirtualSize; this.ContainerCount = data.ContainerCount; + + if (data.Portainer && data.Portainer.Agent && data.Portainer.Agent.NodeName) { + this.NodeName = data.Portainer.Agent.NodeName; + } } function ImageBuildModel(data) { diff --git a/app/docker/models/network.js b/app/docker/models/network.js index baa5ab984..402d4cd96 100644 --- a/app/docker/models/network.js +++ b/app/docker/models/network.js @@ -19,5 +19,8 @@ function NetworkViewModel(data) { if (data.Portainer.ResourceControl) { this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl); } + if (data.Portainer.Agent && data.Portainer.Agent.NodeName) { + this.NodeName = data.Portainer.Agent.NodeName; + } } } diff --git a/app/docker/models/task.js b/app/docker/models/task.js index ab23be4c0..e28390483 100644 --- a/app/docker/models/task.js +++ b/app/docker/models/task.js @@ -7,4 +7,7 @@ function TaskViewModel(data) { this.Status = data.Status; this.ServiceId = data.ServiceID; this.NodeId = data.NodeID; + if (data.Status && data.Status.ContainerStatus && data.Status.ContainerStatus.ContainerID) { + this.ContainerId = data.Status.ContainerStatus.ContainerID; + } } diff --git a/app/docker/models/template.js b/app/docker/models/template.js index dcc00c843..a53c2f290 100644 --- a/app/docker/models/template.js +++ b/app/docker/models/template.js @@ -1,6 +1,7 @@ function TemplateViewModel(data) { this.Type = data.type; this.Name = data.name; + this.Hostname = data.hostname; this.Title = data.title; this.Description = data.description; this.Note = data.note; diff --git a/app/docker/models/volume.js b/app/docker/models/volume.js index 5e2701b70..5ac1d366d 100644 --- a/app/docker/models/volume.js +++ b/app/docker/models/volume.js @@ -14,5 +14,8 @@ function VolumeViewModel(data) { if (data.Portainer.ResourceControl) { this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl); } + if (data.Portainer.Agent && data.Portainer.Agent.NodeName) { + this.NodeName = data.Portainer.Agent.NodeName; + } } } diff --git a/app/docker/rest/container.js b/app/docker/rest/container.js index b2acedd59..9f35d18d8 100644 --- a/app/docker/rest/container.js +++ b/app/docker/rest/container.js @@ -1,22 +1,38 @@ angular.module('portainer.docker') -.factory('Container', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function ContainerFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { +.factory('Container', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', +function ContainerFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { 'use strict'; return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/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'}}, - restart: {method: 'POST', params: {id: '@id', t: 5, action: 'restart'}}, - kill: {method: 'POST', params: {id: '@id', action: 'kill'}}, - pause: {method: 'POST', params: {id: '@id', action: 'pause'}}, - unpause: {method: 'POST', params: {id: '@id', action: 'unpause'}}, + 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' } + }, + restart: { + method: 'POST', params: { id: '@id', t: 5, action: 'restart' } + }, + kill: { + method: 'POST', params: { id: '@id', action: 'kill' } + }, + pause: { + method: 'POST', params: { id: '@id', action: 'pause' } + }, + unpause: { + method: 'POST', params: { id: '@id', action: 'unpause' } + }, logs: { method: 'GET', params: { id: '@id', action: 'logs' }, timeout: 4500, ignoreLoadingBar: true, - transformResponse: logsHandler, isArray: true + transformResponse: logsHandler }, stats: { method: 'GET', params: { id: '@id', stream: false, action: 'stats' }, @@ -40,7 +56,7 @@ angular.module('portainer.docker') transformResponse: genericHandler }, rename: { - method: 'POST', params: {id: '@id', action: 'rename', name: '@name'}, + method: 'POST', params: { id: '@id', action: 'rename', name: '@name' }, transformResponse: genericHandler }, exec: { diff --git a/app/docker/rest/exec.js b/app/docker/rest/exec.js index bbd7642d9..c77af4679 100644 --- a/app/docker/rest/exec.js +++ b/app/docker/rest/exec.js @@ -1,5 +1,6 @@ angular.module('portainer.docker') -.factory('Exec', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function ExecFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { +.factory('Exec', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', +function ExecFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { 'use strict'; return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/exec/:id/:action', { endpointId: EndpointProvider.endpointID diff --git a/app/docker/rest/image.js b/app/docker/rest/image.js index 6430c5637..5395bb6c7 100644 --- a/app/docker/rest/image.js +++ b/app/docker/rest/image.js @@ -1,5 +1,6 @@ angular.module('portainer.docker') -.factory('Image', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'HttpRequestHelper', function ImageFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, HttpRequestHelper) { +.factory('Image', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'HttpRequestHelper', +function ImageFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, HttpRequestHelper) { 'use strict'; return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/images/:id/:action', { diff --git a/app/docker/rest/network.js b/app/docker/rest/network.js index 2bfc4cd4c..bd2a288eb 100644 --- a/app/docker/rest/network.js +++ b/app/docker/rest/network.js @@ -1,16 +1,30 @@ angular.module('portainer.docker') -.factory('Network', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function NetworkFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { +.factory('Network', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', +function NetworkFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { 'use strict'; return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/networks/:id/:action', { id: '@id', endpointId: EndpointProvider.endpointID }, { - query: {method: 'GET', isArray: true}, - get: {method: 'GET'}, - create: {method: 'POST', params: {action: 'create'}, transformResponse: genericHandler, ignoreLoadingBar: true}, - remove: { method: 'DELETE', transformResponse: genericHandler }, - connect: {method: 'POST', params: {action: 'connect'}}, - disconnect: {method: 'POST', params: {action: 'disconnect'}} + query: { + method: 'GET', isArray: true + }, + get: { + method: 'GET' + }, + create: { + method: 'POST', params: {action: 'create'}, + transformResponse: genericHandler, ignoreLoadingBar: true + }, + remove: { + method: 'DELETE', transformResponse: genericHandler + }, + connect: { + method: 'POST', params: { action: 'connect' } + }, + disconnect: { + method: 'POST', params: { action: 'disconnect' } + } }); }]); diff --git a/app/docker/rest/response/handlers.js b/app/docker/rest/response/handlers.js index 53e660c39..7937c1f4e 100644 --- a/app/docker/rest/response/handlers.js +++ b/app/docker/rest/response/handlers.js @@ -45,15 +45,11 @@ function genericHandler(data) { } // The Docker API returns the logs as a single string. -// This handler will return an array with each line being an entry. -// It will also strip the 8 first characters of each line and remove any ANSI code related character sequences. +// This handler wraps the data in a JSON object under the "logs" property. function logsHandler(data) { - var logs = data; - logs = logs.substring(8); - logs = logs.replace(/\n(.{8})/g, '\n\r'); - logs = logs.replace( - /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, ''); - return logs.split('\n'); + return { + logs: data + }; } // Image delete API returns an array on success (Docker 1.9 -> Docker 1.12). diff --git a/app/docker/rest/service.js b/app/docker/rest/service.js index 3af88acd2..806586104 100644 --- a/app/docker/rest/service.js +++ b/app/docker/rest/service.js @@ -1,5 +1,6 @@ angular.module('portainer.docker') -.factory('Service', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'HttpRequestHelper' ,function ServiceFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, HttpRequestHelper) { +.factory('Service', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'HttpRequestHelper', +function ServiceFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, HttpRequestHelper) { 'use strict'; return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/services/:id/:action', { endpointId: EndpointProvider.endpointID @@ -17,7 +18,7 @@ angular.module('portainer.docker') logs: { method: 'GET', params: { id: '@id', action: 'logs' }, timeout: 4500, ignoreLoadingBar: true, - transformResponse: logsHandler, isArray: true + transformResponse: logsHandler } }); }]); diff --git a/app/docker/rest/system.js b/app/docker/rest/system.js index 5af2ebbce..84ce2e756 100644 --- a/app/docker/rest/system.js +++ b/app/docker/rest/system.js @@ -6,7 +6,10 @@ angular.module('portainer.docker') endpointId: EndpointProvider.endpointID }, { - info: { method: 'GET', params: { action: 'info' }, ignoreLoadingBar: true }, + info: { + method: 'GET', params: { action: 'info' }, + ignoreLoadingBar: true + }, version: { method: 'GET', params: { action: 'version' }, ignoreLoadingBar: true, timeout: 4500 }, events: { method: 'GET', params: { action: 'events', since: '@since', until: '@until' }, diff --git a/app/docker/rest/task.js b/app/docker/rest/task.js index 9683b05ba..ed21d5ee5 100644 --- a/app/docker/rest/task.js +++ b/app/docker/rest/task.js @@ -10,7 +10,7 @@ angular.module('portainer.docker') logs: { method: 'GET', params: { id: '@id', action: 'logs' }, timeout: 4500, ignoreLoadingBar: true, - transformResponse: logsHandler, isArray: true + transformResponse: logsHandler } }); }]); diff --git a/app/docker/services/containerService.js b/app/docker/services/containerService.js index 51efd1b8e..d0368ade7 100644 --- a/app/docker/services/containerService.js +++ b/app/docker/services/containerService.js @@ -1,5 +1,6 @@ angular.module('portainer.docker') -.factory('ContainerService', ['$q', 'Container', 'ResourceControlService', function ContainerServiceFactory($q, Container, ResourceControlService) { +.factory('ContainerService', ['$q', 'Container', 'ResourceControlService', 'LogHelper', +function ContainerServiceFactory($q, Container, ResourceControlService, LogHelper) { 'use strict'; var service = {}; @@ -34,6 +35,34 @@ angular.module('portainer.docker') return deferred.promise; }; + service.startContainer = function(id) { + return Container.start({ id: id }, {}).$promise; + }; + + service.stopContainer = function(id) { + return Container.stop({ id: id }, {}).$promise; + }; + + service.restartContainer = function(id) { + return Container.restart({ id: id }, {}).$promise; + }; + + service.killContainer = function(id) { + return Container.kill({ id: id }, {}).$promise; + }; + + service.pauseContainer = function(id) { + return Container.pause({ id: id }, {}).$promise; + }; + + service.resumeContainer = function(id) { + return Container.unpause({ id: id }, {}).$promise; + }; + + service.renameContainer = function(id, newContainerName) { + return Container.rename({id: id, name: newContainerName }, {}).$promise; + }; + service.createContainer = function(configuration) { var deferred = $q.defer(); Container.create(configuration).$promise @@ -50,30 +79,6 @@ angular.module('portainer.docker') return deferred.promise; }; - service.startContainer = function(containerID) { - return Container.start({ id: containerID }, {}).$promise; - }; - - service.stopContainer = function(containerID) { - return Container.stop({ id: containerID }, {}).$promise; - }; - - service.restartContainer = function(containerID) { - return Container.restart({ id: containerID }, {}).$promise; - }; - - service.killContainer = function(containerID) { - return Container.kill({ id: containerID }, {}).$promise; - }; - - service.pauseContainer = function(containerID) { - return Container.pause({ id: containerID }, {}).$promise; - }; - - service.resumeContainer = function(containerID) { - return Container.unpause({ id: containerID }, {}).$promise; - }; - service.createAndStartContainer = function(configuration) { var deferred = $q.defer(); var containerID; @@ -94,7 +99,7 @@ angular.module('portainer.docker') service.remove = function(container, removeVolumes) { var deferred = $q.defer(); - Container.remove({id: container.Id, v: (removeVolumes) ? 1 : 0, force: true}).$promise + Container.remove({ id: container.Id, v: (removeVolumes) ? 1 : 0, force: true }).$promise .then(function success(data) { if (data.message) { deferred.reject({ msg: data.message, err: data.message }); @@ -116,7 +121,7 @@ angular.module('portainer.docker') service.createExec = function(execConfig) { var deferred = $q.defer(); - Container.exec(execConfig).$promise + Container.exec({}, execConfig).$promise .then(function success(data) { if (data.message) { deferred.reject({ msg: data.message, err: data.message }); @@ -131,7 +136,9 @@ angular.module('portainer.docker') return deferred.promise; }; - service.logs = function(id, stdout, stderr, timestamps, tail) { + service.logs = function(id, stdout, stderr, timestamps, tail, stripHeaders) { + var deferred = $q.defer(); + var parameters = { id: id, stdout: stdout || 0, @@ -140,13 +147,22 @@ angular.module('portainer.docker') tail: tail || 'all' }; - return Container.logs(parameters).$promise; + Container.logs(parameters).$promise + .then(function success(data) { + var logs = LogHelper.formatLogs(data.logs, stripHeaders); + deferred.resolve(logs); + }) + .catch(function error(err) { + deferred.reject(err); + }); + + return deferred.promise; }; service.containerStats = function(id) { var deferred = $q.defer(); - Container.stats({id: id}).$promise + Container.stats({ id: id }).$promise .then(function success(data) { var containerStats = new ContainerStatsViewModel(data); deferred.resolve(containerStats); @@ -159,11 +175,11 @@ angular.module('portainer.docker') }; service.containerTop = function(id) { - return Container.top({id: id}).$promise; + return Container.top({ id: id }).$promise; }; service.inspect = function(id) { - return Container.inspect({id: id}).$promise; + return Container.inspect({ id: id }).$promise; }; return service; diff --git a/app/docker/services/execService.js b/app/docker/services/execService.js index 0499aced0..2e0c04f95 100644 --- a/app/docker/services/execService.js +++ b/app/docker/services/execService.js @@ -7,7 +7,7 @@ angular.module('portainer.docker') var deferred = $q.defer(); $timeout(function() { - Exec.resize({id: execId, height: height, width: width}).$promise + Exec.resize({}, { id: execId, height: height, width: width }).$promise .then(function success(data) { if (data.message) { deferred.reject({ msg: 'Unable to exec into container', err: data.message }); diff --git a/app/docker/services/networkService.js b/app/docker/services/networkService.js index a58969f6e..010a5d9b5 100644 --- a/app/docker/services/networkService.js +++ b/app/docker/services/networkService.js @@ -19,7 +19,7 @@ angular.module('portainer.docker') service.network = function(id) { var deferred = $q.defer(); - Network.get({id: id}).$promise + Network.get({ id: id }).$promise .then(function success(data) { var network = new NetworkViewModel(data); deferred.resolve(network); @@ -31,7 +31,7 @@ angular.module('portainer.docker') return deferred.promise; }; - service.networks = function(localNetworks, swarmNetworks, swarmAttachableNetworks, globalNetworks) { + service.networks = function(localNetworks, swarmNetworks, swarmAttachableNetworks) { var deferred = $q.defer(); Network.query({}).$promise @@ -48,9 +48,6 @@ angular.module('portainer.docker') if (swarmAttachableNetworks && network.Scope === 'swarm' && network.Attachable === true) { return network; } - if (globalNetworks && network.Scope === 'global') { - return network; - } }).map(function (item) { return new NetworkViewModel(item); }); @@ -68,5 +65,13 @@ angular.module('portainer.docker') return Network.remove({ id: id }).$promise; }; + service.disconnectContainer = function(networkId, containerId, force) { + return Network.disconnect({ id: networkId }, { Container: containerId, Force: force }).$promise; + }; + + service.connectContainer = function(networkId, containerId) { + return Network.connect({ id: networkId }, { Container: containerId }).$promise; + }; + return service; }]); diff --git a/app/docker/services/serviceService.js b/app/docker/services/serviceService.js index 1e28b75f9..94e8fa7bf 100644 --- a/app/docker/services/serviceService.js +++ b/app/docker/services/serviceService.js @@ -1,5 +1,6 @@ angular.module('portainer.docker') -.factory('ServiceService', ['$q', 'Service', 'ServiceHelper', 'TaskService', 'ResourceControlService', function ServiceServiceFactory($q, Service, ServiceHelper, TaskService, ResourceControlService) { +.factory('ServiceService', ['$q', 'Service', 'ServiceHelper', 'TaskService', 'ResourceControlService', 'LogHelper', +function ServiceServiceFactory($q, Service, ServiceHelper, TaskService, ResourceControlService, LogHelper) { 'use strict'; var service = {}; @@ -59,6 +60,8 @@ angular.module('portainer.docker') }; service.logs = function(id, stdout, stderr, timestamps, tail) { + var deferred = $q.defer(); + var parameters = { id: id, stdout: stdout || 0, @@ -67,7 +70,16 @@ angular.module('portainer.docker') tail: tail || 'all' }; - return Service.logs(parameters).$promise; + Service.logs(parameters).$promise + .then(function success(data) { + var logs = LogHelper.formatLogs(data.logs, true); + deferred.resolve(logs); + }) + .catch(function error(err) { + deferred.reject(err); + }); + + return deferred.promise; }; return service; diff --git a/app/docker/services/taskService.js b/app/docker/services/taskService.js index 4fdbe4331..960b6bd70 100644 --- a/app/docker/services/taskService.js +++ b/app/docker/services/taskService.js @@ -1,5 +1,6 @@ angular.module('portainer.docker') -.factory('TaskService', ['$q', 'Task', function TaskServiceFactory($q, Task) { +.factory('TaskService', ['$q', 'Task', 'LogHelper', +function TaskServiceFactory($q, Task, LogHelper) { 'use strict'; var service = {}; @@ -36,6 +37,8 @@ angular.module('portainer.docker') }; service.logs = function(id, stdout, stderr, timestamps, tail) { + var deferred = $q.defer(); + var parameters = { id: id, stdout: stdout || 0, @@ -44,7 +47,16 @@ angular.module('portainer.docker') tail: tail || 'all' }; - return Task.logs(parameters).$promise; + Task.logs(parameters).$promise + .then(function success(data) { + var logs = LogHelper.formatLogs(data.logs, true); + deferred.resolve(logs); + }) + .catch(function error(err) { + deferred.reject(err); + }); + + return deferred.promise; }; return service; diff --git a/app/docker/views/containers/console/containerConsoleController.js b/app/docker/views/containers/console/containerConsoleController.js index bf8adee6c..1f9353a77 100644 --- a/app/docker/views/containers/console/containerConsoleController.js +++ b/app/docker/views/containers/console/containerConsoleController.js @@ -1,6 +1,8 @@ angular.module('portainer.docker') -.controller('ContainerConsoleController', ['$scope', '$transition$', 'Container', 'Image', 'EndpointProvider', 'Notifications', 'ContainerHelper', 'ContainerService', 'ExecService', -function ($scope, $transition$, Container, Image, EndpointProvider, Notifications, ContainerHelper, ContainerService, ExecService) { +.controller('ContainerConsoleController', ['$scope', '$transition$', 'ContainerService', 'ImageService', 'EndpointProvider', 'Notifications', 'ContainerHelper', 'ExecService', 'HttpRequestHelper', +function ($scope, $transition$, ContainerService, ImageService, EndpointProvider, Notifications, ContainerHelper, ExecService, HttpRequestHelper) { + var socket, term; + $scope.state = { loaded: false, connected: false @@ -8,8 +10,6 @@ function ($scope, $transition$, Container, Image, EndpointProvider, Notification $scope.formValues = {}; - var socket, term; - // Ensure the socket is closed before leaving the view $scope.$on('$stateChangeStart', function (event, next, current) { if (socket && socket !== null) { @@ -17,23 +17,6 @@ function ($scope, $transition$, Container, Image, EndpointProvider, Notification } }); - Container.get({id: $transition$.params().id}, function(d) { - $scope.container = d; - if (d.message) { - Notifications.error('Error', d, 'Unable to retrieve container details'); - } else { - Image.get({id: d.Image}, function(imgData) { - $scope.imageOS = imgData.Os; - $scope.formValues.command = imgData.Os === 'windows' ? 'powershell' : 'bash'; - $scope.state.loaded = true; - }, function (e) { - Notifications.error('Failure', e, 'Unable to retrieve image details'); - }); - } - }, function (e) { - Notifications.error('Failure', e, 'Unable to retrieve container details'); - }); - $scope.connect = function() { var termWidth = Math.floor(($('#terminal-container').width() - 20) / 8.39); var termHeight = 30; @@ -54,6 +37,9 @@ function ($scope, $transition$, Container, Image, EndpointProvider, Notification .then(function success(data) { execId = data.Id; var url = window.location.href.split('#')[0] + 'api/websocket/exec?id=' + execId + '&endpointId=' + EndpointProvider.endpointID(); + if ($transition$.params().nodeName) { + url += '&nodeName=' + $transition$.params().nodeName; + } if (url.indexOf('https') > -1) { url = url.replace('https://', 'wss://'); } else { @@ -108,4 +94,25 @@ function ($scope, $transition$, Container, Image, EndpointProvider, Notification }; }; } + + function initView() { + HttpRequestHelper.setPortainerAgentTargetHeader($transition$.params().nodeName); + ContainerService.container($transition$.params().id) + .then(function success(data) { + var container = data; + $scope.container = container; + return ImageService.image(container.Image); + }) + .then(function success(data) { + var image = data; + $scope.imageOS = image.Os; + $scope.formValues.command = image.Os === 'windows' ? 'powershell' : 'bash'; + $scope.state.loaded = true; + }) + .catch(function error(err) { + Notifications.error('Error', err, 'Unable to retrieve container details'); + }); + } + + initView(); }]); diff --git a/app/docker/views/containers/console/containerconsole.html b/app/docker/views/containers/console/containerconsole.html index b15505e49..9432c3a6f 100644 --- a/app/docker/views/containers/console/containerconsole.html +++ b/app/docker/views/containers/console/containerconsole.html @@ -22,6 +22,7 @@ - - - diff --git a/app/docker/views/containers/edit/container.html b/app/docker/views/containers/edit/container.html index 03e9fba83..a84a1ab8d 100644 --- a/app/docker/views/containers/edit/container.html +++ b/app/docker/views/containers/edit/container.html @@ -20,12 +20,12 @@ -
- - + Duplicate/Edit
@@ -84,10 +84,10 @@ @@ -184,13 +184,13 @@ Image - {{ container.Image }} + {{ container.Image }} Port configuration
- {{ portMapping.container }} {{ portMapping.host }} + {{ portMapping.host }} {{ portMapping.container }}
@@ -259,7 +259,7 @@ {{ vol.Source }} - {{ vol.Name }} + {{ vol.Name }} {{ vol.Destination }} @@ -280,6 +280,7 @@ join-network-action-in-progress="state.joinNetworkInProgress" leave-network-action="containerLeaveNetwork" leave-network-action-in-progress="state.leaveNetworkInProgress" + node-name="nodeName" > diff --git a/app/docker/views/containers/edit/containerController.js b/app/docker/views/containers/edit/containerController.js index c96578c7b..a0a2f4e60 100644 --- a/app/docker/views/containers/edit/containerController.js +++ b/app/docker/views/containers/edit/containerController.js @@ -1,6 +1,6 @@ angular.module('portainer.docker') -.controller('ContainerController', ['$q', '$scope', '$state','$transition$', '$filter', 'Container', 'Commit', 'ContainerHelper', 'ContainerService', 'ImageHelper', 'Network', 'NetworkService', 'Notifications', 'ModalService', 'ResourceControlService', 'RegistryService', 'ImageService', -function ($q, $scope, $state, $transition$, $filter, Container, Commit, ContainerHelper, ContainerService, ImageHelper, Network, NetworkService, Notifications, ModalService, ResourceControlService, RegistryService, ImageService) { +.controller('ContainerController', ['$q', '$scope', '$state','$transition$', '$filter', 'Commit', 'ContainerHelper', 'ContainerService', 'ImageHelper', 'NetworkService', 'Notifications', 'ModalService', 'ResourceControlService', 'RegistryService', 'ImageService', 'HttpRequestHelper', +function ($q, $scope, $state, $transition$, $filter, Commit, ContainerHelper, ContainerService, ImageHelper, NetworkService, Notifications, ModalService, ResourceControlService, RegistryService, ImageService, HttpRequestHelper) { $scope.activityTime = 0; $scope.portBindings = []; @@ -16,8 +16,13 @@ function ($q, $scope, $state, $transition$, $filter, Container, Commit, Containe }; var update = function () { - Container.get({id: $transition$.params().id}, function (d) { - var container = new ContainerDetailsViewModel(d); + var nodeName = $transition$.params().nodeName; + HttpRequestHelper.setPortainerAgentTargetHeader(nodeName); + $scope.nodeName = nodeName; + + ContainerService.container($transition$.params().id) + .then(function success(data) { + var container = data; $scope.container = container; $scope.container.edit = false; $scope.container.newContainerName = $filter('trimcontainername')(container.Name); @@ -41,41 +46,105 @@ function ($q, $scope, $state, $transition$, $filter, Container, Commit, Containe } }); } - }, function (e) { - Notifications.error('Failure', e, 'Unable to retrieve container info'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve container info'); }); }; - $scope.start = function () { - Container.start({id: $scope.container.Id}, {}, function (d) { + function executeContainerAction(id, action, successMessage, errorMessage) { + action(id) + .then(function success(data) { + Notifications.success(successMessage, id); update(); - Notifications.success('Container started', $transition$.params().id); - }, function (e) { - update(); - Notifications.error('Failure', e, 'Unable to start container'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, errorMessage); }); + } + + $scope.start = function () { + var successMessage = 'Container successfully started'; + var errorMessage = 'Unable to start container'; + executeContainerAction($transition$.params().id, ContainerService.startContainer, successMessage, errorMessage); }; $scope.stop = function () { - Container.stop({id: $transition$.params().id}, function (d) { - update(); - Notifications.success('Container stopped', $transition$.params().id); - }, function (e) { - update(); - Notifications.error('Failure', e, 'Unable to stop container'); - }); + var successMessage = 'Container successfully stopped'; + var errorMessage = 'Unable to stop container'; + executeContainerAction($transition$.params().id, ContainerService.stopContainer, successMessage, errorMessage); }; $scope.kill = function () { - Container.kill({id: $transition$.params().id}, function (d) { - update(); - Notifications.success('Container killed', $transition$.params().id); - }, function (e) { - update(); - Notifications.error('Failure', e, 'Unable to kill container'); + var successMessage = 'Container successfully killed'; + var errorMessage = 'Unable to kill container'; + executeContainerAction($transition$.params().id, ContainerService.killContainer, successMessage, errorMessage); + }; + + $scope.pause = function() { + var successMessage = 'Container successfully paused'; + var errorMessage = 'Unable to pause container'; + executeContainerAction($transition$.params().id, ContainerService.pauseContainer, successMessage, errorMessage); + }; + + $scope.unpause = function() { + var successMessage = 'Container successfully resumed'; + var errorMessage = 'Unable to resume container'; + executeContainerAction($transition$.params().id, ContainerService.resumeContainer, successMessage, errorMessage); + }; + + $scope.restart = function () { + var successMessage = 'Container successfully restarted'; + var errorMessage = 'Unable to restart container'; + executeContainerAction($transition$.params().id, ContainerService.restartContainer, successMessage, errorMessage); + }; + + $scope.renameContainer = function () { + var container = $scope.container; + ContainerService.renameContainer($transition$.params().id, container.newContainerName) + .then(function success(data) { + container.Name = container.newContainerName; + Notifications.success('Container successfully renamed', container.Name); + }) + .catch(function error(err) { + container.newContainerName = container.Name; + Notifications.error('Failure', err, 'Unable to rename container'); + }) + .finally(function final() { + $scope.container.edit = false; }); }; + $scope.containerLeaveNetwork = function containerLeaveNetwork(container, networkId) { + $scope.state.leaveNetworkInProgress = true; + NetworkService.disconnectContainer(networkId, container.Id, false) + .then(function success(data) { + Notifications.success('Container left network', container.Id); + $state.reload(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to disconnect container from network'); + }) + .finally(function final() { + $scope.state.leaveNetworkInProgress = false; + }); + }; + + $scope.containerJoinNetwork = function containerJoinNetwork(container, networkId) { + $scope.state.joinNetworkInProgress = true; + NetworkService.connectContainer(networkId, container.Id) + .then(function success(data) { + Notifications.success('Container joined network', container.Id); + $state.reload(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to connect container to network'); + }) + .finally(function final() { + $scope.state.joinNetworkInProgress = false; + }); + }; + $scope.commit = function () { var image = $scope.config.Image; var registry = $scope.config.Registry; @@ -89,25 +158,6 @@ function ($q, $scope, $state, $transition$, $filter, Container, Commit, Containe }); }; - $scope.pause = function () { - Container.pause({id: $transition$.params().id}, function (d) { - update(); - Notifications.success('Container paused', $transition$.params().id); - }, function (e) { - update(); - Notifications.error('Failure', e, 'Unable to pause container'); - }); - }; - - $scope.unpause = function () { - Container.unpause({id: $transition$.params().id}, function (d) { - update(); - Notifications.success('Container unpaused', $transition$.params().id); - }, function (e) { - update(); - Notifications.error('Failure', e, 'Unable to unpause container'); - }); - }; $scope.confirmRemove = function () { var title = 'You are about to remove a container.'; @@ -122,12 +172,12 @@ function ($q, $scope, $state, $transition$, $filter, Container, Commit, Containe if (result[0]) { cleanAssociatedVolumes = true; } - $scope.remove(cleanAssociatedVolumes); + removeContainer(cleanAssociatedVolumes); } ); }; - $scope.remove = function(cleanAssociatedVolumes) { + function removeContainer(cleanAssociatedVolumes) { ContainerService.remove($scope.container, cleanAssociatedVolumes) .then(function success() { Notifications.success('Container successfully removed'); @@ -136,71 +186,7 @@ function ($q, $scope, $state, $transition$, $filter, Container, Commit, Containe .catch(function error(err) { Notifications.error('Failure', err, 'Unable to remove container'); }); - }; - - $scope.restart = function () { - Container.restart({id: $transition$.params().id}, function (d) { - update(); - Notifications.success('Container restarted', $transition$.params().id); - }, function (e) { - update(); - Notifications.error('Failure', e, 'Unable to restart container'); - }); - }; - - $scope.renameContainer = function () { - var container = $scope.container; - Container.rename({id: $transition$.params().id, 'name': container.newContainerName}, function (d) { - if (d.message) { - container.newContainerName = container.Name; - Notifications.error('Unable to rename container', {}, d.message); - } else { - container.Name = container.newContainerName; - Notifications.success('Container successfully renamed', container.Name); - } - }, function (e) { - Notifications.error('Failure', e, 'Unable to rename container'); - }); - $scope.container.edit = false; - }; - - $scope.containerLeaveNetwork = function containerLeaveNetwork(container, networkId) { - $scope.state.leaveNetworkInProgress = true; - Network.disconnect({id: networkId}, { Container: $transition$.params().id, Force: false }, function (d) { - if (container.message) { - Notifications.error('Error', d, 'Unable to disconnect container from network'); - } else { - Notifications.success('Container left network', $transition$.params().id); - $state.go('docker.containers.container', {id: $transition$.params().id}, {reload: true}); - } - $scope.state.leaveNetworkInProgress = false; - }, function (e) { - Notifications.error('Failure', e, 'Unable to disconnect container from network'); - $scope.state.leaveNetworkInProgress = false; - }); - }; - - $scope.duplicate = function() { - $state.go('docker.containers.new', {from: $transition$.params().id}, {reload: true}); - }; - - $scope.confirmRemove = function () { - var title = 'You are about to remove a container.'; - if ($scope.container.State.Running) { - title = 'You are about to remove a running container.'; - } - ModalService.confirmContainerDeletion( - title, - function (result) { - if(!result) { return; } - var cleanAssociatedVolumes = false; - if (result[0]) { - cleanAssociatedVolumes = true; - } - $scope.remove(cleanAssociatedVolumes); - } - ); - }; + } function recreateContainer(pullImage) { var container = $scope.container; @@ -253,29 +239,12 @@ function ($q, $scope, $state, $transition$, $filter, Container, Commit, Containe }); }; - $scope.containerJoinNetwork = function containerJoinNetwork(container, networkId) { - $scope.state.joinNetworkInProgress = true; - Network.connect({id: networkId}, { Container: $transition$.params().id }, function (d) { - if (container.message) { - Notifications.error('Error', d, 'Unable to connect container to network'); - } else { - Notifications.success('Container joined network', $transition$.params().id); - $state.go('docker.containers.container', {id: $transition$.params().id}, {reload: true}); - } - $scope.state.joinNetworkInProgress = false; - }, function (e) { - Notifications.error('Failure', e, 'Unable to connect container to network'); - $scope.state.joinNetworkInProgress = false; - }); - }; - var provider = $scope.applicationState.endpoint.mode.provider; var apiVersion = $scope.applicationState.endpoint.apiVersion; NetworkService.networks( provider === 'DOCKER_STANDALONE' || provider === 'DOCKER_SWARM_MODE', false, - provider === 'DOCKER_SWARM_MODE' && apiVersion >= 1.25, - provider === 'DOCKER_SWARM' + provider === 'DOCKER_SWARM_MODE' && apiVersion >= 1.25 ) .then(function success(data) { var networks = data; diff --git a/app/docker/views/containers/inspect/containerInspectController.js b/app/docker/views/containers/inspect/containerInspectController.js index 27d7b21f2..458a0e6a4 100644 --- a/app/docker/views/containers/inspect/containerInspectController.js +++ b/app/docker/views/containers/inspect/containerInspectController.js @@ -1,6 +1,6 @@ angular.module('portainer.docker') -.controller('ContainerInspectController', ['$scope', '$transition$', 'Notifications', 'ContainerService', -function ($scope, $transition$, Notifications, ContainerService) { +.controller('ContainerInspectController', ['$scope', '$transition$', 'Notifications', 'ContainerService', 'HttpRequestHelper', +function ($scope, $transition$, Notifications, ContainerService, HttpRequestHelper) { $scope.state = { DisplayTextView: false @@ -8,6 +8,7 @@ function ($scope, $transition$, Notifications, ContainerService) { $scope.containerInfo = {}; function initView() { + HttpRequestHelper.setPortainerAgentTargetHeader($transition$.params().nodeName); ContainerService.inspect($transition$.params().id) .then(function success(d) { $scope.containerInfo = d; diff --git a/app/docker/views/containers/logs/containerLogsController.js b/app/docker/views/containers/logs/containerLogsController.js index ba2f661ff..2141a1f3b 100644 --- a/app/docker/views/containers/logs/containerLogsController.js +++ b/app/docker/views/containers/logs/containerLogsController.js @@ -1,6 +1,6 @@ angular.module('portainer.docker') -.controller('ContainerLogsController', ['$scope', '$transition$', '$interval', 'ContainerService', 'Notifications', -function ($scope, $transition$, $interval, ContainerService, Notifications) { +.controller('ContainerLogsController', ['$scope', '$transition$', '$interval', 'ContainerService', 'Notifications', 'HttpRequestHelper', +function ($scope, $transition$, $interval, ContainerService, Notifications, HttpRequestHelper) { $scope.state = { refreshRate: 3, lineCount: 2000, @@ -11,7 +11,7 @@ function ($scope, $transition$, $interval, ContainerService, Notifications) { if (!logCollectionStatus) { stopRepeater(); } else { - setUpdateRepeater(); + setUpdateRepeater(!$scope.container.Config.Tty); } }; @@ -31,10 +31,10 @@ function ($scope, $transition$, $interval, ContainerService, Notifications) { $scope.logs = logs; } - function setUpdateRepeater() { + function setUpdateRepeater(skipHeaders) { var refreshRate = $scope.state.refreshRate; $scope.repeater = $interval(function() { - ContainerService.logs($transition$.params().id, 1, 1, $scope.state.displayTimestamps ? 1 : 0, $scope.state.lineCount) + ContainerService.logs($transition$.params().id, 1, 1, $scope.state.displayTimestamps ? 1 : 0, $scope.state.lineCount, skipHeaders) .then(function success(data) { $scope.logs = data; }) @@ -45,11 +45,11 @@ function ($scope, $transition$, $interval, ContainerService, Notifications) { }, refreshRate * 1000); } - function startLogPolling() { - ContainerService.logs($transition$.params().id, 1, 1, $scope.state.displayTimestamps ? 1 : 0, $scope.state.lineCount) + function startLogPolling(skipHeaders) { + ContainerService.logs($transition$.params().id, 1, 1, $scope.state.displayTimestamps ? 1 : 0, $scope.state.lineCount, skipHeaders) .then(function success(data) { $scope.logs = data; - setUpdateRepeater(); + setUpdateRepeater(skipHeaders); }) .catch(function error(err) { stopRepeater(); @@ -58,10 +58,12 @@ function ($scope, $transition$, $interval, ContainerService, Notifications) { } function initView() { + HttpRequestHelper.setPortainerAgentTargetHeader($transition$.params().nodeName); ContainerService.container($transition$.params().id) .then(function success(data) { - $scope.container = data; - startLogPolling(); + var container = data; + $scope.container = container; + startLogPolling(!container.Config.Tty); }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to retrieve container information'); diff --git a/app/docker/views/containers/stats/containerStatsController.js b/app/docker/views/containers/stats/containerStatsController.js index 554db948f..ed161c8d1 100644 --- a/app/docker/views/containers/stats/containerStatsController.js +++ b/app/docker/views/containers/stats/containerStatsController.js @@ -1,6 +1,6 @@ angular.module('portainer.docker') -.controller('ContainerStatsController', ['$q', '$scope', '$transition$', '$document', '$interval', 'ContainerService', 'ChartService', 'Notifications', -function ($q, $scope, $transition$, $document, $interval, ContainerService, ChartService, Notifications) { +.controller('ContainerStatsController', ['$q', '$scope', '$transition$', '$document', '$interval', 'ContainerService', 'ChartService', 'Notifications', 'HttpRequestHelper', +function ($q, $scope, $transition$, $document, $interval, ContainerService, ChartService, Notifications, HttpRequestHelper) { $scope.state = { refreshRate: '5', @@ -126,6 +126,7 @@ function ($q, $scope, $transition$, $document, $interval, ContainerService, Char } function initView() { + HttpRequestHelper.setPortainerAgentTargetHeader($transition$.params().nodeName); ContainerService.container($transition$.params().id) .then(function success(data) { $scope.container = data; diff --git a/app/docker/views/dashboard/dashboard.html b/app/docker/views/dashboard/dashboard.html index 1aa573f33..6f70f696d 100644 --- a/app/docker/views/dashboard/dashboard.html +++ b/app/docker/views/dashboard/dashboard.html @@ -3,8 +3,14 @@ Dashboard -
-
+
+
+ +
+
+ +
+
@@ -31,33 +37,6 @@
-
- - - - - - - - - - - - - - - - - - - - - - -
Nodes{{ infoData.SystemStatus[0][1] == 'primary' ? infoData.SystemStatus[3][1] : infoData.SystemStatus[4][1] }}
Swarm version{{ infoData.ServerVersion|swarmversion }}
Total CPU{{ infoData.NCPU }}
Total memory{{ infoData.MemTotal|humansize: 2 }}
-
-
-
diff --git a/app/docker/views/images/build/buildImageController.js b/app/docker/views/images/build/buildImageController.js index 4b28b4492..5f4aec623 100644 --- a/app/docker/views/images/build/buildImageController.js +++ b/app/docker/views/images/build/buildImageController.js @@ -1,6 +1,6 @@ angular.module('portainer.docker') -.controller('BuildImageController', ['$scope', '$state', 'BuildService', 'Notifications', -function ($scope, $state, BuildService, Notifications) { +.controller('BuildImageController', ['$scope', '$state', 'BuildService', 'Notifications', 'HttpRequestHelper', +function ($scope, $state, BuildService, Notifications, HttpRequestHelper) { $scope.state = { BuildType: 'editor', @@ -13,7 +13,8 @@ function ($scope, $state, BuildService, Notifications) { UploadFile: null, DockerFileContent: '', URL: '', - Path: 'Dockerfile' + Path: 'Dockerfile', + NodeName: null }; $scope.addImageName = function() { @@ -56,6 +57,9 @@ function ($scope, $state, BuildService, Notifications) { return x.Name; }); + var nodeName = $scope.formValues.NodeName; + HttpRequestHelper.setPortainerAgentTargetHeader(nodeName); + buildImageBasedOnBuildType(buildType, imageNames) .then(function success(data) { $scope.buildLogs = data.buildLogs; diff --git a/app/docker/views/images/build/buildimage.html b/app/docker/views/images/build/buildimage.html index 53e00acd9..9a6e23b51 100644 --- a/app/docker/views/images/build/buildimage.html +++ b/app/docker/views/images/build/buildimage.html @@ -194,6 +194,16 @@
+
+
+ Deployment +
+ + + + +
Actions diff --git a/app/docker/views/images/edit/imageController.js b/app/docker/views/images/edit/imageController.js index 2e59fe912..236f1633f 100644 --- a/app/docker/views/images/edit/imageController.js +++ b/app/docker/views/images/edit/imageController.js @@ -1,6 +1,6 @@ angular.module('portainer.docker') -.controller('ImageController', ['$q', '$scope', '$transition$', '$state', '$timeout', 'ImageService', 'RegistryService', 'Notifications', -function ($q, $scope, $transition$, $state, $timeout, ImageService, RegistryService, Notifications) { +.controller('ImageController', ['$q', '$scope', '$transition$', '$state', '$timeout', 'ImageService', 'RegistryService', 'Notifications', 'HttpRequestHelper', +function ($q, $scope, $transition$, $state, $timeout, ImageService, RegistryService, Notifications, HttpRequestHelper) { $scope.formValues = { Image: '', Registry: '' @@ -98,6 +98,7 @@ function ($q, $scope, $transition$, $state, $timeout, ImageService, RegistryServ }; function initView() { + HttpRequestHelper.setPortainerAgentTargetHeader($transition$.params().nodeName); var endpointProvider = $scope.applicationState.endpoint.mode.provider; $q.all({ image: ImageService.image($transition$.params().id), diff --git a/app/docker/views/images/images.html b/app/docker/views/images/images.html index 2dd679bda..1b6c807c3 100644 --- a/app/docker/views/images/images.html +++ b/app/docker/views/images/images.html @@ -26,6 +26,16 @@
+
+
+ Deployment +
+ + + + +
+
+
+ Deployment +
+ + + + +
diff --git a/app/docker/views/networks/edit/network.html b/app/docker/views/networks/edit/network.html index 87527f91a..b9e269187 100644 --- a/app/docker/views/networks/edit/network.html +++ b/app/docker/views/networks/edit/network.html @@ -89,12 +89,12 @@ - {{ container.Name }} + {{ container.Name }} {{ container.IPv4Address || '-' }} {{ container.IPv6Address || '-' }} {{ container.MacAddress || '-' }} - + diff --git a/app/docker/views/networks/edit/networkController.js b/app/docker/views/networks/edit/networkController.js index b4b889bcd..0ba905c2a 100644 --- a/app/docker/views/networks/edit/networkController.js +++ b/app/docker/views/networks/edit/networkController.js @@ -1,30 +1,27 @@ angular.module('portainer.docker') -.controller('NetworkController', ['$scope', '$state', '$transition$', '$filter', 'Network', 'NetworkService', 'Container', 'ContainerHelper', 'Notifications', -function ($scope, $state, $transition$, $filter, Network, NetworkService, Container, ContainerHelper, Notifications) { +.controller('NetworkController', ['$scope', '$state', '$transition$', '$filter', 'NetworkService', 'Container', 'ContainerHelper', 'Notifications', 'HttpRequestHelper', +function ($scope, $state, $transition$, $filter, NetworkService, Container, ContainerHelper, Notifications, HttpRequestHelper) { $scope.removeNetwork = function removeNetwork(networkId) { - Network.remove({id: $transition$.params().id}, function (d) { - if (d.message) { - Notifications.error('Error', d, 'Unable to remove network'); - } else { - Notifications.success('Network removed', $transition$.params().id); - $state.go('docker.networks', {}); - } - }, function (e) { - Notifications.error('Failure', e, 'Unable to remove network'); + NetworkService.remove($transition$.params().id, $transition$.params().id) + .then(function success(data) { + Notifications.success('Network removed', $transition$.params().id); + $state.go('docker.networks', {}); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove network'); }); }; - $scope.containerLeaveNetwork = function containerLeaveNetwork(network, containerId) { - Network.disconnect({id: $transition$.params().id}, { Container: containerId, Force: false }, function (d) { - if (d.message) { - Notifications.error('Error', d, 'Unable to disconnect container from network'); - } else { - Notifications.success('Container left network', $transition$.params().id); - $state.go('docker.networks.network', {id: network.Id}, {reload: true}); - } - }, function (e) { - Notifications.error('Failure', e, 'Unable to disconnect container from network'); + $scope.containerLeaveNetwork = function containerLeaveNetwork(network, container) { + HttpRequestHelper.setPortainerAgentTargetHeader(container.NodeName); + NetworkService.disconnectContainer($transition$.params().id, container.Id, false) + .then(function success(data) { + Notifications.success('Container left network', $transition$.params().id); + $state.go('docker.networks.network', { id: network.Id }, { reload: true }); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to disconnect container from network'); }); }; @@ -71,6 +68,9 @@ function ($scope, $state, $transition$, $filter, Network, NetworkService, Contai } function initView() { + var nodeName = $transition$.params().nodeName; + HttpRequestHelper.setPortainerAgentTargetHeader(nodeName); + $scope.nodeName = nodeName; NetworkService.network($transition$.params().id) .then(function success(data) { $scope.network = data; diff --git a/app/docker/views/networks/networks.html b/app/docker/views/networks/networks.html index de09d8d4b..1c224f0da 100644 --- a/app/docker/views/networks/networks.html +++ b/app/docker/views/networks/networks.html @@ -15,6 +15,7 @@ order-by="Name" show-text-filter="true" remove-action="removeAction" show-ownership-column="applicationState.application.authentication" + show-host-column="applicationState.endpoint.mode.agentProxy" >
diff --git a/app/docker/views/networks/networksController.js b/app/docker/views/networks/networksController.js index c5ff237b5..013b3bb35 100644 --- a/app/docker/views/networks/networksController.js +++ b/app/docker/views/networks/networksController.js @@ -1,10 +1,11 @@ angular.module('portainer.docker') -.controller('NetworksController', ['$scope', '$state', 'NetworkService', 'Notifications', -function ($scope, $state, NetworkService, Notifications) { +.controller('NetworksController', ['$scope', '$state', 'NetworkService', 'Notifications', 'HttpRequestHelper', +function ($scope, $state, NetworkService, Notifications, HttpRequestHelper) { $scope.removeAction = function (selectedItems) { var actionCount = selectedItems.length; angular.forEach(selectedItems, function (network) { + HttpRequestHelper.setPortainerAgentTargetHeader(network.NodeName); NetworkService.remove(network.Id) .then(function success() { Notifications.success('Network successfully removed', network.Name); diff --git a/app/docker/views/services/edit/includes/tasks.html b/app/docker/views/services/edit/includes/tasks.html index 369276b9a..703317187 100644 --- a/app/docker/views/services/edit/includes/tasks.html +++ b/app/docker/views/services/edit/includes/tasks.html @@ -7,5 +7,6 @@ show-text-filter="true" show-slot-column="service.Mode !== 'global'" show-logs-button="applicationState.endpoint.apiVersion >= 1.30" + agent-proxy="applicationState.endpoint.mode.agentProxy" > diff --git a/app/docker/views/services/edit/serviceController.js b/app/docker/views/services/edit/serviceController.js index eb317988d..ffce87b3b 100644 --- a/app/docker/views/services/edit/serviceController.js +++ b/app/docker/views/services/edit/serviceController.js @@ -1,6 +1,6 @@ angular.module('portainer.docker') -.controller('ServiceController', ['$q', '$scope', '$transition$', '$state', '$location', '$timeout', '$anchorScroll', 'ServiceService', 'ConfigService', 'ConfigHelper', 'SecretService', 'ImageService', 'SecretHelper', 'Service', 'ServiceHelper', 'LabelHelper', 'TaskService', 'NodeService', 'Notifications', 'ModalService', 'PluginService', 'Authentication', 'SettingsService', 'VolumeService', -function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, ServiceService, ConfigService, ConfigHelper, SecretService, ImageService, SecretHelper, Service, ServiceHelper, LabelHelper, TaskService, NodeService, Notifications, ModalService, PluginService, Authentication, SettingsService, VolumeService) { +.controller('ServiceController', ['$q', '$scope', '$transition$', '$state', '$location', '$timeout', '$anchorScroll', 'ServiceService', 'ConfigService', 'ConfigHelper', 'SecretService', 'ImageService', 'SecretHelper', 'Service', 'ServiceHelper', 'LabelHelper', 'TaskService', 'NodeService', 'ContainerService', 'TaskHelper', 'Notifications', 'ModalService', 'PluginService', 'Authentication', 'SettingsService', 'VolumeService', +function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, ServiceService, ConfigService, ConfigHelper, SecretService, ImageService, SecretHelper, Service, ServiceHelper, LabelHelper, TaskService, NodeService, ContainerService, TaskHelper, Notifications, ModalService, PluginService, Authentication, SettingsService, VolumeService) { $scope.state = { updateInProgress: false, @@ -254,28 +254,32 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, config.TaskTemplate.Placement.Constraints = ServiceHelper.translateKeyValueToPlacementConstraints(service.ServiceConstraints); config.TaskTemplate.Placement.Preferences = ServiceHelper.translateKeyValueToPlacementPreferences(service.ServicePreferences); - // Round memory values to 0.125 and convert MB to B - var memoryLimit = (Math.round(service.LimitMemoryBytes * 8) / 8).toFixed(3); - memoryLimit *= 1024 * 1024; - var memoryReservation = (Math.round(service.ReservationMemoryBytes * 8) / 8).toFixed(3); - memoryReservation *= 1024 * 1024; - config.TaskTemplate.Resources = { - Limits: { - NanoCPUs: service.LimitNanoCPUs * 1000000000, - MemoryBytes: memoryLimit - }, - Reservations: { - NanoCPUs: service.ReservationNanoCPUs * 1000000000, - MemoryBytes: memoryReservation - } - }; + if ($scope.hasChanges(service, ['LimitNanoCPUs', 'LimitMemoryBytes', 'ReservationNanoCPUs', 'ReservationMemoryBytes'])) { + // Round memory values to 0.125 and convert MB to B + var memoryLimit = (Math.round(service.LimitMemoryBytes * 8) / 8).toFixed(3); + memoryLimit *= 1024 * 1024; + var memoryReservation = (Math.round(service.ReservationMemoryBytes * 8) / 8).toFixed(3); + memoryReservation *= 1024 * 1024; + config.TaskTemplate.Resources = { + Limits: { + NanoCPUs: service.LimitNanoCPUs * 1000000000, + MemoryBytes: memoryLimit + }, + Reservations: { + NanoCPUs: service.ReservationNanoCPUs * 1000000000, + MemoryBytes: memoryReservation + } + }; + } - config.UpdateConfig = { - Parallelism: service.UpdateParallelism, - Delay: ServiceHelper.translateHumanDurationToNanos(service.UpdateDelay) || 0, - FailureAction: service.UpdateFailureAction, - Order: service.UpdateOrder - }; + if($scope.hasChanges(service, ['UpdateFailureAction', 'UpdateDelay', 'UpdateParallelism', 'UpdateOrder'])) { + config.UpdateConfig = { + Parallelism: service.UpdateParallelism, + Delay: ServiceHelper.translateHumanDurationToNanos(service.UpdateDelay) || 0, + FailureAction: service.UpdateFailureAction, + Order: service.UpdateOrder + }; + } if ($scope.hasChanges(service, ['RestartCondition', 'RestartDelay', 'RestartMaxAttempts', 'RestartWindow'])){ config.TaskTemplate.RestartPolicy = { @@ -407,10 +411,12 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, function initView() { var apiVersion = $scope.applicationState.endpoint.apiVersion; + var agentProxy = $scope.applicationState.endpoint.mode.agentProxy; + var service = null; ServiceService.service($transition$.params().id) .then(function success(data) { - var service = data; + service = data; $scope.isUpdating = $scope.lastVersion >= service.Version; if (!$scope.isUpdating) { $scope.lastVersion = service.Version; @@ -425,6 +431,7 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, return $q.all({ volumes: VolumeService.volumes(), tasks: TaskService.tasks({ service: [service.Name] }), + containers: agentProxy ? ContainerService.containers() : [], nodes: NodeService.nodes(), secrets: apiVersion >= 1.25 ? SecretService.secrets() : [], configs: apiVersion >= 1.30 ? ConfigService.configs() : [], @@ -434,7 +441,6 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, }); }) .then(function success(data) { - $scope.tasks = data.tasks; $scope.nodes = data.nodes; $scope.configs = data.configs; $scope.secrets = data.secrets; @@ -445,6 +451,21 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, var userDetails = Authentication.getUserDetails(); $scope.isAdmin = userDetails.role === 1; + var tasks = data.tasks; + + if (agentProxy) { + var containers = data.containers; + for (var i = 0; i < tasks.length; i++) { + var task = tasks[i]; + TaskHelper.associateContainerToTask(task, containers); + } + } + + ServiceHelper.associateTasksToService(service, tasks); + + $scope.tasks = data.tasks; + + // Set max cpu value var maxCpus = 0; for (var n in data.nodes) { diff --git a/app/docker/views/stacks/edit/stack.html b/app/docker/views/stacks/edit/stack.html index 8779e380a..931ba087b 100644 --- a/app/docker/views/stacks/edit/stack.html +++ b/app/docker/views/stacks/edit/stack.html @@ -27,6 +27,7 @@ nodes="nodes" public-url="state.publicURL" show-text-filter="true" + scale-action="scaleAction" > @@ -41,6 +42,7 @@ show-text-filter="true" show-slot-column="true" show-logs-button="applicationState.endpoint.apiVersion >= 1.30" + agent-proxy="applicationState.endpoint.mode.agentProxy" > diff --git a/app/docker/views/stacks/edit/stackController.js b/app/docker/views/stacks/edit/stackController.js index 04bbe0c36..cb09c398f 100644 --- a/app/docker/views/stacks/edit/stackController.js +++ b/app/docker/views/stacks/edit/stackController.js @@ -1,6 +1,6 @@ angular.module('portainer.docker') -.controller('StackController', ['$q', '$scope', '$state', '$transition$', 'StackService', 'NodeService', 'ServiceService', 'TaskService', 'ServiceHelper', 'Notifications', 'FormHelper', 'EndpointProvider', -function ($q, $scope, $state, $transition$, StackService, NodeService, ServiceService, TaskService, ServiceHelper, Notifications, FormHelper, EndpointProvider) { +.controller('StackController', ['$q', '$scope', '$state', '$transition$', 'StackService', 'NodeService', 'ServiceService', 'TaskService', 'ContainerService', 'ServiceHelper', 'TaskHelper', 'Notifications', 'FormHelper', 'EndpointProvider', +function ($q, $scope, $state, $transition$, StackService, NodeService, ServiceService, TaskService, ContainerService, ServiceHelper, TaskHelper, Notifications, FormHelper, EndpointProvider) { $scope.state = { actionInProgress: false, @@ -41,6 +41,7 @@ function ($q, $scope, $state, $transition$, StackService, NodeService, ServiceSe function initView() { var stackId = $transition$.params().id; var apiVersion = $scope.applicationState.endpoint.apiVersion; + var agentProxy = $scope.applicationState.endpoint.mode.agentProxy; StackService.stack(stackId) .then(function success(data) { @@ -55,24 +56,31 @@ function ($q, $scope, $state, $transition$, StackService, NodeService, ServiceSe stackFile: StackService.getStackFile(stackId), services: ServiceService.services(serviceFilters), tasks: TaskService.tasks(serviceFilters), + containers: agentProxy ? ContainerService.containers() : [], nodes: NodeService.nodes() }); }) .then(function success(data) { $scope.stackFileContent = data.stackFile; - $scope.nodes = data.nodes; var services = data.services; - var tasks = data.tasks; - $scope.tasks = tasks; + + if (agentProxy) { + var containers = data.containers; + for (var j = 0; j < tasks.length; j++) { + var task = tasks[j]; + TaskHelper.associateContainerToTask(task, containers); + } + } for (var i = 0; i < services.length; i++) { var service = services[i]; ServiceHelper.associateTasksToService(service, tasks); } + $scope.tasks = tasks; $scope.services = services; }) .catch(function error(err) { @@ -84,5 +92,20 @@ function ($q, $scope, $state, $transition$, StackService, NodeService, ServiceSe $scope.stackFileContent = cm.getValue(); }; + $scope.scaleAction = function scaleService(service) { + var config = ServiceHelper.serviceToConfig(service.Model); + config.Mode.Replicated.Replicas = service.Replicas; + ServiceService.update(service, config) + .then(function success(data) { + Notifications.success('Service successfully scaled', 'New replica count: ' + service.Replicas); + $state.reload(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to scale service'); + service.Scale = false; + service.Replicas = service.ReplicaCount; + }); + }; + initView(); }]); diff --git a/app/docker/views/swarm/swarm.html b/app/docker/views/swarm/swarm.html index cf678cc30..4e77f6140 100644 --- a/app/docker/views/swarm/swarm.html +++ b/app/docker/views/swarm/swarm.html @@ -16,48 +16,21 @@ Nodes - {{ swarm.Nodes }} - {{ info.Swarm.Nodes }} - - - Images - {{ info.Images }} - - - Swarm version - {{ docker.Version|swarmversion }} + {{ info.Swarm.Nodes }} Docker API version {{ docker.ApiVersion }} - - Strategy - {{ swarm.Strategy }} - Total CPU - {{ info.NCPU }} - {{ totalCPU }} + {{ totalCPU }} Total memory - {{ info.MemTotal|humansize: 2 }} - {{ totalMemory|humansize: 2 }} + {{ totalMemory|humansize: 2 }} - - Operating system - {{ info.OperatingSystem }} - - - Kernel version - {{ info.KernelVersion }} - - - Go version - {{ docker.GoVersion }} - - +
Go to cluster visualizer @@ -72,14 +45,7 @@
-
- -
-
+
- - + +
@@ -45,7 +45,7 @@ Only display running tasks
diff --git a/app/docker/views/templates/templates.html b/app/docker/views/templates/templates.html index cf03bd902..5dcec4d26 100644 --- a/app/docker/views/templates/templates.html +++ b/app/docker/views/templates/templates.html @@ -126,10 +126,7 @@
- - @@ -297,7 +294,7 @@
-
+
@@ -316,7 +313,15 @@
- + + +
+ +
+ +
+
+
@@ -329,9 +334,6 @@ Deploy the container Deployment in progress... - - When using Swarm, we recommend deploying containers in a shared network. Looks like you don't have any shared network, head over the networks view to create one. - {{ state.formValidationError }}
diff --git a/app/docker/views/templates/templatesController.js b/app/docker/views/templates/templatesController.js index b050fc6df..8e5ada599 100644 --- a/app/docker/views/templates/templatesController.js +++ b/app/docker/views/templates/templatesController.js @@ -198,11 +198,8 @@ function ($scope, $q, $state, $transition$, $anchorScroll, $filter, ContainerSer } 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') { + if (network.Name !== 'bridge') { containerMapping = 'BY_CONTAINER_NAME'; } return containerMapping; @@ -231,8 +228,8 @@ function ($scope, $q, $state, $transition$, $anchorScroll, $filter, ContainerSer networks: NetworkService.networks( provider === 'DOCKER_STANDALONE' || provider === 'DOCKER_SWARM_MODE', false, - provider === 'DOCKER_SWARM_MODE' && apiVersion >= 1.25, - provider === 'DOCKER_SWARM'), + provider === 'DOCKER_SWARM_MODE' && apiVersion >= 1.25 + ), settings: SettingsService.publicSettings() }) .then(function success(data) { diff --git a/app/docker/views/volumes/create/createVolumeController.js b/app/docker/views/volumes/create/createVolumeController.js index 249c05924..5180f3976 100644 --- a/app/docker/views/volumes/create/createVolumeController.js +++ b/app/docker/views/volumes/create/createVolumeController.js @@ -1,11 +1,12 @@ angular.module('portainer.docker') -.controller('CreateVolumeController', ['$q', '$scope', '$state', 'VolumeService', 'PluginService', 'ResourceControlService', 'Authentication', 'Notifications', 'FormValidator', -function ($q, $scope, $state, VolumeService, PluginService, ResourceControlService, Authentication, Notifications, FormValidator) { +.controller('CreateVolumeController', ['$q', '$scope', '$state', 'VolumeService', 'PluginService', 'ResourceControlService', 'Authentication', 'Notifications', 'FormValidator', 'HttpRequestHelper', +function ($q, $scope, $state, VolumeService, PluginService, ResourceControlService, Authentication, Notifications, FormValidator, HttpRequestHelper) { $scope.formValues = { Driver: 'local', DriverOptions: [], - AccessControlData: new AccessControlFormData() + AccessControlData: new AccessControlFormData(), + NodeName: null }; $scope.state = { @@ -55,6 +56,9 @@ function ($q, $scope, $state, VolumeService, PluginService, ResourceControlServi return; } + var nodeName = $scope.formValues.NodeName; + HttpRequestHelper.setPortainerAgentTargetHeader(nodeName); + $scope.state.actionInProgress = true; VolumeService.createVolume(volumeConfiguration) .then(function success(data) { @@ -75,17 +79,16 @@ function ($q, $scope, $state, VolumeService, PluginService, ResourceControlServi }; function initView() { - var endpointProvider = $scope.applicationState.endpoint.mode.provider; var apiVersion = $scope.applicationState.endpoint.apiVersion; - if (endpointProvider !== 'DOCKER_SWARM') { - PluginService.volumePlugins(apiVersion < 1.25 || endpointProvider === 'VMWARE_VIC') - .then(function success(data) { - $scope.availableVolumeDrivers = data; - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to retrieve volume drivers'); - }); - } + var endpointProvider = $scope.applicationState.endpoint.mode.provider; + + PluginService.volumePlugins(apiVersion < 1.25 || endpointProvider === 'VMWARE_VIC') + .then(function success(data) { + $scope.availableVolumeDrivers = data; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve volume drivers'); + }); } initView(); diff --git a/app/docker/views/volumes/create/createvolume.html b/app/docker/views/volumes/create/createvolume.html index 25c222e1c..27046bc97 100644 --- a/app/docker/views/volumes/create/createvolume.html +++ b/app/docker/views/volumes/create/createvolume.html @@ -6,7 +6,7 @@
-
+
@@ -70,6 +70,16 @@
+
+
+ Deployment +
+ + + + +
diff --git a/app/docker/views/volumes/edit/volume.html b/app/docker/views/volumes/edit/volume.html index 1b017ec95..0392e8cb3 100644 --- a/app/docker/views/volumes/edit/volume.html +++ b/app/docker/views/volumes/edit/volume.html @@ -84,7 +84,7 @@ - {{ container | containername }} + {{ container | containername }} {{ container.volumeData.Destination }} {{ !container.volumeData.RW }} diff --git a/app/docker/views/volumes/edit/volumeController.js b/app/docker/views/volumes/edit/volumeController.js index 61581ecb6..f314c1b3b 100644 --- a/app/docker/views/volumes/edit/volumeController.js +++ b/app/docker/views/volumes/edit/volumeController.js @@ -1,6 +1,6 @@ angular.module('portainer.docker') -.controller('VolumeController', ['$scope', '$state', '$transition$', 'VolumeService', 'ContainerService', 'Notifications', -function ($scope, $state, $transition$, VolumeService, ContainerService, Notifications) { +.controller('VolumeController', ['$scope', '$state', '$transition$', 'VolumeService', 'ContainerService', 'Notifications', 'HttpRequestHelper', +function ($scope, $state, $transition$, VolumeService, ContainerService, Notifications, HttpRequestHelper) { $scope.removeVolume = function removeVolume() { VolumeService.remove($scope.volume) @@ -20,6 +20,7 @@ function ($scope, $state, $transition$, VolumeService, ContainerService, Notific } function initView() { + HttpRequestHelper.setPortainerAgentTargetHeader($transition$.params().nodeName); VolumeService.volume($transition$.params().id) .then(function success(data) { var volume = data; diff --git a/app/docker/views/volumes/volumes.html b/app/docker/views/volumes/volumes.html index 85b57a11c..b31614b21 100644 --- a/app/docker/views/volumes/volumes.html +++ b/app/docker/views/volumes/volumes.html @@ -15,6 +15,7 @@ order-by="Id" show-text-filter="true" remove-action="removeAction" show-ownership-column="applicationState.application.authentication" + show-host-column="applicationState.endpoint.mode.agentProxy" >
diff --git a/app/docker/views/volumes/volumesController.js b/app/docker/views/volumes/volumesController.js index 30882a35d..362c54b4b 100644 --- a/app/docker/views/volumes/volumesController.js +++ b/app/docker/views/volumes/volumesController.js @@ -1,10 +1,11 @@ angular.module('portainer.docker') -.controller('VolumesController', ['$q', '$scope', '$state', 'VolumeService', 'ServiceService', 'VolumeHelper', 'Notifications', -function ($q, $scope, $state, VolumeService, ServiceService, VolumeHelper, Notifications) { +.controller('VolumesController', ['$q', '$scope', '$state', 'VolumeService', 'ServiceService', 'VolumeHelper', 'Notifications', 'HttpRequestHelper', +function ($q, $scope, $state, VolumeService, ServiceService, VolumeHelper, Notifications, HttpRequestHelper) { $scope.removeAction = function (selectedItems) { var actionCount = selectedItems.length; angular.forEach(selectedItems, function (volume) { + HttpRequestHelper.setPortainerAgentTargetHeader(volume.NodeName); VolumeService.remove(volume) .then(function success() { Notifications.success('Volume successfully removed', volume.Id); diff --git a/app/portainer/__module.js b/app/portainer/__module.js index 64f61b20e..77caaa70c 100644 --- a/app/portainer/__module.js +++ b/app/portainer/__module.js @@ -121,6 +121,17 @@ angular.module('portainer.app', []) } }; + var endpointCreation = { + name: 'portainer.endpoints.new', + url: '/new', + views: { + 'content@': { + templateUrl: 'app/portainer/views/endpoints/create/createendpoint.html', + controller: 'CreateEndpointController' + } + } + }; + var endpointAccess = { name: 'portainer.endpoints.endpoint.access', url: '/access', @@ -132,6 +143,50 @@ angular.module('portainer.app', []) } }; + var groups = { + name: 'portainer.groups', + url: '/groups', + views: { + 'content@': { + templateUrl: 'app/portainer/views/groups/groups.html', + controller: 'GroupsController' + } + } + }; + + var group = { + name: 'portainer.groups.group', + url: '/:id', + views: { + 'content@': { + templateUrl: 'app/portainer/views/groups/edit/group.html', + controller: 'GroupController' + } + } + }; + + var groupCreation = { + name: 'portainer.groups.new', + url: '/new', + views: { + 'content@': { + templateUrl: 'app/portainer/views/groups/create/creategroup.html', + controller: 'CreateGroupController' + } + } + }; + + var groupAccess = { + name: 'portainer.groups.group.access', + url: '/access', + views: { + 'content@': { + templateUrl: 'app/portainer/views/groups/access/groupAccess.html', + controller: 'GroupAccessController' + } + } + }; + var registries = { name: 'portainer.registries', url: '/registries', @@ -253,6 +308,11 @@ angular.module('portainer.app', []) $stateRegistryProvider.register(endpoints); $stateRegistryProvider.register(endpoint); $stateRegistryProvider.register(endpointAccess); + $stateRegistryProvider.register(endpointCreation); + $stateRegistryProvider.register(groups); + $stateRegistryProvider.register(group); + $stateRegistryProvider.register(groupAccess); + $stateRegistryProvider.register(groupCreation); $stateRegistryProvider.register(registries); $stateRegistryProvider.register(registry); $stateRegistryProvider.register(registryAccess); diff --git a/app/portainer/components/access-table/access-table.js b/app/portainer/components/access-table/access-table.js new file mode 100644 index 000000000..c1cd9cc37 --- /dev/null +++ b/app/portainer/components/access-table/access-table.js @@ -0,0 +1,21 @@ +angular.module('portainer.app').component('accessTable', { + templateUrl: 'app/portainer/components/access-table/accessTable.html', + controller: function() { + this.state = { + orderBy: 'Name', + reverseOrder: false, + paginatedItemLimit: '10', + textFilter: '' + }; + + this.changeOrderBy = function(orderField) { + this.state.reverseOrder = this.state.orderBy === orderField ? !this.state.reverseOrder : false; + this.state.orderBy = orderField; + }; + }, + bindings: { + dataset: '<', + entryClick: '<', + emptyDatasetMessage: '@' + } +}); diff --git a/app/portainer/components/access-table/accessTable.html b/app/portainer/components/access-table/accessTable.html new file mode 100644 index 000000000..bf2f366ce --- /dev/null +++ b/app/portainer/components/access-table/accessTable.html @@ -0,0 +1,64 @@ +
+ +
+ + +
+ + + + + + + + + + + + + + + + + + +
+ + Name + + + + + + Type + + + +
+ {{ item.Name }} + + inherited + + + {{ item.Type }} +
Loading...
{{ $ctrl.emptyDatasetMessage }}
+ +
diff --git a/app/portainer/components/accessControlPanel/porAccessControlPanel.html b/app/portainer/components/accessControlPanel/porAccessControlPanel.html index a1c9a7c74..47585f55b 100644 --- a/app/portainer/components/accessControlPanel/porAccessControlPanel.html +++ b/app/portainer/components/accessControlPanel/porAccessControlPanel.html @@ -73,7 +73,7 @@ - +
diff --git a/app/portainer/components/accessManagement/por-access-management.js b/app/portainer/components/accessManagement/por-access-management.js index a59839e99..3a566a694 100644 --- a/app/portainer/components/accessManagement/por-access-management.js +++ b/app/portainer/components/accessManagement/por-access-management.js @@ -3,6 +3,8 @@ angular.module('portainer.app').component('porAccessManagement', { controller: 'porAccessManagementController', bindings: { accessControlledEntity: '<', + inheritFrom: '<', + entityType: '@', updateAccess: '&' } }); diff --git a/app/portainer/components/accessManagement/porAccessManagement.html b/app/portainer/components/accessManagement/porAccessManagement.html index eca9696a5..903f73128 100644 --- a/app/portainer/components/accessManagement/porAccessManagement.html +++ b/app/portainer/components/accessManagement/porAccessManagement.html @@ -1,134 +1,46 @@ -
-
- - -
- Items per page: - -
-
- -
- -
-
- -
-
- -
- - - - - - - - - - - - - - - - - - - -
- - Name - - - - - - Type - - - -
{{ user.Name }} - - {{ user.Type }} -
Loading...
No user or team available.
-
- + + + +
+
+

You can select which user or team can access this {{ $ctrl.entityType }} by moving them to the authorized accesses table. Simply click + on a user or team entry to move it from one table to the other.

+

+ Note: accesses tagged as inherited are inherited from the group accesses and cannot be remove at the endpoint level. +

+
+
+ +
+
Users and teams
+
+ +
+
+
- - -
-
- - -
- Items per page: - -
-
- -
- -
-
- -
-
- -
- - - - - - - - - - - - - - - - - - - -
- - Name - - - - - - Type - - - -
{{ user.Name }} - - {{ user.Type }} -
Loading...
No authorized user or team.
-
- + + +
+
Authorized users and teams
+
+ +
+
+
- - -
-
+ +
+
+
+
diff --git a/app/portainer/components/accessManagement/porAccessManagementController.js b/app/portainer/components/accessManagement/porAccessManagementController.js index 201aa13e5..dcf870498 100644 --- a/app/portainer/components/accessManagement/porAccessManagementController.js +++ b/app/portainer/components/accessManagement/porAccessManagementController.js @@ -1,40 +1,13 @@ angular.module('portainer.app') -.controller('porAccessManagementController', ['AccessService', 'PaginationService', 'Notifications', -function (AccessService, PaginationService, Notifications) { +.controller('porAccessManagementController', ['AccessService', 'Notifications', +function (AccessService, Notifications) { var ctrl = this; - ctrl.state = { - pagination_count_accesses: PaginationService.getPaginationLimit('access_management_accesses'), - pagination_count_authorizedAccesses: PaginationService.getPaginationLimit('access_management_AuthorizedAccesses'), - sortAccessesBy: 'Type', - sortAccessesReverse: false, - sortAuthorizedAccessesBy: 'Type', - sortAuthorizedAccessesReverse: false - }; - - ctrl.orderAccesses = function(sortBy) { - ctrl.state.sortAccessesReverse = (ctrl.state.sortAccessesBy === sortBy) ? !ctrl.state.sortAccessesReverse : false; - ctrl.state.sortAccessesBy = sortBy; - }; - - ctrl.orderAuthorizedAccesses = function(sortBy) { - ctrl.state.sortAuthorizedAccessesReverse = (ctrl.state.sortAuthorizedAccessesBy === sortBy) ? !ctrl.state.sortAuthorizedAccessesReverse : false; - ctrl.state.sortAuthorizedAccessesBy = sortBy; - }; - - ctrl.changePaginationCountAuthorizedAccesses = function() { - PaginationService.setPaginationLimit('access_management_AuthorizedAccesses', ctrl.state.pagination_count_authorizedAccesses); - }; - - ctrl.changePaginationCountAccesses = function() { - PaginationService.setPaginationLimit('access_management_accesses', ctrl.state.pagination_count_accesses); - }; - function dispatchUserAndTeamIDs(accesses, users, teams) { angular.forEach(accesses, function (access) { - if (access.Type === 'user') { + if (access.Type === 'user' && !access.Inherited) { users.push(access.Id); - } else if (access.Type === 'team') { + } else if (access.Type === 'team' && !access.Inherited) { teams.push(access.Id); } }); @@ -111,11 +84,20 @@ function (AccessService, PaginationService, Notifications) { }); }; + function moveAccesses(source, target) { + for (var i = 0; i < source.length; i++) { + var access = source[i]; + if (!access.Inherited) { + target.push(access); + source.splice(i, 1); + } + } + } + ctrl.unauthorizeAllAccesses = function() { ctrl.updateAccess({ userAccesses: [], teamAccesses: [] }) .then(function success(data) { - ctrl.accesses = ctrl.accesses.concat(ctrl.authorizedAccesses); - ctrl.authorizedAccesses = []; + moveAccesses(ctrl.authorizedAccesses, ctrl.accesses); Notifications.success('Accesses successfully updated'); }) .catch(function error(err) { @@ -130,8 +112,7 @@ function (AccessService, PaginationService, Notifications) { ctrl.updateAccess({ userAccesses: authorizedUserIDs, teamAccesses: authorizedTeamIDs }) .then(function success(data) { - ctrl.authorizedAccesses = ctrl.authorizedAccesses.concat(ctrl.accesses); - ctrl.accesses = []; + moveAccesses(ctrl.accesses, ctrl.authorizedAccesses); Notifications.success('Accesses successfully updated'); }) .catch(function error(err) { @@ -141,7 +122,8 @@ function (AccessService, PaginationService, Notifications) { function initComponent() { var entity = ctrl.accessControlledEntity; - AccessService.accesses(entity.AuthorizedUsers, entity.AuthorizedTeams) + var parent = ctrl.inheritFrom; + AccessService.accesses(entity.AuthorizedUsers, entity.AuthorizedTeams, parent ? parent.AuthorizedUsers: [], parent ? parent.AuthorizedTeams : []) .then(function success(data) { ctrl.accesses = data.accesses; ctrl.authorizedAccesses = data.authorizedAccesses; diff --git a/app/portainer/components/datatables/endpoints-datatable/endpointsDatatable.html b/app/portainer/components/datatables/endpoints-datatable/endpointsDatatable.html index 95dc16296..c22dfdc5e 100644 --- a/app/portainer/components/datatables/endpoints-datatable/endpointsDatatable.html +++ b/app/portainer/components/datatables/endpoints-datatable/endpointsDatatable.html @@ -16,6 +16,9 @@ ng-disabled="$ctrl.state.selectedItemCount === 0" ng-click="$ctrl.removeAction($ctrl.state.selectedItems)"> Remove +
@@ -100,7 +99,6 @@ {{ $ctrl.formData.TLSCert.name }} -
@@ -114,7 +112,6 @@ {{ $ctrl.formData.TLSKey.name }} -
diff --git a/app/portainer/components/forms/group-form/group-form.js b/app/portainer/components/forms/group-form/group-form.js new file mode 100644 index 000000000..e72054a7b --- /dev/null +++ b/app/portainer/components/forms/group-form/group-form.js @@ -0,0 +1,31 @@ +angular.module('portainer.app').component('groupForm', { + templateUrl: 'app/portainer/components/forms/group-form/groupForm.html', + controller: function() { + var ctrl = this; + + this.associateEndpoint = function(endpoint) { + ctrl.associatedEndpoints.push(endpoint); + _.remove(ctrl.availableEndpoints, function(n) { + return n.Id === endpoint.Id; + }); + }; + + this.dissociateEndpoint = function(endpoint) { + ctrl.availableEndpoints.push(endpoint); + _.remove(ctrl.associatedEndpoints, function(n) { + return n.Id === endpoint.Id; + }); + }; + + }, + bindings: { + model: '=', + availableEndpoints: '=', + associatedEndpoints: '=', + addLabelAction: '<', + removeLabelAction: '<', + formAction: '<', + formActionLabel: '@', + actionInProgress: '<' + } +}); diff --git a/app/portainer/components/forms/group-form/groupForm.html b/app/portainer/components/forms/group-form/groupForm.html new file mode 100644 index 000000000..0555c40e7 --- /dev/null +++ b/app/portainer/components/forms/group-form/groupForm.html @@ -0,0 +1,120 @@ +
+ +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ + +
+ +
+ +
+
+ + +
+
+ + + add label + +
+ +
+
+
+ name + +
+
+ value + +
+ +
+
+ +
+ + +
+
+ Associated endpoints +
+
+
+ You can select which endpoint should be part of this group by moving them to the associated endpoints table. Simply click + on any endpoint entry to move it from one table to the other. +
+
+ +
+
Available endpoints
+
+ +
+
+ + +
+
Associated endpoints
+
+ +
+
+ +
+
+
+
+
+ Unassociated endpoints +
+
+
+ +
+
+
+ All the endpoints are assigned to a group. +
+
+ + +
+ Actions +
+
+
+ +
+
+ +
diff --git a/app/portainer/components/group-association-table/group-association-table.js b/app/portainer/components/group-association-table/group-association-table.js new file mode 100644 index 000000000..3dd1f0771 --- /dev/null +++ b/app/portainer/components/group-association-table/group-association-table.js @@ -0,0 +1,21 @@ +angular.module('portainer.app').component('groupAssociationTable', { + templateUrl: 'app/portainer/components/group-association-table/groupAssociationTable.html', + controller: function() { + this.state = { + orderBy: 'Name', + reverseOrder: false, + paginatedItemLimit: '10', + textFilter: '' + }; + + this.changeOrderBy = function(orderField) { + this.state.reverseOrder = this.state.orderBy === orderField ? !this.state.reverseOrder : false; + this.state.orderBy = orderField; + }; + }, + bindings: { + dataset: '<', + entryClick: '<', + emptyDatasetMessage: '@' + } +}); diff --git a/app/portainer/components/group-association-table/groupAssociationTable.html b/app/portainer/components/group-association-table/groupAssociationTable.html new file mode 100644 index 000000000..18e024b77 --- /dev/null +++ b/app/portainer/components/group-association-table/groupAssociationTable.html @@ -0,0 +1,49 @@ +
+ +
+ + +
+ + + + + + + + + + + + + + + + +
+ + Name + + + +
{{ item.Name }}
Loading...
{{ $ctrl.emptyDatasetMessage }}
+ +
diff --git a/app/portainer/helpers/endpointHelper.js b/app/portainer/helpers/endpointHelper.js new file mode 100644 index 000000000..2eac33c8a --- /dev/null +++ b/app/portainer/helpers/endpointHelper.js @@ -0,0 +1,23 @@ +angular.module('portainer.app') +.factory('EndpointHelper', [function EndpointHelperFactory() { + 'use strict'; + var helper = {}; + + function findAssociatedGroup(endpoint, groups) { + return _.find(groups, function(group) { + return group.Id === endpoint.GroupId; + }); + } + + helper.mapGroupNameToEndpoint = function(endpoints, groups) { + for (var i = 0; i < endpoints.length; i++) { + var endpoint = endpoints[i]; + var group = findAssociatedGroup(endpoint, groups); + if (group) { + endpoint.GroupName = group.Name; + } + } + }; + + return helper; +}]); diff --git a/app/portainer/helpers/templateHelper.js b/app/portainer/helpers/templateHelper.js index 2922b91fe..a82c9d10a 100644 --- a/app/portainer/helpers/templateHelper.js +++ b/app/portainer/helpers/templateHelper.js @@ -68,8 +68,6 @@ angular.module('portainer.app') 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); diff --git a/app/portainer/models/access.js b/app/portainer/models/access.js index b66a93240..06a50edd1 100644 --- a/app/portainer/models/access.js +++ b/app/portainer/models/access.js @@ -2,10 +2,12 @@ function UserAccessViewModel(data) { this.Id = data.Id; this.Name = data.Username; this.Type = 'user'; + this.Inherited = false; } function TeamAccessViewModel(data) { this.Id = data.Id; this.Name = data.Name; this.Type = 'team'; + this.Inherited = false; } diff --git a/app/portainer/models/group.js b/app/portainer/models/group.js new file mode 100644 index 000000000..bb8cf0b78 --- /dev/null +++ b/app/portainer/models/group.js @@ -0,0 +1,29 @@ +function EndpointGroupDefaultModel() { + this.Name = ''; + this.Description = ''; + this.Labels = []; +} + +function EndpointGroupModel(data) { + this.Id = data.Id; + this.Name = data.Name; + this.Description = data.Description; + this.Labels = data.Labels; + this.AuthorizedUsers = data.AuthorizedUsers; + this.AuthorizedTeams = data.AuthorizedTeams; +} + +function EndpointGroupCreateRequest(model, endpoints) { + this.Name = model.Name; + this.Description = model.Description; + this.Labels = model.Labels; + this.AssociatedEndpoints = endpoints; +} + +function EndpointGroupUpdateRequest(model, endpoints) { + this.id = model.Id; + this.Name = model.Name; + this.Description = model.Description; + this.Labels = model.Labels; + this.AssociatedEndpoints = endpoints; +} diff --git a/app/portainer/rest/endpoint.js b/app/portainer/rest/endpoint.js index 34c3ac855..5969212a0 100644 --- a/app/portainer/rest/endpoint.js +++ b/app/portainer/rest/endpoint.js @@ -2,7 +2,6 @@ angular.module('portainer.app') .factory('Endpoints', ['$resource', 'API_ENDPOINT_ENDPOINTS', function EndpointsFactory($resource, API_ENDPOINT_ENDPOINTS) { 'use strict'; return $resource(API_ENDPOINT_ENDPOINTS + '/:id/:action', {}, { - create: { method: 'POST', ignoreLoadingBar: true }, query: { method: 'GET', isArray: true }, get: { method: 'GET', params: { id: '@id' } }, update: { method: 'PUT', params: { id: '@id' } }, diff --git a/app/portainer/rest/group.js b/app/portainer/rest/group.js new file mode 100644 index 000000000..be4507756 --- /dev/null +++ b/app/portainer/rest/group.js @@ -0,0 +1,12 @@ +angular.module('portainer.app') +.factory('EndpointGroups', ['$resource', 'API_ENDPOINT_ENDPOINT_GROUPS', function EndpointGroupsFactory($resource, API_ENDPOINT_ENDPOINT_GROUPS) { + 'use strict'; + return $resource(API_ENDPOINT_ENDPOINT_GROUPS + '/:id/:action', {}, { + create: { method: 'POST', ignoreLoadingBar: true }, + 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'} } + }); +}]); diff --git a/app/portainer/services/api/accessService.js b/app/portainer/services/api/accessService.js index 65382a4ba..2db034baa 100644 --- a/app/portainer/services/api/accessService.js +++ b/app/portainer/services/api/accessService.js @@ -3,33 +3,30 @@ angular.module('portainer.app') 'use strict'; var service = {}; - function mapAccessDataFromAuthorizedIDs(userAccesses, teamAccesses, authorizedUserIDs, authorizedTeamIDs) { - var accesses = []; + function mapAccessData(accesses, authorizedIDs, inheritedIDs) { + var availableAccesses = []; var authorizedAccesses = []; - angular.forEach(userAccesses, function(access) { - if (_.includes(authorizedUserIDs, access.Id)) { - authorizedAccesses.push(access); - } else { - accesses.push(access); - } - }); + for (var i = 0; i < accesses.length; i++) { - angular.forEach(teamAccesses, function(access) { - if (_.includes(authorizedTeamIDs, access.Id)) { + var access = accesses[i]; + if (_.includes(inheritedIDs, access.Id)) { + access.Inherited = true; + authorizedAccesses.push(access); + } else if (_.includes(authorizedIDs, access.Id)) { authorizedAccesses.push(access); } else { - accesses.push(access); + availableAccesses.push(access); } - }); + } return { - accesses: accesses, + accesses: availableAccesses, authorizedAccesses: authorizedAccesses }; } - service.accesses = function(authorizedUserIDs, authorizedTeamIDs) { + service.accesses = function(authorizedUserIDs, authorizedTeamIDs, inheritedUserIDs, inheritedTeamIDs) { var deferred = $q.defer(); $q.all({ @@ -44,7 +41,14 @@ angular.module('portainer.app') return new TeamAccessViewModel(team); }); - var accessData = mapAccessDataFromAuthorizedIDs(userAccesses, teamAccesses, authorizedUserIDs, authorizedTeamIDs); + var userAccessData = mapAccessData(userAccesses, authorizedUserIDs, inheritedUserIDs); + var teamAccessData = mapAccessData(teamAccesses, authorizedTeamIDs, inheritedTeamIDs); + + var accessData = { + accesses: userAccessData.accesses.concat(teamAccessData.accesses), + authorizedAccesses: userAccessData.authorizedAccesses.concat(teamAccessData.authorizedAccesses) + }; + deferred.resolve(accessData); }) .catch(function error(err) { diff --git a/app/portainer/services/api/endpointService.js b/app/portainer/services/api/endpointService.js index 4905eda77..85e15aa4f 100644 --- a/app/portainer/services/api/endpointService.js +++ b/app/portainer/services/api/endpointService.js @@ -12,6 +12,23 @@ function EndpointServiceFactory($q, Endpoints, FileUploadService) { return Endpoints.query({}).$promise; }; + service.endpointsByGroup = function(groupId) { + var deferred = $q.defer(); + + Endpoints.query({}).$promise + .then(function success(data) { + var endpoints = data.filter(function (endpoint) { + return endpoint.GroupId === groupId; + }); + deferred.resolve(endpoints); + }) + .catch(function error(err) { + deferred.reject({msg: 'Unable to retrieve endpoints', err: err}); + }); + + return deferred.promise; + }; + service.updateAccess = function(id, authorizedUserIDs, authorizedTeamIDs) { return Endpoints.updateAccess({id: id}, {authorizedUsers: authorizedUserIDs, authorizedTeams: authorizedTeamIDs}).$promise; }; @@ -20,6 +37,7 @@ function EndpointServiceFactory($q, Endpoints, FileUploadService) { var query = { name: endpointParams.name, PublicURL: endpointParams.PublicURL, + GroupId: endpointParams.GroupId, TLS: endpointParams.TLS, TLSSkipVerify: endpointParams.TLSSkipVerify, TLSSkipClientVerify: endpointParams.TLSSkipClientVerify, @@ -49,43 +67,29 @@ function EndpointServiceFactory($q, Endpoints, FileUploadService) { 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, PublicURL, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { - var endpoint = { - Name: name, - URL: 'tcp://' + URL, - PublicURL: PublicURL, - TLS: TLS, - TLSSkipVerify: TLSSkipVerify, - TLSSkipClientVerify: TLSSkipClientVerify - }; - + service.createLocalEndpoint = function() { var deferred = $q.defer(); - Endpoints.create({}, endpoint).$promise - .then(function success(data) { - var endpointID = data.Id; - if (!TLSSkipVerify || !TLSSkipClientVerify) { - deferred.notify({upload: true}); - FileUploadService.uploadTLSFilesForEndpoint(endpointID, TLSCAFile, TLSCertFile, TLSKeyFile) - .then(function success() { - deferred.notify({upload: false}); - deferred.resolve(data); - }); - } else { - deferred.resolve(data); - } + + FileUploadService.createEndpoint('local', 'unix:///var/run/docker.sock', '', 1, false) + .then(function success(response) { + deferred.resolve(response.data); }) .catch(function error(err) { - deferred.notify({upload: false}); - deferred.reject({msg: 'Unable to upload TLS certs', err: err}); + deferred.reject({msg: 'Unable to create endpoint', err: err}); + }); + + return deferred.promise; + }; + + service.createRemoteEndpoint = function(name, URL, PublicURL, groupID, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { + var deferred = $q.defer(); + + FileUploadService.createEndpoint(name, 'tcp://' + URL, PublicURL, groupID, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) + .then(function success(response) { + deferred.resolve(response.data); + }) + .catch(function error(err) { + deferred.reject({msg: 'Unable to create endpoint', err: err}); }); return deferred.promise; diff --git a/app/portainer/services/api/groupService.js b/app/portainer/services/api/groupService.js new file mode 100644 index 000000000..ac60f787d --- /dev/null +++ b/app/portainer/services/api/groupService.js @@ -0,0 +1,45 @@ +angular.module('portainer.app') +.factory('GroupService', ['$q', 'EndpointGroups', +function GroupService($q, EndpointGroups) { + 'use strict'; + var service = {}; + + service.group = function(groupId) { + var deferred = $q.defer(); + + EndpointGroups.get({ id: groupId }).$promise + .then(function success(data) { + var group = new EndpointGroupModel(data); + deferred.resolve(group); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve group', err: err }); + }); + + return deferred.promise; + }; + + service.groups = function() { + return EndpointGroups.query({}).$promise; + }; + + service.createGroup = function(model, endpoints) { + var payload = new EndpointGroupCreateRequest(model, endpoints); + return EndpointGroups.create(payload).$promise; + }; + + service.updateGroup = function(model, endpoints) { + var payload = new EndpointGroupUpdateRequest(model, endpoints); + return EndpointGroups.update(payload).$promise; + }; + + service.updateAccess = function(groupId, authorizedUserIDs, authorizedTeamIDs) { + return EndpointGroups.updateAccess({ id: groupId }, { authorizedUsers: authorizedUserIDs, authorizedTeams: authorizedTeamIDs }).$promise; + }; + + service.deleteGroup = function(groupId) { + return EndpointGroups.remove({ id: groupId }).$promise; + }; + + return service; +}]); diff --git a/app/portainer/services/fileUpload.js b/app/portainer/services/fileUpload.js index bcfb73328..d2c2120d6 100644 --- a/app/portainer/services/fileUpload.js +++ b/app/portainer/services/fileUpload.js @@ -42,6 +42,25 @@ angular.module('portainer.app') }); }; + service.createEndpoint = function(name, URL, PublicURL, groupID, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { + return Upload.upload({ + url: 'api/endpoints', + data: { + Name: name, + URL: URL, + PublicURL: PublicURL, + GroupID: groupID, + TLS: TLS, + TLSSkipVerify: TLSSkipVerify, + TLSSkipClientVerify: TLSSkipClientVerify, + TLSCACertFile: TLSCAFile, + TLSCertFile: TLSCertFile, + TLSKeyFile: TLSKeyFile + }, + ignoreLoadingBar: true + }); + }; + service.uploadLDAPTLSFiles = function(TLSCAFile, TLSCertFile, TLSKeyFile) { var queue = []; diff --git a/app/portainer/services/httpRequestHelper.js b/app/portainer/services/httpRequestHelper.js index 857d47d7f..1d5b5dbe8 100644 --- a/app/portainer/services/httpRequestHelper.js +++ b/app/portainer/services/httpRequestHelper.js @@ -4,6 +4,7 @@ angular.module('portainer.app') var service = {}; var headers = {}; + headers.agentTargetQueue = []; service.registryAuthenticationHeader = function() { return headers.registryAuthentication; @@ -13,5 +14,32 @@ angular.module('portainer.app') headers.registryAuthentication = headerValue; }; + // Due to the fact that async HTTP requests are decorated using an interceptor + // we need to store and retrieve the headers using a first-in-first-out (FIFO) data structure. + // Otherwise, sequential HTTP requests might end up using the same header value (incorrect in the case + // of starting multiple containers on different nodes for example). + // To prevent having to use the HttpRequestHelper.setPortainerAgentTargetHeader before EACH request, + // we re-use the latest available header in the data structure (handy in thee case of multiple requests affecting + // the same node in the same view). + service.portainerAgentTargetHeader = function() { + if (headers.agentTargetQueue.length === 0) { + return headers.agentTargetLastValue; + } else if (headers.agentTargetQueue.length === 1) { + headers.agentTargetLastValue = headers.agentTargetQueue[0]; + } + return headers.agentTargetQueue.shift(); + }; + + service.setPortainerAgentTargetHeader = function(headerValue) { + if (headerValue) { + headers.agentTargetQueue.push(headerValue); + } + }; + + service.resetAgentTargetQueue = function() { + headers.agentTargetQueue = []; + delete headers.agentTargetLastValue; + }; + return service; }]); diff --git a/app/portainer/services/localStorage.js b/app/portainer/services/localStorage.js index 27c5256ea..1487a20ea 100644 --- a/app/portainer/services/localStorage.js +++ b/app/portainer/services/localStorage.js @@ -59,6 +59,12 @@ angular.module('portainer.app') storeDataTableSettings: function(key, data) { localStorageService.set('datatable_settings_' + key, data); }, + storeSwarmVisualizerSettings: function(key, data) { + localStorageService.set('swarmvisualizer_' + key, data); + }, + getSwarmVisualizerSettings: function(key) { + return localStorageService.get('swarmvisualizer_' + key); + }, clean: function() { localStorageService.clearAll(); } diff --git a/app/portainer/services/notifications.js b/app/portainer/services/notifications.js index 6ebff6ba1..85de16def 100644 --- a/app/portainer/services/notifications.js +++ b/app/portainer/services/notifications.js @@ -19,8 +19,8 @@ angular.module('portainer.app') msg = e.message; } else if (e.err && e.err.data && e.err.data.message) { msg = e.err.data.message; - } else if (e.data && e.data.length > 0 && e.data[0].message) { - msg = e.data[0].message; + } else if (e.err && e.err.data && e.err.data.length > 0 && e.err.data[0].message) { + msg = e.err.data[0].message; } else if (e.err && e.err.data && e.err.data.err) { msg = e.err.data.err; } else if (e.data && e.data.err) { diff --git a/app/portainer/services/stateManager.js b/app/portainer/services/stateManager.js index 3b9d843dd..c7322a4c3 100644 --- a/app/portainer/services/stateManager.js +++ b/app/portainer/services/stateManager.js @@ -122,18 +122,18 @@ function StateManagerFactory($q, SystemService, InfoHelper, LocalStorage, Settin return extensions; } - manager.updateEndpointState = function(loading, extensions) { + manager.updateEndpointState = function(loading, type, extensions) { var deferred = $q.defer(); if (loading) { state.loading = true; } $q.all({ - info: SystemService.info(), - version: SystemService.version() + version: SystemService.version(), + info: SystemService.info() }) .then(function success(data) { - var endpointMode = InfoHelper.determineEndpointMode(data.info); + var endpointMode = InfoHelper.determineEndpointMode(data.info, type); var endpointAPIVersion = parseFloat(data.version.ApiVersion); state.endpoint.mode = endpointMode; state.endpoint.apiVersion = endpointAPIVersion; diff --git a/app/portainer/services/templateService.js b/app/portainer/services/templateService.js index d6518a6fa..57ec07c2f 100644 --- a/app/portainer/services/templateService.js +++ b/app/portainer/services/templateService.js @@ -44,7 +44,7 @@ angular.module('portainer.app') configuration.HostConfig.RestartPolicy = { Name: template.RestartPolicy }; configuration.HostConfig.ExtraHosts = template.Hosts ? template.Hosts : []; configuration.name = containerName; - configuration.Hostname = containerName; + configuration.Hostname = template.Hostname; configuration.Image = template.Image; configuration.Env = TemplateHelper.EnvToStringArray(template.Env, containerMapping); configuration.Cmd = ContainerHelper.commandStringToArray(template.Command); diff --git a/app/portainer/views/about/about.html b/app/portainer/views/about/about.html index b1af972a3..6486ad264 100644 --- a/app/portainer/views/about/about.html +++ b/app/portainer/views/about/about.html @@ -92,7 +92,7 @@
- Portainer has full support for Docker >=1.10 and Docker Swarm >= 1.2.3, and partial support for Docker 1.9 (some features may not be available). + Portainer has full support for Docker >=1.10 and partial support for Docker 1.9 (some features may not be available).
diff --git a/app/portainer/views/auth/authController.js b/app/portainer/views/auth/authController.js index 300372b75..7b53dfe91 100644 --- a/app/portainer/views/auth/authController.js +++ b/app/portainer/views/auth/authController.js @@ -22,7 +22,7 @@ function ($scope, $state, $transition$, $window, $timeout, $sanitize, Authentica ExtensionManager.initEndpointExtensions(endpoint.Id) .then(function success(data) { var extensions = data; - return StateManager.updateEndpointState(true, extensions); + return StateManager.updateEndpointState(true, endpoint.Type, extensions); }) .then(function success(data) { $state.go('docker.dashboard'); diff --git a/app/portainer/views/endpoints/access/endpointAccess.html b/app/portainer/views/endpoints/access/endpointAccess.html index 32d111856..eae43d390 100644 --- a/app/portainer/views/endpoints/access/endpointAccess.html +++ b/app/portainer/views/endpoints/access/endpointAccess.html @@ -6,7 +6,7 @@
- - - +
+
+ +
+
diff --git a/app/portainer/views/endpoints/access/endpointAccessController.js b/app/portainer/views/endpoints/access/endpointAccessController.js index b2de91a22..c30dba972 100644 --- a/app/portainer/views/endpoints/access/endpointAccessController.js +++ b/app/portainer/views/endpoints/access/endpointAccessController.js @@ -1,6 +1,6 @@ angular.module('portainer.app') -.controller('EndpointAccessController', ['$scope', '$transition$', 'EndpointService', 'Notifications', -function ($scope, $transition$, EndpointService, Notifications) { +.controller('EndpointAccessController', ['$scope', '$transition$', 'EndpointService', 'GroupService', 'Notifications', +function ($scope, $transition$, EndpointService, GroupService, Notifications) { $scope.updateAccess = function(authorizedUsers, authorizedTeams) { return EndpointService.updateAccess($transition$.params().id, authorizedUsers, authorizedTeams); @@ -9,7 +9,12 @@ function ($scope, $transition$, EndpointService, Notifications) { function initView() { EndpointService.endpoint($transition$.params().id) .then(function success(data) { - $scope.endpoint = data; + var endpoint = data; + $scope.endpoint = endpoint; + return GroupService.group(endpoint.GroupId); + }) + .then(function success(data) { + $scope.group = data; }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to retrieve endpoint details'); diff --git a/app/portainer/views/endpoints/create/createEndpointController.js b/app/portainer/views/endpoints/create/createEndpointController.js new file mode 100644 index 000000000..bda06746d --- /dev/null +++ b/app/portainer/views/endpoints/create/createEndpointController.js @@ -0,0 +1,71 @@ +angular.module('portainer.app') +.controller('CreateEndpointController', ['$scope', '$state', '$filter', 'EndpointService', 'GroupService', 'Notifications', +function ($scope, $state, $filter, EndpointService, GroupService, Notifications) { + + $scope.state = { + EnvironmentType: 'docker', + actionInProgress: false + }; + + $scope.formValues = { + Name: '', + URL: '', + PublicURL: '', + GroupId: 1, + SecurityFormData: new EndpointSecurityFormData() + }; + + $scope.addDockerEndpoint = function() { + var name = $scope.formValues.Name; + var URL = $filter('stripprotocol')($scope.formValues.URL); + var publicURL = $scope.formValues.PublicURL === '' ? URL.split(':')[0] : $scope.formValues.PublicURL; + var groupId = $scope.formValues.GroupId; + + var securityData = $scope.formValues.SecurityFormData; + var TLS = securityData.TLS; + var TLSMode = securityData.TLSMode; + var TLSSkipVerify = TLS && (TLSMode === 'tls_client_noca' || TLSMode === 'tls_only'); + var TLSSkipClientVerify = TLS && (TLSMode === 'tls_ca' || TLSMode === 'tls_only'); + var TLSCAFile = TLSSkipVerify ? null : securityData.TLSCACert; + var TLSCertFile = TLSSkipClientVerify ? null : securityData.TLSCert; + var TLSKeyFile = TLSSkipClientVerify ? null : securityData.TLSKey; + + addEndpoint(name, URL, publicURL, groupId, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile); + }; + + $scope.addAgentEndpoint = function() { + var name = $scope.formValues.Name; + var URL = $filter('stripprotocol')($scope.formValues.URL); + var publicURL = $scope.formValues.PublicURL === '' ? URL.split(':')[0] : $scope.formValues.PublicURL; + var groupId = $scope.formValues.GroupId; + + addEndpoint(name, URL, publicURL, groupId, true, true, true, null, null, null); + }; + + function addEndpoint(name, URL, PublicURL, groupId, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { + $scope.state.actionInProgress = true; + EndpointService.createRemoteEndpoint(name, URL, PublicURL, groupId, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) + .then(function success() { + Notifications.success('Endpoint created', name); + $state.go('portainer.endpoints', {}, {reload: true}); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to create endpoint'); + }) + .finally(function final() { + $scope.state.actionInProgress = false; + }); + } + + function initView() { + GroupService.groups() + .then(function success(data) { + $scope.groups = data; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to load groups'); + }); + } + + initView(); +}]); diff --git a/app/portainer/views/endpoints/create/createendpoint.html b/app/portainer/views/endpoints/create/createendpoint.html new file mode 100644 index 000000000..1ab647c4b --- /dev/null +++ b/app/portainer/views/endpoints/create/createendpoint.html @@ -0,0 +1,141 @@ + + + + Endpoints > Add endpoint + + + +
+
+ + +
+
+ Environment type +
+
+
+
+
+ + +
+
+ + +
+
+
+
+
+ Important notice +
+
+ + The Docker API must be exposed over TCP. You can find more information about how to expose the Docker API over TCP in the Docker documentation. + +
+
+
+
+ Information +
+
+ + If you have started Portainer in the same overlay network as the agent, you can use tasks.AGENT_SERVICE_NAME:AGENT_SERVICE_PORT as the + endpoint URL format. + +
+
+
+ Environment details +
+ +
+ +
+ +
+
+
+
+
+

This field is required.

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

This field is required.

+
+
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + + + + +
+
+ + +
+
+ +
+
+
+
+
diff --git a/app/portainer/views/endpoints/edit/endpoint.html b/app/portainer/views/endpoints/edit/endpoint.html index 0a4eba682..54e75b445 100644 --- a/app/portainer/views/endpoints/edit/endpoint.html +++ b/app/portainer/views/endpoints/edit/endpoint.html @@ -43,6 +43,19 @@
+
+ Grouping +
+ +
+ +
+ +
+
+
diff --git a/app/portainer/views/endpoints/edit/endpointController.js b/app/portainer/views/endpoints/edit/endpointController.js index cc9a89004..bdcbc63b0 100644 --- a/app/portainer/views/endpoints/edit/endpointController.js +++ b/app/portainer/views/endpoints/edit/endpointController.js @@ -1,6 +1,6 @@ angular.module('portainer.app') -.controller('EndpointController', ['$scope', '$state', '$transition$', '$filter', 'EndpointService', 'Notifications', -function ($scope, $state, $transition$, $filter, EndpointService, Notifications) { +.controller('EndpointController', ['$q', '$scope', '$state', '$transition$', '$filter', 'EndpointService', 'GroupService', 'EndpointProvider', 'Notifications', +function ($q, $scope, $state, $transition$, $filter, EndpointService, GroupService, EndpointProvider, Notifications) { if (!$scope.applicationState.application.endpointManagement) { $state.go('portainer.endpoints'); @@ -27,6 +27,7 @@ function ($scope, $state, $transition$, $filter, EndpointService, Notifications) name: endpoint.Name, URL: endpoint.URL, PublicURL: endpoint.PublicURL, + GroupId: endpoint.GroupId, TLS: TLS, TLSSkipVerify: TLSSkipVerify, TLSSkipClientVerify: TLSSkipClientVerify, @@ -40,7 +41,8 @@ function ($scope, $state, $transition$, $filter, EndpointService, Notifications) EndpointService.updateEndpoint(endpoint.Id, endpointParams) .then(function success(data) { Notifications.success('Endpoint updated', $scope.endpoint.Name); - $state.go('portainer.endpoints'); + EndpointProvider.setEndpointPublicURL(endpoint.PublicURL); + $state.go('portainer.endpoints', {}, {reload: true}); }, function error(err) { Notifications.error('Failure', err, 'Unable to update endpoint'); $scope.state.actionInProgress = false; @@ -52,9 +54,12 @@ function ($scope, $state, $transition$, $filter, EndpointService, Notifications) }; function initView() { - EndpointService.endpoint($transition$.params().id) + $q.all({ + endpoint: EndpointService.endpoint($transition$.params().id), + groups: GroupService.groups() + }) .then(function success(data) { - var endpoint = data; + var endpoint = data.endpoint; if (endpoint.URL.indexOf('unix://') === 0) { $scope.endpointType = 'local'; } else { @@ -62,6 +67,7 @@ function ($scope, $state, $transition$, $filter, EndpointService, Notifications) } endpoint.URL = $filter('stripprotocol')(endpoint.URL); $scope.endpoint = endpoint; + $scope.groups = data.groups; }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to retrieve endpoint details'); diff --git a/app/portainer/views/endpoints/endpoints.html b/app/portainer/views/endpoints/endpoints.html index 7b6eb9205..75459a89a 100644 --- a/app/portainer/views/endpoints/endpoints.html +++ b/app/portainer/views/endpoints/endpoints.html @@ -8,7 +8,7 @@
-
+
@@ -22,62 +22,6 @@
-
-
- - - - -
- -
- -
- -
-
- - -
- -
- -
-
- - -
- -
- -
-
- - - - - -
-
- -
-
- -
-
-
-
-
-
+ + + Groups > {{ group.Name }} > Access management + + + +
+
+ + + + + + + + + + +
Name + {{ group.Name }} +
+
+
+
+
+ +
+
+ +
+
diff --git a/app/portainer/views/groups/access/groupAccessController.js b/app/portainer/views/groups/access/groupAccessController.js new file mode 100644 index 000000000..191d7a98c --- /dev/null +++ b/app/portainer/views/groups/access/groupAccessController.js @@ -0,0 +1,22 @@ +angular.module('portainer.app') +.controller('GroupAccessController', ['$scope', '$transition$', 'GroupService', 'Notifications', +function ($scope, $transition$, GroupService, Notifications) { + + $scope.updateAccess = function(authorizedUsers, authorizedTeams) { + return GroupService.updateAccess($transition$.params().id, authorizedUsers, authorizedTeams); + }; + + function initView() { + var groupId = $transition$.params().id; + + GroupService.group(groupId) + .then(function success(data) { + $scope.group = data; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to load view'); + }); + } + + initView(); +}]); diff --git a/app/portainer/views/groups/create/createGroupController.js b/app/portainer/views/groups/create/createGroupController.js new file mode 100644 index 000000000..1a14510fa --- /dev/null +++ b/app/portainer/views/groups/create/createGroupController.js @@ -0,0 +1,54 @@ +angular.module('portainer.app') +.controller('CreateGroupController', ['$scope', '$state', 'GroupService', 'EndpointService', 'Notifications', +function ($scope, $state, GroupService, EndpointService, Notifications) { + + $scope.state = { + actionInProgress: false + }; + + $scope.addLabel = function() { + $scope.model.Labels.push({ name: '', value: '' }); + }; + + $scope.removeLabel = function(index) { + $scope.model.Labels.splice(index, 1); + }; + + $scope.create = function() { + var model = $scope.model; + + var associatedEndpoints = []; + for (var i = 0; i < $scope.associatedEndpoints.length; i++) { + var endpoint = $scope.associatedEndpoints[i]; + associatedEndpoints.push(endpoint.Id); + } + + $scope.state.actionInProgress = true; + GroupService.createGroup(model, associatedEndpoints) + .then(function success() { + Notifications.success('Group successfully created'); + $state.go('portainer.groups', {}, {reload: true}); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to create group'); + }) + .finally(function final() { + $scope.state.actionInProgress = false; + }); + }; + + function initView() { + $scope.model = new EndpointGroupDefaultModel(); + + EndpointService.endpointsByGroup(1) + .then(function success(data) { + $scope.availableEndpoints = data; + $scope.associatedEndpoints = []; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve endpoints'); + }); + } + + initView(); +}]); diff --git a/app/portainer/views/groups/create/creategroup.html b/app/portainer/views/groups/create/creategroup.html new file mode 100644 index 000000000..e4938e6ff --- /dev/null +++ b/app/portainer/views/groups/create/creategroup.html @@ -0,0 +1,25 @@ + + + + Endpoint groups > Add group + + + +
+
+ + + + + +
+
diff --git a/app/portainer/views/groups/edit/group.html b/app/portainer/views/groups/edit/group.html new file mode 100644 index 000000000..4d5861d80 --- /dev/null +++ b/app/portainer/views/groups/edit/group.html @@ -0,0 +1,25 @@ + + + + Groups > {{ group.Name }} + + + +
+
+ + + + + +
+
diff --git a/app/portainer/views/groups/edit/groupController.js b/app/portainer/views/groups/edit/groupController.js new file mode 100644 index 000000000..7737a3d3c --- /dev/null +++ b/app/portainer/views/groups/edit/groupController.js @@ -0,0 +1,70 @@ +angular.module('portainer.app') +.controller('GroupController', ['$q', '$scope', '$state', '$transition$', 'GroupService', 'EndpointService', 'Notifications', +function ($q, $scope, $state, $transition$, GroupService, EndpointService, Notifications) { + + $scope.state = { + actionInProgress: false + }; + + $scope.addLabel = function() { + $scope.group.Labels.push({ name: '', value: '' }); + }; + + $scope.removeLabel = function(index) { + $scope.group.Labels.splice(index, 1); + }; + + $scope.update = function() { + var model = $scope.group; + + var associatedEndpoints = []; + for (var i = 0; i < $scope.associatedEndpoints.length; i++) { + var endpoint = $scope.associatedEndpoints[i]; + associatedEndpoints.push(endpoint.Id); + } + + $scope.state.actionInProgress = true; + GroupService.updateGroup(model, associatedEndpoints) + .then(function success(data) { + Notifications.success('Group successfully updated'); + $state.go('portainer.groups', {}, {reload: true}); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to update group'); + }) + .finally(function final() { + $scope.state.actionInProgress = false; + }); + }; + + function initView() { + var groupId = $transition$.params().id; + + $q.all({ + group: GroupService.group(groupId), + endpoints: EndpointService.endpoints() + }) + .then(function success(data) { + $scope.group = data.group; + + var availableEndpoints = []; + var associatedEndpoints = []; + for (var i = 0; i < data.endpoints.length; i++) { + var endpoint = data.endpoints[i]; + if (endpoint.GroupId === +groupId) { + associatedEndpoints.push(endpoint); + } else if (endpoint.GroupId === 1) { + availableEndpoints.push(endpoint); + } + } + + $scope.availableEndpoints = availableEndpoints; + $scope.associatedEndpoints = associatedEndpoints; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to load view'); + }); + } + + initView(); +}]); diff --git a/app/portainer/views/groups/groups.html b/app/portainer/views/groups/groups.html new file mode 100644 index 000000000..2e6f91c39 --- /dev/null +++ b/app/portainer/views/groups/groups.html @@ -0,0 +1,20 @@ + + + + + + + Endpoint group management + + +
+
+ +
+
diff --git a/app/portainer/views/groups/groupsController.js b/app/portainer/views/groups/groupsController.js new file mode 100644 index 000000000..9a8d76a6c --- /dev/null +++ b/app/portainer/views/groups/groupsController.js @@ -0,0 +1,38 @@ +angular.module('portainer.app') +.controller('GroupsController', ['$scope', '$state', '$filter', 'GroupService', 'Notifications', +function ($scope, $state, $filter, GroupService, Notifications) { + + $scope.removeAction = function (selectedItems) { + var actionCount = selectedItems.length; + angular.forEach(selectedItems, function (group) { + GroupService.deleteGroup(group.Id) + .then(function success() { + Notifications.success('Endpoint group successfully removed', group.Name); + var index = $scope.groups.indexOf(group); + $scope.groups.splice(index, 1); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove group'); + }) + .finally(function final() { + --actionCount; + if (actionCount === 0) { + $state.reload(); + } + }); + }); + }; + + function initView() { + GroupService.groups() + .then(function success(data) { + $scope.groups = data; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve endpoint groups'); + $scope.groups = []; + }); + } + + initView(); +}]); diff --git a/app/portainer/views/init/admin/initAdminController.js b/app/portainer/views/init/admin/initAdminController.js index 103862b0a..4ea75c050 100644 --- a/app/portainer/views/init/admin/initAdminController.js +++ b/app/portainer/views/init/admin/initAdminController.js @@ -36,7 +36,7 @@ function ($scope, $state, $sanitize, Notifications, Authentication, StateManager ExtensionManager.initEndpointExtensions(endpointID) .then(function success(data) { var extensions = data; - return StateManager.updateEndpointState(false, extensions); + return StateManager.updateEndpointState(false, endpoint.Type, extensions); }) .then(function success() { $state.go('docker.dashboard'); diff --git a/app/portainer/views/init/endpoint/initEndpoint.html b/app/portainer/views/init/endpoint/initEndpoint.html index 5849c9b27..cbaeac7f5 100644 --- a/app/portainer/views/init/endpoint/initEndpoint.html +++ b/app/portainer/views/init/endpoint/initEndpoint.html @@ -1,7 +1,7 @@
-
+
@@ -29,36 +29,49 @@
+
+ + +
+
+ Information +
-

- - This feature is not yet available for native Docker Windows containers. -

- Please ensure that you have started the Portainer container with the following Docker flag -v "/var/run/docker.sock:/var/run/docker.sock" in order to connect to the local Docker environment. + Manage the Docker environment where Portainer is running using the Unix filesystem socket. +

+

+ + Ensure that you have started the Portainer container with the following Docker flag: -v "/var/run/docker.sock:/var/run/docker.sock".

@@ -75,8 +88,80 @@
+ +
+
+ Information +
+
+
+ +

+ Connect directly to a Portainer agent running inside a Swarm cluster. +

+

+ + If you have started Portainer in the same overlay network as the agent, you can use tasks.AGENT_SERVICE_NAME:AGENT_SERVICE_PORT as the + endpoint URL format. +

+
+
+
+
+ Environment +
+ +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+
+ +
+
+ +
+
+
+ Information +
+
+
+ +

+ Connect Portainer to a remote Docker environment using the Docker API over TCP. +

+

+ + The Docker API must be exposed over TCP. You can find more information about how to expose the Docker API over TCP in the Docker documentation. +

+
+
+
+
+ Environment +
@@ -89,7 +174,7 @@
diff --git a/app/portainer/views/init/endpoint/initEndpointController.js b/app/portainer/views/init/endpoint/initEndpointController.js index 8bf9c36b5..67d9da7b1 100644 --- a/app/portainer/views/init/endpoint/initEndpointController.js +++ b/app/portainer/views/init/endpoint/initEndpointController.js @@ -28,31 +28,38 @@ function ($scope, $state, EndpointService, StateManager, EndpointProvider, Notif $scope.createLocalEndpoint = function() { var name = 'local'; var URL = 'unix:///var/run/docker.sock'; - var endpointID = 1; + var endpoint; $scope.state.actionInProgress = true; - EndpointService.createLocalEndpoint(name, URL, false, true) + EndpointService.createLocalEndpoint() .then(function success(data) { - endpointID = data.Id; - EndpointProvider.setEndpointID(endpointID); - return ExtensionManager.initEndpointExtensions(endpointID); + endpoint = data; + EndpointProvider.setEndpointID(endpoint.Id); + return ExtensionManager.initEndpointExtensions(endpoint.Id); }) .then(function success(data) { var extensions = data; - return StateManager.updateEndpointState(false, extensions); + return StateManager.updateEndpointState(false, endpoint.Type, extensions); }) .then(function success(data) { $state.go('docker.dashboard'); }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to connect to the Docker environment'); - EndpointService.deleteEndpoint(endpointID); }) .finally(function final() { $scope.state.actionInProgress = false; }); }; + $scope.createAgentEndpoint = function() { + var name = $scope.formValues.Name; + var URL = $scope.formValues.URL; + var PublicURL = URL.split(':')[0]; + + createRemoteEndpoint(name, URL, PublicURL, true, true, true, null, null, null); + }; + $scope.createRemoteEndpoint = function() { var name = $scope.formValues.Name; var URL = $scope.formValues.URL; @@ -63,28 +70,31 @@ function ($scope, $state, EndpointService, StateManager, EndpointProvider, Notif var TLSCAFile = TLSSkipVerify ? null : $scope.formValues.TLSCACert; var TLSCertFile = TLSSKipClientVerify ? null : $scope.formValues.TLSCert; var TLSKeyFile = TLSSKipClientVerify ? null : $scope.formValues.TLSKey; - var endpointID = 1; + createRemoteEndpoint(name, URL, PublicURL, TLS, TLSSkipVerify, TLSSKipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile); + }; + + function createRemoteEndpoint(name, URL, PublicURL, TLS, TLSSkipVerify, TLSSKipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { + var endpoint; $scope.state.actionInProgress = true; - EndpointService.createRemoteEndpoint(name, URL, PublicURL, TLS, TLSSkipVerify, TLSSKipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) + EndpointService.createRemoteEndpoint(name, URL, PublicURL, 1, TLS, TLSSkipVerify, TLSSKipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) .then(function success(data) { - endpointID = data.Id; - EndpointProvider.setEndpointID(endpointID); - return ExtensionManager.initEndpointExtensions(endpointID); + endpoint = data; + EndpointProvider.setEndpointID(endpoint.Id); + return ExtensionManager.initEndpointExtensions(endpoint.Id); }) .then(function success(data) { var extensions = data; - return StateManager.updateEndpointState(false, extensions); + return StateManager.updateEndpointState(false, endpoint.Type, extensions); }) .then(function success(data) { $state.go('docker.dashboard'); }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to connect to the Docker environment'); - EndpointService.deleteEndpoint(endpointID); }) .finally(function final() { $scope.state.actionInProgress = false; }); - }; + } }]); diff --git a/app/portainer/views/settings/settings.html b/app/portainer/views/settings/settings.html index ed1e5ba74..cc5b0b50a 100644 --- a/app/portainer/views/settings/settings.html +++ b/app/portainer/views/settings/settings.html @@ -135,7 +135,7 @@ -
+
You can hide containers with specific labels from Portainer UI. You need to specify the label name and value. @@ -144,14 +144,14 @@
- +
- +
diff --git a/app/portainer/views/sidebar/sidebar.html b/app/portainer/views/sidebar/sidebar.html index a50f6516e..ca809fb75 100644 --- a/app/portainer/views/sidebar/sidebar.html +++ b/app/portainer/views/sidebar/sidebar.html @@ -9,12 +9,12 @@