diff --git a/api/archive/tar.go b/api/archive/tar.go new file mode 100644 index 000000000..4040a9ec7 --- /dev/null +++ b/api/archive/tar.go @@ -0,0 +1,36 @@ +package archive + +import ( + "archive/tar" + "bytes" +) + +// TarFileInBuffer will create a tar archive containing a single file named via fileName and using the content +// specified in fileContent. Returns the archive as a byte array. +func TarFileInBuffer(fileContent []byte, fileName string) ([]byte, error) { + var buffer bytes.Buffer + tarWriter := tar.NewWriter(&buffer) + + header := &tar.Header{ + Name: fileName, + Mode: 0600, + Size: int64(len(fileContent)), + } + + err := tarWriter.WriteHeader(header) + if err != nil { + return nil, err + } + + _, err = tarWriter.Write(fileContent) + if err != nil { + return nil, err + } + + err = tarWriter.Close() + if err != nil { + return nil, err + } + + return buffer.Bytes(), nil +} diff --git a/api/bolt/migrate_dbversion7.go b/api/bolt/migrate_dbversion7.go new file mode 100644 index 000000000..bcdd199f2 --- /dev/null +++ b/api/bolt/migrate_dbversion7.go @@ -0,0 +1,20 @@ +package bolt + +import "github.com/portainer/portainer" + +func (m *Migrator) updateEndpointsToVersion8() error { + legacyEndpoints, err := m.EndpointService.Endpoints() + if err != nil { + return err + } + + for _, endpoint := range legacyEndpoints { + endpoint.Extensions = []portainer.EndpointExtension{} + err = m.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) + if err != nil { + return err + } + } + + return nil +} diff --git a/api/bolt/migrator.go b/api/bolt/migrator.go index f74a29b34..9b3aacdec 100644 --- a/api/bolt/migrator.go +++ b/api/bolt/migrator.go @@ -89,6 +89,13 @@ func (m *Migrator) Migrate() error { } } + if m.CurrentDBVersion < 8 { + err := m.updateEndpointsToVersion8() + if err != nil { + return err + } + } + err := m.VersionService.StoreDBVersion(portainer.DBVersion) if err != nil { return err diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 3c8c9f2aa..50b0591e4 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -218,6 +218,7 @@ func main() { }, AuthorizedUsers: []portainer.UserID{}, AuthorizedTeams: []portainer.TeamID{}, + Extensions: []portainer.EndpointExtension{}, } err = store.EndpointService.CreateEndpoint(endpoint) if err != nil { diff --git a/api/errors.go b/api/errors.go index ce2a9d10a..8e4c07d9e 100644 --- a/api/errors.go +++ b/api/errors.go @@ -57,6 +57,12 @@ const ( ErrComposeFileNotFoundInRepository = Error("Unable to find a Compose file in the repository") ) +// Endpoint extensions error +const ( + ErrEndpointExtensionNotSupported = Error("This extension is not supported") + ErrEndpointExtensionAlreadyAssociated = Error("This extension is already associated to the endpoint") +) + // Version errors. const ( ErrDBVersionNotFound = Error("DB version not found") diff --git a/api/exec/stack_manager.go b/api/exec/stack_manager.go index 9b709ea28..b7cf6df86 100644 --- a/api/exec/stack_manager.go +++ b/api/exec/stack_manager.go @@ -28,13 +28,13 @@ func (manager *StackManager) Login(dockerhub *portainer.DockerHub, registries [] for _, registry := range registries { if registry.Authentication { registryArgs := append(args, "login", "--username", registry.Username, "--password", registry.Password, registry.URL) - runCommandAndCaptureStdErr(command, registryArgs, nil) + runCommandAndCaptureStdErr(command, registryArgs, nil, "") } } if dockerhub.Authentication { dockerhubArgs := append(args, "login", "--username", dockerhub.Username, "--password", dockerhub.Password) - runCommandAndCaptureStdErr(command, dockerhubArgs, nil) + runCommandAndCaptureStdErr(command, dockerhubArgs, nil, "") } } @@ -42,7 +42,7 @@ func (manager *StackManager) Login(dockerhub *portainer.DockerHub, registries [] func (manager *StackManager) Logout(endpoint *portainer.Endpoint) error { command, args := prepareDockerCommandAndArgs(manager.binaryPath, endpoint) args = append(args, "logout") - return runCommandAndCaptureStdErr(command, args, nil) + return runCommandAndCaptureStdErr(command, args, nil, "") } // Deploy executes the docker stack deploy command. @@ -61,20 +61,21 @@ func (manager *StackManager) Deploy(stack *portainer.Stack, prune bool, endpoint env = append(env, envvar.Name+"="+envvar.Value) } - return runCommandAndCaptureStdErr(command, args, env) + return runCommandAndCaptureStdErr(command, args, env, stack.ProjectPath) } // Remove executes the docker stack rm command. func (manager *StackManager) Remove(stack *portainer.Stack, endpoint *portainer.Endpoint) error { command, args := prepareDockerCommandAndArgs(manager.binaryPath, endpoint) args = append(args, "stack", "rm", stack.Name) - return runCommandAndCaptureStdErr(command, args, nil) + return runCommandAndCaptureStdErr(command, args, nil, "") } -func runCommandAndCaptureStdErr(command string, args []string, env []string) error { +func runCommandAndCaptureStdErr(command string, args []string, env []string, workingDir string) error { var stderr bytes.Buffer cmd := exec.Command(command, args...) cmd.Stderr = &stderr + cmd.Dir = workingDir if env != nil { cmd.Env = os.Environ() diff --git a/api/http/handler/docker.go b/api/http/handler/docker.go index c823b3d20..5992d7065 100644 --- a/api/http/handler/docker.go +++ b/api/http/handler/docker.go @@ -35,24 +35,6 @@ func NewDockerHandler(bouncer *security.RequestBouncer) *DockerHandler { return h } -func (handler *DockerHandler) checkEndpointAccessControl(endpoint *portainer.Endpoint, userID portainer.UserID) bool { - for _, authorizedUserID := range endpoint.AuthorizedUsers { - if authorizedUserID == userID { - return true - } - } - - memberships, _ := handler.TeamMembershipService.TeamMembershipsByUserID(userID) - for _, authorizedTeamID := range endpoint.AuthorizedTeams { - for _, membership := range memberships { - if membership.TeamID == authorizedTeamID { - return true - } - } - } - return false -} - func (handler *DockerHandler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) id := vars["id"] @@ -75,7 +57,14 @@ func (handler *DockerHandler) proxyRequestsToDockerAPI(w http.ResponseWriter, r httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } - if tokenData.Role != portainer.AdministratorRole && !handler.checkEndpointAccessControl(endpoint, tokenData.ID) { + + memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(tokenData.ID) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if tokenData.Role != portainer.AdministratorRole && !security.AuthorizedEndpointAccess(endpoint, tokenData.ID, memberships) { httperror.WriteErrorResponse(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger) return } @@ -85,7 +74,7 @@ func (handler *DockerHandler) proxyRequestsToDockerAPI(w http.ResponseWriter, r if proxy == nil { proxy, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint) if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } } diff --git a/api/http/handler/endpoint.go b/api/http/handler/endpoint.go index 1b052847e..cd345c1d9 100644 --- a/api/http/handler/endpoint.go +++ b/api/http/handler/endpoint.go @@ -136,6 +136,7 @@ func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *ht }, AuthorizedUsers: []portainer.UserID{}, AuthorizedTeams: []portainer.TeamID{}, + Extensions: []portainer.EndpointExtension{}, } err = handler.EndpointService.CreateEndpoint(endpoint) @@ -372,6 +373,7 @@ func (handler *EndpointHandler) handleDeleteEndpoint(w http.ResponseWriter, r *h } handler.ProxyManager.DeleteProxy(string(endpointID)) + handler.ProxyManager.DeleteExtensionProxies(string(endpointID)) err = handler.EndpointService.DeleteEndpoint(portainer.EndpointID(endpointID)) if err != nil { diff --git a/api/http/handler/extensions.go b/api/http/handler/extensions.go new file mode 100644 index 000000000..f34aab9a7 --- /dev/null +++ b/api/http/handler/extensions.go @@ -0,0 +1,99 @@ +package handler + +import ( + "encoding/json" + "strconv" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/proxy" + "github.com/portainer/portainer/http/security" + + "log" + "net/http" + "os" + + "github.com/gorilla/mux" +) + +// ExtensionHandler represents an HTTP API handler for managing Settings. +type ExtensionHandler struct { + *mux.Router + Logger *log.Logger + EndpointService portainer.EndpointService + ProxyManager *proxy.Manager +} + +// NewExtensionHandler returns a new instance of ExtensionHandler. +func NewExtensionHandler(bouncer *security.RequestBouncer) *ExtensionHandler { + h := &ExtensionHandler{ + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + } + h.Handle("/{endpointId}/extensions", + bouncer.AdministratorAccess(http.HandlerFunc(h.handlePostExtensions))).Methods(http.MethodPost) + return h +} + +type ( + postExtensionRequest struct { + Type int `valid:"required"` + URL string `valid:"required"` + } +) + +func (handler *ExtensionHandler) handlePostExtensions(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id, err := strconv.Atoi(vars["endpointId"]) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + endpointID := portainer.EndpointID(id) + + endpoint, err := handler.EndpointService.Endpoint(endpointID) + if err == portainer.ErrEndpointNotFound { + httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + var req postExtensionRequest + if err = json.NewDecoder(r.Body).Decode(&req); err != nil { + httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger) + return + } + + _, err = govalidator.ValidateStruct(req) + if err != nil { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + + extensionType := portainer.EndpointExtensionType(req.Type) + + for _, extension := range endpoint.Extensions { + if extension.Type == extensionType { + httperror.WriteErrorResponse(w, portainer.ErrEndpointExtensionAlreadyAssociated, http.StatusConflict, handler.Logger) + return + } + } + + extension := portainer.EndpointExtension{ + Type: extensionType, + URL: req.URL, + } + + endpoint.Extensions = append(endpoint.Extensions, extension) + + err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + encodeJSON(w, extension, handler.Logger) +} diff --git a/api/http/handler/extensions/storidge.go b/api/http/handler/extensions/storidge.go new file mode 100644 index 000000000..1977705e5 --- /dev/null +++ b/api/http/handler/extensions/storidge.go @@ -0,0 +1,97 @@ +package extensions + +import ( + "strconv" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/proxy" + "github.com/portainer/portainer/http/security" + + "log" + "net/http" + "os" + + "github.com/gorilla/mux" +) + +// StoridgeHandler represents an HTTP API handler for proxying requests to the Docker API. +type StoridgeHandler struct { + *mux.Router + Logger *log.Logger + EndpointService portainer.EndpointService + TeamMembershipService portainer.TeamMembershipService + ProxyManager *proxy.Manager +} + +// NewStoridgeHandler returns a new instance of StoridgeHandler. +func NewStoridgeHandler(bouncer *security.RequestBouncer) *StoridgeHandler { + h := &StoridgeHandler{ + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + } + h.PathPrefix("/{id}/extensions/storidge").Handler( + bouncer.AuthenticatedAccess(http.HandlerFunc(h.proxyRequestsToStoridgeAPI))) + return h +} + +func (handler *StoridgeHandler) proxyRequestsToStoridgeAPI(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + parsedID, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + endpointID := portainer.EndpointID(parsedID) + endpoint, err := handler.EndpointService.Endpoint(endpointID) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(tokenData.ID) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if tokenData.Role != portainer.AdministratorRole && !security.AuthorizedEndpointAccess(endpoint, tokenData.ID, memberships) { + httperror.WriteErrorResponse(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger) + return + } + + var storidgeExtension *portainer.EndpointExtension + for _, extension := range endpoint.Extensions { + if extension.Type == portainer.StoridgeEndpointExtension { + storidgeExtension = &extension + } + } + + if storidgeExtension == nil { + httperror.WriteErrorResponse(w, portainer.ErrEndpointExtensionNotSupported, http.StatusInternalServerError, handler.Logger) + return + } + + proxyExtensionKey := string(endpoint.ID) + "_" + string(portainer.StoridgeEndpointExtension) + + var proxy http.Handler + proxy = handler.ProxyManager.GetExtensionProxy(proxyExtensionKey) + if proxy == nil { + proxy, err = handler.ProxyManager.CreateAndRegisterExtensionProxy(proxyExtensionKey, storidgeExtension.URL) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + } + + http.StripPrefix("/"+id+"/extensions/storidge", proxy).ServeHTTP(w, r) +} diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index 24367f360..ac0cc7b9f 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -8,6 +8,7 @@ import ( "github.com/portainer/portainer" httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/handler/extensions" ) // Handler is a collection of all the service handlers. @@ -19,6 +20,8 @@ type Handler struct { EndpointHandler *EndpointHandler RegistryHandler *RegistryHandler DockerHubHandler *DockerHubHandler + ExtensionHandler *ExtensionHandler + StoridgeHandler *extensions.StoridgeHandler ResourceHandler *ResourceHandler StackHandler *StackHandler StatusHandler *StatusHandler @@ -48,11 +51,16 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { case strings.HasPrefix(r.URL.Path, "/api/dockerhub"): http.StripPrefix("/api", h.DockerHubHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/endpoints"): - if strings.Contains(r.URL.Path, "/docker/") { + switch { + case strings.Contains(r.URL.Path, "/docker"): http.StripPrefix("/api/endpoints", h.DockerHandler).ServeHTTP(w, r) - } else if strings.Contains(r.URL.Path, "/stacks") { + case strings.Contains(r.URL.Path, "/stacks"): http.StripPrefix("/api/endpoints", h.StackHandler).ServeHTTP(w, r) - } else { + case strings.Contains(r.URL.Path, "/extensions/storidge"): + http.StripPrefix("/api/endpoints", h.StoridgeHandler).ServeHTTP(w, r) + case strings.Contains(r.URL.Path, "/extensions"): + http.StripPrefix("/api/endpoints", h.ExtensionHandler).ServeHTTP(w, r) + default: http.StripPrefix("/api", h.EndpointHandler).ServeHTTP(w, r) } case strings.HasPrefix(r.URL.Path, "/api/registries"): diff --git a/api/http/proxy/build.go b/api/http/proxy/build.go new file mode 100644 index 000000000..0deab93b9 --- /dev/null +++ b/api/http/proxy/build.go @@ -0,0 +1,56 @@ +package proxy + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "net/http" + "strings" + + "github.com/portainer/portainer/archive" +) + +type postDockerfileRequest struct { + Content string +} + +// buildOperation inspects the "Content-Type" header to determine if it needs to alter the request. +// If the value of the header is empty, it means that a Dockerfile is posted via upload, the function +// will extract the file content from the request body, tar it, and rewrite the body. +// If the value of the header contains "application/json", it means that the content of a Dockerfile is posted +// in the request payload as JSON, the function will create a new file called Dockerfile inside a tar archive and +// rewrite the body of the request. +// In any other case, it will leave the request unaltered. +func buildOperation(request *http.Request) error { + contentTypeHeader := request.Header.Get("Content-Type") + if contentTypeHeader != "" && !strings.Contains(contentTypeHeader, "application/json") { + return nil + } + + var dockerfileContent []byte + + if contentTypeHeader == "" { + body, err := ioutil.ReadAll(request.Body) + if err != nil { + return err + } + dockerfileContent = body + } else { + var req postDockerfileRequest + if err := json.NewDecoder(request.Body).Decode(&req); err != nil { + return err + } + dockerfileContent = []byte(req.Content) + } + + buffer, err := archive.TarFileInBuffer(dockerfileContent, "Dockerfile") + if err != nil { + return err + } + + request.Body = ioutil.NopCloser(bytes.NewReader(buffer)) + request.ContentLength = int64(len(buffer)) + request.Header.Set("Content-Type", "application/x-tar") + + return nil +} diff --git a/api/http/proxy/factory.go b/api/http/proxy/factory.go index c0a69109f..1602f9d8d 100644 --- a/api/http/proxy/factory.go +++ b/api/http/proxy/factory.go @@ -17,14 +17,14 @@ type proxyFactory struct { SettingsService portainer.SettingsService } -func (factory *proxyFactory) newHTTPProxy(u *url.URL) http.Handler { +func (factory *proxyFactory) newExtensionHTTPPRoxy(u *url.URL) http.Handler { u.Scheme = "http" - return factory.createReverseProxy(u) + return newSingleHostReverseProxyWithHostHeader(u) } -func (factory *proxyFactory) newHTTPSProxy(u *url.URL, endpoint *portainer.Endpoint) (http.Handler, error) { +func (factory *proxyFactory) newDockerHTTPSProxy(u *url.URL, endpoint *portainer.Endpoint) (http.Handler, error) { u.Scheme = "https" - proxy := factory.createReverseProxy(u) + proxy := factory.createDockerReverseProxy(u) config, err := crypto.CreateTLSConfiguration(&endpoint.TLSConfig) if err != nil { return nil, err @@ -34,7 +34,12 @@ func (factory *proxyFactory) newHTTPSProxy(u *url.URL, endpoint *portainer.Endpo return proxy, nil } -func (factory *proxyFactory) newSocketProxy(path string) http.Handler { +func (factory *proxyFactory) newDockerHTTPProxy(u *url.URL) http.Handler { + u.Scheme = "http" + return factory.createDockerReverseProxy(u) +} + +func (factory *proxyFactory) newDockerSocketProxy(path string) http.Handler { proxy := &socketProxy{} transport := &proxyTransport{ ResourceControlService: factory.ResourceControlService, @@ -46,13 +51,13 @@ func (factory *proxyFactory) newSocketProxy(path string) http.Handler { return proxy } -func (factory *proxyFactory) createReverseProxy(u *url.URL) *httputil.ReverseProxy { +func (factory *proxyFactory) createDockerReverseProxy(u *url.URL) *httputil.ReverseProxy { proxy := newSingleHostReverseProxyWithHostHeader(u) transport := &proxyTransport{ ResourceControlService: factory.ResourceControlService, TeamMembershipService: factory.TeamMembershipService, SettingsService: factory.SettingsService, - dockerTransport: newHTTPTransport(), + dockerTransport: &http.Transport{}, } proxy.Transport = transport return proxy @@ -65,7 +70,3 @@ func newSocketTransport(socketPath string) *http.Transport { }, } } - -func newHTTPTransport() *http.Transport { - return &http.Transport{} -} diff --git a/api/http/proxy/manager.go b/api/http/proxy/manager.go index bdba2b216..a5b57a535 100644 --- a/api/http/proxy/manager.go +++ b/api/http/proxy/manager.go @@ -3,6 +3,7 @@ package proxy import ( "net/http" "net/url" + "strings" "github.com/orcaman/concurrent-map" "github.com/portainer/portainer" @@ -10,14 +11,16 @@ import ( // Manager represents a service used to manage Docker proxies. type Manager struct { - proxyFactory *proxyFactory - proxies cmap.ConcurrentMap + proxyFactory *proxyFactory + proxies cmap.ConcurrentMap + extensionProxies cmap.ConcurrentMap } // NewManager initializes a new proxy Service func NewManager(resourceControlService portainer.ResourceControlService, teamMembershipService portainer.TeamMembershipService, settingsService portainer.SettingsService) *Manager { return &Manager{ - proxies: cmap.New(), + proxies: cmap.New(), + extensionProxies: cmap.New(), proxyFactory: &proxyFactory{ ResourceControlService: resourceControlService, TeamMembershipService: teamMembershipService, @@ -38,16 +41,16 @@ func (manager *Manager) CreateAndRegisterProxy(endpoint *portainer.Endpoint) (ht if endpointURL.Scheme == "tcp" { if endpoint.TLSConfig.TLS { - proxy, err = manager.proxyFactory.newHTTPSProxy(endpointURL, endpoint) + proxy, err = manager.proxyFactory.newDockerHTTPSProxy(endpointURL, endpoint) if err != nil { return nil, err } } else { - proxy = manager.proxyFactory.newHTTPProxy(endpointURL) + proxy = manager.proxyFactory.newDockerHTTPProxy(endpointURL) } } else { // Assume unix:// scheme - proxy = manager.proxyFactory.newSocketProxy(endpointURL.Path) + proxy = manager.proxyFactory.newDockerSocketProxy(endpointURL.Path) } manager.proxies.Set(string(endpoint.ID), proxy) @@ -67,3 +70,34 @@ func (manager *Manager) GetProxy(key string) http.Handler { func (manager *Manager) DeleteProxy(key string) { manager.proxies.Remove(key) } + +// CreateAndRegisterExtensionProxy creates a new HTTP reverse proxy for an extension and adds it to the registered proxies. +func (manager *Manager) CreateAndRegisterExtensionProxy(key, extensionAPIURL string) (http.Handler, error) { + + extensionURL, err := url.Parse(extensionAPIURL) + if err != nil { + return nil, err + } + + proxy := manager.proxyFactory.newExtensionHTTPPRoxy(extensionURL) + manager.extensionProxies.Set(key, proxy) + return proxy, nil +} + +// GetExtensionProxy returns the extension proxy associated to a key +func (manager *Manager) GetExtensionProxy(key string) http.Handler { + proxy, ok := manager.extensionProxies.Get(key) + if !ok { + return nil + } + return proxy.(http.Handler) +} + +// DeleteExtensionProxies deletes all the extension proxies associated to a key +func (manager *Manager) DeleteExtensionProxies(key string) { + for _, k := range manager.extensionProxies.Keys() { + if strings.Contains(k, key+"_") { + manager.extensionProxies.Remove(k) + } + } +} diff --git a/api/http/proxy/transport.go b/api/http/proxy/transport.go index 83edcaf37..3b0b1fa3c 100644 --- a/api/http/proxy/transport.go +++ b/api/http/proxy/transport.go @@ -27,6 +27,7 @@ type ( labelBlackList []portainer.Pair } restrictedOperationRequest func(*http.Request, *http.Response, *operationExecutor) error + operationRequest func(*http.Request) error ) func (p *proxyTransport) RoundTrip(request *http.Request) (*http.Response, error) { @@ -59,6 +60,8 @@ func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Respon return p.proxyNodeRequest(request) case strings.HasPrefix(path, "/tasks"): return p.proxyTaskRequest(request) + case strings.HasPrefix(path, "/build"): + return p.proxyBuildRequest(request) default: return p.executeDockerRequest(request) } @@ -228,6 +231,10 @@ func (p *proxyTransport) proxyTaskRequest(request *http.Request) (*http.Response } } +func (p *proxyTransport) proxyBuildRequest(request *http.Request) (*http.Response, error) { + return p.interceptAndRewriteRequest(request, buildOperation) +} + // restrictedOperation ensures that the current user has the required authorizations // before executing the original request. func (p *proxyTransport) restrictedOperation(request *http.Request, resourceID string) (*http.Response, error) { @@ -300,6 +307,15 @@ func (p *proxyTransport) rewriteOperation(request *http.Request, operation restr return p.executeRequestAndRewriteResponse(request, operation, executor) } +func (p *proxyTransport) interceptAndRewriteRequest(request *http.Request, operation operationRequest) (*http.Response, error) { + err := operation(request) + if err != nil { + return nil, err + } + + return p.executeDockerRequest(request) +} + func (p *proxyTransport) executeRequestAndRewriteResponse(request *http.Request, operation restrictedOperationRequest, executor *operationExecutor) (*http.Response, error) { response, err := p.executeDockerRequest(request) if err != nil { diff --git a/api/http/security/authorization.go b/api/http/security/authorization.go index 30d6dfb72..976c2947f 100644 --- a/api/http/security/authorization.go +++ b/api/http/security/authorization.go @@ -121,3 +121,22 @@ func AuthorizedUserManagement(userID portainer.UserID, context *RestrictedReques } return false } + +// AuthorizedEndpointAccess ensure that the user can access the specified endpoint. +// It will check if the user is part of the authorized users or part of a team that is +// listed in the authorized teams. +func AuthorizedEndpointAccess(endpoint *portainer.Endpoint, userID portainer.UserID, memberships []portainer.TeamMembership) bool { + for _, authorizedUserID := range endpoint.AuthorizedUsers { + if authorizedUserID == userID { + return true + } + } + for _, membership := range memberships { + for _, authorizedTeamID := range endpoint.AuthorizedTeams { + if membership.TeamID == authorizedTeamID { + return true + } + } + } + return false +} diff --git a/api/http/security/bouncer.go b/api/http/security/bouncer.go index e6a8fc962..76b47aaea 100644 --- a/api/http/security/bouncer.go +++ b/api/http/security/bouncer.go @@ -12,6 +12,7 @@ type ( // RequestBouncer represents an entity that manages API request accesses RequestBouncer struct { jwtService portainer.JWTService + userService portainer.UserService teamMembershipService portainer.TeamMembershipService authDisabled bool } @@ -27,9 +28,10 @@ type ( ) // NewRequestBouncer initializes a new RequestBouncer -func NewRequestBouncer(jwtService portainer.JWTService, teamMembershipService portainer.TeamMembershipService, authDisabled bool) *RequestBouncer { +func NewRequestBouncer(jwtService portainer.JWTService, userService portainer.UserService, teamMembershipService portainer.TeamMembershipService, authDisabled bool) *RequestBouncer { return &RequestBouncer{ jwtService: jwtService, + userService: userService, teamMembershipService: teamMembershipService, authDisabled: authDisabled, } @@ -136,6 +138,15 @@ func (bouncer *RequestBouncer) mwCheckAuthentication(next http.Handler) http.Han httperror.WriteErrorResponse(w, err, http.StatusUnauthorized, nil) return } + + _, err = bouncer.userService.User(tokenData.ID) + if err != nil && err == portainer.ErrUserNotFound { + httperror.WriteErrorResponse(w, portainer.ErrUnauthorized, http.StatusUnauthorized, nil) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, nil) + return + } } else { tokenData = &portainer.TokenData{ Role: portainer.AdministratorRole, diff --git a/api/http/server.go b/api/http/server.go index d0402bed4..fc5f08972 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -3,6 +3,7 @@ package http import ( "github.com/portainer/portainer" "github.com/portainer/portainer/http/handler" + "github.com/portainer/portainer/http/handler/extensions" "github.com/portainer/portainer/http/proxy" "github.com/portainer/portainer/http/security" @@ -40,7 +41,7 @@ type Server struct { // Start starts the HTTP server func (server *Server) Start() error { - requestBouncer := security.NewRequestBouncer(server.JWTService, server.TeamMembershipService, server.AuthDisabled) + requestBouncer := security.NewRequestBouncer(server.JWTService, server.UserService, server.TeamMembershipService, server.AuthDisabled) proxyManager := proxy.NewManager(server.ResourceControlService, server.TeamMembershipService, server.SettingsService) var fileHandler = handler.NewFileHandler(filepath.Join(server.AssetsPath, "public")) @@ -96,6 +97,13 @@ func (server *Server) Start() error { stackHandler.GitService = server.GitService stackHandler.RegistryService = server.RegistryService stackHandler.DockerHubService = server.DockerHubService + var extensionHandler = handler.NewExtensionHandler(requestBouncer) + extensionHandler.EndpointService = server.EndpointService + extensionHandler.ProxyManager = proxyManager + var storidgeHandler = extensions.NewStoridgeHandler(requestBouncer) + storidgeHandler.EndpointService = server.EndpointService + storidgeHandler.TeamMembershipService = server.TeamMembershipService + storidgeHandler.ProxyManager = proxyManager server.Handler = &handler.Handler{ AuthHandler: authHandler, @@ -114,6 +122,8 @@ func (server *Server) Start() error { WebSocketHandler: websocketHandler, FileHandler: fileHandler, UploadHandler: uploadHandler, + ExtensionHandler: extensionHandler, + StoridgeHandler: storidgeHandler, } if server.SSL { diff --git a/api/portainer.go b/api/portainer.go index 6cfa73bb9..291f75059 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -171,13 +171,14 @@ type ( // Endpoint represents a Docker endpoint with all the info required // to connect to it. Endpoint struct { - ID EndpointID `json:"Id"` - Name string `json:"Name"` - URL string `json:"URL"` - PublicURL string `json:"PublicURL"` - TLSConfig TLSConfiguration `json:"TLSConfig"` - AuthorizedUsers []UserID `json:"AuthorizedUsers"` - AuthorizedTeams []TeamID `json:"AuthorizedTeams"` + ID EndpointID `json:"Id"` + Name string `json:"Name"` + URL string `json:"URL"` + PublicURL string `json:"PublicURL"` + TLSConfig TLSConfiguration `json:"TLSConfig"` + AuthorizedUsers []UserID `json:"AuthorizedUsers"` + AuthorizedTeams []TeamID `json:"AuthorizedTeams"` + Extensions []EndpointExtension `json:"Extensions"` // Deprecated fields // Deprecated in DBVersion == 4 @@ -187,6 +188,16 @@ type ( TLSKeyPath string `json:"TLSKey,omitempty"` } + // EndpointExtension represents a extension associated to an endpoint. + EndpointExtension struct { + Type EndpointExtensionType `json:"Type"` + URL string `json:"URL"` + } + + // EndpointExtensionType represents the type of an endpoint extension. Only + // one extension of each type can be associated to an endpoint. + EndpointExtensionType int + // ResourceControlID represents a resource control identifier. ResourceControlID int @@ -389,9 +400,9 @@ type ( const ( // APIVersion is the version number of the Portainer API. - APIVersion = "1.16.2" + APIVersion = "1.16.3" // DBVersion is the version number of the Portainer database. - DBVersion = 7 + DBVersion = 8 // DefaultTemplatesURL represents the default URL for the templates definitions. DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates.json" ) @@ -452,3 +463,9 @@ const ( // ConfigResourceControl represents a resource control associated to a Docker config ConfigResourceControl ) + +const ( + _ EndpointExtensionType = iota + // StoridgeEndpointExtension represents the Storidge extension + StoridgeEndpointExtension +) diff --git a/api/swagger.yaml b/api/swagger.yaml index ab7cf1c7d..bec349046 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -56,7 +56,7 @@ info: **NOTE**: You can find more information on how to query the Docker API in the [Docker official documentation](https://docs.docker.com/engine/api/v1.30/) as well as in [this Portainer example](https://gist.github.com/deviantony/77026d402366b4b43fa5918d41bc42f8). - version: "1.16.2" + version: "1.16.3" title: "Portainer API" contact: email: "info@portainer.io" @@ -2143,7 +2143,7 @@ definitions: description: "Is analytics enabled" Version: type: "string" - example: "1.16.2" + example: "1.16.3" description: "Portainer API version" PublicSettingsInspectResponse: type: "object" diff --git a/app/__module.js b/app/__module.js index 15371bdf6..98591b7e6 100644 --- a/app/__module.js +++ b/app/__module.js @@ -13,6 +13,8 @@ angular.module('portainer', [ 'angular-google-analytics', 'angular-json-tree', 'angular-loading-bar', + 'angular-clipboard', + 'luegg.directives', 'portainer.templates', 'portainer.app', 'portainer.docker', diff --git a/app/docker/__module.js b/app/docker/__module.js index 1e15df46e..0ede8298d 100644 --- a/app/docker/__module.js +++ b/app/docker/__module.js @@ -179,6 +179,17 @@ angular.module('portainer.docker', ['portainer.app']) } }; + var imageBuild = { + name: 'docker.images.build', + url: '/build', + views: { + 'content@': { + templateUrl: 'app/docker/views/images/build/buildimage.html', + controller: 'BuildImageController' + } + } + }; + var networks = { name: 'docker.networks', url: '/networks', @@ -378,6 +389,17 @@ angular.module('portainer.docker', ['portainer.app']) } }; + var taskLogs = { + name: 'docker.tasks.task.logs', + url: '/logs', + views: { + 'content@': { + templateUrl: 'app/docker/views/tasks/logs/tasklogs.html', + controller: 'TaskLogsController' + } + } + }; + var templates = { name: 'docker.templates', url: '/templates', @@ -457,6 +479,7 @@ angular.module('portainer.docker', ['portainer.app']) $stateRegistryProvider.register(events); $stateRegistryProvider.register(images); $stateRegistryProvider.register(image); + $stateRegistryProvider.register(imageBuild); $stateRegistryProvider.register(networks); $stateRegistryProvider.register(network); $stateRegistryProvider.register(networkCreation); @@ -476,6 +499,7 @@ angular.module('portainer.docker', ['portainer.app']) $stateRegistryProvider.register(swarmVisualizer); $stateRegistryProvider.register(tasks); $stateRegistryProvider.register(task); + $stateRegistryProvider.register(taskLogs); $stateRegistryProvider.register(templates); $stateRegistryProvider.register(templatesLinuxServer); $stateRegistryProvider.register(volumes); diff --git a/app/docker/components/datatables/containers-datatable/containersDatatable.html b/app/docker/components/datatables/containers-datatable/containersDatatable.html index 3662455d8..f9e82b812 100644 --- a/app/docker/components/datatables/containers-datatable/containersDatatable.html +++ b/app/docker/components/datatables/containers-datatable/containersDatatable.html @@ -195,10 +195,10 @@
- - - - + + + +
{{ item.StackName ? item.StackName : '-' }} diff --git a/app/docker/components/datatables/images-datatable/imagesDatatable.html b/app/docker/components/datatables/images-datatable/imagesDatatable.html index a22ffc57c..89d916dab 100644 --- a/app/docker/components/datatables/images-datatable/imagesDatatable.html +++ b/app/docker/components/datatables/images-datatable/imagesDatatable.html @@ -25,6 +25,9 @@
  • Force Remove
  • + diff --git a/app/docker/views/services/edit/service.html b/app/docker/views/services/edit/service.html index 059da3dca..c2d86925c 100644 --- a/app/docker/views/services/edit/service.html +++ b/app/docker/views/services/edit/service.html @@ -73,7 +73,7 @@ - Service logs + Service logs diff --git a/app/portainer/views/registries/edit/registryController.js b/app/portainer/views/registries/edit/registryController.js index dffb77b70..7cee10785 100644 --- a/app/portainer/views/registries/edit/registryController.js +++ b/app/portainer/views/registries/edit/registryController.js @@ -6,8 +6,13 @@ function ($scope, $state, $transition$, $filter, RegistryService, Notifications) actionInProgress: false }; + $scope.formValues = { + Password: '' + }; + $scope.updateRegistry = function() { var registry = $scope.registry; + registry.Password = $scope.formValues.Password; $scope.state.actionInProgress = true; RegistryService.updateRegistry(registry) .then(function success(data) { diff --git a/app/portainer/views/registries/registries.html b/app/portainer/views/registries/registries.html index 7d5096897..e432c300b 100644 --- a/app/portainer/views/registries/registries.html +++ b/app/portainer/views/registries/registries.html @@ -48,7 +48,7 @@
    - +
    @@ -56,7 +56,7 @@
    - diff --git a/app/portainer/views/registries/registriesController.js b/app/portainer/views/registries/registriesController.js index e6e32c790..9c92354e5 100644 --- a/app/portainer/views/registries/registriesController.js +++ b/app/portainer/views/registries/registriesController.js @@ -6,8 +6,13 @@ function ($q, $scope, $state, RegistryService, DockerHubService, ModalService, N actionInProgress: false }; + $scope.formValues = { + dockerHubPassword: '' + }; + $scope.updateDockerHub = function() { var dockerhub = $scope.dockerhub; + dockerhub.Password = $scope.formValues.dockerHubPassword; $scope.state.actionInProgress = true; DockerHubService.update(dockerhub) .then(function success(data) { diff --git a/app/portainer/views/sidebar/sidebar.html b/app/portainer/views/sidebar/sidebar.html index f1e3dfa6a..a50f6516e 100644 --- a/app/portainer/views/sidebar/sidebar.html +++ b/app/portainer/views/sidebar/sidebar.html @@ -24,7 +24,7 @@ sidebar-toggled-on="toggle" current-state="$state.current.name" > -