diff --git a/README.md b/README.md index a2a16ee24..11bf86ad8 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,6 @@

-[![Microbadger version](https://images.microbadger.com/badges/version/portainer/portainer.svg)](https://microbadger.com/images/portainer/portainer "Latest version on Docker Hub") [![Microbadger](https://images.microbadger.com/badges/image/portainer/portainer.svg)](http://microbadger.com/images/portainer/portainer "Image size") [![Documentation Status](https://readthedocs.org/projects/portainer/badge/?version=stable)](http://portainer.readthedocs.io/en/latest/?badge=stable) [![Gitter](https://badges.gitter.im/portainer/Lobby.svg)](https://gitter.im/portainer/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) diff --git a/api/bolt/endpoint_service.go b/api/bolt/endpoint_service.go index f8bbdd795..cb8349076 100644 --- a/api/bolt/endpoint_service.go +++ b/api/bolt/endpoint_service.go @@ -67,20 +67,41 @@ func (service *EndpointService) Endpoints() ([]portainer.Endpoint, error) { return endpoints, nil } +// Synchronize creates, updates and deletes endpoints inside a single transaction. +func (service *EndpointService) Synchronize(toCreate, toUpdate, toDelete []*portainer.Endpoint) error { + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(endpointBucketName)) + + for _, endpoint := range toCreate { + err := storeNewEndpoint(endpoint, bucket) + if err != nil { + return err + } + } + + for _, endpoint := range toUpdate { + err := marshalAndStoreEndpoint(endpoint, bucket) + if err != nil { + return err + } + } + + for _, endpoint := range toDelete { + err := bucket.Delete(internal.Itob(int(endpoint.ID))) + if err != nil { + return err + } + } + + return 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) + err := storeNewEndpoint(endpoint, bucket) if err != nil { return err } @@ -172,3 +193,23 @@ func (service *EndpointService) DeleteActive() error { return nil }) } + +func marshalAndStoreEndpoint(endpoint *portainer.Endpoint, bucket *bolt.Bucket) error { + 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 +} + +func storeNewEndpoint(endpoint *portainer.Endpoint, bucket *bolt.Bucket) error { + id, _ := bucket.NextSequence() + endpoint.ID = portainer.EndpointID(id) + + return marshalAndStoreEndpoint(endpoint, bucket) +} diff --git a/api/cli/cli.go b/api/cli/cli.go index 10f476c9b..ead424779 100644 --- a/api/cli/cli.go +++ b/api/cli/cli.go @@ -1,6 +1,8 @@ package cli import ( + "time" + "github.com/portainer/portainer" "os" @@ -13,8 +15,11 @@ import ( type Service struct{} const ( - errInvalidEnpointProtocol = portainer.Error("Invalid endpoint protocol: Portainer only supports unix:// or tcp://") - errSocketNotFound = portainer.Error("Unable to locate Unix socket") + errInvalidEnpointProtocol = portainer.Error("Invalid endpoint protocol: Portainer only supports unix:// or tcp://") + errSocketNotFound = portainer.Error("Unable to locate Unix socket") + errEndpointsFileNotFound = portainer.Error("Unable to locate external endpoints file") + errInvalidSyncInterval = portainer.Error("Invalid synchronization interval") + errEndpointExcludeExternal = portainer.Error("Cannot use the -H flag mutually with --external-endpoints") ) // ParseFlags parse the CLI flags and return a portainer.Flags struct @@ -22,17 +27,20 @@ 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(defaultBindAddress).Short('p').String(), - Assets: kingpin.Flag("assets", "Path to the assets").Default(defaultAssetsDirectory).Short('a').String(), - Data: kingpin.Flag("data", "Path to the folder where the data is stored").Default(defaultDataDirectory).Short('d').String(), - Templates: kingpin.Flag("templates", "URL to the templates (apps) definitions").Default(defaultTemplatesURL).Short('t').String(), - TLSVerify: kingpin.Flag("tlsverify", "TLS support").Default(defaultTLSVerify).Bool(), - TLSCacert: kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).String(), - TLSCert: kingpin.Flag("tlscert", "Path to the TLS certificate file").Default(defaultTLSCertPath).String(), - TLSKey: kingpin.Flag("tlskey", "Path to the TLS key").Default(defaultTLSKeyPath).String(), + Endpoint: kingpin.Flag("host", "Dockerd endpoint").Short('H').String(), + Logo: kingpin.Flag("logo", "URL for the logo displayed in the UI").String(), + ExternalEndpoints: kingpin.Flag("external-endpoints", "Path to a file defining available endpoints").String(), + SyncInterval: kingpin.Flag("sync-interval", "Duration between each synchronization via the external endpoints source").Default(defaultSyncInterval).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(defaultBindAddress).Short('p').String(), + Assets: kingpin.Flag("assets", "Path to the assets").Default(defaultAssetsDirectory).Short('a').String(), + Data: kingpin.Flag("data", "Path to the folder where the data is stored").Default(defaultDataDirectory).Short('d').String(), + Templates: kingpin.Flag("templates", "URL to the templates (apps) definitions").Default(defaultTemplatesURL).Short('t').String(), + NoAuth: kingpin.Flag("no-auth", "Disable authentication").Default(defaultNoAuth).Bool(), + TLSVerify: kingpin.Flag("tlsverify", "TLS support").Default(defaultTLSVerify).Bool(), + TLSCacert: kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).String(), + TLSCert: kingpin.Flag("tlscert", "Path to the TLS certificate file").Default(defaultTLSCertPath).String(), + TLSKey: kingpin.Flag("tlskey", "Path to the TLS key").Default(defaultTLSKeyPath).String(), } kingpin.Parse() @@ -41,13 +49,37 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) { // 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://") { + + if *flags.Endpoint != "" && *flags.ExternalEndpoints != "" { + return errEndpointExcludeExternal + } + + err := validateEndpoint(*flags.Endpoint) + if err != nil { + return err + } + + err = validateExternalEndpoints(*flags.ExternalEndpoints) + if err != nil { + return err + } + + err = validateSyncInterval(*flags.SyncInterval) + if err != nil { + return err + } + + return nil +} + +func validateEndpoint(endpoint string) error { + if endpoint != "" { + if !strings.HasPrefix(endpoint, "unix://") && !strings.HasPrefix(endpoint, "tcp://") { return errInvalidEnpointProtocol } - if strings.HasPrefix(*flags.Endpoint, "unix://") { - socketPath := strings.TrimPrefix(*flags.Endpoint, "unix://") + if strings.HasPrefix(endpoint, "unix://") { + socketPath := strings.TrimPrefix(endpoint, "unix://") if _, err := os.Stat(socketPath); err != nil { if os.IsNotExist(err) { return errSocketNotFound @@ -56,6 +88,27 @@ func (*Service) ValidateFlags(flags *portainer.CLIFlags) error { } } } - + return nil +} + +func validateExternalEndpoints(externalEndpoints string) error { + if externalEndpoints != "" { + if _, err := os.Stat(externalEndpoints); err != nil { + if os.IsNotExist(err) { + return errEndpointsFileNotFound + } + return err + } + } + return nil +} + +func validateSyncInterval(syncInterval string) error { + if syncInterval != defaultSyncInterval { + _, err := time.ParseDuration(syncInterval) + if err != nil { + return errInvalidSyncInterval + } + } return nil } diff --git a/api/cli/defaults.go b/api/cli/defaults.go index adf8affbf..4545ecf79 100644 --- a/api/cli/defaults.go +++ b/api/cli/defaults.go @@ -7,8 +7,10 @@ const ( defaultDataDirectory = "/data" defaultAssetsDirectory = "." defaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates.json" + defaultNoAuth = "false" defaultTLSVerify = "false" defaultTLSCACertPath = "/certs/ca.pem" defaultTLSCertPath = "/certs/cert.pem" defaultTLSKeyPath = "/certs/key.pem" + defaultSyncInterval = "60s" ) diff --git a/api/cli/defaults_windows.go b/api/cli/defaults_windows.go index 3a4106c74..17c6c3776 100644 --- a/api/cli/defaults_windows.go +++ b/api/cli/defaults_windows.go @@ -5,8 +5,10 @@ const ( defaultDataDirectory = "C:\\data" defaultAssetsDirectory = "." defaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates.json" + defaultNoAuth = "false" defaultTLSVerify = "false" defaultTLSCACertPath = "C:\\certs\\ca.pem" defaultTLSCertPath = "C:\\certs\\cert.pem" defaultTLSKeyPath = "C:\\certs\\key.pem" + defaultSyncInterval = "60s" ) diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index e01c304a9..423c554cc 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -4,6 +4,7 @@ import ( "github.com/portainer/portainer" "github.com/portainer/portainer/bolt" "github.com/portainer/portainer/cli" + "github.com/portainer/portainer/cron" "github.com/portainer/portainer/crypto" "github.com/portainer/portainer/file" "github.com/portainer/portainer/http" @@ -12,7 +13,7 @@ import ( "log" ) -func main() { +func initCLI() *portainer.CLIFlags { var cli portainer.CLIService = &cli.Service{} flags, err := cli.ParseFlags(portainer.APIVersion) if err != nil { @@ -23,37 +24,76 @@ func main() { if err != nil { log.Fatal(err) } + return flags +} - settings := &portainer.Settings{ - HiddenLabels: *flags.Labels, - Logo: *flags.Logo, - } - - fileService, err := file.NewService(*flags.Data, "") +func initFileService(dataStorePath string) portainer.FileService { + fileService, err := file.NewService(dataStorePath, "") if err != nil { log.Fatal(err) } + return fileService +} - var store = bolt.NewStore(*flags.Data) - err = store.Open() +func initStore(dataStorePath string) *bolt.Store { + var store = bolt.NewStore(dataStorePath) + err := store.Open() if err != nil { log.Fatal(err) } - defer store.Close() + return store +} - jwtService, err := jwt.NewService() +func initJWTService(authenticationEnabled bool) portainer.JWTService { + if authenticationEnabled { + jwtService, err := jwt.NewService() + if err != nil { + log.Fatal(err) + } + return jwtService + } + return nil +} + +func initCryptoService() portainer.CryptoService { + return &crypto.Service{} +} + +func initEndpointWatcher(endpointService portainer.EndpointService, externalEnpointFile string, syncInterval string) bool { + authorizeEndpointMgmt := true + if externalEnpointFile != "" { + authorizeEndpointMgmt = false + log.Println("Using external endpoint definition. Endpoint management via the API will be disabled.") + endpointWatcher := cron.NewWatcher(endpointService, syncInterval) + err := endpointWatcher.WatchEndpointFile(externalEnpointFile) + if err != nil { + log.Fatal(err) + } + } + return authorizeEndpointMgmt +} + +func initSettings(authorizeEndpointMgmt bool, flags *portainer.CLIFlags) *portainer.Settings { + return &portainer.Settings{ + HiddenLabels: *flags.Labels, + Logo: *flags.Logo, + Authentication: !*flags.NoAuth, + EndpointManagement: authorizeEndpointMgmt, + } +} + +func retrieveFirstEndpointFromDatabase(endpointService portainer.EndpointService) *portainer.Endpoint { + endpoints, err := endpointService.Endpoints() if err != nil { log.Fatal(err) } + return &endpoints[0] +} - 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 { +func initActiveEndpoint(endpointService portainer.EndpointService, flags *portainer.CLIFlags) *portainer.Endpoint { + activeEndpoint, err := endpointService.GetActive() + if err == portainer.ErrEndpointNotFound { + if *flags.Endpoint != "" { activeEndpoint = &portainer.Endpoint{ Name: "primary", URL: *flags.Endpoint, @@ -62,30 +102,54 @@ func main() { TLSCertPath: *flags.TLSCert, TLSKeyPath: *flags.TLSKey, } - err = store.EndpointService.CreateEndpoint(activeEndpoint) + err = endpointService.CreateEndpoint(activeEndpoint) if err != nil { log.Fatal(err) } - } else if err != nil { - log.Fatal(err) + } else if *flags.ExternalEndpoints != "" { + activeEndpoint = retrieveFirstEndpointFromDatabase(endpointService) } + } else if err != nil { + log.Fatal(err) } + return activeEndpoint +} + +func main() { + flags := initCLI() + + fileService := initFileService(*flags.Data) + + store := initStore(*flags.Data) + defer store.Close() + + jwtService := initJWTService(!*flags.NoAuth) + + cryptoService := initCryptoService() + + authorizeEndpointMgmt := initEndpointWatcher(store.EndpointService, *flags.ExternalEndpoints, *flags.SyncInterval) + + settings := initSettings(authorizeEndpointMgmt, flags) + + activeEndpoint := initActiveEndpoint(store.EndpointService, flags) 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, + BindAddress: *flags.Addr, + AssetsPath: *flags.Assets, + Settings: settings, + TemplatesURL: *flags.Templates, + AuthDisabled: *flags.NoAuth, + EndpointManagement: authorizeEndpointMgmt, + 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() + err := server.Start() if err != nil { log.Fatal(err) } diff --git a/api/cron/endpoint_sync.go b/api/cron/endpoint_sync.go new file mode 100644 index 000000000..9dcd4e290 --- /dev/null +++ b/api/cron/endpoint_sync.go @@ -0,0 +1,171 @@ +package cron + +import ( + "encoding/json" + "io/ioutil" + "log" + "os" + "strings" + + "github.com/portainer/portainer" +) + +type ( + endpointSyncJob struct { + logger *log.Logger + endpointService portainer.EndpointService + endpointFilePath string + } + + synchronization struct { + endpointsToCreate []*portainer.Endpoint + endpointsToUpdate []*portainer.Endpoint + endpointsToDelete []*portainer.Endpoint + } +) + +const ( + // ErrEmptyEndpointArray is an error raised when the external endpoint source array is empty. + ErrEmptyEndpointArray = portainer.Error("External endpoint source is empty") +) + +func newEndpointSyncJob(endpointFilePath string, endpointService portainer.EndpointService) endpointSyncJob { + return endpointSyncJob{ + logger: log.New(os.Stderr, "", log.LstdFlags), + endpointService: endpointService, + endpointFilePath: endpointFilePath, + } +} + +func endpointSyncError(err error, logger *log.Logger) bool { + if err != nil { + logger.Printf("Endpoint synchronization error: %s", err) + return true + } + return false +} + +func isValidEndpoint(endpoint *portainer.Endpoint) bool { + if endpoint.Name != "" && endpoint.URL != "" { + if !strings.HasPrefix(endpoint.URL, "unix://") && !strings.HasPrefix(endpoint.URL, "tcp://") { + return false + } + return true + } + return false +} + +func endpointExists(endpoint *portainer.Endpoint, endpoints []portainer.Endpoint) int { + for idx, v := range endpoints { + if endpoint.Name == v.Name && isValidEndpoint(&v) { + return idx + } + } + return -1 +} + +func mergeEndpointIfRequired(original, updated *portainer.Endpoint) *portainer.Endpoint { + var endpoint *portainer.Endpoint + if original.URL != updated.URL || original.TLS != updated.TLS { + endpoint = original + endpoint.URL = updated.URL + if updated.TLS { + endpoint.TLS = true + endpoint.TLSCACertPath = updated.TLSCACertPath + endpoint.TLSCertPath = updated.TLSCertPath + endpoint.TLSKeyPath = updated.TLSKeyPath + } else { + endpoint.TLS = false + endpoint.TLSCACertPath = "" + endpoint.TLSCertPath = "" + endpoint.TLSKeyPath = "" + } + } + return endpoint +} + +func (sync synchronization) requireSync() bool { + if len(sync.endpointsToCreate) != 0 || len(sync.endpointsToUpdate) != 0 || len(sync.endpointsToDelete) != 0 { + return true + } + return false +} + +// TMP: endpointSyncJob method to access logger, should be generic +func (job endpointSyncJob) prepareSyncData(storedEndpoints, fileEndpoints []portainer.Endpoint) *synchronization { + endpointsToCreate := make([]*portainer.Endpoint, 0) + endpointsToUpdate := make([]*portainer.Endpoint, 0) + endpointsToDelete := make([]*portainer.Endpoint, 0) + + for idx := range storedEndpoints { + fidx := endpointExists(&storedEndpoints[idx], fileEndpoints) + if fidx != -1 { + endpoint := mergeEndpointIfRequired(&storedEndpoints[idx], &fileEndpoints[fidx]) + if endpoint != nil { + job.logger.Printf("New definition for a stored endpoint found in file, updating database. [name: %v] [url: %v]\n", endpoint.Name, endpoint.URL) + endpointsToUpdate = append(endpointsToUpdate, endpoint) + } else { + job.logger.Printf("No change detected for a stored endpoint. [name: %v] [url: %v]\n", storedEndpoints[idx].Name, storedEndpoints[idx].URL) + } + } else { + job.logger.Printf("Stored endpoint not found in file (definition might be invalid), removing from database. [name: %v] [url: %v]", storedEndpoints[idx].Name, storedEndpoints[idx].URL) + endpointsToDelete = append(endpointsToDelete, &storedEndpoints[idx]) + } + } + + for idx, endpoint := range fileEndpoints { + if endpoint.Name == "" || endpoint.URL == "" { + job.logger.Printf("Invalid file endpoint definition, skipping. [name: %v] [url: %v]", endpoint.Name, endpoint.URL) + continue + } + sidx := endpointExists(&fileEndpoints[idx], storedEndpoints) + if sidx == -1 { + job.logger.Printf("File endpoint not found in database, adding to database. [name: %v] [url: %v]", fileEndpoints[idx].Name, fileEndpoints[idx].URL) + endpointsToCreate = append(endpointsToCreate, &fileEndpoints[idx]) + } + } + + return &synchronization{ + endpointsToCreate: endpointsToCreate, + endpointsToUpdate: endpointsToUpdate, + endpointsToDelete: endpointsToDelete, + } +} + +func (job endpointSyncJob) Sync() error { + data, err := ioutil.ReadFile(job.endpointFilePath) + if endpointSyncError(err, job.logger) { + return err + } + + var fileEndpoints []portainer.Endpoint + err = json.Unmarshal(data, &fileEndpoints) + if endpointSyncError(err, job.logger) { + return err + } + + if len(fileEndpoints) == 0 { + return ErrEmptyEndpointArray + } + + storedEndpoints, err := job.endpointService.Endpoints() + if endpointSyncError(err, job.logger) { + return err + } + + sync := job.prepareSyncData(storedEndpoints, fileEndpoints) + if sync.requireSync() { + err = job.endpointService.Synchronize(sync.endpointsToCreate, sync.endpointsToUpdate, sync.endpointsToDelete) + if endpointSyncError(err, job.logger) { + return err + } + job.logger.Printf("Endpoint synchronization ended. [created: %v] [updated: %v] [deleted: %v]", len(sync.endpointsToCreate), len(sync.endpointsToUpdate), len(sync.endpointsToDelete)) + } + return nil +} + +func (job endpointSyncJob) Run() { + job.logger.Println("Endpoint synchronization job started.") + err := job.Sync() + endpointSyncError(err, job.logger) +} diff --git a/api/cron/watcher.go b/api/cron/watcher.go new file mode 100644 index 000000000..6b44ff5ce --- /dev/null +++ b/api/cron/watcher.go @@ -0,0 +1,40 @@ +package cron + +import ( + "github.com/portainer/portainer" + "github.com/robfig/cron" +) + +// Watcher represents a service for managing crons. +type Watcher struct { + Cron *cron.Cron + EndpointService portainer.EndpointService + syncInterval string +} + +// NewWatcher initializes a new service. +func NewWatcher(endpointService portainer.EndpointService, syncInterval string) *Watcher { + return &Watcher{ + Cron: cron.New(), + EndpointService: endpointService, + syncInterval: syncInterval, + } +} + +// WatchEndpointFile starts a cron job to synchronize the endpoints from a file +func (watcher *Watcher) WatchEndpointFile(endpointFilePath string) error { + job := newEndpointSyncJob(endpointFilePath, watcher.EndpointService) + + err := job.Sync() + if err != nil { + return err + } + + err = watcher.Cron.AddJob("@every "+watcher.syncInterval, job) + if err != nil { + return err + } + + watcher.Cron.Start() + return nil +} diff --git a/api/http/auth_handler.go b/api/http/auth_handler.go index 29c19201d..b4c1af789 100644 --- a/api/http/auth_handler.go +++ b/api/http/auth_handler.go @@ -16,6 +16,7 @@ import ( type AuthHandler struct { *mux.Router Logger *log.Logger + authDisabled bool UserService portainer.UserService CryptoService portainer.CryptoService JWTService portainer.JWTService @@ -26,6 +27,9 @@ const ( ErrInvalidCredentialsFormat = portainer.Error("Invalid credentials format") // ErrInvalidCredentials is an error raised when credentials for a user are invalid ErrInvalidCredentials = portainer.Error("Invalid credentials") + // ErrAuthDisabled is an error raised when trying to access the authentication endpoints + // when the server has been started with the --no-auth flag + ErrAuthDisabled = portainer.Error("Authentication is disabled") ) // NewAuthHandler returns a new instance of AuthHandler. @@ -44,6 +48,11 @@ func (handler *AuthHandler) handlePostAuth(w http.ResponseWriter, r *http.Reques return } + if handler.authDisabled { + Error(w, ErrAuthDisabled, http.StatusServiceUnavailable, handler.Logger) + return + } + var req postAuthRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) diff --git a/api/http/endpoint_handler.go b/api/http/endpoint_handler.go index 9f5ca31c0..7c7d0f930 100644 --- a/api/http/endpoint_handler.go +++ b/api/http/endpoint_handler.go @@ -16,13 +16,20 @@ import ( // 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 + Logger *log.Logger + authorizeEndpointManagement bool + EndpointService portainer.EndpointService + FileService portainer.FileService + server *Server + middleWareService *middleWareService } +const ( + // ErrEndpointManagementDisabled is an error raised when trying to access the endpoints management endpoints + // when the server has been started with the --external-endpoints flag + ErrEndpointManagementDisabled = portainer.Error("Endpoint management is disabled") +) + // NewEndpointHandler returns a new instance of EndpointHandler. func NewEndpointHandler(middleWareService *middleWareService) *EndpointHandler { h := &EndpointHandler{ @@ -65,6 +72,11 @@ func (handler *EndpointHandler) handleGetEndpoints(w http.ResponseWriter, r *htt // 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) { + if !handler.authorizeEndpointManagement { + Error(w, ErrEndpointManagementDisabled, http.StatusServiceUnavailable, handler.Logger) + return + } + var req postEndpointsRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { Error(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) @@ -203,6 +215,11 @@ func (handler *EndpointHandler) handlePostEndpoint(w http.ResponseWriter, r *htt // handlePutEndpoint handles PUT requests on /endpoints/:id func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http.Request) { + if !handler.authorizeEndpointManagement { + Error(w, ErrEndpointManagementDisabled, http.StatusServiceUnavailable, handler.Logger) + return + } + vars := mux.Vars(r) id := vars["id"] @@ -262,6 +279,11 @@ type putEndpointsRequest struct { // handleDeleteEndpoint handles DELETE requests on /endpoints/:id // DELETE /endpoints/0 deletes the active endpoint func (handler *EndpointHandler) handleDeleteEndpoint(w http.ResponseWriter, r *http.Request) { + if !handler.authorizeEndpointManagement { + Error(w, ErrEndpointManagementDisabled, http.StatusServiceUnavailable, handler.Logger) + return + } + vars := mux.Vars(r) id := vars["id"] diff --git a/api/http/middleware.go b/api/http/middleware.go index ff38a736a..891e7c610 100644 --- a/api/http/middleware.go +++ b/api/http/middleware.go @@ -9,7 +9,8 @@ import ( // Service represents a service to manage HTTP middlewares type middleWareService struct { - jwtService portainer.JWTService + jwtService portainer.JWTService + authDisabled bool } func addMiddleware(h http.Handler, middleware ...func(http.Handler) http.Handler) http.Handler { @@ -37,24 +38,26 @@ func (*middleWareService) middleWareSecureHeaders(next http.Handler) http.Handle // 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 + if !service.authDisabled { + 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 ") - } + // 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 == "" { - Error(w, portainer.ErrUnauthorized, http.StatusUnauthorized, nil) - return - } + if token == "" { + Error(w, portainer.ErrUnauthorized, http.StatusUnauthorized, nil) + return + } - err := service.jwtService.VerifyToken(token) - if err != nil { - Error(w, err, http.StatusUnauthorized, nil) - return + err := service.jwtService.VerifyToken(token) + if err != nil { + Error(w, err, http.StatusUnauthorized, nil) + return + } } next.ServeHTTP(w, r) diff --git a/api/http/server.go b/api/http/server.go index 32975944b..dc09db888 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -8,17 +8,19 @@ import ( // 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 + BindAddress string + AssetsPath string + AuthDisabled bool + EndpointManagement bool + 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 { @@ -40,13 +42,15 @@ func (server *Server) updateActiveEndpoint(endpoint *portainer.Endpoint) error { // Start starts the HTTP server func (server *Server) Start() error { middleWareService := &middleWareService{ - jwtService: server.JWTService, + jwtService: server.JWTService, + authDisabled: server.AuthDisabled, } var authHandler = NewAuthHandler() authHandler.UserService = server.UserService authHandler.CryptoService = server.CryptoService authHandler.JWTService = server.JWTService + authHandler.authDisabled = server.AuthDisabled var userHandler = NewUserHandler(middleWareService) userHandler.UserService = server.UserService userHandler.CryptoService = server.CryptoService @@ -58,6 +62,7 @@ func (server *Server) Start() error { var websocketHandler = NewWebSocketHandler() // EndpointHandler requires a reference to the server to be able to update the active endpoint. var endpointHandler = NewEndpointHandler(middleWareService) + endpointHandler.authorizeEndpointManagement = server.EndpointManagement endpointHandler.EndpointService = server.EndpointService endpointHandler.FileService = server.FileService endpointHandler.server = server diff --git a/api/portainer.go b/api/portainer.go index 6d928e1b2..ee84c4404 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -13,23 +13,28 @@ type ( // 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 + Addr *string + Assets *string + Data *string + ExternalEndpoints *string + SyncInterval *string + Endpoint *string + Labels *[]Pair + Logo *string + Templates *string + NoAuth *bool + TLSVerify *bool + TLSCacert *string + TLSCert *string + TLSKey *string } // Settings represents Portainer settings. Settings struct { - HiddenLabels []Pair `json:"hiddenLabels"` - Logo string `json:"logo"` + HiddenLabels []Pair `json:"hiddenLabels"` + Logo string `json:"logo"` + Authentication bool `json:"authentication"` + EndpointManagement bool `json:"endpointManagement"` } // User represent a user account. @@ -95,6 +100,7 @@ type ( GetActive() (*Endpoint, error) SetActive(endpoint *Endpoint) error DeleteActive() error + Synchronize(toCreate, toUpdate, toDelete []*Endpoint) error } // CryptoService represents a service for encrypting/hashing data. @@ -115,11 +121,16 @@ type ( GetPathForTLSFile(endpointID EndpointID, fileType TLSFileType) (string, error) DeleteTLSFiles(endpointID EndpointID) error } + + // EndpointWatcher represents a service to synchronize the endpoints via an external source. + EndpointWatcher interface { + WatchEndpointFile(endpointFilePath string) error + } ) const ( // APIVersion is the version number of portainer API. - APIVersion = "1.11.3" + APIVersion = "1.11.4" ) const ( diff --git a/app/app.js b/app/app.js index 3c563dd9e..e819fd0aa 100644 --- a/app/app.js +++ b/app/app.js @@ -1,5 +1,8 @@ +angular.module('portainer.filters', []); +angular.module('portainer.rest', ['ngResource']); +angular.module('portainer.services', []); +angular.module('portainer.helpers', []); angular.module('portainer', [ - 'portainer.templates', 'ui.bootstrap', 'ui.router', 'ui.select', @@ -9,9 +12,11 @@ angular.module('portainer', [ 'angularUtils.directives.dirPagination', 'LocalStorageModule', 'angular-jwt', - 'portainer.services', - 'portainer.helpers', + 'portainer.templates', 'portainer.filters', + 'portainer.rest', + 'portainer.helpers', + 'portainer.services', 'auth', 'dashboard', 'container', @@ -19,29 +24,29 @@ angular.module('portainer', [ 'containerLogs', 'containers', 'createContainer', + 'createNetwork', + 'createService', + 'createVolume', 'docker', 'endpoint', 'endpointInit', 'endpoints', 'events', - 'images', 'image', + 'images', 'main', + 'network', + 'networks', + 'node', 'service', 'services', 'settings', 'sidebar', - 'createService', 'stats', 'swarm', - 'network', - 'networks', - 'node', - 'createNetwork', 'task', 'templates', - 'volumes', - 'createVolume']) + 'volumes']) .config(['$stateProvider', '$urlRouterProvider', '$httpProvider', 'localStorageServiceProvider', 'jwtOptionsProvider', function ($stateProvider, $urlRouterProvider, $httpProvider, localStorageServiceProvider, jwtOptionsProvider) { 'use strict'; @@ -62,124 +67,121 @@ angular.module('portainer', [ $urlRouterProvider.otherwise('/auth'); $stateProvider + .state('root', { + abstract: true, + resolve: { + requiresLogin: ['StateManager', function (StateManager) { + var applicationState = StateManager.getState(); + return applicationState.application.authentication; + }] + } + }) .state('auth', { - url: '/auth', + parent: 'root', + url: '/auth', params: { logout: false, error: '' }, views: { - "content": { + "content@": { templateUrl: 'app/components/auth/auth.html', controller: 'AuthenticationController' } + }, + data: { + requiresLogin: false } }) .state('containers', { + parent: 'root', url: '/containers/', views: { - "content": { + "content@": { templateUrl: 'app/components/containers/containers.html', controller: 'ContainersController' }, - "sidebar": { + "sidebar@": { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } - }, - data: { - requiresLogin: true } }) .state('container', { url: "^/containers/:id", views: { - "content": { + "content@": { templateUrl: 'app/components/container/container.html', controller: 'ContainerController' }, - "sidebar": { + "sidebar@": { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } - }, - data: { - requiresLogin: true } }) .state('stats', { url: "^/containers/:id/stats", views: { - "content": { + "content@": { templateUrl: 'app/components/stats/stats.html', controller: 'StatsController' }, - "sidebar": { + "sidebar@": { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } - }, - data: { - requiresLogin: true } }) .state('logs', { url: "^/containers/:id/logs", views: { - "content": { + "content@": { templateUrl: 'app/components/containerLogs/containerlogs.html', controller: 'ContainerLogsController' }, - "sidebar": { + "sidebar@": { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } - }, - data: { - requiresLogin: true } }) .state('console', { url: "^/containers/:id/console", views: { - "content": { + "content@": { templateUrl: 'app/components/containerConsole/containerConsole.html', controller: 'ContainerConsoleController' }, - "sidebar": { + "sidebar@": { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } - }, - data: { - requiresLogin: true } }) .state('dashboard', { + parent: 'root', url: '/dashboard', views: { - "content": { + "content@": { templateUrl: 'app/components/dashboard/dashboard.html', controller: 'DashboardController' }, - "sidebar": { + "sidebar@": { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } - }, - data: { - requiresLogin: true } }) .state('actions', { abstract: true, url: "/actions", views: { - "content": { - template: '
' + "content@": { + template: '
' }, - "sidebar": { - template: '
' + "sidebar@": { + template: '
' } } }) @@ -187,344 +189,281 @@ angular.module('portainer', [ abstract: true, url: "/create", views: { - "content": { - template: '
' + "content@": { + template: '
' }, - "sidebar": { - template: '
' + "sidebar@": { + template: '
' } } }) .state('actions.create.container', { url: "/container", views: { - "content": { + "content@": { templateUrl: 'app/components/createContainer/createcontainer.html', controller: 'CreateContainerController' }, - "sidebar": { + "sidebar@": { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } - }, - data: { - requiresLogin: true } }) .state('actions.create.network', { url: "/network", views: { - "content": { + "content@": { templateUrl: 'app/components/createNetwork/createnetwork.html', controller: 'CreateNetworkController' }, - "sidebar": { + "sidebar@": { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } - }, - data: { - requiresLogin: true } }) .state('actions.create.service', { url: "/service", views: { - "content": { + "content@": { templateUrl: 'app/components/createService/createservice.html', controller: 'CreateServiceController' }, - "sidebar": { + "sidebar@": { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } - }, - data: { - requiresLogin: true } }) .state('actions.create.volume', { url: "/volume", views: { - "content": { + "content@": { templateUrl: 'app/components/createVolume/createvolume.html', controller: 'CreateVolumeController' }, - "sidebar": { + "sidebar@": { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } - }, - data: { - requiresLogin: true } }) .state('docker', { url: '/docker/', views: { - "content": { + "content@": { templateUrl: 'app/components/docker/docker.html', controller: 'DockerController' }, - "sidebar": { + "sidebar@": { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } - }, - data: { - requiresLogin: true } }) .state('endpoints', { url: '/endpoints/', views: { - "content": { + "content@": { templateUrl: 'app/components/endpoints/endpoints.html', controller: 'EndpointsController' }, - "sidebar": { + "sidebar@": { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } - }, - data: { - requiresLogin: true } }) .state('endpoint', { url: '^/endpoints/:id', views: { - "content": { + "content@": { templateUrl: 'app/components/endpoint/endpoint.html', controller: 'EndpointController' }, - "sidebar": { + "sidebar@": { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } - }, - data: { - requiresLogin: true } }) .state('endpointInit', { url: '/init/endpoint', views: { - "content": { + "content@": { templateUrl: 'app/components/endpointInit/endpointInit.html', controller: 'EndpointInitController' } - }, - data: { - requiresLogin: true } }) .state('events', { url: '/events/', views: { - "content": { + "content@": { templateUrl: 'app/components/events/events.html', controller: 'EventsController' }, - "sidebar": { + "sidebar@": { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } - }, - data: { - requiresLogin: true } }) .state('images', { url: '/images/', views: { - "content": { + "content@": { templateUrl: 'app/components/images/images.html', controller: 'ImagesController' }, - "sidebar": { + "sidebar@": { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } - }, - data: { - requiresLogin: true } }) .state('image', { url: '^/images/:id/', views: { - "content": { + "content@": { templateUrl: 'app/components/image/image.html', controller: 'ImageController' }, - "sidebar": { + "sidebar@": { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } - }, - data: { - requiresLogin: true } }) .state('networks', { url: '/networks/', views: { - "content": { + "content@": { templateUrl: 'app/components/networks/networks.html', controller: 'NetworksController' }, - "sidebar": { + "sidebar@": { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } - }, - data: { - requiresLogin: true } }) .state('network', { url: '^/networks/:id/', views: { - "content": { + "content@": { templateUrl: 'app/components/network/network.html', controller: 'NetworkController' }, - "sidebar": { + "sidebar@": { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } - }, - data: { - requiresLogin: true } }) .state('node', { url: '^/nodes/:id/', views: { - "content": { + "content@": { templateUrl: 'app/components/node/node.html', controller: 'NodeController' }, - "sidebar": { + "sidebar@": { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } - }, - data: { - requiresLogin: true } }) .state('services', { url: '/services/', views: { - "content": { + "content@": { templateUrl: 'app/components/services/services.html', controller: 'ServicesController' }, - "sidebar": { + "sidebar@": { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } - }, - data: { - requiresLogin: true } }) .state('service', { url: '^/service/:id/', views: { - "content": { + "content@": { templateUrl: 'app/components/service/service.html', controller: 'ServiceController' }, - "sidebar": { + "sidebar@": { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } - }, - data: { - requiresLogin: true } }) .state('settings', { url: '/settings/', views: { - "content": { + "content@": { templateUrl: 'app/components/settings/settings.html', controller: 'SettingsController' }, - "sidebar": { + "sidebar@": { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } - }, - data: { - requiresLogin: true } }) .state('task', { url: '^/task/:id', views: { - "content": { + "content@": { templateUrl: 'app/components/task/task.html', controller: 'TaskController' }, - "sidebar": { + "sidebar@": { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } - }, - data: { - requiresLogin: true } }) .state('templates', { url: '/templates/', views: { - "content": { + "content@": { templateUrl: 'app/components/templates/templates.html', controller: 'TemplatesController' }, - "sidebar": { + "sidebar@": { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } - }, - data: { - requiresLogin: true } }) .state('volumes', { url: '/volumes/', views: { - "content": { + "content@": { templateUrl: 'app/components/volumes/volumes.html', controller: 'VolumesController' }, - "sidebar": { + "sidebar@": { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } - }, - data: { - requiresLogin: true } }) .state('swarm', { url: '/swarm/', views: { - "content": { + "content@": { templateUrl: 'app/components/swarm/swarm.html', controller: 'SwarmController' }, - "sidebar": { + "sidebar@": { templateUrl: 'app/components/sidebar/sidebar.html', controller: 'SidebarController' } - }, - data: { - requiresLogin: true } }); @@ -545,18 +484,21 @@ angular.module('portainer', [ }; }); }]) - .run(['$rootScope', '$state', 'Authentication', 'authManager', 'StateManager', function ($rootScope, $state, Authentication, authManager, StateManager) { - authManager.checkAuthOnRefresh(); - authManager.redirectWhenUnauthenticated(); - - Authentication.init(); - StateManager.init(); + .run(['$rootScope', '$state', 'Authentication', 'authManager', 'StateManager', 'Messages', function ($rootScope, $state, Authentication, authManager, StateManager, Messages) { + StateManager.initialize().then(function success(state) { + if (state.application.authentication) { + authManager.checkAuthOnRefresh(); + authManager.redirectWhenUnauthenticated(); + Authentication.init(); + $rootScope.$on('tokenHasExpired', function($state) { + $state.go('auth', {error: 'Your session has expired'}); + }); + } + }, function error(err) { + Messages.error("Failure", err, 'Unable to retrieve application settings'); + }); $rootScope.$state = $state; - - $rootScope.$on('tokenHasExpired', function($state) { - $state.go('auth', {error: 'Your session has expired'}); - }); }]) // 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 @@ -568,4 +510,4 @@ angular.module('portainer', [ .constant('ENDPOINTS_ENDPOINT', 'api/endpoints') .constant('TEMPLATES_ENDPOINT', 'api/templates') .constant('PAGINATION_MAX_ITEMS', 10) - .constant('UI_VERSION', 'v1.11.3'); + .constant('UI_VERSION', 'v1.11.4'); diff --git a/app/components/auth/authController.js b/app/components/auth/authController.js index 2e30f2e5d..b46e8537a 100644 --- a/app/components/auth/authController.js +++ b/app/components/auth/authController.js @@ -13,6 +13,23 @@ function ($scope, $state, $stateParams, $window, $timeout, $sanitize, Config, Au error: false }; + if (!$scope.applicationState.application.authentication) { + EndpointService.getActive().then(function success(data) { + StateManager.updateEndpointState(true) + .then(function success() { + $state.go('dashboard'); + }, function error(err) { + Messages.error("Failure", err, 'Unable to connect to the Docker endpoint'); + }); + }, function error(err) { + if (err.status === 404) { + $state.go('endpointInit'); + } else { + Messages.error("Failure", err, 'Unable to verify Docker endpoint existence'); + } + }); + } + if ($stateParams.logout) { Authentication.logout(); } diff --git a/app/components/endpoint/endpointController.js b/app/components/endpoint/endpointController.js index 0c254b3df..e6e4eb526 100644 --- a/app/components/endpoint/endpointController.js +++ b/app/components/endpoint/endpointController.js @@ -1,6 +1,11 @@ angular.module('endpoint', []) .controller('EndpointController', ['$scope', '$state', '$stateParams', '$filter', 'EndpointService', 'Messages', function ($scope, $state, $stateParams, $filter, EndpointService, Messages) { + + if (!$scope.applicationState.application.endpointManagement) { + $state.go('endpoints'); + } + $scope.state = { error: '', uploadInProgress: false diff --git a/app/components/endpoints/endpoints.html b/app/components/endpoints/endpoints.html index 7c6acea3e..41501eaa5 100644 --- a/app/components/endpoints/endpoints.html +++ b/app/components/endpoints/endpoints.html @@ -8,7 +8,19 @@ Endpoint management -
+
+
+ + + + + Portainer has been started using the --external-endpoints flag. Endpoint management via the UI is disabled. + + +
+
+ +
@@ -113,7 +125,7 @@
-
+
@@ -125,7 +137,7 @@ - + - + - + -
Name @@ -147,16 +159,16 @@
{{ endpoint.Name }} {{ endpoint.URL | stripprotocol }} + Edit diff --git a/app/components/sidebar/sidebar.html b/app/components/sidebar/sidebar.html index d8e97c81b..1d29b8f8d 100644 --- a/app/components/sidebar/sidebar.html +++ b/app/components/sidebar/sidebar.html @@ -47,7 +47,7 @@ Docker -