From 0a38bba87449e864f91eeb2e8d9de3c399a721ec Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Sun, 18 Dec 2016 18:21:29 +1300 Subject: [PATCH] refactor(api): API overhaul (#392) --- .gitignore | 1 + api/api.go | 102 -------- api/auth.go | 88 ------- api/bolt/datastore.go | 58 ++++ api/bolt/internal/internal.go | 17 ++ api/bolt/user_service.go | 56 ++++ api/cli/cli.go | 59 +++++ api/{flags.go => cli/pairlist.go} | 26 +- api/cmd/portainer/main.go | 70 +++++ api/crypto/crypto.go | 22 ++ api/datastore.go | 98 ------- api/errors.go | 28 ++ api/exec.go | 24 -- api/handler.go | 92 ------- api/http/auth_handler.go | 95 +++++++ api/http/docker_handler.go | 115 ++++++++ api/http/handler.go | 76 ++++++ api/{ => http}/middleware.go | 56 ++-- api/http/server.go | 53 ++++ api/http/settings_handler.go | 39 +++ api/http/templates_handler.go | 55 ++++ api/{ssl.go => http/tls.go} | 15 +- api/http/user_handler.go | 247 ++++++++++++++++++ api/{hijack.go => http/websocket_handler.go} | 63 ++++- api/jwt.go | 29 -- api/jwt/jwt.go | 66 +++++ api/main.go | 52 ---- api/portainer.go | 92 +++++++ api/settings.go | 18 -- api/templates.go | 27 -- api/unix_handler.go | 47 ---- api/users.go | 219 ---------------- app/app.js | 9 +- .../containerConsoleController.js | 2 +- app/shared/services.js | 4 +- gruntFile.js | 24 +- 36 files changed, 1275 insertions(+), 869 deletions(-) delete mode 100644 api/api.go delete mode 100644 api/auth.go create mode 100644 api/bolt/datastore.go create mode 100644 api/bolt/internal/internal.go create mode 100644 api/bolt/user_service.go create mode 100644 api/cli/cli.go rename api/{flags.go => cli/pairlist.go} (51%) create mode 100644 api/cmd/portainer/main.go create mode 100644 api/crypto/crypto.go delete mode 100644 api/datastore.go create mode 100644 api/errors.go delete mode 100644 api/exec.go delete mode 100644 api/handler.go create mode 100644 api/http/auth_handler.go create mode 100644 api/http/docker_handler.go create mode 100644 api/http/handler.go rename api/{ => http}/middleware.go (50%) create mode 100644 api/http/server.go create mode 100644 api/http/settings_handler.go create mode 100644 api/http/templates_handler.go rename api/{ssl.go => http/tls.go} (54%) create mode 100644 api/http/user_handler.go rename api/{hijack.go => http/websocket_handler.go} (60%) delete mode 100644 api/jwt.go create mode 100644 api/jwt/jwt.go delete mode 100644 api/main.go create mode 100644 api/portainer.go delete mode 100644 api/settings.go delete mode 100644 api/templates.go delete mode 100644 api/unix_handler.go delete mode 100644 api/users.go diff --git a/.gitignore b/.gitignore index 61fe02d69..aef04ade6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules bower_components dist portainer-checksum.txt +api/cmd/portainer/portainer-* diff --git a/api/api.go b/api/api.go deleted file mode 100644 index 5b71f7fdf..000000000 --- a/api/api.go +++ /dev/null @@ -1,102 +0,0 @@ -package main - -import ( - "crypto/tls" - "errors" - "github.com/gorilla/securecookie" - "log" - "net/http" - "net/url" -) - -type ( - api struct { - endpoint *url.URL - bindAddress string - assetPath string - dataPath string - tlsConfig *tls.Config - templatesURL string - dataStore *dataStore - secret []byte - } - - apiConfig struct { - Endpoint string - BindAddress string - AssetPath string - DataPath string - SwarmSupport bool - TLSEnabled bool - TLSCACertPath string - TLSCertPath string - TLSKeyPath string - TemplatesURL string - } -) - -const ( - datastoreFileName = "portainer.db" -) - -var ( - errSecretKeyGeneration = errors.New("Unable to generate secret key to sign JWT") -) - -func (a *api) run(settings *Settings) { - err := a.initDatabase() - if err != nil { - log.Fatal(err) - } - defer a.cleanUp() - - handler := a.newHandler(settings) - log.Printf("Starting portainer on %s", a.bindAddress) - if err := http.ListenAndServe(a.bindAddress, handler); err != nil { - log.Fatal(err) - } -} - -func (a *api) cleanUp() { - a.dataStore.cleanUp() -} - -func (a *api) initDatabase() error { - dataStore, err := newDataStore(a.dataPath + "/" + datastoreFileName) - if err != nil { - return err - } - err = dataStore.initDataStore() - if err != nil { - return err - } - a.dataStore = dataStore - return nil -} - -func newAPI(apiConfig apiConfig) *api { - endpointURL, err := url.Parse(apiConfig.Endpoint) - if err != nil { - log.Fatal(err) - } - - secret := securecookie.GenerateRandomKey(32) - if secret == nil { - log.Fatal(errSecretKeyGeneration) - } - - var tlsConfig *tls.Config - if apiConfig.TLSEnabled { - tlsConfig = newTLSConfig(apiConfig.TLSCACertPath, apiConfig.TLSCertPath, apiConfig.TLSKeyPath) - } - - return &api{ - endpoint: endpointURL, - bindAddress: apiConfig.BindAddress, - assetPath: apiConfig.AssetPath, - dataPath: apiConfig.DataPath, - tlsConfig: tlsConfig, - templatesURL: apiConfig.TemplatesURL, - secret: secret, - } -} diff --git a/api/auth.go b/api/auth.go deleted file mode 100644 index 74355c00b..000000000 --- a/api/auth.go +++ /dev/null @@ -1,88 +0,0 @@ -package main - -import ( - "encoding/json" - "github.com/asaskevich/govalidator" - "golang.org/x/crypto/bcrypt" - "io/ioutil" - "log" - "net/http" -) - -type ( - credentials struct { - Username string `valid:"alphanum,required"` - Password string `valid:"length(8)"` - } - authResponse struct { - JWT string `json:"jwt"` - } -) - -func hashPassword(password string) (string, error) { - hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - if err != nil { - return "", nil - } - return string(hash), nil -} - -func checkPasswordValidity(password string, hash string) error { - return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) -} - -// authHandler defines a handler function used to authenticate users -func (api *api) authHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - w.Header().Set("Allow", "POST") - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - body, err := ioutil.ReadAll(r.Body) - if err != nil { - http.Error(w, "Unable to parse request body", http.StatusBadRequest) - return - } - - var credentials credentials - err = json.Unmarshal(body, &credentials) - if err != nil { - http.Error(w, "Unable to parse credentials", http.StatusBadRequest) - return - } - - _, err = govalidator.ValidateStruct(credentials) - if err != nil { - http.Error(w, "Invalid credentials format", http.StatusBadRequest) - return - } - - var username = credentials.Username - var password = credentials.Password - u, err := api.dataStore.getUserByUsername(username) - if err != nil { - log.Printf("User not found: %s", username) - http.Error(w, "User not found", http.StatusNotFound) - return - } - - err = checkPasswordValidity(password, u.Password) - if err != nil { - log.Printf("Invalid credentials for user: %s", username) - http.Error(w, "Invalid credentials", http.StatusUnprocessableEntity) - return - } - - token, err := api.generateJWTToken(username) - if err != nil { - log.Printf("Unable to generate JWT token: %s", err.Error()) - http.Error(w, "Unable to generate JWT token", http.StatusInternalServerError) - return - } - - response := authResponse{ - JWT: token, - } - json.NewEncoder(w).Encode(response) -} diff --git a/api/bolt/datastore.go b/api/bolt/datastore.go new file mode 100644 index 000000000..2f02cd12a --- /dev/null +++ b/api/bolt/datastore.go @@ -0,0 +1,58 @@ +package bolt + +import ( + "github.com/boltdb/bolt" + "time" +) + +// Store defines the implementation of portainer.DataStore using +// BoltDB as the storage system. +type Store struct { + // Path where is stored the BoltDB database. + Path string + + // Services + UserService *UserService + + db *bolt.DB +} + +const ( + databaseFileName = "portainer.db" + userBucketName = "users" +) + +// NewStore initializes a new Store and the associated services +func NewStore(storePath string) *Store { + store := &Store{ + Path: storePath, + UserService: &UserService{}, + } + store.UserService.store = store + return store +} + +// Open opens and initializes the BoltDB database. +func (store *Store) Open() error { + path := store.Path + "/" + databaseFileName + db, err := bolt.Open(path, 0600, &bolt.Options{Timeout: 1 * time.Second}) + if err != nil { + return err + } + store.db = db + return db.Update(func(tx *bolt.Tx) error { + _, err := tx.CreateBucketIfNotExists([]byte(userBucketName)) + if err != nil { + return err + } + return nil + }) +} + +// Close closes the BoltDB database. +func (store *Store) Close() error { + if store.db != nil { + return store.db.Close() + } + return nil +} diff --git a/api/bolt/internal/internal.go b/api/bolt/internal/internal.go new file mode 100644 index 000000000..db11bb9f4 --- /dev/null +++ b/api/bolt/internal/internal.go @@ -0,0 +1,17 @@ +package internal + +import ( + "github.com/portainer/portainer" + + "encoding/json" +) + +// MarshalUser encodes a user to binary format. +func MarshalUser(user *portainer.User) ([]byte, error) { + return json.Marshal(user) +} + +// UnmarshalUser decodes a user from a binary data. +func UnmarshalUser(data []byte, user *portainer.User) error { + return json.Unmarshal(data, user) +} diff --git a/api/bolt/user_service.go b/api/bolt/user_service.go new file mode 100644 index 000000000..0171c3e33 --- /dev/null +++ b/api/bolt/user_service.go @@ -0,0 +1,56 @@ +package bolt + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +// UserService represents a service for managing users. +type UserService struct { + store *Store +} + +// User returns a user by username. +func (service *UserService) User(username string) (*portainer.User, error) { + var data []byte + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(userBucketName)) + value := bucket.Get([]byte(username)) + if value == nil { + return portainer.ErrUserNotFound + } + + data = make([]byte, len(value)) + copy(data, value) + return nil + }) + if err != nil { + return nil, err + } + + var user portainer.User + err = internal.UnmarshalUser(data, &user) + if err != nil { + return nil, err + } + return &user, nil +} + +// UpdateUser saves a user. +func (service *UserService) UpdateUser(user *portainer.User) error { + data, err := internal.MarshalUser(user) + if err != nil { + return err + } + + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(userBucketName)) + err = bucket.Put([]byte(user.Username), data) + if err != nil { + return err + } + return nil + }) +} diff --git a/api/cli/cli.go b/api/cli/cli.go new file mode 100644 index 000000000..6dcea483a --- /dev/null +++ b/api/cli/cli.go @@ -0,0 +1,59 @@ +package cli + +import ( + "github.com/portainer/portainer" + + "gopkg.in/alecthomas/kingpin.v2" + "os" + "strings" +) + +// Service implements the CLIService interface +type Service struct{} + +const ( + errInvalidEnpointProtocol = portainer.Error("Invalid endpoint protocol: Portainer only supports unix:// or tcp://") + errSocketNotFound = portainer.Error("Unable to locate Unix socket") +) + +// ParseFlags parse the CLI flags and return a portainer.Flags struct +func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) { + kingpin.Version(version) + + flags := &portainer.CLIFlags{ + Addr: kingpin.Flag("bind", "Address and port to serve Portainer").Default(":9000").Short('p').String(), + Assets: kingpin.Flag("assets", "Path to the assets").Default(".").Short('a').String(), + Data: kingpin.Flag("data", "Path to the folder where the data is stored").Default("/data").Short('d').String(), + Endpoint: kingpin.Flag("host", "Dockerd endpoint").Default("unix:///var/run/docker.sock").Short('H').String(), + Labels: pairs(kingpin.Flag("hide-label", "Hide containers with a specific label in the UI").Short('l')), + Logo: kingpin.Flag("logo", "URL for the logo displayed in the UI").String(), + Swarm: kingpin.Flag("swarm", "Swarm cluster support").Default("false").Short('s').Bool(), + Templates: kingpin.Flag("templates", "URL to the templates (apps) definitions").Default("https://raw.githubusercontent.com/portainer/templates/master/templates.json").Short('t').String(), + TLSVerify: kingpin.Flag("tlsverify", "TLS support").Default("false").Bool(), + TLSCacert: kingpin.Flag("tlscacert", "Path to the CA").Default("/certs/ca.pem").String(), + TLSCert: kingpin.Flag("tlscert", "Path to the TLS certificate file").Default("/certs/cert.pem").String(), + TLSKey: kingpin.Flag("tlskey", "Path to the TLS key").Default("/certs/key.pem").String(), + } + + kingpin.Parse() + return flags, nil +} + +// ValidateFlags validates the values of the flags. +func (*Service) ValidateFlags(flags *portainer.CLIFlags) error { + if !strings.HasPrefix(*flags.Endpoint, "unix://") && !strings.HasPrefix(*flags.Endpoint, "tcp://") { + return errInvalidEnpointProtocol + } + + if strings.HasPrefix(*flags.Endpoint, "unix://") { + socketPath := strings.TrimPrefix(*flags.Endpoint, "unix://") + if _, err := os.Stat(socketPath); err != nil { + if os.IsNotExist(err) { + return errSocketNotFound + } + return err + } + } + + return nil +} diff --git a/api/flags.go b/api/cli/pairlist.go similarity index 51% rename from api/flags.go rename to api/cli/pairlist.go index 47578a748..7c1d4ea58 100644 --- a/api/flags.go +++ b/api/cli/pairlist.go @@ -1,46 +1,40 @@ -package main +package cli import ( + "github.com/portainer/portainer" + "fmt" "gopkg.in/alecthomas/kingpin.v2" "strings" ) -// pair defines a key/value pair -type pair struct { - Name string `json:"name"` - Value string `json:"value"` -} +type pairList []portainer.Pair -// pairList defines an array of Label -type pairList []pair - -// Set implementation for Labels +// Set implementation for a list of portainer.Pair func (l *pairList) Set(value string) error { parts := strings.SplitN(value, "=", 2) if len(parts) != 2 { return fmt.Errorf("expected NAME=VALUE got '%s'", value) } - p := new(pair) + p := new(portainer.Pair) p.Name = parts[0] p.Value = parts[1] *l = append(*l, *p) return nil } -// String implementation for Labels +// String implementation for a list of pair func (l *pairList) String() string { return "" } -// IsCumulative implementation for Labels +// IsCumulative implementation for a list of pair func (l *pairList) IsCumulative() bool { return true } -// LabelParser defines a custom parser for Labels flags -func pairs(s kingpin.Settings) (target *[]pair) { - target = new([]pair) +func pairs(s kingpin.Settings) (target *[]portainer.Pair) { + target = new([]portainer.Pair) s.SetValue((*pairList)(target)) return } diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go new file mode 100644 index 000000000..48cb6fa72 --- /dev/null +++ b/api/cmd/portainer/main.go @@ -0,0 +1,70 @@ +package main // import "github.com/portainer/portainer" + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt" + "github.com/portainer/portainer/cli" + "github.com/portainer/portainer/crypto" + "github.com/portainer/portainer/http" + "github.com/portainer/portainer/jwt" + + "log" +) + +func main() { + var cli portainer.CLIService = &cli.Service{} + flags, err := cli.ParseFlags(portainer.APIVersion) + if err != nil { + log.Fatal(err) + } + + err = cli.ValidateFlags(flags) + if err != nil { + log.Fatal(err) + } + + settings := &portainer.Settings{ + Swarm: *flags.Swarm, + HiddenLabels: *flags.Labels, + Logo: *flags.Logo, + } + + var store = bolt.NewStore(*flags.Data) + err = store.Open() + if err != nil { + log.Fatal(err) + } + defer store.Close() + + jwtService, err := jwt.NewService() + if err != nil { + log.Fatal(err) + } + + var cryptoService portainer.CryptoService = &crypto.Service{} + + endpointConfiguration := &portainer.EndpointConfiguration{ + Endpoint: *flags.Endpoint, + TLS: *flags.TLSVerify, + TLSCACertPath: *flags.TLSCacert, + TLSCertPath: *flags.TLSCert, + TLSKeyPath: *flags.TLSKey, + } + + var server portainer.Server = &http.Server{ + BindAddress: *flags.Addr, + AssetsPath: *flags.Assets, + Settings: settings, + TemplatesURL: *flags.Templates, + UserService: store.UserService, + CryptoService: cryptoService, + JWTService: jwtService, + EndpointConfig: endpointConfiguration, + } + + log.Printf("Starting Portainer on %s", *flags.Addr) + err = server.Start() + if err != nil { + log.Fatal(err) + } +} diff --git a/api/crypto/crypto.go b/api/crypto/crypto.go new file mode 100644 index 000000000..3e52dfbd3 --- /dev/null +++ b/api/crypto/crypto.go @@ -0,0 +1,22 @@ +package crypto + +import ( + "golang.org/x/crypto/bcrypt" +) + +// Service represents a service for encrypting/hashing data. +type Service struct{} + +// Hash hashes a string using the bcrypt algorithm +func (*Service) Hash(data string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(data), bcrypt.DefaultCost) + if err != nil { + return "", nil + } + return string(hash), nil +} + +// CompareHashAndData compares a hash to clear data and returns an error if the comparison fails. +func (*Service) CompareHashAndData(hash string, data string) error { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(data)) +} diff --git a/api/datastore.go b/api/datastore.go deleted file mode 100644 index 58efd7f5e..000000000 --- a/api/datastore.go +++ /dev/null @@ -1,98 +0,0 @@ -package main - -import ( - "encoding/json" - "errors" - "github.com/boltdb/bolt" -) - -const ( - userBucketName = "users" -) - -type ( - dataStore struct { - db *bolt.DB - } - - userItem struct { - Username string `json:"username"` - Password string `json:"password,omitempty"` - } -) - -var ( - errUserNotFound = errors.New("User not found") -) - -func (dataStore *dataStore) initDataStore() error { - return dataStore.db.Update(func(tx *bolt.Tx) error { - _, err := tx.CreateBucketIfNotExists([]byte(userBucketName)) - if err != nil { - return err - } - return nil - }) -} - -func (dataStore *dataStore) cleanUp() { - dataStore.db.Close() -} - -func newDataStore(databasePath string) (*dataStore, error) { - db, err := bolt.Open(databasePath, 0600, nil) - if err != nil { - return nil, err - } - - return &dataStore{ - db: db, - }, nil -} - -func (dataStore *dataStore) getUserByUsername(username string) (*userItem, error) { - var data []byte - - err := dataStore.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(userBucketName)) - value := bucket.Get([]byte(username)) - if value == nil { - return errUserNotFound - } - - data = make([]byte, len(value)) - copy(data, value) - return nil - }) - if err != nil { - return nil, err - } - - var user userItem - err = json.Unmarshal(data, &user) - if err != nil { - return nil, err - } - return &user, nil -} - -func (dataStore *dataStore) updateUser(user userItem) error { - buffer, err := json.Marshal(user) - if err != nil { - return err - } - - err = dataStore.db.Update(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(userBucketName)) - err = bucket.Put([]byte(user.Username), buffer) - if err != nil { - return err - } - return nil - }) - - if err != nil { - return err - } - return nil -} diff --git a/api/errors.go b/api/errors.go new file mode 100644 index 000000000..fa59de7f2 --- /dev/null +++ b/api/errors.go @@ -0,0 +1,28 @@ +package portainer + +// General errors. +const ( + ErrUnauthorized = Error("Unauthorized") +) + +// User errors. +const ( + ErrUserNotFound = Error("User not found") +) + +// Crypto errors. +const ( + ErrCryptoHashFailure = Error("Unable to hash data") +) + +// JWT errors. +const ( + ErrSecretGeneration = Error("Unable to generate secret key") + ErrInvalidJWTToken = Error("Invalid JWT token") +) + +// Error represents an application error. +type Error string + +// Error returns the error message. +func (e Error) Error() string { return string(e) } diff --git a/api/exec.go b/api/exec.go deleted file mode 100644 index 4b139aeb7..000000000 --- a/api/exec.go +++ /dev/null @@ -1,24 +0,0 @@ -package main - -import ( - "golang.org/x/net/websocket" - "log" -) - -// execContainer is used to create a websocket communication with an exec instance -func (a *api) execContainer(ws *websocket.Conn) { - qry := ws.Request().URL.Query() - execID := qry.Get("id") - - var host string - if a.endpoint.Scheme == "tcp" { - host = a.endpoint.Host - } else if a.endpoint.Scheme == "unix" { - host = a.endpoint.Path - } - - if err := hijack(host, a.endpoint.Scheme, "POST", "/exec/"+execID+"/start", a.tlsConfig, true, ws, ws, ws, nil, nil); err != nil { - log.Fatalf("error during hijack: %s", err) - return - } -} diff --git a/api/handler.go b/api/handler.go deleted file mode 100644 index e8d7ad831..000000000 --- a/api/handler.go +++ /dev/null @@ -1,92 +0,0 @@ -package main - -import ( - "github.com/gorilla/mux" - "golang.org/x/net/websocket" - "log" - "net/http" - "net/http/httputil" - "net/url" - "os" -) - -// newHandler creates a new http.Handler -func (a *api) newHandler(settings *Settings) http.Handler { - var ( - mux = mux.NewRouter() - fileHandler = http.FileServer(http.Dir(a.assetPath)) - ) - handler := a.newAPIHandler() - - mux.Handle("/ws/exec", websocket.Handler(a.execContainer)) - mux.HandleFunc("/auth", a.authHandler) - mux.Handle("/users", addMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - a.usersHandler(w, r) - }), a.authenticate, secureHeaders)) - mux.Handle("/users/{username}", addMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - a.userHandler(w, r) - }), a.authenticate, secureHeaders)) - mux.Handle("/users/{username}/passwd", addMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - a.userPasswordHandler(w, r) - }), a.authenticate, secureHeaders)) - mux.HandleFunc("/users/admin/check", a.checkAdminHandler) - mux.HandleFunc("/users/admin/init", a.initAdminHandler) - mux.HandleFunc("/settings", func(w http.ResponseWriter, r *http.Request) { - settingsHandler(w, r, settings) - }) - mux.HandleFunc("/templates", func(w http.ResponseWriter, r *http.Request) { - templatesHandler(w, r, a.templatesURL) - }) - mux.PathPrefix("/dockerapi/").Handler(http.StripPrefix("/dockerapi", addMiddleware(handler, a.authenticate, secureHeaders))) - mux.PathPrefix("/").Handler(http.StripPrefix("/", fileHandler)) - - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - mux.ServeHTTP(w, r) - }) -} - -// newAPIHandler initializes a new http.Handler based on the URL scheme -func (a *api) newAPIHandler() http.Handler { - var handler http.Handler - var endpoint = *a.endpoint - if endpoint.Scheme == "tcp" { - if a.tlsConfig != nil { - handler = a.newTCPHandlerWithTLS(&endpoint) - } else { - handler = a.newTCPHandler(&endpoint) - } - } else if endpoint.Scheme == "unix" { - socketPath := endpoint.Path - if _, err := os.Stat(socketPath); err != nil { - if os.IsNotExist(err) { - log.Fatalf("Unix socket %s does not exist", socketPath) - } - log.Fatal(err) - } - handler = a.newUnixHandler(socketPath) - } else { - log.Fatalf("Bad Docker enpoint: %v. Only unix:// and tcp:// are supported.", &endpoint) - } - return handler -} - -// newUnixHandler initializes a new UnixHandler -func (a *api) newUnixHandler(e string) http.Handler { - return &unixHandler{e} -} - -// newTCPHandler initializes a HTTP reverse proxy -func (a *api) newTCPHandler(u *url.URL) http.Handler { - u.Scheme = "http" - return httputil.NewSingleHostReverseProxy(u) -} - -// newTCPHandlerWithL initializes a HTTPS reverse proxy with a TLS configuration -func (a *api) newTCPHandlerWithTLS(u *url.URL) http.Handler { - u.Scheme = "https" - proxy := httputil.NewSingleHostReverseProxy(u) - proxy.Transport = &http.Transport{ - TLSClientConfig: a.tlsConfig, - } - return proxy -} diff --git a/api/http/auth_handler.go b/api/http/auth_handler.go new file mode 100644 index 000000000..e63412a15 --- /dev/null +++ b/api/http/auth_handler.go @@ -0,0 +1,95 @@ +package http + +import ( + "github.com/portainer/portainer" + + "encoding/json" + "github.com/asaskevich/govalidator" + "github.com/gorilla/mux" + "log" + "net/http" + "os" +) + +// AuthHandler represents an HTTP API handler for managing authentication. +type AuthHandler struct { + *mux.Router + Logger *log.Logger + UserService portainer.UserService + CryptoService portainer.CryptoService + JWTService portainer.JWTService +} + +const ( + // ErrInvalidCredentialsFormat is an error raised when credentials format is not valid + ErrInvalidCredentialsFormat = portainer.Error("Invalid credentials format") + // ErrInvalidCredentials is an error raised when credentials for a user are invalid + ErrInvalidCredentials = portainer.Error("Invalid credentials") +) + +// NewAuthHandler returns a new instance of DialHandler. +func NewAuthHandler() *AuthHandler { + h := &AuthHandler{ + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + } + h.HandleFunc("/auth", h.handlePostAuth) + return h +} + +func (handler *AuthHandler) handlePostAuth(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + handleNotAllowed(w, []string{"POST"}) + return + } + + var req postAuthRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err := govalidator.ValidateStruct(req) + if err != nil { + Error(w, ErrInvalidCredentialsFormat, http.StatusBadRequest, handler.Logger) + return + } + + var username = req.Username + var password = req.Password + + u, err := handler.UserService.User(username) + if err == portainer.ErrUserNotFound { + Error(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + err = handler.CryptoService.CompareHashAndData(u.Password, password) + if err != nil { + Error(w, ErrInvalidCredentials, http.StatusUnprocessableEntity, handler.Logger) + return + } + + tokenData := &portainer.TokenData{ + username, + } + token, err := handler.JWTService.GenerateToken(tokenData) + if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + encodeJSON(w, &postAuthResponse{JWT: token}, handler.Logger) +} + +type postAuthRequest struct { + Username string `valid:"alphanum,required"` + Password string `valid:"required"` +} + +type postAuthResponse struct { + JWT string `json:"jwt"` +} diff --git a/api/http/docker_handler.go b/api/http/docker_handler.go new file mode 100644 index 000000000..bb67a3943 --- /dev/null +++ b/api/http/docker_handler.go @@ -0,0 +1,115 @@ +package http + +import ( + "github.com/portainer/portainer" + + "github.com/gorilla/mux" + "io" + "log" + "net" + "net/http" + "net/http/httputil" + "net/url" + "os" +) + +// DockerHandler represents an HTTP API handler for proxying requests to the Docker API. +type DockerHandler struct { + *mux.Router + Logger *log.Logger + middleWareService *middleWareService + proxy http.Handler +} + +// NewDockerHandler returns a new instance of DockerHandler. +func NewDockerHandler(middleWareService *middleWareService) *DockerHandler { + h := &DockerHandler{ + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + middleWareService: middleWareService, + } + h.PathPrefix("/").Handler(middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h.proxyRequestsToDockerAPI(w, r) + }))) + return h +} + +func (handler *DockerHandler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http.Request) { + handler.proxy.ServeHTTP(w, r) +} + +func (handler *DockerHandler) setupProxy(config *portainer.EndpointConfiguration) error { + var proxy http.Handler + endpointURL, err := url.Parse(config.Endpoint) + if err != nil { + return err + } + if endpointURL.Scheme == "tcp" { + if config.TLS { + proxy, err = newHTTPSProxy(endpointURL, config) + if err != nil { + return err + } + } else { + proxy = newHTTPProxy(endpointURL) + } + } else { + // Assume unix:// scheme + proxy = newSocketProxy(endpointURL.Path) + } + handler.proxy = proxy + return nil +} + +func newHTTPProxy(u *url.URL) http.Handler { + u.Scheme = "http" + return httputil.NewSingleHostReverseProxy(u) +} + +func newHTTPSProxy(u *url.URL, endpointConfig *portainer.EndpointConfiguration) (http.Handler, error) { + u.Scheme = "https" + proxy := httputil.NewSingleHostReverseProxy(u) + config, err := createTLSConfiguration(endpointConfig.TLSCACertPath, endpointConfig.TLSCertPath, endpointConfig.TLSKeyPath) + if err != nil { + return nil, err + } + proxy.Transport = &http.Transport{ + TLSClientConfig: config, + } + return proxy, nil +} + +func newSocketProxy(path string) http.Handler { + return &unixSocketHandler{path} +} + +// unixSocketHandler represents a handler to proxy HTTP requests via a unix:// socket +type unixSocketHandler struct { + path string +} + +func (h *unixSocketHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + conn, err := net.Dial("unix", h.path) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + c := httputil.NewClientConn(conn, nil) + defer c.Close() + + res, err := c.Do(r) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer res.Body.Close() + + for k, vv := range res.Header { + for _, v := range vv { + w.Header().Add(k, v) + } + } + if _, err := io.Copy(w, res.Body); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} diff --git a/api/http/handler.go b/api/http/handler.go new file mode 100644 index 000000000..3bcfdff54 --- /dev/null +++ b/api/http/handler.go @@ -0,0 +1,76 @@ +package http + +import ( + "github.com/portainer/portainer" + + "encoding/json" + "log" + "net/http" + "strings" +) + +// Handler is a collection of all the service handlers. +type Handler struct { + AuthHandler *AuthHandler + UserHandler *UserHandler + SettingsHandler *SettingsHandler + TemplatesHandler *TemplatesHandler + DockerHandler *DockerHandler + WebSocketHandler *WebSocketHandler + FileHandler http.Handler +} + +const ( + // ErrInvalidJSON defines an error raised the app is unable to parse request data + ErrInvalidJSON = portainer.Error("Invalid JSON") + // ErrInvalidRequestFormat defines an error raised when the format of the data sent in a request is not valid + ErrInvalidRequestFormat = portainer.Error("Invalid request data format") +) + +// ServeHTTP delegates a request to the appropriate subhandler. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/auth") { + http.StripPrefix("/api", h.AuthHandler).ServeHTTP(w, r) + } else if strings.HasPrefix(r.URL.Path, "/api/users") { + http.StripPrefix("/api", h.UserHandler).ServeHTTP(w, r) + } else if strings.HasPrefix(r.URL.Path, "/api/settings") { + http.StripPrefix("/api", h.SettingsHandler).ServeHTTP(w, r) + } else if strings.HasPrefix(r.URL.Path, "/api/templates") { + http.StripPrefix("/api", h.TemplatesHandler).ServeHTTP(w, r) + } else if strings.HasPrefix(r.URL.Path, "/api/websocket") { + http.StripPrefix("/api", h.WebSocketHandler).ServeHTTP(w, r) + } else if strings.HasPrefix(r.URL.Path, "/api/docker") { + http.StripPrefix("/api/docker", h.DockerHandler).ServeHTTP(w, r) + } else if strings.HasPrefix(r.URL.Path, "/") { + h.FileHandler.ServeHTTP(w, r) + } +} + +// Error writes an API error message to the response and logger. +func Error(w http.ResponseWriter, err error, code int, logger *log.Logger) { + // Log error. + logger.Printf("http error: %s (code=%d)", err, code) + + // Write generic error response. + w.WriteHeader(code) + json.NewEncoder(w).Encode(&errorResponse{Err: err.Error()}) +} + +// errorResponse is a generic response for sending a error. +type errorResponse struct { + Err string `json:"err,omitempty"` +} + +// handleNotAllowed writes an API error message to the response and sets the Allow header. +func handleNotAllowed(w http.ResponseWriter, allowedMethods []string) { + w.Header().Set("Allow", strings.Join(allowedMethods, ", ")) + w.WriteHeader(http.StatusMethodNotAllowed) + json.NewEncoder(w).Encode(&errorResponse{Err: http.StatusText(http.StatusMethodNotAllowed)}) +} + +// encodeJSON encodes v to w in JSON format. Error() is called if encoding fails. +func encodeJSON(w http.ResponseWriter, v interface{}, logger *log.Logger) { + if err := json.NewEncoder(w).Encode(v); err != nil { + Error(w, err, http.StatusInternalServerError, logger) + } +} diff --git a/api/middleware.go b/api/http/middleware.go similarity index 50% rename from api/middleware.go rename to api/http/middleware.go index da12ae730..99775dec6 100644 --- a/api/middleware.go +++ b/api/http/middleware.go @@ -1,12 +1,17 @@ -package main +package http import ( - "fmt" - "github.com/dgrijalva/jwt-go" + "github.com/portainer/portainer" + "net/http" "strings" ) +// Service represents a service to manage HTTP middlewares +type middleWareService struct { + jwtService portainer.JWTService +} + func addMiddleware(h http.Handler, middleware ...func(http.Handler) http.Handler) http.Handler { for _, mw := range middleware { h = mw(h) @@ -14,13 +19,27 @@ func addMiddleware(h http.Handler, middleware ...func(http.Handler) http.Handler return h } -// authenticate provides Authentication middleware for handlers -func (api *api) authenticate(next http.Handler) http.Handler { +func (service *middleWareService) addMiddleWares(h http.Handler) http.Handler { + h = service.middleWareSecureHeaders(h) + h = service.middleWareAuthenticate(h) + return h +} + +// middleWareAuthenticate provides secure headers middleware for handlers +func (*middleWareService) middleWareSecureHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("X-Content-Type-Options", "nosniff") + w.Header().Add("X-Frame-Options", "DENY") + next.ServeHTTP(w, r) + }) +} + +// middleWareAuthenticate provides Authentication middleware for handlers +func (service *middleWareService) middleWareAuthenticate(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var token string // Get token from the Authorization header - // format: Authorization: Bearer tokens, ok := r.Header["Authorization"] if ok && len(tokens) >= 1 { token = tokens[0] @@ -32,34 +51,13 @@ func (api *api) authenticate(next http.Handler) http.Handler { return } - parsedToken, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) { - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - msg := fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) - return nil, msg - } - return api.secret, nil - }) + err := service.jwtService.VerifyToken(token) if err != nil { - http.Error(w, "Invalid JWT token", http.StatusUnauthorized) + http.Error(w, err.Error(), http.StatusUnauthorized) return } - if parsedToken == nil || !parsedToken.Valid { - http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - - // context.Set(r, "user", parsedToken) next.ServeHTTP(w, r) return }) } - -// SecureHeaders adds secure headers to the API -func secureHeaders(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Add("X-Content-Type-Options", "nosniff") - w.Header().Add("X-Frame-Options", "DENY") - next.ServeHTTP(w, r) - }) -} diff --git a/api/http/server.go b/api/http/server.go new file mode 100644 index 000000000..4f7d4efa1 --- /dev/null +++ b/api/http/server.go @@ -0,0 +1,53 @@ +package http + +import ( + "github.com/portainer/portainer" + + "net/http" +) + +// Server implements the portainer.Server interface +type Server struct { + BindAddress string + AssetsPath string + UserService portainer.UserService + CryptoService portainer.CryptoService + JWTService portainer.JWTService + Settings *portainer.Settings + TemplatesURL string + EndpointConfig *portainer.EndpointConfiguration +} + +// Start starts the HTTP server +func (server *Server) Start() error { + middleWareService := &middleWareService{ + jwtService: server.JWTService, + } + var authHandler = NewAuthHandler() + authHandler.UserService = server.UserService + authHandler.CryptoService = server.CryptoService + authHandler.JWTService = server.JWTService + var userHandler = NewUserHandler(middleWareService) + userHandler.UserService = server.UserService + userHandler.CryptoService = server.CryptoService + var settingsHandler = NewSettingsHandler(middleWareService) + settingsHandler.settings = server.Settings + var templatesHandler = NewTemplatesHandler(middleWareService) + templatesHandler.templatesURL = server.TemplatesURL + var dockerHandler = NewDockerHandler(middleWareService) + dockerHandler.setupProxy(server.EndpointConfig) + var websocketHandler = NewWebSocketHandler() + websocketHandler.endpointConfiguration = server.EndpointConfig + var fileHandler = http.FileServer(http.Dir(server.AssetsPath)) + + handler := &Handler{ + AuthHandler: authHandler, + UserHandler: userHandler, + SettingsHandler: settingsHandler, + TemplatesHandler: templatesHandler, + DockerHandler: dockerHandler, + WebSocketHandler: websocketHandler, + FileHandler: fileHandler, + } + return http.ListenAndServe(server.BindAddress, handler) +} diff --git a/api/http/settings_handler.go b/api/http/settings_handler.go new file mode 100644 index 000000000..4768e1a05 --- /dev/null +++ b/api/http/settings_handler.go @@ -0,0 +1,39 @@ +package http + +import ( + "github.com/portainer/portainer" + + "github.com/gorilla/mux" + "log" + "net/http" + "os" +) + +// SettingsHandler represents an HTTP API handler for managing settings. +type SettingsHandler struct { + *mux.Router + Logger *log.Logger + middleWareService *middleWareService + settings *portainer.Settings +} + +// NewSettingsHandler returns a new instance of SettingsHandler. +func NewSettingsHandler(middleWareService *middleWareService) *SettingsHandler { + h := &SettingsHandler{ + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + middleWareService: middleWareService, + } + h.HandleFunc("/settings", h.handleGetSettings) + return h +} + +// handleGetSettings handles GET requests on /settings +func (handler *SettingsHandler) handleGetSettings(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + handleNotAllowed(w, []string{"GET"}) + return + } + + encodeJSON(w, handler.settings, handler.Logger) +} diff --git a/api/http/templates_handler.go b/api/http/templates_handler.go new file mode 100644 index 000000000..b690012fb --- /dev/null +++ b/api/http/templates_handler.go @@ -0,0 +1,55 @@ +package http + +import ( + "fmt" + "github.com/gorilla/mux" + "io/ioutil" + "log" + "net/http" + "os" +) + +// TemplatesHandler represents an HTTP API handler for managing templates. +type TemplatesHandler struct { + *mux.Router + Logger *log.Logger + middleWareService *middleWareService + templatesURL string +} + +// NewTemplatesHandler returns a new instance of TemplatesHandler. +func NewTemplatesHandler(middleWareService *middleWareService) *TemplatesHandler { + h := &TemplatesHandler{ + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + middleWareService: middleWareService, + } + h.Handle("/templates", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h.handleGetTemplates(w, r) + }))) + return h +} + +// handleGetTemplates handles GET requests on /templates +func (handler *TemplatesHandler) handleGetTemplates(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + handleNotAllowed(w, []string{"GET"}) + return + } + + resp, err := http.Get(handler.templatesURL) + if err != nil { + log.Print(err) + http.Error(w, fmt.Sprintf("Error making request to %s: %s", handler.templatesURL, err.Error()), http.StatusInternalServerError) + return + } + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + log.Print(err) + http.Error(w, "Error reading body from templates URL", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(body) +} diff --git a/api/ssl.go b/api/http/tls.go similarity index 54% rename from api/ssl.go rename to api/http/tls.go index 89f76e85e..20d679ef6 100644 --- a/api/ssl.go +++ b/api/http/tls.go @@ -1,27 +1,26 @@ -package main +package http import ( "crypto/tls" "crypto/x509" "io/ioutil" - "log" ) -// newTLSConfig initializes a tls.Config using a CA certificate, a certificate and a key -func newTLSConfig(caCertPath, certPath, keyPath string) *tls.Config { +// createTLSConfiguration initializes a tls.Config using a CA certificate, a certificate and a key +func createTLSConfiguration(caCertPath, certPath, keyPath string) (*tls.Config, error) { cert, err := tls.LoadX509KeyPair(certPath, keyPath) if err != nil { - log.Fatal(err) + return nil, err } caCert, err := ioutil.ReadFile(caCertPath) if err != nil { - log.Fatal(err) + return nil, err } caCertPool := x509.NewCertPool() caCertPool.AppendCertsFromPEM(caCert) - tlsConfig := &tls.Config{ + config := &tls.Config{ Certificates: []tls.Certificate{cert}, RootCAs: caCertPool, } - return tlsConfig + return config, nil } diff --git a/api/http/user_handler.go b/api/http/user_handler.go new file mode 100644 index 000000000..461130e76 --- /dev/null +++ b/api/http/user_handler.go @@ -0,0 +1,247 @@ +package http + +import ( + "github.com/portainer/portainer" + + "encoding/json" + "github.com/asaskevich/govalidator" + "github.com/gorilla/mux" + "log" + "net/http" + "os" +) + +// UserHandler represents an HTTP API handler for managing users. +type UserHandler struct { + *mux.Router + Logger *log.Logger + UserService portainer.UserService + CryptoService portainer.CryptoService + middleWareService *middleWareService +} + +// NewUserHandler returns a new instance of UserHandler. +func NewUserHandler(middleWareService *middleWareService) *UserHandler { + h := &UserHandler{ + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + middleWareService: middleWareService, + } + h.Handle("/users", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h.handlePostUsers(w, r) + }))) + h.Handle("/users/{username}", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h.handleGetUser(w, r) + }))).Methods("GET") + h.Handle("/users/{username}", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h.handlePutUser(w, r) + }))).Methods("PUT") + h.Handle("/users/{username}/passwd", middleWareService.addMiddleWares(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h.handlePostUserPasswd(w, r) + }))) + h.HandleFunc("/users/admin/check", h.handleGetAdminCheck) + h.HandleFunc("/users/admin/init", h.handlePostAdminInit) + return h +} + +// handlePostUsers handles POST requests on /users +func (handler *UserHandler) handlePostUsers(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + handleNotAllowed(w, []string{"POST"}) + return + } + + var req postUsersRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err := govalidator.ValidateStruct(req) + if err != nil { + Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + user := &portainer.User{ + Username: req.Username, + } + user.Password, err = handler.CryptoService.Hash(req.Password) + if err != nil { + Error(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger) + return + } + + err = handler.UserService.UpdateUser(user) + if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } +} + +type postUsersRequest struct { + Username string `valid:"alphanum,required"` + Password string `valid:"required"` +} + +// handlePostUserPasswd handles POST requests on /users/:username/passwd +func (handler *UserHandler) handlePostUserPasswd(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + handleNotAllowed(w, []string{"POST"}) + return + } + + vars := mux.Vars(r) + username := vars["username"] + + var req postUserPasswdRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err := govalidator.ValidateStruct(req) + if err != nil { + Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + var password = req.Password + + u, err := handler.UserService.User(username) + if err == portainer.ErrUserNotFound { + Error(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + valid := true + err = handler.CryptoService.CompareHashAndData(u.Password, password) + if err != nil { + valid = false + } + + encodeJSON(w, &postUserPasswdResponse{Valid: valid}, handler.Logger) +} + +type postUserPasswdRequest struct { + Password string `valid:"required"` +} + +type postUserPasswdResponse struct { + Valid bool `json:"valid"` +} + +// handleGetUser handles GET requests on /users/:username +func (handler *UserHandler) handleGetUser(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + username := vars["username"] + + user, err := handler.UserService.User(username) + if err == portainer.ErrUserNotFound { + Error(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + user.Password = "" + encodeJSON(w, &user, handler.Logger) +} + +// handlePutUser handles PUT requests on /users/:username +func (handler *UserHandler) handlePutUser(w http.ResponseWriter, r *http.Request) { + var req putUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err := govalidator.ValidateStruct(req) + if err != nil { + Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + user := &portainer.User{ + Username: req.Username, + } + user.Password, err = handler.CryptoService.Hash(req.Password) + if err != nil { + Error(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger) + return + } + + err = handler.UserService.UpdateUser(user) + if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } +} + +type putUserRequest struct { + Username string `valid:"alphanum,required"` + Password string `valid:"required"` +} + +// handlePostAdminInit handles GET requests on /users/admin/check +func (handler *UserHandler) handleGetAdminCheck(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + handleNotAllowed(w, []string{"GET"}) + return + } + + user, err := handler.UserService.User("admin") + if err == portainer.ErrUserNotFound { + Error(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + user.Password = "" + encodeJSON(w, &user, handler.Logger) +} + +// handlePostAdminInit handles POST requests on /users/admin/init +func (handler *UserHandler) handlePostAdminInit(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + handleNotAllowed(w, []string{"POST"}) + return + } + + var req postAdminInitRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err := govalidator.ValidateStruct(req) + if err != nil { + Error(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + user := &portainer.User{ + Username: "admin", + } + user.Password, err = handler.CryptoService.Hash(req.Password) + if err != nil { + Error(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger) + return + } + + err = handler.UserService.UpdateUser(user) + if err != nil { + Error(w, err, http.StatusInternalServerError, handler.Logger) + return + } +} + +type postAdminInitRequest struct { + Password string `valid:"required"` +} diff --git a/api/hijack.go b/api/http/websocket_handler.go similarity index 60% rename from api/hijack.go rename to api/http/websocket_handler.go index ff6cd9071..365a1ccd1 100644 --- a/api/hijack.go +++ b/api/http/websocket_handler.go @@ -1,17 +1,78 @@ -package main +package http import ( + "github.com/portainer/portainer" + "bytes" "crypto/tls" "encoding/json" "fmt" + "github.com/gorilla/mux" + "golang.org/x/net/websocket" "io" + "log" "net" "net/http" "net/http/httputil" + "net/url" + "os" "time" ) +// WebSocketHandler represents an HTTP API handler for proxying requests to a web socket. +type WebSocketHandler struct { + *mux.Router + Logger *log.Logger + middleWareService *middleWareService + endpointConfiguration *portainer.EndpointConfiguration +} + +// NewWebSocketHandler returns a new instance of WebSocketHandler. +func NewWebSocketHandler() *WebSocketHandler { + h := &WebSocketHandler{ + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + } + h.Handle("/websocket/exec", websocket.Handler(h.webSocketDockerExec)) + return h +} + +func (handler *WebSocketHandler) webSocketDockerExec(ws *websocket.Conn) { + qry := ws.Request().URL.Query() + execID := qry.Get("id") + + // Should not be managed here + endpoint, err := url.Parse(handler.endpointConfiguration.Endpoint) + if err != nil { + log.Fatalf("Unable to parse endpoint URL: %s", err) + return + } + + var host string + if endpoint.Scheme == "tcp" { + host = endpoint.Host + } else if endpoint.Scheme == "unix" { + host = endpoint.Path + } + + // Should not be managed here + var tlsConfig *tls.Config + if handler.endpointConfiguration.TLS { + tlsConfig, err = createTLSConfiguration(handler.endpointConfiguration.TLSCACertPath, + handler.endpointConfiguration.TLSCertPath, + handler.endpointConfiguration.TLSKeyPath) + if err != nil { + log.Fatalf("Unable to create TLS configuration: %s", err) + return + } + } + + if err := hijack(host, endpoint.Scheme, "POST", "/exec/"+execID+"/start", tlsConfig, true, ws, ws, ws, nil, nil); err != nil { + log.Fatalf("error during hijack: %s", err) + return + } +} + type execConfig struct { Tty bool Detach bool diff --git a/api/jwt.go b/api/jwt.go deleted file mode 100644 index 880a23a70..000000000 --- a/api/jwt.go +++ /dev/null @@ -1,29 +0,0 @@ -package main - -import ( - "github.com/dgrijalva/jwt-go" - "time" -) - -type claims struct { - Username string `json:"username"` - jwt.StandardClaims -} - -func (api *api) generateJWTToken(username string) (string, error) { - expireToken := time.Now().Add(time.Hour * 8).Unix() - claims := claims{ - username, - jwt.StandardClaims{ - ExpiresAt: expireToken, - }, - } - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - - signedToken, err := token.SignedString(api.secret) - if err != nil { - return "", err - } - - return signedToken, nil -} diff --git a/api/jwt/jwt.go b/api/jwt/jwt.go new file mode 100644 index 000000000..0971ee5f7 --- /dev/null +++ b/api/jwt/jwt.go @@ -0,0 +1,66 @@ +package jwt + +import ( + "github.com/portainer/portainer" + + "fmt" + "github.com/dgrijalva/jwt-go" + "github.com/gorilla/securecookie" + "time" +) + +// Service represents a service for managing JWT tokens. +type Service struct { + secret []byte +} + +type claims struct { + Username string `json:"username"` + jwt.StandardClaims +} + +// NewService initializes a new service. It will generate a random key that will be used to sign JWT tokens. +func NewService() (*Service, error) { + secret := securecookie.GenerateRandomKey(32) + if secret == nil { + return nil, portainer.ErrSecretGeneration + } + service := &Service{ + secret, + } + return service, nil +} + +// GenerateToken generates a new JWT token. +func (service *Service) GenerateToken(data *portainer.TokenData) (string, error) { + expireToken := time.Now().Add(time.Hour * 8).Unix() + cl := claims{ + data.Username, + jwt.StandardClaims{ + ExpiresAt: expireToken, + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, cl) + + signedToken, err := token.SignedString(service.secret) + if err != nil { + return "", err + } + + return signedToken, nil +} + +// VerifyToken parses a JWT token and verify its validity. It returns an error if token is invalid. +func (service *Service) VerifyToken(token string) error { + parsedToken, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + msg := fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) + return nil, msg + } + return service.secret, nil + }) + if err != nil || parsedToken == nil || !parsedToken.Valid { + return portainer.ErrInvalidJWTToken + } + return nil +} diff --git a/api/main.go b/api/main.go deleted file mode 100644 index a63d5a534..000000000 --- a/api/main.go +++ /dev/null @@ -1,52 +0,0 @@ -package main // import "github.com/portainer/portainer" - -import ( - "gopkg.in/alecthomas/kingpin.v2" -) - -const ( - // Version number of portainer API - Version = "1.10.2" -) - -// main is the entry point of the program -func main() { - kingpin.Version(Version) - var ( - endpoint = kingpin.Flag("host", "Dockerd endpoint").Default("unix:///var/run/docker.sock").Short('H').String() - addr = kingpin.Flag("bind", "Address and port to serve Portainer").Default(":9000").Short('p').String() - assets = kingpin.Flag("assets", "Path to the assets").Default(".").Short('a').String() - data = kingpin.Flag("data", "Path to the folder where the data is stored").Default("/data").Short('d').String() - tlsverify = kingpin.Flag("tlsverify", "TLS support").Default("false").Bool() - tlscacert = kingpin.Flag("tlscacert", "Path to the CA").Default("/certs/ca.pem").String() - tlscert = kingpin.Flag("tlscert", "Path to the TLS certificate file").Default("/certs/cert.pem").String() - tlskey = kingpin.Flag("tlskey", "Path to the TLS key").Default("/certs/key.pem").String() - swarm = kingpin.Flag("swarm", "Swarm cluster support").Default("false").Short('s').Bool() - labels = pairs(kingpin.Flag("hide-label", "Hide containers with a specific label in the UI").Short('l')) - logo = kingpin.Flag("logo", "URL for the logo displayed in the UI").String() - templates = kingpin.Flag("templates", "URL to the templates (apps) definitions").Default("https://raw.githubusercontent.com/portainer/templates/master/templates.json").Short('t').String() - ) - kingpin.Parse() - - apiConfig := apiConfig{ - Endpoint: *endpoint, - BindAddress: *addr, - AssetPath: *assets, - DataPath: *data, - SwarmSupport: *swarm, - TLSEnabled: *tlsverify, - TLSCACertPath: *tlscacert, - TLSCertPath: *tlscert, - TLSKeyPath: *tlskey, - TemplatesURL: *templates, - } - - settings := &Settings{ - Swarm: *swarm, - HiddenLabels: *labels, - Logo: *logo, - } - - api := newAPI(apiConfig) - api.run(settings) -} diff --git a/api/portainer.go b/api/portainer.go new file mode 100644 index 000000000..d9038c630 --- /dev/null +++ b/api/portainer.go @@ -0,0 +1,92 @@ +package portainer + +type ( + // Pair defines a key/value string pair + Pair struct { + Name string `json:"name"` + Value string `json:"value"` + } + + // CLIFlags represents the available flags on the CLI. + CLIFlags struct { + Addr *string + Assets *string + Data *string + Endpoint *string + Labels *[]Pair + Logo *string + Swarm *bool + Templates *string + TLSVerify *bool + TLSCacert *string + TLSCert *string + TLSKey *string + } + + // Settings represents Portainer settings. + Settings struct { + Swarm bool `json:"swarm"` + HiddenLabels []Pair `json:"hiddenLabels"` + Logo string `json:"logo"` + } + + // User represent a user account. + User struct { + Username string `json:"username"` + Password string `json:"password,omitempty"` + } + + // TokenData represents the data embedded in a JWT token. + TokenData struct { + Username string + } + + // EndpointConfiguration represents the data required to connect to a Docker API endpoint. + EndpointConfiguration struct { + Endpoint string + TLS bool + TLSCACertPath string + TLSCertPath string + TLSKeyPath string + } + + // CLIService represents a service for managing CLI. + CLIService interface { + ParseFlags(version string) (*CLIFlags, error) + ValidateFlags(flags *CLIFlags) error + } + + // DataStore defines the interface to manage the data. + DataStore interface { + Open() error + Close() error + } + + // Server defines the interface to serve the data. + Server interface { + Start() error + } + + // UserService represents a service for managing users. + UserService interface { + User(username string) (*User, error) + UpdateUser(user *User) error + } + + // CryptoService represents a service for encrypting/hashing data. + CryptoService interface { + Hash(data string) (string, error) + CompareHashAndData(hash string, data string) error + } + + // JWTService represents a service for managing JWT tokens. + JWTService interface { + GenerateToken(data *TokenData) (string, error) + VerifyToken(token string) error + } +) + +const ( + // APIVersion is the version number of portainer API. + APIVersion = "1.10.2" +) diff --git a/api/settings.go b/api/settings.go deleted file mode 100644 index 2103a0c69..000000000 --- a/api/settings.go +++ /dev/null @@ -1,18 +0,0 @@ -package main - -import ( - "encoding/json" - "net/http" -) - -// Settings defines the settings available under the /settings endpoint -type Settings struct { - Swarm bool `json:"swarm"` - HiddenLabels pairList `json:"hiddenLabels"` - Logo string `json:"logo"` -} - -// settingsHandler defines a handler function used to encode the configuration in JSON -func settingsHandler(w http.ResponseWriter, r *http.Request, s *Settings) { - json.NewEncoder(w).Encode(*s) -} diff --git a/api/templates.go b/api/templates.go deleted file mode 100644 index 7c69a2ee7..000000000 --- a/api/templates.go +++ /dev/null @@ -1,27 +0,0 @@ -package main - -import ( - "fmt" - "io/ioutil" - "log" - "net/http" -) - -// templatesHandler defines a handler function used to retrieve the templates from a URL and put them in the response -func templatesHandler(w http.ResponseWriter, r *http.Request, templatesURL string) { - resp, err := http.Get(templatesURL) - if err != nil { - http.Error(w, fmt.Sprintf("Error making request to %s: %s", templatesURL, err.Error()), http.StatusInternalServerError) - log.Print(err) - return - } - defer resp.Body.Close() - body, err := ioutil.ReadAll(resp.Body) - if err != nil { - http.Error(w, "Error reading body from templates URL", http.StatusInternalServerError) - log.Print(err) - return - } - w.Header().Set("Content-Type", "application/json") - w.Write(body) -} diff --git a/api/unix_handler.go b/api/unix_handler.go deleted file mode 100644 index 15a5119d3..000000000 --- a/api/unix_handler.go +++ /dev/null @@ -1,47 +0,0 @@ -package main - -import ( - "io" - "log" - "net" - "net/http" - "net/http/httputil" -) - -// unixHandler defines a handler holding the path to a socket under UNIX -type unixHandler struct { - path string -} - -// ServeHTTP implementation for unixHandler -func (h *unixHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - conn, err := net.Dial("unix", h.path) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - log.Println(err) - return - } - c := httputil.NewClientConn(conn, nil) - defer c.Close() - - res, err := c.Do(r) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - log.Println(err) - return - } - defer res.Body.Close() - - copyHeader(w.Header(), res.Header) - if _, err := io.Copy(w, res.Body); err != nil { - log.Println(err) - } -} - -func copyHeader(dst, src http.Header) { - for k, vv := range src { - for _, v := range vv { - dst.Add(k, v) - } - } -} diff --git a/api/users.go b/api/users.go deleted file mode 100644 index d0a48aceb..000000000 --- a/api/users.go +++ /dev/null @@ -1,219 +0,0 @@ -package main - -import ( - "encoding/json" - "github.com/gorilla/mux" - "io/ioutil" - "log" - "net/http" -) - -type ( - passwordCheckRequest struct { - Password string `json:"password"` - } - passwordCheckResponse struct { - Valid bool `json:"valid"` - } - initAdminRequest struct { - Password string `json:"password"` - } -) - -// handle /users -// Allowed methods: POST -func (api *api) usersHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - w.Header().Set("Allow", "POST") - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - body, err := ioutil.ReadAll(r.Body) - if err != nil { - http.Error(w, "Unable to parse request body", http.StatusBadRequest) - return - } - - var user userItem - err = json.Unmarshal(body, &user) - if err != nil { - http.Error(w, "Unable to parse user data", http.StatusBadRequest) - return - } - - user.Password, err = hashPassword(user.Password) - if err != nil { - http.Error(w, "Unable to hash user password", http.StatusInternalServerError) - return - } - - err = api.dataStore.updateUser(user) - if err != nil { - log.Printf("Unable to persist user: %s", err.Error()) - http.Error(w, "Unable to persist user", http.StatusInternalServerError) - return - } -} - -// handle /users/admin/check -// Allowed methods: POST -func (api *api) checkAdminHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" { - w.Header().Set("Allow", "GET") - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - user, err := api.dataStore.getUserByUsername("admin") - if err == errUserNotFound { - log.Printf("User not found: %s", "admin") - http.Error(w, "User not found", http.StatusNotFound) - return - } - if err != nil { - log.Printf("Unable to retrieve user: %s", err.Error()) - http.Error(w, "Unable to retrieve user", http.StatusInternalServerError) - return - } - - user.Password = "" - json.NewEncoder(w).Encode(user) -} - -// handle /users/admin/init -// Allowed methods: POST -func (api *api) initAdminHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - w.Header().Set("Allow", "POST") - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - body, err := ioutil.ReadAll(r.Body) - if err != nil { - http.Error(w, "Unable to parse request body", http.StatusBadRequest) - return - } - - var requestData initAdminRequest - err = json.Unmarshal(body, &requestData) - if err != nil { - http.Error(w, "Unable to parse user data", http.StatusBadRequest) - return - } - - user := userItem{ - Username: "admin", - } - user.Password, err = hashPassword(requestData.Password) - if err != nil { - http.Error(w, "Unable to hash user password", http.StatusInternalServerError) - return - } - - err = api.dataStore.updateUser(user) - if err != nil { - log.Printf("Unable to persist user: %s", err.Error()) - http.Error(w, "Unable to persist user", http.StatusInternalServerError) - return - } -} - -// handle /users/{username} -// Allowed methods: PUT, GET -func (api *api) userHandler(w http.ResponseWriter, r *http.Request) { - if r.Method == "PUT" { - body, err := ioutil.ReadAll(r.Body) - if err != nil { - http.Error(w, "Unable to parse request body", http.StatusBadRequest) - return - } - - var user userItem - err = json.Unmarshal(body, &user) - if err != nil { - http.Error(w, "Unable to parse user data", http.StatusBadRequest) - return - } - - user.Password, err = hashPassword(user.Password) - if err != nil { - http.Error(w, "Unable to hash user password", http.StatusInternalServerError) - return - } - - err = api.dataStore.updateUser(user) - if err != nil { - log.Printf("Unable to persist user: %s", err.Error()) - http.Error(w, "Unable to persist user", http.StatusInternalServerError) - return - } - } else if r.Method == "GET" { - vars := mux.Vars(r) - username := vars["username"] - - user, err := api.dataStore.getUserByUsername(username) - if err == errUserNotFound { - log.Printf("User not found: %s", username) - http.Error(w, "User not found", http.StatusNotFound) - return - } - if err != nil { - log.Printf("Unable to retrieve user: %s", err.Error()) - http.Error(w, "Unable to retrieve user", http.StatusInternalServerError) - return - } - - user.Password = "" - json.NewEncoder(w).Encode(user) - } else { - w.Header().Set("Allow", "PUT, GET") - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } -} - -// handle /users/{username}/passwd -// Allowed methods: POST -func (api *api) userPasswordHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - w.Header().Set("Allow", "POST") - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - vars := mux.Vars(r) - username := vars["username"] - - body, err := ioutil.ReadAll(r.Body) - if err != nil { - http.Error(w, "Unable to parse request body", http.StatusBadRequest) - return - } - - var data passwordCheckRequest - err = json.Unmarshal(body, &data) - if err != nil { - http.Error(w, "Unable to parse user data", http.StatusBadRequest) - return - } - - user, err := api.dataStore.getUserByUsername(username) - if err != nil { - log.Printf("Unable to retrieve user: %s", err.Error()) - http.Error(w, "Unable to retrieve user", http.StatusInternalServerError) - return - } - - valid := true - err = checkPasswordValidity(data.Password, user.Password) - if err != nil { - valid = false - } - - response := passwordCheckResponse{ - Valid: valid, - } - json.NewEncoder(w).Encode(response) -} diff --git a/app/app.js b/app/app.js index ea6c6c24c..dc0aa5024 100644 --- a/app/app.js +++ b/app/app.js @@ -498,10 +498,11 @@ angular.module('portainer', [ }]) // This is your docker url that the api will use to make requests // You need to set this to the api endpoint without the port i.e. http://192.168.1.9 - .constant('DOCKER_ENDPOINT', 'dockerapi') + .constant('DOCKER_ENDPOINT', '/api/docker') .constant('DOCKER_PORT', '') // Docker port, leave as an empty string if no port is required. If you have a port, prefix it with a ':' i.e. :4243 - .constant('CONFIG_ENDPOINT', 'settings') - .constant('AUTH_ENDPOINT', 'auth') - .constant('TEMPLATES_ENDPOINT', 'templates') + .constant('CONFIG_ENDPOINT', '/api/settings') + .constant('AUTH_ENDPOINT', '/api/auth') + .constant('USERS_ENDPOINT', '/api/users') + .constant('TEMPLATES_ENDPOINT', '/api/templates') .constant('PAGINATION_MAX_ITEMS', 10) .constant('UI_VERSION', 'v1.10.2'); diff --git a/app/components/containerConsole/containerConsoleController.js b/app/components/containerConsole/containerConsoleController.js index 1804ef1ca..d81bff349 100644 --- a/app/components/containerConsole/containerConsoleController.js +++ b/app/components/containerConsole/containerConsoleController.js @@ -55,7 +55,7 @@ function ($scope, $stateParams, Settings, Container, Image, Exec, $timeout, Mess } else { var execId = d.Id; resizeTTY(execId, termHeight, termWidth); - var url = window.location.href.split('#')[0] + 'ws/exec?id=' + execId; + var url = window.location.href.split('#')[0] + 'api/websocket/exec?id=' + execId; if (url.indexOf('https') > -1) { url = url.replace('https://', 'wss://'); } else { diff --git a/app/shared/services.js b/app/shared/services.js index 513ceaa9d..67fb2aa39 100644 --- a/app/shared/services.js +++ b/app/shared/services.js @@ -229,9 +229,9 @@ angular.module('portainer.services', ['ngResource', 'ngSanitize']) } }); }]) - .factory('Users', ['$resource', function UsersFactory($resource) { + .factory('Users', ['$resource', 'USERS_ENDPOINT', function UsersFactory($resource, USERS_ENDPOINT) { 'use strict'; - return $resource('/users/:username/:action', {}, { + return $resource(USERS_ENDPOINT + '/:username/:action', {}, { create: { method: 'POST' }, get: {method: 'GET', params: { username: '@username' } }, update: { method: 'PUT', params: { username: '@username' } }, diff --git a/gruntFile.js b/gruntFile.js index 58f35279e..1aa1e8aa7 100644 --- a/gruntFile.js +++ b/gruntFile.js @@ -297,34 +297,34 @@ module.exports = function (grunt) { }, buildBinary: { command: [ - 'docker run --rm -v $(pwd)/api:/src portainer/golang-builder', - 'shasum api/portainer > portainer-checksum.txt', + 'docker run --rm -v $(pwd)/api:/src portainer/golang-builder /src/cmd/portainer', + 'shasum api/cmd/portainer/portainer > portainer-checksum.txt', 'mkdir -p dist', - 'mv api/portainer dist/' + 'mv api/cmd/portainer/portainer dist/' ].join(' && ') }, buildUnixArmBinary: { command: [ - 'docker run --rm -v $(pwd)/api:/src -e BUILD_GOOS="linux" -e BUILD_GOARCH="arm" portainer/golang-builder:cross-platform', - 'shasum api/portainer-linux-arm > portainer-checksum.txt', + 'docker run --rm -v $(pwd)/api:/src -e BUILD_GOOS="linux" -e BUILD_GOARCH="arm" portainer/golang-builder:cross-platform /src/cmd/portainer', + 'shasum api/cmd/portainer/portainer-linux-arm > portainer-checksum.txt', 'mkdir -p dist', - 'mv api/portainer-linux-arm dist/portainer' + 'mv api/cmd/portainer/portainer-linux-arm dist/portainer' ].join(' && ') }, buildDarwinBinary: { command: [ - 'docker run --rm -v $(pwd)/api:/src -e BUILD_GOOS="darwin" -e BUILD_GOARCH="amd64" portainer/golang-builder:cross-platform', - 'shasum api/portainer-darwin-amd64 > portainer-checksum.txt', + 'docker run --rm -v $(pwd)/api:/src -e BUILD_GOOS="darwin" -e BUILD_GOARCH="amd64" portainer/golang-builder:cross-platform /src/cmd/portainer', + 'shasum api/cmd/portainer/portainer-darwin-amd64 > portainer-checksum.txt', 'mkdir -p dist', - 'mv api/portainer-darwin-amd64 dist/portainer' + 'mv api/cmd/portainer/portainer-darwin-amd64 dist/portainer' ].join(' && ') }, buildWindowsBinary: { command: [ - 'docker run --rm -v $(pwd)/api:/src -e BUILD_GOOS="windows" -e BUILD_GOARCH="amd64" portainer/golang-builder:cross-platform', - 'shasum api/portainer-windows-amd64 > portainer-checksum.txt', + 'docker run --rm -v $(pwd)/api:/src -e BUILD_GOOS="windows" -e BUILD_GOARCH="amd64" portainer/golang-builder:cross-platform /src/cmd/portainer', + 'shasum api/cmd/portainer/portainer-windows-amd64 > portainer-checksum.txt', 'mkdir -p dist', - 'mv api/portainer-windows-amd64 dist/portainer.exe' + 'mv api/cmd/portainer/portainer-windows-amd64 dist/portainer.exe' ].join(' && ') }, run: {