diff --git a/.gitignore b/.gitignore index 61fe02d69..42c695629 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules bower_components dist portainer-checksum.txt +api/cmd/portainer/portainer* 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 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) diff --git a/api/api.go b/api/api.go deleted file mode 100644 index af3eb23ed..000000000 --- a/api/api.go +++ /dev/null @@ -1,61 +0,0 @@ -package main - -import ( - "crypto/tls" - "log" - "net/http" - "net/url" -) - -type ( - api struct { - endpoint *url.URL - bindAddress string - assetPath string - dataPath string - tlsConfig *tls.Config - templatesURL string - } - - apiConfig struct { - Endpoint string - BindAddress string - AssetPath string - DataPath string - SwarmSupport bool - TLSEnabled bool - TLSCACertPath string - TLSCertPath string - TLSKeyPath string - TemplatesURL string - } -) - -func (a *api) run(settings *Settings) { - 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 newAPI(apiConfig apiConfig) *api { - endpointURL, err := url.Parse(apiConfig.Endpoint) - if err != nil { - log.Fatal(err) - } - - 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, - } -} diff --git a/api/bolt/datastore.go b/api/bolt/datastore.go new file mode 100644 index 000000000..d0321db21 --- /dev/null +++ b/api/bolt/datastore.go @@ -0,0 +1,72 @@ +package bolt + +import ( + "time" + + "github.com/boltdb/bolt" +) + +// 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 + EndpointService *EndpointService + + db *bolt.DB +} + +const ( + 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{}, + EndpointService: &EndpointService{}, + } + store.UserService.store = store + store.EndpointService.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 + } + _, err = tx.CreateBucketIfNotExists([]byte(endpointBucketName)) + if err != nil { + return err + } + _, err = tx.CreateBucketIfNotExists([]byte(activeEndpointBucketName)) + 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/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 new file mode 100644 index 000000000..e9d6416eb --- /dev/null +++ b/api/bolt/internal/internal.go @@ -0,0 +1,37 @@ +package internal + +import ( + "github.com/portainer/portainer" + + "encoding/binary" + "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) +} + +// 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/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..ccd9c1f9a --- /dev/null +++ b/api/cli/cli.go @@ -0,0 +1,61 @@ +package cli + +import ( + "github.com/portainer/portainer" + + "os" + "strings" + + "gopkg.in/alecthomas/kingpin.v2" +) + +// 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{ + 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(), + 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 *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 + } + 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..c11f9b34f --- /dev/null +++ b/api/cmd/portainer/main.go @@ -0,0 +1,92 @@ +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/file" + "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{ + 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) + } + + fileService, err := file.NewService(*flags.Data) + if err != nil { + log.Fatal(err) + } + + var cryptoService portainer.CryptoService = &crypto.Service{} + + // 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, + EndpointService: store.EndpointService, + CryptoService: cryptoService, + JWTService: jwtService, + FileService: fileService, + ActiveEndpoint: activeEndpoint, + } + + 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/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/errors.go b/api/errors.go new file mode 100644 index 000000000..8fcd44758 --- /dev/null +++ b/api/errors.go @@ -0,0 +1,39 @@ +package portainer + +// General errors. +const ( + ErrUnauthorized = Error("Unauthorized") +) + +// User errors. +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") +) + +// JWT errors. +const ( + ErrSecretGeneration = Error("Unable to generate secret key") + ErrInvalidJWTToken = Error("Invalid JWT token") +) + +// File errors. +const ( + ErrUndefinedTLSFileType = Error("Undefined TLS file type") +) + +// 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/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/handler.go b/api/handler.go deleted file mode 100644 index ef60a8583..000000000 --- a/api/handler.go +++ /dev/null @@ -1,82 +0,0 @@ -package main - -import ( - "golang.org/x/net/websocket" - "log" - "net/http" - "net/http/httputil" - "net/url" - "os" -) - -// newHandler creates a new http.Handler with CSRF protection -func (a *api) newHandler(settings *Settings) http.Handler { - var ( - mux = http.NewServeMux() - 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("/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) - }) - // 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) - }) -} - -// 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..29c19201d --- /dev/null +++ b/api/http/auth_handler.go @@ -0,0 +1,96 @@ +package http + +import ( + "github.com/portainer/portainer" + + "encoding/json" + "log" + "net/http" + "os" + + "github.com/asaskevich/govalidator" + "github.com/gorilla/mux" +) + +// 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 AuthHandler. +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 != http.MethodPost { + handleNotAllowed(w, []string{http.MethodPost}) + 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..4894b797c --- /dev/null +++ b/api/http/docker_handler.go @@ -0,0 +1,159 @@ +package http + +import ( + "github.com/portainer/portainer" + + "io" + "log" + "net" + "net/http" + "net/http/httputil" + "net/url" + "os" + "strings" + + "github.com/gorilla/mux" +) + +// 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) { + if handler.proxy != nil { + handler.proxy.ServeHTTP(w, r) + } else { + Error(w, portainer.ErrNoActiveEndpoint, http.StatusNotFound, handler.Logger) + } +} + +func (handler *DockerHandler) setupProxy(endpoint *portainer.Endpoint) error { + var proxy http.Handler + endpointURL, err := url.Parse(endpoint.URL) + if err != nil { + return err + } + if endpointURL.Scheme == "tcp" { + if endpoint.TLS { + proxy, err = newHTTPSProxy(endpointURL, endpoint) + if err != nil { + return err + } + } else { + proxy = newHTTPProxy(endpointURL) + } + } else { + // Assume unix:// scheme + proxy = newSocketProxy(endpointURL.Path) + } + handler.proxy = proxy + return nil +} + +// singleJoiningSlash from golang.org/src/net/http/httputil/reverseproxy.go +// included here for use in NewSingleHostReverseProxyWithHostHeader +// because its used in NewSingleHostReverseProxy from golang.org/src/net/http/httputil/reverseproxy.go +func singleJoiningSlash(a, b string) string { + aslash := strings.HasSuffix(a, "/") + bslash := strings.HasPrefix(b, "/") + switch { + case aslash && bslash: + return a + b[1:] + case !aslash && !bslash: + return a + "/" + b + } + 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 NewSingleHostReverseProxyWithHostHeader(u) +} + +func newHTTPSProxy(u *url.URL, endpoint *portainer.Endpoint) (http.Handler, error) { + u.Scheme = "https" + proxy := NewSingleHostReverseProxyWithHostHeader(u) + config, err := createTLSConfiguration(endpoint.TLSCACertPath, endpoint.TLSCertPath, endpoint.TLSKeyPath) + 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/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 new file mode 100644 index 000000000..ca4b15ede --- /dev/null +++ b/api/http/handler.go @@ -0,0 +1,82 @@ +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 + EndpointHandler *EndpointHandler + SettingsHandler *SettingsHandler + TemplatesHandler *TemplatesHandler + DockerHandler *DockerHandler + WebSocketHandler *WebSocketHandler + UploadHandler *UploadHandler + 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/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") { + 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/http/middleware.go b/api/http/middleware.go new file mode 100644 index 000000000..99775dec6 --- /dev/null +++ b/api/http/middleware.go @@ -0,0 +1,63 @@ +package http + +import ( + "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) + } + return h +} + +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 + 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 + } + + err := service.jwtService.VerifyToken(token) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + next.ServeHTTP(w, r) + return + }) +} diff --git a/api/http/server.go b/api/http/server.go new file mode 100644 index 000000000..cd376883b --- /dev/null +++ b/api/http/server.go @@ -0,0 +1,85 @@ +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 + 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 +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) + var websocketHandler = NewWebSocketHandler() + // 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)) + + server.Handler = &Handler{ + AuthHandler: authHandler, + UserHandler: userHandler, + EndpointHandler: endpointHandler, + SettingsHandler: settingsHandler, + TemplatesHandler: templatesHandler, + DockerHandler: dockerHandler, + WebSocketHandler: websocketHandler, + FileHandler: fileHandler, + UploadHandler: uploadHandler, + } + err := server.updateActiveEndpoint(server.ActiveEndpoint) + if err != nil { + return err + } + + return http.ListenAndServe(server.BindAddress, server.Handler) +} diff --git a/api/http/settings_handler.go b/api/http/settings_handler.go new file mode 100644 index 000000000..fab77aaa7 --- /dev/null +++ b/api/http/settings_handler.go @@ -0,0 +1,40 @@ +package http + +import ( + "github.com/portainer/portainer" + + "log" + "net/http" + "os" + + "github.com/gorilla/mux" +) + +// 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 != http.MethodGet { + handleNotAllowed(w, []string{http.MethodGet}) + 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..867891291 --- /dev/null +++ b/api/http/templates_handler.go @@ -0,0 +1,56 @@ +package http + +import ( + "fmt" + "io/ioutil" + "log" + "net/http" + "os" + + "github.com/gorilla/mux" +) + +// 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 != http.MethodGet { + handleNotAllowed(w, []string{http.MethodGet}) + 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/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 new file mode 100644 index 000000000..b47cda7ca --- /dev/null +++ b/api/http/user_handler.go @@ -0,0 +1,248 @@ +package http + +import ( + "github.com/portainer/portainer" + + "encoding/json" + "log" + "net/http" + "os" + + "github.com/asaskevich/govalidator" + "github.com/gorilla/mux" +) + +// 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(http.MethodGet) + h.Handle("/users/{username}", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h.handlePutUser(w, r) + }))).Methods(http.MethodPut) + h.Handle("/users/{username}/passwd", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h.handlePostUserPasswd(w, r) + }))) + h.HandleFunc("/users/admin/check", h.handleGetAdminCheck) + h.HandleFunc("/users/admin/init", h.handlePostAdminInit) + return h +} + +// handlePostUsers handles POST requests on /users +func (handler *UserHandler) handlePostUsers(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + handleNotAllowed(w, []string{http.MethodPost}) + return + } + + var req postUsersRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + 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 != http.MethodPost { + handleNotAllowed(w, []string{http.MethodPost}) + 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 != http.MethodGet { + handleNotAllowed(w, []string{http.MethodGet}) + 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 != http.MethodPost { + handleNotAllowed(w, []string{http.MethodPost}) + 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 61% rename from api/hijack.go rename to api/http/websocket_handler.go index ff6cd9071..c217404a0 100644 --- a/api/hijack.go +++ b/api/http/websocket_handler.go @@ -1,17 +1,79 @@ -package main +package http import ( + "github.com/portainer/portainer" + "bytes" "crypto/tls" "encoding/json" "fmt" "io" + "log" "net" "net/http" "net/http/httputil" + "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 + endpoint *portainer.Endpoint +} + +// 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.endpoint.URL) + 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.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 + } + } + + 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/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 82fbd5651..000000000 --- a/api/main.go +++ /dev/null @@ -1,47 +0,0 @@ -package main // import "github.com/portainer/portainer" - -import ( - "gopkg.in/alecthomas/kingpin.v2" -) - -// main is the entry point of the program -func main() { - kingpin.Version("1.10.2") - 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() - 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..a2e99e0de --- /dev/null +++ b/api/portainer.go @@ -0,0 +1,131 @@ +package portainer + +import ( + "io" +) + +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 + Templates *string + TLSVerify *bool + TLSCacert *string + TLSCert *string + TLSKey *string + } + + // Settings represents Portainer settings. + Settings struct { + 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 + } + + // 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) + 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 + } + + // 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) + 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 + } + + // 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.11.0" +) + +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/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/app/app.js b/app/app.js index f06c050fc..a070100ed 100644 --- a/app/app.js +++ b/app/app.js @@ -5,10 +5,14 @@ angular.module('portainer', [ 'ui.select', 'ngCookies', 'ngSanitize', + 'ngFileUpload', 'angularUtils.directives.dirPagination', + 'LocalStorageModule', + 'angular-jwt', 'portainer.services', 'portainer.helpers', 'portainer.filters', + 'auth', 'dashboard', 'container', 'containerConsole', @@ -16,11 +20,17 @@ angular.module('portainer', [ 'containers', 'createContainer', 'docker', + 'endpoint', + 'endpointInit', + 'endpoints', 'events', 'images', 'image', + 'main', 'service', 'services', + 'settings', + 'sidebar', 'createService', 'stats', 'swarm', @@ -31,163 +41,517 @@ 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('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/', - 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 - // $httpProvider.defaults.xsrfCookieName = 'csrfToken'; - // $httpProvider.defaults.xsrfHeaderName = 'X-CSRF-Token'; $httpProvider.interceptors.push(function() { 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(), time: 10000 }); } - // CSRF protection is disabled for the moment - // var csrfToken = response.headers('X-Csrf-Token'); - // if (csrfToken) { - // document.cookie = 'csrfToken=' + csrfToken; - // } return response; } }; }); }]) + .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 (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', '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('CONFIG_ENDPOINT', 'settings') - .constant('TEMPLATES_ENDPOINT', 'templates') + .constant('DOCKER_PORT', '') // Docker port, leave as an empty string if no port is required. If you have a port, prefix it with a ':' i.e. :4243 + .constant('DOCKER_ENDPOINT', '/api/docker') + .constant('CONFIG_ENDPOINT', '/api/settings') + .constant('AUTH_ENDPOINT', '/api/auth') + .constant('USERS_ENDPOINT', '/api/users') + .constant('ENDPOINTS_ENDPOINT', '/api/endpoints') + .constant('TEMPLATES_ENDPOINT', '/api/templates') .constant('PAGINATION_MAX_ITEMS', 10) - .constant('UI_VERSION', 'v1.10.2'); + .constant('UI_VERSION', 'v1.11.0'); diff --git a/app/components/auth/auth.html b/app/components/auth/auth.html new file mode 100644 index 000000000..8668d328a --- /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..89d99b6d3 --- /dev/null +++ b/app/components/auth/authController.js @@ -0,0 +1,75 @@ +angular.module('auth', []) +.controller('AuthenticationController', ['$scope', '$state', '$stateParams', '$window', '$timeout', '$sanitize', 'Config', 'Authentication', 'Users', 'EndpointService', 'Messages', +function ($scope, $state, $stateParams, $window, $timeout, $sanitize, Config, Authentication, Users, EndpointService, 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 success() { + EndpointService.getActive().then(function success(data) { + $state.go('dashboard'); + }, function error(err) { + if (err.status === 404) { + $state.go('endpointInit'); + } else { + Messages.error("Failure", err, 'Unable to verify Docker endpoint existence'); + } + }); + }, function error() { + $scope.authData.error = 'Invalid credentials'; + }); + }; +}]); 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 @@ - - - +
+ + + +
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/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/components/containers/containers.html b/app/components/containers/containers.html index 97a9f677a..7d1ecca69 100644 --- a/app/components/containers/containers.html +++ b/app/components/containers/containers.html @@ -66,7 +66,7 @@ - + Host IP @@ -85,12 +85,12 @@ - {{ container.Status|containerstatus }} - {{ container|swarmcontainername}} - {{ container|containername}} + {{ container.Status }} + {{ 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..7705ef770 100644 --- a/app/components/containers/containersController.js +++ b/app/components/containers/containersController.js @@ -1,15 +1,13 @@ 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; $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; @@ -25,10 +23,12 @@ 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; } - 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 +150,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 ($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 9ea9ac735..439b7bf34 100644 --- a/app/components/createContainer/createContainerController.js +++ b/app/components/createContainer/createContainerController.js @@ -1,13 +1,14 @@ 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, 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,13 +53,15 @@ 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; - Info.get({}, function(info) { - if ($scope.swarm && !_.startsWith(info.ServerVersion, 'swarm')) { - $scope.swarm_mode = true; - } - }); var containersToHideLabels = c.hiddenLabels; Volume.query({}, function (d) { @@ -68,7 +72,7 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai Network.query({}, function (d) { var networks = d; - if ($scope.swarm) { + if ($scope.endpointMode.provider === 'DOCKER_SWARM' || $scope.endpointMode.provider === 'DOCKER_SWARM_MODE') { networks = d.filter(function (network) { if (network.Scope === 'global') { return network; @@ -129,37 +133,17 @@ 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'); }); } - 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; } @@ -236,7 +220,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.endpointMode.provider === 'DOCKER_SWARM') { containerName = $filter('swarmcontainername')(container); } } @@ -247,6 +231,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); @@ -255,6 +249,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 795ecfefa..d7697b0b8 100644 --- a/app/components/createContainer/createcontainer.html +++ b/app/components/createContainer/createcontainer.html @@ -80,7 +80,7 @@
- @@ -95,6 +95,16 @@
+ +
+ +
+ + label + +
+
+ @@ -109,6 +119,7 @@
  • Command
  • Volumes
  • Network
  • +
  • Labels
  • Security/Host
  • @@ -232,7 +243,7 @@
    Path - @@ -258,7 +269,7 @@
    -
    +
    You don't have any shared network. Head over the networks view to create one.
    @@ -267,7 +278,7 @@
    - @@ -278,10 +289,10 @@
    - -
    @@ -306,6 +317,41 @@
    + +
    +
    + +
    + +
    + + label + +
    + +
    +
    +
    + name + +
    +
    + value + + + + +
    +
    +
    + +
    + +
    +
    +
    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 + + + + +
    +
    +
    + +
    + diff --git a/app/components/createService/createServiceController.js b/app/components/createService/createServiceController.js index 4f7619e6c..8497463ab 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: [], @@ -17,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() { @@ -69,8 +73,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) { @@ -93,9 +97,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; @@ -157,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 = { @@ -177,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 a1d017fd8..c38c606e4 100644 --- a/app/components/createService/createservice.html +++ b/app/components/createService/createservice.html @@ -72,7 +72,7 @@
    - @@ -102,6 +102,7 @@
  • Volumes
  • Network
  • Labels
  • +
  • Update config
  • @@ -116,6 +117,14 @@
    + +
    + +
    + +
    +
    +
    @@ -183,7 +192,7 @@
    bind - @@ -213,7 +222,7 @@
    - @@ -238,7 +247,7 @@ - @@ -316,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/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..e82cec9da 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 @@ -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,11 +80,13 @@ 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"); }); } Config.$promise.then(function (c) { - $scope.swarm = c.swarm; fetchDashboardData(c.hiddenLabels); }); }]); diff --git a/app/components/endpoint/endpoint.html b/app/components/endpoint/endpoint.html new file mode 100644 index 000000000..3cb1b240c --- /dev/null +++ b/app/components/endpoint/endpoint.html @@ -0,0 +1,99 @@ + + + + + + Endpoints > {{ endpoint.Name }} + + + +
    +
    + + +
    + +
    + +
    + +
    +
    + + +
    + +
    + +
    +
    + + +
    + +
    + +
    +
    + + +
    + +
    + +
    + + + {{ formValues.TLSCACert.name }} + + + + +
    +
    + + +
    + +
    + + + {{ formValues.TLSCert.name }} + + + + +
    +
    + + +
    + +
    + + + {{ formValues.TLSKey.name }} + + + + +
    +
    + +
    + +
    +
    + + Cancel + + + {{ state.error }} + +
    +
    +
    +
    +
    +
    +
    diff --git a/app/components/endpoint/endpointController.js b/app/components/endpoint/endpointController.js new file mode 100644 index 000000000..1d8d4cedc --- /dev/null +++ b/app/components/endpoint/endpointController.js @@ -0,0 +1,55 @@ +angular.module('endpoint', []) +.controller('EndpointController', ['$scope', '$state', '$stateParams', '$filter', 'EndpointService', 'Messages', +function ($scope, $state, $stateParams, $filter, EndpointService, Messages) { + $scope.state = { + error: '', + uploadInProgress: false + }; + $scope.formValues = { + TLSCACert: null, + TLSCert: null, + TLSKey: null + }; + + $scope.updateEndpoint = function() { + var ID = $scope.endpoint.Id; + var name = $scope.endpoint.Name; + var URL = $scope.endpoint.URL; + var TLS = $scope.endpoint.TLS; + var TLSCACert = $scope.formValues.TLSCACert !== $scope.endpoint.TLSCACert ? $scope.formValues.TLSCACert : null; + var TLSCert = $scope.formValues.TLSCert !== $scope.endpoint.TLSCert ? $scope.formValues.TLSCert : null; + var TLSKey = $scope.formValues.TLSKey !== $scope.endpoint.TLSKey ? $scope.formValues.TLSKey : null; + EndpointService.updateEndpoint(ID, name, URL, TLS, TLSCACert, TLSCert, TLSKey).then(function success(data) { + Messages.send("Endpoint updated", $scope.endpoint.Name); + $state.go('endpoints'); + }, function error(err) { + $scope.state.error = err.msg; + }, function update(evt) { + if (evt.upload) { + $scope.state.uploadInProgress = evt.upload; + } + }); + }; + + function getEndpoint(endpointID) { + $('#loadingViewSpinner').show(); + EndpointService.endpoint($stateParams.id).then(function success(data) { + $('#loadingViewSpinner').hide(); + $scope.endpoint = data; + if (data.URL.indexOf("unix://") === 0) { + $scope.endpointType = 'local'; + } else { + $scope.endpointType = 'remote'; + } + $scope.endpoint.URL = $filter('stripprotocol')(data.URL); + $scope.formValues.TLSCACert = data.TLSCACert; + $scope.formValues.TLSCert = data.TLSCert; + $scope.formValues.TLSKey = data.TLSKey; + }, function error(err) { + $('#loadingViewSpinner').hide(); + Messages.error("Failure", err, "Unable to retrieve endpoint details"); + }); + } + + getEndpoint($stateParams.id); +}]); diff --git a/app/components/endpointInit/endpointInit.html b/app/components/endpointInit/endpointInit.html new file mode 100644 index 000000000..dbaf4a69d --- /dev/null +++ b/app/components/endpointInit/endpointInit.html @@ -0,0 +1,139 @@ +
    + +
    +
    + +
    + + +
    + + +
    +
    + +
    + +
    +

    Connect Portainer to a Docker engine or Swarm cluster endpoint.

    +
    + + +
    +
    + +
    +
    + +
    +
    + + +
    +
    +
    + Note: ensure that the Docker socket is bind mounted in the Portainer container at /var/run/docker.sock +
    +
    + +
    +
    +

    + {{ state.error }} +

    + +
    +
    + +
    + + +
    + +
    + +
    + +
    +
    + + +
    + +
    + +
    +
    + + +
    + +
    + +
    +
    + + +
    + +
    + +
    + + + {{ formValues.TLSCACert.name }} + + + +
    +
    + + +
    + +
    + + + {{ formValues.TLSCert.name }} + + + +
    +
    + + +
    + +
    + + + {{ formValues.TLSKey.name }} + + + +
    +
    + +
    + + +
    +
    +

    + {{ state.error }} +

    + +
    +
    + +
    + +
    + +
    +
    + +
    +
    + +
    diff --git a/app/components/endpointInit/endpointInitController.js b/app/components/endpointInit/endpointInitController.js new file mode 100644 index 000000000..2478bf8cf --- /dev/null +++ b/app/components/endpointInit/endpointInitController.js @@ -0,0 +1,57 @@ +angular.module('endpointInit', []) +.controller('EndpointInitController', ['$scope', '$state', 'EndpointService', 'Messages', +function ($scope, $state, EndpointService, Messages) { + $scope.state = { + error: '', + uploadInProgress: false + }; + $scope.formValues = { + endpointType: "remote", + Name: '', + URL: '', + TLS: false, + TLSCACert: null, + TLSCert: null, + TLSKey: null + }; + + EndpointService.getActive().then(function success(data) { + $state.go('dashboard'); + }, function error(err) { + if (err.status !== 404) { + Messages.error("Failure", err, 'Unable to verify Docker endpoint existence'); + } + }); + + $scope.createLocalEndpoint = function() { + $scope.state.error = ''; + var name = "local"; + var URL = "unix:///var/run/docker.sock"; + var TLS = false; + EndpointService.createLocalEndpoint(name, URL, TLS, true).then(function success(data) { + $state.go('dashboard'); + }, function error(err) { + $scope.state.error = 'Unable to create endpoint'; + }); + }; + + $scope.createRemoteEndpoint = function() { + $scope.state.error = ''; + var name = $scope.formValues.Name; + var URL = $scope.formValues.URL; + var TLS = $scope.formValues.TLS; + var TLSCAFile = $scope.formValues.TLSCACert; + var TLSCertFile = $scope.formValues.TLSCert; + var TLSKeyFile = $scope.formValues.TLSKey; + EndpointService.createRemoteEndpoint(name, URL, TLS, TLSCAFile, TLSCertFile, TLSKeyFile, TLS ? false : true).then(function success(data) { + $state.go('dashboard'); + }, function error(err) { + $scope.state.uploadInProgress = false; + $scope.state.error = err.msg; + }, function update(evt) { + if (evt.upload) { + $scope.state.uploadInProgress = evt.upload; + } + }); + }; +}]); diff --git a/app/components/endpoints/endpoints.html b/app/components/endpoints/endpoints.html new file mode 100644 index 000000000..6d05247b1 --- /dev/null +++ b/app/components/endpoints/endpoints.html @@ -0,0 +1,175 @@ + + + + + + + Endpoint management + + +
    +
    + + + + +
    + +
    + +
    + +
    +
    + + +
    + +
    + +
    +
    + + +
    + +
    + +
    +
    + + +
    + +
    + +
    + + + {{ formValues.TLSCACert.name }} + + + +
    +
    + + +
    + +
    + + + {{ formValues.TLSCert.name }} + + + +
    +
    + + +
    + +
    + + + {{ formValues.TLSKey.name }} + + + +
    +
    + +
    + +
    +
    + + + + {{ state.error }} + +
    +
    +
    +
    +
    +
    +
    + +
    +
    + + +
    + +
    +
    + +
    + +
    +
    + +
    +
    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + Name + + + + + + URL + + + + + + TLS + + + +
    {{ endpoint.Name }}{{ endpoint.URL | stripprotocol }} + + Edit + + + You cannot edit the active endpoint + +
    Loading...
    No endpoints available.
    +
    + +
    +
    +
    + +
    +
    diff --git a/app/components/endpoints/endpointsController.js b/app/components/endpoints/endpointsController.js new file mode 100644 index 000000000..188258ba4 --- /dev/null +++ b/app/components/endpoints/endpointsController.js @@ -0,0 +1,100 @@ +angular.module('endpoints', []) +.controller('EndpointsController', ['$scope', '$state', 'EndpointService', 'Settings', 'Messages', +function ($scope, $state, EndpointService, Settings, Messages) { + $scope.state = { + error: '', + uploadInProgress: false, + selectedItemCount: 0 + }; + $scope.sortType = 'Name'; + $scope.sortReverse = true; + $scope.pagination_count = Settings.pagination_count; + + $scope.formValues = { + Name: '', + URL: '', + TLS: false, + TLSCACert: null, + TLSCert: null, + TLSKey: null + }; + + $scope.order = function(sortType) { + $scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false; + $scope.sortType = sortType; + }; + + $scope.selectItem = function (item) { + if (item.Checked) { + $scope.state.selectedItemCount++; + } else { + $scope.state.selectedItemCount--; + } + }; + + $scope.addEndpoint = function() { + $scope.state.error = ''; + var name = $scope.formValues.Name; + var URL = $scope.formValues.URL; + var TLS = $scope.formValues.TLS; + var TLSCAFile = $scope.formValues.TLSCACert; + var TLSCertFile = $scope.formValues.TLSCert; + var TLSKeyFile = $scope.formValues.TLSKey; + EndpointService.createRemoteEndpoint(name, URL, TLS, TLSCAFile, TLSCertFile, TLSKeyFile, false).then(function success(data) { + Messages.send("Endpoint created", name); + $state.reload(); + }, function error(err) { + $scope.state.uploadInProgress = false; + $scope.state.error = err.msg; + }, function update(evt) { + if (evt.upload) { + $scope.state.uploadInProgress = evt.upload; + } + }); + }; + + $scope.removeAction = function () { + $('#loadEndpointsSpinner').show(); + var counter = 0; + var complete = function () { + counter = counter - 1; + if (counter === 0) { + $('#loadEndpointsSpinner').hide(); + } + }; + angular.forEach($scope.endpoints, function (endpoint) { + if (endpoint.Checked) { + counter = counter + 1; + EndpointService.deleteEndpoint(endpoint.Id).then(function success(data) { + Messages.send("Endpoint deleted", endpoint.Name); + var index = $scope.endpoints.indexOf(endpoint); + $scope.endpoints.splice(index, 1); + complete(); + }, function error(err) { + Messages.error("Failure", err, 'Unable to remove endpoint'); + complete(); + }); + } + }); + }; + + function fetchEndpoints() { + $('#loadEndpointsSpinner').show(); + EndpointService.endpoints().then(function success(data) { + $scope.endpoints = data; + EndpointService.getActive().then(function success(data) { + $scope.activeEndpoint = data; + $('#loadEndpointsSpinner').hide(); + }, function error(err) { + $('#loadEndpointsSpinner').hide(); + Messages.error("Failure", err, "Unable to retrieve active endpoint"); + }); + }, function error(err) { + $('#loadEndpointsSpinner').hide(); + Messages.error("Failure", err, "Unable to retrieve endpoints"); + $scope.endpoints = []; + }); + } + + fetchEndpoints(); +}]); 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..b9be43dc4 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) { @@ -51,7 +38,7 @@ function ($scope, $state, Config, Image, Messages, Settings) { Messages.error('Error', {}, detail.error); } else { $('#pullImageSpinner').hide(); - $state.go('images', {}, {reload: true}); + $state.reload(); } }, function (e) { $('#pullImageSpinner').hide(); 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/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/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/networks/networksController.js b/app/components/networks/networksController.js index e4ba34b9a..dfd1ced1e 100644 --- a/app/components/networks/networksController.js +++ b/app/components/networks/networksController.js @@ -13,7 +13,7 @@ function ($scope, $state, Network, Config, Messages, Settings) { function prepareNetworkConfiguration() { var config = angular.copy($scope.config); - if ($scope.swarm) { + if ($scope.endpointMode.provider === 'DOCKER_SWARM' || $scope.endpointMode.provider === 'DOCKER_SWARM_MODE') { config.Driver = 'overlay'; // Force IPAM Driver to 'default', should not be required. // See: https://github.com/docker/docker/issues/25735 @@ -34,7 +34,7 @@ function ($scope, $state, Network, Config, Messages, Settings) { } else { Messages.send("Network created", d.Id); $('#createNetworkSpinner').hide(); - $state.go('networks', {}, {reload: true}); + $state.reload(); } }, function (e) { $('#createNetworkSpinner').hide(); @@ -97,7 +97,6 @@ function ($scope, $state, Network, Config, Messages, Settings) { } Config.$promise.then(function (c) { - $scope.swarm = c.swarm; fetchNetworks(); }); }]); 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/components/services/servicesController.js b/app/components/services/servicesController.js index e30eb1e9a..b6587a04e 100644 --- a/app/components/services/servicesController.js +++ b/app/components/services/servicesController.js @@ -14,7 +14,7 @@ function ($scope, $stateParams, $state, Service, ServiceHelper, Messages, Settin Service.update({ id: service.Id, version: service.Version }, config, function (data) { $('#loadServicesSpinner').hide(); Messages.send("Service successfully scaled", "New replica count: " + service.Replicas); - $state.go('services', {}, {reload: true}); + $state.reload(); }, function (e) { $('#loadServicesSpinner').hide(); service.Scale = false; 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..d79dcc2e0 --- /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.reload(); + }, 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..4bdfed76a --- /dev/null +++ b/app/components/sidebar/sidebar.html @@ -0,0 +1,66 @@ + + + diff --git a/app/components/sidebar/sidebarController.js b/app/components/sidebar/sidebarController.js new file mode 100644 index 000000000..5d42e3cdb --- /dev/null +++ b/app/components/sidebar/sidebarController.js @@ -0,0 +1,38 @@ +angular.module('sidebar', []) +.controller('SidebarController', ['$scope', '$state', 'Settings', 'Config', 'EndpointService', 'EndpointMode', 'Messages', +function ($scope, $state, Settings, Config, EndpointService, EndpointMode, Messages) { + + Config.$promise.then(function (c) { + $scope.logo = c.logo; + }); + + $scope.uiVersion = Settings.uiVersion; + + $scope.switchEndpoint = function(endpoint) { + EndpointService.setActive(endpoint.Id).then(function success(data) { + EndpointMode.determineEndpointMode(); + $state.reload(); + }, function error(err) { + Messages.error("Failure", err, "Unable to switch to new endpoint"); + }); + }; + + function fetchEndpoints() { + EndpointService.endpoints().then(function success(data) { + $scope.endpoints = data; + EndpointService.getActive().then(function success(data) { + angular.forEach($scope.endpoints, function (endpoint) { + if (endpoint.Id === data.Id) { + $scope.activeEndpoint = endpoint; + } + }); + }, function error(err) { + Messages.error("Failure", err, "Unable to retrieve active endpoint"); + }); + }, function error(err) { + $scope.endpoints = []; + }); + } + + fetchEndpoints(); +}]); 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 49ef933c1..f6b402aef 100644 --- a/app/components/templates/templates.html +++ b/app/components/templates/templates.html @@ -6,20 +6,19 @@ Templates - -
    +
    -
    +
    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. @@ -33,7 +32,7 @@
    -
    @@ -42,10 +41,10 @@
    - - @@ -80,7 +79,7 @@
    - diff --git a/app/components/templates/templatesController.js b/app/components/templates/templatesController.js index 1d442a9a3..b7d10fb94 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', '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 @@ -115,7 +115,7 @@ function ($scope, $q, $state, $filter, Config, Info, Container, ContainerHelper, if (v.value || v.set) { var val; if (v.type && v.type === 'container') { - if ($scope.swarm && $scope.formValues.network.Scope === 'global') { + if ($scope.endpointMode.provider === 'DOCKER_SWARM' && $scope.formValues.network.Scope === 'global') { val = $filter('swarmcontainername')(v.value); } else { var container = v.value; @@ -129,9 +129,18 @@ function ($scope, $q, $state, $filter, Config, Info, Container, ContainerHelper, }); } 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, Config, Info, Container, ContainerHelper, $('#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); }); }; @@ -179,6 +184,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'); } }; @@ -197,16 +203,10 @@ function ($scope, $q, $state, $filter, Config, Info, Container, ContainerHelper, } 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; - if ($scope.swarm) { + if ($scope.endpointMode.provider === 'DOCKER_SWARM' || $scope.endpointMode.provider === 'DOCKER_SWARM_MODE') { networks = d.filter(function (network) { if (network.Scope === 'global') { return network; 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/filters.js b/app/shared/filters.js index 981626427..2efdf81b5 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'; @@ -106,6 +106,12 @@ angular.module('portainer.filters', []) return 'Stopped'; }; }) +.filter('stripprotocol', function() { + 'use strict'; + return function (url) { + return url.replace(/.*?:\/\//g, ''); + }; +}) .filter('getstatelabel', function () { 'use strict'; return function (state) { 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; } }; }]) diff --git a/app/shared/services.js b/app/shared/services.js index 0a7bf27b3..1aba157aa 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'}}, @@ -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 @@ -197,12 +189,12 @@ angular.module('portainer.services', ['ngResource', 'ngSanitize']) 'use strict'; // http://docs.docker.com/reference/api/docker_remote_api_<%= remoteApiVersion %>/#2-5-networks return $resource(Settings.url + '/volumes/:name/:action', {name: '@name'}, { - query: {method: 'GET'}, - get: {method: 'GET'}, - create: {method: 'POST', params: {action: 'create'}, transformResponse: genericHandler}, - remove: { - method: 'DELETE', transformResponse: genericHandler - } + query: {method: 'GET'}, + get: {method: 'GET'}, + create: {method: 'POST', params: {action: 'create'}, transformResponse: genericHandler}, + remove: { + method: 'DELETE', transformResponse: genericHandler + } }); }]) .factory('Config', ['$resource', 'CONFIG_ENDPOINT', function ConfigFactory($resource, CONFIG_ENDPOINT) { @@ -229,6 +221,227 @@ 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', 'USERS_ENDPOINT', function UsersFactory($resource, USERS_ENDPOINT) { + 'use strict'; + return $resource(USERS_ENDPOINT + '/: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('FileUploadService', ['$q', 'Upload', function FileUploadFactory($q, Upload) { + 'use strict'; + function uploadFile(url, file) { + var deferred = $q.defer(); + Upload.upload({ + url: url, + data: { file: file } + }).then(function success(data) { + deferred.resolve(data); + }, function error(e) { + deferred.reject(e); + }, function progress(evt) { + }); + return deferred.promise; + } + return { + uploadTLSFilesForEndpoint: function(endpointID, TLSCAFile, TLSCertFile, TLSKeyFile) { + var deferred = $q.defer(); + var queue = []; + + if (TLSCAFile !== null) { + var uploadTLSCA = uploadFile('api/upload/tls/' + endpointID + '/ca', TLSCAFile); + queue.push(uploadTLSCA); + } + if (TLSCertFile !== null) { + var uploadTLSCert = uploadFile('api/upload/tls/' + endpointID + '/cert', TLSCertFile); + queue.push(uploadTLSCert); + } + if (TLSKeyFile !== null) { + var uploadTLSKey = uploadFile('api/upload/tls/' + endpointID + '/key', TLSKeyFile); + queue.push(uploadTLSKey); + } + $q.all(queue).then(function (data) { + deferred.resolve(data); + }, function (err) { + deferred.reject(err); + }, function update(evt) { + deferred.notify(evt); + }); + return deferred.promise; + } + }; + }]) + .factory('Endpoints', ['$resource', 'ENDPOINTS_ENDPOINT', function EndpointsFactory($resource, ENDPOINTS_ENDPOINT) { + 'use strict'; + return $resource(ENDPOINTS_ENDPOINT + '/:id/:action', {}, { + create: { method: 'POST' }, + query: { method: 'GET', isArray: true }, + get: { method: 'GET', params: { id: '@id' } }, + update: { method: 'PUT', params: { id: '@id' } }, + remove: { method: 'DELETE', params: { id: '@id'} }, + getActiveEndpoint: { method: 'GET', params: { id: '0' } }, + setActiveEndpoint: { method: 'POST', params: { id: '@id', action: 'active' } } + }); + }]) + .factory('EndpointService', ['$q', '$timeout', 'Endpoints', 'FileUploadService', function EndpointServiceFactory($q, $timeout, Endpoints, FileUploadService) { + 'use strict'; + return { + getActive: function() { + return Endpoints.getActiveEndpoint().$promise; + }, + setActive: function(endpointID) { + return Endpoints.setActiveEndpoint({id: endpointID}).$promise; + }, + endpoint: function(endpointID) { + return Endpoints.get({id: endpointID}).$promise; + }, + endpoints: function() { + return Endpoints.query({}).$promise; + }, + updateEndpoint: function(ID, name, URL, TLS, TLSCAFile, TLSCertFile, TLSKeyFile) { + var endpoint = { + id: ID, + Name: name, + URL: "tcp://" + URL, + TLS: TLS + }; + var deferred = $q.defer(); + Endpoints.update({}, endpoint, function success(data) { + FileUploadService.uploadTLSFilesForEndpoint(ID, TLSCAFile, TLSCertFile, TLSKeyFile).then(function success(data) { + deferred.notify({upload: false}); + deferred.resolve(data); + }, function error(err) { + deferred.notify({upload: false}); + deferred.reject({msg: 'Unable to upload TLS certs', err: err}); + }); + }, function error(err) { + deferred.reject({msg: 'Unable to update endpoint', err: err}); + }); + return deferred.promise; + }, + deleteEndpoint: function(endpointID) { + return Endpoints.remove({id: endpointID}).$promise; + }, + createLocalEndpoint: function(name, URL, TLS, active) { + var endpoint = { + Name: "local", + URL: "unix:///var/run/docker.sock", + TLS: false + }; + return Endpoints.create({active: active}, endpoint).$promise; + }, + createRemoteEndpoint: function(name, URL, TLS, TLSCAFile, TLSCertFile, TLSKeyFile, active) { + var endpoint = { + Name: name, + URL: 'tcp://' + URL, + TLS: TLS + }; + var deferred = $q.defer(); + Endpoints.create({active: active}, endpoint, function success(data) { + var endpointID = data.Id; + if (TLS) { + deferred.notify({upload: true}); + FileUploadService.uploadTLSFilesForEndpoint(endpointID, TLSCAFile, TLSCertFile, TLSKeyFile).then(function success(data) { + deferred.notify({upload: false}); + if (active) { + Endpoints.setActiveEndpoint({}, {id: endpointID}, function success(data) { + deferred.resolve(data); + }, function error(err) { + deferred.reject({msg: 'Unable to create endpoint', err: err}); + }); + } else { + deferred.resolve(data); + } + }, function error(err) { + deferred.notify({upload: false}); + deferred.reject({msg: 'Unable to upload TLS certs', err: err}); + }); + } else { + deferred.resolve(data); + } + }, function error(err) { + deferred.reject({msg: 'Unable to create endpoint', err: err}); + }); + return deferred.promise; + } + }; + }]) .factory('Messages', ['$rootScope', '$sanitize', function MessagesFactory($rootScope, $sanitize) { 'use strict'; return { diff --git a/app/shared/viewmodel.js b/app/shared/viewmodel.js index e320c0752..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; @@ -53,6 +63,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)) { diff --git a/assets/css/app.css b/assets/css/app.css index 31b712997..140d1b1d9 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,53 @@ input[type="radio"] { margin-bottom: 5px; } +.page-wrapper { + margin-top: 25px; + height: 100%; + width: 100%; + display: flex; + align-items: center; +} + +.simple-box { + margin-bottom: 80px; +} + +.simple-box > div:first-child { + padding-bottom: 10px; +} + +.simple-box-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; +} + +.select-endpoint { + width: 80%; + margin: 0 auto; +} diff --git a/assets/images/logo_alt.png b/assets/images/logo_alt.png new file mode 100644 index 000000000..63318532c Binary files /dev/null and b/assets/images/logo_alt.png differ diff --git a/bower.json b/bower.json index 793042685..80e07497c 100644 --- a/bower.json +++ b/bower.json @@ -1,6 +1,6 @@ { "name": "portainer", - "version": "1.10.2", + "version": "1.11.0", "homepage": "https://github.com/portainer/portainer", "authors": [ "Anthony Lapenna " @@ -33,15 +33,18 @@ "angular-resource": "~1.5.0", "angular-ui-select": "~0.17.1", "angular-utils-pagination": "~0.11.1", + "angular-local-storage": "~0.5.2", + "angular-jwt": "~0.1.8", "bootstrap": "~3.3.6", - "font-awesome": "~4.6.3", "filesize": "~3.3.0", "jquery": "1.11.1", "jquery.gritter": "1.7.4", "lodash": "4.12.0", "rdash-ui": "1.0.*", "moment": "~2.14.1", - "xterm.js": "~2.0.1" + "xterm.js": "~2.0.1", + "font-awesome": "~4.7.0", + "ng-file-upload": "~12.2.13" }, "resolutions": { "angular": "1.5.5" diff --git a/gruntFile.js b/gruntFile.js index 00d99c317..2690804f5 100644 --- a/gruntFile.js +++ b/gruntFile.js @@ -193,9 +193,12 @@ module.exports = function (grunt) { src: ['bower_components/angular/angular.min.js', 'bower_components/angular-sanitize/angular-sanitize.min.js', 'bower_components/angular-cookies/angular-cookies.min.js', + 'bower_components/angular-local-storage/dist/angular-local-storage.min.js', + 'bower_components/angular-jwt/dist/angular-jwt.min.js', 'bower_components/angular-ui-router/release/angular-ui-router.min.js', 'bower_components/angular-resource/angular-resource.min.js', 'bower_components/angular-bootstrap/ui-bootstrap-tpls.min.js', + 'bower_components/ng-file-upload/ng-file-upload.min.js', 'bower_components/angular-utils-pagination/dirPagination.js', 'bower_components/angular-ui-select/dist/select.min.js'], dest: '<%= distdir %>/js/angular.js' @@ -295,34 +298,34 @@ module.exports = function (grunt) { }, buildBinary: { command: [ - 'docker run --rm -v $(pwd)/api:/src centurylink/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" centurylink/golang-builder-cross', - '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" centurylink/golang-builder-cross', - '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" centurylink/golang-builder-cross', - '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: { diff --git a/index.html b/index.html index 726d1243d..114070f3b 100644 --- a/index.html +++ b/index.html @@ -24,67 +24,16 @@ - -
    + +
    - - - +
    -
    +
    diff --git a/package.json b/package.json index 1f34a2c0a..986211aaa 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "author": "Portainer.io", "name": "portainer", "homepage": "http://portainer.io", - "version": "1.10.2", + "version": "1.11.0", "repository": { "type": "git", "url": "git@github.com:portainer/portainer.git"