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 @@
[](https://microbadger.com/images/portainer/portainer "Latest version on Docker Hub")
[](http://microbadger.com/images/portainer/portainer "Image size")
-[](http://portainer.readthedocs.io/en/latest/?badge=latest)
+[](http://portainer.readthedocs.io/en/latest/?badge=stable)
[](https://gitter.im/portainer/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
[](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: '
+ + Your new password must be at least 8 characters long +
++ Current password is not valid +
+