From 99d49a1f87935441313ce024a453c3e7e5617ebb Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Fri, 2 Dec 2016 19:19:24 +1300 Subject: [PATCH 01/20] chore(project): update contribution guidelines --- CONTRIBUTING.md | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 015abca17..0113ed680 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,8 +6,27 @@ Some basic conventions for contributing to this project. Please make sure that there aren't existing pull requests attempting to address the issue mentioned. Likewise, please check for issues related to update, as someone else may be working on the issue in a branch or fork. -* Non-trivial changes should be discussed in an issue first -* Develop in a topic branch, not master +* Please open a discussion in a new issue / existing issue to talk about the changes you'd like to bring +* Develop in a topic branch, not master/develop + +When creating a new branch, prefix it with the *type* of the change (see section **Commit Message Format** below), the associated opened issue number, a dash and some text describing the issue (using dash as a separator). + +For example, if you work on a bugfix for the issue #361, you could name the branch `fix361-template-selection`. + +### Issues open to contribution + +Want to contribute but don't know where to start? + +Some of the open issues are labeled with prefix `exp/`, this is used to mark them as available for contributors to work on. All of these have an attributed difficulty level: + +* **beginner**: a task that should be accessible with users not familiar with the codebase +* **intermediate**: a task that require some understanding of the project codebase or some experience in +either AngularJS or Golang + +You can have a use Github filters to list these issues: + +* beginner labeled issues: https://github.com/portainer/portainer/labels/exp%2Fbeginner +* intermediate labeled issues: https://github.com/portainer/portainer/labels/exp%2Fintermediate ### Linting @@ -47,6 +66,7 @@ Must be one of the following: The scope could be anything specifying place of the commit change. For example `networks`, `containers`, `images` etc... +You can use the **area** label tag associated on the issue here (for `area/containers` use `containers` as a scope...) #### Subject From 8869a2c79c6101e2b16aeff6abfe58d97d7abc28 Mon Sep 17 00:00:00 2001 From: Paul Kling Date: Tue, 13 Dec 2016 14:25:23 -0600 Subject: [PATCH 02/20] feat(templates): automatically scroll up to the app template form after selecting a template --- app/components/templates/templates.html | 3 +-- app/components/templates/templatesController.js | 5 +++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/components/templates/templates.html b/app/components/templates/templates.html index 49ef933c1..66ed097a6 100644 --- a/app/components/templates/templates.html +++ b/app/components/templates/templates.html @@ -6,8 +6,7 @@ Templates - -
+
diff --git a/app/components/templates/templatesController.js b/app/components/templates/templatesController.js index 1d442a9a3..e271e6b3f 100644 --- a/app/components/templates/templatesController.js +++ b/app/components/templates/templatesController.js @@ -1,6 +1,6 @@ angular.module('templates', []) -.controller('TemplatesController', ['$scope', '$q', '$state', '$filter', 'Config', 'Info', 'Container', 'ContainerHelper', 'Image', 'Volume', 'Network', 'Templates', 'TemplateHelper', 'Messages', 'Settings', -function ($scope, $q, $state, $filter, Config, Info, Container, ContainerHelper, Image, Volume, Network, Templates, TemplateHelper, Messages, Settings) { +.controller('TemplatesController', ['$scope', '$q', '$state', '$filter', '$anchorScroll', 'Config', 'Info', 'Container', 'ContainerHelper', 'Image', 'Volume', 'Network', 'Templates', 'TemplateHelper', 'Messages', 'Settings', +function ($scope, $q, $state, $filter, $anchorScroll, Config, Info, Container, ContainerHelper, Image, Volume, Network, Templates, TemplateHelper, Messages, Settings) { $scope.state = { selectedTemplate: null, showAdvancedOptions: false @@ -179,6 +179,7 @@ function ($scope, $q, $state, $filter, Config, Info, Container, ContainerHelper, var selectedTemplate = $scope.templates[id]; $scope.state.selectedTemplate = selectedTemplate; $scope.formValues.ports = selectedTemplate.ports ? TemplateHelper.getPortBindings(selectedTemplate.ports) : []; + $anchorScroll('selectedTemplate'); } }; From b5bf7cdeade5bab4da403f3192377531fba7c91d Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Wed, 14 Dec 2016 09:33:24 +1300 Subject: [PATCH 03/20] feat(templates): add support for the template registry field --- .../container/containerController.js | 2 +- .../createContainerController.js | 19 +++---------------- .../createService/createServiceController.js | 4 ++-- app/components/image/imageController.js | 2 +- app/components/images/imagesController.js | 19 +++---------------- .../templates/templatesController.js | 15 ++++++++++----- app/shared/helpers.js | 14 +++++++++++++- 7 files changed, 33 insertions(+), 42 deletions(-) diff --git a/app/components/container/containerController.js b/app/components/container/containerController.js index 27780b4da..7b78aa658 100644 --- a/app/components/container/containerController.js +++ b/app/components/container/containerController.js @@ -77,7 +77,7 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Ima $('#createImageSpinner').show(); var image = _.toLower($scope.config.Image); var registry = _.toLower($scope.config.Registry); - var imageConfig = ImageHelper.createImageConfig(image, registry); + var imageConfig = ImageHelper.createImageConfigForCommit(image, registry); ContainerCommit.commit({id: $stateParams.id, tag: imageConfig.tag, repo: imageConfig.repo}, function (d) { $('#createImageSpinner').hide(); update(); diff --git a/app/components/createContainer/createContainerController.js b/app/components/createContainer/createContainerController.js index 9ea9ac735..317005272 100644 --- a/app/components/createContainer/createContainerController.js +++ b/app/components/createContainer/createContainerController.js @@ -1,6 +1,6 @@ angular.module('createContainer', []) -.controller('CreateContainerController', ['$scope', '$state', '$stateParams', '$filter', 'Config', 'Info', 'Container', 'ContainerHelper', 'Image', 'Volume', 'Network', 'Messages', -function ($scope, $state, $stateParams, $filter, Config, Info, Container, ContainerHelper, Image, Volume, Network, Messages) { +.controller('CreateContainerController', ['$scope', '$state', '$stateParams', '$filter', 'Config', 'Info', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'Network', 'Messages', +function ($scope, $state, $stateParams, $filter, Config, Info, Container, ContainerHelper, Image, ImageHelper, Volume, Network, Messages) { $scope.formValues = { alwaysPull: true, @@ -143,23 +143,10 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai }); } - function createImageConfig(imageName, registry) { - var imageNameAndTag = imageName.split(':'); - var image = imageNameAndTag[0]; - if (registry) { - image = registry + '/' + imageNameAndTag[0]; - } - var imageConfig = { - fromImage: image, - tag: imageNameAndTag[1] ? imageNameAndTag[1] : 'latest' - }; - return imageConfig; - } - function prepareImageConfig(config) { var image = _.toLower(config.Image); var registry = $scope.formValues.Registry; - var imageConfig = createImageConfig(image, registry); + var imageConfig = ImageHelper.createImageConfigForContainer(image, registry); config.Image = imageConfig.fromImage + ':' + imageConfig.tag; $scope.imageConfig = imageConfig; } diff --git a/app/components/createService/createServiceController.js b/app/components/createService/createServiceController.js index 4f7619e6c..4797ae3b3 100644 --- a/app/components/createService/createServiceController.js +++ b/app/components/createService/createServiceController.js @@ -69,8 +69,8 @@ function ($scope, $state, Service, Volume, Network, ImageHelper, Messages) { }; function prepareImageConfig(config, input) { - var imageConfig = ImageHelper.createImageConfig(input.Image, input.Registry); - config.TaskTemplate.ContainerSpec.Image = imageConfig.repo + ':' + imageConfig.tag; + var imageConfig = ImageHelper.createImageConfigForContainer(input.Image, input.Registry); + config.TaskTemplate.ContainerSpec.Image = imageConfig.fromImage + ':' + imageConfig.tag; } function preparePortsConfig(config, input) { diff --git a/app/components/image/imageController.js b/app/components/image/imageController.js index 8ad61a242..ec4ba5c91 100644 --- a/app/components/image/imageController.js +++ b/app/components/image/imageController.js @@ -23,7 +23,7 @@ function ($scope, $stateParams, $state, Image, ImageHelper, Messages) { $('#loadingViewSpinner').show(); var image = _.toLower($scope.config.Image); var registry = _.toLower($scope.config.Registry); - var imageConfig = ImageHelper.createImageConfig(image, registry); + var imageConfig = ImageHelper.createImageConfigForCommit(image, registry); Image.tag({id: $stateParams.id, tag: imageConfig.tag, repo: imageConfig.repo}, function (d) { Messages.send('Image successfully tagged'); $('#loadingViewSpinner').hide(); diff --git a/app/components/images/imagesController.js b/app/components/images/imagesController.js index 685d287cb..704499a58 100644 --- a/app/components/images/imagesController.js +++ b/app/components/images/imagesController.js @@ -1,6 +1,6 @@ angular.module('images', []) -.controller('ImagesController', ['$scope', '$state', 'Config', 'Image', 'Messages', 'Settings', -function ($scope, $state, Config, Image, Messages, Settings) { +.controller('ImagesController', ['$scope', '$state', 'Config', 'Image', 'ImageHelper', 'Messages', 'Settings', +function ($scope, $state, Config, Image, ImageHelper, Messages, Settings) { $scope.state = {}; $scope.sortType = 'RepoTags'; $scope.sortReverse = true; @@ -25,24 +25,11 @@ function ($scope, $state, Config, Image, Messages, Settings) { } }; - function createImageConfig(imageName, registry) { - var imageNameAndTag = imageName.split(':'); - var image = imageNameAndTag[0]; - if (registry) { - image = registry + '/' + imageNameAndTag[0]; - } - var imageConfig = { - fromImage: image, - tag: imageNameAndTag[1] ? imageNameAndTag[1] : 'latest' - }; - return imageConfig; - } - $scope.pullImage = function() { $('#pullImageSpinner').show(); var image = _.toLower($scope.config.Image); var registry = _.toLower($scope.config.Registry); - var imageConfig = createImageConfig(image, registry); + var imageConfig = ImageHelper.createImageConfigForContainer(image, registry); Image.create(imageConfig, function (data) { var err = data.length > 0 && data[data.length - 1].hasOwnProperty('error'); if (err) { diff --git a/app/components/templates/templatesController.js b/app/components/templates/templatesController.js index e271e6b3f..5c6860eff 100644 --- a/app/components/templates/templatesController.js +++ b/app/components/templates/templatesController.js @@ -129,9 +129,18 @@ function ($scope, $q, $state, $filter, $anchorScroll, Config, Info, Container, C }); } preparePortBindings(containerConfig, $scope.formValues.ports); + prepareImageConfig(containerConfig, template); return containerConfig; } + function prepareImageConfig(config, template) { + var image = _.toLower(template.image); + var registry = template.registry; + var imageConfig = ImageHelper.createImageConfigForContainer(image, registry); + config.Image = imageConfig.fromImage + ':' + imageConfig.tag; + $scope.imageConfig = imageConfig; + } + function prepareVolumeQueries(template, containerConfig) { var volumeQueries = []; if (template.volumes) { @@ -158,13 +167,9 @@ function ($scope, $q, $state, $filter, $anchorScroll, Config, Info, Container, C $('#createContainerSpinner').show(); var template = $scope.state.selectedTemplate; var containerConfig = createConfigFromTemplate(template); - var imageConfig = { - fromImage: template.image.split(':')[0], - tag: template.image.split(':')[1] ? template.image.split(':')[1] : 'latest' - }; var createVolumeQueries = prepareVolumeQueries(template, containerConfig); $q.all(createVolumeQueries).then(function (d) { - pullImageAndCreateContainer(imageConfig, containerConfig); + pullImageAndCreateContainer($scope.imageConfig, containerConfig); }); }; diff --git a/app/shared/helpers.js b/app/shared/helpers.js index 2ccf027f7..1bfc282c6 100644 --- a/app/shared/helpers.js +++ b/app/shared/helpers.js @@ -2,7 +2,7 @@ angular.module('portainer.helpers', []) .factory('ImageHelper', [function ImageHelperFactory() { 'use strict'; return { - createImageConfig: function(imageName, registry) { + createImageConfigForCommit: function(imageName, registry) { var imageNameAndTag = imageName.split(':'); var image = imageNameAndTag[0]; if (registry) { @@ -13,6 +13,18 @@ angular.module('portainer.helpers', []) tag: imageNameAndTag[1] ? imageNameAndTag[1] : 'latest' }; return imageConfig; + }, + createImageConfigForContainer: function (imageName, registry) { + var imageNameAndTag = imageName.split(':'); + var image = imageNameAndTag[0]; + if (registry) { + image = registry + '/' + imageNameAndTag[0]; + } + var imageConfig = { + fromImage: image, + tag: imageNameAndTag[1] ? imageNameAndTag[1] : 'latest' + }; + return imageConfig; } }; }]) From 2a28921984c9d799f3ffeb503a468db90ed86a98 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Wed, 14 Dec 2016 09:46:01 +1300 Subject: [PATCH 04/20] docs(README): update readthedocs badge to point at stable version --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 47a495dad..a2a16ee24 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Microbadger version](https://images.microbadger.com/badges/version/portainer/portainer.svg)](https://microbadger.com/images/portainer/portainer "Latest version on Docker Hub") [![Microbadger](https://images.microbadger.com/badges/image/portainer/portainer.svg)](http://microbadger.com/images/portainer/portainer "Image size") -[![Documentation Status](https://readthedocs.org/projects/portainer/badge/?version=latest)](http://portainer.readthedocs.io/en/latest/?badge=latest) +[![Documentation Status](https://readthedocs.org/projects/portainer/badge/?version=stable)](http://portainer.readthedocs.io/en/latest/?badge=stable) [![Gitter](https://badges.gitter.im/portainer/Lobby.svg)](https://gitter.im/portainer/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=YHXZJQNJQ36H6) From 1e5207517d6cbf716575415e3b53337931b0d42f Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Thu, 15 Dec 2016 14:30:35 +1300 Subject: [PATCH 05/20] fix(container-creation): do not stop container creation if unable to pull image --- .../createContainer/createContainerController.js | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/app/components/createContainer/createContainerController.js b/app/components/createContainer/createContainerController.js index 317005272..fa5f785d1 100644 --- a/app/components/createContainer/createContainerController.js +++ b/app/components/createContainer/createContainerController.js @@ -129,14 +129,7 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai // TODO: centralize, already present in templatesController function pullImageAndCreateContainer(config) { Image.create($scope.imageConfig, function (data) { - var err = data.length > 0 && data[data.length - 1].hasOwnProperty('error'); - if (err) { - var detail = data[data.length - 1]; - $('#createContainerSpinner').hide(); - Messages.error('Error', {}, detail.error); - } else { - createContainer(config); - } + createContainer(config); }, function (e) { $('#createContainerSpinner').hide(); Messages.error('Failure', e, 'Unable to pull image'); From 4e77c72fa276f46a67b7b5de2ecba570695ae3ff Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Thu, 15 Dec 2016 16:33:47 +1300 Subject: [PATCH 06/20] feat(global): add authentication support with single admin account --- api/api.go | 41 ++ api/auth.go | 88 ++++ api/datastore.go | 98 ++++ api/handler.go | 23 +- api/jwt.go | 29 ++ api/main.go | 9 +- api/middleware.go | 65 +++ api/users.go | 219 +++++++++ app/app.js | 425 +++++++++++++++--- app/components/auth/auth.html | 101 +++++ app/components/auth/authController.js | 68 +++ app/components/containers/containers.html | 8 +- .../containers/containersController.js | 14 +- .../createContainerController.js | 7 +- .../createContainer/createcontainer.html | 6 +- app/components/dashboard/dashboard.html | 6 +- .../dashboard/dashboardController.js | 7 +- .../master-ctrl.js => main/mainController.js} | 26 +- app/components/networks/networks.html | 4 +- app/components/settings/settings.html | 67 +++ app/components/settings/settingsController.js | 30 ++ app/components/sidebar/sidebar.html | 55 +++ app/components/sidebar/sidebarController.js | 10 + app/components/swarm/swarm.html | 28 +- app/components/swarm/swarmController.js | 4 +- app/components/templates/templates.html | 8 +- .../templates/templatesController.js | 5 - app/directives/header-content.js | 2 +- app/directives/header-title.js | 9 +- app/shared/services.js | 91 +++- assets/css/app.css | 67 ++- assets/images/logo_alt.png | Bin 0 -> 23441 bytes bower.json | 6 +- gruntFile.js | 10 +- index.html | 59 +-- 35 files changed, 1475 insertions(+), 220 deletions(-) create mode 100644 api/auth.go create mode 100644 api/datastore.go create mode 100644 api/jwt.go create mode 100644 api/middleware.go create mode 100644 api/users.go create mode 100644 app/components/auth/auth.html create mode 100644 app/components/auth/authController.js rename app/components/{dashboard/master-ctrl.js => main/mainController.js} (51%) create mode 100644 app/components/settings/settings.html create mode 100644 app/components/settings/settingsController.js create mode 100644 app/components/sidebar/sidebar.html create mode 100644 app/components/sidebar/sidebarController.js create mode 100644 assets/images/logo_alt.png diff --git a/api/api.go b/api/api.go index af3eb23ed..5b71f7fdf 100644 --- a/api/api.go +++ b/api/api.go @@ -2,6 +2,8 @@ package main import ( "crypto/tls" + "errors" + "github.com/gorilla/securecookie" "log" "net/http" "net/url" @@ -15,6 +17,8 @@ type ( dataPath string tlsConfig *tls.Config templatesURL string + dataStore *dataStore + secret []byte } apiConfig struct { @@ -31,7 +35,21 @@ type ( } ) +const ( + datastoreFileName = "portainer.db" +) + +var ( + errSecretKeyGeneration = errors.New("Unable to generate secret key to sign JWT") +) + func (a *api) run(settings *Settings) { + err := a.initDatabase() + if err != nil { + log.Fatal(err) + } + defer a.cleanUp() + handler := a.newHandler(settings) log.Printf("Starting portainer on %s", a.bindAddress) if err := http.ListenAndServe(a.bindAddress, handler); err != nil { @@ -39,12 +57,34 @@ func (a *api) run(settings *Settings) { } } +func (a *api) cleanUp() { + a.dataStore.cleanUp() +} + +func (a *api) initDatabase() error { + dataStore, err := newDataStore(a.dataPath + "/" + datastoreFileName) + if err != nil { + return err + } + err = dataStore.initDataStore() + if err != nil { + return err + } + a.dataStore = dataStore + return nil +} + func newAPI(apiConfig apiConfig) *api { endpointURL, err := url.Parse(apiConfig.Endpoint) if err != nil { log.Fatal(err) } + secret := securecookie.GenerateRandomKey(32) + if secret == nil { + log.Fatal(errSecretKeyGeneration) + } + var tlsConfig *tls.Config if apiConfig.TLSEnabled { tlsConfig = newTLSConfig(apiConfig.TLSCACertPath, apiConfig.TLSCertPath, apiConfig.TLSKeyPath) @@ -57,5 +97,6 @@ func newAPI(apiConfig apiConfig) *api { dataPath: apiConfig.DataPath, tlsConfig: tlsConfig, templatesURL: apiConfig.TemplatesURL, + secret: secret, } } diff --git a/api/auth.go b/api/auth.go new file mode 100644 index 000000000..74355c00b --- /dev/null +++ b/api/auth.go @@ -0,0 +1,88 @@ +package main + +import ( + "encoding/json" + "github.com/asaskevich/govalidator" + "golang.org/x/crypto/bcrypt" + "io/ioutil" + "log" + "net/http" +) + +type ( + credentials struct { + Username string `valid:"alphanum,required"` + Password string `valid:"length(8)"` + } + authResponse struct { + JWT string `json:"jwt"` + } +) + +func hashPassword(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", nil + } + return string(hash), nil +} + +func checkPasswordValidity(password string, hash string) error { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) +} + +// authHandler defines a handler function used to authenticate users +func (api *api) authHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + w.Header().Set("Allow", "POST") + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + body, err := ioutil.ReadAll(r.Body) + if err != nil { + http.Error(w, "Unable to parse request body", http.StatusBadRequest) + return + } + + var credentials credentials + err = json.Unmarshal(body, &credentials) + if err != nil { + http.Error(w, "Unable to parse credentials", http.StatusBadRequest) + return + } + + _, err = govalidator.ValidateStruct(credentials) + if err != nil { + http.Error(w, "Invalid credentials format", http.StatusBadRequest) + return + } + + var username = credentials.Username + var password = credentials.Password + u, err := api.dataStore.getUserByUsername(username) + if err != nil { + log.Printf("User not found: %s", username) + http.Error(w, "User not found", http.StatusNotFound) + return + } + + err = checkPasswordValidity(password, u.Password) + if err != nil { + log.Printf("Invalid credentials for user: %s", username) + http.Error(w, "Invalid credentials", http.StatusUnprocessableEntity) + return + } + + token, err := api.generateJWTToken(username) + if err != nil { + log.Printf("Unable to generate JWT token: %s", err.Error()) + http.Error(w, "Unable to generate JWT token", http.StatusInternalServerError) + return + } + + response := authResponse{ + JWT: token, + } + json.NewEncoder(w).Encode(response) +} diff --git a/api/datastore.go b/api/datastore.go new file mode 100644 index 000000000..58efd7f5e --- /dev/null +++ b/api/datastore.go @@ -0,0 +1,98 @@ +package main + +import ( + "encoding/json" + "errors" + "github.com/boltdb/bolt" +) + +const ( + userBucketName = "users" +) + +type ( + dataStore struct { + db *bolt.DB + } + + userItem struct { + Username string `json:"username"` + Password string `json:"password,omitempty"` + } +) + +var ( + errUserNotFound = errors.New("User not found") +) + +func (dataStore *dataStore) initDataStore() error { + return dataStore.db.Update(func(tx *bolt.Tx) error { + _, err := tx.CreateBucketIfNotExists([]byte(userBucketName)) + if err != nil { + return err + } + return nil + }) +} + +func (dataStore *dataStore) cleanUp() { + dataStore.db.Close() +} + +func newDataStore(databasePath string) (*dataStore, error) { + db, err := bolt.Open(databasePath, 0600, nil) + if err != nil { + return nil, err + } + + return &dataStore{ + db: db, + }, nil +} + +func (dataStore *dataStore) getUserByUsername(username string) (*userItem, error) { + var data []byte + + err := dataStore.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(userBucketName)) + value := bucket.Get([]byte(username)) + if value == nil { + return errUserNotFound + } + + data = make([]byte, len(value)) + copy(data, value) + return nil + }) + if err != nil { + return nil, err + } + + var user userItem + err = json.Unmarshal(data, &user) + if err != nil { + return nil, err + } + return &user, nil +} + +func (dataStore *dataStore) updateUser(user userItem) error { + buffer, err := json.Marshal(user) + if err != nil { + return err + } + + err = dataStore.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(userBucketName)) + err = bucket.Put([]byte(user.Username), buffer) + if err != nil { + return err + } + return nil + }) + + if err != nil { + return err + } + return nil +} diff --git a/api/handler.go b/api/handler.go index ef60a8583..f0d902659 100644 --- a/api/handler.go +++ b/api/handler.go @@ -1,6 +1,7 @@ package main import ( + "github.com/gorilla/mux" "golang.org/x/net/websocket" "log" "net/http" @@ -12,21 +13,35 @@ import ( // newHandler creates a new http.Handler with CSRF protection func (a *api) newHandler(settings *Settings) http.Handler { var ( - mux = http.NewServeMux() + mux = mux.NewRouter() fileHandler = http.FileServer(http.Dir(a.assetPath)) ) - handler := a.newAPIHandler() - mux.Handle("/", fileHandler) - mux.Handle("/dockerapi/", http.StripPrefix("/dockerapi", handler)) mux.Handle("/ws/exec", websocket.Handler(a.execContainer)) + mux.HandleFunc("/auth", a.authHandler) + mux.Handle("/users", addMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + a.usersHandler(w, r) + }), a.authenticate, secureHeaders)) + mux.Handle("/users/{username}", addMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + a.userHandler(w, r) + }), a.authenticate, secureHeaders)) + mux.Handle("/users/{username}/passwd", addMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + a.userPasswordHandler(w, r) + }), a.authenticate, secureHeaders)) + mux.HandleFunc("/users/admin/check", a.checkAdminHandler) + mux.HandleFunc("/users/admin/init", a.initAdminHandler) mux.HandleFunc("/settings", func(w http.ResponseWriter, r *http.Request) { settingsHandler(w, r, settings) }) mux.HandleFunc("/templates", func(w http.ResponseWriter, r *http.Request) { templatesHandler(w, r, a.templatesURL) }) + // mux.PathPrefix("/dockerapi/").Handler(http.StripPrefix("/dockerapi", handler)) + mux.PathPrefix("/dockerapi/").Handler(http.StripPrefix("/dockerapi", addMiddleware(handler, a.authenticate, secureHeaders))) + + mux.PathPrefix("/").Handler(http.StripPrefix("/", fileHandler)) + // CSRF protection is disabled for the moment // CSRFHandler := newCSRFHandler(a.dataPath) // return CSRFHandler(newCSRFWrapper(mux)) diff --git a/api/jwt.go b/api/jwt.go new file mode 100644 index 000000000..880a23a70 --- /dev/null +++ b/api/jwt.go @@ -0,0 +1,29 @@ +package main + +import ( + "github.com/dgrijalva/jwt-go" + "time" +) + +type claims struct { + Username string `json:"username"` + jwt.StandardClaims +} + +func (api *api) generateJWTToken(username string) (string, error) { + expireToken := time.Now().Add(time.Hour * 8).Unix() + claims := claims{ + username, + jwt.StandardClaims{ + ExpiresAt: expireToken, + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + signedToken, err := token.SignedString(api.secret) + if err != nil { + return "", err + } + + return signedToken, nil +} diff --git a/api/main.go b/api/main.go index 82fbd5651..a63d5a534 100644 --- a/api/main.go +++ b/api/main.go @@ -4,14 +4,19 @@ import ( "gopkg.in/alecthomas/kingpin.v2" ) +const ( + // Version number of portainer API + Version = "1.10.2" +) + // main is the entry point of the program func main() { - kingpin.Version("1.10.2") + kingpin.Version(Version) var ( endpoint = kingpin.Flag("host", "Dockerd endpoint").Default("unix:///var/run/docker.sock").Short('H').String() addr = kingpin.Flag("bind", "Address and port to serve Portainer").Default(":9000").Short('p').String() assets = kingpin.Flag("assets", "Path to the assets").Default(".").Short('a').String() - data = kingpin.Flag("data", "Path to the data").Default(".").Short('d').String() + data = kingpin.Flag("data", "Path to the folder where the data is stored").Default("/data").Short('d').String() tlsverify = kingpin.Flag("tlsverify", "TLS support").Default("false").Bool() tlscacert = kingpin.Flag("tlscacert", "Path to the CA").Default("/certs/ca.pem").String() tlscert = kingpin.Flag("tlscert", "Path to the TLS certificate file").Default("/certs/cert.pem").String() diff --git a/api/middleware.go b/api/middleware.go new file mode 100644 index 000000000..da12ae730 --- /dev/null +++ b/api/middleware.go @@ -0,0 +1,65 @@ +package main + +import ( + "fmt" + "github.com/dgrijalva/jwt-go" + "net/http" + "strings" +) + +func addMiddleware(h http.Handler, middleware ...func(http.Handler) http.Handler) http.Handler { + for _, mw := range middleware { + h = mw(h) + } + return h +} + +// authenticate provides Authentication middleware for handlers +func (api *api) authenticate(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var token string + + // Get token from the Authorization header + // format: Authorization: Bearer + tokens, ok := r.Header["Authorization"] + if ok && len(tokens) >= 1 { + token = tokens[0] + token = strings.TrimPrefix(token, "Bearer ") + } + + if token == "" { + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } + + parsedToken, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + msg := fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) + return nil, msg + } + return api.secret, nil + }) + if err != nil { + http.Error(w, "Invalid JWT token", http.StatusUnauthorized) + return + } + + if parsedToken == nil || !parsedToken.Valid { + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } + + // context.Set(r, "user", parsedToken) + next.ServeHTTP(w, r) + return + }) +} + +// SecureHeaders adds secure headers to the API +func secureHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("X-Content-Type-Options", "nosniff") + w.Header().Add("X-Frame-Options", "DENY") + next.ServeHTTP(w, r) + }) +} diff --git a/api/users.go b/api/users.go new file mode 100644 index 000000000..d0a48aceb --- /dev/null +++ b/api/users.go @@ -0,0 +1,219 @@ +package main + +import ( + "encoding/json" + "github.com/gorilla/mux" + "io/ioutil" + "log" + "net/http" +) + +type ( + passwordCheckRequest struct { + Password string `json:"password"` + } + passwordCheckResponse struct { + Valid bool `json:"valid"` + } + initAdminRequest struct { + Password string `json:"password"` + } +) + +// handle /users +// Allowed methods: POST +func (api *api) usersHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + w.Header().Set("Allow", "POST") + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + body, err := ioutil.ReadAll(r.Body) + if err != nil { + http.Error(w, "Unable to parse request body", http.StatusBadRequest) + return + } + + var user userItem + err = json.Unmarshal(body, &user) + if err != nil { + http.Error(w, "Unable to parse user data", http.StatusBadRequest) + return + } + + user.Password, err = hashPassword(user.Password) + if err != nil { + http.Error(w, "Unable to hash user password", http.StatusInternalServerError) + return + } + + err = api.dataStore.updateUser(user) + if err != nil { + log.Printf("Unable to persist user: %s", err.Error()) + http.Error(w, "Unable to persist user", http.StatusInternalServerError) + return + } +} + +// handle /users/admin/check +// Allowed methods: POST +func (api *api) checkAdminHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + w.Header().Set("Allow", "GET") + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + user, err := api.dataStore.getUserByUsername("admin") + if err == errUserNotFound { + log.Printf("User not found: %s", "admin") + http.Error(w, "User not found", http.StatusNotFound) + return + } + if err != nil { + log.Printf("Unable to retrieve user: %s", err.Error()) + http.Error(w, "Unable to retrieve user", http.StatusInternalServerError) + return + } + + user.Password = "" + json.NewEncoder(w).Encode(user) +} + +// handle /users/admin/init +// Allowed methods: POST +func (api *api) initAdminHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + w.Header().Set("Allow", "POST") + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + body, err := ioutil.ReadAll(r.Body) + if err != nil { + http.Error(w, "Unable to parse request body", http.StatusBadRequest) + return + } + + var requestData initAdminRequest + err = json.Unmarshal(body, &requestData) + if err != nil { + http.Error(w, "Unable to parse user data", http.StatusBadRequest) + return + } + + user := userItem{ + Username: "admin", + } + user.Password, err = hashPassword(requestData.Password) + if err != nil { + http.Error(w, "Unable to hash user password", http.StatusInternalServerError) + return + } + + err = api.dataStore.updateUser(user) + if err != nil { + log.Printf("Unable to persist user: %s", err.Error()) + http.Error(w, "Unable to persist user", http.StatusInternalServerError) + return + } +} + +// handle /users/{username} +// Allowed methods: PUT, GET +func (api *api) userHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "PUT" { + body, err := ioutil.ReadAll(r.Body) + if err != nil { + http.Error(w, "Unable to parse request body", http.StatusBadRequest) + return + } + + var user userItem + err = json.Unmarshal(body, &user) + if err != nil { + http.Error(w, "Unable to parse user data", http.StatusBadRequest) + return + } + + user.Password, err = hashPassword(user.Password) + if err != nil { + http.Error(w, "Unable to hash user password", http.StatusInternalServerError) + return + } + + err = api.dataStore.updateUser(user) + if err != nil { + log.Printf("Unable to persist user: %s", err.Error()) + http.Error(w, "Unable to persist user", http.StatusInternalServerError) + return + } + } else if r.Method == "GET" { + vars := mux.Vars(r) + username := vars["username"] + + user, err := api.dataStore.getUserByUsername(username) + if err == errUserNotFound { + log.Printf("User not found: %s", username) + http.Error(w, "User not found", http.StatusNotFound) + return + } + if err != nil { + log.Printf("Unable to retrieve user: %s", err.Error()) + http.Error(w, "Unable to retrieve user", http.StatusInternalServerError) + return + } + + user.Password = "" + json.NewEncoder(w).Encode(user) + } else { + w.Header().Set("Allow", "PUT, GET") + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } +} + +// handle /users/{username}/passwd +// Allowed methods: POST +func (api *api) userPasswordHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + w.Header().Set("Allow", "POST") + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + vars := mux.Vars(r) + username := vars["username"] + + body, err := ioutil.ReadAll(r.Body) + if err != nil { + http.Error(w, "Unable to parse request body", http.StatusBadRequest) + return + } + + var data passwordCheckRequest + err = json.Unmarshal(body, &data) + if err != nil { + http.Error(w, "Unable to parse user data", http.StatusBadRequest) + return + } + + user, err := api.dataStore.getUserByUsername(username) + if err != nil { + log.Printf("Unable to retrieve user: %s", err.Error()) + http.Error(w, "Unable to retrieve user", http.StatusInternalServerError) + return + } + + valid := true + err = checkPasswordValidity(data.Password, user.Password) + if err != nil { + valid = false + } + + response := passwordCheckResponse{ + Valid: valid, + } + json.NewEncoder(w).Encode(response) +} diff --git a/app/app.js b/app/app.js index f06c050fc..144f407d8 100644 --- a/app/app.js +++ b/app/app.js @@ -6,9 +6,12 @@ angular.module('portainer', [ 'ngCookies', 'ngSanitize', 'angularUtils.directives.dirPagination', + 'LocalStorageModule', + 'angular-jwt', 'portainer.services', 'portainer.helpers', 'portainer.filters', + 'auth', 'dashboard', 'container', 'containerConsole', @@ -19,8 +22,11 @@ angular.module('portainer', [ 'events', 'images', 'image', + 'main', 'service', 'services', + 'settings', + 'sidebar', 'createService', 'stats', 'swarm', @@ -31,131 +37,430 @@ angular.module('portainer', [ 'templates', 'volumes', 'createVolume']) - .config(['$stateProvider', '$urlRouterProvider', '$httpProvider', function ($stateProvider, $urlRouterProvider, $httpProvider) { + .config(['$stateProvider', '$urlRouterProvider', '$httpProvider', 'localStorageServiceProvider', 'jwtOptionsProvider', function ($stateProvider, $urlRouterProvider, $httpProvider, localStorageServiceProvider, jwtOptionsProvider) { 'use strict'; - $urlRouterProvider.otherwise('/'); + localStorageServiceProvider + .setStorageType('sessionStorage') + .setPrefix('portainer'); + + jwtOptionsProvider.config({ + tokenGetter: ['localStorageService', function(localStorageService) { + return localStorageService.get('JWT'); + }], + unauthenticatedRedirector: ['$state', function($state) { + $state.go('auth', {error: 'Your session has expired'}); + }] + }); + $httpProvider.interceptors.push('jwtInterceptor'); + + $urlRouterProvider.otherwise('/auth'); $stateProvider - .state('index', { - url: '/', - templateUrl: 'app/components/dashboard/dashboard.html', - controller: 'DashboardController' + .state('auth', { + url: '/auth', + params: { + logout: false, + error: '' + }, + views: { + "content": { + templateUrl: 'app/components/auth/auth.html', + controller: 'AuthenticationController' + } + } }) .state('containers', { url: '/containers/', - templateUrl: 'app/components/containers/containers.html', - controller: 'ContainersController' + views: { + "content": { + templateUrl: 'app/components/containers/containers.html', + controller: 'ContainersController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }) .state('container', { url: "^/containers/:id", - templateUrl: 'app/components/container/container.html', - controller: 'ContainerController' + views: { + "content": { + templateUrl: 'app/components/container/container.html', + controller: 'ContainerController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }) .state('stats', { url: "^/containers/:id/stats", - templateUrl: 'app/components/stats/stats.html', - controller: 'StatsController' + views: { + "content": { + templateUrl: 'app/components/stats/stats.html', + controller: 'StatsController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }) .state('logs', { url: "^/containers/:id/logs", - templateUrl: 'app/components/containerLogs/containerlogs.html', - controller: 'ContainerLogsController' + views: { + "content": { + templateUrl: 'app/components/containerLogs/containerlogs.html', + controller: 'ContainerLogsController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }) .state('console', { url: "^/containers/:id/console", - templateUrl: 'app/components/containerConsole/containerConsole.html', - controller: 'ContainerConsoleController' + views: { + "content": { + templateUrl: 'app/components/containerConsole/containerConsole.html', + controller: 'ContainerConsoleController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } + }) + .state('dashboard', { + url: '/dashboard', + views: { + "content": { + templateUrl: 'app/components/dashboard/dashboard.html', + controller: 'DashboardController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }) .state('actions', { abstract: true, url: "/actions", - template: '' + views: { + "content": { + template: '
' + }, + "sidebar": { + template: '
' + } + } }) .state('actions.create', { abstract: true, url: "/create", - template: '' + views: { + "content": { + template: '
' + }, + "sidebar": { + template: '
' + } + } }) .state('actions.create.container', { url: "/container", - templateUrl: 'app/components/createContainer/createcontainer.html', - controller: 'CreateContainerController' + views: { + "content": { + templateUrl: 'app/components/createContainer/createcontainer.html', + controller: 'CreateContainerController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }) .state('actions.create.network', { url: "/network", - templateUrl: 'app/components/createNetwork/createnetwork.html', - controller: 'CreateNetworkController' + views: { + "content": { + templateUrl: 'app/components/createNetwork/createnetwork.html', + controller: 'CreateNetworkController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }) .state('actions.create.service', { url: "/service", - templateUrl: 'app/components/createService/createservice.html', - controller: 'CreateServiceController' + views: { + "content": { + templateUrl: 'app/components/createService/createservice.html', + controller: 'CreateServiceController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }) .state('actions.create.volume', { url: "/volume", - templateUrl: 'app/components/createVolume/createvolume.html', - controller: 'CreateVolumeController' + views: { + "content": { + templateUrl: 'app/components/createVolume/createvolume.html', + controller: 'CreateVolumeController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }) .state('docker', { url: '/docker/', - templateUrl: 'app/components/docker/docker.html', - controller: 'DockerController' + views: { + "content": { + templateUrl: 'app/components/docker/docker.html', + controller: 'DockerController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }) .state('events', { url: '/events/', - templateUrl: 'app/components/events/events.html', - controller: 'EventsController' + views: { + "content": { + templateUrl: 'app/components/events/events.html', + controller: 'EventsController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }) .state('images', { url: '/images/', - templateUrl: 'app/components/images/images.html', - controller: 'ImagesController' + views: { + "content": { + templateUrl: 'app/components/images/images.html', + controller: 'ImagesController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }) .state('image', { url: '^/images/:id/', - templateUrl: 'app/components/image/image.html', - controller: 'ImageController' + views: { + "content": { + templateUrl: 'app/components/image/image.html', + controller: 'ImageController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }) .state('networks', { url: '/networks/', - templateUrl: 'app/components/networks/networks.html', - controller: 'NetworksController' + views: { + "content": { + templateUrl: 'app/components/networks/networks.html', + controller: 'NetworksController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }) .state('network', { url: '^/networks/:id/', - templateUrl: 'app/components/network/network.html', - controller: 'NetworkController' + views: { + "content": { + templateUrl: 'app/components/network/network.html', + controller: 'NetworkController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }) .state('services', { url: '/services/', - templateUrl: 'app/components/services/services.html', - controller: 'ServicesController' + views: { + "content": { + templateUrl: 'app/components/services/services.html', + controller: 'ServicesController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }) .state('service', { url: '^/service/:id/', - templateUrl: 'app/components/service/service.html', - controller: 'ServiceController' + views: { + "content": { + templateUrl: 'app/components/service/service.html', + controller: 'ServiceController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } + }) + .state('settings', { + url: '/settings/', + views: { + "content": { + templateUrl: 'app/components/settings/settings.html', + controller: 'SettingsController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }) .state('task', { url: '^/task/:id', - templateUrl: 'app/components/task/task.html', - controller: 'TaskController' + views: { + "content": { + templateUrl: 'app/components/task/task.html', + controller: 'TaskController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }) .state('templates', { url: '/templates/', - templateUrl: 'app/components/templates/templates.html', - controller: 'TemplatesController' + views: { + "content": { + templateUrl: 'app/components/templates/templates.html', + controller: 'TemplatesController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }) .state('volumes', { url: '/volumes/', - templateUrl: 'app/components/volumes/volumes.html', - controller: 'VolumesController' + views: { + "content": { + templateUrl: 'app/components/volumes/volumes.html', + controller: 'VolumesController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }) .state('swarm', { url: '/swarm/', - templateUrl: 'app/components/swarm/swarm.html', - controller: 'SwarmController' + views: { + "content": { + templateUrl: 'app/components/swarm/swarm.html', + controller: 'SwarmController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }); // The Docker API likes to return plaintext errors, this catches them and disp @@ -165,7 +470,7 @@ angular.module('portainer', [ return { 'response': function(response) { if (typeof(response.data) === 'string' && - (response.data.startsWith('Conflict.') || response.data.startsWith('conflict:'))) { + (response.data.startsWith('Conflict.') || response.data.startsWith('conflict:'))) { $.gritter.add({ title: 'Error', text: $('
').text(response.data).html(), @@ -182,12 +487,28 @@ angular.module('portainer', [ }; }); }]) + .run(['$rootScope', '$state', 'Authentication', 'authManager', 'EndpointMode', function ($rootScope, $state, Authentication, authManager, EndpointMode) { + authManager.checkAuthOnRefresh(); + authManager.redirectWhenUnauthenticated(); + Authentication.init(); + $rootScope.$state = $state; + $rootScope.$on('tokenHasExpired', function($state) { + $state.go('auth', {error: 'Your session has expired'}); + }); + + $rootScope.$on("$stateChangeStart", function(event, toState, toParams, fromState, fromParams) { + if ((fromState.name === 'auth' || fromState.name === '') && Authentication.isAuthenticated()) { + EndpointMode.determineEndpointMode(); + } + }); + }]) // This is your docker url that the api will use to make requests // You need to set this to the api endpoint without the port i.e. http://192.168.1.9 .constant('DOCKER_ENDPOINT', 'dockerapi') - .constant('DOCKER_PORT', '') // Docker port, leave as an empty string if no port is requred. If you have a port, prefix it with a ':' i.e. :4243 + .constant('DOCKER_PORT', '') // Docker port, leave as an empty string if no port is required. If you have a port, prefix it with a ':' i.e. :4243 .constant('CONFIG_ENDPOINT', 'settings') + .constant('AUTH_ENDPOINT', 'auth') .constant('TEMPLATES_ENDPOINT', 'templates') .constant('PAGINATION_MAX_ITEMS', 10) .constant('UI_VERSION', 'v1.10.2'); diff --git a/app/components/auth/auth.html b/app/components/auth/auth.html new file mode 100644 index 000000000..f335c52ef --- /dev/null +++ b/app/components/auth/auth.html @@ -0,0 +1,101 @@ + diff --git a/app/components/auth/authController.js b/app/components/auth/authController.js new file mode 100644 index 000000000..6fef01af2 --- /dev/null +++ b/app/components/auth/authController.js @@ -0,0 +1,68 @@ +angular.module('auth', []) +.controller('AuthenticationController', ['$scope', '$state', '$stateParams', '$window', '$timeout', '$sanitize', 'Config', 'Authentication', 'Users', 'Messages', +function ($scope, $state, $stateParams, $window, $timeout, $sanitize, Config, Authentication, Users, Messages) { + + $scope.authData = { + username: 'admin', + password: '', + error: '' + }; + $scope.initPasswordData = { + password: '', + password_confirmation: '', + error: false + }; + + if ($stateParams.logout) { + Authentication.logout(); + } + + if ($stateParams.error) { + $scope.authData.error = $stateParams.error; + Authentication.logout(); + } + + if (Authentication.isAuthenticated()) { + $state.go('dashboard'); + } + + Config.$promise.then(function (c) { + $scope.logo = c.logo; + }); + + Users.checkAdminUser({}, function (d) {}, + function (e) { + if (e.status === 404) { + $scope.initPassword = true; + } else { + Messages.error("Failure", e, 'Unable to verify administrator account existence'); + } + }); + + $scope.createAdminUser = function() { + var password = $sanitize($scope.initPasswordData.password); + Users.initAdminUser({password: password}, function (d) { + $scope.initPassword = false; + $timeout(function() { + var element = $window.document.getElementById('password'); + if(element) { + element.focus(); + } + }); + }, function (e) { + $scope.initPassword.error = true; + }); + }; + + $scope.authenticateUser = function() { + $scope.authenticationError = false; + var username = $sanitize($scope.authData.username); + var password = $sanitize($scope.authData.password); + Authentication.login(username, password) + .then(function() { + $state.go('dashboard'); + }, function() { + $scope.authData.error = 'Invalid credentials'; + }); + }; +}]); diff --git a/app/components/containers/containers.html b/app/components/containers/containers.html index 97a9f677a..c7d86a426 100644 --- a/app/components/containers/containers.html +++ b/app/components/containers/containers.html @@ -66,7 +66,7 @@ - + Host IP @@ -86,11 +86,11 @@ {{ container.Status|containerstatus }} - {{ container|swarmcontainername}} - {{ container|containername}} + {{ container|swarmcontainername}} + {{ container|containername}} {{ container.Image }} {{ container.IP ? container.IP : '-' }} - {{ container.hostIP }} + {{ container.hostIP }} {{p.public}}:{{ p.private }} diff --git a/app/components/containers/containersController.js b/app/components/containers/containersController.js index 63db2d0b9..acaa912ed 100644 --- a/app/components/containers/containersController.js +++ b/app/components/containers/containersController.js @@ -7,9 +7,7 @@ function ($scope, Container, ContainerHelper, Info, Settings, Messages, Config) $scope.sortType = 'State'; $scope.sortReverse = false; $scope.state.selectedItemCount = 0; - $scope.swarm_mode = false; $scope.pagination_count = Settings.pagination_count; - $scope.order = function (sortType) { $scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false; $scope.sortType = sortType; @@ -28,7 +26,7 @@ function ($scope, Container, ContainerHelper, Info, Settings, Messages, Config) if (model.IP) { $scope.state.displayIP = true; } - if ($scope.swarm && !$scope.swarm_mode) { + if ($scope.endpointMode.provider === 'DOCKER_SWARM') { model.hostIP = $scope.swarm_hosts[_.split(container.Names[0], '/')[1]]; } return model; @@ -150,17 +148,11 @@ function ($scope, Container, ContainerHelper, Info, Settings, Messages, Config) return swarm_hosts; } - $scope.swarm = false; Config.$promise.then(function (c) { $scope.containersToHideLabels = c.hiddenLabels; - $scope.swarm = c.swarm; - if (c.swarm) { + if (c.swarm && $scope.endpointMode.provider === 'DOCKER_SWARM') { Info.get({}, function (d) { - if (!_.startsWith(d.ServerVersion, 'swarm')) { - $scope.swarm_mode = true; - } else { - $scope.swarm_hosts = retrieveSwarmHostsInfo(d); - } + $scope.swarm_hosts = retrieveSwarmHostsInfo(d); update({all: Settings.displayAll ? 1 : 0}); }); } else { diff --git a/app/components/createContainer/createContainerController.js b/app/components/createContainer/createContainerController.js index fa5f785d1..617f3d49b 100644 --- a/app/components/createContainer/createContainerController.js +++ b/app/components/createContainer/createContainerController.js @@ -53,11 +53,6 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai Config.$promise.then(function (c) { $scope.swarm = c.swarm; - Info.get({}, function(info) { - if ($scope.swarm && !_.startsWith(info.ServerVersion, 'swarm')) { - $scope.swarm_mode = true; - } - }); var containersToHideLabels = c.hiddenLabels; Volume.query({}, function (d) { @@ -216,7 +211,7 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai var containerName = container; if (container && typeof container === 'object') { containerName = $filter('trimcontainername')(container.Names[0]); - if ($scope.swarm && !$scope.swarm_mode) { + if ($scope.swarm && $scope.endpointMode.provider === 'DOCKER_SWARM') { containerName = $filter('swarmcontainername')(container); } } diff --git a/app/components/createContainer/createcontainer.html b/app/components/createContainer/createcontainer.html index 795ecfefa..b6aa18322 100644 --- a/app/components/createContainer/createcontainer.html +++ b/app/components/createContainer/createcontainer.html @@ -258,7 +258,7 @@
-
+
@@ -278,10 +278,10 @@
- -
diff --git a/app/components/dashboard/dashboard.html b/app/components/dashboard/dashboard.html index 9e4366633..f9beebe91 100644 --- a/app/components/dashboard/dashboard.html +++ b/app/components/dashboard/dashboard.html @@ -6,7 +6,7 @@
-
+
@@ -33,7 +33,7 @@
-
+
@@ -60,7 +60,7 @@
-
+
diff --git a/app/components/dashboard/dashboardController.js b/app/components/dashboard/dashboardController.js index 884633943..c48b76b1b 100644 --- a/app/components/dashboard/dashboardController.js +++ b/app/components/dashboard/dashboardController.js @@ -14,7 +14,6 @@ function ($scope, $q, Config, Container, ContainerHelper, Image, Network, Volume $scope.volumeData = { total: 0 }; - $scope.swarm_mode = false; function prepareContainerData(d, containersToHideLabels) { var running = 0; @@ -64,9 +63,6 @@ function ($scope, $q, Config, Container, ContainerHelper, Image, Network, Volume function prepareInfoData(d) { var info = d; $scope.infoData = info; - if ($scope.swarm && !_.startsWith(info.ServerVersion, 'swarm')) { - $scope.swarm_mode = true; - } } function fetchDashboardData(containersToHideLabels) { @@ -84,6 +80,9 @@ function ($scope, $q, Config, Container, ContainerHelper, Image, Network, Volume prepareNetworkData(d[3]); prepareInfoData(d[4]); $('#loadingViewSpinner').hide(); + }, function(e) { + $('#loadingViewSpinner').hide(); + Messages.error("Failure", e, "Unable to load dashboard data"); }); } diff --git a/app/components/dashboard/master-ctrl.js b/app/components/main/mainController.js similarity index 51% rename from app/components/dashboard/master-ctrl.js rename to app/components/main/mainController.js index 356ce7de4..3a48c9bc4 100644 --- a/app/components/dashboard/master-ctrl.js +++ b/app/components/main/mainController.js @@ -1,31 +1,15 @@ -angular.module('dashboard') -.controller('MasterCtrl', ['$scope', '$cookieStore', 'Settings', 'Config', 'Info', -function ($scope, $cookieStore, Settings, Config, Info) { +angular.module('main', []) +.controller('MainController', ['$scope', '$cookieStore', +function ($scope, $cookieStore) { + /** * Sidebar Toggle & Cookie Control */ var mobileView = 992; - $scope.getWidth = function() { return window.innerWidth; }; - $scope.swarm_mode = false; - - Config.$promise.then(function (c) { - $scope.logo = c.logo; - $scope.swarm = c.swarm; - Info.get({}, function(d) { - if ($scope.swarm && !_.startsWith(d.ServerVersion, 'swarm')) { - $scope.swarm_mode = true; - $scope.swarm_manager = false; - if (d.Swarm.ControlAvailable) { - $scope.swarm_manager = true; - } - } - }); - }); - $scope.$watch($scope.getWidth, function(newValue, oldValue) { if (newValue >= mobileView) { if (angular.isDefined($cookieStore.get('toggle'))) { @@ -47,6 +31,4 @@ function ($scope, $cookieStore, Settings, Config, Info) { window.onresize = function() { $scope.$apply(); }; - - $scope.uiVersion = Settings.uiVersion; }]); diff --git a/app/components/networks/networks.html b/app/components/networks/networks.html index b8ef43cd4..806640a95 100644 --- a/app/components/networks/networks.html +++ b/app/components/networks/networks.html @@ -23,12 +23,12 @@
-
+
Note: The network will be created using the overlay driver and will allow containers to communicate across the hosts of your cluster.
-
+
Note: The network will be created using the bridge driver.
diff --git a/app/components/settings/settings.html b/app/components/settings/settings.html new file mode 100644 index 000000000..bc63db071 --- /dev/null +++ b/app/components/settings/settings.html @@ -0,0 +1,67 @@ + + + + Settings + + +
+
+ + + + + +
+ +
+
+ + +
+
+
+ +
+

+ + Your new password must be at least 8 characters long +

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

+ Current password is not valid +

+
+
+ +
+
+
+
diff --git a/app/components/settings/settingsController.js b/app/components/settings/settingsController.js new file mode 100644 index 000000000..5e4fd565a --- /dev/null +++ b/app/components/settings/settingsController.js @@ -0,0 +1,30 @@ +angular.module('settings', []) +.controller('SettingsController', ['$scope', '$state', '$sanitize', 'Users', 'Messages', +function ($scope, $state, $sanitize, Users, Messages) { + $scope.formValues = { + currentPassword: '', + newPassword: '', + confirmPassword: '' + }; + + $scope.updatePassword = function() { + $scope.invalidPassword = false; + $scope.error = false; + var currentPassword = $sanitize($scope.formValues.currentPassword); + Users.checkPassword({ username: $scope.username, password: currentPassword }, function (d) { + if (d.valid) { + var newPassword = $sanitize($scope.formValues.newPassword); + Users.update({ username: $scope.username, password: newPassword }, function (d) { + Messages.send("Success", "Password successfully updated"); + $state.go('settings', {}, {reload: true}); + }, function (e) { + Messages.error("Failure", e, "Unable to update password"); + }); + } else { + $scope.invalidPassword = true; + } + }, function (e) { + Messages.error("Failure", e, "Unable to check password validity"); + }); + }; +}]); diff --git a/app/components/sidebar/sidebar.html b/app/components/sidebar/sidebar.html new file mode 100644 index 000000000..f3b133a76 --- /dev/null +++ b/app/components/sidebar/sidebar.html @@ -0,0 +1,55 @@ + + + diff --git a/app/components/sidebar/sidebarController.js b/app/components/sidebar/sidebarController.js new file mode 100644 index 000000000..090e23cef --- /dev/null +++ b/app/components/sidebar/sidebarController.js @@ -0,0 +1,10 @@ +angular.module('sidebar', []) +.controller('SidebarController', ['$scope', 'Settings', 'Config', 'Info', +function ($scope, Settings, Config, Info) { + + Config.$promise.then(function (c) { + $scope.logo = c.logo; + }); + + $scope.uiVersion = Settings.uiVersion; +}]); diff --git a/app/components/swarm/swarm.html b/app/components/swarm/swarm.html index 5754c5973..71ed078ca 100644 --- a/app/components/swarm/swarm.html +++ b/app/components/swarm/swarm.html @@ -16,14 +16,14 @@ Nodes - {{ swarm.Nodes }} - {{ info.Swarm.Nodes }} + {{ swarm.Nodes }} + {{ info.Swarm.Nodes }} - + Images {{ info.Images }} - + Swarm version {{ docker.Version|swarmversion }} @@ -31,29 +31,29 @@ Docker API version {{ docker.ApiVersion }} - + Strategy {{ swarm.Strategy }} Total CPU - {{ info.NCPU }} - {{ totalCPU }} + {{ info.NCPU }} + {{ totalCPU }} Total memory - {{ info.MemTotal|humansize: 2 }} - {{ totalMemory|humansize: 2 }} + {{ info.MemTotal|humansize: 2 }} + {{ totalMemory|humansize: 2 }} - + Operating system {{ info.OperatingSystem }} - + Kernel version {{ info.KernelVersion }} - + Go version {{ docker.GoVersion }} @@ -65,7 +65,7 @@
-
+
@@ -133,7 +133,7 @@
-
+
diff --git a/app/components/swarm/swarmController.js b/app/components/swarm/swarmController.js index a04886564..2f7659407 100644 --- a/app/components/swarm/swarmController.js +++ b/app/components/swarm/swarmController.js @@ -7,7 +7,6 @@ function ($scope, Info, Version, Node, Settings) { $scope.info = {}; $scope.docker = {}; $scope.swarm = {}; - $scope.swarm_mode = false; $scope.totalCPU = 0; $scope.totalMemory = 0; $scope.pagination_count = Settings.pagination_count; @@ -23,8 +22,7 @@ function ($scope, Info, Version, Node, Settings) { Info.get({}, function (d) { $scope.info = d; - if (!_.startsWith(d.ServerVersion, 'swarm')) { - $scope.swarm_mode = true; + if ($scope.endpointMode.provider === 'DOCKER_SWARM_MODE') { Node.query({}, function(d) { $scope.nodes = d; var CPU = 0, memory = 0; diff --git a/app/components/templates/templates.html b/app/components/templates/templates.html index 66ed097a6..c4f0bc48a 100644 --- a/app/components/templates/templates.html +++ b/app/components/templates/templates.html @@ -13,12 +13,12 @@
-
+
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.
-
+
App templates cannot be used with swarm-mode at the moment. You can still use them to quickly deploy containers to the Docker host. @@ -41,10 +41,10 @@
- - diff --git a/app/components/templates/templatesController.js b/app/components/templates/templatesController.js index 5c6860eff..261ad96ad 100644 --- a/app/components/templates/templatesController.js +++ b/app/components/templates/templatesController.js @@ -204,11 +204,6 @@ function ($scope, $q, $state, $filter, $anchorScroll, Config, Info, Container, C Config.$promise.then(function (c) { $scope.swarm = c.swarm; - Info.get({}, function(info) { - if ($scope.swarm && !_.startsWith(info.ServerVersion, 'swarm')) { - $scope.swarm_mode = true; - } - }); var containersToHideLabels = c.hiddenLabels; Network.query({}, function (d) { var networks = d; diff --git a/app/directives/header-content.js b/app/directives/header-content.js index 40df9a066..862356650 100644 --- a/app/directives/header-content.js +++ b/app/directives/header-content.js @@ -4,7 +4,7 @@ angular var directive = { requires: '^rdHeader', transclude: true, - template: '', + template: '', restrict: 'E' }; return directive; diff --git a/app/directives/header-title.js b/app/directives/header-title.js index b0816529d..352aa0643 100644 --- a/app/directives/header-title.js +++ b/app/directives/header-title.js @@ -1,14 +1,17 @@ angular .module('portainer') -.directive('rdHeaderTitle', function rdHeaderTitle() { +.directive('rdHeaderTitle', ['$rootScope', function rdHeaderTitle($rootScope) { var directive = { requires: '^rdHeader', scope: { title: '@' }, + link: function (scope, iElement, iAttrs) { + scope.username = $rootScope.username; + }, transclude: true, - template: '
{{title}}
', + template: '
{{title}} {{username}}
', restrict: 'E' }; return directive; -}); +}]); diff --git a/app/shared/services.js b/app/shared/services.js index 0a7bf27b3..513ceaa9d 100644 --- a/app/shared/services.js +++ b/app/shared/services.js @@ -166,14 +166,6 @@ angular.module('portainer.services', ['ngResource', 'ngSanitize']) get: {method: 'GET'} }); }]) - .factory('Auth', ['$resource', 'Settings', function AuthFactory($resource, Settings) { - 'use strict'; - // http://docs.docker.com/reference/api/docker_remote_api_<%= remoteApiVersion %>/#check-auth-configuration - return $resource(Settings.url + '/auth', {}, { - get: {method: 'GET'}, - update: {method: 'POST'} - }); - }]) .factory('Info', ['$resource', 'Settings', function InfoFactory($resource, Settings) { 'use strict'; // http://docs.docker.com/reference/api/docker_remote_api_<%= remoteApiVersion %>/#display-system-wide-information @@ -229,6 +221,89 @@ angular.module('portainer.services', ['ngResource', 'ngSanitize']) pagination_count: PAGINATION_MAX_ITEMS }; }]) + .factory('Auth', ['$resource', 'AUTH_ENDPOINT', function AuthFactory($resource, AUTH_ENDPOINT) { + 'use strict'; + return $resource(AUTH_ENDPOINT, {}, { + login: { + method: 'POST' + } + }); + }]) + .factory('Users', ['$resource', function UsersFactory($resource) { + 'use strict'; + return $resource('/users/:username/:action', {}, { + create: { method: 'POST' }, + get: {method: 'GET', params: { username: '@username' } }, + update: { method: 'PUT', params: { username: '@username' } }, + checkPassword: { method: 'POST', params: { username: '@username', action: 'passwd' } }, + checkAdminUser: {method: 'GET', params: { username: 'admin', action: 'check' }}, + initAdminUser: {method: 'POST', params: { username: 'admin', action: 'init' }} + }); + }]) + .factory('EndpointMode', ['$rootScope', 'Info', function EndpointMode($rootScope, Info) { + 'use strict'; + return { + determineEndpointMode: function() { + Info.get({}, function(d) { + var mode = { + provider: '', + role: '' + }; + if (_.startsWith(d.ServerVersion, 'swarm')) { + mode.provider = "DOCKER_SWARM"; + if (d.SystemStatus[0][1] === 'primary') { + mode.role = "PRIMARY"; + } else { + mode.role = "REPLICA"; + } + } else { + if (!d.Swarm || _.isEmpty(d.Swarm.NodeID)) { + mode.provider = "DOCKER_STANDALONE"; + } else { + mode.provider = "DOCKER_SWARM_MODE"; + if (d.Swarm.ControlAvailable) { + mode.role = "MANAGER"; + } else { + mode.role = "WORKER"; + } + } + } + $rootScope.endpointMode = mode; + }); + } + }; + }]) + .factory('Authentication', ['$q', '$rootScope', 'Auth', 'jwtHelper', 'localStorageService', function AuthenticationFactory($q, $rootScope, Auth, jwtHelper, localStorageService) { + 'use strict'; + return { + init: function() { + var jwt = localStorageService.get('JWT'); + if (jwt) { + var tokenPayload = jwtHelper.decodeToken(jwt); + $rootScope.username = tokenPayload.username; + } + }, + login: function(username, password) { + return $q(function (resolve, reject) { + Auth.login({username: username, password: password}).$promise + .then(function(data) { + localStorageService.set('JWT', data.jwt); + $rootScope.username = username; + resolve(); + }, function() { + reject(); + }); + }); + }, + logout: function() { + localStorageService.remove('JWT'); + }, + isAuthenticated: function() { + var jwt = localStorageService.get('JWT'); + return jwt && !jwtHelper.isTokenExpired(jwt); + } + }; + }]) .factory('Messages', ['$rootScope', '$sanitize', function MessagesFactory($rootScope, $sanitize) { 'use strict'; return { diff --git a/assets/css/app.css b/assets/css/app.css index 31b712997..fd7f667b0 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -1,18 +1,27 @@ +html, body, #content-wrapper, .page-content, #view { + height: 100%; + width: 100%; +} + +.white-space-normal { + white-space: normal !important; +} + .btn-group button { - margin: 3px; + margin: 3px; } .messages { - max-height: 50px; - overflow-x: hidden; - overflow-y: scroll; + max-height: 50px; + overflow-x: hidden; + overflow-y: scroll; } .legend .title { - padding: 0 0.3em; - margin: 0.5em; - border-style: solid; - border-width: 0 0 0 1em; + padding: 0 0.3em; + margin: 0.5em; + border-style: solid; + border-width: 0 0 0 1em; } .logo { @@ -203,6 +212,48 @@ input[type="radio"] { margin-bottom: 5px; } +.login-wrapper { + margin-top: 25px; + height: 100%; + width: 100%; + display: flex; + align-items: center; +} + +.login-box { + margin-bottom: 80px; +} + +.login-box > div:first-child { + padding-bottom: 10px; +} + +.login-logo { + display: block; + margin: auto; + position: relative; + width: 240px; + margin-bottom: 10px; +} + +.login-form > div { + margin-bottom: 25px; +} + +.login-form > div:last-child { + margin-top: 10px; + margin-bottom: 10px; +} + +.panel-body { + padding-top: 30px; + background-color: #ffffff; +} + .pagination-controls { margin-left: 10px; } + +.user-box { + margin-right: 25px; +} diff --git a/assets/images/logo_alt.png b/assets/images/logo_alt.png new file mode 100644 index 0000000000000000000000000000000000000000..63318532ca832c9c21f8a43dda48758c895de9bc GIT binary patch literal 23441 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 portainer-checksum.txt', 'mkdir -p dist', 'mv api/portainer dist/' @@ -303,7 +305,7 @@ module.exports = function (grunt) { }, buildUnixArmBinary: { command: [ - 'docker run --rm -v $(pwd)/api:/src -e BUILD_GOOS="linux" -e BUILD_GOARCH="arm" centurylink/golang-builder-cross', + 'docker run --rm -v $(pwd)/api:/src -e BUILD_GOOS="linux" -e BUILD_GOARCH="arm" portainer/golang-builder:cross-platform', 'shasum api/portainer-linux-arm > portainer-checksum.txt', 'mkdir -p dist', 'mv api/portainer-linux-arm dist/portainer' @@ -311,7 +313,7 @@ module.exports = function (grunt) { }, buildDarwinBinary: { command: [ - 'docker run --rm -v $(pwd)/api:/src -e BUILD_GOOS="darwin" -e BUILD_GOARCH="amd64" centurylink/golang-builder-cross', + 'docker run --rm -v $(pwd)/api:/src -e BUILD_GOOS="darwin" -e BUILD_GOARCH="amd64" portainer/golang-builder:cross-platform', 'shasum api/portainer-darwin-amd64 > portainer-checksum.txt', 'mkdir -p dist', 'mv api/portainer-darwin-amd64 dist/portainer' @@ -319,7 +321,7 @@ module.exports = function (grunt) { }, buildWindowsBinary: { command: [ - 'docker run --rm -v $(pwd)/api:/src -e BUILD_GOOS="windows" -e BUILD_GOARCH="amd64" centurylink/golang-builder-cross', + 'docker run --rm -v $(pwd)/api:/src -e BUILD_GOOS="windows" -e BUILD_GOARCH="amd64" portainer/golang-builder:cross-platform', 'shasum api/portainer-windows-amd64 > portainer-checksum.txt', 'mkdir -p dist', 'mv api/portainer-windows-amd64 dist/portainer.exe' diff --git a/index.html b/index.html index 726d1243d..09c2ae1c9 100644 --- a/index.html +++ b/index.html @@ -24,67 +24,16 @@ - -

+ +
- - - +
-
+
From 5b16deb73e200caa33a22e40734077a99e44df88 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Fri, 16 Dec 2016 13:39:24 +1300 Subject: [PATCH 07/20] fix(templates): fix an issue with template creation introduced with #384 --- app/components/templates/templatesController.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/components/templates/templatesController.js b/app/components/templates/templatesController.js index 261ad96ad..7961b921e 100644 --- a/app/components/templates/templatesController.js +++ b/app/components/templates/templatesController.js @@ -1,6 +1,6 @@ angular.module('templates', []) -.controller('TemplatesController', ['$scope', '$q', '$state', '$filter', '$anchorScroll', 'Config', 'Info', 'Container', 'ContainerHelper', 'Image', 'Volume', 'Network', 'Templates', 'TemplateHelper', 'Messages', 'Settings', -function ($scope, $q, $state, $filter, $anchorScroll, Config, Info, Container, ContainerHelper, Image, Volume, Network, Templates, TemplateHelper, Messages, Settings) { +.controller('TemplatesController', ['$scope', '$q', '$state', '$filter', '$anchorScroll', 'Config', 'Info', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'Network', 'Templates', 'TemplateHelper', 'Messages', 'Settings', +function ($scope, $q, $state, $filter, $anchorScroll, Config, Info, Container, ContainerHelper, Image, ImageHelper, Volume, Network, Templates, TemplateHelper, Messages, Settings) { $scope.state = { selectedTemplate: null, showAdvancedOptions: false @@ -135,7 +135,7 @@ function ($scope, $q, $state, $filter, $anchorScroll, Config, Info, Container, C function prepareImageConfig(config, template) { var image = _.toLower(template.image); - var registry = template.registry; + var registry = template.registry || ''; var imageConfig = ImageHelper.createImageConfigForContainer(image, registry); config.Image = imageConfig.fromImage + ':' + imageConfig.tag; $scope.imageConfig = imageConfig; From d9f6124609c41419b0c1fbd3f984774f66e71378 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Fri, 16 Dec 2016 14:00:57 +1300 Subject: [PATCH 08/20] refactor(global): remove useless code related to CSRF (#387) --- api/csrf.go | 48 ------------------------------------------------ api/handler.go | 7 +------ app/app.js | 7 ------- 3 files changed, 1 insertion(+), 61 deletions(-) delete mode 100644 api/csrf.go diff --git a/api/csrf.go b/api/csrf.go deleted file mode 100644 index 4377ee343..000000000 --- a/api/csrf.go +++ /dev/null @@ -1,48 +0,0 @@ -package main - -import ( - "github.com/gorilla/csrf" - "github.com/gorilla/securecookie" - "io/ioutil" - "log" - "net/http" -) - -const keyFile = "authKey.dat" - -// newAuthKey reuses an existing CSRF authkey if present or generates a new one -func newAuthKey(path string) []byte { - var authKey []byte - authKeyPath := path + "/" + keyFile - data, err := ioutil.ReadFile(authKeyPath) - if err != nil { - log.Print("Unable to find an existing CSRF auth key. Generating a new key.") - authKey = securecookie.GenerateRandomKey(32) - err := ioutil.WriteFile(authKeyPath, authKey, 0644) - if err != nil { - log.Fatal("Unable to persist CSRF auth key.") - log.Fatal(err) - } - } else { - authKey = data - } - return authKey -} - -// newCSRF initializes a new CSRF handler -func newCSRFHandler(keyPath string) func(h http.Handler) http.Handler { - authKey := newAuthKey(keyPath) - return csrf.Protect( - authKey, - csrf.HttpOnly(false), - csrf.Secure(false), - ) -} - -// newCSRFWrapper wraps a http.Handler to add the CSRF token -func newCSRFWrapper(h http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("X-CSRF-Token", csrf.Token(r)) - h.ServeHTTP(w, r) - }) -} diff --git a/api/handler.go b/api/handler.go index f0d902659..e8d7ad831 100644 --- a/api/handler.go +++ b/api/handler.go @@ -10,7 +10,7 @@ import ( "os" ) -// newHandler creates a new http.Handler with CSRF protection +// newHandler creates a new http.Handler func (a *api) newHandler(settings *Settings) http.Handler { var ( mux = mux.NewRouter() @@ -37,14 +37,9 @@ func (a *api) newHandler(settings *Settings) http.Handler { mux.HandleFunc("/templates", func(w http.ResponseWriter, r *http.Request) { templatesHandler(w, r, a.templatesURL) }) - // mux.PathPrefix("/dockerapi/").Handler(http.StripPrefix("/dockerapi", handler)) mux.PathPrefix("/dockerapi/").Handler(http.StripPrefix("/dockerapi", addMiddleware(handler, a.authenticate, secureHeaders))) - mux.PathPrefix("/").Handler(http.StripPrefix("/", fileHandler)) - // CSRF protection is disabled for the moment - // CSRFHandler := newCSRFHandler(a.dataPath) - // return CSRFHandler(newCSRFWrapper(mux)) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mux.ServeHTTP(w, r) }) diff --git a/app/app.js b/app/app.js index 144f407d8..ea6c6c24c 100644 --- a/app/app.js +++ b/app/app.js @@ -464,8 +464,6 @@ angular.module('portainer', [ }); // The Docker API likes to return plaintext errors, this catches them and disp - // $httpProvider.defaults.xsrfCookieName = 'csrfToken'; - // $httpProvider.defaults.xsrfHeaderName = 'X-CSRF-Token'; $httpProvider.interceptors.push(function() { return { 'response': function(response) { @@ -477,11 +475,6 @@ angular.module('portainer', [ time: 10000 }); } - // CSRF protection is disabled for the moment - // var csrfToken = response.headers('X-Csrf-Token'); - // if (csrfToken) { - // document.cookie = 'csrfToken=' + csrfToken; - // } return response; } }; From 0a38bba87449e864f91eeb2e8d9de3c399a721ec Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Sun, 18 Dec 2016 18:21:29 +1300 Subject: [PATCH 09/20] refactor(api): API overhaul (#392) --- .gitignore | 1 + api/api.go | 102 -------- api/auth.go | 88 ------- api/bolt/datastore.go | 58 ++++ api/bolt/internal/internal.go | 17 ++ api/bolt/user_service.go | 56 ++++ api/cli/cli.go | 59 +++++ api/{flags.go => cli/pairlist.go} | 26 +- api/cmd/portainer/main.go | 70 +++++ api/crypto/crypto.go | 22 ++ api/datastore.go | 98 ------- api/errors.go | 28 ++ api/exec.go | 24 -- api/handler.go | 92 ------- api/http/auth_handler.go | 95 +++++++ api/http/docker_handler.go | 115 ++++++++ api/http/handler.go | 76 ++++++ api/{ => http}/middleware.go | 56 ++-- api/http/server.go | 53 ++++ api/http/settings_handler.go | 39 +++ api/http/templates_handler.go | 55 ++++ api/{ssl.go => http/tls.go} | 15 +- api/http/user_handler.go | 247 ++++++++++++++++++ api/{hijack.go => http/websocket_handler.go} | 63 ++++- api/jwt.go | 29 -- api/jwt/jwt.go | 66 +++++ api/main.go | 52 ---- api/portainer.go | 92 +++++++ api/settings.go | 18 -- api/templates.go | 27 -- api/unix_handler.go | 47 ---- api/users.go | 219 ---------------- app/app.js | 9 +- .../containerConsoleController.js | 2 +- app/shared/services.js | 4 +- gruntFile.js | 24 +- 36 files changed, 1275 insertions(+), 869 deletions(-) delete mode 100644 api/api.go delete mode 100644 api/auth.go create mode 100644 api/bolt/datastore.go create mode 100644 api/bolt/internal/internal.go create mode 100644 api/bolt/user_service.go create mode 100644 api/cli/cli.go rename api/{flags.go => cli/pairlist.go} (51%) create mode 100644 api/cmd/portainer/main.go create mode 100644 api/crypto/crypto.go delete mode 100644 api/datastore.go create mode 100644 api/errors.go delete mode 100644 api/exec.go delete mode 100644 api/handler.go create mode 100644 api/http/auth_handler.go create mode 100644 api/http/docker_handler.go create mode 100644 api/http/handler.go rename api/{ => http}/middleware.go (50%) create mode 100644 api/http/server.go create mode 100644 api/http/settings_handler.go create mode 100644 api/http/templates_handler.go rename api/{ssl.go => http/tls.go} (54%) create mode 100644 api/http/user_handler.go rename api/{hijack.go => http/websocket_handler.go} (60%) delete mode 100644 api/jwt.go create mode 100644 api/jwt/jwt.go delete mode 100644 api/main.go create mode 100644 api/portainer.go delete mode 100644 api/settings.go delete mode 100644 api/templates.go delete mode 100644 api/unix_handler.go delete mode 100644 api/users.go diff --git a/.gitignore b/.gitignore index 61fe02d69..aef04ade6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules bower_components dist portainer-checksum.txt +api/cmd/portainer/portainer-* diff --git a/api/api.go b/api/api.go deleted file mode 100644 index 5b71f7fdf..000000000 --- a/api/api.go +++ /dev/null @@ -1,102 +0,0 @@ -package main - -import ( - "crypto/tls" - "errors" - "github.com/gorilla/securecookie" - "log" - "net/http" - "net/url" -) - -type ( - api struct { - endpoint *url.URL - bindAddress string - assetPath string - dataPath string - tlsConfig *tls.Config - templatesURL string - dataStore *dataStore - secret []byte - } - - apiConfig struct { - Endpoint string - BindAddress string - AssetPath string - DataPath string - SwarmSupport bool - TLSEnabled bool - TLSCACertPath string - TLSCertPath string - TLSKeyPath string - TemplatesURL string - } -) - -const ( - datastoreFileName = "portainer.db" -) - -var ( - errSecretKeyGeneration = errors.New("Unable to generate secret key to sign JWT") -) - -func (a *api) run(settings *Settings) { - err := a.initDatabase() - if err != nil { - log.Fatal(err) - } - defer a.cleanUp() - - handler := a.newHandler(settings) - log.Printf("Starting portainer on %s", a.bindAddress) - if err := http.ListenAndServe(a.bindAddress, handler); err != nil { - log.Fatal(err) - } -} - -func (a *api) cleanUp() { - a.dataStore.cleanUp() -} - -func (a *api) initDatabase() error { - dataStore, err := newDataStore(a.dataPath + "/" + datastoreFileName) - if err != nil { - return err - } - err = dataStore.initDataStore() - if err != nil { - return err - } - a.dataStore = dataStore - return nil -} - -func newAPI(apiConfig apiConfig) *api { - endpointURL, err := url.Parse(apiConfig.Endpoint) - if err != nil { - log.Fatal(err) - } - - secret := securecookie.GenerateRandomKey(32) - if secret == nil { - log.Fatal(errSecretKeyGeneration) - } - - var tlsConfig *tls.Config - if apiConfig.TLSEnabled { - tlsConfig = newTLSConfig(apiConfig.TLSCACertPath, apiConfig.TLSCertPath, apiConfig.TLSKeyPath) - } - - return &api{ - endpoint: endpointURL, - bindAddress: apiConfig.BindAddress, - assetPath: apiConfig.AssetPath, - dataPath: apiConfig.DataPath, - tlsConfig: tlsConfig, - templatesURL: apiConfig.TemplatesURL, - secret: secret, - } -} diff --git a/api/auth.go b/api/auth.go deleted file mode 100644 index 74355c00b..000000000 --- a/api/auth.go +++ /dev/null @@ -1,88 +0,0 @@ -package main - -import ( - "encoding/json" - "github.com/asaskevich/govalidator" - "golang.org/x/crypto/bcrypt" - "io/ioutil" - "log" - "net/http" -) - -type ( - credentials struct { - Username string `valid:"alphanum,required"` - Password string `valid:"length(8)"` - } - authResponse struct { - JWT string `json:"jwt"` - } -) - -func hashPassword(password string) (string, error) { - hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - if err != nil { - return "", nil - } - return string(hash), nil -} - -func checkPasswordValidity(password string, hash string) error { - return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) -} - -// authHandler defines a handler function used to authenticate users -func (api *api) authHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - w.Header().Set("Allow", "POST") - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - body, err := ioutil.ReadAll(r.Body) - if err != nil { - http.Error(w, "Unable to parse request body", http.StatusBadRequest) - return - } - - var credentials credentials - err = json.Unmarshal(body, &credentials) - if err != nil { - http.Error(w, "Unable to parse credentials", http.StatusBadRequest) - return - } - - _, err = govalidator.ValidateStruct(credentials) - if err != nil { - http.Error(w, "Invalid credentials format", http.StatusBadRequest) - return - } - - var username = credentials.Username - var password = credentials.Password - u, err := api.dataStore.getUserByUsername(username) - if err != nil { - log.Printf("User not found: %s", username) - http.Error(w, "User not found", http.StatusNotFound) - return - } - - err = checkPasswordValidity(password, u.Password) - if err != nil { - log.Printf("Invalid credentials for user: %s", username) - http.Error(w, "Invalid credentials", http.StatusUnprocessableEntity) - return - } - - token, err := api.generateJWTToken(username) - if err != nil { - log.Printf("Unable to generate JWT token: %s", err.Error()) - http.Error(w, "Unable to generate JWT token", http.StatusInternalServerError) - return - } - - response := authResponse{ - JWT: token, - } - json.NewEncoder(w).Encode(response) -} diff --git a/api/bolt/datastore.go b/api/bolt/datastore.go new file mode 100644 index 000000000..2f02cd12a --- /dev/null +++ b/api/bolt/datastore.go @@ -0,0 +1,58 @@ +package bolt + +import ( + "github.com/boltdb/bolt" + "time" +) + +// Store defines the implementation of portainer.DataStore using +// BoltDB as the storage system. +type Store struct { + // Path where is stored the BoltDB database. + Path string + + // Services + UserService *UserService + + db *bolt.DB +} + +const ( + databaseFileName = "portainer.db" + userBucketName = "users" +) + +// NewStore initializes a new Store and the associated services +func NewStore(storePath string) *Store { + store := &Store{ + Path: storePath, + UserService: &UserService{}, + } + store.UserService.store = store + return store +} + +// Open opens and initializes the BoltDB database. +func (store *Store) Open() error { + path := store.Path + "/" + databaseFileName + db, err := bolt.Open(path, 0600, &bolt.Options{Timeout: 1 * time.Second}) + if err != nil { + return err + } + store.db = db + return db.Update(func(tx *bolt.Tx) error { + _, err := tx.CreateBucketIfNotExists([]byte(userBucketName)) + if err != nil { + return err + } + return nil + }) +} + +// Close closes the BoltDB database. +func (store *Store) Close() error { + if store.db != nil { + return store.db.Close() + } + return nil +} diff --git a/api/bolt/internal/internal.go b/api/bolt/internal/internal.go new file mode 100644 index 000000000..db11bb9f4 --- /dev/null +++ b/api/bolt/internal/internal.go @@ -0,0 +1,17 @@ +package internal + +import ( + "github.com/portainer/portainer" + + "encoding/json" +) + +// MarshalUser encodes a user to binary format. +func MarshalUser(user *portainer.User) ([]byte, error) { + return json.Marshal(user) +} + +// UnmarshalUser decodes a user from a binary data. +func UnmarshalUser(data []byte, user *portainer.User) error { + return json.Unmarshal(data, user) +} diff --git a/api/bolt/user_service.go b/api/bolt/user_service.go new file mode 100644 index 000000000..0171c3e33 --- /dev/null +++ b/api/bolt/user_service.go @@ -0,0 +1,56 @@ +package bolt + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +// UserService represents a service for managing users. +type UserService struct { + store *Store +} + +// User returns a user by username. +func (service *UserService) User(username string) (*portainer.User, error) { + var data []byte + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(userBucketName)) + value := bucket.Get([]byte(username)) + if value == nil { + return portainer.ErrUserNotFound + } + + data = make([]byte, len(value)) + copy(data, value) + return nil + }) + if err != nil { + return nil, err + } + + var user portainer.User + err = internal.UnmarshalUser(data, &user) + if err != nil { + return nil, err + } + return &user, nil +} + +// UpdateUser saves a user. +func (service *UserService) UpdateUser(user *portainer.User) error { + data, err := internal.MarshalUser(user) + if err != nil { + return err + } + + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(userBucketName)) + err = bucket.Put([]byte(user.Username), data) + if err != nil { + return err + } + return nil + }) +} diff --git a/api/cli/cli.go b/api/cli/cli.go new file mode 100644 index 000000000..6dcea483a --- /dev/null +++ b/api/cli/cli.go @@ -0,0 +1,59 @@ +package cli + +import ( + "github.com/portainer/portainer" + + "gopkg.in/alecthomas/kingpin.v2" + "os" + "strings" +) + +// Service implements the CLIService interface +type Service struct{} + +const ( + errInvalidEnpointProtocol = portainer.Error("Invalid endpoint protocol: Portainer only supports unix:// or tcp://") + errSocketNotFound = portainer.Error("Unable to locate Unix socket") +) + +// ParseFlags parse the CLI flags and return a portainer.Flags struct +func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) { + kingpin.Version(version) + + flags := &portainer.CLIFlags{ + Addr: kingpin.Flag("bind", "Address and port to serve Portainer").Default(":9000").Short('p').String(), + Assets: kingpin.Flag("assets", "Path to the assets").Default(".").Short('a').String(), + Data: kingpin.Flag("data", "Path to the folder where the data is stored").Default("/data").Short('d').String(), + Endpoint: kingpin.Flag("host", "Dockerd endpoint").Default("unix:///var/run/docker.sock").Short('H').String(), + Labels: pairs(kingpin.Flag("hide-label", "Hide containers with a specific label in the UI").Short('l')), + Logo: kingpin.Flag("logo", "URL for the logo displayed in the UI").String(), + Swarm: kingpin.Flag("swarm", "Swarm cluster support").Default("false").Short('s').Bool(), + Templates: kingpin.Flag("templates", "URL to the templates (apps) definitions").Default("https://raw.githubusercontent.com/portainer/templates/master/templates.json").Short('t').String(), + TLSVerify: kingpin.Flag("tlsverify", "TLS support").Default("false").Bool(), + TLSCacert: kingpin.Flag("tlscacert", "Path to the CA").Default("/certs/ca.pem").String(), + TLSCert: kingpin.Flag("tlscert", "Path to the TLS certificate file").Default("/certs/cert.pem").String(), + TLSKey: kingpin.Flag("tlskey", "Path to the TLS key").Default("/certs/key.pem").String(), + } + + kingpin.Parse() + return flags, nil +} + +// ValidateFlags validates the values of the flags. +func (*Service) ValidateFlags(flags *portainer.CLIFlags) error { + if !strings.HasPrefix(*flags.Endpoint, "unix://") && !strings.HasPrefix(*flags.Endpoint, "tcp://") { + return errInvalidEnpointProtocol + } + + if strings.HasPrefix(*flags.Endpoint, "unix://") { + socketPath := strings.TrimPrefix(*flags.Endpoint, "unix://") + if _, err := os.Stat(socketPath); err != nil { + if os.IsNotExist(err) { + return errSocketNotFound + } + return err + } + } + + return nil +} diff --git a/api/flags.go b/api/cli/pairlist.go similarity index 51% rename from api/flags.go rename to api/cli/pairlist.go index 47578a748..7c1d4ea58 100644 --- a/api/flags.go +++ b/api/cli/pairlist.go @@ -1,46 +1,40 @@ -package main +package cli import ( + "github.com/portainer/portainer" + "fmt" "gopkg.in/alecthomas/kingpin.v2" "strings" ) -// pair defines a key/value pair -type pair struct { - Name string `json:"name"` - Value string `json:"value"` -} +type pairList []portainer.Pair -// pairList defines an array of Label -type pairList []pair - -// Set implementation for Labels +// Set implementation for a list of portainer.Pair func (l *pairList) Set(value string) error { parts := strings.SplitN(value, "=", 2) if len(parts) != 2 { return fmt.Errorf("expected NAME=VALUE got '%s'", value) } - p := new(pair) + p := new(portainer.Pair) p.Name = parts[0] p.Value = parts[1] *l = append(*l, *p) return nil } -// String implementation for Labels +// String implementation for a list of pair func (l *pairList) String() string { return "" } -// IsCumulative implementation for Labels +// IsCumulative implementation for a list of pair func (l *pairList) IsCumulative() bool { return true } -// LabelParser defines a custom parser for Labels flags -func pairs(s kingpin.Settings) (target *[]pair) { - target = new([]pair) +func pairs(s kingpin.Settings) (target *[]portainer.Pair) { + target = new([]portainer.Pair) s.SetValue((*pairList)(target)) return } diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go new file mode 100644 index 000000000..48cb6fa72 --- /dev/null +++ b/api/cmd/portainer/main.go @@ -0,0 +1,70 @@ +package main // import "github.com/portainer/portainer" + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt" + "github.com/portainer/portainer/cli" + "github.com/portainer/portainer/crypto" + "github.com/portainer/portainer/http" + "github.com/portainer/portainer/jwt" + + "log" +) + +func main() { + var cli portainer.CLIService = &cli.Service{} + flags, err := cli.ParseFlags(portainer.APIVersion) + if err != nil { + log.Fatal(err) + } + + err = cli.ValidateFlags(flags) + if err != nil { + log.Fatal(err) + } + + settings := &portainer.Settings{ + Swarm: *flags.Swarm, + HiddenLabels: *flags.Labels, + Logo: *flags.Logo, + } + + var store = bolt.NewStore(*flags.Data) + err = store.Open() + if err != nil { + log.Fatal(err) + } + defer store.Close() + + jwtService, err := jwt.NewService() + if err != nil { + log.Fatal(err) + } + + var cryptoService portainer.CryptoService = &crypto.Service{} + + endpointConfiguration := &portainer.EndpointConfiguration{ + Endpoint: *flags.Endpoint, + TLS: *flags.TLSVerify, + TLSCACertPath: *flags.TLSCacert, + TLSCertPath: *flags.TLSCert, + TLSKeyPath: *flags.TLSKey, + } + + var server portainer.Server = &http.Server{ + BindAddress: *flags.Addr, + AssetsPath: *flags.Assets, + Settings: settings, + TemplatesURL: *flags.Templates, + UserService: store.UserService, + CryptoService: cryptoService, + JWTService: jwtService, + EndpointConfig: endpointConfiguration, + } + + log.Printf("Starting Portainer on %s", *flags.Addr) + err = server.Start() + if err != nil { + log.Fatal(err) + } +} diff --git a/api/crypto/crypto.go b/api/crypto/crypto.go new file mode 100644 index 000000000..3e52dfbd3 --- /dev/null +++ b/api/crypto/crypto.go @@ -0,0 +1,22 @@ +package crypto + +import ( + "golang.org/x/crypto/bcrypt" +) + +// Service represents a service for encrypting/hashing data. +type Service struct{} + +// Hash hashes a string using the bcrypt algorithm +func (*Service) Hash(data string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(data), bcrypt.DefaultCost) + if err != nil { + return "", nil + } + return string(hash), nil +} + +// CompareHashAndData compares a hash to clear data and returns an error if the comparison fails. +func (*Service) CompareHashAndData(hash string, data string) error { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(data)) +} diff --git a/api/datastore.go b/api/datastore.go deleted file mode 100644 index 58efd7f5e..000000000 --- a/api/datastore.go +++ /dev/null @@ -1,98 +0,0 @@ -package main - -import ( - "encoding/json" - "errors" - "github.com/boltdb/bolt" -) - -const ( - userBucketName = "users" -) - -type ( - dataStore struct { - db *bolt.DB - } - - userItem struct { - Username string `json:"username"` - Password string `json:"password,omitempty"` - } -) - -var ( - errUserNotFound = errors.New("User not found") -) - -func (dataStore *dataStore) initDataStore() error { - return dataStore.db.Update(func(tx *bolt.Tx) error { - _, err := tx.CreateBucketIfNotExists([]byte(userBucketName)) - if err != nil { - return err - } - return nil - }) -} - -func (dataStore *dataStore) cleanUp() { - dataStore.db.Close() -} - -func newDataStore(databasePath string) (*dataStore, error) { - db, err := bolt.Open(databasePath, 0600, nil) - if err != nil { - return nil, err - } - - return &dataStore{ - db: db, - }, nil -} - -func (dataStore *dataStore) getUserByUsername(username string) (*userItem, error) { - var data []byte - - err := dataStore.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(userBucketName)) - value := bucket.Get([]byte(username)) - if value == nil { - return errUserNotFound - } - - data = make([]byte, len(value)) - copy(data, value) - return nil - }) - if err != nil { - return nil, err - } - - var user userItem - err = json.Unmarshal(data, &user) - if err != nil { - return nil, err - } - return &user, nil -} - -func (dataStore *dataStore) updateUser(user userItem) error { - buffer, err := json.Marshal(user) - if err != nil { - return err - } - - err = dataStore.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(userBucketName)) - err = bucket.Put([]byte(user.Username), buffer) - if err != nil { - return err - } - return nil - }) - - if err != nil { - return err - } - return nil -} diff --git a/api/errors.go b/api/errors.go new file mode 100644 index 000000000..fa59de7f2 --- /dev/null +++ b/api/errors.go @@ -0,0 +1,28 @@ +package portainer + +// General errors. +const ( + ErrUnauthorized = Error("Unauthorized") +) + +// User errors. +const ( + ErrUserNotFound = Error("User not found") +) + +// Crypto errors. +const ( + ErrCryptoHashFailure = Error("Unable to hash data") +) + +// JWT errors. +const ( + ErrSecretGeneration = Error("Unable to generate secret key") + ErrInvalidJWTToken = Error("Invalid JWT token") +) + +// Error represents an application error. +type Error string + +// Error returns the error message. +func (e Error) Error() string { return string(e) } diff --git a/api/exec.go b/api/exec.go deleted file mode 100644 index 4b139aeb7..000000000 --- a/api/exec.go +++ /dev/null @@ -1,24 +0,0 @@ -package main - -import ( - "golang.org/x/net/websocket" - "log" -) - -// execContainer is used to create a websocket communication with an exec instance -func (a *api) execContainer(ws *websocket.Conn) { - qry := ws.Request().URL.Query() - execID := qry.Get("id") - - var host string - if a.endpoint.Scheme == "tcp" { - host = a.endpoint.Host - } else if a.endpoint.Scheme == "unix" { - host = a.endpoint.Path - } - - if err := hijack(host, a.endpoint.Scheme, "POST", "/exec/"+execID+"/start", a.tlsConfig, true, ws, ws, ws, nil, nil); err != nil { - log.Fatalf("error during hijack: %s", err) - return - } -} diff --git a/api/handler.go b/api/handler.go deleted file mode 100644 index e8d7ad831..000000000 --- a/api/handler.go +++ /dev/null @@ -1,92 +0,0 @@ -package main - -import ( - "github.com/gorilla/mux" - "golang.org/x/net/websocket" - "log" - "net/http" - "net/http/httputil" - "net/url" - "os" -) - -// newHandler creates a new http.Handler -func (a *api) newHandler(settings *Settings) http.Handler { - var ( - mux = mux.NewRouter() - fileHandler = http.FileServer(http.Dir(a.assetPath)) - ) - handler := a.newAPIHandler() - - mux.Handle("/ws/exec", websocket.Handler(a.execContainer)) - mux.HandleFunc("/auth", a.authHandler) - mux.Handle("/users", addMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - a.usersHandler(w, r) - }), a.authenticate, secureHeaders)) - mux.Handle("/users/{username}", addMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - a.userHandler(w, r) - }), a.authenticate, secureHeaders)) - mux.Handle("/users/{username}/passwd", addMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - a.userPasswordHandler(w, r) - }), a.authenticate, secureHeaders)) - mux.HandleFunc("/users/admin/check", a.checkAdminHandler) - mux.HandleFunc("/users/admin/init", a.initAdminHandler) - mux.HandleFunc("/settings", func(w http.ResponseWriter, r *http.Request) { - settingsHandler(w, r, settings) - }) - mux.HandleFunc("/templates", func(w http.ResponseWriter, r *http.Request) { - templatesHandler(w, r, a.templatesURL) - }) - mux.PathPrefix("/dockerapi/").Handler(http.StripPrefix("/dockerapi", addMiddleware(handler, a.authenticate, secureHeaders))) - mux.PathPrefix("/").Handler(http.StripPrefix("/", fileHandler)) - - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - mux.ServeHTTP(w, r) - }) -} - -// newAPIHandler initializes a new http.Handler based on the URL scheme -func (a *api) newAPIHandler() http.Handler { - var handler http.Handler - var endpoint = *a.endpoint - if endpoint.Scheme == "tcp" { - if a.tlsConfig != nil { - handler = a.newTCPHandlerWithTLS(&endpoint) - } else { - handler = a.newTCPHandler(&endpoint) - } - } else if endpoint.Scheme == "unix" { - socketPath := endpoint.Path - if _, err := os.Stat(socketPath); err != nil { - if os.IsNotExist(err) { - log.Fatalf("Unix socket %s does not exist", socketPath) - } - log.Fatal(err) - } - handler = a.newUnixHandler(socketPath) - } else { - log.Fatalf("Bad Docker enpoint: %v. Only unix:// and tcp:// are supported.", &endpoint) - } - return handler -} - -// newUnixHandler initializes a new UnixHandler -func (a *api) newUnixHandler(e string) http.Handler { - return &unixHandler{e} -} - -// newTCPHandler initializes a HTTP reverse proxy -func (a *api) newTCPHandler(u *url.URL) http.Handler { - u.Scheme = "http" - return httputil.NewSingleHostReverseProxy(u) -} - -// newTCPHandlerWithL initializes a HTTPS reverse proxy with a TLS configuration -func (a *api) newTCPHandlerWithTLS(u *url.URL) http.Handler { - u.Scheme = "https" - proxy := httputil.NewSingleHostReverseProxy(u) - proxy.Transport = &http.Transport{ - TLSClientConfig: a.tlsConfig, - } - return proxy -} diff --git a/api/http/auth_handler.go b/api/http/auth_handler.go new file mode 100644 index 000000000..e63412a15 --- /dev/null +++ b/api/http/auth_handler.go @@ -0,0 +1,95 @@ +package http + +import ( + "github.com/portainer/portainer" + + "encoding/json" + "github.com/asaskevich/govalidator" + "github.com/gorilla/mux" + "log" + "net/http" + "os" +) + +// AuthHandler represents an HTTP API handler for managing authentication. +type AuthHandler struct { + *mux.Router + Logger *log.Logger + UserService portainer.UserService + CryptoService portainer.CryptoService + JWTService portainer.JWTService +} + +const ( + // ErrInvalidCredentialsFormat is an error raised when credentials format is not valid + ErrInvalidCredentialsFormat = portainer.Error("Invalid credentials format") + // ErrInvalidCredentials is an error raised when credentials for a user are invalid + ErrInvalidCredentials = portainer.Error("Invalid credentials") +) + +// NewAuthHandler returns a new instance of DialHandler. +func NewAuthHandler() *AuthHandler { + h := &AuthHandler{ + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + } + h.HandleFunc("/auth", h.handlePostAuth) + return h +} + +func (handler *AuthHandler) handlePostAuth(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + handleNotAllowed(w, []string{"POST"}) + return + } + + var req postAuthRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err := govalidator.ValidateStruct(req) + if err != nil { + Error(w, ErrInvalidCredentialsFormat, http.StatusBadRequest, handler.Logger) + return + } + + var username = req.Username + var password = req.Password + + u, err := handler.UserService.User(username) + if err == portainer.ErrUserNotFound { + Error(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + err = handler.CryptoService.CompareHashAndData(u.Password, password) + if err != nil { + Error(w, ErrInvalidCredentials, http.StatusUnprocessableEntity, handler.Logger) + return + } + + tokenData := &portainer.TokenData{ + username, + } + token, err := handler.JWTService.GenerateToken(tokenData) + if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + encodeJSON(w, &postAuthResponse{JWT: token}, handler.Logger) +} + +type postAuthRequest struct { + Username string `valid:"alphanum,required"` + Password string `valid:"required"` +} + +type postAuthResponse struct { + JWT string `json:"jwt"` +} diff --git a/api/http/docker_handler.go b/api/http/docker_handler.go new file mode 100644 index 000000000..bb67a3943 --- /dev/null +++ b/api/http/docker_handler.go @@ -0,0 +1,115 @@ +package http + +import ( + "github.com/portainer/portainer" + + "github.com/gorilla/mux" + "io" + "log" + "net" + "net/http" + "net/http/httputil" + "net/url" + "os" +) + +// DockerHandler represents an HTTP API handler for proxying requests to the Docker API. +type DockerHandler struct { + *mux.Router + Logger *log.Logger + middleWareService *middleWareService + proxy http.Handler +} + +// NewDockerHandler returns a new instance of DockerHandler. +func NewDockerHandler(middleWareService *middleWareService) *DockerHandler { + h := &DockerHandler{ + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + middleWareService: middleWareService, + } + h.PathPrefix("/").Handler(middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h.proxyRequestsToDockerAPI(w, r) + }))) + return h +} + +func (handler *DockerHandler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http.Request) { + handler.proxy.ServeHTTP(w, r) +} + +func (handler *DockerHandler) setupProxy(config *portainer.EndpointConfiguration) error { + var proxy http.Handler + endpointURL, err := url.Parse(config.Endpoint) + if err != nil { + return err + } + if endpointURL.Scheme == "tcp" { + if config.TLS { + proxy, err = newHTTPSProxy(endpointURL, config) + if err != nil { + return err + } + } else { + proxy = newHTTPProxy(endpointURL) + } + } else { + // Assume unix:// scheme + proxy = newSocketProxy(endpointURL.Path) + } + handler.proxy = proxy + return nil +} + +func newHTTPProxy(u *url.URL) http.Handler { + u.Scheme = "http" + return httputil.NewSingleHostReverseProxy(u) +} + +func newHTTPSProxy(u *url.URL, endpointConfig *portainer.EndpointConfiguration) (http.Handler, error) { + u.Scheme = "https" + proxy := httputil.NewSingleHostReverseProxy(u) + config, err := createTLSConfiguration(endpointConfig.TLSCACertPath, endpointConfig.TLSCertPath, endpointConfig.TLSKeyPath) + if err != nil { + return nil, err + } + proxy.Transport = &http.Transport{ + TLSClientConfig: config, + } + return proxy, nil +} + +func newSocketProxy(path string) http.Handler { + return &unixSocketHandler{path} +} + +// unixSocketHandler represents a handler to proxy HTTP requests via a unix:// socket +type unixSocketHandler struct { + path string +} + +func (h *unixSocketHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + conn, err := net.Dial("unix", h.path) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + c := httputil.NewClientConn(conn, nil) + defer c.Close() + + res, err := c.Do(r) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer res.Body.Close() + + for k, vv := range res.Header { + for _, v := range vv { + w.Header().Add(k, v) + } + } + if _, err := io.Copy(w, res.Body); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} diff --git a/api/http/handler.go b/api/http/handler.go new file mode 100644 index 000000000..3bcfdff54 --- /dev/null +++ b/api/http/handler.go @@ -0,0 +1,76 @@ +package http + +import ( + "github.com/portainer/portainer" + + "encoding/json" + "log" + "net/http" + "strings" +) + +// Handler is a collection of all the service handlers. +type Handler struct { + AuthHandler *AuthHandler + UserHandler *UserHandler + SettingsHandler *SettingsHandler + TemplatesHandler *TemplatesHandler + DockerHandler *DockerHandler + WebSocketHandler *WebSocketHandler + FileHandler http.Handler +} + +const ( + // ErrInvalidJSON defines an error raised the app is unable to parse request data + ErrInvalidJSON = portainer.Error("Invalid JSON") + // ErrInvalidRequestFormat defines an error raised when the format of the data sent in a request is not valid + ErrInvalidRequestFormat = portainer.Error("Invalid request data format") +) + +// ServeHTTP delegates a request to the appropriate subhandler. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/auth") { + http.StripPrefix("/api", h.AuthHandler).ServeHTTP(w, r) + } else if strings.HasPrefix(r.URL.Path, "/api/users") { + http.StripPrefix("/api", h.UserHandler).ServeHTTP(w, r) + } else if strings.HasPrefix(r.URL.Path, "/api/settings") { + http.StripPrefix("/api", h.SettingsHandler).ServeHTTP(w, r) + } else if strings.HasPrefix(r.URL.Path, "/api/templates") { + http.StripPrefix("/api", h.TemplatesHandler).ServeHTTP(w, r) + } else if strings.HasPrefix(r.URL.Path, "/api/websocket") { + http.StripPrefix("/api", h.WebSocketHandler).ServeHTTP(w, r) + } else if strings.HasPrefix(r.URL.Path, "/api/docker") { + http.StripPrefix("/api/docker", h.DockerHandler).ServeHTTP(w, r) + } else if strings.HasPrefix(r.URL.Path, "/") { + h.FileHandler.ServeHTTP(w, r) + } +} + +// Error writes an API error message to the response and logger. +func Error(w http.ResponseWriter, err error, code int, logger *log.Logger) { + // Log error. + logger.Printf("http error: %s (code=%d)", err, code) + + // Write generic error response. + w.WriteHeader(code) + json.NewEncoder(w).Encode(&errorResponse{Err: err.Error()}) +} + +// errorResponse is a generic response for sending a error. +type errorResponse struct { + Err string `json:"err,omitempty"` +} + +// handleNotAllowed writes an API error message to the response and sets the Allow header. +func handleNotAllowed(w http.ResponseWriter, allowedMethods []string) { + w.Header().Set("Allow", strings.Join(allowedMethods, ", ")) + w.WriteHeader(http.StatusMethodNotAllowed) + json.NewEncoder(w).Encode(&errorResponse{Err: http.StatusText(http.StatusMethodNotAllowed)}) +} + +// encodeJSON encodes v to w in JSON format. Error() is called if encoding fails. +func encodeJSON(w http.ResponseWriter, v interface{}, logger *log.Logger) { + if err := json.NewEncoder(w).Encode(v); err != nil { + Error(w, err, http.StatusInternalServerError, logger) + } +} diff --git a/api/middleware.go b/api/http/middleware.go similarity index 50% rename from api/middleware.go rename to api/http/middleware.go index da12ae730..99775dec6 100644 --- a/api/middleware.go +++ b/api/http/middleware.go @@ -1,12 +1,17 @@ -package main +package http import ( - "fmt" - "github.com/dgrijalva/jwt-go" + "github.com/portainer/portainer" + "net/http" "strings" ) +// Service represents a service to manage HTTP middlewares +type middleWareService struct { + jwtService portainer.JWTService +} + func addMiddleware(h http.Handler, middleware ...func(http.Handler) http.Handler) http.Handler { for _, mw := range middleware { h = mw(h) @@ -14,13 +19,27 @@ func addMiddleware(h http.Handler, middleware ...func(http.Handler) http.Handler return h } -// authenticate provides Authentication middleware for handlers -func (api *api) authenticate(next http.Handler) http.Handler { +func (service *middleWareService) addMiddleWares(h http.Handler) http.Handler { + h = service.middleWareSecureHeaders(h) + h = service.middleWareAuthenticate(h) + return h +} + +// middleWareAuthenticate provides secure headers middleware for handlers +func (*middleWareService) middleWareSecureHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("X-Content-Type-Options", "nosniff") + w.Header().Add("X-Frame-Options", "DENY") + next.ServeHTTP(w, r) + }) +} + +// middleWareAuthenticate provides Authentication middleware for handlers +func (service *middleWareService) middleWareAuthenticate(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var token string // Get token from the Authorization header - // format: Authorization: Bearer tokens, ok := r.Header["Authorization"] if ok && len(tokens) >= 1 { token = tokens[0] @@ -32,34 +51,13 @@ func (api *api) authenticate(next http.Handler) http.Handler { return } - parsedToken, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) { - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - msg := fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) - return nil, msg - } - return api.secret, nil - }) + err := service.jwtService.VerifyToken(token) if err != nil { - http.Error(w, "Invalid JWT token", http.StatusUnauthorized) + http.Error(w, err.Error(), http.StatusUnauthorized) return } - if parsedToken == nil || !parsedToken.Valid { - http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - - // context.Set(r, "user", parsedToken) next.ServeHTTP(w, r) return }) } - -// SecureHeaders adds secure headers to the API -func secureHeaders(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Add("X-Content-Type-Options", "nosniff") - w.Header().Add("X-Frame-Options", "DENY") - next.ServeHTTP(w, r) - }) -} diff --git a/api/http/server.go b/api/http/server.go new file mode 100644 index 000000000..4f7d4efa1 --- /dev/null +++ b/api/http/server.go @@ -0,0 +1,53 @@ +package http + +import ( + "github.com/portainer/portainer" + + "net/http" +) + +// Server implements the portainer.Server interface +type Server struct { + BindAddress string + AssetsPath string + UserService portainer.UserService + CryptoService portainer.CryptoService + JWTService portainer.JWTService + Settings *portainer.Settings + TemplatesURL string + EndpointConfig *portainer.EndpointConfiguration +} + +// Start starts the HTTP server +func (server *Server) Start() error { + middleWareService := &middleWareService{ + jwtService: server.JWTService, + } + var authHandler = NewAuthHandler() + authHandler.UserService = server.UserService + authHandler.CryptoService = server.CryptoService + authHandler.JWTService = server.JWTService + var userHandler = NewUserHandler(middleWareService) + userHandler.UserService = server.UserService + userHandler.CryptoService = server.CryptoService + var settingsHandler = NewSettingsHandler(middleWareService) + settingsHandler.settings = server.Settings + var templatesHandler = NewTemplatesHandler(middleWareService) + templatesHandler.templatesURL = server.TemplatesURL + var dockerHandler = NewDockerHandler(middleWareService) + dockerHandler.setupProxy(server.EndpointConfig) + var websocketHandler = NewWebSocketHandler() + websocketHandler.endpointConfiguration = server.EndpointConfig + var fileHandler = http.FileServer(http.Dir(server.AssetsPath)) + + handler := &Handler{ + AuthHandler: authHandler, + UserHandler: userHandler, + SettingsHandler: settingsHandler, + TemplatesHandler: templatesHandler, + DockerHandler: dockerHandler, + WebSocketHandler: websocketHandler, + FileHandler: fileHandler, + } + return http.ListenAndServe(server.BindAddress, handler) +} diff --git a/api/http/settings_handler.go b/api/http/settings_handler.go new file mode 100644 index 000000000..4768e1a05 --- /dev/null +++ b/api/http/settings_handler.go @@ -0,0 +1,39 @@ +package http + +import ( + "github.com/portainer/portainer" + + "github.com/gorilla/mux" + "log" + "net/http" + "os" +) + +// SettingsHandler represents an HTTP API handler for managing settings. +type SettingsHandler struct { + *mux.Router + Logger *log.Logger + middleWareService *middleWareService + settings *portainer.Settings +} + +// NewSettingsHandler returns a new instance of SettingsHandler. +func NewSettingsHandler(middleWareService *middleWareService) *SettingsHandler { + h := &SettingsHandler{ + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + middleWareService: middleWareService, + } + h.HandleFunc("/settings", h.handleGetSettings) + return h +} + +// handleGetSettings handles GET requests on /settings +func (handler *SettingsHandler) handleGetSettings(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + handleNotAllowed(w, []string{"GET"}) + return + } + + encodeJSON(w, handler.settings, handler.Logger) +} diff --git a/api/http/templates_handler.go b/api/http/templates_handler.go new file mode 100644 index 000000000..b690012fb --- /dev/null +++ b/api/http/templates_handler.go @@ -0,0 +1,55 @@ +package http + +import ( + "fmt" + "github.com/gorilla/mux" + "io/ioutil" + "log" + "net/http" + "os" +) + +// TemplatesHandler represents an HTTP API handler for managing templates. +type TemplatesHandler struct { + *mux.Router + Logger *log.Logger + middleWareService *middleWareService + templatesURL string +} + +// NewTemplatesHandler returns a new instance of TemplatesHandler. +func NewTemplatesHandler(middleWareService *middleWareService) *TemplatesHandler { + h := &TemplatesHandler{ + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + middleWareService: middleWareService, + } + h.Handle("/templates", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h.handleGetTemplates(w, r) + }))) + return h +} + +// handleGetTemplates handles GET requests on /templates +func (handler *TemplatesHandler) handleGetTemplates(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + handleNotAllowed(w, []string{"GET"}) + return + } + + resp, err := http.Get(handler.templatesURL) + if err != nil { + log.Print(err) + http.Error(w, fmt.Sprintf("Error making request to %s: %s", handler.templatesURL, err.Error()), http.StatusInternalServerError) + return + } + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + log.Print(err) + http.Error(w, "Error reading body from templates URL", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(body) +} diff --git a/api/ssl.go b/api/http/tls.go similarity index 54% rename from api/ssl.go rename to api/http/tls.go index 89f76e85e..20d679ef6 100644 --- a/api/ssl.go +++ b/api/http/tls.go @@ -1,27 +1,26 @@ -package main +package http import ( "crypto/tls" "crypto/x509" "io/ioutil" - "log" ) -// newTLSConfig initializes a tls.Config using a CA certificate, a certificate and a key -func newTLSConfig(caCertPath, certPath, keyPath string) *tls.Config { +// createTLSConfiguration initializes a tls.Config using a CA certificate, a certificate and a key +func createTLSConfiguration(caCertPath, certPath, keyPath string) (*tls.Config, error) { cert, err := tls.LoadX509KeyPair(certPath, keyPath) if err != nil { - log.Fatal(err) + return nil, err } caCert, err := ioutil.ReadFile(caCertPath) if err != nil { - log.Fatal(err) + return nil, err } caCertPool := x509.NewCertPool() caCertPool.AppendCertsFromPEM(caCert) - tlsConfig := &tls.Config{ + config := &tls.Config{ Certificates: []tls.Certificate{cert}, RootCAs: caCertPool, } - return tlsConfig + return config, nil } diff --git a/api/http/user_handler.go b/api/http/user_handler.go new file mode 100644 index 000000000..461130e76 --- /dev/null +++ b/api/http/user_handler.go @@ -0,0 +1,247 @@ +package http + +import ( + "github.com/portainer/portainer" + + "encoding/json" + "github.com/asaskevich/govalidator" + "github.com/gorilla/mux" + "log" + "net/http" + "os" +) + +// UserHandler represents an HTTP API handler for managing users. +type UserHandler struct { + *mux.Router + Logger *log.Logger + UserService portainer.UserService + CryptoService portainer.CryptoService + middleWareService *middleWareService +} + +// NewUserHandler returns a new instance of UserHandler. +func NewUserHandler(middleWareService *middleWareService) *UserHandler { + h := &UserHandler{ + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + middleWareService: middleWareService, + } + h.Handle("/users", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h.handlePostUsers(w, r) + }))) + h.Handle("/users/{username}", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h.handleGetUser(w, r) + }))).Methods("GET") + h.Handle("/users/{username}", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h.handlePutUser(w, r) + }))).Methods("PUT") + h.Handle("/users/{username}/passwd", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h.handlePostUserPasswd(w, r) + }))) + h.HandleFunc("/users/admin/check", h.handleGetAdminCheck) + h.HandleFunc("/users/admin/init", h.handlePostAdminInit) + return h +} + +// handlePostUsers handles POST requests on /users +func (handler *UserHandler) handlePostUsers(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + handleNotAllowed(w, []string{"POST"}) + return + } + + var req postUsersRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err := govalidator.ValidateStruct(req) + if err != nil { + Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + user := &portainer.User{ + Username: req.Username, + } + user.Password, err = handler.CryptoService.Hash(req.Password) + if err != nil { + Error(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger) + return + } + + err = handler.UserService.UpdateUser(user) + if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } +} + +type postUsersRequest struct { + Username string `valid:"alphanum,required"` + Password string `valid:"required"` +} + +// handlePostUserPasswd handles POST requests on /users/:username/passwd +func (handler *UserHandler) handlePostUserPasswd(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + handleNotAllowed(w, []string{"POST"}) + return + } + + vars := mux.Vars(r) + username := vars["username"] + + var req postUserPasswdRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err := govalidator.ValidateStruct(req) + if err != nil { + Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + var password = req.Password + + u, err := handler.UserService.User(username) + if err == portainer.ErrUserNotFound { + Error(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + valid := true + err = handler.CryptoService.CompareHashAndData(u.Password, password) + if err != nil { + valid = false + } + + encodeJSON(w, &postUserPasswdResponse{Valid: valid}, handler.Logger) +} + +type postUserPasswdRequest struct { + Password string `valid:"required"` +} + +type postUserPasswdResponse struct { + Valid bool `json:"valid"` +} + +// handleGetUser handles GET requests on /users/:username +func (handler *UserHandler) handleGetUser(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + username := vars["username"] + + user, err := handler.UserService.User(username) + if err == portainer.ErrUserNotFound { + Error(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + user.Password = "" + encodeJSON(w, &user, handler.Logger) +} + +// handlePutUser handles PUT requests on /users/:username +func (handler *UserHandler) handlePutUser(w http.ResponseWriter, r *http.Request) { + var req putUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err := govalidator.ValidateStruct(req) + if err != nil { + Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + user := &portainer.User{ + Username: req.Username, + } + user.Password, err = handler.CryptoService.Hash(req.Password) + if err != nil { + Error(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger) + return + } + + err = handler.UserService.UpdateUser(user) + if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } +} + +type putUserRequest struct { + Username string `valid:"alphanum,required"` + Password string `valid:"required"` +} + +// handlePostAdminInit handles GET requests on /users/admin/check +func (handler *UserHandler) handleGetAdminCheck(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + handleNotAllowed(w, []string{"GET"}) + return + } + + user, err := handler.UserService.User("admin") + if err == portainer.ErrUserNotFound { + Error(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + user.Password = "" + encodeJSON(w, &user, handler.Logger) +} + +// handlePostAdminInit handles POST requests on /users/admin/init +func (handler *UserHandler) handlePostAdminInit(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + handleNotAllowed(w, []string{"POST"}) + return + } + + var req postAdminInitRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err := govalidator.ValidateStruct(req) + if err != nil { + Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + user := &portainer.User{ + Username: "admin", + } + user.Password, err = handler.CryptoService.Hash(req.Password) + if err != nil { + Error(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger) + return + } + + err = handler.UserService.UpdateUser(user) + if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } +} + +type postAdminInitRequest struct { + Password string `valid:"required"` +} diff --git a/api/hijack.go b/api/http/websocket_handler.go similarity index 60% rename from api/hijack.go rename to api/http/websocket_handler.go index ff6cd9071..365a1ccd1 100644 --- a/api/hijack.go +++ b/api/http/websocket_handler.go @@ -1,17 +1,78 @@ -package main +package http import ( + "github.com/portainer/portainer" + "bytes" "crypto/tls" "encoding/json" "fmt" + "github.com/gorilla/mux" + "golang.org/x/net/websocket" "io" + "log" "net" "net/http" "net/http/httputil" + "net/url" + "os" "time" ) +// WebSocketHandler represents an HTTP API handler for proxying requests to a web socket. +type WebSocketHandler struct { + *mux.Router + Logger *log.Logger + middleWareService *middleWareService + endpointConfiguration *portainer.EndpointConfiguration +} + +// NewWebSocketHandler returns a new instance of WebSocketHandler. +func NewWebSocketHandler() *WebSocketHandler { + h := &WebSocketHandler{ + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + } + h.Handle("/websocket/exec", websocket.Handler(h.webSocketDockerExec)) + return h +} + +func (handler *WebSocketHandler) webSocketDockerExec(ws *websocket.Conn) { + qry := ws.Request().URL.Query() + execID := qry.Get("id") + + // Should not be managed here + endpoint, err := url.Parse(handler.endpointConfiguration.Endpoint) + if err != nil { + log.Fatalf("Unable to parse endpoint URL: %s", err) + return + } + + var host string + if endpoint.Scheme == "tcp" { + host = endpoint.Host + } else if endpoint.Scheme == "unix" { + host = endpoint.Path + } + + // Should not be managed here + var tlsConfig *tls.Config + if handler.endpointConfiguration.TLS { + tlsConfig, err = createTLSConfiguration(handler.endpointConfiguration.TLSCACertPath, + handler.endpointConfiguration.TLSCertPath, + handler.endpointConfiguration.TLSKeyPath) + if err != nil { + log.Fatalf("Unable to create TLS configuration: %s", err) + return + } + } + + if err := hijack(host, endpoint.Scheme, "POST", "/exec/"+execID+"/start", tlsConfig, true, ws, ws, ws, nil, nil); err != nil { + log.Fatalf("error during hijack: %s", err) + return + } +} + type execConfig struct { Tty bool Detach bool diff --git a/api/jwt.go b/api/jwt.go deleted file mode 100644 index 880a23a70..000000000 --- a/api/jwt.go +++ /dev/null @@ -1,29 +0,0 @@ -package main - -import ( - "github.com/dgrijalva/jwt-go" - "time" -) - -type claims struct { - Username string `json:"username"` - jwt.StandardClaims -} - -func (api *api) generateJWTToken(username string) (string, error) { - expireToken := time.Now().Add(time.Hour * 8).Unix() - claims := claims{ - username, - jwt.StandardClaims{ - ExpiresAt: expireToken, - }, - } - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - - signedToken, err := token.SignedString(api.secret) - if err != nil { - return "", err - } - - return signedToken, nil -} diff --git a/api/jwt/jwt.go b/api/jwt/jwt.go new file mode 100644 index 000000000..0971ee5f7 --- /dev/null +++ b/api/jwt/jwt.go @@ -0,0 +1,66 @@ +package jwt + +import ( + "github.com/portainer/portainer" + + "fmt" + "github.com/dgrijalva/jwt-go" + "github.com/gorilla/securecookie" + "time" +) + +// Service represents a service for managing JWT tokens. +type Service struct { + secret []byte +} + +type claims struct { + Username string `json:"username"` + jwt.StandardClaims +} + +// NewService initializes a new service. It will generate a random key that will be used to sign JWT tokens. +func NewService() (*Service, error) { + secret := securecookie.GenerateRandomKey(32) + if secret == nil { + return nil, portainer.ErrSecretGeneration + } + service := &Service{ + secret, + } + return service, nil +} + +// GenerateToken generates a new JWT token. +func (service *Service) GenerateToken(data *portainer.TokenData) (string, error) { + expireToken := time.Now().Add(time.Hour * 8).Unix() + cl := claims{ + data.Username, + jwt.StandardClaims{ + ExpiresAt: expireToken, + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, cl) + + signedToken, err := token.SignedString(service.secret) + if err != nil { + return "", err + } + + return signedToken, nil +} + +// VerifyToken parses a JWT token and verify its validity. It returns an error if token is invalid. +func (service *Service) VerifyToken(token string) error { + parsedToken, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + msg := fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) + return nil, msg + } + return service.secret, nil + }) + if err != nil || parsedToken == nil || !parsedToken.Valid { + return portainer.ErrInvalidJWTToken + } + return nil +} diff --git a/api/main.go b/api/main.go deleted file mode 100644 index a63d5a534..000000000 --- a/api/main.go +++ /dev/null @@ -1,52 +0,0 @@ -package main // import "github.com/portainer/portainer" - -import ( - "gopkg.in/alecthomas/kingpin.v2" -) - -const ( - // Version number of portainer API - Version = "1.10.2" -) - -// main is the entry point of the program -func main() { - kingpin.Version(Version) - var ( - endpoint = kingpin.Flag("host", "Dockerd endpoint").Default("unix:///var/run/docker.sock").Short('H').String() - addr = kingpin.Flag("bind", "Address and port to serve Portainer").Default(":9000").Short('p').String() - assets = kingpin.Flag("assets", "Path to the assets").Default(".").Short('a').String() - data = kingpin.Flag("data", "Path to the folder where the data is stored").Default("/data").Short('d').String() - tlsverify = kingpin.Flag("tlsverify", "TLS support").Default("false").Bool() - tlscacert = kingpin.Flag("tlscacert", "Path to the CA").Default("/certs/ca.pem").String() - tlscert = kingpin.Flag("tlscert", "Path to the TLS certificate file").Default("/certs/cert.pem").String() - tlskey = kingpin.Flag("tlskey", "Path to the TLS key").Default("/certs/key.pem").String() - swarm = kingpin.Flag("swarm", "Swarm cluster support").Default("false").Short('s').Bool() - labels = pairs(kingpin.Flag("hide-label", "Hide containers with a specific label in the UI").Short('l')) - logo = kingpin.Flag("logo", "URL for the logo displayed in the UI").String() - templates = kingpin.Flag("templates", "URL to the templates (apps) definitions").Default("https://raw.githubusercontent.com/portainer/templates/master/templates.json").Short('t').String() - ) - kingpin.Parse() - - apiConfig := apiConfig{ - Endpoint: *endpoint, - BindAddress: *addr, - AssetPath: *assets, - DataPath: *data, - SwarmSupport: *swarm, - TLSEnabled: *tlsverify, - TLSCACertPath: *tlscacert, - TLSCertPath: *tlscert, - TLSKeyPath: *tlskey, - TemplatesURL: *templates, - } - - settings := &Settings{ - Swarm: *swarm, - HiddenLabels: *labels, - Logo: *logo, - } - - api := newAPI(apiConfig) - api.run(settings) -} diff --git a/api/portainer.go b/api/portainer.go new file mode 100644 index 000000000..d9038c630 --- /dev/null +++ b/api/portainer.go @@ -0,0 +1,92 @@ +package portainer + +type ( + // Pair defines a key/value string pair + Pair struct { + Name string `json:"name"` + Value string `json:"value"` + } + + // CLIFlags represents the available flags on the CLI. + CLIFlags struct { + Addr *string + Assets *string + Data *string + Endpoint *string + Labels *[]Pair + Logo *string + Swarm *bool + Templates *string + TLSVerify *bool + TLSCacert *string + TLSCert *string + TLSKey *string + } + + // Settings represents Portainer settings. + Settings struct { + Swarm bool `json:"swarm"` + HiddenLabels []Pair `json:"hiddenLabels"` + Logo string `json:"logo"` + } + + // User represent a user account. + User struct { + Username string `json:"username"` + Password string `json:"password,omitempty"` + } + + // TokenData represents the data embedded in a JWT token. + TokenData struct { + Username string + } + + // EndpointConfiguration represents the data required to connect to a Docker API endpoint. + EndpointConfiguration struct { + Endpoint string + TLS bool + TLSCACertPath string + TLSCertPath string + TLSKeyPath string + } + + // CLIService represents a service for managing CLI. + CLIService interface { + ParseFlags(version string) (*CLIFlags, error) + ValidateFlags(flags *CLIFlags) error + } + + // DataStore defines the interface to manage the data. + DataStore interface { + Open() error + Close() error + } + + // Server defines the interface to serve the data. + Server interface { + Start() error + } + + // UserService represents a service for managing users. + UserService interface { + User(username string) (*User, error) + UpdateUser(user *User) error + } + + // CryptoService represents a service for encrypting/hashing data. + CryptoService interface { + Hash(data string) (string, error) + CompareHashAndData(hash string, data string) error + } + + // JWTService represents a service for managing JWT tokens. + JWTService interface { + GenerateToken(data *TokenData) (string, error) + VerifyToken(token string) error + } +) + +const ( + // APIVersion is the version number of portainer API. + APIVersion = "1.10.2" +) diff --git a/api/settings.go b/api/settings.go deleted file mode 100644 index 2103a0c69..000000000 --- a/api/settings.go +++ /dev/null @@ -1,18 +0,0 @@ -package main - -import ( - "encoding/json" - "net/http" -) - -// Settings defines the settings available under the /settings endpoint -type Settings struct { - Swarm bool `json:"swarm"` - HiddenLabels pairList `json:"hiddenLabels"` - Logo string `json:"logo"` -} - -// settingsHandler defines a handler function used to encode the configuration in JSON -func settingsHandler(w http.ResponseWriter, r *http.Request, s *Settings) { - json.NewEncoder(w).Encode(*s) -} diff --git a/api/templates.go b/api/templates.go deleted file mode 100644 index 7c69a2ee7..000000000 --- a/api/templates.go +++ /dev/null @@ -1,27 +0,0 @@ -package main - -import ( - "fmt" - "io/ioutil" - "log" - "net/http" -) - -// templatesHandler defines a handler function used to retrieve the templates from a URL and put them in the response -func templatesHandler(w http.ResponseWriter, r *http.Request, templatesURL string) { - resp, err := http.Get(templatesURL) - if err != nil { - http.Error(w, fmt.Sprintf("Error making request to %s: %s", templatesURL, err.Error()), http.StatusInternalServerError) - log.Print(err) - return - } - defer resp.Body.Close() - body, err := ioutil.ReadAll(resp.Body) - if err != nil { - http.Error(w, "Error reading body from templates URL", http.StatusInternalServerError) - log.Print(err) - return - } - w.Header().Set("Content-Type", "application/json") - w.Write(body) -} diff --git a/api/unix_handler.go b/api/unix_handler.go deleted file mode 100644 index 15a5119d3..000000000 --- a/api/unix_handler.go +++ /dev/null @@ -1,47 +0,0 @@ -package main - -import ( - "io" - "log" - "net" - "net/http" - "net/http/httputil" -) - -// unixHandler defines a handler holding the path to a socket under UNIX -type unixHandler struct { - path string -} - -// ServeHTTP implementation for unixHandler -func (h *unixHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - conn, err := net.Dial("unix", h.path) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - log.Println(err) - return - } - c := httputil.NewClientConn(conn, nil) - defer c.Close() - - res, err := c.Do(r) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - log.Println(err) - return - } - defer res.Body.Close() - - copyHeader(w.Header(), res.Header) - if _, err := io.Copy(w, res.Body); err != nil { - log.Println(err) - } -} - -func copyHeader(dst, src http.Header) { - for k, vv := range src { - for _, v := range vv { - dst.Add(k, v) - } - } -} diff --git a/api/users.go b/api/users.go deleted file mode 100644 index d0a48aceb..000000000 --- a/api/users.go +++ /dev/null @@ -1,219 +0,0 @@ -package main - -import ( - "encoding/json" - "github.com/gorilla/mux" - "io/ioutil" - "log" - "net/http" -) - -type ( - passwordCheckRequest struct { - Password string `json:"password"` - } - passwordCheckResponse struct { - Valid bool `json:"valid"` - } - initAdminRequest struct { - Password string `json:"password"` - } -) - -// handle /users -// Allowed methods: POST -func (api *api) usersHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - w.Header().Set("Allow", "POST") - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - body, err := ioutil.ReadAll(r.Body) - if err != nil { - http.Error(w, "Unable to parse request body", http.StatusBadRequest) - return - } - - var user userItem - err = json.Unmarshal(body, &user) - if err != nil { - http.Error(w, "Unable to parse user data", http.StatusBadRequest) - return - } - - user.Password, err = hashPassword(user.Password) - if err != nil { - http.Error(w, "Unable to hash user password", http.StatusInternalServerError) - return - } - - err = api.dataStore.updateUser(user) - if err != nil { - log.Printf("Unable to persist user: %s", err.Error()) - http.Error(w, "Unable to persist user", http.StatusInternalServerError) - return - } -} - -// handle /users/admin/check -// Allowed methods: POST -func (api *api) checkAdminHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" { - w.Header().Set("Allow", "GET") - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - user, err := api.dataStore.getUserByUsername("admin") - if err == errUserNotFound { - log.Printf("User not found: %s", "admin") - http.Error(w, "User not found", http.StatusNotFound) - return - } - if err != nil { - log.Printf("Unable to retrieve user: %s", err.Error()) - http.Error(w, "Unable to retrieve user", http.StatusInternalServerError) - return - } - - user.Password = "" - json.NewEncoder(w).Encode(user) -} - -// handle /users/admin/init -// Allowed methods: POST -func (api *api) initAdminHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - w.Header().Set("Allow", "POST") - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - body, err := ioutil.ReadAll(r.Body) - if err != nil { - http.Error(w, "Unable to parse request body", http.StatusBadRequest) - return - } - - var requestData initAdminRequest - err = json.Unmarshal(body, &requestData) - if err != nil { - http.Error(w, "Unable to parse user data", http.StatusBadRequest) - return - } - - user := userItem{ - Username: "admin", - } - user.Password, err = hashPassword(requestData.Password) - if err != nil { - http.Error(w, "Unable to hash user password", http.StatusInternalServerError) - return - } - - err = api.dataStore.updateUser(user) - if err != nil { - log.Printf("Unable to persist user: %s", err.Error()) - http.Error(w, "Unable to persist user", http.StatusInternalServerError) - return - } -} - -// handle /users/{username} -// Allowed methods: PUT, GET -func (api *api) userHandler(w http.ResponseWriter, r *http.Request) { - if r.Method == "PUT" { - body, err := ioutil.ReadAll(r.Body) - if err != nil { - http.Error(w, "Unable to parse request body", http.StatusBadRequest) - return - } - - var user userItem - err = json.Unmarshal(body, &user) - if err != nil { - http.Error(w, "Unable to parse user data", http.StatusBadRequest) - return - } - - user.Password, err = hashPassword(user.Password) - if err != nil { - http.Error(w, "Unable to hash user password", http.StatusInternalServerError) - return - } - - err = api.dataStore.updateUser(user) - if err != nil { - log.Printf("Unable to persist user: %s", err.Error()) - http.Error(w, "Unable to persist user", http.StatusInternalServerError) - return - } - } else if r.Method == "GET" { - vars := mux.Vars(r) - username := vars["username"] - - user, err := api.dataStore.getUserByUsername(username) - if err == errUserNotFound { - log.Printf("User not found: %s", username) - http.Error(w, "User not found", http.StatusNotFound) - return - } - if err != nil { - log.Printf("Unable to retrieve user: %s", err.Error()) - http.Error(w, "Unable to retrieve user", http.StatusInternalServerError) - return - } - - user.Password = "" - json.NewEncoder(w).Encode(user) - } else { - w.Header().Set("Allow", "PUT, GET") - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } -} - -// handle /users/{username}/passwd -// Allowed methods: POST -func (api *api) userPasswordHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - w.Header().Set("Allow", "POST") - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - vars := mux.Vars(r) - username := vars["username"] - - body, err := ioutil.ReadAll(r.Body) - if err != nil { - http.Error(w, "Unable to parse request body", http.StatusBadRequest) - return - } - - var data passwordCheckRequest - err = json.Unmarshal(body, &data) - if err != nil { - http.Error(w, "Unable to parse user data", http.StatusBadRequest) - return - } - - user, err := api.dataStore.getUserByUsername(username) - if err != nil { - log.Printf("Unable to retrieve user: %s", err.Error()) - http.Error(w, "Unable to retrieve user", http.StatusInternalServerError) - return - } - - valid := true - err = checkPasswordValidity(data.Password, user.Password) - if err != nil { - valid = false - } - - response := passwordCheckResponse{ - Valid: valid, - } - json.NewEncoder(w).Encode(response) -} diff --git a/app/app.js b/app/app.js index ea6c6c24c..dc0aa5024 100644 --- a/app/app.js +++ b/app/app.js @@ -498,10 +498,11 @@ angular.module('portainer', [ }]) // This is your docker url that the api will use to make requests // You need to set this to the api endpoint without the port i.e. http://192.168.1.9 - .constant('DOCKER_ENDPOINT', 'dockerapi') + .constant('DOCKER_ENDPOINT', '/api/docker') .constant('DOCKER_PORT', '') // Docker port, leave as an empty string if no port is required. If you have a port, prefix it with a ':' i.e. :4243 - .constant('CONFIG_ENDPOINT', 'settings') - .constant('AUTH_ENDPOINT', 'auth') - .constant('TEMPLATES_ENDPOINT', 'templates') + .constant('CONFIG_ENDPOINT', '/api/settings') + .constant('AUTH_ENDPOINT', '/api/auth') + .constant('USERS_ENDPOINT', '/api/users') + .constant('TEMPLATES_ENDPOINT', '/api/templates') .constant('PAGINATION_MAX_ITEMS', 10) .constant('UI_VERSION', 'v1.10.2'); diff --git a/app/components/containerConsole/containerConsoleController.js b/app/components/containerConsole/containerConsoleController.js index 1804ef1ca..d81bff349 100644 --- a/app/components/containerConsole/containerConsoleController.js +++ b/app/components/containerConsole/containerConsoleController.js @@ -55,7 +55,7 @@ function ($scope, $stateParams, Settings, Container, Image, Exec, $timeout, Mess } else { var execId = d.Id; resizeTTY(execId, termHeight, termWidth); - var url = window.location.href.split('#')[0] + 'ws/exec?id=' + execId; + var url = window.location.href.split('#')[0] + 'api/websocket/exec?id=' + execId; if (url.indexOf('https') > -1) { url = url.replace('https://', 'wss://'); } else { diff --git a/app/shared/services.js b/app/shared/services.js index 513ceaa9d..67fb2aa39 100644 --- a/app/shared/services.js +++ b/app/shared/services.js @@ -229,9 +229,9 @@ angular.module('portainer.services', ['ngResource', 'ngSanitize']) } }); }]) - .factory('Users', ['$resource', function UsersFactory($resource) { + .factory('Users', ['$resource', 'USERS_ENDPOINT', function UsersFactory($resource, USERS_ENDPOINT) { 'use strict'; - return $resource('/users/:username/:action', {}, { + return $resource(USERS_ENDPOINT + '/:username/:action', {}, { create: { method: 'POST' }, get: {method: 'GET', params: { username: '@username' } }, update: { method: 'PUT', params: { username: '@username' } }, diff --git a/gruntFile.js b/gruntFile.js index 58f35279e..1aa1e8aa7 100644 --- a/gruntFile.js +++ b/gruntFile.js @@ -297,34 +297,34 @@ module.exports = function (grunt) { }, buildBinary: { command: [ - 'docker run --rm -v $(pwd)/api:/src portainer/golang-builder', - 'shasum api/portainer > portainer-checksum.txt', + 'docker run --rm -v $(pwd)/api:/src portainer/golang-builder /src/cmd/portainer', + 'shasum api/cmd/portainer/portainer > portainer-checksum.txt', 'mkdir -p dist', - 'mv api/portainer dist/' + 'mv api/cmd/portainer/portainer dist/' ].join(' && ') }, buildUnixArmBinary: { command: [ - 'docker run --rm -v $(pwd)/api:/src -e BUILD_GOOS="linux" -e BUILD_GOARCH="arm" portainer/golang-builder:cross-platform', - 'shasum api/portainer-linux-arm > portainer-checksum.txt', + 'docker run --rm -v $(pwd)/api:/src -e BUILD_GOOS="linux" -e BUILD_GOARCH="arm" portainer/golang-builder:cross-platform /src/cmd/portainer', + 'shasum api/cmd/portainer/portainer-linux-arm > portainer-checksum.txt', 'mkdir -p dist', - 'mv api/portainer-linux-arm dist/portainer' + 'mv api/cmd/portainer/portainer-linux-arm dist/portainer' ].join(' && ') }, buildDarwinBinary: { command: [ - 'docker run --rm -v $(pwd)/api:/src -e BUILD_GOOS="darwin" -e BUILD_GOARCH="amd64" portainer/golang-builder:cross-platform', - 'shasum api/portainer-darwin-amd64 > portainer-checksum.txt', + 'docker run --rm -v $(pwd)/api:/src -e BUILD_GOOS="darwin" -e BUILD_GOARCH="amd64" portainer/golang-builder:cross-platform /src/cmd/portainer', + 'shasum api/cmd/portainer/portainer-darwin-amd64 > portainer-checksum.txt', 'mkdir -p dist', - 'mv api/portainer-darwin-amd64 dist/portainer' + 'mv api/cmd/portainer/portainer-darwin-amd64 dist/portainer' ].join(' && ') }, buildWindowsBinary: { command: [ - 'docker run --rm -v $(pwd)/api:/src -e BUILD_GOOS="windows" -e BUILD_GOARCH="amd64" portainer/golang-builder:cross-platform', - 'shasum api/portainer-windows-amd64 > portainer-checksum.txt', + 'docker run --rm -v $(pwd)/api:/src -e BUILD_GOOS="windows" -e BUILD_GOARCH="amd64" portainer/golang-builder:cross-platform /src/cmd/portainer', + 'shasum api/cmd/portainer/portainer-windows-amd64 > portainer-checksum.txt', 'mkdir -p dist', - 'mv api/portainer-windows-amd64 dist/portainer.exe' + 'mv api/cmd/portainer/portainer-windows-amd64 dist/portainer.exe' ].join(' && ') }, run: { From 9165b5b2159e62bf715f4d942b46feb1f8b88321 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Wed, 21 Dec 2016 11:24:34 +1300 Subject: [PATCH 10/20] fix(dashboard): add missing dependency to Messages service (#402) --- app/components/dashboard/dashboardController.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/dashboard/dashboardController.js b/app/components/dashboard/dashboardController.js index c48b76b1b..a875e0f36 100644 --- a/app/components/dashboard/dashboardController.js +++ b/app/components/dashboard/dashboardController.js @@ -1,6 +1,6 @@ angular.module('dashboard', []) -.controller('DashboardController', ['$scope', '$q', 'Config', 'Container', 'ContainerHelper', 'Image', 'Network', 'Volume', 'Info', -function ($scope, $q, Config, Container, ContainerHelper, Image, Network, Volume, Info) { +.controller('DashboardController', ['$scope', '$q', 'Config', 'Container', 'ContainerHelper', 'Image', 'Network', 'Volume', 'Info', 'Messages', +function ($scope, $q, Config, Container, ContainerHelper, Image, Network, Volume, Info, Messages) { $scope.containerData = { total: 0 From 419727e1eb1ff1ba84bc3d4fabfbf93e241d270d Mon Sep 17 00:00:00 2001 From: David Eisner Date: Sat, 24 Dec 2016 04:49:29 +0000 Subject: [PATCH 11/20] feat(api): Connect to docker behind a name based virtual host proxy (#379) This involves copying and modifying go's httputil.NewSingleHostReverseProxy, which is documented to (perhaps surprisingly) leave the Host header untouched. Instead, we set the Host header to the target host for the connection for the benefit of name based virtual host proxies that make use of this. The value it would otherwise have in this app, typically 'localhost:8000', is strange and unlikely to be any use. See golang/go#7618 and golang/go#10342 --- api/http/docker_handler.go | 45 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/api/http/docker_handler.go b/api/http/docker_handler.go index bb67a3943..f15b4386e 100644 --- a/api/http/docker_handler.go +++ b/api/http/docker_handler.go @@ -11,6 +11,7 @@ import ( "net/http/httputil" "net/url" "os" + "strings" ) // DockerHandler represents an HTTP API handler for proxying requests to the Docker API. @@ -61,14 +62,54 @@ func (handler *DockerHandler) setupProxy(config *portainer.EndpointConfiguration return nil } +// singleJoiningSlash from golang.org/src/net/http/httputil/reverseproxy.go +// included here for use in NewSingleHostReverseProxyWithHostHeader +// because its used in NewSingleHostReverseProxy from golang.org/src/net/http/httputil/reverseproxy.go + +func singleJoiningSlash(a, b string) string { + aslash := strings.HasSuffix(a, "/") + bslash := strings.HasPrefix(b, "/") + switch { + case aslash && bslash: + return a + b[1:] + case !aslash && !bslash: + return a + "/" + b + } + return a + b +} + +// NewSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy +// from golang.org/src/net/http/httputil/reverseproxy.go and merely sets the Host +// HTTP header, which NewSingleHostReverseProxy deliberately preserves + +func NewSingleHostReverseProxyWithHostHeader(target *url.URL) *httputil.ReverseProxy { + targetQuery := target.RawQuery + director := func(req *http.Request) { + req.URL.Scheme = target.Scheme + req.URL.Host = target.Host + req.URL.Path = singleJoiningSlash(target.Path, req.URL.Path) + req.Host = req.URL.Host + if targetQuery == "" || req.URL.RawQuery == "" { + req.URL.RawQuery = targetQuery + req.URL.RawQuery + } else { + req.URL.RawQuery = targetQuery + "&" + req.URL.RawQuery + } + if _, ok := req.Header["User-Agent"]; !ok { + // explicitly disable User-Agent so it's not set to default value + req.Header.Set("User-Agent", "") + } + } + return &httputil.ReverseProxy{Director: director} +} + func newHTTPProxy(u *url.URL) http.Handler { u.Scheme = "http" - return httputil.NewSingleHostReverseProxy(u) + return NewSingleHostReverseProxyWithHostHeader(u) } func newHTTPSProxy(u *url.URL, endpointConfig *portainer.EndpointConfiguration) (http.Handler, error) { u.Scheme = "https" - proxy := httputil.NewSingleHostReverseProxy(u) + proxy := NewSingleHostReverseProxyWithHostHeader(u) config, err := createTLSConfiguration(endpointConfig.TLSCACertPath, endpointConfig.TLSCertPath, endpointConfig.TLSKeyPath) if err != nil { return nil, err From edeed41797620d8e3e386db28ff5f167449da801 Mon Sep 17 00:00:00 2001 From: Paul Kling Date: Sat, 24 Dec 2016 13:53:57 -0600 Subject: [PATCH 12/20] #186 feat(container): bind the enter key when renaming container (#385) --- app/components/container/container.html | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/components/container/container.html b/app/components/container/container.html index a638a403b..7a58a335a 100644 --- a/app/components/container/container.html +++ b/app/components/container/container.html @@ -40,9 +40,11 @@ - - - + + + + + From ce32ed5b9808318b57cae3ce406d06ed154e82c9 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Sun, 25 Dec 2016 22:14:26 +1300 Subject: [PATCH 13/20] fix(service-creation): fix the command specification and add the ability to specify an entrypoint (#409) --- .../createService/createServiceController.js | 13 ++++++++++++- app/components/createService/createservice.html | 8 ++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/app/components/createService/createServiceController.js b/app/components/createService/createServiceController.js index 4797ae3b3..061b4c6c5 100644 --- a/app/components/createService/createServiceController.js +++ b/app/components/createService/createServiceController.js @@ -9,6 +9,7 @@ function ($scope, $state, Service, Volume, Network, ImageHelper, Messages) { Mode: 'replicated', Replicas: 1, Command: '', + EntryPoint: '', WorkingDir: '', User: '', Env: [], @@ -93,9 +94,19 @@ function ($scope, $state, Service, Volume, Network, ImageHelper, Messages) { } } + function commandToArray(cmd) { + var tokens = [].concat.apply([], cmd.split('"').map(function(v,i) { + return i%2 ? v : v.split(' '); + })).filter(Boolean); + return tokens; + } + function prepareCommandConfig(config, input) { + if (input.EntryPoint) { + config.TaskTemplate.ContainerSpec.Command = commandToArray(input.EntryPoint); + } if (input.Command) { - config.TaskTemplate.ContainerSpec.Command = _.split(input.Command, ' '); + config.TaskTemplate.ContainerSpec.Args = commandToArray(input.Command); } if (input.User) { config.TaskTemplate.ContainerSpec.User = input.User; diff --git a/app/components/createService/createservice.html b/app/components/createService/createservice.html index a1d017fd8..6b9e02ed2 100644 --- a/app/components/createService/createservice.html +++ b/app/components/createService/createservice.html @@ -116,6 +116,14 @@
+ +
+ +
+ +
+
+
From 03456ddcf8740a8927e0373b84d66cce9cd40323 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Sun, 25 Dec 2016 22:43:53 +1300 Subject: [PATCH 14/20] feat(containers): add the ability to filter by state (#410) --- app/components/containers/containers.html | 2 +- app/components/containers/containersController.js | 6 ++++-- app/shared/filters.js | 2 +- app/shared/viewmodel.js | 1 + 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app/components/containers/containers.html b/app/components/containers/containers.html index c7d86a426..7d1ecca69 100644 --- a/app/components/containers/containers.html +++ b/app/components/containers/containers.html @@ -85,7 +85,7 @@ - {{ container.Status|containerstatus }} + {{ container.Status }} {{ container|swarmcontainername}} {{ container|containername}} {{ container.Image }} diff --git a/app/components/containers/containersController.js b/app/components/containers/containersController.js index acaa912ed..e5d8ba98d 100644 --- a/app/components/containers/containersController.js +++ b/app/components/containers/containersController.js @@ -1,6 +1,6 @@ angular.module('containers', []) -.controller('ContainersController', ['$scope', 'Container', 'ContainerHelper', 'Info', 'Settings', 'Messages', 'Config', -function ($scope, Container, ContainerHelper, Info, Settings, Messages, Config) { +.controller('ContainersController', ['$scope', '$filter', 'Container', 'ContainerHelper', 'Info', 'Settings', 'Messages', 'Config', +function ($scope, $filter, Container, ContainerHelper, Info, Settings, Messages, Config) { $scope.state = {}; $scope.state.displayAll = Settings.displayAll; $scope.state.displayIP = false; @@ -23,6 +23,8 @@ function ($scope, Container, ContainerHelper, Info, Settings, Messages, Config) } $scope.containers = containers.map(function (container) { var model = new ContainerViewModel(container); + model.Status = $filter('containerstatus')(model.Status); + if (model.IP) { $scope.state.displayIP = true; } diff --git a/app/shared/filters.js b/app/shared/filters.js index 981626427..ce5b616c4 100644 --- a/app/shared/filters.js +++ b/app/shared/filters.js @@ -44,7 +44,7 @@ angular.module('portainer.filters', []) return 'warning'; } else if (status.indexOf('created') !== -1) { return 'info'; - } else if (status.indexOf('exited') !== -1) { + } else if (status.indexOf('stopped') !== -1) { return 'danger'; } return 'success'; diff --git a/app/shared/viewmodel.js b/app/shared/viewmodel.js index e320c0752..64f823670 100644 --- a/app/shared/viewmodel.js +++ b/app/shared/viewmodel.js @@ -53,6 +53,7 @@ function ServiceViewModel(data) { function ContainerViewModel(data) { this.Id = data.Id; this.Status = data.Status; + this.State = data.State; this.Names = data.Names; // Unavailable in Docker < 1.10 if (data.NetworkSettings && !_.isEmpty(data.NetworkSettings.Networks)) { From 712b4528c01a43fa29dbea8c6403ee58c87ca3c0 Mon Sep 17 00:00:00 2001 From: Glowbal Date: Sun, 25 Dec 2016 21:28:54 +0100 Subject: [PATCH 15/20] feat(network-details): add list of containers in network (#302) - shows all containers currently connected to the network - abillity to disconect a container from the network - fix error when a container is not connected to any networks --- app/components/network/network.html | 31 +++++++++++ app/components/network/networkController.js | 61 +++++++++++++++++---- app/shared/services.js | 2 +- 3 files changed, 83 insertions(+), 11 deletions(-) diff --git a/app/components/network/network.html b/app/components/network/network.html index 289f1a5e9..ae0cc846d 100644 --- a/app/components/network/network.html +++ b/app/components/network/network.html @@ -65,3 +65,34 @@
+ + +
+
+ + + + + + + + + + + + + + + + + + + + +
Container NameIPv4 AddressIPv6 AddressMacAddressActions
{{ container.Name }}{{ container.IPv4Address || '-' }}{{ container.IPv6Address || '-' }}{{ container.MacAddress || '-' }} + +
+
+
+
+
diff --git a/app/components/network/networkController.js b/app/components/network/networkController.js index d5e93b7ff..656a6b062 100644 --- a/app/components/network/networkController.js +++ b/app/components/network/networkController.js @@ -1,6 +1,6 @@ angular.module('network', []) -.controller('NetworkController', ['$scope', '$state', '$stateParams', 'Network', 'Messages', -function ($scope, $state, $stateParams, Network, Messages) { +.controller('NetworkController', ['$scope', '$state', '$stateParams', 'Network', 'Container', 'ContainerHelper', 'Messages', +function ($scope, $state, $stateParams, Network, Container, ContainerHelper, Messages) { $scope.removeNetwork = function removeNetwork(networkId) { $('#loadingViewSpinner').show(); @@ -19,12 +19,53 @@ function ($scope, $state, $stateParams, Network, Messages) { }); }; - $('#loadingViewSpinner').show(); - Network.get({id: $stateParams.id}, function (d) { - $scope.network = d; - $('#loadingViewSpinner').hide(); - }, function (e) { - $('#loadingViewSpinner').hide(); - Messages.error("Failure", e, "Unable to retrieve network info"); - }); + $scope.containerLeaveNetwork = function containerLeaveNetwork(network, containerId) { + $('#loadingViewSpinner').show(); + Network.disconnect({id: $stateParams.id}, { Container: containerId, Force: false }, function (d) { + if (d.message) { + $('#loadingViewSpinner').hide(); + Messages.send("Error", {}, d.message); + } else { + $('#loadingViewSpinner').hide(); + Messages.send("Container left network", $stateParams.id); + $state.go('network', {id: network.Id}, {reload: true}); + } + }, function (e) { + $('#loadingViewSpinner').hide(); + Messages.error("Failure", e, "Unable to disconnect container from network"); + }); + }; + + function getNetwork() { + $('#loadingViewSpinner').show(); + Network.get({id: $stateParams.id}, function (d) { + $scope.network = d; + getContainersInNetwork(d); + $('#loadingViewSpinner').hide(); + }, function (e) { + $('#loadingViewSpinner').hide(); + Messages.error("Failure", e, "Unable to retrieve network info"); + }); + } + + function getContainersInNetwork(network) { + if (network.Containers) { + Container.query({ + filters: {network: [$stateParams.id]} + }, function (containersInNetworkResult) { + if ($scope.containersToHideLabels) { + containersInNetworkResult = ContainerHelper.hideContainers(containersInNetworkResult, $scope.containersToHideLabels); + } + var containersInNetwork = []; + containersInNetworkResult.forEach(function(container) { + var containerInNetwork = network.Containers[container.Id]; + containerInNetwork.Id = container.Id; + containersInNetwork.push(containerInNetwork); + }); + $scope.containersInNetwork = containersInNetwork; + }); + } + } + + getNetwork(); }]); diff --git a/app/shared/services.js b/app/shared/services.js index 67fb2aa39..71091ab0d 100644 --- a/app/shared/services.js +++ b/app/shared/services.js @@ -6,7 +6,7 @@ angular.module('portainer.services', ['ngResource', 'ngSanitize']) return $resource(Settings.url + '/containers/:id/:action', { name: '@name' }, { - query: {method: 'GET', params: {all: 0, action: 'json'}, isArray: true}, + 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'}}, From 986171ecfef842dc02a954899bbbe2fa3f6abc9b Mon Sep 17 00:00:00 2001 From: Glowbal Date: Sun, 25 Dec 2016 21:31:22 +0100 Subject: [PATCH 16/20] feat(service): Add editable service update configuration (#346) * #304 Add editable service update configuration * fix unable to use 0 for update-delay * apply margin top to center help text --- .../createService/createServiceController.js | 14 +++++- .../createService/createservice.html | 49 +++++++++++++++++++ app/components/service/service.html | 46 +++++++++++++++++ app/components/service/serviceController.js | 22 +++++++++ app/shared/viewmodel.js | 10 ++++ 5 files changed, 140 insertions(+), 1 deletion(-) diff --git a/app/components/createService/createServiceController.js b/app/components/createService/createServiceController.js index 061b4c6c5..8497463ab 100644 --- a/app/components/createService/createServiceController.js +++ b/app/components/createService/createServiceController.js @@ -18,7 +18,10 @@ function ($scope, $state, Service, Volume, Network, ImageHelper, Messages) { Volumes: [], Network: '', ExtraNetworks: [], - Ports: [] + Ports: [], + Parallelism: 1, + UpdateDelay: 0, + FailureAction: 'pause' }; $scope.addPortBinding = function() { @@ -168,6 +171,14 @@ function ($scope, $state, Service, Volume, Network, ImageHelper, Messages) { config.Networks = _.uniqWith(networks, _.isEqual); } + function prepareUpdateConfig(config, input) { + config.UpdateConfig = { + Parallelism: input.Parallelism || 0, + Delay: input.UpdateDelay || 0, + FailureAction: input.FailureAction + }; + } + function prepareConfiguration() { var input = $scope.formValues; var config = { @@ -188,6 +199,7 @@ function ($scope, $state, Service, Volume, Network, ImageHelper, Messages) { prepareLabelsConfig(config, input); prepareVolumes(config, input); prepareNetworks(config, input); + prepareUpdateConfig(config, input); return config; } diff --git a/app/components/createService/createservice.html b/app/components/createService/createservice.html index 6b9e02ed2..b3e6544ec 100644 --- a/app/components/createService/createservice.html +++ b/app/components/createService/createservice.html @@ -102,6 +102,7 @@
  • Volumes
  • Network
  • Labels
  • +
  • Update config
  • @@ -324,7 +325,55 @@
    + +
    +
    + +
    + +
    + +
    +
    +

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

    +
    +
    + + +
    + +
    + +
    +
    +

    + Amount of time between updates. +

    +
    +
    + + +
    + +
    + + +
    +
    +
    + +
    +
    +
    diff --git a/app/components/service/service.html b/app/components/service/service.html index 194f06228..596ee3c73 100644 --- a/app/components/service/service.html +++ b/app/components/service/service.html @@ -168,6 +168,52 @@
    + + Update Parallelism + + + {{ service.UpdateParallelism }} + Change + + + + + + + + + + Update Delay + + + {{ service.UpdateDelay }} + Change + + + + + + + + + + Update Failure Action + +
    +
    + + +
    +
    +
    + + diff --git a/app/components/service/serviceController.js b/app/components/service/serviceController.js index ca45c97b6..e33db7dab 100644 --- a/app/components/service/serviceController.js +++ b/app/components/service/serviceController.js @@ -60,6 +60,18 @@ function ($scope, $stateParams, $state, Service, ServiceHelper, Task, Node, Mess service.hasChanges = service.hasChanges || removedElement !== null; }; + $scope.changeParallelism = function changeParallelism(service) { + updateServiceAttribute(service, 'UpdateParallelism', service.newServiceUpdateParallelism); + service.EditParallelism = false; + }; + $scope.changeUpdateDelay = function changeUpdateDelay(service) { + updateServiceAttribute(service, 'UpdateDelay', service.newServiceUpdateDelay); + service.EditDelay = false; + }; + $scope.changeUpdateFailureAction = function changeUpdateFailureAction(service) { + updateServiceAttribute(service, 'UpdateFailureAction', service.newServiceUpdateFailureAction); + }; + $scope.cancelChanges = function changeServiceImage(service) { Object.keys(previousServiceValues).forEach(function(attribute) { service[attribute] = previousServiceValues[attribute]; // reset service values @@ -86,6 +98,12 @@ function ($scope, $stateParams, $state, Service, ServiceHelper, Task, Node, Mess config.Mode.Replicated.Replicas = service.Replicas; } + config.UpdateConfig = { + Parallelism: service.newServiceUpdateParallelism, + Delay: service.newServiceUpdateDelay, + FailureAction: service.newServiceUpdateFailureAction + }; + Service.update({ id: service.Id, version: service.Version }, config, function (data) { $('#loadServicesSpinner').hide(); Messages.send("Service successfully updated", "Service updated"); @@ -121,6 +139,10 @@ function ($scope, $stateParams, $state, Service, ServiceHelper, Task, Node, Mess service.newServiceName = service.Name; service.newServiceImage = service.Image; service.newServiceReplicas = service.Replicas; + service.newServiceUpdateParallelism = service.UpdateParallelism; + service.newServiceUpdateDelay = service.UpdateDelay; + service.newServiceUpdateFailureAction = service.UpdateFailureAction; + service.EnvironmentVariables = translateEnvironmentVariables(service.Env); service.ServiceLabels = translateLabelsToServiceLabels(service.Labels); service.ServiceContainerLabels = translateLabelsToServiceLabels(service.ContainerLabels); diff --git a/app/shared/viewmodel.js b/app/shared/viewmodel.js index 64f823670..c7296a6e7 100644 --- a/app/shared/viewmodel.js +++ b/app/shared/viewmodel.js @@ -45,6 +45,16 @@ function ServiceViewModel(data) { if (data.Endpoint.Ports) { this.Ports = data.Endpoint.Ports; } + if (data.Spec.UpdateConfig) { + this.UpdateParallelism = (typeof data.Spec.UpdateConfig.Parallelism !== undefined) ? data.Spec.UpdateConfig.Parallelism || 0 : 1; + this.UpdateDelay = data.Spec.UpdateConfig.Delay || 0; + this.UpdateFailureAction = data.Spec.UpdateConfig.FailureAction || 'pause'; + } else { + this.UpdateParallelism = 1; + this.UpdateDelay = 0; + this.UpdateFailureAction = 'pause'; + } + this.Checked = false; this.Scale = false; this.EditName = false; From c9ba16ef10fed2fb4976f87c6025a25d153006b3 Mon Sep 17 00:00:00 2001 From: Glowbal Date: Sun, 25 Dec 2016 21:32:17 +0100 Subject: [PATCH 17/20] feat(network-creation): add labels on network create (#408) --- .../createNetwork/createNetworkController.js | 25 ++++++++++++++-- .../createNetwork/createnetwork.html | 29 +++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/app/components/createNetwork/createNetworkController.js b/app/components/createNetwork/createNetworkController.js index 3d03b56bd..79970cf76 100644 --- a/app/components/createNetwork/createNetworkController.js +++ b/app/components/createNetwork/createNetworkController.js @@ -4,7 +4,8 @@ function ($scope, $state, Messages, Network) { $scope.formValues = { DriverOptions: [], Subnet: '', - Gateway: '' + Gateway: '', + Labels: [] }; $scope.config = { @@ -16,7 +17,8 @@ function ($scope, $state, Messages, Network) { IPAM: { Driver: 'default', Config: [] - } + }, + Labels: {} }; $scope.addDriverOption = function() { @@ -27,6 +29,14 @@ function ($scope, $state, Messages, Network) { $scope.formValues.DriverOptions.splice(index, 1); }; + $scope.addLabel = function() { + $scope.formValues.Labels.push({ name: '', value: ''}); + }; + + $scope.removeLabel = function(index) { + $scope.formValues.Labels.splice(index, 1); + }; + function createNetwork(config) { $('#createNetworkSpinner').show(); Network.create(config, function (d) { @@ -63,10 +73,21 @@ function ($scope, $state, Messages, Network) { config.Options = options; } + function prepareLabelsConfig(config) { + var labels = {}; + $scope.formValues.Labels.forEach(function (label) { + if (label.name && label.value) { + labels[label.name] = label.value; + } + }); + config.Labels = labels; + } + function prepareConfiguration() { var config = angular.copy($scope.config); prepareIPAMConfiguration(config); prepareDriverOptions(config); + prepareLabelsConfig(config); return config; } diff --git a/app/components/createNetwork/createnetwork.html b/app/components/createNetwork/createnetwork.html index 3ca1fccf2..c51aaca7d 100644 --- a/app/components/createNetwork/createnetwork.html +++ b/app/components/createNetwork/createnetwork.html @@ -78,6 +78,35 @@
    + +
    + +
    + + label + +
    + +
    +
    +
    + name + +
    +
    + value + + + + +
    +
    +
    + +
    + From a08ea134fcc39d8f08dc67af79f419249695dcc2 Mon Sep 17 00:00:00 2001 From: Glowbal Date: Sun, 25 Dec 2016 21:33:14 +0100 Subject: [PATCH 18/20] feat(container-creation): add ability to specify labels in the container creation view (#412) --- .../createContainerController.js | 25 +++++++++- .../createContainer/createcontainer.html | 46 +++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/app/components/createContainer/createContainerController.js b/app/components/createContainer/createContainerController.js index 617f3d49b..5f5346c68 100644 --- a/app/components/createContainer/createContainerController.js +++ b/app/components/createContainer/createContainerController.js @@ -7,7 +7,8 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai Console: 'none', Volumes: [], Registry: '', - NetworkContainer: '' + NetworkContainer: '', + Labels: [] }; $scope.imageConfig = {}; @@ -24,7 +25,8 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai Binds: [], NetworkMode: 'bridge', Privileged: false - } + }, + Labels: {} }; $scope.addVolume = function() { @@ -51,6 +53,14 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai $scope.config.HostConfig.PortBindings.splice(index, 1); }; + $scope.addLabel = function() { + $scope.formValues.Labels.push({ name: '', value: ''}); + }; + + $scope.removeLabel = function(index) { + $scope.formValues.Labels.splice(index, 1); + }; + Config.$promise.then(function (c) { $scope.swarm = c.swarm; var containersToHideLabels = c.hiddenLabels; @@ -222,6 +232,16 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai config.HostConfig.NetworkMode = networkMode; } + function prepareLabels(config) { + var labels = {}; + $scope.formValues.Labels.forEach(function (label) { + if (label.name && label.value) { + labels[label.name] = label.value; + } + }); + config.Labels = labels; + } + function prepareConfiguration() { var config = angular.copy($scope.config); prepareNetworkConfig(config); @@ -230,6 +250,7 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai prepareConsole(config); prepareEnvironmentVariables(config); prepareVolumes(config); + prepareLabels(config); return config; } diff --git a/app/components/createContainer/createcontainer.html b/app/components/createContainer/createcontainer.html index b6aa18322..208b0cd2a 100644 --- a/app/components/createContainer/createcontainer.html +++ b/app/components/createContainer/createcontainer.html @@ -95,6 +95,16 @@
    + +
    + +
    + + label + +
    +
    + @@ -109,6 +119,7 @@
  • Command
  • Volumes
  • Network
  • +
  • Labels
  • Security/Host
  • @@ -306,6 +317,41 @@
    + +
    +
    + +
    + +
    + + label + +
    + +
    +
    +
    + name + +
    +
    + value + + + + +
    +
    +
    + +
    + +
    +
    +
    From d54d30a7bef8f4ec82f28b39b51369c090cb7713 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Mon, 26 Dec 2016 09:34:02 +1300 Subject: [PATCH 19/20] feat(global): multi endpoint management (#407) --- .gitignore | 2 +- api/bolt/datastore.go | 26 +- api/bolt/endpoint_service.go | 162 ++++++++++ api/bolt/internal/internal.go | 20 ++ api/cli/cli.go | 30 +- api/cmd/portainer/main.go | 52 +++- api/errors.go | 11 + api/file/file.go | 125 ++++++++ api/http/auth_handler.go | 11 +- api/http/docker_handler.go | 23 +- api/http/endpoint_handler.go | 294 ++++++++++++++++++ api/http/handler.go | 6 + api/http/server.go | 56 +++- api/http/settings_handler.go | 7 +- api/http/templates_handler.go | 7 +- api/http/upload_handler.go | 74 +++++ api/http/user_handler.go | 25 +- api/http/websocket_handler.go | 21 +- api/portainer.go | 61 +++- app/app.js | 55 +++- app/components/auth/auth.html | 8 +- app/components/auth/authController.js | 19 +- .../containers/containersController.js | 2 +- .../createContainerController.js | 5 +- .../createContainer/createcontainer.html | 10 +- .../createService/createservice.html | 8 +- .../dashboard/dashboardController.js | 1 - app/components/endpoint/endpoint.html | 99 ++++++ app/components/endpoint/endpointController.js | 55 ++++ app/components/endpointInit/endpointInit.html | 139 +++++++++ .../endpointInit/endpointInitController.js | 57 ++++ app/components/endpoints/endpoints.html | 175 +++++++++++ .../endpoints/endpointsController.js | 100 ++++++ app/components/images/imagesController.js | 2 +- app/components/networks/networksController.js | 5 +- app/components/services/servicesController.js | 2 +- app/components/settings/settingsController.js | 2 +- app/components/sidebar/sidebar.html | 15 +- app/components/sidebar/sidebarController.js | 32 +- app/components/templates/templates.html | 8 +- .../templates/templatesController.js | 5 +- app/shared/filters.js | 6 + app/shared/services.js | 156 +++++++++- assets/css/app.css | 13 +- bower.json | 3 +- gruntFile.js | 1 + index.html | 2 +- 47 files changed, 1837 insertions(+), 161 deletions(-) create mode 100644 api/bolt/endpoint_service.go create mode 100644 api/file/file.go create mode 100644 api/http/endpoint_handler.go create mode 100644 api/http/upload_handler.go create mode 100644 app/components/endpoint/endpoint.html create mode 100644 app/components/endpoint/endpointController.js create mode 100644 app/components/endpointInit/endpointInit.html create mode 100644 app/components/endpointInit/endpointInitController.js create mode 100644 app/components/endpoints/endpoints.html create mode 100644 app/components/endpoints/endpointsController.js diff --git a/.gitignore b/.gitignore index aef04ade6..42c695629 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,4 @@ node_modules bower_components dist portainer-checksum.txt -api/cmd/portainer/portainer-* +api/cmd/portainer/portainer* diff --git a/api/bolt/datastore.go b/api/bolt/datastore.go index 2f02cd12a..d0321db21 100644 --- a/api/bolt/datastore.go +++ b/api/bolt/datastore.go @@ -1,8 +1,9 @@ package bolt import ( - "github.com/boltdb/bolt" "time" + + "github.com/boltdb/bolt" ) // Store defines the implementation of portainer.DataStore using @@ -12,23 +13,28 @@ type Store struct { Path string // Services - UserService *UserService + UserService *UserService + EndpointService *EndpointService db *bolt.DB } const ( - databaseFileName = "portainer.db" - userBucketName = "users" + databaseFileName = "portainer.db" + userBucketName = "users" + endpointBucketName = "endpoints" + activeEndpointBucketName = "activeEndpoint" ) // NewStore initializes a new Store and the associated services func NewStore(storePath string) *Store { store := &Store{ - Path: storePath, - UserService: &UserService{}, + Path: storePath, + UserService: &UserService{}, + EndpointService: &EndpointService{}, } store.UserService.store = store + store.EndpointService.store = store return store } @@ -45,6 +51,14 @@ func (store *Store) Open() error { if err != nil { return err } + _, err = tx.CreateBucketIfNotExists([]byte(endpointBucketName)) + if err != nil { + return err + } + _, err = tx.CreateBucketIfNotExists([]byte(activeEndpointBucketName)) + if err != nil { + return err + } return nil }) } diff --git a/api/bolt/endpoint_service.go b/api/bolt/endpoint_service.go new file mode 100644 index 000000000..9046bf30f --- /dev/null +++ b/api/bolt/endpoint_service.go @@ -0,0 +1,162 @@ +package bolt + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +// EndpointService represents a service for managing users. +type EndpointService struct { + store *Store +} + +const ( + activeEndpointID = 0 +) + +// Endpoint returns an endpoint by ID. +func (service *EndpointService) Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, error) { + var data []byte + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(endpointBucketName)) + value := bucket.Get(internal.Itob(int(ID))) + if value == nil { + return portainer.ErrEndpointNotFound + } + + data = make([]byte, len(value)) + copy(data, value) + return nil + }) + if err != nil { + return nil, err + } + + var endpoint portainer.Endpoint + err = internal.UnmarshalEndpoint(data, &endpoint) + if err != nil { + return nil, err + } + return &endpoint, nil +} + +// Endpoints return an array containing all the endpoints. +func (service *EndpointService) Endpoints() ([]portainer.Endpoint, error) { + var endpoints []portainer.Endpoint + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(endpointBucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var endpoint portainer.Endpoint + err := internal.UnmarshalEndpoint(v, &endpoint) + if err != nil { + return err + } + endpoints = append(endpoints, endpoint) + } + + return nil + }) + if err != nil { + return nil, err + } + + return endpoints, nil +} + +// CreateEndpoint assign an ID to a new endpoint and saves it. +func (service *EndpointService) CreateEndpoint(endpoint *portainer.Endpoint) error { + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(endpointBucketName)) + + id, _ := bucket.NextSequence() + endpoint.ID = portainer.EndpointID(id) + + data, err := internal.MarshalEndpoint(endpoint) + if err != nil { + return err + } + + err = bucket.Put(internal.Itob(int(endpoint.ID)), data) + if err != nil { + return err + } + return nil + }) +} + +// UpdateEndpoint updates an endpoint. +func (service *EndpointService) UpdateEndpoint(ID portainer.EndpointID, endpoint *portainer.Endpoint) error { + data, err := internal.MarshalEndpoint(endpoint) + if err != nil { + return err + } + + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(endpointBucketName)) + err = bucket.Put(internal.Itob(int(ID)), data) + if err != nil { + return err + } + return nil + }) +} + +// DeleteEndpoint deletes an endpoint. +func (service *EndpointService) DeleteEndpoint(ID portainer.EndpointID) error { + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(endpointBucketName)) + err := bucket.Delete(internal.Itob(int(ID))) + if err != nil { + return err + } + return nil + }) +} + +// GetActive returns the active endpoint. +func (service *EndpointService) GetActive() (*portainer.Endpoint, error) { + var data []byte + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(activeEndpointBucketName)) + value := bucket.Get(internal.Itob(activeEndpointID)) + if value == nil { + return portainer.ErrEndpointNotFound + } + + data = make([]byte, len(value)) + copy(data, value) + return nil + }) + if err != nil { + return nil, err + } + + var endpoint portainer.Endpoint + err = internal.UnmarshalEndpoint(data, &endpoint) + if err != nil { + return nil, err + } + return &endpoint, nil +} + +// SetActive saves an endpoint as active. +func (service *EndpointService) SetActive(endpoint *portainer.Endpoint) error { + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(activeEndpointBucketName)) + + data, err := internal.MarshalEndpoint(endpoint) + if err != nil { + return err + } + + err = bucket.Put(internal.Itob(activeEndpointID), data) + if err != nil { + return err + } + return nil + }) +} diff --git a/api/bolt/internal/internal.go b/api/bolt/internal/internal.go index db11bb9f4..e9d6416eb 100644 --- a/api/bolt/internal/internal.go +++ b/api/bolt/internal/internal.go @@ -3,6 +3,7 @@ package internal import ( "github.com/portainer/portainer" + "encoding/binary" "encoding/json" ) @@ -15,3 +16,22 @@ func MarshalUser(user *portainer.User) ([]byte, error) { func UnmarshalUser(data []byte, user *portainer.User) error { return json.Unmarshal(data, user) } + +// MarshalEndpoint encodes an endpoint to binary format. +func MarshalEndpoint(endpoint *portainer.Endpoint) ([]byte, error) { + return json.Marshal(endpoint) +} + +// UnmarshalEndpoint decodes an endpoint from a binary data. +func UnmarshalEndpoint(data []byte, endpoint *portainer.Endpoint) error { + return json.Unmarshal(data, endpoint) +} + +// Itob returns an 8-byte big endian representation of v. +// This function is typically used for encoding integer IDs to byte slices +// so that they can be used as BoltDB keys. +func Itob(v int) []byte { + b := make([]byte, 8) + binary.BigEndian.PutUint64(b, uint64(v)) + return b +} diff --git a/api/cli/cli.go b/api/cli/cli.go index 6dcea483a..ccd9c1f9a 100644 --- a/api/cli/cli.go +++ b/api/cli/cli.go @@ -3,9 +3,10 @@ package cli import ( "github.com/portainer/portainer" - "gopkg.in/alecthomas/kingpin.v2" "os" "strings" + + "gopkg.in/alecthomas/kingpin.v2" ) // Service implements the CLIService interface @@ -21,13 +22,12 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) { kingpin.Version(version) flags := &portainer.CLIFlags{ + Endpoint: kingpin.Flag("host", "Dockerd endpoint").Short('H').String(), + Logo: kingpin.Flag("logo", "URL for the logo displayed in the UI").String(), + Labels: pairs(kingpin.Flag("hide-label", "Hide containers with a specific label in the UI").Short('l')), Addr: kingpin.Flag("bind", "Address and port to serve Portainer").Default(":9000").Short('p').String(), Assets: kingpin.Flag("assets", "Path to the assets").Default(".").Short('a').String(), Data: kingpin.Flag("data", "Path to the folder where the data is stored").Default("/data").Short('d').String(), - Endpoint: kingpin.Flag("host", "Dockerd endpoint").Default("unix:///var/run/docker.sock").Short('H').String(), - Labels: pairs(kingpin.Flag("hide-label", "Hide containers with a specific label in the UI").Short('l')), - Logo: kingpin.Flag("logo", "URL for the logo displayed in the UI").String(), - Swarm: kingpin.Flag("swarm", "Swarm cluster support").Default("false").Short('s').Bool(), Templates: kingpin.Flag("templates", "URL to the templates (apps) definitions").Default("https://raw.githubusercontent.com/portainer/templates/master/templates.json").Short('t').String(), TLSVerify: kingpin.Flag("tlsverify", "TLS support").Default("false").Bool(), TLSCacert: kingpin.Flag("tlscacert", "Path to the CA").Default("/certs/ca.pem").String(), @@ -41,17 +41,19 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) { // ValidateFlags validates the values of the flags. func (*Service) ValidateFlags(flags *portainer.CLIFlags) error { - if !strings.HasPrefix(*flags.Endpoint, "unix://") && !strings.HasPrefix(*flags.Endpoint, "tcp://") { - return errInvalidEnpointProtocol - } + if *flags.Endpoint != "" { + if !strings.HasPrefix(*flags.Endpoint, "unix://") && !strings.HasPrefix(*flags.Endpoint, "tcp://") { + return errInvalidEnpointProtocol + } - if strings.HasPrefix(*flags.Endpoint, "unix://") { - socketPath := strings.TrimPrefix(*flags.Endpoint, "unix://") - if _, err := os.Stat(socketPath); err != nil { - if os.IsNotExist(err) { - return errSocketNotFound + if strings.HasPrefix(*flags.Endpoint, "unix://") { + socketPath := strings.TrimPrefix(*flags.Endpoint, "unix://") + if _, err := os.Stat(socketPath); err != nil { + if os.IsNotExist(err) { + return errSocketNotFound + } + return err } - return err } } diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 48cb6fa72..c11f9b34f 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -5,6 +5,7 @@ import ( "github.com/portainer/portainer/bolt" "github.com/portainer/portainer/cli" "github.com/portainer/portainer/crypto" + "github.com/portainer/portainer/file" "github.com/portainer/portainer/http" "github.com/portainer/portainer/jwt" @@ -24,7 +25,6 @@ func main() { } settings := &portainer.Settings{ - Swarm: *flags.Swarm, HiddenLabels: *flags.Labels, Logo: *flags.Logo, } @@ -41,25 +41,47 @@ func main() { log.Fatal(err) } + fileService, err := file.NewService(*flags.Data) + if err != nil { + log.Fatal(err) + } + var cryptoService portainer.CryptoService = &crypto.Service{} - endpointConfiguration := &portainer.EndpointConfiguration{ - Endpoint: *flags.Endpoint, - TLS: *flags.TLSVerify, - TLSCACertPath: *flags.TLSCacert, - TLSCertPath: *flags.TLSCert, - TLSKeyPath: *flags.TLSKey, + // Initialize the active endpoint from the CLI only if there is no + // active endpoint defined yet. + var activeEndpoint *portainer.Endpoint + if *flags.Endpoint != "" { + activeEndpoint, err = store.EndpointService.GetActive() + if err == portainer.ErrEndpointNotFound { + activeEndpoint = &portainer.Endpoint{ + Name: "primary", + URL: *flags.Endpoint, + TLS: *flags.TLSVerify, + TLSCACertPath: *flags.TLSCacert, + TLSCertPath: *flags.TLSCert, + TLSKeyPath: *flags.TLSKey, + } + err = store.EndpointService.CreateEndpoint(activeEndpoint) + if err != nil { + log.Fatal(err) + } + } else if err != nil { + log.Fatal(err) + } } var server portainer.Server = &http.Server{ - BindAddress: *flags.Addr, - AssetsPath: *flags.Assets, - Settings: settings, - TemplatesURL: *flags.Templates, - UserService: store.UserService, - CryptoService: cryptoService, - JWTService: jwtService, - EndpointConfig: endpointConfiguration, + BindAddress: *flags.Addr, + AssetsPath: *flags.Assets, + Settings: settings, + TemplatesURL: *flags.Templates, + UserService: store.UserService, + EndpointService: store.EndpointService, + CryptoService: cryptoService, + JWTService: jwtService, + FileService: fileService, + ActiveEndpoint: activeEndpoint, } log.Printf("Starting Portainer on %s", *flags.Addr) diff --git a/api/errors.go b/api/errors.go index fa59de7f2..8fcd44758 100644 --- a/api/errors.go +++ b/api/errors.go @@ -10,6 +10,12 @@ const ( ErrUserNotFound = Error("User not found") ) +// Endpoint errors. +const ( + ErrEndpointNotFound = Error("Endpoint not found") + ErrNoActiveEndpoint = Error("Undefined Docker endpoint") +) + // Crypto errors. const ( ErrCryptoHashFailure = Error("Unable to hash data") @@ -21,6 +27,11 @@ const ( ErrInvalidJWTToken = Error("Invalid JWT token") ) +// File errors. +const ( + ErrUndefinedTLSFileType = Error("Undefined TLS file type") +) + // Error represents an application error. type Error string diff --git a/api/file/file.go b/api/file/file.go new file mode 100644 index 000000000..cd76acec9 --- /dev/null +++ b/api/file/file.go @@ -0,0 +1,125 @@ +package file + +import ( + "strconv" + + "github.com/portainer/portainer" + + "io" + "os" + "path" +) + +const ( + // TLSStorePath represents the subfolder where TLS files are stored in the file store folder. + TLSStorePath = "tls" + // TLSCACertFile represents the name on disk for a TLS CA file. + TLSCACertFile = "ca.pem" + // TLSCertFile represents the name on disk for a TLS certificate file. + TLSCertFile = "cert.pem" + // TLSKeyFile represents the name on disk for a TLS key file. + TLSKeyFile = "key.pem" +) + +// Service represents a service for managing files. +type Service struct { + fileStorePath string +} + +// NewService initializes a new service. +func NewService(fileStorePath string) (*Service, error) { + service := &Service{ + fileStorePath: fileStorePath, + } + + err := service.createFolderInStoreIfNotExist(TLSStorePath) + if err != nil { + return nil, err + } + + return service, nil +} + +// StoreTLSFile creates a subfolder in the TLSStorePath and stores a new file with the content from r. +func (service *Service) StoreTLSFile(endpointID portainer.EndpointID, fileType portainer.TLSFileType, r io.Reader) error { + ID := strconv.Itoa(int(endpointID)) + endpointStorePath := path.Join(TLSStorePath, ID) + err := service.createFolderInStoreIfNotExist(endpointStorePath) + if err != nil { + return err + } + + var fileName string + switch fileType { + case portainer.TLSFileCA: + fileName = TLSCACertFile + case portainer.TLSFileCert: + fileName = TLSCertFile + case portainer.TLSFileKey: + fileName = TLSKeyFile + default: + return portainer.ErrUndefinedTLSFileType + } + + tlsFilePath := path.Join(endpointStorePath, fileName) + err = service.createFileInStore(tlsFilePath, r) + if err != nil { + return err + } + return nil +} + +// GetPathForTLSFile returns the absolute path to a specific TLS file for an endpoint. +func (service *Service) GetPathForTLSFile(endpointID portainer.EndpointID, fileType portainer.TLSFileType) (string, error) { + var fileName string + switch fileType { + case portainer.TLSFileCA: + fileName = TLSCACertFile + case portainer.TLSFileCert: + fileName = TLSCertFile + case portainer.TLSFileKey: + fileName = TLSKeyFile + default: + return "", portainer.ErrUndefinedTLSFileType + } + ID := strconv.Itoa(int(endpointID)) + return path.Join(service.fileStorePath, TLSStorePath, ID, fileName), nil +} + +// DeleteTLSFiles deletes a folder containing the TLS files for an endpoint. +func (service *Service) DeleteTLSFiles(endpointID portainer.EndpointID) error { + ID := strconv.Itoa(int(endpointID)) + endpointPath := path.Join(service.fileStorePath, TLSStorePath, ID) + err := os.RemoveAll(endpointPath) + if err != nil { + return err + } + return nil +} + +// createFolderInStoreIfNotExist creates a new folder in the file store if it doesn't exists on the file system. +func (service *Service) createFolderInStoreIfNotExist(name string) error { + path := path.Join(service.fileStorePath, name) + _, err := os.Stat(path) + if os.IsNotExist(err) { + os.Mkdir(path, 0600) + } else if err != nil { + return err + } + return nil +} + +// createFile creates a new file in the file store with the content from r. +func (service *Service) createFileInStore(filePath string, r io.Reader) error { + path := path.Join(service.fileStorePath, filePath) + out, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, r) + if err != nil { + return err + } + return nil +} diff --git a/api/http/auth_handler.go b/api/http/auth_handler.go index e63412a15..29c19201d 100644 --- a/api/http/auth_handler.go +++ b/api/http/auth_handler.go @@ -4,11 +4,12 @@ import ( "github.com/portainer/portainer" "encoding/json" - "github.com/asaskevich/govalidator" - "github.com/gorilla/mux" "log" "net/http" "os" + + "github.com/asaskevich/govalidator" + "github.com/gorilla/mux" ) // AuthHandler represents an HTTP API handler for managing authentication. @@ -27,7 +28,7 @@ const ( ErrInvalidCredentials = portainer.Error("Invalid credentials") ) -// NewAuthHandler returns a new instance of DialHandler. +// NewAuthHandler returns a new instance of AuthHandler. func NewAuthHandler() *AuthHandler { h := &AuthHandler{ Router: mux.NewRouter(), @@ -38,8 +39,8 @@ func NewAuthHandler() *AuthHandler { } func (handler *AuthHandler) handlePostAuth(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - handleNotAllowed(w, []string{"POST"}) + if r.Method != http.MethodPost { + handleNotAllowed(w, []string{http.MethodPost}) return } diff --git a/api/http/docker_handler.go b/api/http/docker_handler.go index f15b4386e..4894b797c 100644 --- a/api/http/docker_handler.go +++ b/api/http/docker_handler.go @@ -3,7 +3,6 @@ package http import ( "github.com/portainer/portainer" - "github.com/gorilla/mux" "io" "log" "net" @@ -12,6 +11,8 @@ import ( "net/url" "os" "strings" + + "github.com/gorilla/mux" ) // DockerHandler represents an HTTP API handler for proxying requests to the Docker API. @@ -36,18 +37,22 @@ func NewDockerHandler(middleWareService *middleWareService) *DockerHandler { } func (handler *DockerHandler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http.Request) { - handler.proxy.ServeHTTP(w, r) + if handler.proxy != nil { + handler.proxy.ServeHTTP(w, r) + } else { + Error(w, portainer.ErrNoActiveEndpoint, http.StatusNotFound, handler.Logger) + } } -func (handler *DockerHandler) setupProxy(config *portainer.EndpointConfiguration) error { +func (handler *DockerHandler) setupProxy(endpoint *portainer.Endpoint) error { var proxy http.Handler - endpointURL, err := url.Parse(config.Endpoint) + endpointURL, err := url.Parse(endpoint.URL) if err != nil { return err } if endpointURL.Scheme == "tcp" { - if config.TLS { - proxy, err = newHTTPSProxy(endpointURL, config) + if endpoint.TLS { + proxy, err = newHTTPSProxy(endpointURL, endpoint) if err != nil { return err } @@ -65,7 +70,6 @@ func (handler *DockerHandler) setupProxy(config *portainer.EndpointConfiguration // singleJoiningSlash from golang.org/src/net/http/httputil/reverseproxy.go // included here for use in NewSingleHostReverseProxyWithHostHeader // because its used in NewSingleHostReverseProxy from golang.org/src/net/http/httputil/reverseproxy.go - func singleJoiningSlash(a, b string) string { aslash := strings.HasSuffix(a, "/") bslash := strings.HasPrefix(b, "/") @@ -81,7 +85,6 @@ func singleJoiningSlash(a, b string) string { // NewSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy // from golang.org/src/net/http/httputil/reverseproxy.go and merely sets the Host // HTTP header, which NewSingleHostReverseProxy deliberately preserves - func NewSingleHostReverseProxyWithHostHeader(target *url.URL) *httputil.ReverseProxy { targetQuery := target.RawQuery director := func(req *http.Request) { @@ -107,10 +110,10 @@ func newHTTPProxy(u *url.URL) http.Handler { return NewSingleHostReverseProxyWithHostHeader(u) } -func newHTTPSProxy(u *url.URL, endpointConfig *portainer.EndpointConfiguration) (http.Handler, error) { +func newHTTPSProxy(u *url.URL, endpoint *portainer.Endpoint) (http.Handler, error) { u.Scheme = "https" proxy := NewSingleHostReverseProxyWithHostHeader(u) - config, err := createTLSConfiguration(endpointConfig.TLSCACertPath, endpointConfig.TLSCertPath, endpointConfig.TLSKeyPath) + config, err := createTLSConfiguration(endpoint.TLSCACertPath, endpoint.TLSCertPath, endpoint.TLSKeyPath) if err != nil { return nil, err } diff --git a/api/http/endpoint_handler.go b/api/http/endpoint_handler.go new file mode 100644 index 000000000..42a77cbec --- /dev/null +++ b/api/http/endpoint_handler.go @@ -0,0 +1,294 @@ +package http + +import ( + "github.com/portainer/portainer" + + "encoding/json" + "log" + "net/http" + "os" + "strconv" + + "github.com/asaskevich/govalidator" + "github.com/gorilla/mux" +) + +// EndpointHandler represents an HTTP API handler for managing Docker endpoints. +type EndpointHandler struct { + *mux.Router + Logger *log.Logger + EndpointService portainer.EndpointService + FileService portainer.FileService + server *Server + middleWareService *middleWareService +} + +// NewEndpointHandler returns a new instance of EndpointHandler. +func NewEndpointHandler(middleWareService *middleWareService) *EndpointHandler { + h := &EndpointHandler{ + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + middleWareService: middleWareService, + } + h.Handle("/endpoints", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h.handlePostEndpoints(w, r) + }))).Methods(http.MethodPost) + h.Handle("/endpoints", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h.handleGetEndpoints(w, r) + }))).Methods(http.MethodGet) + h.Handle("/endpoints/{id}", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h.handleGetEndpoint(w, r) + }))).Methods(http.MethodGet) + h.Handle("/endpoints/{id}", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h.handlePutEndpoint(w, r) + }))).Methods(http.MethodPut) + h.Handle("/endpoints/{id}", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h.handleDeleteEndpoint(w, r) + }))).Methods(http.MethodDelete) + h.Handle("/endpoints/{id}/active", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h.handlePostEndpoint(w, r) + }))).Methods(http.MethodPost) + return h +} + +// handleGetEndpoints handles GET requests on /endpoints +func (handler *EndpointHandler) handleGetEndpoints(w http.ResponseWriter, r *http.Request) { + endpoints, err := handler.EndpointService.Endpoints() + if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } + encodeJSON(w, endpoints, handler.Logger) +} + +// handlePostEndpoints handles POST requests on /endpoints +// if the active URL parameter is specified, will also define the new endpoint as the active endpoint. +// /endpoints(?active=true|false) +func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *http.Request) { + var req postEndpointsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err := govalidator.ValidateStruct(req) + if err != nil { + Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + endpoint := &portainer.Endpoint{ + Name: req.Name, + URL: req.URL, + TLS: req.TLS, + } + + err = handler.EndpointService.CreateEndpoint(endpoint) + if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if req.TLS { + caCertPath, _ := handler.FileService.GetPathForTLSFile(endpoint.ID, portainer.TLSFileCA) + endpoint.TLSCACertPath = caCertPath + certPath, _ := handler.FileService.GetPathForTLSFile(endpoint.ID, portainer.TLSFileCert) + endpoint.TLSCertPath = certPath + keyPath, _ := handler.FileService.GetPathForTLSFile(endpoint.ID, portainer.TLSFileKey) + endpoint.TLSKeyPath = keyPath + err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) + if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } + } + + activeEndpointParameter := r.FormValue("active") + if activeEndpointParameter != "" { + active, err := strconv.ParseBool(activeEndpointParameter) + if err != nil { + Error(w, err, http.StatusBadRequest, handler.Logger) + return + } + if active == true { + err = handler.server.updateActiveEndpoint(endpoint) + if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } + } + } + + encodeJSON(w, &postEndpointsResponse{ID: int(endpoint.ID)}, handler.Logger) +} + +type postEndpointsRequest struct { + Name string `valid:"required"` + URL string `valid:"required"` + TLS bool +} + +type postEndpointsResponse struct { + ID int `json:"Id"` +} + +// handleGetEndpoint handles GET requests on /endpoints/:id +// GET /endpoints/0 returns active endpoint +func (handler *EndpointHandler) handleGetEndpoint(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + endpointID, err := strconv.Atoi(id) + if err != nil { + Error(w, err, http.StatusBadRequest, handler.Logger) + return + } + + var endpoint *portainer.Endpoint + if id == "0" { + endpoint, err = handler.EndpointService.GetActive() + if err == portainer.ErrEndpointNotFound { + Error(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } + if handler.server.ActiveEndpoint == nil { + err = handler.server.updateActiveEndpoint(endpoint) + if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } + } + } else { + endpoint, err = handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrEndpointNotFound { + Error(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } + } + + encodeJSON(w, endpoint, handler.Logger) +} + +// handlePostEndpoint handles POST requests on /endpoints/:id/active +func (handler *EndpointHandler) handlePostEndpoint(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + endpointID, err := strconv.Atoi(id) + if err != nil { + Error(w, err, http.StatusBadRequest, handler.Logger) + return + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrEndpointNotFound { + Error(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + err = handler.server.updateActiveEndpoint(endpoint) + if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + } +} + +// handlePutEndpoint handles PUT requests on /endpoints/:id +func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + endpointID, err := strconv.Atoi(id) + if err != nil { + Error(w, err, http.StatusBadRequest, handler.Logger) + return + } + + var req putEndpointsRequest + if err = json.NewDecoder(r.Body).Decode(&req); err != nil { + Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err = govalidator.ValidateStruct(req) + if err != nil { + Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + endpoint := &portainer.Endpoint{ + ID: portainer.EndpointID(endpointID), + Name: req.Name, + URL: req.URL, + TLS: req.TLS, + } + + if req.TLS { + caCertPath, _ := handler.FileService.GetPathForTLSFile(endpoint.ID, portainer.TLSFileCA) + endpoint.TLSCACertPath = caCertPath + certPath, _ := handler.FileService.GetPathForTLSFile(endpoint.ID, portainer.TLSFileCert) + endpoint.TLSCertPath = certPath + keyPath, _ := handler.FileService.GetPathForTLSFile(endpoint.ID, portainer.TLSFileKey) + endpoint.TLSKeyPath = keyPath + } else { + err = handler.FileService.DeleteTLSFiles(endpoint.ID) + if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } + } + + err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) + if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } +} + +type putEndpointsRequest struct { + Name string `valid:"required"` + URL string `valid:"required"` + TLS bool +} + +// handleDeleteEndpoint handles DELETE requests on /endpoints/:id +func (handler *EndpointHandler) handleDeleteEndpoint(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + endpointID, err := strconv.Atoi(id) + if err != nil { + Error(w, err, http.StatusBadRequest, handler.Logger) + return + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrEndpointNotFound { + Error(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + err = handler.EndpointService.DeleteEndpoint(portainer.EndpointID(endpointID)) + if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if endpoint.TLS { + err = handler.FileService.DeleteTLSFiles(portainer.EndpointID(endpointID)) + if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + } + } +} diff --git a/api/http/handler.go b/api/http/handler.go index 3bcfdff54..ca4b15ede 100644 --- a/api/http/handler.go +++ b/api/http/handler.go @@ -13,10 +13,12 @@ import ( type Handler struct { AuthHandler *AuthHandler UserHandler *UserHandler + EndpointHandler *EndpointHandler SettingsHandler *SettingsHandler TemplatesHandler *TemplatesHandler DockerHandler *DockerHandler WebSocketHandler *WebSocketHandler + UploadHandler *UploadHandler FileHandler http.Handler } @@ -33,10 +35,14 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.StripPrefix("/api", h.AuthHandler).ServeHTTP(w, r) } else if strings.HasPrefix(r.URL.Path, "/api/users") { http.StripPrefix("/api", h.UserHandler).ServeHTTP(w, r) + } else if strings.HasPrefix(r.URL.Path, "/api/endpoints") { + http.StripPrefix("/api", h.EndpointHandler).ServeHTTP(w, r) } else if strings.HasPrefix(r.URL.Path, "/api/settings") { http.StripPrefix("/api", h.SettingsHandler).ServeHTTP(w, r) } else if strings.HasPrefix(r.URL.Path, "/api/templates") { http.StripPrefix("/api", h.TemplatesHandler).ServeHTTP(w, r) + } else if strings.HasPrefix(r.URL.Path, "/api/upload") { + http.StripPrefix("/api", h.UploadHandler).ServeHTTP(w, r) } else if strings.HasPrefix(r.URL.Path, "/api/websocket") { http.StripPrefix("/api", h.WebSocketHandler).ServeHTTP(w, r) } else if strings.HasPrefix(r.URL.Path, "/api/docker") { diff --git a/api/http/server.go b/api/http/server.go index 4f7d4efa1..cd376883b 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -8,14 +8,33 @@ import ( // Server implements the portainer.Server interface type Server struct { - BindAddress string - AssetsPath string - UserService portainer.UserService - CryptoService portainer.CryptoService - JWTService portainer.JWTService - Settings *portainer.Settings - TemplatesURL string - EndpointConfig *portainer.EndpointConfiguration + BindAddress string + AssetsPath string + UserService portainer.UserService + EndpointService portainer.EndpointService + CryptoService portainer.CryptoService + JWTService portainer.JWTService + FileService portainer.FileService + Settings *portainer.Settings + TemplatesURL string + ActiveEndpoint *portainer.Endpoint + Handler *Handler +} + +func (server *Server) updateActiveEndpoint(endpoint *portainer.Endpoint) error { + if endpoint != nil { + server.ActiveEndpoint = endpoint + server.Handler.WebSocketHandler.endpoint = endpoint + err := server.Handler.DockerHandler.setupProxy(endpoint) + if err != nil { + return err + } + err = server.EndpointService.SetActive(endpoint) + if err != nil { + return err + } + } + return nil } // Start starts the HTTP server @@ -23,6 +42,7 @@ func (server *Server) Start() error { middleWareService := &middleWareService{ jwtService: server.JWTService, } + var authHandler = NewAuthHandler() authHandler.UserService = server.UserService authHandler.CryptoService = server.CryptoService @@ -35,19 +55,31 @@ func (server *Server) Start() error { var templatesHandler = NewTemplatesHandler(middleWareService) templatesHandler.templatesURL = server.TemplatesURL var dockerHandler = NewDockerHandler(middleWareService) - dockerHandler.setupProxy(server.EndpointConfig) var websocketHandler = NewWebSocketHandler() - websocketHandler.endpointConfiguration = server.EndpointConfig + // EndpointHandler requires a reference to the server to be able to update the active endpoint. + var endpointHandler = NewEndpointHandler(middleWareService) + endpointHandler.EndpointService = server.EndpointService + endpointHandler.FileService = server.FileService + endpointHandler.server = server + var uploadHandler = NewUploadHandler(middleWareService) + uploadHandler.FileService = server.FileService var fileHandler = http.FileServer(http.Dir(server.AssetsPath)) - handler := &Handler{ + server.Handler = &Handler{ AuthHandler: authHandler, UserHandler: userHandler, + EndpointHandler: endpointHandler, SettingsHandler: settingsHandler, TemplatesHandler: templatesHandler, DockerHandler: dockerHandler, WebSocketHandler: websocketHandler, FileHandler: fileHandler, + UploadHandler: uploadHandler, } - return http.ListenAndServe(server.BindAddress, handler) + err := server.updateActiveEndpoint(server.ActiveEndpoint) + if err != nil { + return err + } + + return http.ListenAndServe(server.BindAddress, server.Handler) } diff --git a/api/http/settings_handler.go b/api/http/settings_handler.go index 4768e1a05..fab77aaa7 100644 --- a/api/http/settings_handler.go +++ b/api/http/settings_handler.go @@ -3,10 +3,11 @@ package http import ( "github.com/portainer/portainer" - "github.com/gorilla/mux" "log" "net/http" "os" + + "github.com/gorilla/mux" ) // SettingsHandler represents an HTTP API handler for managing settings. @@ -30,8 +31,8 @@ func NewSettingsHandler(middleWareService *middleWareService) *SettingsHandler { // handleGetSettings handles GET requests on /settings func (handler *SettingsHandler) handleGetSettings(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" { - handleNotAllowed(w, []string{"GET"}) + if r.Method != http.MethodGet { + handleNotAllowed(w, []string{http.MethodGet}) return } diff --git a/api/http/templates_handler.go b/api/http/templates_handler.go index b690012fb..867891291 100644 --- a/api/http/templates_handler.go +++ b/api/http/templates_handler.go @@ -2,11 +2,12 @@ package http import ( "fmt" - "github.com/gorilla/mux" "io/ioutil" "log" "net/http" "os" + + "github.com/gorilla/mux" ) // TemplatesHandler represents an HTTP API handler for managing templates. @@ -32,8 +33,8 @@ func NewTemplatesHandler(middleWareService *middleWareService) *TemplatesHandler // handleGetTemplates handles GET requests on /templates func (handler *TemplatesHandler) handleGetTemplates(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" { - handleNotAllowed(w, []string{"GET"}) + if r.Method != http.MethodGet { + handleNotAllowed(w, []string{http.MethodGet}) return } diff --git a/api/http/upload_handler.go b/api/http/upload_handler.go new file mode 100644 index 000000000..24b992392 --- /dev/null +++ b/api/http/upload_handler.go @@ -0,0 +1,74 @@ +package http + +import ( + "github.com/portainer/portainer" + + "log" + "net/http" + "os" + "strconv" + + "github.com/gorilla/mux" +) + +// UploadHandler represents an HTTP API handler for managing file uploads. +type UploadHandler struct { + *mux.Router + Logger *log.Logger + FileService portainer.FileService + middleWareService *middleWareService +} + +// NewUploadHandler returns a new instance of UploadHandler. +func NewUploadHandler(middleWareService *middleWareService) *UploadHandler { + h := &UploadHandler{ + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + middleWareService: middleWareService, + } + h.Handle("/upload/tls/{endpointID}/{certificate:(ca|cert|key)}", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h.handlePostUploadTLS(w, r) + }))) + return h +} + +func (handler *UploadHandler) handlePostUploadTLS(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + handleNotAllowed(w, []string{http.MethodPost}) + return + } + + vars := mux.Vars(r) + endpointID := vars["endpointID"] + certificate := vars["certificate"] + ID, err := strconv.Atoi(endpointID) + if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + file, _, err := r.FormFile("file") + defer file.Close() + if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + var fileType portainer.TLSFileType + switch certificate { + case "ca": + fileType = portainer.TLSFileCA + case "cert": + fileType = portainer.TLSFileCert + case "key": + fileType = portainer.TLSFileKey + default: + Error(w, portainer.ErrUndefinedTLSFileType, http.StatusInternalServerError, handler.Logger) + return + } + + err = handler.FileService.StoreTLSFile(portainer.EndpointID(ID), fileType, file) + if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + } +} diff --git a/api/http/user_handler.go b/api/http/user_handler.go index 461130e76..b47cda7ca 100644 --- a/api/http/user_handler.go +++ b/api/http/user_handler.go @@ -4,11 +4,12 @@ import ( "github.com/portainer/portainer" "encoding/json" - "github.com/asaskevich/govalidator" - "github.com/gorilla/mux" "log" "net/http" "os" + + "github.com/asaskevich/govalidator" + "github.com/gorilla/mux" ) // UserHandler represents an HTTP API handler for managing users. @@ -32,10 +33,10 @@ func NewUserHandler(middleWareService *middleWareService) *UserHandler { }))) h.Handle("/users/{username}", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h.handleGetUser(w, r) - }))).Methods("GET") + }))).Methods(http.MethodGet) h.Handle("/users/{username}", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h.handlePutUser(w, r) - }))).Methods("PUT") + }))).Methods(http.MethodPut) h.Handle("/users/{username}/passwd", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h.handlePostUserPasswd(w, r) }))) @@ -46,8 +47,8 @@ func NewUserHandler(middleWareService *middleWareService) *UserHandler { // handlePostUsers handles POST requests on /users func (handler *UserHandler) handlePostUsers(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - handleNotAllowed(w, []string{"POST"}) + if r.Method != http.MethodPost { + handleNotAllowed(w, []string{http.MethodPost}) return } @@ -86,8 +87,8 @@ type postUsersRequest struct { // handlePostUserPasswd handles POST requests on /users/:username/passwd func (handler *UserHandler) handlePostUserPasswd(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - handleNotAllowed(w, []string{"POST"}) + if r.Method != http.MethodPost { + handleNotAllowed(w, []string{http.MethodPost}) return } @@ -189,8 +190,8 @@ type putUserRequest struct { // handlePostAdminInit handles GET requests on /users/admin/check func (handler *UserHandler) handleGetAdminCheck(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" { - handleNotAllowed(w, []string{"GET"}) + if r.Method != http.MethodGet { + handleNotAllowed(w, []string{http.MethodGet}) return } @@ -209,8 +210,8 @@ func (handler *UserHandler) handleGetAdminCheck(w http.ResponseWriter, r *http.R // handlePostAdminInit handles POST requests on /users/admin/init func (handler *UserHandler) handlePostAdminInit(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - handleNotAllowed(w, []string{"POST"}) + if r.Method != http.MethodPost { + handleNotAllowed(w, []string{http.MethodPost}) return } diff --git a/api/http/websocket_handler.go b/api/http/websocket_handler.go index 365a1ccd1..c217404a0 100644 --- a/api/http/websocket_handler.go +++ b/api/http/websocket_handler.go @@ -7,8 +7,6 @@ import ( "crypto/tls" "encoding/json" "fmt" - "github.com/gorilla/mux" - "golang.org/x/net/websocket" "io" "log" "net" @@ -17,14 +15,17 @@ import ( "net/url" "os" "time" + + "github.com/gorilla/mux" + "golang.org/x/net/websocket" ) // WebSocketHandler represents an HTTP API handler for proxying requests to a web socket. type WebSocketHandler struct { *mux.Router - Logger *log.Logger - middleWareService *middleWareService - endpointConfiguration *portainer.EndpointConfiguration + Logger *log.Logger + middleWareService *middleWareService + endpoint *portainer.Endpoint } // NewWebSocketHandler returns a new instance of WebSocketHandler. @@ -42,7 +43,7 @@ func (handler *WebSocketHandler) webSocketDockerExec(ws *websocket.Conn) { execID := qry.Get("id") // Should not be managed here - endpoint, err := url.Parse(handler.endpointConfiguration.Endpoint) + endpoint, err := url.Parse(handler.endpoint.URL) if err != nil { log.Fatalf("Unable to parse endpoint URL: %s", err) return @@ -57,10 +58,10 @@ func (handler *WebSocketHandler) webSocketDockerExec(ws *websocket.Conn) { // Should not be managed here var tlsConfig *tls.Config - if handler.endpointConfiguration.TLS { - tlsConfig, err = createTLSConfiguration(handler.endpointConfiguration.TLSCACertPath, - handler.endpointConfiguration.TLSCertPath, - handler.endpointConfiguration.TLSKeyPath) + if handler.endpoint.TLS { + tlsConfig, err = createTLSConfiguration(handler.endpoint.TLSCACertPath, + handler.endpoint.TLSCertPath, + handler.endpoint.TLSKeyPath) if err != nil { log.Fatalf("Unable to create TLS configuration: %s", err) return diff --git a/api/portainer.go b/api/portainer.go index d9038c630..60703300f 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -1,5 +1,9 @@ package portainer +import ( + "io" +) + type ( // Pair defines a key/value string pair Pair struct { @@ -15,7 +19,6 @@ type ( Endpoint *string Labels *[]Pair Logo *string - Swarm *bool Templates *string TLSVerify *bool TLSCacert *string @@ -25,15 +28,14 @@ type ( // Settings represents Portainer settings. Settings struct { - Swarm bool `json:"swarm"` HiddenLabels []Pair `json:"hiddenLabels"` Logo string `json:"logo"` } // User represent a user account. User struct { - Username string `json:"username"` - Password string `json:"password,omitempty"` + Username string `json:"Username"` + Password string `json:"Password,omitempty"` } // TokenData represents the data embedded in a JWT token. @@ -41,15 +43,25 @@ type ( Username string } - // EndpointConfiguration represents the data required to connect to a Docker API endpoint. - EndpointConfiguration struct { - Endpoint string - TLS bool - TLSCACertPath string - TLSCertPath string - TLSKeyPath string + // EndpointID represents an endpoint identifier. + EndpointID 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"` + URL string `json:"URL"` + TLS bool `json:"TLS"` + TLSCACertPath string `json:"TLSCACert,omitempty"` + TLSCertPath string `json:"TLSCert,omitempty"` + TLSKeyPath string `json:"TLSKey,omitempty"` } + // TLSFileType represents a type of TLS file required to connect to a Docker endpoint. + // It can be either a TLS CA file, a TLS certificate file or a TLS key file. + TLSFileType int + // CLIService represents a service for managing CLI. CLIService interface { ParseFlags(version string) (*CLIFlags, error) @@ -73,6 +85,17 @@ type ( UpdateUser(user *User) error } + // EndpointService represents a service for managing endpoints. + EndpointService interface { + Endpoint(ID EndpointID) (*Endpoint, error) + Endpoints() ([]Endpoint, error) + CreateEndpoint(endpoint *Endpoint) error + UpdateEndpoint(ID EndpointID, endpoint *Endpoint) error + DeleteEndpoint(ID EndpointID) error + GetActive() (*Endpoint, error) + SetActive(endpoint *Endpoint) error + } + // CryptoService represents a service for encrypting/hashing data. CryptoService interface { Hash(data string) (string, error) @@ -84,9 +107,25 @@ type ( GenerateToken(data *TokenData) (string, error) VerifyToken(token string) error } + + // FileService represents a service for managing files. + FileService interface { + StoreTLSFile(endpointID EndpointID, fileType TLSFileType, r io.Reader) error + GetPathForTLSFile(endpointID EndpointID, fileType TLSFileType) (string, error) + DeleteTLSFiles(endpointID EndpointID) error + } ) const ( // APIVersion is the version number of portainer API. APIVersion = "1.10.2" ) + +const ( + // TLSFileCA represents a TLS CA certificate file. + TLSFileCA TLSFileType = iota + // TLSFileCert represents a TLS certificate file. + TLSFileCert + // TLSFileKey represents a TLS key file. + TLSFileKey +) diff --git a/app/app.js b/app/app.js index dc0aa5024..e8171d909 100644 --- a/app/app.js +++ b/app/app.js @@ -5,6 +5,7 @@ angular.module('portainer', [ 'ui.select', 'ngCookies', 'ngSanitize', + 'ngFileUpload', 'angularUtils.directives.dirPagination', 'LocalStorageModule', 'angular-jwt', @@ -19,6 +20,9 @@ angular.module('portainer', [ 'containers', 'createContainer', 'docker', + 'endpoint', + 'endpointInit', + 'endpoints', 'events', 'images', 'image', @@ -270,6 +274,50 @@ angular.module('portainer', [ requiresLogin: true } }) + .state('endpoints', { + url: '/endpoints/', + views: { + "content": { + templateUrl: 'app/components/endpoints/endpoints.html', + controller: 'EndpointsController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } + }) + .state('endpoint', { + url: '^/endpoints/:id', + views: { + "content": { + templateUrl: 'app/components/endpoint/endpoint.html', + controller: 'EndpointController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } + }) + .state('endpointInit', { + url: '/init/endpoint', + views: { + "content": { + templateUrl: 'app/components/endpointInit/endpointInit.html', + controller: 'EndpointInitController' + } + }, + data: { + requiresLogin: true + } + }) .state('events', { url: '/events/', views: { @@ -491,18 +539,19 @@ angular.module('portainer', [ }); $rootScope.$on("$stateChangeStart", function(event, toState, toParams, fromState, fromParams) { - if ((fromState.name === 'auth' || fromState.name === '') && Authentication.isAuthenticated()) { + if (toState.name !== 'endpointInit' && (fromState.name === 'auth' || fromState.name === '' || fromState.name === 'endpointInit') && Authentication.isAuthenticated()) { EndpointMode.determineEndpointMode(); } }); }]) // This is your docker url that the api will use to make requests // You need to set this to the api endpoint without the port i.e. http://192.168.1.9 - .constant('DOCKER_ENDPOINT', '/api/docker') .constant('DOCKER_PORT', '') // Docker port, leave as an empty string if no port is required. If you have a port, prefix it with a ':' i.e. :4243 - .constant('CONFIG_ENDPOINT', '/api/settings') + .constant('DOCKER_ENDPOINT', '/api/docker') + .constant('CONFIG_ENDPOINT', '/api/settings') .constant('AUTH_ENDPOINT', '/api/auth') .constant('USERS_ENDPOINT', '/api/users') + .constant('ENDPOINTS_ENDPOINT', '/api/endpoints') .constant('TEMPLATES_ENDPOINT', '/api/templates') .constant('PAGINATION_MAX_ITEMS', 10) .constant('UI_VERSION', 'v1.10.2'); diff --git a/app/components/auth/auth.html b/app/components/auth/auth.html index f335c52ef..8668d328a 100644 --- a/app/components/auth/auth.html +++ b/app/components/auth/auth.html @@ -1,11 +1,11 @@ -