From e3d564325b669461153d23a76e9b2ffcd832ab2c Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Mon, 11 Jun 2018 15:13:19 +0200 Subject: [PATCH] feat(stacks): support compose v2.0 stack (#1963) --- api/bolt/stack_service.go | 56 +- api/cmd/portainer/main.go | 16 +- api/errors.go | 1 + api/exec/{stack_manager.go => swarm_stack.go} | 22 +- api/filesystem/filesystem.go | 38 +- api/http/error/error.go | 42 +- api/http/handler/auth.go | 126 --- api/http/handler/auth/authenticate.go | 79 ++ api/http/handler/auth/handler.go | 41 + api/http/handler/azure.go | 102 -- api/http/handler/docker.go | 92 -- api/http/handler/dockerhub.go | 91 -- .../handler/dockerhub/dockerhub_inspect.go | 19 + .../handler/dockerhub/dockerhub_update.go | 52 + api/http/handler/dockerhub/handler.go | 33 + api/http/handler/endpoint.go | 625 ------------ api/http/handler/endpoint_group.go | 364 ------- .../endpointgroups/endpointgroup_create.go | 63 ++ .../endpointgroups/endpointgroup_delete.go | 51 + .../endpointgroups/endpointgroup_inspect.go | 27 + .../endpointgroups/endpointgroup_list.go | 25 + .../endpointgroups/endpointgroup_update.go | 71 ++ .../endpointgroup_update_access.go | 63 ++ api/http/handler/endpointgroups/handler.go | 69 ++ api/http/handler/endpointproxy/handler.go | 50 + api/http/handler/endpointproxy/proxy_azure.go | 53 + .../handler/endpointproxy/proxy_docker.go | 53 + .../handler/endpointproxy/proxy_storidge.go | 66 ++ api/http/handler/endpoints/endpoint_create.go | 295 ++++++ api/http/handler/endpoints/endpoint_delete.go | 48 + .../endpoints/endpoint_extension_add.go | 73 ++ .../endpoints/endpoint_extension_remove.go | 43 + .../handler/endpoints/endpoint_inspect.go | 27 + api/http/handler/endpoints/endpoint_list.go | 34 + api/http/handler/endpoints/endpoint_update.go | 137 +++ .../endpoints/endpoint_update_access.go | 67 ++ api/http/handler/endpoints/handler.go | 59 ++ api/http/handler/extensions.go | 143 --- api/http/handler/extensions/storidge.go | 106 -- api/http/handler/{file.go => file/handler.go} | 19 +- api/http/handler/handler.go | 113 +-- api/http/handler/registries/handler.go | 43 + .../handler/registries/registry_create.go | 68 ++ .../handler/registries/registry_delete.go | 32 + .../handler/registries/registry_inspect.go | 28 + api/http/handler/registries/registry_list.go | 29 + .../handler/registries/registry_update.go | 82 ++ .../registries/registry_update_access.go | 63 ++ api/http/handler/registry.go | 320 ------ api/http/handler/resource_control.go | 266 ----- api/http/handler/resourcecontrols/handler.go | 31 + .../resourcecontrol_create.go | 116 +++ .../resourcecontrol_delete.go | 42 + .../resourcecontrol_update.go | 83 ++ api/http/handler/settings.go | 177 ---- api/http/handler/settings/handler.go | 35 + api/http/handler/settings/settings_inspect.go | 18 + .../handler/settings/settings_ldap_check.go | 40 + api/http/handler/settings/settings_public.go | 35 + api/http/handler/settings/settings_update.go | 85 ++ api/http/handler/stack.go | 794 --------------- .../handler/stacks/create_compose_stack.go | 302 ++++++ api/http/handler/stacks/create_swarm_stack.go | 334 +++++++ api/http/handler/stacks/git.go | 16 + api/http/handler/stacks/handler.go | 69 ++ api/http/handler/stacks/stack_create.go | 99 ++ api/http/handler/stacks/stack_delete.go | 127 +++ api/http/handler/stacks/stack_file.go | 58 ++ api/http/handler/stacks/stack_inspect.go | 48 + api/http/handler/stacks/stack_list.go | 65 ++ api/http/handler/stacks/stack_update.go | 146 +++ api/http/handler/status.go | 38 - api/http/handler/status/handler.go | 28 + api/http/handler/status/status_inspect.go | 13 + api/http/handler/team.go | 262 ----- api/http/handler/team_membership.go | 242 ----- api/http/handler/teammemberships/handler.go | 35 + .../teammemberships/teammembership_create.go | 74 ++ .../teammemberships/teammembership_delete.go | 42 + .../teammemberships/teammembership_list.go | 29 + .../teammemberships/teammembership_update.go | 75 ++ api/http/handler/teams/handler.go | 39 + api/http/handler/teams/team_create.go | 49 + api/http/handler/teams/team_delete.go | 37 + api/http/handler/teams/team_inspect.go | 37 + api/http/handler/teams/team_list.go | 26 + api/http/handler/teams/team_memberships.go | 35 + api/http/handler/teams/team_update.go | 50 + api/http/handler/templates.go | 74 -- api/http/handler/templates/handler.go | 30 + api/http/handler/templates/template_list.go | 50 + api/http/handler/upload.go | 69 -- api/http/handler/upload/handler.go | 27 + api/http/handler/upload/upload_tls.go | 47 + api/http/handler/user.go | 463 --------- api/http/handler/users/admin_check.go | 23 + api/http/handler/users/admin_init.go | 61 ++ api/http/handler/users/handler.go | 53 + api/http/handler/users/user_create.go | 84 ++ api/http/handler/users/user_delete.go | 47 + api/http/handler/users/user_inspect.go | 28 + api/http/handler/users/user_list.go | 29 + api/http/handler/users/user_memberships.go | 35 + api/http/handler/users/user_password.go | 57 ++ api/http/handler/users/user_update.go | 75 ++ api/http/handler/websocket/handler.go | 28 + .../websocket_exec.go} | 82 +- api/http/proxy/access_control.go | 2 +- api/http/proxy/containers.go | 28 +- api/http/proxy/docker_transport.go | 2 + api/http/proxy/manager.go | 1 - api/http/proxy/socket.go | 5 +- api/http/request/request.go | 160 +++ api/http/response/response.go | 32 + api/http/security/bouncer.go | 14 +- api/http/security/filter.go | 13 +- api/http/security/rate_limiter.go | 2 +- api/http/server.go | 181 ++-- api/libcompose/compose_stack.go | 91 ++ api/portainer.go | 42 +- api/swagger.yaml | 936 ++++++++++++++---- app/constants.js | 1 + app/docker/__module.js | 39 - .../actions/containersDatatableActions.html | 35 + .../actions/containersDatatableActions.js | 12 + .../containersDatatableActionsController.js | 104 ++ .../containersDatatable.html | 45 +- .../containersDatatable.js | 10 +- .../containersDatatableController.js | 45 +- .../serviceTasksDatatable.html | 82 ++ .../serviceTasksDatatable.js | 15 + .../serviceTasksDatatableController.js | 70 ++ .../actions/servicesDatatableActions.html | 15 + .../actions/servicesDatatableActions.js | 10 + .../servicesDatatableActionsController.js | 81 ++ .../services-datatable/servicesDatatable.html | 75 +- .../services-datatable/servicesDatatable.js | 13 +- .../servicesDatatableController.js | 125 +++ .../tasks-datatable/tasksDatatable.html | 9 +- .../dockerSidebarContent.html | 4 +- app/docker/filters/filters.js | 32 +- app/docker/models/container.js | 23 +- app/docker/services/networkService.js | 4 +- app/docker/services/stackService.js | 178 ---- app/docker/views/containers/containers.html | 10 +- .../views/containers/containersController.js | 130 +-- app/docker/views/dashboard/dashboard.html | 7 +- .../views/dashboard/dashboardController.js | 16 +- .../views/networks/networksController.js | 2 +- .../create/createServiceController.js | 2 +- app/docker/views/services/services.html | 13 +- .../views/services/servicesController.js | 127 +-- app/docker/views/stacks/edit/stack.html | 133 --- .../views/stacks/edit/stackController.js | 111 --- app/docker/views/stacks/stacks.html | 51 - app/docker/views/tasks/edit/task.html | 2 +- .../views/templates/templatesController.js | 9 +- app/portainer/__module.js | 36 + .../stacks-datatable/stacksDatatable.html | 28 +- .../stacks-datatable/stacksDatatable.js | 3 +- .../stacksDatatableController.js | 2 +- app/portainer/helpers/stackHelper.js | 15 +- app/{docker => portainer}/models/stack.js | 10 +- app/portainer/rest/stack.js | 10 +- app/portainer/services/api/stackService.js | 276 ++++++ app/portainer/services/datatableService.js | 16 + app/portainer/services/fileUpload.js | 16 +- app/portainer/services/localStorage.js | 12 + .../stacks/create/createStackController.js | 60 +- .../views/stacks/create/createstack.html | 57 +- app/portainer/views/stacks/edit/stack.html | 158 +++ .../views/stacks/edit/stackController.js | 171 ++++ app/portainer/views/stacks/stacks.html | 20 + .../views/stacks/stacksController.js | 30 +- 174 files changed, 7898 insertions(+), 5849 deletions(-) rename api/exec/{stack_manager.go => swarm_stack.go} (82%) delete mode 100644 api/http/handler/auth.go create mode 100644 api/http/handler/auth/authenticate.go create mode 100644 api/http/handler/auth/handler.go delete mode 100644 api/http/handler/azure.go delete mode 100644 api/http/handler/docker.go delete mode 100644 api/http/handler/dockerhub.go create mode 100644 api/http/handler/dockerhub/dockerhub_inspect.go create mode 100644 api/http/handler/dockerhub/dockerhub_update.go create mode 100644 api/http/handler/dockerhub/handler.go delete mode 100644 api/http/handler/endpoint.go delete mode 100644 api/http/handler/endpoint_group.go create mode 100644 api/http/handler/endpointgroups/endpointgroup_create.go create mode 100644 api/http/handler/endpointgroups/endpointgroup_delete.go create mode 100644 api/http/handler/endpointgroups/endpointgroup_inspect.go create mode 100644 api/http/handler/endpointgroups/endpointgroup_list.go create mode 100644 api/http/handler/endpointgroups/endpointgroup_update.go create mode 100644 api/http/handler/endpointgroups/endpointgroup_update_access.go create mode 100644 api/http/handler/endpointgroups/handler.go create mode 100644 api/http/handler/endpointproxy/handler.go create mode 100644 api/http/handler/endpointproxy/proxy_azure.go create mode 100644 api/http/handler/endpointproxy/proxy_docker.go create mode 100644 api/http/handler/endpointproxy/proxy_storidge.go create mode 100644 api/http/handler/endpoints/endpoint_create.go create mode 100644 api/http/handler/endpoints/endpoint_delete.go create mode 100644 api/http/handler/endpoints/endpoint_extension_add.go create mode 100644 api/http/handler/endpoints/endpoint_extension_remove.go create mode 100644 api/http/handler/endpoints/endpoint_inspect.go create mode 100644 api/http/handler/endpoints/endpoint_list.go create mode 100644 api/http/handler/endpoints/endpoint_update.go create mode 100644 api/http/handler/endpoints/endpoint_update_access.go create mode 100644 api/http/handler/endpoints/handler.go delete mode 100644 api/http/handler/extensions.go delete mode 100644 api/http/handler/extensions/storidge.go rename api/http/handler/{file.go => file/handler.go} (54%) create mode 100644 api/http/handler/registries/handler.go create mode 100644 api/http/handler/registries/registry_create.go create mode 100644 api/http/handler/registries/registry_delete.go create mode 100644 api/http/handler/registries/registry_inspect.go create mode 100644 api/http/handler/registries/registry_list.go create mode 100644 api/http/handler/registries/registry_update.go create mode 100644 api/http/handler/registries/registry_update_access.go delete mode 100644 api/http/handler/registry.go delete mode 100644 api/http/handler/resource_control.go create mode 100644 api/http/handler/resourcecontrols/handler.go create mode 100644 api/http/handler/resourcecontrols/resourcecontrol_create.go create mode 100644 api/http/handler/resourcecontrols/resourcecontrol_delete.go create mode 100644 api/http/handler/resourcecontrols/resourcecontrol_update.go delete mode 100644 api/http/handler/settings.go create mode 100644 api/http/handler/settings/handler.go create mode 100644 api/http/handler/settings/settings_inspect.go create mode 100644 api/http/handler/settings/settings_ldap_check.go create mode 100644 api/http/handler/settings/settings_public.go create mode 100644 api/http/handler/settings/settings_update.go delete mode 100644 api/http/handler/stack.go create mode 100644 api/http/handler/stacks/create_compose_stack.go create mode 100644 api/http/handler/stacks/create_swarm_stack.go create mode 100644 api/http/handler/stacks/git.go create mode 100644 api/http/handler/stacks/handler.go create mode 100644 api/http/handler/stacks/stack_create.go create mode 100644 api/http/handler/stacks/stack_delete.go create mode 100644 api/http/handler/stacks/stack_file.go create mode 100644 api/http/handler/stacks/stack_inspect.go create mode 100644 api/http/handler/stacks/stack_list.go create mode 100644 api/http/handler/stacks/stack_update.go delete mode 100644 api/http/handler/status.go create mode 100644 api/http/handler/status/handler.go create mode 100644 api/http/handler/status/status_inspect.go delete mode 100644 api/http/handler/team.go delete mode 100644 api/http/handler/team_membership.go create mode 100644 api/http/handler/teammemberships/handler.go create mode 100644 api/http/handler/teammemberships/teammembership_create.go create mode 100644 api/http/handler/teammemberships/teammembership_delete.go create mode 100644 api/http/handler/teammemberships/teammembership_list.go create mode 100644 api/http/handler/teammemberships/teammembership_update.go create mode 100644 api/http/handler/teams/handler.go create mode 100644 api/http/handler/teams/team_create.go create mode 100644 api/http/handler/teams/team_delete.go create mode 100644 api/http/handler/teams/team_inspect.go create mode 100644 api/http/handler/teams/team_list.go create mode 100644 api/http/handler/teams/team_memberships.go create mode 100644 api/http/handler/teams/team_update.go delete mode 100644 api/http/handler/templates.go create mode 100644 api/http/handler/templates/handler.go create mode 100644 api/http/handler/templates/template_list.go delete mode 100644 api/http/handler/upload.go create mode 100644 api/http/handler/upload/handler.go create mode 100644 api/http/handler/upload/upload_tls.go delete mode 100644 api/http/handler/user.go create mode 100644 api/http/handler/users/admin_check.go create mode 100644 api/http/handler/users/admin_init.go create mode 100644 api/http/handler/users/handler.go create mode 100644 api/http/handler/users/user_create.go create mode 100644 api/http/handler/users/user_delete.go create mode 100644 api/http/handler/users/user_inspect.go create mode 100644 api/http/handler/users/user_list.go create mode 100644 api/http/handler/users/user_memberships.go create mode 100644 api/http/handler/users/user_password.go create mode 100644 api/http/handler/users/user_update.go create mode 100644 api/http/handler/websocket/handler.go rename api/http/handler/{websocket.go => websocket/websocket_exec.go} (73%) create mode 100644 api/http/request/request.go create mode 100644 api/http/response/response.go create mode 100644 api/libcompose/compose_stack.go create mode 100644 app/docker/components/datatables/containers-datatable/actions/containersDatatableActions.html create mode 100644 app/docker/components/datatables/containers-datatable/actions/containersDatatableActions.js create mode 100644 app/docker/components/datatables/containers-datatable/actions/containersDatatableActionsController.js create mode 100644 app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatable.html create mode 100644 app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatable.js create mode 100644 app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatableController.js create mode 100644 app/docker/components/datatables/services-datatable/actions/servicesDatatableActions.html create mode 100644 app/docker/components/datatables/services-datatable/actions/servicesDatatableActions.js create mode 100644 app/docker/components/datatables/services-datatable/actions/servicesDatatableActionsController.js create mode 100644 app/docker/components/datatables/services-datatable/servicesDatatableController.js delete mode 100644 app/docker/services/stackService.js delete mode 100644 app/docker/views/stacks/edit/stack.html delete mode 100644 app/docker/views/stacks/edit/stackController.js delete mode 100644 app/docker/views/stacks/stacks.html rename app/{docker => portainer}/models/stack.js (60%) create mode 100644 app/portainer/services/api/stackService.js rename app/{docker => portainer}/views/stacks/create/createStackController.js (57%) rename app/{docker => portainer}/views/stacks/create/createstack.html (80%) create mode 100644 app/portainer/views/stacks/edit/stack.html create mode 100644 app/portainer/views/stacks/edit/stackController.js create mode 100644 app/portainer/views/stacks/stacks.html rename app/{docker => portainer}/views/stacks/stacksController.js (73%) diff --git a/api/bolt/stack_service.go b/api/bolt/stack_service.go index bdbaac791..7fe023546 100644 --- a/api/bolt/stack_service.go +++ b/api/bolt/stack_service.go @@ -38,6 +38,35 @@ func (service *StackService) Stack(ID portainer.StackID) (*portainer.Stack, erro return &stack, nil } +// StackByName returns a stack object by name. +func (service *StackService) StackByName(name string) (*portainer.Stack, error) { + var stack *portainer.Stack + + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(stackBucketName)) + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var t portainer.Stack + err := internal.UnmarshalStack(v, &t) + if err != nil { + return err + } + if t.Name == name { + stack = &t + } + } + + if stack == nil { + return portainer.ErrStackNotFound + } + return nil + }) + if err != nil { + return nil, err + } + return stack, nil +} + // Stacks returns an array containing all the stacks. func (service *StackService) Stacks() ([]portainer.Stack, error) { var stacks = make([]portainer.Stack, 0) @@ -63,33 +92,6 @@ func (service *StackService) Stacks() ([]portainer.Stack, error) { return stacks, nil } -// StacksBySwarmID return an array containing all the stacks related to the specified Swarm ID. -func (service *StackService) StacksBySwarmID(id string) ([]portainer.Stack, error) { - var stacks = make([]portainer.Stack, 0) - err := service.store.db.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(stackBucketName)) - - cursor := bucket.Cursor() - for k, v := cursor.First(); k != nil; k, v = cursor.Next() { - var stack portainer.Stack - err := internal.UnmarshalStack(v, &stack) - if err != nil { - return err - } - if stack.SwarmID == id { - stacks = append(stacks, stack) - } - } - - return nil - }) - if err != nil { - return nil, err - } - - return stacks, nil -} - // CreateStack creates a new stack. func (service *StackService) CreateStack(stack *portainer.Stack) error { return service.store.db.Update(func(tx *bolt.Tx) error { diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 2ed222d33..0c13e118f 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -15,6 +15,7 @@ import ( "github.com/portainer/portainer/http/client" "github.com/portainer/portainer/jwt" "github.com/portainer/portainer/ldap" + "github.com/portainer/portainer/libcompose" "log" ) @@ -64,8 +65,12 @@ func initStore(dataStorePath string) *bolt.Store { return store } -func initStackManager(assetsPath string, dataStorePath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService) (portainer.StackManager, error) { - return exec.NewStackManager(assetsPath, dataStorePath, signatureService, fileService) +func initComposeStackManager(dataStorePath string) portainer.ComposeStackManager { + return libcompose.NewComposeStackManager(dataStorePath) +} + +func initSwarmStackManager(assetsPath string, dataStorePath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService) (portainer.SwarmStackManager, error) { + return exec.NewSwarmStackManager(assetsPath, dataStorePath, signatureService, fileService) } func initJWTService(authenticationEnabled bool) portainer.JWTService { @@ -320,11 +325,13 @@ func main() { log.Fatal(err) } - stackManager, err := initStackManager(*flags.Assets, *flags.Data, digitalSignatureService, fileService) + swarmStackManager, err := initSwarmStackManager(*flags.Assets, *flags.Data, digitalSignatureService, fileService) if err != nil { log.Fatal(err) } + composeStackManager := initComposeStackManager(*flags.Data) + err = initSettings(store.SettingsService, flags) if err != nil { log.Fatal(err) @@ -394,7 +401,8 @@ func main() { RegistryService: store.RegistryService, DockerHubService: store.DockerHubService, StackService: store.StackService, - StackManager: stackManager, + SwarmStackManager: swarmStackManager, + ComposeStackManager: composeStackManager, CryptoService: cryptoService, JWTService: jwtService, FileService: fileService, diff --git a/api/errors.go b/api/errors.go index 854cefbab..c8f534f2c 100644 --- a/api/errors.go +++ b/api/errors.go @@ -65,6 +65,7 @@ const ( ErrStackNotFound = Error("Stack not found") ErrStackAlreadyExists = Error("A stack already exists with this name") ErrComposeFileNotFoundInRepository = Error("Unable to find a Compose file in the repository") + ErrStackNotExternal = Error("Not an external stack") ) // Endpoint extensions error diff --git a/api/exec/stack_manager.go b/api/exec/swarm_stack.go similarity index 82% rename from api/exec/stack_manager.go rename to api/exec/swarm_stack.go index d8c2d9438..1e896971e 100644 --- a/api/exec/stack_manager.go +++ b/api/exec/swarm_stack.go @@ -11,18 +11,18 @@ import ( "github.com/portainer/portainer" ) -// StackManager represents a service for managing stacks. -type StackManager struct { +// SwarmStackManager represents a service for managing stacks. +type SwarmStackManager struct { binaryPath string dataPath string signatureService portainer.DigitalSignatureService fileService portainer.FileService } -// NewStackManager initializes a new StackManager service. +// NewSwarmStackManager initializes a new SwarmStackManager service. // It also updates the configuration of the Docker CLI binary. -func NewStackManager(binaryPath, dataPath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService) (*StackManager, error) { - manager := &StackManager{ +func NewSwarmStackManager(binaryPath, dataPath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService) (*SwarmStackManager, error) { + manager := &SwarmStackManager{ binaryPath: binaryPath, dataPath: dataPath, signatureService: signatureService, @@ -38,7 +38,7 @@ func NewStackManager(binaryPath, dataPath string, signatureService portainer.Dig } // Login executes the docker login command against a list of registries (including DockerHub). -func (manager *StackManager) Login(dockerhub *portainer.DockerHub, registries []portainer.Registry, endpoint *portainer.Endpoint) { +func (manager *SwarmStackManager) Login(dockerhub *portainer.DockerHub, registries []portainer.Registry, endpoint *portainer.Endpoint) { command, args := prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint) for _, registry := range registries { if registry.Authentication { @@ -54,14 +54,14 @@ func (manager *StackManager) Login(dockerhub *portainer.DockerHub, registries [] } // Logout executes the docker logout command. -func (manager *StackManager) Logout(endpoint *portainer.Endpoint) error { +func (manager *SwarmStackManager) Logout(endpoint *portainer.Endpoint) error { command, args := prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint) args = append(args, "logout") return runCommandAndCaptureStdErr(command, args, nil, "") } // Deploy executes the docker stack deploy command. -func (manager *StackManager) Deploy(stack *portainer.Stack, prune bool, endpoint *portainer.Endpoint) error { +func (manager *SwarmStackManager) Deploy(stack *portainer.Stack, prune bool, endpoint *portainer.Endpoint) error { stackFilePath := path.Join(stack.ProjectPath, stack.EntryPoint) command, args := prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint) @@ -81,7 +81,7 @@ func (manager *StackManager) Deploy(stack *portainer.Stack, prune bool, endpoint } // Remove executes the docker stack rm command. -func (manager *StackManager) Remove(stack *portainer.Stack, endpoint *portainer.Endpoint) error { +func (manager *SwarmStackManager) Remove(stack *portainer.Stack, endpoint *portainer.Endpoint) error { command, args := prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint) args = append(args, "stack", "rm", stack.Name) return runCommandAndCaptureStdErr(command, args, nil, "") @@ -133,7 +133,7 @@ func prepareDockerCommandAndArgs(binaryPath, dataPath string, endpoint *portaine return command, args } -func (manager *StackManager) updateDockerCLIConfiguration(dataPath string) error { +func (manager *SwarmStackManager) updateDockerCLIConfiguration(dataPath string) error { configFilePath := path.Join(dataPath, "config.json") config, err := manager.retrieveConfigurationFromDisk(configFilePath) if err != nil { @@ -161,7 +161,7 @@ func (manager *StackManager) updateDockerCLIConfiguration(dataPath string) error return nil } -func (manager *StackManager) retrieveConfigurationFromDisk(path string) (map[string]interface{}, error) { +func (manager *SwarmStackManager) retrieveConfigurationFromDisk(path string) (map[string]interface{}, error) { var config map[string]interface{} raw, err := manager.fileService.GetFileContent(path) diff --git a/api/filesystem/filesystem.go b/api/filesystem/filesystem.go index 770b8af76..c3f459ddf 100644 --- a/api/filesystem/filesystem.go +++ b/api/filesystem/filesystem.go @@ -77,9 +77,9 @@ func (service *Service) GetStackProjectPath(stackIdentifier string) string { return path.Join(service.fileStorePath, ComposeStorePath, stackIdentifier) } -// StoreStackFileFromString creates a subfolder in the ComposeStorePath and stores a new file using the content from a string. +// StoreStackFileFromBytes creates a subfolder in the ComposeStorePath and stores a new file from bytes. // It returns the path to the folder where the file is stored. -func (service *Service) StoreStackFileFromString(stackIdentifier, fileName, stackFileContent string) (string, error) { +func (service *Service) StoreStackFileFromBytes(stackIdentifier, fileName string, data []byte) (string, error) { stackStorePath := path.Join(ComposeStorePath, stackIdentifier) err := service.createDirectoryInStore(stackStorePath) if err != nil { @@ -87,7 +87,6 @@ func (service *Service) StoreStackFileFromString(stackIdentifier, fileName, stac } composeFilePath := path.Join(stackStorePath, fileName) - data := []byte(stackFileContent) r := bytes.NewReader(data) err = service.createFileInStore(composeFilePath, r) @@ -98,31 +97,13 @@ func (service *Service) StoreStackFileFromString(stackIdentifier, fileName, stac return path.Join(service.fileStorePath, stackStorePath), nil } -// StoreStackFileFromReader creates a subfolder in the ComposeStorePath and stores a new file using the content from an io.Reader. -// It returns the path to the folder where the file is stored. -func (service *Service) StoreStackFileFromReader(stackIdentifier, fileName string, r io.Reader) (string, error) { - stackStorePath := path.Join(ComposeStorePath, stackIdentifier) - err := service.createDirectoryInStore(stackStorePath) - if err != nil { - return "", err - } - - composeFilePath := path.Join(stackStorePath, fileName) - - err = service.createFileInStore(composeFilePath, r) - if err != nil { - return "", err - } - - return path.Join(service.fileStorePath, stackStorePath), nil -} - -// StoreTLSFile creates a folder in the TLSStorePath and stores a new file with the content from r. -func (service *Service) StoreTLSFile(folder string, fileType portainer.TLSFileType, r io.Reader) error { +// StoreTLSFileFromBytes creates a folder in the TLSStorePath and stores a new file from bytes. +// It returns the path to the newly created file. +func (service *Service) StoreTLSFileFromBytes(folder string, fileType portainer.TLSFileType, data []byte) (string, error) { storePath := path.Join(TLSStorePath, folder) err := service.createDirectoryInStore(storePath) if err != nil { - return err + return "", err } var fileName string @@ -134,15 +115,16 @@ func (service *Service) StoreTLSFile(folder string, fileType portainer.TLSFileTy case portainer.TLSFileKey: fileName = TLSKeyFile default: - return portainer.ErrUndefinedTLSFileType + return "", portainer.ErrUndefinedTLSFileType } tlsFilePath := path.Join(storePath, fileName) + r := bytes.NewReader(data) err = service.createFileInStore(tlsFilePath, r) if err != nil { - return err + return "", err } - return nil + return path.Join(service.fileStorePath, tlsFilePath), nil } // GetPathForTLSFile returns the absolute path to a specific TLS file for an endpoint. diff --git a/api/http/error/error.go b/api/http/error/error.go index 7a2715c8a..b9153a8a6 100644 --- a/api/http/error/error.go +++ b/api/http/error/error.go @@ -6,18 +6,36 @@ import ( "net/http" ) -// errorResponse is a generic response for sending a error. -type errorResponse struct { - Err string `json:"err,omitempty"` -} - -// WriteErrorResponse writes an error message to the response and logger. -func WriteErrorResponse(w http.ResponseWriter, err error, code int, logger *log.Logger) { - if logger != nil { - logger.Printf("http error: %s (code=%d)", err, code) +type ( + // LoggerHandler defines a HTTP handler that includes a HandlerError return pointer + LoggerHandler func(http.ResponseWriter, *http.Request) *HandlerError + // HandlerError represents an error raised inside a HTTP handler + HandlerError struct { + StatusCode int + Message string + Err error } + errorResponse struct { + Err string `json:"err,omitempty"` + } +) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(code) - json.NewEncoder(w).Encode(&errorResponse{Err: err.Error()}) +func (handler LoggerHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { + err := handler(rw, r) + if err != nil { + writeErrorResponse(rw, err) + } +} + +func writeErrorResponse(rw http.ResponseWriter, err *HandlerError) { + log.Printf("http error: %s (err=%s) (code=%d)\n", err.Message, err.Err, err.StatusCode) + rw.Header().Set("Content-Type", "application/json") + rw.WriteHeader(err.StatusCode) + json.NewEncoder(rw).Encode(&errorResponse{Err: err.Message}) +} + +// WriteError is a convenience function that creates a new HandlerError before calling writeErrorResponse. +// For use outside of the standard http handlers. +func WriteError(rw http.ResponseWriter, code int, message string, err error) { + writeErrorResponse(rw, &HandlerError{code, message, err}) } diff --git a/api/http/handler/auth.go b/api/http/handler/auth.go deleted file mode 100644 index 4b75967e9..000000000 --- a/api/http/handler/auth.go +++ /dev/null @@ -1,126 +0,0 @@ -package handler - -import ( - "github.com/portainer/portainer" - - "encoding/json" - "log" - "net/http" - "os" - - "github.com/asaskevich/govalidator" - "github.com/gorilla/mux" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/security" -) - -// AuthHandler represents an HTTP API handler for managing authentication. -type AuthHandler struct { - *mux.Router - Logger *log.Logger - authDisabled bool - UserService portainer.UserService - CryptoService portainer.CryptoService - JWTService portainer.JWTService - LDAPService portainer.LDAPService - SettingsService portainer.SettingsService -} - -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") - // 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. -func NewAuthHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimiter, authDisabled bool) *AuthHandler { - h := &AuthHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - authDisabled: authDisabled, - } - h.Handle("/auth", - rateLimiter.LimitAccess(bouncer.PublicAccess(http.HandlerFunc(h.handlePostAuth)))).Methods(http.MethodPost) - - return h -} - -type ( - postAuthRequest struct { - Username string `valid:"required"` - Password string `valid:"required"` - } - - postAuthResponse struct { - JWT string `json:"jwt"` - } -) - -func (handler *AuthHandler) handlePostAuth(w http.ResponseWriter, r *http.Request) { - if handler.authDisabled { - httperror.WriteErrorResponse(w, ErrAuthDisabled, http.StatusServiceUnavailable, handler.Logger) - return - } - - var req postAuthRequest - 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, ErrInvalidCredentialsFormat, http.StatusBadRequest, handler.Logger) - return - } - - var username = req.Username - var password = req.Password - - u, err := handler.UserService.UserByUsername(username) - if err == portainer.ErrUserNotFound { - httperror.WriteErrorResponse(w, ErrInvalidCredentials, http.StatusBadRequest, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - settings, err := handler.SettingsService.Settings() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if settings.AuthenticationMethod == portainer.AuthenticationLDAP && u.ID != 1 { - err = handler.LDAPService.AuthenticateUser(username, password, &settings.LDAPSettings) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - } else { - err = handler.CryptoService.CompareHashAndData(u.Password, password) - if err != nil { - httperror.WriteErrorResponse(w, ErrInvalidCredentials, http.StatusUnprocessableEntity, handler.Logger) - return - } - } - - tokenData := &portainer.TokenData{ - ID: u.ID, - Username: u.Username, - Role: u.Role, - } - - token, err := handler.JWTService.GenerateToken(tokenData) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, &postAuthResponse{JWT: token}, handler.Logger) -} diff --git a/api/http/handler/auth/authenticate.go b/api/http/handler/auth/authenticate.go new file mode 100644 index 000000000..a5200f7d3 --- /dev/null +++ b/api/http/handler/auth/authenticate.go @@ -0,0 +1,79 @@ +package auth + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type authenticatePayload struct { + Username string + Password string +} + +type authenticateResponse struct { + JWT string `json:"jwt"` +} + +func (payload *authenticatePayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Username) { + return portainer.Error("Invalid username") + } + if govalidator.IsNull(payload.Password) { + return portainer.Error("Invalid password") + } + return nil +} + +func (handler *Handler) authenticate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + if handler.authDisabled { + return &httperror.HandlerError{http.StatusServiceUnavailable, "Cannot authenticate user. Portainer was started with the --no-auth flag", ErrAuthDisabled} + } + + var payload authenticatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + u, err := handler.UserService.UserByUsername(payload.Username) + if err == portainer.ErrUserNotFound { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid credentials", ErrInvalidCredentials} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a user with the specified username from the database", err} + } + + settings, err := handler.SettingsService.Settings() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err} + } + + if settings.AuthenticationMethod == portainer.AuthenticationLDAP && u.ID != 1 { + err = handler.LDAPService.AuthenticateUser(payload.Username, payload.Password, &settings.LDAPSettings) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to authenticate user via LDAP/AD", err} + } + } else { + err = handler.CryptoService.CompareHashAndData(u.Password, payload.Password) + if err != nil { + return &httperror.HandlerError{http.StatusUnprocessableEntity, "Invalid credentials", ErrInvalidCredentials} + } + } + + tokenData := &portainer.TokenData{ + ID: u.ID, + Username: u.Username, + Role: u.Role, + } + + token, err := handler.JWTService.GenerateToken(tokenData) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to generate JWT token", err} + } + + return response.JSON(w, &authenticateResponse{JWT: token}) +} diff --git a/api/http/handler/auth/handler.go b/api/http/handler/auth/handler.go new file mode 100644 index 000000000..db47b82e2 --- /dev/null +++ b/api/http/handler/auth/handler.go @@ -0,0 +1,41 @@ +package auth + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" +) + +const ( + // 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") +) + +// Handler is the HTTP handler used to handle authentication operations. +type Handler struct { + *mux.Router + authDisabled bool + UserService portainer.UserService + CryptoService portainer.CryptoService + JWTService portainer.JWTService + LDAPService portainer.LDAPService + SettingsService portainer.SettingsService +} + +// NewHandler creates a handler to manage authentication operations. +func NewHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimiter, authDisabled bool) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + authDisabled: authDisabled, + } + h.Handle("/auth", + rateLimiter.LimitAccess(bouncer.PublicAccess(httperror.LoggerHandler(h.authenticate)))).Methods(http.MethodPost) + + return h +} diff --git a/api/http/handler/azure.go b/api/http/handler/azure.go deleted file mode 100644 index a372244a1..000000000 --- a/api/http/handler/azure.go +++ /dev/null @@ -1,102 +0,0 @@ -package handler - -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" -) - -// AzureHandler represents an HTTP API handler for proxying requests to the Azure API. -type AzureHandler struct { - *mux.Router - Logger *log.Logger - EndpointService portainer.EndpointService - EndpointGroupService portainer.EndpointGroupService - TeamMembershipService portainer.TeamMembershipService - ProxyManager *proxy.Manager -} - -// NewAzureHandler returns a new instance of AzureHandler. -func NewAzureHandler(bouncer *security.RequestBouncer) *AzureHandler { - h := &AzureHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.PathPrefix("/{id}/azure").Handler( - bouncer.AuthenticatedAccess(http.HandlerFunc(h.proxyRequestsToAzureAPI))) - return h -} - -func (handler *AzureHandler) checkEndpointAccess(endpoint *portainer.Endpoint, userID portainer.UserID) error { - memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(userID) - if err != nil { - return err - } - - group, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID) - if err != nil { - return err - } - - if !security.AuthorizedEndpointAccess(endpoint, group, userID, memberships) { - return portainer.ErrEndpointAccessDenied - } - - return nil -} - -func (handler *AzureHandler) proxyRequestsToAzureAPI(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 - } - - if tokenData.Role != portainer.AdministratorRole { - err = handler.checkEndpointAccess(endpoint, tokenData.ID) - if err != nil && err == portainer.ErrEndpointAccessDenied { - httperror.WriteErrorResponse(w, err, http.StatusForbidden, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - } - - var proxy http.Handler - proxy = handler.ProxyManager.GetProxy(string(endpointID)) - if proxy == nil { - proxy, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - } - - http.StripPrefix("/"+id+"/azure", proxy).ServeHTTP(w, r) -} diff --git a/api/http/handler/docker.go b/api/http/handler/docker.go deleted file mode 100644 index 24cf0831d..000000000 --- a/api/http/handler/docker.go +++ /dev/null @@ -1,92 +0,0 @@ -package handler - -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" -) - -// DockerHandler represents an HTTP API handler for proxying requests to the Docker API. -type DockerHandler struct { - *mux.Router - Logger *log.Logger - EndpointService portainer.EndpointService - EndpointGroupService portainer.EndpointGroupService - TeamMembershipService portainer.TeamMembershipService - ProxyManager *proxy.Manager -} - -// NewDockerHandler returns a new instance of DockerHandler. -func NewDockerHandler(bouncer *security.RequestBouncer) *DockerHandler { - h := &DockerHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.PathPrefix("/{id}/docker").Handler( - bouncer.AuthenticatedAccess(http.HandlerFunc(h.proxyRequestsToDockerAPI))) - return h -} - -func (handler *DockerHandler) proxyRequestsToDockerAPI(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 { - group, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if !security.AuthorizedEndpointAccess(endpoint, group, tokenData.ID, memberships) { - httperror.WriteErrorResponse(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger) - return - } - } - - var proxy http.Handler - proxy = handler.ProxyManager.GetProxy(string(endpointID)) - if proxy == nil { - proxy, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - } - - http.StripPrefix("/"+id+"/docker", proxy).ServeHTTP(w, r) -} diff --git a/api/http/handler/dockerhub.go b/api/http/handler/dockerhub.go deleted file mode 100644 index 75acc0517..000000000 --- a/api/http/handler/dockerhub.go +++ /dev/null @@ -1,91 +0,0 @@ -package handler - -import ( - "encoding/json" - - "github.com/asaskevich/govalidator" - "github.com/portainer/portainer" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/security" - - "log" - "net/http" - "os" - - "github.com/gorilla/mux" -) - -// DockerHubHandler represents an HTTP API handler for managing DockerHub. -type DockerHubHandler struct { - *mux.Router - Logger *log.Logger - DockerHubService portainer.DockerHubService -} - -// NewDockerHubHandler returns a new instance of DockerHubHandler. -func NewDockerHubHandler(bouncer *security.RequestBouncer) *DockerHubHandler { - h := &DockerHubHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.Handle("/dockerhub", - bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleGetDockerHub))).Methods(http.MethodGet) - h.Handle("/dockerhub", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutDockerHub))).Methods(http.MethodPut) - - return h -} - -type ( - putDockerHubRequest struct { - Authentication bool `valid:""` - Username string `valid:""` - Password string `valid:""` - } -) - -// handleGetDockerHub handles GET requests on /dockerhub -func (handler *DockerHubHandler) handleGetDockerHub(w http.ResponseWriter, r *http.Request) { - dockerhub, err := handler.DockerHubService.DockerHub() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - dockerhub.Password = "" - - encodeJSON(w, dockerhub, handler.Logger) - return -} - -// handlePutDockerHub handles PUT requests on /dockerhub -func (handler *DockerHubHandler) handlePutDockerHub(w http.ResponseWriter, r *http.Request) { - var req putDockerHubRequest - 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 - } - - dockerhub := &portainer.DockerHub{ - Authentication: false, - Username: "", - Password: "", - } - - if req.Authentication { - dockerhub.Authentication = true - dockerhub.Username = req.Username - dockerhub.Password = req.Password - } - - err = handler.DockerHubService.StoreDockerHub(dockerhub) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - } -} diff --git a/api/http/handler/dockerhub/dockerhub_inspect.go b/api/http/handler/dockerhub/dockerhub_inspect.go new file mode 100644 index 000000000..25be6617c --- /dev/null +++ b/api/http/handler/dockerhub/dockerhub_inspect.go @@ -0,0 +1,19 @@ +package dockerhub + +import ( + "net/http" + + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/response" +) + +// GET request on /api/dockerhub +func (handler *Handler) dockerhubInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + dockerhub, err := handler.DockerHubService.DockerHub() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve DockerHub details from the database", err} + } + + hideFields(dockerhub) + return response.JSON(w, dockerhub) +} diff --git a/api/http/handler/dockerhub/dockerhub_update.go b/api/http/handler/dockerhub/dockerhub_update.go new file mode 100644 index 000000000..9f8f8d201 --- /dev/null +++ b/api/http/handler/dockerhub/dockerhub_update.go @@ -0,0 +1,52 @@ +package dockerhub + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type dockerhubUpdatePayload struct { + Authentication bool + Username string + Password string +} + +func (payload *dockerhubUpdatePayload) Validate(r *http.Request) error { + if payload.Authentication && (govalidator.IsNull(payload.Username) || govalidator.IsNull(payload.Password)) { + return portainer.Error("Invalid credentials. Username and password must be specified when authentication is enabled") + } + return nil +} + +// PUT request on /api/dockerhub +func (handler *Handler) dockerhubUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload dockerhubUpdatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + dockerhub := &portainer.DockerHub{ + Authentication: false, + Username: "", + Password: "", + } + + if payload.Authentication { + dockerhub.Authentication = true + dockerhub.Username = payload.Username + dockerhub.Password = payload.Password + } + + err = handler.DockerHubService.StoreDockerHub(dockerhub) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the Dockerhub changes inside the database", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/dockerhub/handler.go b/api/http/handler/dockerhub/handler.go new file mode 100644 index 000000000..cd2f5ae50 --- /dev/null +++ b/api/http/handler/dockerhub/handler.go @@ -0,0 +1,33 @@ +package dockerhub + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" +) + +func hideFields(dockerHub *portainer.DockerHub) { + dockerHub.Password = "" +} + +// Handler is the HTTP handler used to handle DockerHub operations. +type Handler struct { + *mux.Router + DockerHubService portainer.DockerHubService +} + +// NewHandler creates a handler to manage Dockerhub operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/dockerhub", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.dockerhubInspect))).Methods(http.MethodGet) + h.Handle("/dockerhub", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.dockerhubUpdate))).Methods(http.MethodPut) + + return h +} diff --git a/api/http/handler/endpoint.go b/api/http/handler/endpoint.go deleted file mode 100644 index 4b6a43220..000000000 --- a/api/http/handler/endpoint.go +++ /dev/null @@ -1,625 +0,0 @@ -package handler - -import ( - "bytes" - "strings" - - "github.com/portainer/portainer" - "github.com/portainer/portainer/crypto" - "github.com/portainer/portainer/http/client" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/proxy" - "github.com/portainer/portainer/http/security" - - "encoding/json" - "log" - "net/http" - "os" - "strconv" - - "github.com/asaskevich/govalidator" - "github.com/gorilla/mux" -) - -// EndpointHandler represents an HTTP API handler for managing Docker endpoints. -type EndpointHandler struct { - *mux.Router - Logger *log.Logger - authorizeEndpointManagement bool - EndpointService portainer.EndpointService - EndpointGroupService portainer.EndpointGroupService - FileService portainer.FileService - ProxyManager *proxy.Manager -} - -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(bouncer *security.RequestBouncer, authorizeEndpointManagement bool) *EndpointHandler { - h := &EndpointHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - authorizeEndpointManagement: authorizeEndpointManagement, - } - h.Handle("/endpoints", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePostEndpoints))).Methods(http.MethodPost) - h.Handle("/endpoints", - bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetEndpoints))).Methods(http.MethodGet) - h.Handle("/endpoints/{id}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handleGetEndpoint))).Methods(http.MethodGet) - h.Handle("/endpoints/{id}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutEndpoint))).Methods(http.MethodPut) - h.Handle("/endpoints/{id}/access", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutEndpointAccess))).Methods(http.MethodPut) - h.Handle("/endpoints/{id}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handleDeleteEndpoint))).Methods(http.MethodDelete) - - return h -} - -type ( - putEndpointAccessRequest struct { - AuthorizedUsers []int `valid:"-"` - AuthorizedTeams []int `valid:"-"` - } - - putEndpointsRequest struct { - Name string `valid:"-"` - URL string `valid:"-"` - PublicURL string `valid:"-"` - GroupID int `valid:"-"` - TLS bool `valid:"-"` - TLSSkipVerify bool `valid:"-"` - TLSSkipClientVerify bool `valid:"-"` - AzureApplicationID string `valid:"-"` - AzureTenantID string `valid:"-"` - AzureAuthenticationKey string `valid:"-"` - } - - postEndpointPayload struct { - name string - url string - endpointType int - publicURL string - groupID int - useTLS bool - skipTLSServerVerification bool - skipTLSClientVerification bool - caCert []byte - cert []byte - key []byte - azureApplicationID string - azureTenantID string - azureAuthenticationKey string - } -) - -// handleGetEndpoints handles GET requests on /endpoints -func (handler *EndpointHandler) handleGetEndpoints(w http.ResponseWriter, r *http.Request) { - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - endpoints, err := handler.EndpointService.Endpoints() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - groups, err := handler.EndpointGroupService.EndpointGroups() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - filteredEndpoints, err := security.FilterEndpoints(endpoints, groups, securityContext) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - for i := range filteredEndpoints { - filteredEndpoints[i].AzureCredentials = portainer.AzureCredentials{} - } - - encodeJSON(w, filteredEndpoints, handler.Logger) -} - -func (handler *EndpointHandler) createAzureEndpoint(payload *postEndpointPayload) (*portainer.Endpoint, error) { - credentials := portainer.AzureCredentials{ - ApplicationID: payload.azureApplicationID, - TenantID: payload.azureTenantID, - AuthenticationKey: payload.azureAuthenticationKey, - } - - httpClient := client.NewHTTPClient() - _, err := httpClient.ExecuteAzureAuthenticationRequest(&credentials) - if err != nil { - return nil, err - } - - endpoint := &portainer.Endpoint{ - Name: payload.name, - URL: proxy.AzureAPIBaseURL, - Type: portainer.AzureEnvironment, - GroupID: portainer.EndpointGroupID(payload.groupID), - PublicURL: payload.publicURL, - AuthorizedUsers: []portainer.UserID{}, - AuthorizedTeams: []portainer.TeamID{}, - Extensions: []portainer.EndpointExtension{}, - AzureCredentials: credentials, - } - - err = handler.EndpointService.CreateEndpoint(endpoint) - if err != nil { - return nil, err - } - - return endpoint, nil -} - -func (handler *EndpointHandler) createTLSSecuredEndpoint(payload *postEndpointPayload) (*portainer.Endpoint, error) { - tlsConfig, err := crypto.CreateTLSConfigurationFromBytes(payload.caCert, payload.cert, payload.key, payload.skipTLSClientVerification, payload.skipTLSServerVerification) - if err != nil { - return nil, err - } - - agentOnDockerEnvironment, err := client.ExecutePingOperation(payload.url, tlsConfig) - if err != nil { - return nil, err - } - - endpointType := portainer.DockerEnvironment - if agentOnDockerEnvironment { - endpointType = portainer.AgentOnDockerEnvironment - } - - endpoint := &portainer.Endpoint{ - Name: payload.name, - URL: payload.url, - Type: endpointType, - GroupID: portainer.EndpointGroupID(payload.groupID), - PublicURL: payload.publicURL, - TLSConfig: portainer.TLSConfiguration{ - TLS: payload.useTLS, - TLSSkipVerify: payload.skipTLSServerVerification, - }, - AuthorizedUsers: []portainer.UserID{}, - AuthorizedTeams: []portainer.TeamID{}, - Extensions: []portainer.EndpointExtension{}, - } - - err = handler.EndpointService.CreateEndpoint(endpoint) - if err != nil { - return nil, err - } - - folder := strconv.Itoa(int(endpoint.ID)) - - if !payload.skipTLSServerVerification { - r := bytes.NewReader(payload.caCert) - // TODO: review the API exposed by the FileService to store - // a file from a byte slice and return the path to the stored file instead - // of using multiple legacy calls (StoreTLSFile, GetPathForTLSFile) here. - err = handler.FileService.StoreTLSFile(folder, portainer.TLSFileCA, r) - if err != nil { - handler.EndpointService.DeleteEndpoint(endpoint.ID) - return nil, err - } - caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA) - endpoint.TLSConfig.TLSCACertPath = caCertPath - } - - if !payload.skipTLSClientVerification { - r := bytes.NewReader(payload.cert) - err = handler.FileService.StoreTLSFile(folder, portainer.TLSFileCert, r) - if err != nil { - handler.EndpointService.DeleteEndpoint(endpoint.ID) - return nil, err - } - certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert) - endpoint.TLSConfig.TLSCertPath = certPath - - r = bytes.NewReader(payload.key) - err = handler.FileService.StoreTLSFile(folder, portainer.TLSFileKey, r) - if err != nil { - handler.EndpointService.DeleteEndpoint(endpoint.ID) - return nil, err - } - keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey) - endpoint.TLSConfig.TLSKeyPath = keyPath - } - - err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) - if err != nil { - return nil, err - } - - return endpoint, nil -} - -func (handler *EndpointHandler) createUnsecuredEndpoint(payload *postEndpointPayload) (*portainer.Endpoint, error) { - endpointType := portainer.DockerEnvironment - - if !strings.HasPrefix(payload.url, "unix://") { - agentOnDockerEnvironment, err := client.ExecutePingOperation(payload.url, nil) - if err != nil { - return nil, err - } - if agentOnDockerEnvironment { - endpointType = portainer.AgentOnDockerEnvironment - } - } - - endpoint := &portainer.Endpoint{ - Name: payload.name, - URL: payload.url, - Type: endpointType, - GroupID: portainer.EndpointGroupID(payload.groupID), - PublicURL: payload.publicURL, - TLSConfig: portainer.TLSConfiguration{ - TLS: false, - }, - AuthorizedUsers: []portainer.UserID{}, - AuthorizedTeams: []portainer.TeamID{}, - Extensions: []portainer.EndpointExtension{}, - } - - err := handler.EndpointService.CreateEndpoint(endpoint) - if err != nil { - return nil, err - } - - return endpoint, nil -} - -func (handler *EndpointHandler) createEndpoint(payload *postEndpointPayload) (*portainer.Endpoint, error) { - if portainer.EndpointType(payload.endpointType) == portainer.AzureEnvironment { - return handler.createAzureEndpoint(payload) - } - - if payload.useTLS { - return handler.createTLSSecuredEndpoint(payload) - } - return handler.createUnsecuredEndpoint(payload) -} - -func convertPostEndpointRequestToPayload(r *http.Request) (*postEndpointPayload, error) { - payload := &postEndpointPayload{} - payload.name = r.FormValue("Name") - - endpointType := r.FormValue("EndpointType") - - if payload.name == "" || endpointType == "" { - return nil, ErrInvalidRequestFormat - } - - parsedType, err := strconv.Atoi(endpointType) - if err != nil { - return nil, err - } - - payload.url = r.FormValue("URL") - payload.endpointType = parsedType - - if portainer.EndpointType(payload.endpointType) != portainer.AzureEnvironment && payload.url == "" { - return nil, ErrInvalidRequestFormat - } - - payload.publicURL = r.FormValue("PublicURL") - - if portainer.EndpointType(payload.endpointType) == portainer.AzureEnvironment { - payload.azureApplicationID = r.FormValue("AzureApplicationID") - payload.azureTenantID = r.FormValue("AzureTenantID") - payload.azureAuthenticationKey = r.FormValue("AzureAuthenticationKey") - - if payload.azureApplicationID == "" || payload.azureTenantID == "" || payload.azureAuthenticationKey == "" { - return nil, ErrInvalidRequestFormat - } - } - - rawGroupID := r.FormValue("GroupID") - if rawGroupID == "" { - payload.groupID = 1 - } else { - groupID, err := strconv.Atoi(rawGroupID) - if err != nil { - return nil, err - } - payload.groupID = groupID - } - - payload.useTLS = r.FormValue("TLS") == "true" - - if payload.useTLS { - payload.skipTLSServerVerification = r.FormValue("TLSSkipVerify") == "true" - payload.skipTLSClientVerification = r.FormValue("TLSSkipClientVerify") == "true" - - if !payload.skipTLSServerVerification { - caCert, err := getUploadedFileContent(r, "TLSCACertFile") - if err != nil { - return nil, err - } - payload.caCert = caCert - } - - if !payload.skipTLSClientVerification { - cert, err := getUploadedFileContent(r, "TLSCertFile") - if err != nil { - return nil, err - } - payload.cert = cert - key, err := getUploadedFileContent(r, "TLSKeyFile") - if err != nil { - return nil, err - } - payload.key = key - } - } - - return payload, nil -} - -// handlePostEndpoints handles POST requests on /endpoints -func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *http.Request) { - if !handler.authorizeEndpointManagement { - httperror.WriteErrorResponse(w, ErrEndpointManagementDisabled, http.StatusServiceUnavailable, handler.Logger) - return - } - - payload, err := convertPostEndpointRequestToPayload(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - endpoint, err := handler.createEndpoint(payload) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, &endpoint, handler.Logger) -} - -// handleGetEndpoint handles GET requests on /endpoints/:id -func (handler *EndpointHandler) handleGetEndpoint(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - endpointID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(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 - } - - encodeJSON(w, endpoint, handler.Logger) -} - -// handlePutEndpointAccess handles PUT requests on /endpoints/:id/access -func (handler *EndpointHandler) handlePutEndpointAccess(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - endpointID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - var req putEndpointAccessRequest - 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 - } - - endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(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 - } - - if req.AuthorizedUsers != nil { - authorizedUserIDs := []portainer.UserID{} - for _, value := range req.AuthorizedUsers { - authorizedUserIDs = append(authorizedUserIDs, portainer.UserID(value)) - } - endpoint.AuthorizedUsers = authorizedUserIDs - } - - if req.AuthorizedTeams != nil { - authorizedTeamIDs := []portainer.TeamID{} - for _, value := range req.AuthorizedTeams { - authorizedTeamIDs = append(authorizedTeamIDs, portainer.TeamID(value)) - } - endpoint.AuthorizedTeams = authorizedTeamIDs - } - - err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -// handlePutEndpoint handles PUT requests on /endpoints/:id -func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http.Request) { - if !handler.authorizeEndpointManagement { - httperror.WriteErrorResponse(w, ErrEndpointManagementDisabled, http.StatusServiceUnavailable, handler.Logger) - return - } - - vars := mux.Vars(r) - id := vars["id"] - - endpointID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - var req putEndpointsRequest - 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 - } - - endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(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 - } - - if req.Name != "" { - endpoint.Name = req.Name - } - - if req.URL != "" { - endpoint.URL = req.URL - } - - if req.PublicURL != "" { - endpoint.PublicURL = req.PublicURL - } - - if req.GroupID != 0 { - endpoint.GroupID = portainer.EndpointGroupID(req.GroupID) - } - - if endpoint.Type == portainer.AzureEnvironment { - if req.AzureApplicationID != "" { - endpoint.AzureCredentials.ApplicationID = req.AzureApplicationID - } - if req.AzureTenantID != "" { - endpoint.AzureCredentials.TenantID = req.AzureTenantID - } - if req.AzureAuthenticationKey != "" { - endpoint.AzureCredentials.AuthenticationKey = req.AzureAuthenticationKey - } - } - - folder := strconv.Itoa(int(endpoint.ID)) - if req.TLS { - endpoint.TLSConfig.TLS = true - endpoint.TLSConfig.TLSSkipVerify = req.TLSSkipVerify - if !req.TLSSkipVerify { - caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA) - endpoint.TLSConfig.TLSCACertPath = caCertPath - } else { - endpoint.TLSConfig.TLSCACertPath = "" - handler.FileService.DeleteTLSFile(folder, portainer.TLSFileCA) - } - - if !req.TLSSkipClientVerify { - certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert) - endpoint.TLSConfig.TLSCertPath = certPath - keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey) - endpoint.TLSConfig.TLSKeyPath = keyPath - } else { - endpoint.TLSConfig.TLSCertPath = "" - handler.FileService.DeleteTLSFile(folder, portainer.TLSFileCert) - endpoint.TLSConfig.TLSKeyPath = "" - handler.FileService.DeleteTLSFile(folder, portainer.TLSFileKey) - } - } else { - endpoint.TLSConfig.TLS = false - endpoint.TLSConfig.TLSSkipVerify = false - endpoint.TLSConfig.TLSCACertPath = "" - endpoint.TLSConfig.TLSCertPath = "" - endpoint.TLSConfig.TLSKeyPath = "" - err = handler.FileService.DeleteTLSFiles(folder) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - } - - _, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -// handleDeleteEndpoint handles DELETE requests on /endpoints/:id -func (handler *EndpointHandler) handleDeleteEndpoint(w http.ResponseWriter, r *http.Request) { - if !handler.authorizeEndpointManagement { - httperror.WriteErrorResponse(w, ErrEndpointManagementDisabled, http.StatusServiceUnavailable, handler.Logger) - return - } - - vars := mux.Vars(r) - id := vars["id"] - - endpointID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(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 - } - - handler.ProxyManager.DeleteProxy(string(endpointID)) - handler.ProxyManager.DeleteExtensionProxies(string(endpointID)) - - err = handler.EndpointService.DeleteEndpoint(portainer.EndpointID(endpointID)) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if endpoint.TLSConfig.TLS { - err = handler.FileService.DeleteTLSFiles(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - } -} diff --git a/api/http/handler/endpoint_group.go b/api/http/handler/endpoint_group.go deleted file mode 100644 index 064f0dff1..000000000 --- a/api/http/handler/endpoint_group.go +++ /dev/null @@ -1,364 +0,0 @@ -package handler - -import ( - "github.com/portainer/portainer" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/security" - - "encoding/json" - "log" - "net/http" - "os" - "strconv" - - "github.com/asaskevich/govalidator" - "github.com/gorilla/mux" -) - -// EndpointGroupHandler represents an HTTP API handler for managing endpoint groups. -type EndpointGroupHandler struct { - *mux.Router - Logger *log.Logger - EndpointService portainer.EndpointService - EndpointGroupService portainer.EndpointGroupService -} - -// NewEndpointGroupHandler returns a new instance of EndpointGroupHandler. -func NewEndpointGroupHandler(bouncer *security.RequestBouncer) *EndpointGroupHandler { - h := &EndpointGroupHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.Handle("/endpoint_groups", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePostEndpointGroups))).Methods(http.MethodPost) - h.Handle("/endpoint_groups", - bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetEndpointGroups))).Methods(http.MethodGet) - h.Handle("/endpoint_groups/{id}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handleGetEndpointGroup))).Methods(http.MethodGet) - h.Handle("/endpoint_groups/{id}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutEndpointGroup))).Methods(http.MethodPut) - h.Handle("/endpoint_groups/{id}/access", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutEndpointGroupAccess))).Methods(http.MethodPut) - h.Handle("/endpoint_groups/{id}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handleDeleteEndpointGroup))).Methods(http.MethodDelete) - - return h -} - -type ( - postEndpointGroupsResponse struct { - ID int `json:"Id"` - } - - postEndpointGroupsRequest struct { - Name string `valid:"required"` - Description string `valid:"-"` - Labels []portainer.Pair `valid:""` - AssociatedEndpoints []portainer.EndpointID `valid:""` - } - - putEndpointGroupAccessRequest struct { - AuthorizedUsers []int `valid:"-"` - AuthorizedTeams []int `valid:"-"` - } - - putEndpointGroupsRequest struct { - Name string `valid:"-"` - Description string `valid:"-"` - Labels []portainer.Pair `valid:""` - AssociatedEndpoints []portainer.EndpointID `valid:""` - } -) - -// handleGetEndpointGroups handles GET requests on /endpoint_groups -func (handler *EndpointGroupHandler) handleGetEndpointGroups(w http.ResponseWriter, r *http.Request) { - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - endpointGroups, err := handler.EndpointGroupService.EndpointGroups() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - filteredEndpointGroups, err := security.FilterEndpointGroups(endpointGroups, securityContext) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, filteredEndpointGroups, handler.Logger) -} - -// handlePostEndpointGroups handles POST requests on /endpoint_groups -func (handler *EndpointGroupHandler) handlePostEndpointGroups(w http.ResponseWriter, r *http.Request) { - var req postEndpointGroupsRequest - 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 - } - - endpointGroup := &portainer.EndpointGroup{ - Name: req.Name, - Description: req.Description, - Labels: req.Labels, - AuthorizedUsers: []portainer.UserID{}, - AuthorizedTeams: []portainer.TeamID{}, - } - - err = handler.EndpointGroupService.CreateEndpointGroup(endpointGroup) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - endpoints, err := handler.EndpointService.Endpoints() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - for _, endpoint := range endpoints { - if endpoint.GroupID == portainer.EndpointGroupID(1) { - err = handler.checkForGroupAssignment(endpoint, endpointGroup.ID, req.AssociatedEndpoints) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - } - } - - encodeJSON(w, &postEndpointGroupsResponse{ID: int(endpointGroup.ID)}, handler.Logger) -} - -// handleGetEndpointGroup handles GET requests on /endpoint_groups/:id -func (handler *EndpointGroupHandler) handleGetEndpointGroup(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - endpointGroupID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - endpointGroup, err := handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) - if err == portainer.ErrEndpointGroupNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, endpointGroup, handler.Logger) -} - -// handlePutEndpointGroupAccess handles PUT requests on /endpoint_groups/:id/access -func (handler *EndpointGroupHandler) handlePutEndpointGroupAccess(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - endpointGroupID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - var req putEndpointGroupAccessRequest - 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 - } - - endpointGroup, err := handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) - if err == portainer.ErrEndpointGroupNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if req.AuthorizedUsers != nil { - authorizedUserIDs := []portainer.UserID{} - for _, value := range req.AuthorizedUsers { - authorizedUserIDs = append(authorizedUserIDs, portainer.UserID(value)) - } - endpointGroup.AuthorizedUsers = authorizedUserIDs - } - - if req.AuthorizedTeams != nil { - authorizedTeamIDs := []portainer.TeamID{} - for _, value := range req.AuthorizedTeams { - authorizedTeamIDs = append(authorizedTeamIDs, portainer.TeamID(value)) - } - endpointGroup.AuthorizedTeams = authorizedTeamIDs - } - - err = handler.EndpointGroupService.UpdateEndpointGroup(endpointGroup.ID, endpointGroup) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -// handlePutEndpointGroup handles PUT requests on /endpoint_groups/:id -func (handler *EndpointGroupHandler) handlePutEndpointGroup(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - endpointGroupID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - var req putEndpointGroupsRequest - 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 - } - - groupID := portainer.EndpointGroupID(endpointGroupID) - endpointGroup, err := handler.EndpointGroupService.EndpointGroup(groupID) - if err == portainer.ErrEndpointGroupNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if req.Name != "" { - endpointGroup.Name = req.Name - } - - if req.Description != "" { - endpointGroup.Description = req.Description - } - - endpointGroup.Labels = req.Labels - - err = handler.EndpointGroupService.UpdateEndpointGroup(endpointGroup.ID, endpointGroup) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - endpoints, err := handler.EndpointService.Endpoints() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - for _, endpoint := range endpoints { - err = handler.updateEndpointGroup(endpoint, groupID, req.AssociatedEndpoints) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - } -} - -func (handler *EndpointGroupHandler) updateEndpointGroup(endpoint portainer.Endpoint, groupID portainer.EndpointGroupID, associatedEndpoints []portainer.EndpointID) error { - if endpoint.GroupID == groupID { - return handler.checkForGroupUnassignment(endpoint, associatedEndpoints) - } else if endpoint.GroupID == portainer.EndpointGroupID(1) { - return handler.checkForGroupAssignment(endpoint, groupID, associatedEndpoints) - } - return nil -} - -func (handler *EndpointGroupHandler) checkForGroupUnassignment(endpoint portainer.Endpoint, associatedEndpoints []portainer.EndpointID) error { - for _, id := range associatedEndpoints { - if id == endpoint.ID { - return nil - } - } - - endpoint.GroupID = portainer.EndpointGroupID(1) - return handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) -} - -func (handler *EndpointGroupHandler) checkForGroupAssignment(endpoint portainer.Endpoint, groupID portainer.EndpointGroupID, associatedEndpoints []portainer.EndpointID) error { - for _, id := range associatedEndpoints { - - if id == endpoint.ID { - endpoint.GroupID = groupID - return handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) - } - } - return nil -} - -// handleDeleteEndpointGroup handles DELETE requests on /endpoint_groups/:id -func (handler *EndpointGroupHandler) handleDeleteEndpointGroup(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - endpointGroupID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - if endpointGroupID == 1 { - httperror.WriteErrorResponse(w, portainer.ErrCannotRemoveDefaultGroup, http.StatusForbidden, handler.Logger) - return - } - - groupID := portainer.EndpointGroupID(endpointGroupID) - _, err = handler.EndpointGroupService.EndpointGroup(groupID) - if err == portainer.ErrEndpointGroupNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - err = handler.EndpointGroupService.DeleteEndpointGroup(portainer.EndpointGroupID(endpointGroupID)) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - endpoints, err := handler.EndpointService.Endpoints() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - for _, endpoint := range endpoints { - if endpoint.GroupID == groupID { - endpoint.GroupID = portainer.EndpointGroupID(1) - err = handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - } - } -} diff --git a/api/http/handler/endpointgroups/endpointgroup_create.go b/api/http/handler/endpointgroups/endpointgroup_create.go new file mode 100644 index 000000000..834122f08 --- /dev/null +++ b/api/http/handler/endpointgroups/endpointgroup_create.go @@ -0,0 +1,63 @@ +package endpointgroups + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type endpointGroupCreatePayload struct { + Name string + Description string + Labels []portainer.Pair + AssociatedEndpoints []portainer.EndpointID +} + +func (payload *endpointGroupCreatePayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Name) { + return portainer.Error("Invalid endpoint group name") + } + return nil +} + +// POST request on /api/endpoint_groups +func (handler *Handler) endpointGroupCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload endpointGroupCreatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + endpointGroup := &portainer.EndpointGroup{ + Name: payload.Name, + Description: payload.Description, + Labels: payload.Labels, + AuthorizedUsers: []portainer.UserID{}, + AuthorizedTeams: []portainer.TeamID{}, + } + + err = handler.EndpointGroupService.CreateEndpointGroup(endpointGroup) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the endpoint group inside the database", err} + } + + endpoints, err := handler.EndpointService.Endpoints() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err} + } + + for _, endpoint := range endpoints { + if endpoint.GroupID == portainer.EndpointGroupID(1) { + err = handler.checkForGroupAssignment(endpoint, endpointGroup.ID, payload.AssociatedEndpoints) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update endpoint", err} + } + } + } + + return response.JSON(w, endpointGroup) +} diff --git a/api/http/handler/endpointgroups/endpointgroup_delete.go b/api/http/handler/endpointgroups/endpointgroup_delete.go new file mode 100644 index 000000000..87c34f94d --- /dev/null +++ b/api/http/handler/endpointgroups/endpointgroup_delete.go @@ -0,0 +1,51 @@ +package endpointgroups + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +// DELETE request on /api/endpoint_groups/:id +func (handler *Handler) endpointGroupDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointGroupID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint group identifier route variable", err} + } + + if endpointGroupID == 1 { + return &httperror.HandlerError{http.StatusForbidden, "Unable to remove the default 'Unassigned' group", portainer.ErrCannotRemoveDefaultGroup} + } + + _, err = handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) + if err == portainer.ErrEndpointGroupNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint group with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint group with the specified identifier inside the database", err} + } + + err = handler.EndpointGroupService.DeleteEndpointGroup(portainer.EndpointGroupID(endpointGroupID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the endpoint group from the database", err} + } + + endpoints, err := handler.EndpointService.Endpoints() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err} + } + + for _, endpoint := range endpoints { + if endpoint.GroupID == portainer.EndpointGroupID(endpointGroupID) { + endpoint.GroupID = portainer.EndpointGroupID(1) + err = handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update endpoint", err} + } + } + } + + return response.Empty(w) +} diff --git a/api/http/handler/endpointgroups/endpointgroup_inspect.go b/api/http/handler/endpointgroups/endpointgroup_inspect.go new file mode 100644 index 000000000..8ff26b28f --- /dev/null +++ b/api/http/handler/endpointgroups/endpointgroup_inspect.go @@ -0,0 +1,27 @@ +package endpointgroups + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +// GET request on /api/endpoint_groups/:id +func (handler *Handler) endpointGroupInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointGroupID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint group identifier route variable", err} + } + + endpointGroup, err := handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) + if err == portainer.ErrEndpointGroupNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint group with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint group with the specified identifier inside the database", err} + } + + return response.JSON(w, endpointGroup) +} diff --git a/api/http/handler/endpointgroups/endpointgroup_list.go b/api/http/handler/endpointgroups/endpointgroup_list.go new file mode 100644 index 000000000..fa7a35ec4 --- /dev/null +++ b/api/http/handler/endpointgroups/endpointgroup_list.go @@ -0,0 +1,25 @@ +package endpointgroups + +import ( + "net/http" + + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// GET request on /api/endpoint_groups +func (handler *Handler) endpointGroupList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointGroups, err := handler.EndpointGroupService.EndpointGroups() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint groups from the database", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + endpointGroups = security.FilterEndpointGroups(endpointGroups, securityContext) + return response.JSON(w, endpointGroups) +} diff --git a/api/http/handler/endpointgroups/endpointgroup_update.go b/api/http/handler/endpointgroups/endpointgroup_update.go new file mode 100644 index 000000000..2f23c0f66 --- /dev/null +++ b/api/http/handler/endpointgroups/endpointgroup_update.go @@ -0,0 +1,71 @@ +package endpointgroups + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type endpointGroupUpdatePayload struct { + Name string + Description string + Labels []portainer.Pair + AssociatedEndpoints []portainer.EndpointID +} + +func (payload *endpointGroupUpdatePayload) Validate(r *http.Request) error { + return nil +} + +// PUT request on /api/endpoint_groups/:id +func (handler *Handler) endpointGroupUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointGroupID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint group identifier route variable", err} + } + + var payload endpointGroupUpdatePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + endpointGroup, err := handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) + if err == portainer.ErrEndpointGroupNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint group with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint group with the specified identifier inside the database", err} + } + + if payload.Name != "" { + endpointGroup.Name = payload.Name + } + + if payload.Description != "" { + endpointGroup.Description = payload.Description + } + + endpointGroup.Labels = payload.Labels + + err = handler.EndpointGroupService.UpdateEndpointGroup(endpointGroup.ID, endpointGroup) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint group changes inside the database", err} + } + + endpoints, err := handler.EndpointService.Endpoints() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err} + } + + for _, endpoint := range endpoints { + err = handler.updateEndpointGroup(endpoint, portainer.EndpointGroupID(endpointGroupID), payload.AssociatedEndpoints) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update endpoint", err} + } + } + + return response.JSON(w, endpointGroup) +} diff --git a/api/http/handler/endpointgroups/endpointgroup_update_access.go b/api/http/handler/endpointgroups/endpointgroup_update_access.go new file mode 100644 index 000000000..a39dbea94 --- /dev/null +++ b/api/http/handler/endpointgroups/endpointgroup_update_access.go @@ -0,0 +1,63 @@ +package endpointgroups + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type endpointGroupUpdateAccessPayload struct { + AuthorizedUsers []int + AuthorizedTeams []int +} + +func (payload *endpointGroupUpdateAccessPayload) Validate(r *http.Request) error { + return nil +} + +// PUT request on /api/endpoint_groups/:id/access +func (handler *Handler) endpointGroupUpdateAccess(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointGroupID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint group identifier route variable", err} + } + + var payload endpointGroupUpdateAccessPayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + endpointGroup, err := handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) + if err == portainer.ErrEndpointGroupNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint group with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint group with the specified identifier inside the database", err} + } + + if payload.AuthorizedUsers != nil { + authorizedUserIDs := []portainer.UserID{} + for _, value := range payload.AuthorizedUsers { + authorizedUserIDs = append(authorizedUserIDs, portainer.UserID(value)) + } + endpointGroup.AuthorizedUsers = authorizedUserIDs + } + + if payload.AuthorizedTeams != nil { + authorizedTeamIDs := []portainer.TeamID{} + for _, value := range payload.AuthorizedTeams { + authorizedTeamIDs = append(authorizedTeamIDs, portainer.TeamID(value)) + } + endpointGroup.AuthorizedTeams = authorizedTeamIDs + } + + err = handler.EndpointGroupService.UpdateEndpointGroup(endpointGroup.ID, endpointGroup) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint group changes inside the database", err} + } + + return response.JSON(w, endpointGroup) +} diff --git a/api/http/handler/endpointgroups/handler.go b/api/http/handler/endpointgroups/handler.go new file mode 100644 index 000000000..f31a40a27 --- /dev/null +++ b/api/http/handler/endpointgroups/handler.go @@ -0,0 +1,69 @@ +package endpointgroups + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" +) + +// Handler is the HTTP handler used to handle endpoint group operations. +type Handler struct { + *mux.Router + EndpointService portainer.EndpointService + EndpointGroupService portainer.EndpointGroupService +} + +// NewHandler creates a handler to manage endpoint group operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/endpoint_groups", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointGroupCreate))).Methods(http.MethodPost) + h.Handle("/endpoint_groups", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointGroupList))).Methods(http.MethodGet) + h.Handle("/endpoint_groups/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointGroupInspect))).Methods(http.MethodGet) + h.Handle("/endpoint_groups/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointGroupUpdate))).Methods(http.MethodPut) + h.Handle("/endpoint_groups/{id}/access", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointGroupUpdateAccess))).Methods(http.MethodPut) + h.Handle("/endpoint_groups/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointGroupDelete))).Methods(http.MethodDelete) + + return h +} + +func (handler *Handler) checkForGroupUnassignment(endpoint portainer.Endpoint, associatedEndpoints []portainer.EndpointID) error { + for _, id := range associatedEndpoints { + if id == endpoint.ID { + return nil + } + } + + endpoint.GroupID = portainer.EndpointGroupID(1) + return handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) +} + +func (handler *Handler) checkForGroupAssignment(endpoint portainer.Endpoint, groupID portainer.EndpointGroupID, associatedEndpoints []portainer.EndpointID) error { + for _, id := range associatedEndpoints { + + if id == endpoint.ID { + endpoint.GroupID = groupID + return handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) + } + } + return nil +} + +func (handler *Handler) updateEndpointGroup(endpoint portainer.Endpoint, groupID portainer.EndpointGroupID, associatedEndpoints []portainer.EndpointID) error { + if endpoint.GroupID == groupID { + return handler.checkForGroupUnassignment(endpoint, associatedEndpoints) + } else if endpoint.GroupID == portainer.EndpointGroupID(1) { + return handler.checkForGroupAssignment(endpoint, groupID, associatedEndpoints) + } + return nil +} diff --git a/api/http/handler/endpointproxy/handler.go b/api/http/handler/endpointproxy/handler.go new file mode 100644 index 000000000..a9543f938 --- /dev/null +++ b/api/http/handler/endpointproxy/handler.go @@ -0,0 +1,50 @@ +package endpointproxy + +import ( + "github.com/gorilla/mux" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/proxy" + "github.com/portainer/portainer/http/security" +) + +// Handler is the HTTP handler used to proxy requests to external APIs. +type Handler struct { + *mux.Router + EndpointService portainer.EndpointService + EndpointGroupService portainer.EndpointGroupService + TeamMembershipService portainer.TeamMembershipService + ProxyManager *proxy.Manager +} + +// NewHandler creates a handler to proxy requests to external APIs. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.PathPrefix("/{id}/azure").Handler( + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToAzureAPI))) + h.PathPrefix("/{id}/docker").Handler( + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToDockerAPI))) + h.PathPrefix("/{id}/extensions/storidge").Handler( + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToStoridgeAPI))) + return h +} + +func (handler *Handler) checkEndpointAccess(endpoint *portainer.Endpoint, userID portainer.UserID) error { + memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(userID) + if err != nil { + return err + } + + group, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID) + if err != nil { + return err + } + + if !security.AuthorizedEndpointAccess(endpoint, group, userID, memberships) { + return portainer.ErrEndpointAccessDenied + } + + return nil +} diff --git a/api/http/handler/endpointproxy/proxy_azure.go b/api/http/handler/endpointproxy/proxy_azure.go new file mode 100644 index 000000000..1cf932393 --- /dev/null +++ b/api/http/handler/endpointproxy/proxy_azure.go @@ -0,0 +1,53 @@ +package endpointproxy + +import ( + "strconv" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/security" + + "net/http" +) + +func (handler *Handler) proxyRequestsToAzureAPI(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrEndpointNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err} + } + + if tokenData.Role != portainer.AdministratorRole { + err = handler.checkEndpointAccess(endpoint, tokenData.ID) + if err != nil && err == portainer.ErrEndpointAccessDenied { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", portainer.ErrEndpointAccessDenied} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify permission to access endpoint", err} + } + } + + var proxy http.Handler + proxy = handler.ProxyManager.GetProxy(string(endpointID)) + if proxy == nil { + proxy, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create proxy", err} + } + } + + id := strconv.Itoa(endpointID) + http.StripPrefix("/"+id+"/azure", proxy).ServeHTTP(w, r) + return nil +} diff --git a/api/http/handler/endpointproxy/proxy_docker.go b/api/http/handler/endpointproxy/proxy_docker.go new file mode 100644 index 000000000..fd73b5c22 --- /dev/null +++ b/api/http/handler/endpointproxy/proxy_docker.go @@ -0,0 +1,53 @@ +package endpointproxy + +import ( + "strconv" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/security" + + "net/http" +) + +func (handler *Handler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrEndpointNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err} + } + + if tokenData.Role != portainer.AdministratorRole { + err = handler.checkEndpointAccess(endpoint, tokenData.ID) + if err != nil && err == portainer.ErrEndpointAccessDenied { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", portainer.ErrEndpointAccessDenied} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify permission to access endpoint", err} + } + } + + var proxy http.Handler + proxy = handler.ProxyManager.GetProxy(string(endpointID)) + if proxy == nil { + proxy, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create proxy", err} + } + } + + id := strconv.Itoa(endpointID) + http.StripPrefix("/"+id+"/docker", proxy).ServeHTTP(w, r) + return nil +} diff --git a/api/http/handler/endpointproxy/proxy_storidge.go b/api/http/handler/endpointproxy/proxy_storidge.go new file mode 100644 index 000000000..8a636205c --- /dev/null +++ b/api/http/handler/endpointproxy/proxy_storidge.go @@ -0,0 +1,66 @@ +package endpointproxy + +import ( + "strconv" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/security" + + "net/http" +) + +func (handler *Handler) proxyRequestsToStoridgeAPI(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrEndpointNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err} + } + + if tokenData.Role != portainer.AdministratorRole { + err = handler.checkEndpointAccess(endpoint, tokenData.ID) + if err != nil && err == portainer.ErrEndpointAccessDenied { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", portainer.ErrEndpointAccessDenied} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify permission to access endpoint", err} + } + } + + var storidgeExtension *portainer.EndpointExtension + for _, extension := range endpoint.Extensions { + if extension.Type == portainer.StoridgeEndpointExtension { + storidgeExtension = &extension + } + } + + if storidgeExtension == nil { + return &httperror.HandlerError{http.StatusServiceUnavailable, "Storidge extension not supported on this endpoint", portainer.ErrEndpointExtensionNotSupported} + } + + 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 { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create extension proxy", err} + } + } + + id := strconv.Itoa(endpointID) + http.StripPrefix("/"+id+"/extensions/storidge", proxy).ServeHTTP(w, r) + return nil +} diff --git a/api/http/handler/endpoints/endpoint_create.go b/api/http/handler/endpoints/endpoint_create.go new file mode 100644 index 000000000..38d2cfd47 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_create.go @@ -0,0 +1,295 @@ +package endpoints + +import ( + "net/http" + "strconv" + "strings" + + "github.com/portainer/portainer" + "github.com/portainer/portainer/crypto" + "github.com/portainer/portainer/http/client" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type endpointCreatePayload struct { + Name string + URL string + EndpointType int + PublicURL string + GroupID int + TLS bool + TLSSkipVerify bool + TLSSkipClientVerify bool + TLSCACertFile []byte + TLSCertFile []byte + TLSKeyFile []byte + AzureApplicationID string + AzureTenantID string + AzureAuthenticationKey string +} + +func (payload *endpointCreatePayload) Validate(r *http.Request) error { + name, err := request.RetrieveMultiPartFormValue(r, "Name", false) + if err != nil { + return portainer.Error("Invalid stack name") + } + payload.Name = name + + endpointType, err := request.RetrieveNumericMultiPartFormValue(r, "EndpointType", false) + if err != nil || endpointType == 0 { + return portainer.Error("Invalid endpoint type value. Value must be one of: 1 (Docker environment), 2 (Agent environment) or 3 (Azure environment)") + } + payload.EndpointType = endpointType + + groupID, _ := request.RetrieveNumericMultiPartFormValue(r, "GroupID", true) + if groupID == 0 { + groupID = 1 + } + payload.GroupID = groupID + + useTLS, _ := request.RetrieveBooleanMultiPartFormValue(r, "TLS", true) + payload.TLS = useTLS + + if payload.TLS { + skipTLSServerVerification, _ := request.RetrieveBooleanMultiPartFormValue(r, "TLSSkipVerify", true) + payload.TLSSkipVerify = skipTLSServerVerification + skipTLSClientVerification, _ := request.RetrieveBooleanMultiPartFormValue(r, "TLSSkipClientVerify", true) + payload.TLSSkipClientVerify = skipTLSClientVerification + + if !payload.TLSSkipVerify { + caCert, err := request.RetrieveMultiPartFormFile(r, "TLSCACertFile") + if err != nil { + return portainer.Error("Invalid CA certificate file. Ensure that the file is uploaded correctly") + } + payload.TLSCACertFile = caCert + } + + if !payload.TLSSkipClientVerify { + cert, err := request.RetrieveMultiPartFormFile(r, "TLSCertFile") + if err != nil { + return portainer.Error("Invalid certificate file. Ensure that the file is uploaded correctly") + } + payload.TLSCertFile = cert + + key, err := request.RetrieveMultiPartFormFile(r, "TLSKeyFile") + if err != nil { + return portainer.Error("Invalid key file. Ensure that the file is uploaded correctly") + } + payload.TLSKeyFile = key + } + } + + switch portainer.EndpointType(payload.EndpointType) { + case portainer.AzureEnvironment: + azureApplicationID, err := request.RetrieveMultiPartFormValue(r, "AzureApplicationID", false) + if err != nil { + return portainer.Error("Invalid Azure application ID") + } + payload.AzureApplicationID = azureApplicationID + + azureTenantID, err := request.RetrieveMultiPartFormValue(r, "AzureTenantID", false) + if err != nil { + return portainer.Error("Invalid Azure tenant ID") + } + payload.AzureTenantID = azureTenantID + + azureAuthenticationKey, err := request.RetrieveMultiPartFormValue(r, "AzureAuthenticationKey", false) + if err != nil { + return portainer.Error("Invalid Azure authentication key") + } + payload.AzureAuthenticationKey = azureAuthenticationKey + default: + url, err := request.RetrieveMultiPartFormValue(r, "URL", false) + if err != nil { + return portainer.Error("Invalid endpoint URL") + } + payload.URL = url + + publicURL, _ := request.RetrieveMultiPartFormValue(r, "PublicURL", true) + payload.PublicURL = publicURL + } + + return nil +} + +// POST request on /api/endpoints +func (handler *Handler) endpointCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + if !handler.authorizeEndpointManagement { + return &httperror.HandlerError{http.StatusServiceUnavailable, "Endpoint management is disabled", ErrEndpointManagementDisabled} + } + + payload := &endpointCreatePayload{} + err := payload.Validate(r) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + endpoint, endpointCreationError := handler.createEndpoint(payload) + if endpointCreationError != nil { + return endpointCreationError + } + + return response.JSON(w, endpoint) +} + +func (handler *Handler) createEndpoint(payload *endpointCreatePayload) (*portainer.Endpoint, *httperror.HandlerError) { + if portainer.EndpointType(payload.EndpointType) == portainer.AzureEnvironment { + return handler.createAzureEndpoint(payload) + } + + if payload.TLS { + return handler.createTLSSecuredEndpoint(payload) + } + return handler.createUnsecuredEndpoint(payload) +} + +func (handler *Handler) createAzureEndpoint(payload *endpointCreatePayload) (*portainer.Endpoint, *httperror.HandlerError) { + credentials := portainer.AzureCredentials{ + ApplicationID: payload.AzureApplicationID, + TenantID: payload.AzureTenantID, + AuthenticationKey: payload.AzureAuthenticationKey, + } + + httpClient := client.NewHTTPClient() + _, err := httpClient.ExecuteAzureAuthenticationRequest(&credentials) + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to authenticate against Azure", err} + } + + endpoint := &portainer.Endpoint{ + Name: payload.Name, + URL: payload.URL, + Type: portainer.AzureEnvironment, + GroupID: portainer.EndpointGroupID(payload.GroupID), + PublicURL: payload.PublicURL, + AuthorizedUsers: []portainer.UserID{}, + AuthorizedTeams: []portainer.TeamID{}, + Extensions: []portainer.EndpointExtension{}, + AzureCredentials: credentials, + } + + err = handler.EndpointService.CreateEndpoint(endpoint) + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint inside the database", err} + } + + return endpoint, nil +} + +func (handler *Handler) createUnsecuredEndpoint(payload *endpointCreatePayload) (*portainer.Endpoint, *httperror.HandlerError) { + endpointType := portainer.DockerEnvironment + + if !strings.HasPrefix(payload.URL, "unix://") { + agentOnDockerEnvironment, err := client.ExecutePingOperation(payload.URL, nil) + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to ping Docker environment", err} + } + if agentOnDockerEnvironment { + endpointType = portainer.AgentOnDockerEnvironment + } + } + + endpoint := &portainer.Endpoint{ + Name: payload.Name, + URL: payload.URL, + Type: endpointType, + GroupID: portainer.EndpointGroupID(payload.GroupID), + PublicURL: payload.PublicURL, + TLSConfig: portainer.TLSConfiguration{ + TLS: false, + }, + AuthorizedUsers: []portainer.UserID{}, + AuthorizedTeams: []portainer.TeamID{}, + Extensions: []portainer.EndpointExtension{}, + } + + err := handler.EndpointService.CreateEndpoint(endpoint) + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint inside the database", err} + } + + return endpoint, nil +} + +func (handler *Handler) createTLSSecuredEndpoint(payload *endpointCreatePayload) (*portainer.Endpoint, *httperror.HandlerError) { + tlsConfig, err := crypto.CreateTLSConfigurationFromBytes(payload.TLSCACertFile, payload.TLSCertFile, payload.TLSKeyFile, payload.TLSSkipClientVerify, payload.TLSSkipVerify) + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to create TLS configuration", err} + } + + agentOnDockerEnvironment, err := client.ExecutePingOperation(payload.URL, tlsConfig) + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to ping Docker environment", err} + } + + endpointType := portainer.DockerEnvironment + if agentOnDockerEnvironment { + endpointType = portainer.AgentOnDockerEnvironment + } + + endpoint := &portainer.Endpoint{ + Name: payload.Name, + URL: payload.URL, + Type: endpointType, + GroupID: portainer.EndpointGroupID(payload.GroupID), + PublicURL: payload.PublicURL, + TLSConfig: portainer.TLSConfiguration{ + TLS: payload.TLS, + TLSSkipVerify: payload.TLSSkipVerify, + }, + AuthorizedUsers: []portainer.UserID{}, + AuthorizedTeams: []portainer.TeamID{}, + Extensions: []portainer.EndpointExtension{}, + } + + err = handler.EndpointService.CreateEndpoint(endpoint) + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint inside the database", err} + } + + filesystemError := handler.storeTLSFiles(endpoint, payload) + if err != nil { + handler.EndpointService.DeleteEndpoint(endpoint.ID) + return nil, filesystemError + } + + err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} + } + + return endpoint, nil +} + +func (handler *Handler) storeTLSFiles(endpoint *portainer.Endpoint, payload *endpointCreatePayload) *httperror.HandlerError { + folder := strconv.Itoa(int(endpoint.ID)) + + if !payload.TLSSkipVerify { + caCertPath, err := handler.FileService.StoreTLSFileFromBytes(folder, portainer.TLSFileCA, payload.TLSCACertFile) + if err != nil { + handler.EndpointService.DeleteEndpoint(endpoint.ID) + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist TLS CA certificate file on disk", err} + } + endpoint.TLSConfig.TLSCACertPath = caCertPath + } + + if !payload.TLSSkipClientVerify { + certPath, err := handler.FileService.StoreTLSFileFromBytes(folder, portainer.TLSFileCert, payload.TLSCertFile) + if err != nil { + handler.EndpointService.DeleteEndpoint(endpoint.ID) + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist TLS certificate file on disk", err} + } + endpoint.TLSConfig.TLSCertPath = certPath + + keyPath, err := handler.FileService.StoreTLSFileFromBytes(folder, portainer.TLSFileKey, payload.TLSKeyFile) + if err != nil { + handler.EndpointService.DeleteEndpoint(endpoint.ID) + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist TLS key file on disk", err} + } + endpoint.TLSConfig.TLSKeyPath = keyPath + } + + return nil +} diff --git a/api/http/handler/endpoints/endpoint_delete.go b/api/http/handler/endpoints/endpoint_delete.go new file mode 100644 index 000000000..4bcb4f14c --- /dev/null +++ b/api/http/handler/endpoints/endpoint_delete.go @@ -0,0 +1,48 @@ +package endpoints + +import ( + "net/http" + "strconv" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +// DELETE request on /api/endpoints/:id +func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + if !handler.authorizeEndpointManagement { + return &httperror.HandlerError{http.StatusServiceUnavailable, "Endpoint management is disabled", ErrEndpointManagementDisabled} + } + + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrEndpointNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + if endpoint.TLSConfig.TLS { + folder := strconv.Itoa(endpointID) + err = handler.FileService.DeleteTLSFiles(folder) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove TLS files from disk", err} + } + } + + err = handler.EndpointService.DeleteEndpoint(portainer.EndpointID(endpointID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove endpoint from the database", err} + } + + handler.ProxyManager.DeleteProxy(string(endpointID)) + handler.ProxyManager.DeleteExtensionProxies(string(endpointID)) + + return response.Empty(w) +} diff --git a/api/http/handler/endpoints/endpoint_extension_add.go b/api/http/handler/endpoints/endpoint_extension_add.go new file mode 100644 index 000000000..25bf1ed71 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_extension_add.go @@ -0,0 +1,73 @@ +package endpoints + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type endpointExtensionAddPayload struct { + Type int + URL string +} + +func (payload *endpointExtensionAddPayload) Validate(r *http.Request) error { + if payload.Type != 1 { + return portainer.Error("Invalid type value. Value must be one of: 1 (Storidge)") + } + if govalidator.IsNull(payload.URL) { + return portainer.Error("Invalid URL") + } + return nil +} + +// POST request on /api/endpoints/:id/extensions +func (handler *Handler) endpointExtensionAdd(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrEndpointNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + var payload endpointExtensionAddPayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + extensionType := portainer.EndpointExtensionType(payload.Type) + + var extension *portainer.EndpointExtension + for _, ext := range endpoint.Extensions { + if ext.Type == extensionType { + extension = &ext + } + } + + if extension != nil { + extension.URL = payload.URL + } else { + extension = &portainer.EndpointExtension{ + Type: extensionType, + URL: payload.URL, + } + endpoint.Extensions = append(endpoint.Extensions, *extension) + } + + err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} + } + + return response.JSON(w, extension) +} diff --git a/api/http/handler/endpoints/endpoint_extension_remove.go b/api/http/handler/endpoints/endpoint_extension_remove.go new file mode 100644 index 000000000..bce4fc478 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_extension_remove.go @@ -0,0 +1,43 @@ +package endpoints + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +// DELETE request on /api/endpoints/:id/extensions/:extensionType +func (handler *Handler) endpointExtensionRemove(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrEndpointNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + extensionType, err := request.RetrieveNumericRouteVariableValue(r, "extensionType") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid extension type route variable", err} + } + + for idx, ext := range endpoint.Extensions { + if ext.Type == portainer.EndpointExtensionType(extensionType) { + endpoint.Extensions = append(endpoint.Extensions[:idx], endpoint.Extensions[idx+1:]...) + } + } + + err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/endpoints/endpoint_inspect.go b/api/http/handler/endpoints/endpoint_inspect.go new file mode 100644 index 000000000..80b5703a9 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_inspect.go @@ -0,0 +1,27 @@ +package endpoints + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +// GET request on /api/endpoints/:id +func (handler *Handler) endpointInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrEndpointNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + return response.JSON(w, endpoint) +} diff --git a/api/http/handler/endpoints/endpoint_list.go b/api/http/handler/endpoints/endpoint_list.go new file mode 100644 index 000000000..13268b48d --- /dev/null +++ b/api/http/handler/endpoints/endpoint_list.go @@ -0,0 +1,34 @@ +package endpoints + +import ( + "net/http" + + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// GET request on /api/endpoints +func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpoints, err := handler.EndpointService.Endpoints() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err} + } + + endpointGroups, err := handler.EndpointGroupService.EndpointGroups() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint groups from the database", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + filteredEndpoints := security.FilterEndpoints(endpoints, endpointGroups, securityContext) + + for _, endpoint := range filteredEndpoints { + hideFields(&endpoint) + } + return response.JSON(w, filteredEndpoints) +} diff --git a/api/http/handler/endpoints/endpoint_update.go b/api/http/handler/endpoints/endpoint_update.go new file mode 100644 index 000000000..a65e9872b --- /dev/null +++ b/api/http/handler/endpoints/endpoint_update.go @@ -0,0 +1,137 @@ +package endpoints + +import ( + "net/http" + "strconv" + + "github.com/portainer/portainer" + "github.com/portainer/portainer/http/client" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type endpointUpdatePayload struct { + Name string + URL string + PublicURL string + GroupID int + TLS bool + TLSSkipVerify bool + TLSSkipClientVerify bool + AzureApplicationID string + AzureTenantID string + AzureAuthenticationKey string +} + +func (payload *endpointUpdatePayload) Validate(r *http.Request) error { + return nil +} + +// PUT request on /api/endpoints/:id +func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + if !handler.authorizeEndpointManagement { + return &httperror.HandlerError{http.StatusServiceUnavailable, "Endpoint management is disabled", ErrEndpointManagementDisabled} + } + + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} + } + + var payload endpointUpdatePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrEndpointNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + if payload.Name != "" { + endpoint.Name = payload.Name + } + + if payload.URL != "" { + endpoint.URL = payload.URL + } + + if payload.PublicURL != "" { + endpoint.PublicURL = payload.PublicURL + } + + if payload.GroupID != 0 { + endpoint.GroupID = portainer.EndpointGroupID(payload.GroupID) + } + + if endpoint.Type == portainer.AzureEnvironment { + credentials := endpoint.AzureCredentials + if payload.AzureApplicationID != "" { + credentials.ApplicationID = payload.AzureApplicationID + } + if payload.AzureTenantID != "" { + credentials.TenantID = payload.AzureTenantID + } + if payload.AzureAuthenticationKey != "" { + credentials.AuthenticationKey = payload.AzureAuthenticationKey + } + + httpClient := client.NewHTTPClient() + _, authErr := httpClient.ExecuteAzureAuthenticationRequest(&credentials) + if authErr != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to authenticate against Azure", authErr} + } + endpoint.AzureCredentials = credentials + } + + folder := strconv.Itoa(endpointID) + if payload.TLS { + endpoint.TLSConfig.TLS = true + endpoint.TLSConfig.TLSSkipVerify = payload.TLSSkipVerify + if !payload.TLSSkipVerify { + caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA) + endpoint.TLSConfig.TLSCACertPath = caCertPath + } else { + endpoint.TLSConfig.TLSCACertPath = "" + handler.FileService.DeleteTLSFile(folder, portainer.TLSFileCA) + } + + if !payload.TLSSkipClientVerify { + certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert) + endpoint.TLSConfig.TLSCertPath = certPath + keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey) + endpoint.TLSConfig.TLSKeyPath = keyPath + } else { + endpoint.TLSConfig.TLSCertPath = "" + handler.FileService.DeleteTLSFile(folder, portainer.TLSFileCert) + endpoint.TLSConfig.TLSKeyPath = "" + handler.FileService.DeleteTLSFile(folder, portainer.TLSFileKey) + } + } else { + endpoint.TLSConfig.TLS = false + endpoint.TLSConfig.TLSSkipVerify = false + endpoint.TLSConfig.TLSCACertPath = "" + endpoint.TLSConfig.TLSCertPath = "" + endpoint.TLSConfig.TLSKeyPath = "" + err = handler.FileService.DeleteTLSFiles(folder) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove TLS files from disk", err} + } + } + + _, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to register HTTP proxy for the endpoint", err} + } + + err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} + } + + return response.JSON(w, endpoint) +} diff --git a/api/http/handler/endpoints/endpoint_update_access.go b/api/http/handler/endpoints/endpoint_update_access.go new file mode 100644 index 000000000..6fb72ba5f --- /dev/null +++ b/api/http/handler/endpoints/endpoint_update_access.go @@ -0,0 +1,67 @@ +package endpoints + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type endpointUpdateAccessPayload struct { + AuthorizedUsers []int + AuthorizedTeams []int +} + +func (payload *endpointUpdateAccessPayload) Validate(r *http.Request) error { + return nil +} + +// PUT request on /api/endpoints/:id/access +func (handler *Handler) endpointUpdateAccess(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + if !handler.authorizeEndpointManagement { + return &httperror.HandlerError{http.StatusServiceUnavailable, "Endpoint management is disabled", ErrEndpointManagementDisabled} + } + + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} + } + + var payload endpointUpdateAccessPayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrEndpointNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + if payload.AuthorizedUsers != nil { + authorizedUserIDs := []portainer.UserID{} + for _, value := range payload.AuthorizedUsers { + authorizedUserIDs = append(authorizedUserIDs, portainer.UserID(value)) + } + endpoint.AuthorizedUsers = authorizedUserIDs + } + + if payload.AuthorizedTeams != nil { + authorizedTeamIDs := []portainer.TeamID{} + for _, value := range payload.AuthorizedTeams { + authorizedTeamIDs = append(authorizedTeamIDs, portainer.TeamID(value)) + } + endpoint.AuthorizedTeams = authorizedTeamIDs + } + + err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} + } + + return response.JSON(w, endpoint) +} diff --git a/api/http/handler/endpoints/handler.go b/api/http/handler/endpoints/handler.go new file mode 100644 index 000000000..6a593479c --- /dev/null +++ b/api/http/handler/endpoints/handler.go @@ -0,0 +1,59 @@ +package endpoints + +import ( + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/proxy" + "github.com/portainer/portainer/http/security" + + "net/http" + + "github.com/gorilla/mux" +) + +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") +) + +func hideFields(endpoint *portainer.Endpoint) { + endpoint.AzureCredentials = portainer.AzureCredentials{} +} + +// Handler is the HTTP handler used to handle endpoint operations. +type Handler struct { + *mux.Router + authorizeEndpointManagement bool + EndpointService portainer.EndpointService + EndpointGroupService portainer.EndpointGroupService + FileService portainer.FileService + ProxyManager *proxy.Manager +} + +// NewHandler creates a handler to manage endpoint operations. +func NewHandler(bouncer *security.RequestBouncer, authorizeEndpointManagement bool) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + authorizeEndpointManagement: authorizeEndpointManagement, + } + + h.Handle("/endpoints", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointCreate))).Methods(http.MethodPost) + h.Handle("/endpoints", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointList))).Methods(http.MethodGet) + h.Handle("/endpoints/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointInspect))).Methods(http.MethodGet) + h.Handle("/endpoints/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointUpdate))).Methods(http.MethodPut) + h.Handle("/endpoints/{id}/access", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointUpdateAccess))).Methods(http.MethodPut) + h.Handle("/endpoints/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointDelete))).Methods(http.MethodDelete) + h.Handle("/endpoints/{id}/extensions", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointExtensionAdd))).Methods(http.MethodPost) + h.Handle("/endpoints/{id}/extensions/{extensionType}", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointExtensionRemove))).Methods(http.MethodDelete) + + return h +} diff --git a/api/http/handler/extensions.go b/api/http/handler/extensions.go deleted file mode 100644 index ecae4678e..000000000 --- a/api/http/handler/extensions.go +++ /dev/null @@ -1,143 +0,0 @@ -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.AuthenticatedAccess(http.HandlerFunc(h.handlePostExtensions))).Methods(http.MethodPost) - h.Handle("/{endpointId}/extensions/{extensionType}", - bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleDeleteExtensions))).Methods(http.MethodDelete) - 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) - - var extension *portainer.EndpointExtension - - for _, ext := range endpoint.Extensions { - if ext.Type == extensionType { - extension = &ext - } - } - - if extension != nil { - extension.URL = req.URL - } else { - 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) -} - -func (handler *ExtensionHandler) handleDeleteExtensions(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 - } - - extType, err := strconv.Atoi(vars["extensionType"]) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - extensionType := portainer.EndpointExtensionType(extType) - - for idx, ext := range endpoint.Extensions { - if ext.Type == extensionType { - endpoint.Extensions = append(endpoint.Extensions[:idx], endpoint.Extensions[idx+1:]...) - } - } - - err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} diff --git a/api/http/handler/extensions/storidge.go b/api/http/handler/extensions/storidge.go deleted file mode 100644 index bee1cd7b3..000000000 --- a/api/http/handler/extensions/storidge.go +++ /dev/null @@ -1,106 +0,0 @@ -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 - EndpointGroupService portainer.EndpointGroupService - 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 { - group, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if !security.AuthorizedEndpointAccess(endpoint, group, 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/file.go b/api/http/handler/file/handler.go similarity index 54% rename from api/http/handler/file.go rename to api/http/handler/file/handler.go index efc7e2b77..15ec1417f 100644 --- a/api/http/handler/file.go +++ b/api/http/handler/file/handler.go @@ -1,24 +1,19 @@ -package handler +package file import ( - "os" - - "log" "net/http" "strings" ) -// FileHandler represents an HTTP API handler for managing static files. -type FileHandler struct { +// Handler represents an HTTP API handler for managing static files. +type Handler struct { http.Handler - Logger *log.Logger } -// NewFileHandler returns a new instance of FileHandler. -func NewFileHandler(assetPublicPath string) *FileHandler { - h := &FileHandler{ +// NewHandler creates a handler to serve static files. +func NewHandler(assetPublicPath string) *Handler { + h := &Handler{ Handler: http.FileServer(http.Dir(assetPublicPath)), - Logger: log.New(os.Stderr, "", log.LstdFlags), } return h } @@ -32,7 +27,7 @@ func isHTML(acceptContent []string) bool { return false } -func (handler *FileHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { +func (handler *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if !isHTML(r.Header["Accept"]) { w.Header().Set("Cache-Control", "max-age=31536000") } else { diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index 70e8f4e8b..84d01ddc5 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -1,53 +1,56 @@ package handler import ( - "encoding/json" - "io/ioutil" - "log" "net/http" "strings" - "github.com/portainer/portainer" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/handler/extensions" + "github.com/portainer/portainer/http/handler/auth" + "github.com/portainer/portainer/http/handler/dockerhub" + "github.com/portainer/portainer/http/handler/endpointgroups" + "github.com/portainer/portainer/http/handler/endpointproxy" + "github.com/portainer/portainer/http/handler/endpoints" + "github.com/portainer/portainer/http/handler/file" + "github.com/portainer/portainer/http/handler/registries" + "github.com/portainer/portainer/http/handler/resourcecontrols" + "github.com/portainer/portainer/http/handler/settings" + "github.com/portainer/portainer/http/handler/stacks" + "github.com/portainer/portainer/http/handler/status" + "github.com/portainer/portainer/http/handler/teammemberships" + "github.com/portainer/portainer/http/handler/teams" + "github.com/portainer/portainer/http/handler/templates" + "github.com/portainer/portainer/http/handler/upload" + "github.com/portainer/portainer/http/handler/users" + "github.com/portainer/portainer/http/handler/websocket" ) // Handler is a collection of all the service handlers. type Handler struct { - AuthHandler *AuthHandler - UserHandler *UserHandler - TeamHandler *TeamHandler - TeamMembershipHandler *TeamMembershipHandler - EndpointHandler *EndpointHandler - EndpointGroupHandler *EndpointGroupHandler - RegistryHandler *RegistryHandler - DockerHubHandler *DockerHubHandler - ExtensionHandler *ExtensionHandler - StoridgeHandler *extensions.StoridgeHandler - ResourceHandler *ResourceHandler - StackHandler *StackHandler - StatusHandler *StatusHandler - SettingsHandler *SettingsHandler - TemplatesHandler *TemplatesHandler - DockerHandler *DockerHandler - AzureHandler *AzureHandler - WebSocketHandler *WebSocketHandler - UploadHandler *UploadHandler - FileHandler *FileHandler -} + AuthHandler *auth.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") - // ErrInvalidQueryFormat defines an error raised when the data sent in the query or the URL is invalid - ErrInvalidQueryFormat = portainer.Error("Invalid query format") -) + DockerHubHandler *dockerhub.Handler + EndpointGroupHandler *endpointgroups.Handler + EndpointHandler *endpoints.Handler + EndpointProxyHandler *endpointproxy.Handler + FileHandler *file.Handler + RegistryHandler *registries.Handler + ResourceControlHandler *resourcecontrols.Handler + SettingsHandler *settings.Handler + StackHandler *stacks.Handler + StatusHandler *status.Handler + TeamMembershipHandler *teammemberships.Handler + TeamHandler *teams.Handler + TemplatesHandler *templates.Handler + UploadHandler *upload.Handler + UserHandler *users.Handler + WebSocketHandler *websocket.Handler + + // StoridgeHandler *extensions.StoridgeHandler + // AzureHandler *azure.Handler + // DockerHandler *docker.Handler +} // ServeHTTP delegates a request to the appropriate subhandler. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - switch { case strings.HasPrefix(r.URL.Path, "/api/auth"): http.StripPrefix("/api", h.AuthHandler).ServeHTTP(w, r) @@ -58,24 +61,22 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { case strings.HasPrefix(r.URL.Path, "/api/endpoints"): switch { case strings.Contains(r.URL.Path, "/docker/"): - http.StripPrefix("/api/endpoints", h.DockerHandler).ServeHTTP(w, r) - case strings.Contains(r.URL.Path, "/stacks"): - http.StripPrefix("/api/endpoints", h.StackHandler).ServeHTTP(w, r) + http.StripPrefix("/api/endpoints", h.EndpointProxyHandler).ServeHTTP(w, r) 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) + http.StripPrefix("/api/endpoints", h.EndpointProxyHandler).ServeHTTP(w, r) case strings.Contains(r.URL.Path, "/azure/"): - http.StripPrefix("/api/endpoints", h.AzureHandler).ServeHTTP(w, r) + http.StripPrefix("/api/endpoints", h.EndpointProxyHandler).ServeHTTP(w, r) default: http.StripPrefix("/api", h.EndpointHandler).ServeHTTP(w, r) } case strings.HasPrefix(r.URL.Path, "/api/registries"): http.StripPrefix("/api", h.RegistryHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/resource_controls"): - http.StripPrefix("/api", h.ResourceHandler).ServeHTTP(w, r) + http.StripPrefix("/api", h.ResourceControlHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/settings"): http.StripPrefix("/api", h.SettingsHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/stacks"): + http.StripPrefix("/api", h.StackHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/status"): http.StripPrefix("/api", h.StatusHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/templates"): @@ -94,27 +95,3 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.FileHandler.ServeHTTP(w, r) } } - -// encodeJSON encodes v to w in JSON format. WriteErrorResponse() is called if encoding fails. -func encodeJSON(w http.ResponseWriter, v interface{}, logger *log.Logger) { - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(v); err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, logger) - } -} - -// getUploadedFileContent retrieve the content of a file uploaded in the request. -// Uses requestParameter as the key to retrieve the file in the request payload. -func getUploadedFileContent(request *http.Request, requestParameter string) ([]byte, error) { - file, _, err := request.FormFile(requestParameter) - if err != nil { - return nil, err - } - defer file.Close() - - fileContent, err := ioutil.ReadAll(file) - if err != nil { - return nil, err - } - return fileContent, nil -} diff --git a/api/http/handler/registries/handler.go b/api/http/handler/registries/handler.go new file mode 100644 index 000000000..7a21561db --- /dev/null +++ b/api/http/handler/registries/handler.go @@ -0,0 +1,43 @@ +package registries + +import ( + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" + + "net/http" + + "github.com/gorilla/mux" +) + +func hideFields(registry *portainer.Registry) { + registry.Password = "" +} + +// Handler is the HTTP handler used to handle registry operations. +type Handler struct { + *mux.Router + RegistryService portainer.RegistryService +} + +// NewHandler creates a handler to manage registry operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + + h.Handle("/registries", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.registryCreate))).Methods(http.MethodPost) + h.Handle("/registries", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.registryList))).Methods(http.MethodGet) + h.Handle("/registries/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.registryInspect))).Methods(http.MethodGet) + h.Handle("/registries/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.registryUpdate))).Methods(http.MethodPut) + h.Handle("/registries/{id}/access", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.registryUpdateAccess))).Methods(http.MethodPut) + h.Handle("/registries/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.registryDelete))).Methods(http.MethodDelete) + + return h +} diff --git a/api/http/handler/registries/registry_create.go b/api/http/handler/registries/registry_create.go new file mode 100644 index 000000000..781f172bf --- /dev/null +++ b/api/http/handler/registries/registry_create.go @@ -0,0 +1,68 @@ +package registries + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type registryCreatePayload struct { + Name string + URL string + Authentication bool + Username string + Password string +} + +func (payload *registryCreatePayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Name) { + return portainer.Error("Invalid registry name") + } + if govalidator.IsNull(payload.URL) { + return portainer.Error("Invalid registry URL") + } + if payload.Authentication && (govalidator.IsNull(payload.Username) || govalidator.IsNull(payload.Password)) { + return portainer.Error("Invalid credentials. Username and password must be specified when authentication is enabled") + } + return nil +} + +func (handler *Handler) registryCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload registryCreatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + registries, err := handler.RegistryService.Registries() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err} + } + for _, r := range registries { + if r.URL == payload.URL { + return &httperror.HandlerError{http.StatusConflict, "A registry with the same URL already exists", portainer.ErrRegistryAlreadyExists} + } + } + + registry := &portainer.Registry{ + Name: payload.Name, + URL: payload.URL, + Authentication: payload.Authentication, + Username: payload.Username, + Password: payload.Password, + AuthorizedUsers: []portainer.UserID{}, + AuthorizedTeams: []portainer.TeamID{}, + } + + err = handler.RegistryService.CreateRegistry(registry) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the registry inside the database", err} + } + + hideFields(registry) + return response.JSON(w, registry) +} diff --git a/api/http/handler/registries/registry_delete.go b/api/http/handler/registries/registry_delete.go new file mode 100644 index 000000000..1fbe5bc04 --- /dev/null +++ b/api/http/handler/registries/registry_delete.go @@ -0,0 +1,32 @@ +package registries + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +// DELETE request on /api/registries/:id +func (handler *Handler) registryDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + registryID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err} + } + + _, err = handler.RegistryService.Registry(portainer.RegistryID(registryID)) + if err == portainer.ErrEndpointGroupNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err} + } + + err = handler.RegistryService.DeleteRegistry(portainer.RegistryID(registryID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the registry from the database", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/registries/registry_inspect.go b/api/http/handler/registries/registry_inspect.go new file mode 100644 index 000000000..d78d1969b --- /dev/null +++ b/api/http/handler/registries/registry_inspect.go @@ -0,0 +1,28 @@ +package registries + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +// GET request on /api/registries/:id +func (handler *Handler) registryInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + registryID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err} + } + + registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID)) + if err == portainer.ErrEndpointGroupNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err} + } + + hideFields(registry) + return response.JSON(w, registry) +} diff --git a/api/http/handler/registries/registry_list.go b/api/http/handler/registries/registry_list.go new file mode 100644 index 000000000..158f8e3fe --- /dev/null +++ b/api/http/handler/registries/registry_list.go @@ -0,0 +1,29 @@ +package registries + +import ( + "net/http" + + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// GET request on /api/registries +func (handler *Handler) registryList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + registries, err := handler.RegistryService.Registries() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + filteredRegistries := security.FilterRegistries(registries, securityContext) + + for _, registry := range filteredRegistries { + hideFields(®istry) + } + return response.JSON(w, registries) +} diff --git a/api/http/handler/registries/registry_update.go b/api/http/handler/registries/registry_update.go new file mode 100644 index 000000000..b1a0167bb --- /dev/null +++ b/api/http/handler/registries/registry_update.go @@ -0,0 +1,82 @@ +package registries + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type registryUpdatePayload struct { + Name string + URL string + Authentication bool + Username string + Password string +} + +func (payload *registryUpdatePayload) Validate(r *http.Request) error { + if payload.Authentication && (govalidator.IsNull(payload.Username) || govalidator.IsNull(payload.Password)) { + return portainer.Error("Invalid credentials. Username and password must be specified when authentication is enabled") + } + return nil +} + +// PUT request on /api/registries/:id +func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + registryID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err} + } + + var payload registryUpdatePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID)) + if err == portainer.ErrEndpointGroupNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err} + } + + registries, err := handler.RegistryService.Registries() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err} + } + for _, r := range registries { + if r.URL == payload.URL && r.ID != registry.ID { + return &httperror.HandlerError{http.StatusConflict, "Another registry with the same URL already exists", portainer.ErrRegistryAlreadyExists} + } + } + + if payload.Name != "" { + registry.Name = payload.Name + } + + if payload.URL != "" { + registry.URL = payload.URL + } + + if payload.Authentication { + registry.Authentication = true + registry.Username = payload.Username + registry.Password = payload.Password + } else { + registry.Authentication = false + registry.Username = "" + registry.Password = "" + } + + err = handler.RegistryService.UpdateRegistry(registry.ID, registry) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist registry changes inside the database", err} + } + + return response.JSON(w, registry) +} diff --git a/api/http/handler/registries/registry_update_access.go b/api/http/handler/registries/registry_update_access.go new file mode 100644 index 000000000..a8234e95c --- /dev/null +++ b/api/http/handler/registries/registry_update_access.go @@ -0,0 +1,63 @@ +package registries + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type registryUpdateAccessPayload struct { + AuthorizedUsers []int + AuthorizedTeams []int +} + +func (payload *registryUpdateAccessPayload) Validate(r *http.Request) error { + return nil +} + +// PUT request on /api/registries/:id/access +func (handler *Handler) registryUpdateAccess(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + registryID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err} + } + + var payload registryUpdateAccessPayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID)) + if err == portainer.ErrEndpointGroupNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err} + } + + if payload.AuthorizedUsers != nil { + authorizedUserIDs := []portainer.UserID{} + for _, value := range payload.AuthorizedUsers { + authorizedUserIDs = append(authorizedUserIDs, portainer.UserID(value)) + } + registry.AuthorizedUsers = authorizedUserIDs + } + + if payload.AuthorizedTeams != nil { + authorizedTeamIDs := []portainer.TeamID{} + for _, value := range payload.AuthorizedTeams { + authorizedTeamIDs = append(authorizedTeamIDs, portainer.TeamID(value)) + } + registry.AuthorizedTeams = authorizedTeamIDs + } + + err = handler.RegistryService.UpdateRegistry(registry.ID, registry) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist registry changes inside the database", err} + } + + return response.JSON(w, registry) +} diff --git a/api/http/handler/registry.go b/api/http/handler/registry.go deleted file mode 100644 index 37cb2c971..000000000 --- a/api/http/handler/registry.go +++ /dev/null @@ -1,320 +0,0 @@ -package handler - -import ( - "github.com/portainer/portainer" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/security" - - "encoding/json" - "log" - "net/http" - "os" - "strconv" - - "github.com/asaskevich/govalidator" - "github.com/gorilla/mux" -) - -// RegistryHandler represents an HTTP API handler for managing Docker registries. -type RegistryHandler struct { - *mux.Router - Logger *log.Logger - RegistryService portainer.RegistryService -} - -// NewRegistryHandler returns a new instance of RegistryHandler. -func NewRegistryHandler(bouncer *security.RequestBouncer) *RegistryHandler { - h := &RegistryHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.Handle("/registries", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePostRegistries))).Methods(http.MethodPost) - h.Handle("/registries", - bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetRegistries))).Methods(http.MethodGet) - h.Handle("/registries/{id}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handleGetRegistry))).Methods(http.MethodGet) - h.Handle("/registries/{id}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutRegistry))).Methods(http.MethodPut) - h.Handle("/registries/{id}/access", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutRegistryAccess))).Methods(http.MethodPut) - h.Handle("/registries/{id}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handleDeleteRegistry))).Methods(http.MethodDelete) - - return h -} - -type ( - postRegistriesRequest struct { - Name string `valid:"required"` - URL string `valid:"required"` - Authentication bool `valid:""` - Username string `valid:""` - Password string `valid:""` - } - - postRegistriesResponse struct { - ID int `json:"Id"` - } - - putRegistryAccessRequest struct { - AuthorizedUsers []int `valid:"-"` - AuthorizedTeams []int `valid:"-"` - } - - putRegistriesRequest struct { - Name string `valid:"required"` - URL string `valid:"required"` - Authentication bool `valid:""` - Username string `valid:""` - Password string `valid:""` - } -) - -// handleGetRegistries handles GET requests on /registries -func (handler *RegistryHandler) handleGetRegistries(w http.ResponseWriter, r *http.Request) { - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - registries, err := handler.RegistryService.Registries() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - filteredRegistries, err := security.FilterRegistries(registries, securityContext) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - for i := range filteredRegistries { - filteredRegistries[i].Password = "" - } - - encodeJSON(w, filteredRegistries, handler.Logger) -} - -// handlePostRegistries handles POST requests on /registries -func (handler *RegistryHandler) handlePostRegistries(w http.ResponseWriter, r *http.Request) { - var req postRegistriesRequest - 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 - } - - registries, err := handler.RegistryService.Registries() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - for _, r := range registries { - if r.URL == req.URL { - httperror.WriteErrorResponse(w, portainer.ErrRegistryAlreadyExists, http.StatusConflict, handler.Logger) - return - } - } - - registry := &portainer.Registry{ - Name: req.Name, - URL: req.URL, - Authentication: req.Authentication, - Username: req.Username, - Password: req.Password, - AuthorizedUsers: []portainer.UserID{}, - AuthorizedTeams: []portainer.TeamID{}, - } - - err = handler.RegistryService.CreateRegistry(registry) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, &postRegistriesResponse{ID: int(registry.ID)}, handler.Logger) -} - -// handleGetRegistry handles GET requests on /registries/:id -func (handler *RegistryHandler) handleGetRegistry(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - registryID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID)) - if err == portainer.ErrRegistryNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - registry.Password = "" - - encodeJSON(w, registry, handler.Logger) -} - -// handlePutRegistryAccess handles PUT requests on /registries/:id/access -func (handler *RegistryHandler) handlePutRegistryAccess(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - registryID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - var req putRegistryAccessRequest - 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 - } - - registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID)) - if err == portainer.ErrRegistryNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if req.AuthorizedUsers != nil { - authorizedUserIDs := []portainer.UserID{} - for _, value := range req.AuthorizedUsers { - authorizedUserIDs = append(authorizedUserIDs, portainer.UserID(value)) - } - registry.AuthorizedUsers = authorizedUserIDs - } - - if req.AuthorizedTeams != nil { - authorizedTeamIDs := []portainer.TeamID{} - for _, value := range req.AuthorizedTeams { - authorizedTeamIDs = append(authorizedTeamIDs, portainer.TeamID(value)) - } - registry.AuthorizedTeams = authorizedTeamIDs - } - - err = handler.RegistryService.UpdateRegistry(registry.ID, registry) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -// handlePutRegistry handles PUT requests on /registries/:id -func (handler *RegistryHandler) handlePutRegistry(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - registryID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - var req putRegistriesRequest - 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 - } - - registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID)) - if err == portainer.ErrRegistryNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - registries, err := handler.RegistryService.Registries() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - for _, r := range registries { - if r.URL == req.URL && r.ID != registry.ID { - httperror.WriteErrorResponse(w, portainer.ErrRegistryAlreadyExists, http.StatusConflict, handler.Logger) - return - } - } - - if req.Name != "" { - registry.Name = req.Name - } - - if req.URL != "" { - registry.URL = req.URL - } - - if req.Authentication { - registry.Authentication = true - registry.Username = req.Username - registry.Password = req.Password - } else { - registry.Authentication = false - registry.Username = "" - registry.Password = "" - } - - err = handler.RegistryService.UpdateRegistry(registry.ID, registry) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -// handleDeleteRegistry handles DELETE requests on /registries/:id -func (handler *RegistryHandler) handleDeleteRegistry(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - registryID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - _, err = handler.RegistryService.Registry(portainer.RegistryID(registryID)) - if err == portainer.ErrRegistryNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - err = handler.RegistryService.DeleteRegistry(portainer.RegistryID(registryID)) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} diff --git a/api/http/handler/resource_control.go b/api/http/handler/resource_control.go deleted file mode 100644 index fa939bb12..000000000 --- a/api/http/handler/resource_control.go +++ /dev/null @@ -1,266 +0,0 @@ -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/security" - - "log" - "net/http" - "os" - - "github.com/gorilla/mux" -) - -// ResourceHandler represents an HTTP API handler for managing resource controls. -type ResourceHandler struct { - *mux.Router - Logger *log.Logger - ResourceControlService portainer.ResourceControlService -} - -// NewResourceHandler returns a new instance of ResourceHandler. -func NewResourceHandler(bouncer *security.RequestBouncer) *ResourceHandler { - h := &ResourceHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.Handle("/resource_controls", - bouncer.RestrictedAccess(http.HandlerFunc(h.handlePostResources))).Methods(http.MethodPost) - h.Handle("/resource_controls/{id}", - bouncer.RestrictedAccess(http.HandlerFunc(h.handlePutResources))).Methods(http.MethodPut) - h.Handle("/resource_controls/{id}", - bouncer.RestrictedAccess(http.HandlerFunc(h.handleDeleteResources))).Methods(http.MethodDelete) - - return h -} - -type ( - postResourcesRequest struct { - ResourceID string `valid:"required"` - Type string `valid:"required"` - AdministratorsOnly bool `valid:"-"` - Users []int `valid:"-"` - Teams []int `valid:"-"` - SubResourceIDs []string `valid:"-"` - } - - putResourcesRequest struct { - AdministratorsOnly bool `valid:"-"` - Users []int `valid:"-"` - Teams []int `valid:"-"` - } -) - -// handlePostResources handles POST requests on /resources -func (handler *ResourceHandler) handlePostResources(w http.ResponseWriter, r *http.Request) { - var req postResourcesRequest - 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 - } - - var resourceControlType portainer.ResourceControlType - switch req.Type { - case "container": - resourceControlType = portainer.ContainerResourceControl - case "service": - resourceControlType = portainer.ServiceResourceControl - case "volume": - resourceControlType = portainer.VolumeResourceControl - case "network": - resourceControlType = portainer.NetworkResourceControl - case "secret": - resourceControlType = portainer.SecretResourceControl - case "stack": - resourceControlType = portainer.StackResourceControl - case "config": - resourceControlType = portainer.ConfigResourceControl - default: - httperror.WriteErrorResponse(w, portainer.ErrInvalidResourceControlType, http.StatusBadRequest, handler.Logger) - return - } - - if len(req.Users) == 0 && len(req.Teams) == 0 && !req.AdministratorsOnly { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - rc, err := handler.ResourceControlService.ResourceControlByResourceID(req.ResourceID) - if err != nil && err != portainer.ErrResourceControlNotFound { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - if rc != nil { - httperror.WriteErrorResponse(w, portainer.ErrResourceControlAlreadyExists, http.StatusConflict, handler.Logger) - return - } - - var userAccesses = make([]portainer.UserResourceAccess, 0) - for _, v := range req.Users { - userAccess := portainer.UserResourceAccess{ - UserID: portainer.UserID(v), - AccessLevel: portainer.ReadWriteAccessLevel, - } - userAccesses = append(userAccesses, userAccess) - } - - var teamAccesses = make([]portainer.TeamResourceAccess, 0) - for _, v := range req.Teams { - teamAccess := portainer.TeamResourceAccess{ - TeamID: portainer.TeamID(v), - AccessLevel: portainer.ReadWriteAccessLevel, - } - teamAccesses = append(teamAccesses, teamAccess) - } - - resourceControl := portainer.ResourceControl{ - ResourceID: req.ResourceID, - SubResourceIDs: req.SubResourceIDs, - Type: resourceControlType, - AdministratorsOnly: req.AdministratorsOnly, - UserAccesses: userAccesses, - TeamAccesses: teamAccesses, - } - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if !security.AuthorizedResourceControlCreation(&resourceControl, securityContext) { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) - return - } - - err = handler.ResourceControlService.CreateResourceControl(&resourceControl) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - return -} - -// handlePutResources handles PUT requests on /resources/:id -func (handler *ResourceHandler) handlePutResources(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - resourceControlID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - var req putResourcesRequest - 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 - } - - resourceControl, err := handler.ResourceControlService.ResourceControl(portainer.ResourceControlID(resourceControlID)) - - if err == portainer.ErrResourceControlNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - resourceControl.AdministratorsOnly = req.AdministratorsOnly - - var userAccesses = make([]portainer.UserResourceAccess, 0) - for _, v := range req.Users { - userAccess := portainer.UserResourceAccess{ - UserID: portainer.UserID(v), - AccessLevel: portainer.ReadWriteAccessLevel, - } - userAccesses = append(userAccesses, userAccess) - } - resourceControl.UserAccesses = userAccesses - - var teamAccesses = make([]portainer.TeamResourceAccess, 0) - for _, v := range req.Teams { - teamAccess := portainer.TeamResourceAccess{ - TeamID: portainer.TeamID(v), - AccessLevel: portainer.ReadWriteAccessLevel, - } - teamAccesses = append(teamAccesses, teamAccess) - } - resourceControl.TeamAccesses = teamAccesses - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if !security.AuthorizedResourceControlUpdate(resourceControl, securityContext) { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) - return - } - - err = handler.ResourceControlService.UpdateResourceControl(resourceControl.ID, resourceControl) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -// handleDeleteResources handles DELETE requests on /resources/:id -func (handler *ResourceHandler) handleDeleteResources(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - resourceControlID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - resourceControl, err := handler.ResourceControlService.ResourceControl(portainer.ResourceControlID(resourceControlID)) - - if err == portainer.ErrResourceControlNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if !security.AuthorizedResourceControlDeletion(resourceControl, securityContext) { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) - return - } - - err = handler.ResourceControlService.DeleteResourceControl(portainer.ResourceControlID(resourceControlID)) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} diff --git a/api/http/handler/resourcecontrols/handler.go b/api/http/handler/resourcecontrols/handler.go new file mode 100644 index 000000000..4ad474f6b --- /dev/null +++ b/api/http/handler/resourcecontrols/handler.go @@ -0,0 +1,31 @@ +package resourcecontrols + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" +) + +// Handler is the HTTP handler used to handle resource control operations. +type Handler struct { + *mux.Router + ResourceControlService portainer.ResourceControlService +} + +// NewHandler creates a handler to manage resource control operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/resource_controls", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.resourceControlCreate))).Methods(http.MethodPost) + h.Handle("/resource_controls/{id}", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.resourceControlUpdate))).Methods(http.MethodPut) + h.Handle("/resource_controls/{id}", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.resourceControlDelete))).Methods(http.MethodDelete) + + return h +} diff --git a/api/http/handler/resourcecontrols/resourcecontrol_create.go b/api/http/handler/resourcecontrols/resourcecontrol_create.go new file mode 100644 index 000000000..4dc41e406 --- /dev/null +++ b/api/http/handler/resourcecontrols/resourcecontrol_create.go @@ -0,0 +1,116 @@ +package resourcecontrols + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +type resourceControlCreatePayload struct { + ResourceID string + Type string + AdministratorsOnly bool + Users []int + Teams []int + SubResourceIDs []string +} + +func (payload *resourceControlCreatePayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.ResourceID) { + return portainer.Error("Invalid resource identifier") + } + + if govalidator.IsNull(payload.Type) { + return portainer.Error("Invalid type") + } + + if len(payload.Users) == 0 && len(payload.Teams) == 0 && !payload.AdministratorsOnly { + return portainer.Error("Invalid resource control declaration. Must specify Users, Teams or AdministratorOnly") + } + return nil +} + +// POST request on /api/resource_controls +func (handler *Handler) resourceControlCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload resourceControlCreatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + var resourceControlType portainer.ResourceControlType + switch payload.Type { + case "container": + resourceControlType = portainer.ContainerResourceControl + case "service": + resourceControlType = portainer.ServiceResourceControl + case "volume": + resourceControlType = portainer.VolumeResourceControl + case "network": + resourceControlType = portainer.NetworkResourceControl + case "secret": + resourceControlType = portainer.SecretResourceControl + case "stack": + resourceControlType = portainer.StackResourceControl + case "config": + resourceControlType = portainer.ConfigResourceControl + default: + return &httperror.HandlerError{http.StatusBadRequest, "Invalid type value. Value must be one of: container, service, volume, network, secret, stack or config", portainer.ErrInvalidResourceControlType} + } + + rc, err := handler.ResourceControlService.ResourceControlByResourceID(payload.ResourceID) + if err != nil && err != portainer.ErrResourceControlNotFound { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve resource controls from the database", err} + } + if rc != nil { + return &httperror.HandlerError{http.StatusConflict, "A resource control is already associated to this resource", portainer.ErrResourceControlAlreadyExists} + } + + var userAccesses = make([]portainer.UserResourceAccess, 0) + for _, v := range payload.Users { + userAccess := portainer.UserResourceAccess{ + UserID: portainer.UserID(v), + AccessLevel: portainer.ReadWriteAccessLevel, + } + userAccesses = append(userAccesses, userAccess) + } + + var teamAccesses = make([]portainer.TeamResourceAccess, 0) + for _, v := range payload.Teams { + teamAccess := portainer.TeamResourceAccess{ + TeamID: portainer.TeamID(v), + AccessLevel: portainer.ReadWriteAccessLevel, + } + teamAccesses = append(teamAccesses, teamAccess) + } + + resourceControl := portainer.ResourceControl{ + ResourceID: payload.ResourceID, + SubResourceIDs: payload.SubResourceIDs, + Type: resourceControlType, + AdministratorsOnly: payload.AdministratorsOnly, + UserAccesses: userAccesses, + TeamAccesses: teamAccesses, + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if !security.AuthorizedResourceControlCreation(&resourceControl, securityContext) { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to create a resource control for the specified resource", portainer.ErrResourceAccessDenied} + } + + err = handler.ResourceControlService.CreateResourceControl(&resourceControl) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the resource control inside the database", err} + } + + return response.JSON(w, resourceControl) +} diff --git a/api/http/handler/resourcecontrols/resourcecontrol_delete.go b/api/http/handler/resourcecontrols/resourcecontrol_delete.go new file mode 100644 index 000000000..47a507e9d --- /dev/null +++ b/api/http/handler/resourcecontrols/resourcecontrol_delete.go @@ -0,0 +1,42 @@ +package resourcecontrols + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// DELETE request on /api/resource_controls/:id +func (handler *Handler) resourceControlDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + resourceControlID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid resource control identifier route variable", err} + } + + resourceControl, err := handler.ResourceControlService.ResourceControl(portainer.ResourceControlID(resourceControlID)) + if err == portainer.ErrResourceControlNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a resource control with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a resource control with with the specified identifier inside the database", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if !security.AuthorizedResourceControlDeletion(resourceControl, securityContext) { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to delete the resource control", portainer.ErrResourceAccessDenied} + } + + err = handler.ResourceControlService.DeleteResourceControl(portainer.ResourceControlID(resourceControlID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the resource control from the database", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/resourcecontrols/resourcecontrol_update.go b/api/http/handler/resourcecontrols/resourcecontrol_update.go new file mode 100644 index 000000000..1a5aa29d8 --- /dev/null +++ b/api/http/handler/resourcecontrols/resourcecontrol_update.go @@ -0,0 +1,83 @@ +package resourcecontrols + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +type resourceControlUpdatePayload struct { + AdministratorsOnly bool + Users []int + Teams []int +} + +func (payload *resourceControlUpdatePayload) Validate(r *http.Request) error { + if len(payload.Users) == 0 && len(payload.Teams) == 0 && !payload.AdministratorsOnly { + return portainer.Error("Invalid resource control declaration. Must specify Users, Teams or AdministratorOnly") + } + return nil +} + +// PUT request on /api/resource_controls/:id +func (handler *Handler) resourceControlUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + resourceControlID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid resource control identifier route variable", err} + } + + var payload resourceControlUpdatePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + resourceControl, err := handler.ResourceControlService.ResourceControl(portainer.ResourceControlID(resourceControlID)) + if err == portainer.ErrResourceControlNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a resource control with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a resource control with with the specified identifier inside the database", err} + } + + resourceControl.AdministratorsOnly = payload.AdministratorsOnly + + var userAccesses = make([]portainer.UserResourceAccess, 0) + for _, v := range payload.Users { + userAccess := portainer.UserResourceAccess{ + UserID: portainer.UserID(v), + AccessLevel: portainer.ReadWriteAccessLevel, + } + userAccesses = append(userAccesses, userAccess) + } + resourceControl.UserAccesses = userAccesses + + var teamAccesses = make([]portainer.TeamResourceAccess, 0) + for _, v := range payload.Teams { + teamAccess := portainer.TeamResourceAccess{ + TeamID: portainer.TeamID(v), + AccessLevel: portainer.ReadWriteAccessLevel, + } + teamAccesses = append(teamAccesses, teamAccess) + } + resourceControl.TeamAccesses = teamAccesses + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if !security.AuthorizedResourceControlUpdate(resourceControl, securityContext) { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update the resource control", portainer.ErrResourceAccessDenied} + } + + err = handler.ResourceControlService.UpdateResourceControl(resourceControl.ID, resourceControl) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist resource control changes inside the database", err} + } + + return response.JSON(w, resourceControl) +} diff --git a/api/http/handler/settings.go b/api/http/handler/settings.go deleted file mode 100644 index 2df31b62e..000000000 --- a/api/http/handler/settings.go +++ /dev/null @@ -1,177 +0,0 @@ -package handler - -import ( - "encoding/json" - - "github.com/asaskevich/govalidator" - "github.com/portainer/portainer" - "github.com/portainer/portainer/filesystem" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/security" - - "log" - "net/http" - "os" - - "github.com/gorilla/mux" -) - -// SettingsHandler represents an HTTP API handler for managing Settings. -type SettingsHandler struct { - *mux.Router - Logger *log.Logger - SettingsService portainer.SettingsService - LDAPService portainer.LDAPService - FileService portainer.FileService -} - -// NewSettingsHandler returns a new instance of OldSettingsHandler. -func NewSettingsHandler(bouncer *security.RequestBouncer) *SettingsHandler { - h := &SettingsHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.Handle("/settings", - bouncer.AdministratorAccess(http.HandlerFunc(h.handleGetSettings))).Methods(http.MethodGet) - h.Handle("/settings", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutSettings))).Methods(http.MethodPut) - h.Handle("/settings/public", - bouncer.PublicAccess(http.HandlerFunc(h.handleGetPublicSettings))).Methods(http.MethodGet) - h.Handle("/settings/authentication/checkLDAP", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutSettingsLDAPCheck))).Methods(http.MethodPut) - - return h -} - -type ( - publicSettingsResponse struct { - LogoURL string `json:"LogoURL"` - DisplayExternalContributors bool `json:"DisplayExternalContributors"` - AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod"` - AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"` - AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"` - } - - putSettingsRequest struct { - TemplatesURL string `valid:"required"` - LogoURL string `valid:""` - BlackListedLabels []portainer.Pair `valid:""` - DisplayExternalContributors bool `valid:""` - AuthenticationMethod int `valid:"required"` - LDAPSettings portainer.LDAPSettings `valid:""` - AllowBindMountsForRegularUsers bool `valid:""` - AllowPrivilegedModeForRegularUsers bool `valid:""` - } - - putSettingsLDAPCheckRequest struct { - LDAPSettings portainer.LDAPSettings `valid:""` - } -) - -// handleGetSettings handles GET requests on /settings -func (handler *SettingsHandler) handleGetSettings(w http.ResponseWriter, r *http.Request) { - settings, err := handler.SettingsService.Settings() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, settings, handler.Logger) - return -} - -// handleGetPublicSettings handles GET requests on /settings/public -func (handler *SettingsHandler) handleGetPublicSettings(w http.ResponseWriter, r *http.Request) { - settings, err := handler.SettingsService.Settings() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - publicSettings := &publicSettingsResponse{ - LogoURL: settings.LogoURL, - DisplayExternalContributors: settings.DisplayExternalContributors, - AuthenticationMethod: settings.AuthenticationMethod, - AllowBindMountsForRegularUsers: settings.AllowBindMountsForRegularUsers, - AllowPrivilegedModeForRegularUsers: settings.AllowPrivilegedModeForRegularUsers, - } - - encodeJSON(w, publicSettings, handler.Logger) - return -} - -// handlePutSettings handles PUT requests on /settings -func (handler *SettingsHandler) handlePutSettings(w http.ResponseWriter, r *http.Request) { - var req putSettingsRequest - 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 - } - - settings := &portainer.Settings{ - TemplatesURL: req.TemplatesURL, - LogoURL: req.LogoURL, - BlackListedLabels: req.BlackListedLabels, - DisplayExternalContributors: req.DisplayExternalContributors, - LDAPSettings: req.LDAPSettings, - AllowBindMountsForRegularUsers: req.AllowBindMountsForRegularUsers, - AllowPrivilegedModeForRegularUsers: req.AllowPrivilegedModeForRegularUsers, - } - - if req.AuthenticationMethod == 1 { - settings.AuthenticationMethod = portainer.AuthenticationInternal - } else if req.AuthenticationMethod == 2 { - settings.AuthenticationMethod = portainer.AuthenticationLDAP - } else { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - if (settings.LDAPSettings.TLSConfig.TLS || settings.LDAPSettings.StartTLS) && !settings.LDAPSettings.TLSConfig.TLSSkipVerify { - caCertPath, _ := handler.FileService.GetPathForTLSFile(filesystem.LDAPStorePath, portainer.TLSFileCA) - settings.LDAPSettings.TLSConfig.TLSCACertPath = caCertPath - } else { - settings.LDAPSettings.TLSConfig.TLSCACertPath = "" - err := handler.FileService.DeleteTLSFiles(filesystem.LDAPStorePath) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - } - } - - err = handler.SettingsService.StoreSettings(settings) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - } -} - -// handlePutSettingsLDAPCheck handles PUT requests on /settings/ldap/check -func (handler *SettingsHandler) handlePutSettingsLDAPCheck(w http.ResponseWriter, r *http.Request) { - var req putSettingsLDAPCheckRequest - 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 - } - - if (req.LDAPSettings.TLSConfig.TLS || req.LDAPSettings.StartTLS) && !req.LDAPSettings.TLSConfig.TLSSkipVerify { - caCertPath, _ := handler.FileService.GetPathForTLSFile(filesystem.LDAPStorePath, portainer.TLSFileCA) - req.LDAPSettings.TLSConfig.TLSCACertPath = caCertPath - } - - err = handler.LDAPService.TestConnectivity(&req.LDAPSettings) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} diff --git a/api/http/handler/settings/handler.go b/api/http/handler/settings/handler.go new file mode 100644 index 000000000..0850ec83e --- /dev/null +++ b/api/http/handler/settings/handler.go @@ -0,0 +1,35 @@ +package settings + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" +) + +// Handler is the HTTP handler used to handle settings operations. +type Handler struct { + *mux.Router + SettingsService portainer.SettingsService + LDAPService portainer.LDAPService + FileService portainer.FileService +} + +// NewHandler creates a handler to manage settings operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/settings", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.settingsInspect))).Methods(http.MethodGet) + h.Handle("/settings", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.settingsUpdate))).Methods(http.MethodPut) + h.Handle("/settings/public", + bouncer.PublicAccess(httperror.LoggerHandler(h.settingsPublic))).Methods(http.MethodGet) + h.Handle("/settings/authentication/checkLDAP", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.settingsLDAPCheck))).Methods(http.MethodPut) + + return h +} diff --git a/api/http/handler/settings/settings_inspect.go b/api/http/handler/settings/settings_inspect.go new file mode 100644 index 000000000..48da08612 --- /dev/null +++ b/api/http/handler/settings/settings_inspect.go @@ -0,0 +1,18 @@ +package settings + +import ( + "net/http" + + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/response" +) + +// GET request on /api/settings +func (handler *Handler) settingsInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + settings, err := handler.SettingsService.Settings() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve the settings from the database", err} + } + + return response.JSON(w, settings) +} diff --git a/api/http/handler/settings/settings_ldap_check.go b/api/http/handler/settings/settings_ldap_check.go new file mode 100644 index 000000000..80d058e33 --- /dev/null +++ b/api/http/handler/settings/settings_ldap_check.go @@ -0,0 +1,40 @@ +package settings + +import ( + "net/http" + + "github.com/portainer/portainer" + "github.com/portainer/portainer/filesystem" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type settingsLDAPCheckPayload struct { + LDAPSettings portainer.LDAPSettings +} + +func (payload *settingsLDAPCheckPayload) Validate(r *http.Request) error { + return nil +} + +// PUT request on /settings/ldap/check +func (handler *Handler) settingsLDAPCheck(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload settingsLDAPCheckPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + if (payload.LDAPSettings.TLSConfig.TLS || payload.LDAPSettings.StartTLS) && !payload.LDAPSettings.TLSConfig.TLSSkipVerify { + caCertPath, _ := handler.FileService.GetPathForTLSFile(filesystem.LDAPStorePath, portainer.TLSFileCA) + payload.LDAPSettings.TLSConfig.TLSCACertPath = caCertPath + } + + err = handler.LDAPService.TestConnectivity(&payload.LDAPSettings) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to connect to LDAP server", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/settings/settings_public.go b/api/http/handler/settings/settings_public.go new file mode 100644 index 000000000..ef76231f0 --- /dev/null +++ b/api/http/handler/settings/settings_public.go @@ -0,0 +1,35 @@ +package settings + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/response" +) + +type publicSettingsResponse struct { + LogoURL string `json:"LogoURL"` + DisplayExternalContributors bool `json:"DisplayExternalContributors"` + AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod"` + AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"` + AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"` +} + +// GET request on /api/settings/public +func (handler *Handler) settingsPublic(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + settings, err := handler.SettingsService.Settings() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve the settings from the database", err} + } + + publicSettings := &publicSettingsResponse{ + LogoURL: settings.LogoURL, + DisplayExternalContributors: settings.DisplayExternalContributors, + AuthenticationMethod: settings.AuthenticationMethod, + AllowBindMountsForRegularUsers: settings.AllowBindMountsForRegularUsers, + AllowPrivilegedModeForRegularUsers: settings.AllowPrivilegedModeForRegularUsers, + } + + return response.JSON(w, publicSettings) +} diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go new file mode 100644 index 000000000..defe9cd6b --- /dev/null +++ b/api/http/handler/settings/settings_update.go @@ -0,0 +1,85 @@ +package settings + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + "github.com/portainer/portainer/filesystem" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type settingsUpdatePayload struct { + TemplatesURL string + LogoURL string + BlackListedLabels []portainer.Pair + DisplayExternalContributors bool + AuthenticationMethod int + LDAPSettings portainer.LDAPSettings + AllowBindMountsForRegularUsers bool + AllowPrivilegedModeForRegularUsers bool +} + +func (payload *settingsUpdatePayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.TemplatesURL) || !govalidator.IsURL(payload.TemplatesURL) { + return portainer.Error("Invalid templates URL. Must correspond to a valid URL format") + } + if payload.AuthenticationMethod == 0 { + return portainer.Error("Invalid authentication method") + } + if payload.AuthenticationMethod != 1 && payload.AuthenticationMethod != 2 { + return portainer.Error("Invalid authentication method value. Value must be one of: 1 (internal) or 2 (LDAP/AD)") + } + if !govalidator.IsNull(payload.LogoURL) && !govalidator.IsURL(payload.LogoURL) { + return portainer.Error("Invalid logo URL. Must correspond to a valid URL format") + } + return nil +} + +// PUT request on /api/settings +func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload settingsUpdatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + settings := &portainer.Settings{ + TemplatesURL: payload.TemplatesURL, + LogoURL: payload.LogoURL, + BlackListedLabels: payload.BlackListedLabels, + DisplayExternalContributors: payload.DisplayExternalContributors, + LDAPSettings: payload.LDAPSettings, + AllowBindMountsForRegularUsers: payload.AllowBindMountsForRegularUsers, + AllowPrivilegedModeForRegularUsers: payload.AllowPrivilegedModeForRegularUsers, + } + + settings.AuthenticationMethod = portainer.AuthenticationMethod(payload.AuthenticationMethod) + tlsError := handler.updateTLS(settings) + if tlsError != nil { + return tlsError + } + + err = handler.SettingsService.StoreSettings(settings) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist settings changes inside the database", err} + } + + return response.JSON(w, settings) +} + +func (handler *Handler) updateTLS(settings *portainer.Settings) *httperror.HandlerError { + if (settings.LDAPSettings.TLSConfig.TLS || settings.LDAPSettings.StartTLS) && !settings.LDAPSettings.TLSConfig.TLSSkipVerify { + caCertPath, _ := handler.FileService.GetPathForTLSFile(filesystem.LDAPStorePath, portainer.TLSFileCA) + settings.LDAPSettings.TLSConfig.TLSCACertPath = caCertPath + } else { + settings.LDAPSettings.TLSConfig.TLSCACertPath = "" + err := handler.FileService.DeleteTLSFiles(filesystem.LDAPStorePath) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove TLS files from disk", err} + } + } + return nil +} diff --git a/api/http/handler/stack.go b/api/http/handler/stack.go deleted file mode 100644 index 5b9e53187..000000000 --- a/api/http/handler/stack.go +++ /dev/null @@ -1,794 +0,0 @@ -package handler - -import ( - "encoding/json" - "path" - "strconv" - "strings" - "sync" - - "github.com/asaskevich/govalidator" - "github.com/portainer/portainer" - "github.com/portainer/portainer/filesystem" - 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" -) - -// StackHandler represents an HTTP API handler for managing Stack. -type StackHandler struct { - stackCreationMutex *sync.Mutex - stackDeletionMutex *sync.Mutex - *mux.Router - Logger *log.Logger - FileService portainer.FileService - GitService portainer.GitService - StackService portainer.StackService - EndpointService portainer.EndpointService - ResourceControlService portainer.ResourceControlService - RegistryService portainer.RegistryService - DockerHubService portainer.DockerHubService - StackManager portainer.StackManager -} - -type stackDeploymentConfig struct { - endpoint *portainer.Endpoint - stack *portainer.Stack - prune bool - dockerhub *portainer.DockerHub - registries []portainer.Registry -} - -// NewStackHandler returns a new instance of StackHandler. -func NewStackHandler(bouncer *security.RequestBouncer) *StackHandler { - h := &StackHandler{ - Router: mux.NewRouter(), - stackCreationMutex: &sync.Mutex{}, - stackDeletionMutex: &sync.Mutex{}, - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.Handle("/{endpointId}/stacks", - bouncer.RestrictedAccess(http.HandlerFunc(h.handlePostStacks))).Methods(http.MethodPost) - h.Handle("/{endpointId}/stacks", - bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetStacks))).Methods(http.MethodGet) - h.Handle("/{endpointId}/stacks/{id}", - bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetStack))).Methods(http.MethodGet) - h.Handle("/{endpointId}/stacks/{id}", - bouncer.RestrictedAccess(http.HandlerFunc(h.handleDeleteStack))).Methods(http.MethodDelete) - h.Handle("/{endpointId}/stacks/{id}", - bouncer.RestrictedAccess(http.HandlerFunc(h.handlePutStack))).Methods(http.MethodPut) - h.Handle("/{endpointId}/stacks/{id}/stackfile", - bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetStackFile))).Methods(http.MethodGet) - return h -} - -type ( - postStacksRequest struct { - Name string `valid:"required"` - SwarmID string `valid:"required"` - StackFileContent string `valid:""` - RepositoryURL string `valid:""` - RepositoryAuthentication bool `valid:""` - RepositoryUsername string `valid:""` - RepositoryPassword string `valid:""` - ComposeFilePathInRepository string `valid:""` - Env []portainer.Pair `valid:""` - } - postStacksResponse struct { - ID string `json:"Id"` - } - getStackFileResponse struct { - StackFileContent string `json:"StackFileContent"` - } - putStackRequest struct { - StackFileContent string `valid:"required"` - Env []portainer.Pair `valid:""` - Prune bool `valid:"-"` - } -) - -// handlePostStacks handles POST requests on /:endpointId/stacks?method= -func (handler *StackHandler) handlePostStacks(w http.ResponseWriter, r *http.Request) { - method := r.FormValue("method") - if method == "" { - httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger) - return - } - - if method == "string" { - handler.handlePostStacksStringMethod(w, r) - } else if method == "repository" { - handler.handlePostStacksRepositoryMethod(w, r) - } else if method == "file" { - handler.handlePostStacksFileMethod(w, r) - } else { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } -} - -func (handler *StackHandler) handlePostStacksStringMethod(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 postStacksRequest - 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 - } - - stackName := req.Name - if stackName == "" { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - stackFileContent := req.StackFileContent - if stackFileContent == "" { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - swarmID := req.SwarmID - if swarmID == "" { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - stacks, err := handler.StackService.Stacks() - if err != nil && err != portainer.ErrStackNotFound { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - for _, stack := range stacks { - if strings.EqualFold(stack.Name, stackName) { - httperror.WriteErrorResponse(w, portainer.ErrStackAlreadyExists, http.StatusConflict, handler.Logger) - return - } - } - - stack := &portainer.Stack{ - ID: portainer.StackID(stackName + "_" + swarmID), - Name: stackName, - SwarmID: swarmID, - EntryPoint: filesystem.ComposeFileDefaultName, - Env: req.Env, - } - - projectPath, err := handler.FileService.StoreStackFileFromString(string(stack.ID), stack.EntryPoint, stackFileContent) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - stack.ProjectPath = projectPath - - err = handler.StackService.CreateStack(stack) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - dockerhub, err := handler.DockerHubService.DockerHub() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - registries, err := handler.RegistryService.Registries() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - filteredRegistries, err := security.FilterRegistries(registries, securityContext) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - config := stackDeploymentConfig{ - stack: stack, - endpoint: endpoint, - dockerhub: dockerhub, - registries: filteredRegistries, - prune: false, - } - err = handler.deployStack(&config) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, &postStacksResponse{ID: string(stack.ID)}, handler.Logger) -} - -func (handler *StackHandler) handlePostStacksRepositoryMethod(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 postStacksRequest - 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 - } - - stackName := req.Name - swarmID := req.SwarmID - - if stackName == "" || swarmID == "" || req.RepositoryURL == "" { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - if req.RepositoryAuthentication && (req.RepositoryUsername == "" || req.RepositoryPassword == "") { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - if req.ComposeFilePathInRepository == "" { - req.ComposeFilePathInRepository = filesystem.ComposeFileDefaultName - } - - stacks, err := handler.StackService.Stacks() - if err != nil && err != portainer.ErrStackNotFound { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - for _, stack := range stacks { - if strings.EqualFold(stack.Name, stackName) { - httperror.WriteErrorResponse(w, portainer.ErrStackAlreadyExists, http.StatusConflict, handler.Logger) - return - } - } - - stack := &portainer.Stack{ - ID: portainer.StackID(stackName + "_" + swarmID), - Name: stackName, - SwarmID: swarmID, - EntryPoint: req.ComposeFilePathInRepository, - Env: req.Env, - } - - projectPath := handler.FileService.GetStackProjectPath(string(stack.ID)) - stack.ProjectPath = projectPath - - // Ensure projectPath is empty - err = handler.FileService.RemoveDirectory(projectPath) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if req.RepositoryAuthentication { - err = handler.GitService.ClonePrivateRepositoryWithBasicAuth(req.RepositoryURL, projectPath, req.RepositoryUsername, req.RepositoryPassword) - } else { - err = handler.GitService.ClonePublicRepository(req.RepositoryURL, projectPath) - } - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - err = handler.StackService.CreateStack(stack) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - dockerhub, err := handler.DockerHubService.DockerHub() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - registries, err := handler.RegistryService.Registries() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - filteredRegistries, err := security.FilterRegistries(registries, securityContext) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - config := stackDeploymentConfig{ - stack: stack, - endpoint: endpoint, - dockerhub: dockerhub, - registries: filteredRegistries, - prune: false, - } - err = handler.deployStack(&config) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, &postStacksResponse{ID: string(stack.ID)}, handler.Logger) -} - -func (handler *StackHandler) handlePostStacksFileMethod(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 - } - - stackName := r.FormValue("Name") - if stackName == "" { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - swarmID := r.FormValue("SwarmID") - if swarmID == "" { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - envParam := r.FormValue("Env") - var env []portainer.Pair - if err = json.Unmarshal([]byte(envParam), &env); err != nil { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - stackFile, _, err := r.FormFile("file") - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - defer stackFile.Close() - - stacks, err := handler.StackService.Stacks() - if err != nil && err != portainer.ErrStackNotFound { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - for _, stack := range stacks { - if strings.EqualFold(stack.Name, stackName) { - httperror.WriteErrorResponse(w, portainer.ErrStackAlreadyExists, http.StatusConflict, handler.Logger) - return - } - } - - stack := &portainer.Stack{ - ID: portainer.StackID(stackName + "_" + swarmID), - Name: stackName, - SwarmID: swarmID, - EntryPoint: filesystem.ComposeFileDefaultName, - Env: env, - } - - projectPath, err := handler.FileService.StoreStackFileFromReader(string(stack.ID), stack.EntryPoint, stackFile) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - stack.ProjectPath = projectPath - - err = handler.StackService.CreateStack(stack) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - dockerhub, err := handler.DockerHubService.DockerHub() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - registries, err := handler.RegistryService.Registries() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - filteredRegistries, err := security.FilterRegistries(registries, securityContext) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - config := stackDeploymentConfig{ - stack: stack, - endpoint: endpoint, - dockerhub: dockerhub, - registries: filteredRegistries, - prune: false, - } - err = handler.deployStack(&config) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, &postStacksResponse{ID: string(stack.ID)}, handler.Logger) -} - -// handleGetStacks handles GET requests on /:endpointId/stacks?swarmId= -func (handler *StackHandler) handleGetStacks(w http.ResponseWriter, r *http.Request) { - swarmID := r.FormValue("swarmId") - - vars := mux.Vars(r) - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - id, err := strconv.Atoi(vars["endpointId"]) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - endpointID := portainer.EndpointID(id) - - _, 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 stacks []portainer.Stack - if swarmID == "" { - stacks, err = handler.StackService.Stacks() - } else { - stacks, err = handler.StackService.StacksBySwarmID(swarmID) - } - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - resourceControls, err := handler.ResourceControlService.ResourceControls() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - filteredStacks := proxy.FilterStacks(stacks, resourceControls, securityContext.IsAdmin, - securityContext.UserID, securityContext.UserMemberships) - - encodeJSON(w, filteredStacks, handler.Logger) -} - -// handleGetStack handles GET requests on /:endpointId/stacks/:id -func (handler *StackHandler) handleGetStack(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - stackID := vars["id"] - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - endpointID, err := strconv.Atoi(vars["endpointId"]) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - _, err = handler.EndpointService.Endpoint(portainer.EndpointID(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 - } - - stack, err := handler.StackService.Stack(portainer.StackID(stackID)) - if err == portainer.ErrStackNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - resourceControl, err := handler.ResourceControlService.ResourceControlByResourceID(stack.Name) - if err != nil && err != portainer.ErrResourceControlNotFound { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - extendedStack := proxy.ExtendedStack{*stack, portainer.ResourceControl{}} - if resourceControl != nil { - if securityContext.IsAdmin || proxy.CanAccessStack(stack, resourceControl, securityContext.UserID, securityContext.UserMemberships) { - extendedStack.ResourceControl = *resourceControl - } else { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) - return - } - } - - encodeJSON(w, extendedStack, handler.Logger) -} - -// handlePutStack handles PUT requests on /:endpointId/stacks/:id -func (handler *StackHandler) handlePutStack(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - stackID := vars["id"] - - endpointID, err := strconv.Atoi(vars["endpointId"]) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(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 - } - - stack, err := handler.StackService.Stack(portainer.StackID(stackID)) - if err == portainer.ErrStackNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - var req putStackRequest - 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 - } - stack.Env = req.Env - - _, err = handler.FileService.StoreStackFileFromString(string(stack.ID), stack.EntryPoint, req.StackFileContent) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - err = handler.StackService.UpdateStack(stack.ID, stack) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - dockerhub, err := handler.DockerHubService.DockerHub() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - registries, err := handler.RegistryService.Registries() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - filteredRegistries, err := security.FilterRegistries(registries, securityContext) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - config := stackDeploymentConfig{ - stack: stack, - endpoint: endpoint, - dockerhub: dockerhub, - registries: filteredRegistries, - prune: req.Prune, - } - err = handler.deployStack(&config) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -// handleGetStackFile handles GET requests on /:endpointId/stacks/:id/stackfile -func (handler *StackHandler) handleGetStackFile(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - stackID := vars["id"] - - endpointID, err := strconv.Atoi(vars["endpointId"]) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - _, err = handler.EndpointService.Endpoint(portainer.EndpointID(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 - } - - stack, err := handler.StackService.Stack(portainer.StackID(stackID)) - if err == portainer.ErrStackNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - stackFileContent, err := handler.FileService.GetFileContent(path.Join(stack.ProjectPath, stack.EntryPoint)) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - encodeJSON(w, &getStackFileResponse{StackFileContent: stackFileContent}, handler.Logger) -} - -// handleDeleteStack handles DELETE requests on /:endpointId/stacks/:id -func (handler *StackHandler) handleDeleteStack(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - stackID := vars["id"] - - endpointID, err := strconv.Atoi(vars["endpointId"]) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(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 - } - - stack, err := handler.StackService.Stack(portainer.StackID(stackID)) - if err == portainer.ErrStackNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - handler.stackDeletionMutex.Lock() - err = handler.StackManager.Remove(stack, endpoint) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - handler.stackDeletionMutex.Unlock() - - err = handler.StackService.DeleteStack(portainer.StackID(stackID)) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - err = handler.FileService.RemoveDirectory(stack.ProjectPath) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -func (handler *StackHandler) deployStack(config *stackDeploymentConfig) error { - handler.stackCreationMutex.Lock() - - handler.StackManager.Login(config.dockerhub, config.registries, config.endpoint) - - err := handler.StackManager.Deploy(config.stack, config.prune, config.endpoint) - if err != nil { - handler.stackCreationMutex.Unlock() - return err - } - - err = handler.StackManager.Logout(config.endpoint) - if err != nil { - handler.stackCreationMutex.Unlock() - return err - } - - handler.stackCreationMutex.Unlock() - return nil -} diff --git a/api/http/handler/stacks/create_compose_stack.go b/api/http/handler/stacks/create_compose_stack.go new file mode 100644 index 000000000..c1321e297 --- /dev/null +++ b/api/http/handler/stacks/create_compose_stack.go @@ -0,0 +1,302 @@ +package stacks + +import ( + "net/http" + "strings" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + "github.com/portainer/portainer/filesystem" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +type composeStackFromFileContentPayload struct { + Name string + StackFileContent string +} + +func (payload *composeStackFromFileContentPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Name) { + return portainer.Error("Invalid stack name") + } + if govalidator.IsNull(payload.StackFileContent) { + return portainer.Error("Invalid stack file content") + } + return nil +} + +func (handler *Handler) createComposeStackFromFileContent(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError { + var payload composeStackFromFileContentPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + stacks, err := handler.StackService.Stacks() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve stacks from the database", err} + } + + for _, stack := range stacks { + if strings.EqualFold(stack.Name, payload.Name) { + return &httperror.HandlerError{http.StatusConflict, "A stack with this name already exists", portainer.ErrStackAlreadyExists} + } + } + + stackIdentifier := buildStackIdentifier(payload.Name, endpoint.ID) + stack := &portainer.Stack{ + ID: portainer.StackID(stackIdentifier), + Name: payload.Name, + Type: portainer.DockerComposeStack, + EndpointID: endpoint.ID, + EntryPoint: filesystem.ComposeFileDefaultName, + } + + projectPath, err := handler.FileService.StoreStackFileFromBytes(string(stack.ID), stack.EntryPoint, []byte(payload.StackFileContent)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist Compose file on disk", err} + } + stack.ProjectPath = projectPath + + doCleanUp := true + defer handler.cleanUp(stack, &doCleanUp) + + config, configErr := handler.createComposeDeployConfig(r, stack, endpoint) + if configErr != nil { + return configErr + } + + err = handler.deployComposeStack(config) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + } + + err = handler.StackService.CreateStack(stack) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err} + } + + doCleanUp = false + return response.JSON(w, stack) +} + +type composeStackFromGitRepositoryPayload struct { + Name string + RepositoryURL string + RepositoryAuthentication bool + RepositoryUsername string + RepositoryPassword string + ComposeFilePathInRepository string +} + +func (payload *composeStackFromGitRepositoryPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Name) { + return portainer.Error("Invalid stack name") + } + if govalidator.IsNull(payload.RepositoryURL) || !govalidator.IsURL(payload.RepositoryURL) { + return portainer.Error("Invalid repository URL. Must correspond to a valid URL format") + } + if payload.RepositoryAuthentication && (govalidator.IsNull(payload.RepositoryUsername) || govalidator.IsNull(payload.RepositoryPassword)) { + return portainer.Error("Invalid repository credentials. Username and password must be specified when authentication is enabled") + } + if govalidator.IsNull(payload.ComposeFilePathInRepository) { + payload.ComposeFilePathInRepository = filesystem.ComposeFileDefaultName + } + return nil +} + +func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError { + var payload composeStackFromGitRepositoryPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + stacks, err := handler.StackService.Stacks() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve stacks from the database", err} + } + + for _, stack := range stacks { + if strings.EqualFold(stack.Name, payload.Name) { + return &httperror.HandlerError{http.StatusConflict, "A stack with this name already exists", portainer.ErrStackAlreadyExists} + } + } + + stackIdentifier := buildStackIdentifier(payload.Name, endpoint.ID) + stack := &portainer.Stack{ + ID: portainer.StackID(stackIdentifier), + Name: payload.Name, + Type: portainer.DockerComposeStack, + EndpointID: endpoint.ID, + EntryPoint: payload.ComposeFilePathInRepository, + } + + projectPath := handler.FileService.GetStackProjectPath(string(stack.ID)) + stack.ProjectPath = projectPath + + gitCloneParams := &cloneRepositoryParameters{ + url: payload.RepositoryURL, + path: projectPath, + authentication: payload.RepositoryAuthentication, + username: payload.RepositoryUsername, + password: payload.RepositoryPassword, + } + err = handler.cloneGitRepository(gitCloneParams) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to clone git repository", err} + } + + doCleanUp := true + defer handler.cleanUp(stack, &doCleanUp) + + config, configErr := handler.createComposeDeployConfig(r, stack, endpoint) + if configErr != nil { + return configErr + } + + err = handler.deployComposeStack(config) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + } + + err = handler.StackService.CreateStack(stack) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err} + } + + doCleanUp = false + return response.JSON(w, stack) +} + +type composeStackFromFileUploadPayload struct { + Name string + StackFileContent []byte +} + +func (payload *composeStackFromFileUploadPayload) Validate(r *http.Request) error { + name, err := request.RetrieveMultiPartFormValue(r, "Name", false) + if err != nil { + return portainer.Error("Invalid stack name") + } + payload.Name = name + + composeFileContent, err := request.RetrieveMultiPartFormFile(r, "file") + if err != nil { + return portainer.Error("Invalid Compose file. Ensure that the Compose file is uploaded correctly") + } + payload.StackFileContent = composeFileContent + + return nil +} + +func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError { + payload := &composeStackFromFileUploadPayload{} + err := payload.Validate(r) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + stacks, err := handler.StackService.Stacks() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve stacks from the database", err} + } + + for _, stack := range stacks { + if strings.EqualFold(stack.Name, payload.Name) { + return &httperror.HandlerError{http.StatusConflict, "A stack with this name already exists", portainer.ErrStackAlreadyExists} + } + } + + stackIdentifier := buildStackIdentifier(payload.Name, endpoint.ID) + stack := &portainer.Stack{ + ID: portainer.StackID(stackIdentifier), + Name: payload.Name, + Type: portainer.DockerComposeStack, + EndpointID: endpoint.ID, + EntryPoint: filesystem.ComposeFileDefaultName, + } + + projectPath, err := handler.FileService.StoreStackFileFromBytes(string(stack.ID), stack.EntryPoint, []byte(payload.StackFileContent)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist Compose file on disk", err} + } + stack.ProjectPath = projectPath + + doCleanUp := true + defer handler.cleanUp(stack, &doCleanUp) + + config, configErr := handler.createComposeDeployConfig(r, stack, endpoint) + if configErr != nil { + return configErr + } + + err = handler.deployComposeStack(config) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + } + + err = handler.StackService.CreateStack(stack) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err} + } + + doCleanUp = false + return response.JSON(w, stack) +} + +type composeStackDeploymentConfig struct { + stack *portainer.Stack + endpoint *portainer.Endpoint + dockerhub *portainer.DockerHub + registries []portainer.Registry +} + +func (handler *Handler) createComposeDeployConfig(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) (*composeStackDeploymentConfig, *httperror.HandlerError) { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + dockerhub, err := handler.DockerHubService.DockerHub() + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve DockerHub details from the database", err} + } + + registries, err := handler.RegistryService.Registries() + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err} + } + filteredRegistries := security.FilterRegistries(registries, securityContext) + + config := &composeStackDeploymentConfig{ + stack: stack, + endpoint: endpoint, + dockerhub: dockerhub, + registries: filteredRegistries, + } + + return config, nil +} + +// TODO: libcompose uses credentials store into a config.json file to pull images from +// private registries. Right now the only solution is to re-use the embedded Docker binary +// to login/logout, which will generate the required data in the config.json file and then +// clean it. Hence the use of the mutex. +// We should contribute to libcompose to support authentication without using the config.json file. +func (handler *Handler) deployComposeStack(config *composeStackDeploymentConfig) error { + handler.stackCreationMutex.Lock() + defer handler.stackCreationMutex.Unlock() + + handler.SwarmStackManager.Login(config.dockerhub, config.registries, config.endpoint) + + err := handler.ComposeStackManager.Up(config.stack, config.endpoint) + if err != nil { + return err + } + + return handler.SwarmStackManager.Logout(config.endpoint) +} diff --git a/api/http/handler/stacks/create_swarm_stack.go b/api/http/handler/stacks/create_swarm_stack.go new file mode 100644 index 000000000..0a87fb53f --- /dev/null +++ b/api/http/handler/stacks/create_swarm_stack.go @@ -0,0 +1,334 @@ +package stacks + +import ( + "net/http" + "strings" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + "github.com/portainer/portainer/filesystem" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +type swarmStackFromFileContentPayload struct { + Name string + SwarmID string + StackFileContent string + Env []portainer.Pair +} + +func (payload *swarmStackFromFileContentPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Name) { + return portainer.Error("Invalid stack name") + } + if govalidator.IsNull(payload.SwarmID) { + return portainer.Error("Invalid Swarm ID") + } + if govalidator.IsNull(payload.StackFileContent) { + return portainer.Error("Invalid stack file content") + } + return nil +} + +func (handler *Handler) createSwarmStackFromFileContent(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError { + var payload swarmStackFromFileContentPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + stacks, err := handler.StackService.Stacks() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve stacks from the database", err} + } + + for _, stack := range stacks { + if strings.EqualFold(stack.Name, payload.Name) { + return &httperror.HandlerError{http.StatusConflict, "A stack with this name already exists", portainer.ErrStackAlreadyExists} + } + } + + stackIdentifier := buildStackIdentifier(payload.Name, endpoint.ID) + stack := &portainer.Stack{ + ID: portainer.StackID(stackIdentifier), + Name: payload.Name, + Type: portainer.DockerSwarmStack, + SwarmID: payload.SwarmID, + EndpointID: endpoint.ID, + EntryPoint: filesystem.ComposeFileDefaultName, + Env: payload.Env, + } + + projectPath, err := handler.FileService.StoreStackFileFromBytes(string(stack.ID), stack.EntryPoint, []byte(payload.StackFileContent)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist Compose file on disk", err} + } + stack.ProjectPath = projectPath + + doCleanUp := true + defer handler.cleanUp(stack, &doCleanUp) + + config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, false) + if configErr != nil { + return configErr + } + + err = handler.deploySwarmStack(config) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + } + + err = handler.StackService.CreateStack(stack) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err} + } + + doCleanUp = false + return response.JSON(w, stack) +} + +type swarmStackFromGitRepositoryPayload struct { + Name string + SwarmID string + Env []portainer.Pair + RepositoryURL string + RepositoryAuthentication bool + RepositoryUsername string + RepositoryPassword string + ComposeFilePathInRepository string +} + +func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Name) { + return portainer.Error("Invalid stack name") + } + if govalidator.IsNull(payload.SwarmID) { + return portainer.Error("Invalid Swarm ID") + } + if govalidator.IsNull(payload.RepositoryURL) || !govalidator.IsURL(payload.RepositoryURL) { + return portainer.Error("Invalid repository URL. Must correspond to a valid URL format") + } + if payload.RepositoryAuthentication && (govalidator.IsNull(payload.RepositoryUsername) || govalidator.IsNull(payload.RepositoryPassword)) { + return portainer.Error("Invalid repository credentials. Username and password must be specified when authentication is enabled") + } + if govalidator.IsNull(payload.ComposeFilePathInRepository) { + payload.ComposeFilePathInRepository = filesystem.ComposeFileDefaultName + } + return nil +} + +func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError { + var payload swarmStackFromGitRepositoryPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + stacks, err := handler.StackService.Stacks() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve stacks from the database", err} + } + + for _, stack := range stacks { + if strings.EqualFold(stack.Name, payload.Name) { + return &httperror.HandlerError{http.StatusConflict, "A stack with this name already exists", portainer.ErrStackAlreadyExists} + } + } + + stackIdentifier := buildStackIdentifier(payload.Name, endpoint.ID) + stack := &portainer.Stack{ + ID: portainer.StackID(stackIdentifier), + Name: payload.Name, + Type: portainer.DockerSwarmStack, + SwarmID: payload.SwarmID, + EndpointID: endpoint.ID, + EntryPoint: payload.ComposeFilePathInRepository, + Env: payload.Env, + } + + projectPath := handler.FileService.GetStackProjectPath(string(stack.ID)) + stack.ProjectPath = projectPath + + gitCloneParams := &cloneRepositoryParameters{ + url: payload.RepositoryURL, + path: projectPath, + authentication: payload.RepositoryAuthentication, + username: payload.RepositoryUsername, + password: payload.RepositoryPassword, + } + err = handler.cloneGitRepository(gitCloneParams) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to clone git repository", err} + } + + doCleanUp := true + defer handler.cleanUp(stack, &doCleanUp) + + config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, false) + if configErr != nil { + return configErr + } + + err = handler.deploySwarmStack(config) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + } + + err = handler.StackService.CreateStack(stack) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err} + } + + doCleanUp = false + return response.JSON(w, stack) +} + +type swarmStackFromFileUploadPayload struct { + Name string + SwarmID string + StackFileContent []byte + Env []portainer.Pair +} + +func (payload *swarmStackFromFileUploadPayload) Validate(r *http.Request) error { + name, err := request.RetrieveMultiPartFormValue(r, "Name", false) + if err != nil { + return portainer.Error("Invalid stack name") + } + payload.Name = name + + swarmID, err := request.RetrieveMultiPartFormValue(r, "SwarmID", false) + if err != nil { + return portainer.Error("Invalid Swarm ID") + } + payload.SwarmID = swarmID + + composeFileContent, err := request.RetrieveMultiPartFormFile(r, "file") + if err != nil { + return portainer.Error("Invalid Compose file. Ensure that the Compose file is uploaded correctly") + } + payload.StackFileContent = composeFileContent + + var env []portainer.Pair + err = request.RetrieveMultiPartFormJSONValue(r, "Env", &env, true) + if err != nil { + return portainer.Error("Invalid Env parameter") + } + payload.Env = env + return nil +} + +func (handler *Handler) createSwarmStackFromFileUpload(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError { + payload := &swarmStackFromFileUploadPayload{} + err := payload.Validate(r) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + stacks, err := handler.StackService.Stacks() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve stacks from the database", err} + } + + for _, stack := range stacks { + if strings.EqualFold(stack.Name, payload.Name) { + return &httperror.HandlerError{http.StatusConflict, "A stack with this name already exists", portainer.ErrStackAlreadyExists} + } + } + + stackIdentifier := buildStackIdentifier(payload.Name, endpoint.ID) + stack := &portainer.Stack{ + ID: portainer.StackID(stackIdentifier), + Name: payload.Name, + Type: portainer.DockerSwarmStack, + SwarmID: payload.SwarmID, + EndpointID: endpoint.ID, + EntryPoint: filesystem.ComposeFileDefaultName, + Env: payload.Env, + } + + projectPath, err := handler.FileService.StoreStackFileFromBytes(string(stack.ID), stack.EntryPoint, []byte(payload.StackFileContent)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist Compose file on disk", err} + } + stack.ProjectPath = projectPath + + doCleanUp := true + defer handler.cleanUp(stack, &doCleanUp) + + config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, false) + if configErr != nil { + return configErr + } + + err = handler.deploySwarmStack(config) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + } + + err = handler.StackService.CreateStack(stack) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err} + } + + doCleanUp = false + return response.JSON(w, stack) +} + +type swarmStackDeploymentConfig struct { + stack *portainer.Stack + endpoint *portainer.Endpoint + dockerhub *portainer.DockerHub + registries []portainer.Registry + prune bool +} + +func (handler *Handler) createSwarmDeployConfig(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint, prune bool) (*swarmStackDeploymentConfig, *httperror.HandlerError) { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + dockerhub, err := handler.DockerHubService.DockerHub() + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve DockerHub details from the database", err} + } + + registries, err := handler.RegistryService.Registries() + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err} + } + filteredRegistries := security.FilterRegistries(registries, securityContext) + + config := &swarmStackDeploymentConfig{ + stack: stack, + endpoint: endpoint, + dockerhub: dockerhub, + registries: filteredRegistries, + prune: prune, + } + + return config, nil +} + +func (handler *Handler) deploySwarmStack(config *swarmStackDeploymentConfig) error { + handler.stackCreationMutex.Lock() + defer handler.stackCreationMutex.Unlock() + + handler.SwarmStackManager.Login(config.dockerhub, config.registries, config.endpoint) + + err := handler.SwarmStackManager.Deploy(config.stack, config.prune, config.endpoint) + if err != nil { + return err + } + + err = handler.SwarmStackManager.Logout(config.endpoint) + if err != nil { + return err + } + + return nil +} diff --git a/api/http/handler/stacks/git.go b/api/http/handler/stacks/git.go new file mode 100644 index 000000000..1ac62a443 --- /dev/null +++ b/api/http/handler/stacks/git.go @@ -0,0 +1,16 @@ +package stacks + +type cloneRepositoryParameters struct { + url string + path string + authentication bool + username string + password string +} + +func (handler *Handler) cloneGitRepository(parameters *cloneRepositoryParameters) error { + if parameters.authentication { + return handler.GitService.ClonePrivateRepositoryWithBasicAuth(parameters.url, parameters.path, parameters.username, parameters.password) + } + return handler.GitService.ClonePublicRepository(parameters.url, parameters.path) +} diff --git a/api/http/handler/stacks/handler.go b/api/http/handler/stacks/handler.go new file mode 100644 index 000000000..9ce1bab17 --- /dev/null +++ b/api/http/handler/stacks/handler.go @@ -0,0 +1,69 @@ +package stacks + +import ( + "net/http" + "sync" + + "github.com/gorilla/mux" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" +) + +// Handler is the HTTP handler used to handle stack operations. +type Handler struct { + stackCreationMutex *sync.Mutex + stackDeletionMutex *sync.Mutex + *mux.Router + FileService portainer.FileService + GitService portainer.GitService + StackService portainer.StackService + EndpointService portainer.EndpointService + EndpointGroupService portainer.EndpointGroupService + TeamMembershipService portainer.TeamMembershipService + ResourceControlService portainer.ResourceControlService + RegistryService portainer.RegistryService + DockerHubService portainer.DockerHubService + SwarmStackManager portainer.SwarmStackManager + ComposeStackManager portainer.ComposeStackManager +} + +// NewHandler creates a handler to manage stack operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + stackCreationMutex: &sync.Mutex{}, + stackDeletionMutex: &sync.Mutex{}, + } + h.Handle("/stacks", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.stackCreate))).Methods(http.MethodPost) + h.Handle("/stacks", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.stackList))).Methods(http.MethodGet) + h.Handle("/stacks/{id}", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.stackInspect))).Methods(http.MethodGet) + h.Handle("/stacks/{id}", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.stackDelete))).Methods(http.MethodDelete) + h.Handle("/stacks/{id}", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.stackUpdate))).Methods(http.MethodPut) + h.Handle("/stacks/{id}/file", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.stackFile))).Methods(http.MethodGet) + return h +} + +func (handler *Handler) checkEndpointAccess(endpoint *portainer.Endpoint, userID portainer.UserID) error { + memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(userID) + if err != nil { + return err + } + + group, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID) + if err != nil { + return err + } + + if !security.AuthorizedEndpointAccess(endpoint, group, userID, memberships) { + return portainer.ErrEndpointAccessDenied + } + + return nil +} diff --git a/api/http/handler/stacks/stack_create.go b/api/http/handler/stacks/stack_create.go new file mode 100644 index 000000000..dac491f69 --- /dev/null +++ b/api/http/handler/stacks/stack_create.go @@ -0,0 +1,99 @@ +package stacks + +import ( + "net/http" + "strconv" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/security" +) + +func (handler *Handler) cleanUp(stack *portainer.Stack, doCleanUp *bool) error { + if !*doCleanUp { + return nil + } + + handler.FileService.RemoveDirectory(stack.ProjectPath) + return nil +} + +func buildStackIdentifier(stackName string, endpointID portainer.EndpointID) string { + return stackName + "_" + strconv.Itoa(int(endpointID)) +} + +// POST request on /api/stacks?type=&method=&endpointId= +func (handler *Handler) stackCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + stackType, err := request.RetrieveNumericQueryParameter(r, "type", false) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: type", err} + } + + method, err := request.RetrieveQueryParameter(r, "method", false) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: method", err} + } + + endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err} + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrEndpointNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err} + } + + if tokenData.Role != portainer.AdministratorRole { + err = handler.checkEndpointAccess(endpoint, tokenData.ID) + if err != nil && err == portainer.ErrEndpointAccessDenied { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", portainer.ErrEndpointAccessDenied} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify permission to access endpoint", err} + } + } + + switch portainer.StackType(stackType) { + case portainer.DockerSwarmStack: + return handler.createSwarmStack(w, r, method, endpoint) + case portainer.DockerComposeStack: + return handler.createComposeStack(w, r, method, endpoint) + } + + return &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: type. Value must be one of: 1 (Swarm stack) or 2 (Compose stack)", request.ErrInvalidQueryParameter} +} + +func (handler *Handler) createComposeStack(w http.ResponseWriter, r *http.Request, method string, endpoint *portainer.Endpoint) *httperror.HandlerError { + + switch method { + case "string": + return handler.createComposeStackFromFileContent(w, r, endpoint) + case "repository": + return handler.createComposeStackFromGitRepository(w, r, endpoint) + case "file": + return handler.createComposeStackFromFileUpload(w, r, endpoint) + } + + return &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: method. Value must be one of: string, repository or file", request.ErrInvalidQueryParameter} +} + +func (handler *Handler) createSwarmStack(w http.ResponseWriter, r *http.Request, method string, endpoint *portainer.Endpoint) *httperror.HandlerError { + switch method { + case "string": + return handler.createSwarmStackFromFileContent(w, r, endpoint) + case "repository": + return handler.createSwarmStackFromGitRepository(w, r, endpoint) + case "file": + return handler.createSwarmStackFromFileUpload(w, r, endpoint) + } + + return &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: method. Value must be one of: string, repository or file", request.ErrInvalidQueryParameter} +} diff --git a/api/http/handler/stacks/stack_delete.go b/api/http/handler/stacks/stack_delete.go new file mode 100644 index 000000000..4deed5612 --- /dev/null +++ b/api/http/handler/stacks/stack_delete.go @@ -0,0 +1,127 @@ +package stacks + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/proxy" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// DELETE request on /api/stacks/:id?external=&endpointId= +func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + stackID, err := request.RetrieveRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err} + } + + externalStack, _ := request.RetrieveBooleanQueryParameter(r, "external", true) + if externalStack { + return handler.deleteExternalStack(r, w, stackID) + } + + stack, err := handler.StackService.Stack(portainer.StackID(stackID)) + if err == portainer.ErrStackNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a stack with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err} + } + + resourceControl, err := handler.ResourceControlService.ResourceControlByResourceID(stack.Name) + if err != nil && err != portainer.ErrResourceControlNotFound { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if resourceControl != nil { + if !securityContext.IsAdmin && !proxy.CanAccessStack(stack, resourceControl, securityContext.UserID, securityContext.UserMemberships) { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", portainer.ErrResourceAccessDenied} + } + } + + endpoint, err := handler.EndpointService.Endpoint(stack.EndpointID) + if err == portainer.ErrEndpointNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} + } + + err = handler.deleteStack(stack, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + } + + err = handler.StackService.DeleteStack(portainer.StackID(stackID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the stack from the database", err} + } + + err = handler.FileService.RemoveDirectory(stack.ProjectPath) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove stack files from disk", err} + } + + return response.Empty(w) +} + +func (handler *Handler) deleteExternalStack(r *http.Request, w http.ResponseWriter, stackName string) *httperror.HandlerError { + stack, err := handler.StackService.StackByName(stackName) + if err != nil && err != portainer.ErrStackNotFound { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to check for stack existence inside the database", err} + } + if stack != nil { + return &httperror.HandlerError{http.StatusBadRequest, "A stack with this name exists inside the database. Cannot use external delete method", portainer.ErrStackNotExternal} + } + + endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err} + } + + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrEndpointNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err} + } + + if tokenData.Role != portainer.AdministratorRole { + err = handler.checkEndpointAccess(endpoint, tokenData.ID) + if err != nil && err == portainer.ErrEndpointAccessDenied { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", portainer.ErrEndpointAccessDenied} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify permission to access endpoint", err} + } + } + + stack = &portainer.Stack{ + Name: stackName, + Type: portainer.DockerSwarmStack, + } + + err = handler.deleteStack(stack, endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to delete stack", err} + } + + return response.Empty(w) +} + +func (handler *Handler) deleteStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error { + if stack.Type == portainer.DockerSwarmStack { + return handler.SwarmStackManager.Remove(stack, endpoint) + } + return handler.ComposeStackManager.Down(stack, endpoint) +} diff --git a/api/http/handler/stacks/stack_file.go b/api/http/handler/stacks/stack_file.go new file mode 100644 index 000000000..b476a2e47 --- /dev/null +++ b/api/http/handler/stacks/stack_file.go @@ -0,0 +1,58 @@ +package stacks + +import ( + "net/http" + "path" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/proxy" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +type stackFileResponse struct { + StackFileContent string `json:"StackFileContent"` +} + +// GET request on /api/stacks/:id/file +func (handler *Handler) stackFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + stackID, err := request.RetrieveRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err} + } + + stack, err := handler.StackService.Stack(portainer.StackID(stackID)) + if err == portainer.ErrStackNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a stack with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err} + } + + resourceControl, err := handler.ResourceControlService.ResourceControlByResourceID(stack.Name) + if err != nil && err != portainer.ErrResourceControlNotFound { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + extendedStack := proxy.ExtendedStack{*stack, portainer.ResourceControl{}} + if resourceControl != nil { + if securityContext.IsAdmin || proxy.CanAccessStack(stack, resourceControl, securityContext.UserID, securityContext.UserMemberships) { + extendedStack.ResourceControl = *resourceControl + } else { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", portainer.ErrResourceAccessDenied} + } + } + + stackFileContent, err := handler.FileService.GetFileContent(path.Join(stack.ProjectPath, stack.EntryPoint)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Compose file from disk", err} + } + + return response.JSON(w, &stackFileResponse{StackFileContent: stackFileContent}) +} diff --git a/api/http/handler/stacks/stack_inspect.go b/api/http/handler/stacks/stack_inspect.go new file mode 100644 index 000000000..66a94a601 --- /dev/null +++ b/api/http/handler/stacks/stack_inspect.go @@ -0,0 +1,48 @@ +package stacks + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/proxy" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// GET request on /api/stacks/:id +func (handler *Handler) stackInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + stackID, err := request.RetrieveRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err} + } + + stack, err := handler.StackService.Stack(portainer.StackID(stackID)) + if err == portainer.ErrStackNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a stack with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err} + } + + resourceControl, err := handler.ResourceControlService.ResourceControlByResourceID(stack.Name) + if err != nil && err != portainer.ErrResourceControlNotFound { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + extendedStack := proxy.ExtendedStack{*stack, portainer.ResourceControl{}} + if resourceControl != nil { + if securityContext.IsAdmin || proxy.CanAccessStack(stack, resourceControl, securityContext.UserID, securityContext.UserMemberships) { + extendedStack.ResourceControl = *resourceControl + } else { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", portainer.ErrResourceAccessDenied} + } + } + + return response.JSON(w, extendedStack) +} diff --git a/api/http/handler/stacks/stack_list.go b/api/http/handler/stacks/stack_list.go new file mode 100644 index 000000000..fc4732e06 --- /dev/null +++ b/api/http/handler/stacks/stack_list.go @@ -0,0 +1,65 @@ +package stacks + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/proxy" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +type stackListOperationFilters struct { + SwarmID string `json:"SwarmID"` + EndpointID int `json:"EndpointID"` +} + +// GET request on /api/stacks?(filters=) +func (handler *Handler) stackList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var filters stackListOperationFilters + err := request.RetrieveJSONQueryParameter(r, "filters", &filters, true) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: filters", err} + } + + stacks, err := handler.StackService.Stacks() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve stacks from the database", err} + } + stacks = filterStacks(stacks, &filters) + + resourceControls, err := handler.ResourceControlService.ResourceControls() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve resource controls from the database", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + filteredStacks := proxy.FilterStacks(stacks, resourceControls, securityContext.IsAdmin, + securityContext.UserID, securityContext.UserMemberships) + + return response.JSON(w, filteredStacks) +} + +func filterStacks(stacks []portainer.Stack, filters *stackListOperationFilters) []portainer.Stack { + if filters.EndpointID == 0 && filters.SwarmID == "" { + return stacks + } + + filteredStacks := make([]portainer.Stack, 0, len(stacks)) + for _, stack := range stacks { + if stack.Type == portainer.DockerComposeStack && stack.EndpointID == portainer.EndpointID(filters.EndpointID) { + filteredStacks = append(filteredStacks, stack) + } + if stack.Type == portainer.DockerSwarmStack && stack.SwarmID == filters.SwarmID { + filteredStacks = append(filteredStacks, stack) + } + } + + return filteredStacks +} diff --git a/api/http/handler/stacks/stack_update.go b/api/http/handler/stacks/stack_update.go new file mode 100644 index 000000000..98c25d60f --- /dev/null +++ b/api/http/handler/stacks/stack_update.go @@ -0,0 +1,146 @@ +package stacks + +import ( + "net/http" + + "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/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +type updateComposeStackPayload struct { + StackFileContent string +} + +func (payload *updateComposeStackPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.StackFileContent) { + return portainer.Error("Invalid stack file content") + } + return nil +} + +type updateSwarmStackPayload struct { + StackFileContent string + Env []portainer.Pair + Prune bool +} + +func (payload *updateSwarmStackPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.StackFileContent) { + return portainer.Error("Invalid stack file content") + } + return nil +} + +// PUT request on /api/stacks/:id +func (handler *Handler) stackUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + stackID, err := request.RetrieveRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err} + } + + stack, err := handler.StackService.Stack(portainer.StackID(stackID)) + if err == portainer.ErrStackNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a stack with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err} + } + + resourceControl, err := handler.ResourceControlService.ResourceControlByResourceID(stack.Name) + if err != nil && err != portainer.ErrResourceControlNotFound { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if resourceControl != nil { + if !securityContext.IsAdmin && !proxy.CanAccessStack(stack, resourceControl, securityContext.UserID, securityContext.UserMemberships) { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", portainer.ErrResourceAccessDenied} + } + } + + endpoint, err := handler.EndpointService.Endpoint(stack.EndpointID) + if err == portainer.ErrEndpointNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} + } + + updateError := handler.updateAndDeployStack(r, stack, endpoint) + if updateError != nil { + return updateError + } + + err = handler.StackService.UpdateStack(stack.ID, stack) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack changes inside the database", err} + } + + return response.JSON(w, stack) +} + +func (handler *Handler) updateAndDeployStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError { + if stack.Type == portainer.DockerSwarmStack { + return handler.updateSwarmStack(r, stack, endpoint) + } + return handler.updateComposeStack(r, stack, endpoint) +} + +func (handler *Handler) updateComposeStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError { + var payload updateComposeStackPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + _, err = handler.FileService.StoreStackFileFromBytes(string(stack.ID), stack.EntryPoint, []byte(payload.StackFileContent)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist updated Compose file on disk", err} + } + + config, configErr := handler.createComposeDeployConfig(r, stack, endpoint) + if configErr != nil { + return configErr + } + + err = handler.deployComposeStack(config) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + } + + return nil +} + +func (handler *Handler) updateSwarmStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError { + var payload updateSwarmStackPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + stack.Env = payload.Env + + _, err = handler.FileService.StoreStackFileFromBytes(string(stack.ID), stack.EntryPoint, []byte(payload.StackFileContent)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist updated Compose file on disk", err} + } + + config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, payload.Prune) + if configErr != nil { + return configErr + } + + err = handler.deploySwarmStack(config) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + } + + return nil +} diff --git a/api/http/handler/status.go b/api/http/handler/status.go deleted file mode 100644 index 6bae3c8a7..000000000 --- a/api/http/handler/status.go +++ /dev/null @@ -1,38 +0,0 @@ -package handler - -import ( - "github.com/portainer/portainer" - "github.com/portainer/portainer/http/security" - - "log" - "net/http" - "os" - - "github.com/gorilla/mux" -) - -// StatusHandler represents an HTTP API handler for managing Status. -type StatusHandler struct { - *mux.Router - Logger *log.Logger - Status *portainer.Status -} - -// NewStatusHandler returns a new instance of StatusHandler. -func NewStatusHandler(bouncer *security.RequestBouncer, status *portainer.Status) *StatusHandler { - h := &StatusHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - Status: status, - } - h.Handle("/status", - bouncer.PublicAccess(http.HandlerFunc(h.handleGetStatus))).Methods(http.MethodGet) - - return h -} - -// handleGetStatus handles GET requests on /status -func (handler *StatusHandler) handleGetStatus(w http.ResponseWriter, r *http.Request) { - encodeJSON(w, handler.Status, handler.Logger) - return -} diff --git a/api/http/handler/status/handler.go b/api/http/handler/status/handler.go new file mode 100644 index 000000000..692c64130 --- /dev/null +++ b/api/http/handler/status/handler.go @@ -0,0 +1,28 @@ +package status + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" +) + +// Handler is the HTTP handler used to handle status operations. +type Handler struct { + *mux.Router + Status *portainer.Status +} + +// NewHandler creates a handler to manage status operations. +func NewHandler(bouncer *security.RequestBouncer, status *portainer.Status) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + Status: status, + } + h.Handle("/status", + bouncer.PublicAccess(httperror.LoggerHandler(h.statusInspect))).Methods(http.MethodGet) + + return h +} diff --git a/api/http/handler/status/status_inspect.go b/api/http/handler/status/status_inspect.go new file mode 100644 index 000000000..93d379179 --- /dev/null +++ b/api/http/handler/status/status_inspect.go @@ -0,0 +1,13 @@ +package status + +import ( + "net/http" + + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/response" +) + +// GET request on /api/status +func (handler *Handler) statusInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + return response.JSON(w, handler.Status) +} diff --git a/api/http/handler/team.go b/api/http/handler/team.go deleted file mode 100644 index 1bf90e689..000000000 --- a/api/http/handler/team.go +++ /dev/null @@ -1,262 +0,0 @@ -package handler - -import ( - "strconv" - - "github.com/portainer/portainer" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/security" - - "encoding/json" - "log" - "net/http" - "os" - - "github.com/asaskevich/govalidator" - "github.com/gorilla/mux" -) - -// TeamHandler represents an HTTP API handler for managing teams. -type TeamHandler struct { - *mux.Router - Logger *log.Logger - TeamService portainer.TeamService - TeamMembershipService portainer.TeamMembershipService - ResourceControlService portainer.ResourceControlService -} - -// NewTeamHandler returns a new instance of TeamHandler. -func NewTeamHandler(bouncer *security.RequestBouncer) *TeamHandler { - h := &TeamHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.Handle("/teams", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePostTeams))).Methods(http.MethodPost) - h.Handle("/teams", - bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetTeams))).Methods(http.MethodGet) - h.Handle("/teams/{id}", - bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetTeam))).Methods(http.MethodGet) - h.Handle("/teams/{id}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutTeam))).Methods(http.MethodPut) - h.Handle("/teams/{id}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handleDeleteTeam))).Methods(http.MethodDelete) - h.Handle("/teams/{id}/memberships", - bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetMemberships))).Methods(http.MethodGet) - - return h -} - -type ( - postTeamsRequest struct { - Name string `valid:"required"` - } - - postTeamsResponse struct { - ID int `json:"Id"` - } - - putTeamRequest struct { - Name string `valid:"-"` - } -) - -// handlePostTeams handles POST requests on /teams -func (handler *TeamHandler) handlePostTeams(w http.ResponseWriter, r *http.Request) { - var req postTeamsRequest - 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 - } - - team, err := handler.TeamService.TeamByName(req.Name) - if err != nil && err != portainer.ErrTeamNotFound { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - if team != nil { - httperror.WriteErrorResponse(w, portainer.ErrTeamAlreadyExists, http.StatusConflict, handler.Logger) - return - } - - team = &portainer.Team{ - Name: req.Name, - } - - err = handler.TeamService.CreateTeam(team) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, &postTeamsResponse{ID: int(team.ID)}, handler.Logger) -} - -// handleGetTeams handles GET requests on /teams -func (handler *TeamHandler) handleGetTeams(w http.ResponseWriter, r *http.Request) { - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - teams, err := handler.TeamService.Teams() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - filteredTeams := security.FilterUserTeams(teams, securityContext) - - encodeJSON(w, filteredTeams, handler.Logger) -} - -// handleGetTeam handles GET requests on /teams/:id -func (handler *TeamHandler) handleGetTeam(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - tid, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - teamID := portainer.TeamID(tid) - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if !security.AuthorizedTeamManagement(teamID, securityContext) { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) - return - } - - team, err := handler.TeamService.Team(teamID) - if err == portainer.ErrTeamNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, &team, handler.Logger) -} - -// handlePutTeam handles PUT requests on /teams/:id -func (handler *TeamHandler) handlePutTeam(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - teamID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - var req putTeamRequest - 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 - } - - team, err := handler.TeamService.Team(portainer.TeamID(teamID)) - if err == portainer.ErrTeamNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if req.Name != "" { - team.Name = req.Name - } - - err = handler.TeamService.UpdateTeam(team.ID, team) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -// handleDeleteTeam handles DELETE requests on /teams/:id -func (handler *TeamHandler) handleDeleteTeam(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - teamID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - _, err = handler.TeamService.Team(portainer.TeamID(teamID)) - - if err == portainer.ErrTeamNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - err = handler.TeamService.DeleteTeam(portainer.TeamID(teamID)) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - err = handler.TeamMembershipService.DeleteTeamMembershipByTeamID(portainer.TeamID(teamID)) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -// handleGetMemberships handles GET requests on /teams/:id/memberships -func (handler *TeamHandler) handleGetMemberships(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - tid, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - teamID := portainer.TeamID(tid) - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if !security.AuthorizedTeamManagement(teamID, securityContext) { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) - return - } - - memberships, err := handler.TeamMembershipService.TeamMembershipsByTeamID(teamID) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, memberships, handler.Logger) -} diff --git a/api/http/handler/team_membership.go b/api/http/handler/team_membership.go deleted file mode 100644 index c96f5c8ca..000000000 --- a/api/http/handler/team_membership.go +++ /dev/null @@ -1,242 +0,0 @@ -package handler - -import ( - "strconv" - - "github.com/portainer/portainer" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/security" - - "encoding/json" - "log" - "net/http" - "os" - - "github.com/asaskevich/govalidator" - "github.com/gorilla/mux" -) - -// TeamMembershipHandler represents an HTTP API handler for managing teams. -type TeamMembershipHandler struct { - *mux.Router - Logger *log.Logger - TeamMembershipService portainer.TeamMembershipService - ResourceControlService portainer.ResourceControlService -} - -// NewTeamMembershipHandler returns a new instance of TeamMembershipHandler. -func NewTeamMembershipHandler(bouncer *security.RequestBouncer) *TeamMembershipHandler { - h := &TeamMembershipHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.Handle("/team_memberships", - bouncer.RestrictedAccess(http.HandlerFunc(h.handlePostTeamMemberships))).Methods(http.MethodPost) - h.Handle("/team_memberships", - bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetTeamsMemberships))).Methods(http.MethodGet) - h.Handle("/team_memberships/{id}", - bouncer.RestrictedAccess(http.HandlerFunc(h.handlePutTeamMembership))).Methods(http.MethodPut) - h.Handle("/team_memberships/{id}", - bouncer.RestrictedAccess(http.HandlerFunc(h.handleDeleteTeamMembership))).Methods(http.MethodDelete) - - return h -} - -type ( - postTeamMembershipsRequest struct { - UserID int `valid:"required"` - TeamID int `valid:"required"` - Role int `valid:"required"` - } - - postTeamMembershipsResponse struct { - ID int `json:"Id"` - } - - putTeamMembershipRequest struct { - UserID int `valid:"required"` - TeamID int `valid:"required"` - Role int `valid:"required"` - } -) - -// handlePostTeamMemberships handles POST requests on /team_memberships -func (handler *TeamMembershipHandler) handlePostTeamMemberships(w http.ResponseWriter, r *http.Request) { - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - var req postTeamMembershipsRequest - 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 - } - - userID := portainer.UserID(req.UserID) - teamID := portainer.TeamID(req.TeamID) - role := portainer.MembershipRole(req.Role) - - if !security.AuthorizedTeamManagement(teamID, securityContext) { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) - return - } - - memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(userID) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - if len(memberships) > 0 { - for _, membership := range memberships { - if membership.UserID == userID && membership.TeamID == teamID { - httperror.WriteErrorResponse(w, portainer.ErrTeamMembershipAlreadyExists, http.StatusConflict, handler.Logger) - return - } - } - } - - membership := &portainer.TeamMembership{ - UserID: userID, - TeamID: teamID, - Role: role, - } - - err = handler.TeamMembershipService.CreateTeamMembership(membership) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, &postTeamMembershipsResponse{ID: int(membership.ID)}, handler.Logger) -} - -// handleGetTeamsMemberships handles GET requests on /team_memberships -func (handler *TeamMembershipHandler) handleGetTeamsMemberships(w http.ResponseWriter, r *http.Request) { - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if !securityContext.IsAdmin && !securityContext.IsTeamLeader { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) - return - } - - memberships, err := handler.TeamMembershipService.TeamMemberships() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, memberships, handler.Logger) -} - -// handlePutTeamMembership handles PUT requests on /team_memberships/:id -func (handler *TeamMembershipHandler) handlePutTeamMembership(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - membershipID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - var req putTeamMembershipRequest - 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 - } - - userID := portainer.UserID(req.UserID) - teamID := portainer.TeamID(req.TeamID) - role := portainer.MembershipRole(req.Role) - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if !security.AuthorizedTeamManagement(teamID, securityContext) { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) - return - } - - membership, err := handler.TeamMembershipService.TeamMembership(portainer.TeamMembershipID(membershipID)) - if err == portainer.ErrTeamMembershipNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if securityContext.IsTeamLeader && membership.Role != role { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) - return - } - - membership.UserID = userID - membership.TeamID = teamID - membership.Role = role - - err = handler.TeamMembershipService.UpdateTeamMembership(membership.ID, membership) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -// handleDeleteTeamMembership handles DELETE requests on /team_memberships/:id -func (handler *TeamMembershipHandler) handleDeleteTeamMembership(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - membershipID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - membership, err := handler.TeamMembershipService.TeamMembership(portainer.TeamMembershipID(membershipID)) - if err == portainer.ErrTeamMembershipNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if !security.AuthorizedTeamManagement(membership.TeamID, securityContext) { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger) - return - } - - err = handler.TeamMembershipService.DeleteTeamMembership(portainer.TeamMembershipID(membershipID)) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} diff --git a/api/http/handler/teammemberships/handler.go b/api/http/handler/teammemberships/handler.go new file mode 100644 index 000000000..c50773a85 --- /dev/null +++ b/api/http/handler/teammemberships/handler.go @@ -0,0 +1,35 @@ +package teammemberships + +import ( + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" + + "net/http" + + "github.com/gorilla/mux" +) + +// Handler is the HTTP handler used to handle team membership operations. +type Handler struct { + *mux.Router + TeamMembershipService portainer.TeamMembershipService + ResourceControlService portainer.ResourceControlService +} + +// NewHandler creates a handler to manage team membership operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/team_memberships", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.teamMembershipCreate))).Methods(http.MethodPost) + h.Handle("/team_memberships", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.teamMembershipList))).Methods(http.MethodGet) + h.Handle("/team_memberships/{id}", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.teamMembershipUpdate))).Methods(http.MethodPut) + h.Handle("/team_memberships/{id}", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.teamMembershipDelete))).Methods(http.MethodDelete) + + return h +} diff --git a/api/http/handler/teammemberships/teammembership_create.go b/api/http/handler/teammemberships/teammembership_create.go new file mode 100644 index 000000000..49783d767 --- /dev/null +++ b/api/http/handler/teammemberships/teammembership_create.go @@ -0,0 +1,74 @@ +package teammemberships + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +type teamMembershipCreatePayload struct { + UserID int + TeamID int + Role int +} + +func (payload *teamMembershipCreatePayload) Validate(r *http.Request) error { + if payload.UserID == 0 { + return portainer.Error("Invalid UserID") + } + if payload.TeamID == 0 { + return portainer.Error("Invalid TeamID") + } + if payload.Role != 1 && payload.Role != 2 { + return portainer.Error("Invalid role value. Value must be one of: 1 (leader) or 2 (member)") + } + return nil +} + +// POST request on /api/team_memberships +func (handler *Handler) teamMembershipCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload teamMembershipCreatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if !security.AuthorizedTeamManagement(portainer.TeamID(payload.TeamID), securityContext) { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to manage team memberships", portainer.ErrResourceAccessDenied} + } + + memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(portainer.UserID(payload.UserID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve team memberships from the database", err} + } + + if len(memberships) > 0 { + for _, membership := range memberships { + if membership.UserID == portainer.UserID(payload.UserID) && membership.TeamID == portainer.TeamID(payload.TeamID) { + return &httperror.HandlerError{http.StatusConflict, "Team membership already registered", portainer.ErrTeamMembershipAlreadyExists} + } + } + } + + membership := &portainer.TeamMembership{ + UserID: portainer.UserID(payload.UserID), + TeamID: portainer.TeamID(payload.TeamID), + Role: portainer.MembershipRole(payload.Role), + } + + err = handler.TeamMembershipService.CreateTeamMembership(membership) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist team memberships inside the database", err} + } + + return response.JSON(w, membership) +} diff --git a/api/http/handler/teammemberships/teammembership_delete.go b/api/http/handler/teammemberships/teammembership_delete.go new file mode 100644 index 000000000..577f3be7d --- /dev/null +++ b/api/http/handler/teammemberships/teammembership_delete.go @@ -0,0 +1,42 @@ +package teammemberships + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// DELETE request on /api/team_memberships/:id +func (handler *Handler) teamMembershipDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + membershipID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid membership identifier route variable", err} + } + + membership, err := handler.TeamMembershipService.TeamMembership(portainer.TeamMembershipID(membershipID)) + if err == portainer.ErrTeamMembershipNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a team membership with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a team membership with the specified identifier inside the database", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if !security.AuthorizedTeamManagement(membership.TeamID, securityContext) { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to delete the membership", portainer.ErrResourceAccessDenied} + } + + err = handler.TeamMembershipService.DeleteTeamMembership(portainer.TeamMembershipID(membershipID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the team membership from the database", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/teammemberships/teammembership_list.go b/api/http/handler/teammemberships/teammembership_list.go new file mode 100644 index 000000000..0f9267a10 --- /dev/null +++ b/api/http/handler/teammemberships/teammembership_list.go @@ -0,0 +1,29 @@ +package teammemberships + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// GET request on /api/team_memberships +func (handler *Handler) teamMembershipList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if !securityContext.IsAdmin && !securityContext.IsTeamLeader { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to list team memberships", portainer.ErrResourceAccessDenied} + } + + memberships, err := handler.TeamMembershipService.TeamMemberships() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve team memberships from the database", err} + } + + return response.JSON(w, memberships) +} diff --git a/api/http/handler/teammemberships/teammembership_update.go b/api/http/handler/teammemberships/teammembership_update.go new file mode 100644 index 000000000..953ca5618 --- /dev/null +++ b/api/http/handler/teammemberships/teammembership_update.go @@ -0,0 +1,75 @@ +package teammemberships + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +type teamMembershipUpdatePayload struct { + UserID int + TeamID int + Role int +} + +func (payload *teamMembershipUpdatePayload) Validate(r *http.Request) error { + if payload.UserID == 0 { + return portainer.Error("Invalid UserID") + } + if payload.TeamID == 0 { + return portainer.Error("Invalid TeamID") + } + if payload.Role != 1 && payload.Role != 2 { + return portainer.Error("Invalid role value. Value must be one of: 1 (leader) or 2 (member)") + } + return nil +} + +// PUT request on /api/team_memberships/:id +func (handler *Handler) teamMembershipUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + membershipID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid membership identifier route variable", err} + } + + var payload teamMembershipUpdatePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if !security.AuthorizedTeamManagement(portainer.TeamID(payload.TeamID), securityContext) { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update the membership", portainer.ErrResourceAccessDenied} + } + + membership, err := handler.TeamMembershipService.TeamMembership(portainer.TeamMembershipID(membershipID)) + if err == portainer.ErrTeamMembershipNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a team membership with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a team membership with the specified identifier inside the database", err} + } + + if securityContext.IsTeamLeader && membership.Role != portainer.MembershipRole(payload.Role) { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update the role of membership", portainer.ErrResourceAccessDenied} + } + + membership.UserID = portainer.UserID(payload.UserID) + membership.TeamID = portainer.TeamID(payload.TeamID) + membership.Role = portainer.MembershipRole(payload.Role) + + err = handler.TeamMembershipService.UpdateTeamMembership(membership.ID, membership) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist membership changes inside the database", err} + } + + return response.JSON(w, membership) +} diff --git a/api/http/handler/teams/handler.go b/api/http/handler/teams/handler.go new file mode 100644 index 000000000..2b8cd7c3b --- /dev/null +++ b/api/http/handler/teams/handler.go @@ -0,0 +1,39 @@ +package teams + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" +) + +// Handler is the HTTP handler used to handle team operations. +type Handler struct { + *mux.Router + TeamService portainer.TeamService + TeamMembershipService portainer.TeamMembershipService + ResourceControlService portainer.ResourceControlService +} + +// NewHandler creates a handler to manage team operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/teams", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.teamCreate))).Methods(http.MethodPost) + h.Handle("/teams", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.teamList))).Methods(http.MethodGet) + h.Handle("/teams/{id}", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.teamInspect))).Methods(http.MethodGet) + h.Handle("/teams/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.teamUpdate))).Methods(http.MethodPut) + h.Handle("/teams/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.teamDelete))).Methods(http.MethodDelete) + h.Handle("/teams/{id}/memberships", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.teamMemberships))).Methods(http.MethodGet) + + return h +} diff --git a/api/http/handler/teams/team_create.go b/api/http/handler/teams/team_create.go new file mode 100644 index 000000000..49dc6f093 --- /dev/null +++ b/api/http/handler/teams/team_create.go @@ -0,0 +1,49 @@ +package teams + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type teamCreatePayload struct { + Name string +} + +func (payload *teamCreatePayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Name) { + return portainer.Error("Invalid team name") + } + return nil +} + +func (handler *Handler) teamCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload teamCreatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + team, err := handler.TeamService.TeamByName(payload.Name) + if err != nil && err != portainer.ErrTeamNotFound { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve teams from the database", err} + } + if team != nil { + return &httperror.HandlerError{http.StatusConflict, "A team with the same name already exists", portainer.ErrTeamAlreadyExists} + } + + team = &portainer.Team{ + Name: payload.Name, + } + + err = handler.TeamService.CreateTeam(team) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the team inside the database", err} + } + + return response.JSON(w, team) +} diff --git a/api/http/handler/teams/team_delete.go b/api/http/handler/teams/team_delete.go new file mode 100644 index 000000000..ab805248b --- /dev/null +++ b/api/http/handler/teams/team_delete.go @@ -0,0 +1,37 @@ +package teams + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +// DELETE request on /api/teams/:id +func (handler *Handler) teamDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + teamID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid team identifier route variable", err} + } + + _, err = handler.TeamService.Team(portainer.TeamID(teamID)) + if err == portainer.ErrTeamNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a team with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a team with the specified identifier inside the database", err} + } + + err = handler.TeamService.DeleteTeam(portainer.TeamID(teamID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to delete the team from the database", err} + } + + err = handler.TeamMembershipService.DeleteTeamMembershipByTeamID(portainer.TeamID(teamID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to delete associated team memberships from the database", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/teams/team_inspect.go b/api/http/handler/teams/team_inspect.go new file mode 100644 index 000000000..b6c6ab1a6 --- /dev/null +++ b/api/http/handler/teams/team_inspect.go @@ -0,0 +1,37 @@ +package teams + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// GET request on /api/teams/:id +func (handler *Handler) teamInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + teamID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid team identifier route variable", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if !security.AuthorizedTeamManagement(portainer.TeamID(teamID), securityContext) { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to team", portainer.ErrResourceAccessDenied} + } + + team, err := handler.TeamService.Team(portainer.TeamID(teamID)) + if err == portainer.ErrTeamNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a team with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a team with the specified identifier inside the database", err} + } + + return response.JSON(w, team) +} diff --git a/api/http/handler/teams/team_list.go b/api/http/handler/teams/team_list.go new file mode 100644 index 000000000..7c4268e13 --- /dev/null +++ b/api/http/handler/teams/team_list.go @@ -0,0 +1,26 @@ +package teams + +import ( + "net/http" + + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// GET request on /api/teams +func (handler *Handler) teamList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + teams, err := handler.TeamService.Teams() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve teams from the database", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + filteredTeams := security.FilterUserTeams(teams, securityContext) + + return response.JSON(w, filteredTeams) +} diff --git a/api/http/handler/teams/team_memberships.go b/api/http/handler/teams/team_memberships.go new file mode 100644 index 000000000..d09abe7d5 --- /dev/null +++ b/api/http/handler/teams/team_memberships.go @@ -0,0 +1,35 @@ +package teams + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// GET request on /api/teams/:id/memberships +func (handler *Handler) teamMemberships(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + teamID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid team identifier route variable", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if !security.AuthorizedTeamManagement(portainer.TeamID(teamID), securityContext) { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to team", portainer.ErrResourceAccessDenied} + } + + memberships, err := handler.TeamMembershipService.TeamMembershipsByTeamID(portainer.TeamID(teamID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve associated team memberships from the database", err} + } + + return response.JSON(w, memberships) +} diff --git a/api/http/handler/teams/team_update.go b/api/http/handler/teams/team_update.go new file mode 100644 index 000000000..ea80f0dd5 --- /dev/null +++ b/api/http/handler/teams/team_update.go @@ -0,0 +1,50 @@ +package teams + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type teamUpdatePayload struct { + Name string +} + +func (payload *teamUpdatePayload) Validate(r *http.Request) error { + return nil +} + +// PUT request on /api/teams/:id +func (handler *Handler) teamUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + teamID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid team identifier route variable", err} + } + + var payload teamUpdatePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + team, err := handler.TeamService.Team(portainer.TeamID(teamID)) + if err == portainer.ErrTeamNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a team with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a team with the specified identifier inside the database", err} + } + + if payload.Name != "" { + team.Name = payload.Name + } + + err = handler.TeamService.UpdateTeam(team.ID, team) + if err != nil { + return &httperror.HandlerError{http.StatusNotFound, "Unable to persist team changes inside the database", err} + } + + return response.JSON(w, team) +} diff --git a/api/http/handler/templates.go b/api/http/handler/templates.go deleted file mode 100644 index 83527870b..000000000 --- a/api/http/handler/templates.go +++ /dev/null @@ -1,74 +0,0 @@ -package handler - -import ( - "io/ioutil" - "log" - "net/http" - "os" - - "github.com/gorilla/mux" - "github.com/portainer/portainer" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/security" -) - -// TemplatesHandler represents an HTTP API handler for managing templates. -type TemplatesHandler struct { - *mux.Router - Logger *log.Logger - SettingsService portainer.SettingsService -} - -const ( - containerTemplatesURLLinuxServerIo = "https://tools.linuxserver.io/portainer.json" -) - -// NewTemplatesHandler returns a new instance of TemplatesHandler. -func NewTemplatesHandler(bouncer *security.RequestBouncer) *TemplatesHandler { - h := &TemplatesHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.Handle("/templates", - bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleGetTemplates))).Methods(http.MethodGet) - return h -} - -// handleGetTemplates handles GET requests on /templates?key= -func (handler *TemplatesHandler) handleGetTemplates(w http.ResponseWriter, r *http.Request) { - key := r.FormValue("key") - if key == "" { - httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger) - return - } - - var templatesURL string - switch key { - case "containers": - settings, err := handler.SettingsService.Settings() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - templatesURL = settings.TemplatesURL - case "linuxserver.io": - templatesURL = containerTemplatesURLLinuxServerIo - default: - httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger) - return - } - - resp, err := http.Get(templatesURL) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - defer resp.Body.Close() - body, err := ioutil.ReadAll(resp.Body) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - w.Header().Set("Content-Type", "application/json") - w.Write(body) -} diff --git a/api/http/handler/templates/handler.go b/api/http/handler/templates/handler.go new file mode 100644 index 000000000..f4d0c6fcf --- /dev/null +++ b/api/http/handler/templates/handler.go @@ -0,0 +1,30 @@ +package templates + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" +) + +const ( + containerTemplatesURLLinuxServerIo = "https://tools.linuxserver.io/portainer.json" +) + +// Handler represents an HTTP API handler for managing templates. +type Handler struct { + *mux.Router + SettingsService portainer.SettingsService +} + +// NewHandler returns a new instance of Handler. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/templates", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.templateList))).Methods(http.MethodGet) + return h +} diff --git a/api/http/handler/templates/template_list.go b/api/http/handler/templates/template_list.go new file mode 100644 index 000000000..188951201 --- /dev/null +++ b/api/http/handler/templates/template_list.go @@ -0,0 +1,50 @@ +package templates + +import ( + "io/ioutil" + "net/http" + + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +// GET request on /api/templates?key= +func (handler *Handler) templateList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + key, err := request.RetrieveQueryParameter(r, "key", false) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: key", err} + } + + templatesURL, templateErr := handler.retrieveTemplateURLFromKey(key) + if templateErr != nil { + return templateErr + } + + resp, err := http.Get(templatesURL) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve templates via the network", err} + } + defer resp.Body.Close() + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to read template response", err} + } + + return response.Bytes(w, body, "application/json") +} + +func (handler *Handler) retrieveTemplateURLFromKey(key string) (string, *httperror.HandlerError) { + switch key { + case "containers": + settings, err := handler.SettingsService.Settings() + if err != nil { + return "", &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err} + } + return settings.TemplatesURL, nil + case "linuxserver.io": + return containerTemplatesURLLinuxServerIo, nil + } + return "", &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: key. Value must be one of: containers or linuxserver.io", request.ErrInvalidQueryParameter} +} diff --git a/api/http/handler/upload.go b/api/http/handler/upload.go deleted file mode 100644 index 0343d1a2f..000000000 --- a/api/http/handler/upload.go +++ /dev/null @@ -1,69 +0,0 @@ -package handler - -import ( - "github.com/portainer/portainer" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/security" - - "log" - "net/http" - "os" - - "github.com/gorilla/mux" -) - -// UploadHandler represents an HTTP API handler for managing file uploads. -type UploadHandler struct { - *mux.Router - Logger *log.Logger - FileService portainer.FileService -} - -// NewUploadHandler returns a new instance of UploadHandler. -func NewUploadHandler(bouncer *security.RequestBouncer) *UploadHandler { - h := &UploadHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.Handle("/upload/tls/{certificate:(?:ca|cert|key)}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handlePostUploadTLS))).Methods(http.MethodPost) - return h -} - -// handlePostUploadTLS handles POST requests on /upload/tls/{certificate:(?:ca|cert|key)}?folder= -func (handler *UploadHandler) handlePostUploadTLS(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - certificate := vars["certificate"] - - folder := r.FormValue("folder") - if folder == "" { - httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger) - return - } - - file, _, err := r.FormFile("file") - defer file.Close() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - var fileType portainer.TLSFileType - switch certificate { - case "ca": - fileType = portainer.TLSFileCA - case "cert": - fileType = portainer.TLSFileCert - case "key": - fileType = portainer.TLSFileKey - default: - httperror.WriteErrorResponse(w, portainer.ErrUndefinedTLSFileType, http.StatusInternalServerError, handler.Logger) - return - } - - err = handler.FileService.StoreTLSFile(folder, fileType, file) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} diff --git a/api/http/handler/upload/handler.go b/api/http/handler/upload/handler.go new file mode 100644 index 000000000..6ce36df77 --- /dev/null +++ b/api/http/handler/upload/handler.go @@ -0,0 +1,27 @@ +package upload + +import ( + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" + + "net/http" + + "github.com/gorilla/mux" +) + +// Handler is the HTTP handler used to handle upload operations. +type Handler struct { + *mux.Router + FileService portainer.FileService +} + +// NewHandler creates a handler to manage upload operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/upload/tls/{certificate:(?:ca|cert|key)}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.uploadTLS))).Methods(http.MethodPost) + return h +} diff --git a/api/http/handler/upload/upload_tls.go b/api/http/handler/upload/upload_tls.go new file mode 100644 index 000000000..aebab6813 --- /dev/null +++ b/api/http/handler/upload/upload_tls.go @@ -0,0 +1,47 @@ +package upload + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +// POST request on /api/upload/tls/{certificate:(?:ca|cert|key)}?folder= +func (handler *Handler) uploadTLS(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + certificate, err := request.RetrieveRouteVariableValue(r, "certificate") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid certificate route variable", err} + } + + folder, err := request.RetrieveMultiPartFormValue(r, "folder", false) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: folder", err} + } + + file, err := request.RetrieveMultiPartFormFile(r, "file") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid certificate file. Ensure that the certificate file is uploaded correctly", err} + } + + var fileType portainer.TLSFileType + switch certificate { + case "ca": + fileType = portainer.TLSFileCA + case "cert": + fileType = portainer.TLSFileCert + case "key": + fileType = portainer.TLSFileKey + default: + return &httperror.HandlerError{http.StatusBadRequest, "Invalid certificate route value. Value must be one of: ca, cert or key", portainer.ErrUndefinedTLSFileType} + } + + _, err = handler.FileService.StoreTLSFileFromBytes(folder, fileType, file) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist certificate file on disk", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/user.go b/api/http/handler/user.go deleted file mode 100644 index d4f34d9b4..000000000 --- a/api/http/handler/user.go +++ /dev/null @@ -1,463 +0,0 @@ -package handler - -import ( - "strconv" - "strings" - - "github.com/portainer/portainer" - httperror "github.com/portainer/portainer/http/error" - "github.com/portainer/portainer/http/security" - - "encoding/json" - "log" - "net/http" - "os" - - "github.com/asaskevich/govalidator" - "github.com/gorilla/mux" -) - -// UserHandler represents an HTTP API handler for managing users. -type UserHandler struct { - *mux.Router - Logger *log.Logger - UserService portainer.UserService - TeamService portainer.TeamService - TeamMembershipService portainer.TeamMembershipService - ResourceControlService portainer.ResourceControlService - CryptoService portainer.CryptoService - SettingsService portainer.SettingsService -} - -// NewUserHandler returns a new instance of UserHandler. -func NewUserHandler(bouncer *security.RequestBouncer) *UserHandler { - h := &UserHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - } - h.Handle("/users", - bouncer.RestrictedAccess(http.HandlerFunc(h.handlePostUsers))).Methods(http.MethodPost) - h.Handle("/users", - bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetUsers))).Methods(http.MethodGet) - h.Handle("/users/{id}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handleGetUser))).Methods(http.MethodGet) - h.Handle("/users/{id}", - bouncer.AuthenticatedAccess(http.HandlerFunc(h.handlePutUser))).Methods(http.MethodPut) - h.Handle("/users/{id}", - bouncer.AdministratorAccess(http.HandlerFunc(h.handleDeleteUser))).Methods(http.MethodDelete) - h.Handle("/users/{id}/memberships", - bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleGetMemberships))).Methods(http.MethodGet) - h.Handle("/users/{id}/passwd", - bouncer.AuthenticatedAccess(http.HandlerFunc(h.handlePostUserPasswd))).Methods(http.MethodPost) - h.Handle("/users/admin/check", - bouncer.PublicAccess(http.HandlerFunc(h.handleGetAdminCheck))).Methods(http.MethodGet) - h.Handle("/users/admin/init", - bouncer.PublicAccess(http.HandlerFunc(h.handlePostAdminInit))).Methods(http.MethodPost) - - return h -} - -type ( - postUsersRequest struct { - Username string `valid:"required"` - Password string `valid:""` - Role int `valid:"required"` - } - - postUsersResponse struct { - ID int `json:"Id"` - } - - postUserPasswdRequest struct { - Password string `valid:"required"` - } - - postUserPasswdResponse struct { - Valid bool `json:"valid"` - } - - putUserRequest struct { - Password string `valid:"-"` - Role int `valid:"-"` - } - - postAdminInitRequest struct { - Username string `valid:"required"` - Password string `valid:"required"` - } -) - -// handlePostUsers handles POST requests on /users -func (handler *UserHandler) handlePostUsers(w http.ResponseWriter, r *http.Request) { - var req postUsersRequest - 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 - } - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if !securityContext.IsAdmin && !securityContext.IsTeamLeader { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil) - return - } - - if securityContext.IsTeamLeader && req.Role == 1 { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil) - return - } - - if strings.ContainsAny(req.Username, " ") { - httperror.WriteErrorResponse(w, portainer.ErrInvalidUsername, http.StatusBadRequest, handler.Logger) - return - } - - user, err := handler.UserService.UserByUsername(req.Username) - if err != nil && err != portainer.ErrUserNotFound { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - if user != nil { - httperror.WriteErrorResponse(w, portainer.ErrUserAlreadyExists, http.StatusConflict, handler.Logger) - return - } - - var role portainer.UserRole - if req.Role == 1 { - role = portainer.AdministratorRole - } else { - role = portainer.StandardUserRole - } - - user = &portainer.User{ - Username: req.Username, - Role: role, - } - - settings, err := handler.SettingsService.Settings() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if settings.AuthenticationMethod == portainer.AuthenticationInternal { - user.Password, err = handler.CryptoService.Hash(req.Password) - if err != nil { - httperror.WriteErrorResponse(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger) - return - } - } - - err = handler.UserService.CreateUser(user) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, &postUsersResponse{ID: int(user.ID)}, handler.Logger) -} - -// handleGetUsers handles GET requests on /users -func (handler *UserHandler) handleGetUsers(w http.ResponseWriter, r *http.Request) { - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - users, err := handler.UserService.Users() - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - filteredUsers := security.FilterUsers(users, securityContext) - - for i := range filteredUsers { - filteredUsers[i].Password = "" - } - - encodeJSON(w, filteredUsers, handler.Logger) -} - -// handlePostUserPasswd handles POST requests on /users/:id/passwd -func (handler *UserHandler) handlePostUserPasswd(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - userID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - var req postUserPasswdRequest - 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 - } - - var password = req.Password - - u, err := handler.UserService.User(portainer.UserID(userID)) - if err == portainer.ErrUserNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(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) -} - -// handleGetUser handles GET requests on /users/:id -func (handler *UserHandler) handleGetUser(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - userID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - user, err := handler.UserService.User(portainer.UserID(userID)) - if err == portainer.ErrUserNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - user.Password = "" - encodeJSON(w, &user, handler.Logger) -} - -// handlePutUser handles PUT requests on /users/:id -func (handler *UserHandler) handlePutUser(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - userID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - tokenData, err := security.RetrieveTokenData(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(userID) { - httperror.WriteErrorResponse(w, portainer.ErrUnauthorized, http.StatusForbidden, handler.Logger) - return - } - - var req putUserRequest - 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 - } - - if req.Password == "" && req.Role == 0 { - httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) - return - } - - user, err := handler.UserService.User(portainer.UserID(userID)) - if err == portainer.ErrUserNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if req.Password != "" { - user.Password, err = handler.CryptoService.Hash(req.Password) - if err != nil { - httperror.WriteErrorResponse(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger) - return - } - } - - if req.Role != 0 { - if tokenData.Role != portainer.AdministratorRole { - httperror.WriteErrorResponse(w, portainer.ErrUnauthorized, http.StatusForbidden, handler.Logger) - return - } - if req.Role == 1 { - user.Role = portainer.AdministratorRole - } else { - user.Role = portainer.StandardUserRole - } - } - - err = handler.UserService.UpdateUser(user.ID, user) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -// handleGetAdminCheck handles GET requests on /users/admin/check -func (handler *UserHandler) handleGetAdminCheck(w http.ResponseWriter, r *http.Request) { - users, err := handler.UserService.UsersByRole(portainer.AdministratorRole) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - if len(users) == 0 { - httperror.WriteErrorResponse(w, portainer.ErrUserNotFound, http.StatusNotFound, handler.Logger) - return - } -} - -// handlePostAdminInit handles POST requests on /users/admin/init -func (handler *UserHandler) handlePostAdminInit(w http.ResponseWriter, r *http.Request) { - var req postAdminInitRequest - 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 - } - - users, err := handler.UserService.UsersByRole(portainer.AdministratorRole) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - if len(users) == 0 { - user := &portainer.User{ - Username: req.Username, - Role: portainer.AdministratorRole, - } - user.Password, err = handler.CryptoService.Hash(req.Password) - if err != nil { - httperror.WriteErrorResponse(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger) - return - } - - err = handler.UserService.CreateUser(user) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - } else { - httperror.WriteErrorResponse(w, portainer.ErrAdminAlreadyInitialized, http.StatusConflict, handler.Logger) - return - } -} - -// handleDeleteUser handles DELETE requests on /users/:id -func (handler *UserHandler) handleDeleteUser(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - userID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - tokenData, err := security.RetrieveTokenData(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if tokenData.ID == portainer.UserID(userID) { - httperror.WriteErrorResponse(w, portainer.ErrAdminCannotRemoveSelf, http.StatusForbidden, handler.Logger) - return - } - - _, err = handler.UserService.User(portainer.UserID(userID)) - - if err == portainer.ErrUserNotFound { - httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger) - return - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - err = handler.UserService.DeleteUser(portainer.UserID(userID)) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - err = handler.TeamMembershipService.DeleteTeamMembershipByUserID(portainer.UserID(userID)) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } -} - -// handleGetMemberships handles GET requests on /users/:id/memberships -func (handler *UserHandler) handleGetMemberships(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - - userID, err := strconv.Atoi(id) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return - } - - tokenData, err := security.RetrieveTokenData(r) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(userID) { - httperror.WriteErrorResponse(w, portainer.ErrUnauthorized, http.StatusForbidden, handler.Logger) - return - } - - memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(portainer.UserID(userID)) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - - encodeJSON(w, memberships, handler.Logger) -} diff --git a/api/http/handler/users/admin_check.go b/api/http/handler/users/admin_check.go new file mode 100644 index 000000000..1120c957a --- /dev/null +++ b/api/http/handler/users/admin_check.go @@ -0,0 +1,23 @@ +package users + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/response" +) + +// GET request on /api/users/admin/check +func (handler *Handler) adminCheck(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + users, err := handler.UserService.UsersByRole(portainer.AdministratorRole) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve users from the database", err} + } + + if len(users) == 0 { + return &httperror.HandlerError{http.StatusNotFound, "No administrator account found inside the database", portainer.ErrUserNotFound} + } + + return response.Empty(w) +} diff --git a/api/http/handler/users/admin_init.go b/api/http/handler/users/admin_init.go new file mode 100644 index 000000000..2ad394803 --- /dev/null +++ b/api/http/handler/users/admin_init.go @@ -0,0 +1,61 @@ +package users + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type adminInitPayload struct { + Username string + Password string +} + +func (payload *adminInitPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Username) || govalidator.Contains(payload.Username, " ") { + return portainer.Error("Invalid username. Must not contain any whitespace") + } + if govalidator.IsNull(payload.Password) { + return portainer.Error("Invalid password") + } + return nil +} + +// POST request on /api/users/admin/init +func (handler *Handler) adminInit(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload adminInitPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + users, err := handler.UserService.UsersByRole(portainer.AdministratorRole) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve users from the database", err} + } + + if len(users) != 0 { + return &httperror.HandlerError{http.StatusConflict, "Unable to retrieve users from the database", portainer.ErrAdminAlreadyInitialized} + } + + user := &portainer.User{ + Username: payload.Username, + Role: portainer.AdministratorRole, + } + + user.Password, err = handler.CryptoService.Hash(payload.Password) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to hash user password", portainer.ErrCryptoHashFailure} + } + + err = handler.UserService.CreateUser(user) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist user inside the database", err} + } + + return response.JSON(w, user) +} diff --git a/api/http/handler/users/handler.go b/api/http/handler/users/handler.go new file mode 100644 index 000000000..f6e4df727 --- /dev/null +++ b/api/http/handler/users/handler.go @@ -0,0 +1,53 @@ +package users + +import ( + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" + + "net/http" + + "github.com/gorilla/mux" +) + +func hideFields(user *portainer.User) { + user.Password = "" +} + +// Handler is the HTTP handler used to handle user operations. +type Handler struct { + *mux.Router + UserService portainer.UserService + TeamService portainer.TeamService + TeamMembershipService portainer.TeamMembershipService + ResourceControlService portainer.ResourceControlService + CryptoService portainer.CryptoService + SettingsService portainer.SettingsService +} + +// NewHandler creates a handler to manage user operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/users", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.userCreate))).Methods(http.MethodPost) + h.Handle("/users", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.userList))).Methods(http.MethodGet) + h.Handle("/users/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.userInspect))).Methods(http.MethodGet) + h.Handle("/users/{id}", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.userUpdate))).Methods(http.MethodPut) + h.Handle("/users/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.userDelete))).Methods(http.MethodDelete) + h.Handle("/users/{id}/memberships", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.userMemberships))).Methods(http.MethodGet) + h.Handle("/users/{id}/passwd", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.userPassword))).Methods(http.MethodPost) + h.Handle("/users/admin/check", + bouncer.PublicAccess(httperror.LoggerHandler(h.adminCheck))).Methods(http.MethodGet) + h.Handle("/users/admin/init", + bouncer.PublicAccess(httperror.LoggerHandler(h.adminInit))).Methods(http.MethodPost) + + return h +} diff --git a/api/http/handler/users/user_create.go b/api/http/handler/users/user_create.go new file mode 100644 index 000000000..7efba6d25 --- /dev/null +++ b/api/http/handler/users/user_create.go @@ -0,0 +1,84 @@ +package users + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +type userCreatePayload struct { + Username string + Password string + Role int +} + +func (payload *userCreatePayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Username) || govalidator.Contains(payload.Username, " ") { + return portainer.Error("Invalid username. Must not contain any whitespace") + } + + if payload.Role != 1 && payload.Role != 2 { + return portainer.Error("Invalid role value. Value must be one of: 1 (administrator) or 2 (regular user)") + } + return nil +} + +// POST request on /api/users +func (handler *Handler) userCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload userCreatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + if !securityContext.IsAdmin && !securityContext.IsTeamLeader { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to create user", portainer.ErrResourceAccessDenied} + } + + if securityContext.IsTeamLeader && payload.Role == 1 { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to create administrator user", portainer.ErrResourceAccessDenied} + } + + user, err := handler.UserService.UserByUsername(payload.Username) + if err != nil && err != portainer.ErrUserNotFound { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve users from the database", err} + } + if user != nil { + return &httperror.HandlerError{http.StatusConflict, "Another user with the same username already exists", portainer.ErrUserAlreadyExists} + } + + user = &portainer.User{ + Username: payload.Username, + Role: portainer.UserRole(payload.Role), + } + + settings, err := handler.SettingsService.Settings() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err} + } + + if settings.AuthenticationMethod == portainer.AuthenticationInternal { + user.Password, err = handler.CryptoService.Hash(payload.Password) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to hash user password", portainer.ErrCryptoHashFailure} + } + } + + err = handler.UserService.CreateUser(user) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist user inside the database", err} + } + + hideFields(user) + return response.JSON(w, user) +} diff --git a/api/http/handler/users/user_delete.go b/api/http/handler/users/user_delete.go new file mode 100644 index 000000000..5723bf387 --- /dev/null +++ b/api/http/handler/users/user_delete.go @@ -0,0 +1,47 @@ +package users + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// DELETE request on /api/users/:id +func (handler *Handler) userDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + userID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid user identifier route variable", err} + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err} + } + + if tokenData.ID == portainer.UserID(userID) { + return &httperror.HandlerError{http.StatusForbidden, "Cannot remove your own user account. Contact another administrator", portainer.ErrAdminCannotRemoveSelf} + } + + _, err = handler.UserService.User(portainer.UserID(userID)) + if err == portainer.ErrUserNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a user with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a user with the specified identifier inside the database", err} + } + + err = handler.UserService.DeleteUser(portainer.UserID(userID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove user from the database", err} + } + + err = handler.TeamMembershipService.DeleteTeamMembershipByUserID(portainer.UserID(userID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove user memberships from the database", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/users/user_inspect.go b/api/http/handler/users/user_inspect.go new file mode 100644 index 000000000..c74d170d6 --- /dev/null +++ b/api/http/handler/users/user_inspect.go @@ -0,0 +1,28 @@ +package users + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +// GET request on /api/users/:id +func (handler *Handler) userInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + userID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid user identifier route variable", err} + } + + user, err := handler.UserService.User(portainer.UserID(userID)) + if err == portainer.ErrUserNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a user with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a user with the specified identifier inside the database", err} + } + + hideFields(user) + return response.JSON(w, user) +} diff --git a/api/http/handler/users/user_list.go b/api/http/handler/users/user_list.go new file mode 100644 index 000000000..760c4ec54 --- /dev/null +++ b/api/http/handler/users/user_list.go @@ -0,0 +1,29 @@ +package users + +import ( + "net/http" + + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// GET request on /api/users +func (handler *Handler) userList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + users, err := handler.UserService.Users() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve users from the database", err} + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } + + filteredUsers := security.FilterUsers(users, securityContext) + + for _, user := range filteredUsers { + hideFields(&user) + } + return response.JSON(w, filteredUsers) +} diff --git a/api/http/handler/users/user_memberships.go b/api/http/handler/users/user_memberships.go new file mode 100644 index 000000000..dfbb355ab --- /dev/null +++ b/api/http/handler/users/user_memberships.go @@ -0,0 +1,35 @@ +package users + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +// GET request on /api/users/:id/memberships +func (handler *Handler) userMemberships(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + userID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid user identifier route variable", err} + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err} + } + + if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(userID) { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update user memberships", portainer.ErrUnauthorized} + } + + memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(portainer.UserID(userID)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist membership changes inside the database", err} + } + + return response.JSON(w, memberships) +} diff --git a/api/http/handler/users/user_password.go b/api/http/handler/users/user_password.go new file mode 100644 index 000000000..073c10360 --- /dev/null +++ b/api/http/handler/users/user_password.go @@ -0,0 +1,57 @@ +package users + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type userPasswordPayload struct { + Password string +} + +func (payload *userPasswordPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Password) { + return portainer.Error("Invalid password") + } + return nil +} + +type userPasswordResponse struct { + Valid bool `json:"valid"` +} + +// POST request on /api/users/:id/passwd +func (handler *Handler) userPassword(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + userID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid user identifier route variable", err} + } + + var payload userPasswordPayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + var password = payload.Password + + u, err := handler.UserService.User(portainer.UserID(userID)) + if err == portainer.ErrUserNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a user with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a user with the specified identifier inside the database", err} + } + + valid := true + err = handler.CryptoService.CompareHashAndData(u.Password, password) + if err != nil { + valid = false + } + + return response.JSON(w, &userPasswordResponse{Valid: valid}) +} diff --git a/api/http/handler/users/user_update.go b/api/http/handler/users/user_update.go new file mode 100644 index 000000000..4a01d0e37 --- /dev/null +++ b/api/http/handler/users/user_update.go @@ -0,0 +1,75 @@ +package users + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" + "github.com/portainer/portainer/http/security" +) + +type userUpdatePayload struct { + Password string + Role int +} + +func (payload *userUpdatePayload) Validate(r *http.Request) error { + if payload.Role != 0 && payload.Role != 1 && payload.Role != 2 { + return portainer.Error("Invalid role value. Value must be one of: 1 (administrator) or 2 (regular user)") + } + return nil +} + +// PUT request on /api/users/:id +func (handler *Handler) userUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + userID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid user identifier route variable", err} + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err} + } + + if tokenData.Role != portainer.AdministratorRole && tokenData.ID != portainer.UserID(userID) { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update user", portainer.ErrUnauthorized} + } + + var payload userUpdatePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + if tokenData.Role != portainer.AdministratorRole && payload.Role != 0 { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update user to administrator role", portainer.ErrResourceAccessDenied} + } + + user, err := handler.UserService.User(portainer.UserID(userID)) + if err == portainer.ErrUserNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a user with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a user with the specified identifier inside the database", err} + } + + if payload.Password != "" { + user.Password, err = handler.CryptoService.Hash(payload.Password) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to hash user password", portainer.ErrCryptoHashFailure} + } + } + + if payload.Role != 0 { + user.Role = portainer.UserRole(payload.Role) + } + + err = handler.UserService.UpdateUser(user.ID, user) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist user changes inside the database", err} + } + + return response.JSON(w, user) +} diff --git a/api/http/handler/websocket/handler.go b/api/http/handler/websocket/handler.go new file mode 100644 index 000000000..34dcce593 --- /dev/null +++ b/api/http/handler/websocket/handler.go @@ -0,0 +1,28 @@ +package websocket + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/gorilla/websocket" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" +) + +// Handler is the HTTP handler used to handle websocket operations. +type Handler struct { + *mux.Router + EndpointService portainer.EndpointService + SignatureService portainer.DigitalSignatureService + connectionUpgrader websocket.Upgrader +} + +// NewHandler creates a handler to manage websocket operations. +func NewHandler() *Handler { + h := &Handler{ + Router: mux.NewRouter(), + connectionUpgrader: websocket.Upgrader{}, + } + h.Handle("/websocket/exec", httperror.LoggerHandler(h.websocketExec)).Methods(http.MethodGet) + return h +} diff --git a/api/http/handler/websocket.go b/api/http/handler/websocket/websocket_exec.go similarity index 73% rename from api/http/handler/websocket.go rename to api/http/handler/websocket/websocket_exec.go index 629e60390..98cb6d983 100644 --- a/api/http/handler/websocket.go +++ b/api/http/handler/websocket/websocket_exec.go @@ -1,4 +1,4 @@ -package handler +package websocket import ( "bufio" @@ -6,94 +6,68 @@ import ( "crypto/tls" "encoding/json" "fmt" - "log" "net" "net/http" "net/http/httputil" "net/url" - "os" - "strconv" "time" - "github.com/gorilla/mux" "github.com/gorilla/websocket" "github.com/koding/websocketproxy" "github.com/portainer/portainer" "github.com/portainer/portainer/crypto" httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" ) -type ( - // WebSocketHandler represents an HTTP API handler for proxying requests to a web socket. - WebSocketHandler struct { - *mux.Router - Logger *log.Logger - EndpointService portainer.EndpointService - SignatureService portainer.DigitalSignatureService - connectionUpgrader websocket.Upgrader - } - - webSocketExecRequestParams struct { - execID string - nodeName string - endpoint *portainer.Endpoint - } - - execStartOperationPayload struct { - Tty bool - Detach bool - } -) - -// NewWebSocketHandler returns a new instance of WebSocketHandler. -func NewWebSocketHandler() *WebSocketHandler { - h := &WebSocketHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), - connectionUpgrader: websocket.Upgrader{}, - } - h.HandleFunc("/websocket/exec", h.handleWebsocketExec).Methods(http.MethodGet) - return h +type webSocketExecRequestParams struct { + execID string + nodeName string + endpoint *portainer.Endpoint } -// handleWebsocketExec handles GET requests on /websocket/exec?id=&endpointId=&nodeName= +type execStartOperationPayload struct { + Tty bool + Detach bool +} + +// websocketExec handles GET requests on /websocket/exec?id=&endpointId=&nodeName= // If the nodeName query parameter is present, the request will be proxied to the underlying agent endpoint. // If the nodeName query parameter is not specified, the request will be upgraded to the websocket protocol and // an ExecStart operation HTTP request will be created and hijacked. -func (handler *WebSocketHandler) handleWebsocketExec(w http.ResponseWriter, r *http.Request) { - paramExecID := r.FormValue("id") - paramEndpointID := r.FormValue("endpointId") - if paramExecID == "" || paramEndpointID == "" { - httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger) - return +func (handler *Handler) websocketExec(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + execID, err := request.RetrieveQueryParameter(r, "id", false) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: id", err} } - endpointID, err := strconv.Atoi(paramEndpointID) + endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false) if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) - return + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err} } endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) - if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return + if err == portainer.ErrEndpointNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} } params := &webSocketExecRequestParams{ endpoint: endpoint, - execID: paramExecID, + execID: execID, nodeName: r.FormValue("nodeName"), } err = handler.handleRequest(w, r, params) if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return + return &httperror.HandlerError{http.StatusInternalServerError, "An error occured during websocket exec operation", err} } + + return nil } -func (handler *WebSocketHandler) handleRequest(w http.ResponseWriter, r *http.Request, params *webSocketExecRequestParams) error { +func (handler *Handler) handleRequest(w http.ResponseWriter, r *http.Request, params *webSocketExecRequestParams) error { r.Header.Del("Origin") if params.nodeName != "" { @@ -109,7 +83,7 @@ func (handler *WebSocketHandler) handleRequest(w http.ResponseWriter, r *http.Re return hijackExecStartOperation(websocketConn, params.endpoint, params.execID) } -func (handler *WebSocketHandler) proxyWebsocketRequest(w http.ResponseWriter, r *http.Request, params *webSocketExecRequestParams) error { +func (handler *Handler) proxyWebsocketRequest(w http.ResponseWriter, r *http.Request, params *webSocketExecRequestParams) error { agentURL, err := url.Parse(params.endpoint.URL) if err != nil { return err diff --git a/api/http/proxy/access_control.go b/api/http/proxy/access_control.go index 408ff8c7d..331b9d0da 100644 --- a/api/http/proxy/access_control.go +++ b/api/http/proxy/access_control.go @@ -10,7 +10,7 @@ type ( } ) -// applyResourceAccessControl returns an optionally decorated object as the first return value and the +// applyResourceAccessControlFromLabel returns an optionally decorated object as the first return value and the // access level for the user (granted or denied) as the second return value. // It will retrieve an identifier from the labels object. If an identifier exists, it will check for // an existing resource control associated to it. diff --git a/api/http/proxy/containers.go b/api/http/proxy/containers.go index b05149940..9da66e21f 100644 --- a/api/http/proxy/containers.go +++ b/api/http/proxy/containers.go @@ -8,10 +8,11 @@ import ( const ( // ErrDockerContainerIdentifierNotFound defines an error raised when Portainer is unable to find a container identifier - ErrDockerContainerIdentifierNotFound = portainer.Error("Docker container identifier not found") - containerIdentifier = "Id" - containerLabelForServiceIdentifier = "com.docker.swarm.service.id" - containerLabelForStackIdentifier = "com.docker.stack.namespace" + ErrDockerContainerIdentifierNotFound = portainer.Error("Docker container identifier not found") + containerIdentifier = "Id" + containerLabelForServiceIdentifier = "com.docker.swarm.service.id" + containerLabelForSwarmStackIdentifier = "com.docker.stack.namespace" + containerLabelForComposeStackIdentifier = "com.docker.compose.project" ) // containerListOperation extracts the response as a JSON object, loop through the containers array @@ -71,7 +72,12 @@ func containerInspectOperation(response *http.Response, executor *operationExecu return rewriteAccessDeniedResponse(response) } - responseObject, access = applyResourceAccessControlFromLabel(containerLabels, responseObject, containerLabelForStackIdentifier, executor.operationContext) + responseObject, access = applyResourceAccessControlFromLabel(containerLabels, responseObject, containerLabelForSwarmStackIdentifier, executor.operationContext) + if !access { + return rewriteAccessDeniedResponse(response) + } + + responseObject, access = applyResourceAccessControlFromLabel(containerLabels, responseObject, containerLabelForComposeStackIdentifier, executor.operationContext) if !access { return rewriteAccessDeniedResponse(response) } @@ -117,7 +123,8 @@ func decorateContainerList(containerData []interface{}, resourceControls []porta containerLabels := extractContainerLabelsFromContainerListObject(containerObject) containerObject = decorateResourceWithAccessControlFromLabel(containerLabels, containerObject, containerLabelForServiceIdentifier, resourceControls) - containerObject = decorateResourceWithAccessControlFromLabel(containerLabels, containerObject, containerLabelForStackIdentifier, resourceControls) + containerObject = decorateResourceWithAccessControlFromLabel(containerLabels, containerObject, containerLabelForSwarmStackIdentifier, resourceControls) + containerObject = decorateResourceWithAccessControlFromLabel(containerLabels, containerObject, containerLabelForComposeStackIdentifier, resourceControls) decoratedContainerData = append(decoratedContainerData, containerObject) } @@ -143,11 +150,14 @@ func filterContainerList(containerData []interface{}, context *restrictedOperati containerObject, access := applyResourceAccessControl(containerObject, containerID, context) if access { containerLabels := extractContainerLabelsFromContainerListObject(containerObject) - containerObject, access = applyResourceAccessControlFromLabel(containerLabels, containerObject, containerLabelForServiceIdentifier, context) + containerObject, access = applyResourceAccessControlFromLabel(containerLabels, containerObject, containerLabelForComposeStackIdentifier, context) if access { - containerObject, access = applyResourceAccessControlFromLabel(containerLabels, containerObject, containerLabelForStackIdentifier, context) + containerObject, access = applyResourceAccessControlFromLabel(containerLabels, containerObject, containerLabelForServiceIdentifier, context) if access { - filteredContainerData = append(filteredContainerData, containerObject) + containerObject, access = applyResourceAccessControlFromLabel(containerLabels, containerObject, containerLabelForSwarmStackIdentifier, context) + if access { + filteredContainerData = append(filteredContainerData, containerObject) + } } } } diff --git a/api/http/proxy/docker_transport.go b/api/http/proxy/docker_transport.go index 9effb54d8..8fb20d3f0 100644 --- a/api/http/proxy/docker_transport.go +++ b/api/http/proxy/docker_transport.go @@ -3,6 +3,7 @@ package proxy import ( "encoding/base64" "encoding/json" + "log" "net/http" "path" "regexp" @@ -303,6 +304,7 @@ func (p *proxyTransport) replaceRegistryAuthenticationHeader(request *http.Reque } authenticationHeader := createRegistryAuthenticationHeader(originalHeaderData.Serveraddress, accessContext) + log.Printf("Header: %+v", authenticationHeader) headerData, err := json.Marshal(authenticationHeader) if err != nil { diff --git a/api/http/proxy/manager.go b/api/http/proxy/manager.go index f2a691634..88acdf515 100644 --- a/api/http/proxy/manager.go +++ b/api/http/proxy/manager.go @@ -99,7 +99,6 @@ func (manager *Manager) DeleteProxy(key string) { // 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 diff --git a/api/http/proxy/socket.go b/api/http/proxy/socket.go index 5a9158492..58ccdec27 100644 --- a/api/http/proxy/socket.go +++ b/api/http/proxy/socket.go @@ -3,6 +3,7 @@ package proxy // unixSocketHandler represents a handler to proxy HTTP requests via a unix:// socket import ( "io" + "log" "net/http" httperror "github.com/portainer/portainer/http/error" @@ -24,7 +25,7 @@ func (proxy *socketProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { if res != nil && res.StatusCode != 0 { code = res.StatusCode } - httperror.WriteErrorResponse(w, err, code, nil) + httperror.WriteError(w, code, "Unable to proxy the request via the Docker socket", err) return } defer res.Body.Close() @@ -38,6 +39,6 @@ func (proxy *socketProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.WriteHeader(res.StatusCode) if _, err := io.Copy(w, res.Body); err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, nil) + log.Printf("proxy error: %s\n", err) } } diff --git a/api/http/request/request.go b/api/http/request/request.go new file mode 100644 index 000000000..16ba4590b --- /dev/null +++ b/api/http/request/request.go @@ -0,0 +1,160 @@ +package request + +import ( + "encoding/json" + "io/ioutil" + "net/http" + "strconv" + + "github.com/gorilla/mux" + "github.com/portainer/portainer" +) + +const ( + // ErrInvalidQueryParameter defines an error raised when a mandatory query parameter has an invalid value. + ErrInvalidQueryParameter = portainer.Error("Invalid query parameter") + // errInvalidRequestURL defines an error raised when the data sent in the query or the URL is invalid + errInvalidRequestURL = portainer.Error("Invalid request URL") + // errMissingQueryParameter defines an error raised when a mandatory query parameter is missing. + errMissingQueryParameter = portainer.Error("Missing query parameter") + // errMissingFormDataValue defines an error raised when a mandatory form data value is missing. + errMissingFormDataValue = portainer.Error("Missing form data value") +) + +// PayloadValidation is an interface used to validate the payload of a request. +type PayloadValidation interface { + Validate(request *http.Request) error +} + +// DecodeAndValidateJSONPayload decodes the body of the request into an object +// implementing the PayloadValidation interface. +// It also triggers a validation of object content. +func DecodeAndValidateJSONPayload(request *http.Request, v PayloadValidation) error { + if err := json.NewDecoder(request.Body).Decode(v); err != nil { + return err + } + return v.Validate(request) +} + +// RetrieveMultiPartFormFile returns the content of an uploaded file (form data) as bytes. +func RetrieveMultiPartFormFile(request *http.Request, requestParameter string) ([]byte, error) { + file, _, err := request.FormFile(requestParameter) + if err != nil { + return nil, err + } + defer file.Close() + + fileContent, err := ioutil.ReadAll(file) + if err != nil { + return nil, err + } + return fileContent, nil +} + +// RetrieveMultiPartFormJSONValue decodes the value of some form data as a JSON object into the target parameter. +// If optional is set to true, will not return an error when the form data value is not found. +func RetrieveMultiPartFormJSONValue(request *http.Request, name string, target interface{}, optional bool) error { + value, err := RetrieveMultiPartFormValue(request, name, optional) + if err != nil { + return err + } + if value == "" { + return nil + } + return json.Unmarshal([]byte(value), target) +} + +// RetrieveMultiPartFormValue returns the value of some form data as a string. +// If optional is set to true, will not return an error when the form data value is not found. +func RetrieveMultiPartFormValue(request *http.Request, name string, optional bool) (string, error) { + value := request.FormValue(name) + if value == "" && !optional { + return "", errMissingFormDataValue + } + return value, nil +} + +// RetrieveNumericMultiPartFormValue returns the value of some form data as an integer. +// If optional is set to true, will not return an error when the form data value is not found. +func RetrieveNumericMultiPartFormValue(request *http.Request, name string, optional bool) (int, error) { + value, err := RetrieveMultiPartFormValue(request, name, optional) + if err != nil { + return 0, err + } + return strconv.Atoi(value) +} + +// RetrieveBooleanMultiPartFormValue returns the value of some form data as a boolean. +// If optional is set to true, will not return an error when the form data value is not found. +func RetrieveBooleanMultiPartFormValue(request *http.Request, name string, optional bool) (bool, error) { + value, err := RetrieveMultiPartFormValue(request, name, optional) + if err != nil { + return false, err + } + return value == "true", nil +} + +// RetrieveRouteVariableValue returns the value of a route variable as a string. +func RetrieveRouteVariableValue(request *http.Request, name string) (string, error) { + routeVariables := mux.Vars(request) + if routeVariables == nil { + return "", errInvalidRequestURL + } + routeVar := routeVariables[name] + if routeVar == "" { + return "", errInvalidRequestURL + } + return routeVar, nil +} + +// RetrieveNumericRouteVariableValue returns the value of a route variable as an integer. +func RetrieveNumericRouteVariableValue(request *http.Request, name string) (int, error) { + routeVar, err := RetrieveRouteVariableValue(request, name) + if err != nil { + return 0, err + } + return strconv.Atoi(routeVar) +} + +// RetrieveQueryParameter returns the value of a query parameter as a string. +// If optional is set to true, will not return an error when the query parameter is not found. +func RetrieveQueryParameter(request *http.Request, name string, optional bool) (string, error) { + queryParameter := request.FormValue(name) + if queryParameter == "" && !optional { + return "", errMissingQueryParameter + } + return queryParameter, nil +} + +// RetrieveNumericQueryParameter returns the value of a query parameter as an integer. +// If optional is set to true, will not return an error when the query parameter is not found. +func RetrieveNumericQueryParameter(request *http.Request, name string, optional bool) (int, error) { + queryParameter, err := RetrieveQueryParameter(request, name, optional) + if err != nil { + return 0, err + } + return strconv.Atoi(queryParameter) +} + +// RetrieveBooleanQueryParameter returns the value of a query parameter as a boolean. +// If optional is set to true, will not return an error when the query parameter is not found. +func RetrieveBooleanQueryParameter(request *http.Request, name string, optional bool) (bool, error) { + queryParameter, err := RetrieveQueryParameter(request, name, optional) + if err != nil { + return false, err + } + return queryParameter == "true", nil +} + +// RetrieveJSONQueryParameter decodes the value of a query paramater as a JSON object into the target parameter. +// If optional is set to true, will not return an error when the query parameter is not found. +func RetrieveJSONQueryParameter(request *http.Request, name string, target interface{}, optional bool) error { + queryParameter, err := RetrieveQueryParameter(request, name, optional) + if err != nil { + return err + } + if queryParameter == "" { + return nil + } + return json.Unmarshal([]byte(queryParameter), target) +} diff --git a/api/http/response/response.go b/api/http/response/response.go new file mode 100644 index 000000000..1334d4d7e --- /dev/null +++ b/api/http/response/response.go @@ -0,0 +1,32 @@ +package response + +import ( + "encoding/json" + "net/http" + + httperror "github.com/portainer/portainer/http/error" +) + +// JSON encodes data to rw in JSON format. Returns a pointer to a +// HandlerError if encoding fails. +func JSON(rw http.ResponseWriter, data interface{}) *httperror.HandlerError { + rw.Header().Set("Content-Type", "application/json") + err := json.NewEncoder(rw).Encode(data) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to write JSON response", err} + } + return nil +} + +// Empty merely sets the response code to NoContent (204). +func Empty(rw http.ResponseWriter) *httperror.HandlerError { + rw.WriteHeader(http.StatusNoContent) + return nil +} + +// Bytes write data into rw. It also allows to set the Content-Type header. +func Bytes(rw http.ResponseWriter, data []byte, contentType string) *httperror.HandlerError { + rw.Header().Set("Content-Type", contentType) + rw.Write(data) + return nil +} diff --git a/api/http/security/bouncer.go b/api/http/security/bouncer.go index 76b47aaea..1e3e7d522 100644 --- a/api/http/security/bouncer.go +++ b/api/http/security/bouncer.go @@ -85,13 +85,13 @@ func (bouncer *RequestBouncer) mwUpgradeToRestrictedRequest(next http.Handler) h return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tokenData, err := RetrieveTokenData(r) if err != nil { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil) + httperror.WriteError(w, http.StatusForbidden, "Access denied", portainer.ErrResourceAccessDenied) return } requestContext, err := bouncer.newRestrictedContextRequest(tokenData.ID, tokenData.Role) if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, nil) + httperror.WriteError(w, http.StatusInternalServerError, "Unable to create restricted request context ", err) return } @@ -105,7 +105,7 @@ func mwCheckAdministratorRole(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tokenData, err := RetrieveTokenData(r) if err != nil || tokenData.Role != portainer.AdministratorRole { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil) + httperror.WriteError(w, http.StatusForbidden, "Access denied", portainer.ErrResourceAccessDenied) return } @@ -128,23 +128,23 @@ func (bouncer *RequestBouncer) mwCheckAuthentication(next http.Handler) http.Han } if token == "" { - httperror.WriteErrorResponse(w, portainer.ErrUnauthorized, http.StatusUnauthorized, nil) + httperror.WriteError(w, http.StatusUnauthorized, "Unauthorized", portainer.ErrUnauthorized) return } var err error tokenData, err = bouncer.jwtService.ParseAndVerifyToken(token) if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusUnauthorized, nil) + httperror.WriteError(w, http.StatusUnauthorized, "Invalid JWT token", err) return } _, err = bouncer.userService.User(tokenData.ID) if err != nil && err == portainer.ErrUserNotFound { - httperror.WriteErrorResponse(w, portainer.ErrUnauthorized, http.StatusUnauthorized, nil) + httperror.WriteError(w, http.StatusUnauthorized, "Unauthorized", portainer.ErrUnauthorized) return } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, nil) + httperror.WriteError(w, http.StatusInternalServerError, "Unable to retrieve users from the database", err) return } } else { diff --git a/api/http/security/filter.go b/api/http/security/filter.go index 5c1b0774f..71bd314b4 100644 --- a/api/http/security/filter.go +++ b/api/http/security/filter.go @@ -62,8 +62,7 @@ func FilterUsers(users []portainer.User, context *RestrictedRequestContext) []po // FilterRegistries filters registries based on user role and team memberships. // Non administrator users only have access to authorized registries. -func FilterRegistries(registries []portainer.Registry, context *RestrictedRequestContext) ([]portainer.Registry, error) { - +func FilterRegistries(registries []portainer.Registry, context *RestrictedRequestContext) []portainer.Registry { filteredRegistries := registries if !context.IsAdmin { filteredRegistries = make([]portainer.Registry, 0) @@ -75,12 +74,12 @@ func FilterRegistries(registries []portainer.Registry, context *RestrictedReques } } - return filteredRegistries, nil + return filteredRegistries } // FilterEndpoints filters endpoints based on user role and team memberships. // Non administrator users only have access to authorized endpoints (can be inherited via endoint groups). -func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.EndpointGroup, context *RestrictedRequestContext) ([]portainer.Endpoint, error) { +func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.EndpointGroup, context *RestrictedRequestContext) []portainer.Endpoint { filteredEndpoints := endpoints if !context.IsAdmin { @@ -95,12 +94,12 @@ func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.Endpoint } } - return filteredEndpoints, nil + return filteredEndpoints } // FilterEndpointGroups filters endpoint groups based on user role and team memberships. // Non administrator users only have access to authorized endpoint groups. -func FilterEndpointGroups(endpointGroups []portainer.EndpointGroup, context *RestrictedRequestContext) ([]portainer.EndpointGroup, error) { +func FilterEndpointGroups(endpointGroups []portainer.EndpointGroup, context *RestrictedRequestContext) []portainer.EndpointGroup { filteredEndpointGroups := endpointGroups if !context.IsAdmin { @@ -113,7 +112,7 @@ func FilterEndpointGroups(endpointGroups []portainer.EndpointGroup, context *Res } } - return filteredEndpointGroups, nil + return filteredEndpointGroups } func getAssociatedGroup(endpoint *portainer.Endpoint, groups []portainer.EndpointGroup) *portainer.EndpointGroup { diff --git a/api/http/security/rate_limiter.go b/api/http/security/rate_limiter.go index 0eb89e0c1..27ab2523a 100644 --- a/api/http/security/rate_limiter.go +++ b/api/http/security/rate_limiter.go @@ -30,7 +30,7 @@ func (limiter *RateLimiter) LimitAccess(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ip := StripAddrPort(r.RemoteAddr) if banned := limiter.Inc(ip); banned == true { - httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, nil) + httperror.WriteError(w, http.StatusForbidden, "Access denied", portainer.ErrResourceAccessDenied) return } next.ServeHTTP(w, r) diff --git a/api/http/server.go b/api/http/server.go index c32374e20..f0b1cdbda 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -5,7 +5,23 @@ import ( "github.com/portainer/portainer" "github.com/portainer/portainer/http/handler" - "github.com/portainer/portainer/http/handler/extensions" + "github.com/portainer/portainer/http/handler/auth" + "github.com/portainer/portainer/http/handler/dockerhub" + "github.com/portainer/portainer/http/handler/endpointgroups" + "github.com/portainer/portainer/http/handler/endpointproxy" + "github.com/portainer/portainer/http/handler/endpoints" + "github.com/portainer/portainer/http/handler/file" + "github.com/portainer/portainer/http/handler/registries" + "github.com/portainer/portainer/http/handler/resourcecontrols" + "github.com/portainer/portainer/http/handler/settings" + "github.com/portainer/portainer/http/handler/stacks" + "github.com/portainer/portainer/http/handler/status" + "github.com/portainer/portainer/http/handler/teammemberships" + "github.com/portainer/portainer/http/handler/teams" + "github.com/portainer/portainer/http/handler/templates" + "github.com/portainer/portainer/http/handler/upload" + "github.com/portainer/portainer/http/handler/users" + "github.com/portainer/portainer/http/handler/websocket" "github.com/portainer/portainer/http/proxy" "github.com/portainer/portainer/http/security" @@ -33,7 +49,8 @@ type Server struct { RegistryService portainer.RegistryService DockerHubService portainer.DockerHubService StackService portainer.StackService - StackManager portainer.StackManager + SwarmStackManager portainer.SwarmStackManager + ComposeStackManager portainer.ComposeStackManager LDAPService portainer.LDAPService GitService portainer.GitService SignatureService portainer.DigitalSignatureService @@ -57,100 +74,102 @@ func (server *Server) Start() error { proxyManager := proxy.NewManager(proxyManagerParameters) rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour) - var fileHandler = handler.NewFileHandler(filepath.Join(server.AssetsPath, "public")) - var authHandler = handler.NewAuthHandler(requestBouncer, rateLimiter, server.AuthDisabled) + var authHandler = auth.NewHandler(requestBouncer, rateLimiter, server.AuthDisabled) authHandler.UserService = server.UserService authHandler.CryptoService = server.CryptoService authHandler.JWTService = server.JWTService authHandler.LDAPService = server.LDAPService authHandler.SettingsService = server.SettingsService - var userHandler = handler.NewUserHandler(requestBouncer) + + var dockerHubHandler = dockerhub.NewHandler(requestBouncer) + dockerHubHandler.DockerHubService = server.DockerHubService + + var endpointHandler = endpoints.NewHandler(requestBouncer, server.EndpointManagement) + endpointHandler.EndpointService = server.EndpointService + endpointHandler.EndpointGroupService = server.EndpointGroupService + endpointHandler.FileService = server.FileService + endpointHandler.ProxyManager = proxyManager + + var endpointGroupHandler = endpointgroups.NewHandler(requestBouncer) + endpointGroupHandler.EndpointGroupService = server.EndpointGroupService + endpointGroupHandler.EndpointService = server.EndpointService + + var endpointProxyHandler = endpointproxy.NewHandler(requestBouncer) + endpointProxyHandler.EndpointService = server.EndpointService + endpointProxyHandler.EndpointGroupService = server.EndpointGroupService + endpointProxyHandler.TeamMembershipService = server.TeamMembershipService + endpointProxyHandler.ProxyManager = proxyManager + + var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public")) + + var registryHandler = registries.NewHandler(requestBouncer) + registryHandler.RegistryService = server.RegistryService + + var resourceControlHandler = resourcecontrols.NewHandler(requestBouncer) + resourceControlHandler.ResourceControlService = server.ResourceControlService + + var settingsHandler = settings.NewHandler(requestBouncer) + settingsHandler.SettingsService = server.SettingsService + settingsHandler.LDAPService = server.LDAPService + settingsHandler.FileService = server.FileService + + var stackHandler = stacks.NewHandler(requestBouncer) + stackHandler.FileService = server.FileService + stackHandler.StackService = server.StackService + stackHandler.EndpointService = server.EndpointService + stackHandler.EndpointGroupService = server.EndpointGroupService + stackHandler.TeamMembershipService = server.TeamMembershipService + stackHandler.ResourceControlService = server.ResourceControlService + stackHandler.SwarmStackManager = server.SwarmStackManager + stackHandler.ComposeStackManager = server.ComposeStackManager + stackHandler.GitService = server.GitService + stackHandler.RegistryService = server.RegistryService + stackHandler.DockerHubService = server.DockerHubService + + var teamHandler = teams.NewHandler(requestBouncer) + teamHandler.TeamService = server.TeamService + teamHandler.TeamMembershipService = server.TeamMembershipService + + var teamMembershipHandler = teammemberships.NewHandler(requestBouncer) + teamMembershipHandler.TeamMembershipService = server.TeamMembershipService + var statusHandler = status.NewHandler(requestBouncer, server.Status) + + var templatesHandler = templates.NewHandler(requestBouncer) + templatesHandler.SettingsService = server.SettingsService + + var uploadHandler = upload.NewHandler(requestBouncer) + uploadHandler.FileService = server.FileService + + var userHandler = users.NewHandler(requestBouncer) userHandler.UserService = server.UserService userHandler.TeamService = server.TeamService userHandler.TeamMembershipService = server.TeamMembershipService userHandler.CryptoService = server.CryptoService userHandler.ResourceControlService = server.ResourceControlService userHandler.SettingsService = server.SettingsService - var teamHandler = handler.NewTeamHandler(requestBouncer) - teamHandler.TeamService = server.TeamService - teamHandler.TeamMembershipService = server.TeamMembershipService - var teamMembershipHandler = handler.NewTeamMembershipHandler(requestBouncer) - teamMembershipHandler.TeamMembershipService = server.TeamMembershipService - var statusHandler = handler.NewStatusHandler(requestBouncer, server.Status) - var settingsHandler = handler.NewSettingsHandler(requestBouncer) - settingsHandler.SettingsService = server.SettingsService - settingsHandler.LDAPService = server.LDAPService - settingsHandler.FileService = server.FileService - var templatesHandler = handler.NewTemplatesHandler(requestBouncer) - templatesHandler.SettingsService = server.SettingsService - var dockerHandler = handler.NewDockerHandler(requestBouncer) - dockerHandler.EndpointService = server.EndpointService - dockerHandler.EndpointGroupService = server.EndpointGroupService - dockerHandler.TeamMembershipService = server.TeamMembershipService - dockerHandler.ProxyManager = proxyManager - var azureHandler = handler.NewAzureHandler(requestBouncer) - azureHandler.EndpointService = server.EndpointService - azureHandler.EndpointGroupService = server.EndpointGroupService - azureHandler.TeamMembershipService = server.TeamMembershipService - azureHandler.ProxyManager = proxyManager - var websocketHandler = handler.NewWebSocketHandler() + + var websocketHandler = websocket.NewHandler() websocketHandler.EndpointService = server.EndpointService websocketHandler.SignatureService = server.SignatureService - var endpointHandler = handler.NewEndpointHandler(requestBouncer, server.EndpointManagement) - endpointHandler.EndpointService = server.EndpointService - endpointHandler.EndpointGroupService = server.EndpointGroupService - endpointHandler.FileService = server.FileService - endpointHandler.ProxyManager = proxyManager - var endpointGroupHandler = handler.NewEndpointGroupHandler(requestBouncer) - endpointGroupHandler.EndpointGroupService = server.EndpointGroupService - endpointGroupHandler.EndpointService = server.EndpointService - var registryHandler = handler.NewRegistryHandler(requestBouncer) - registryHandler.RegistryService = server.RegistryService - var dockerHubHandler = handler.NewDockerHubHandler(requestBouncer) - dockerHubHandler.DockerHubService = server.DockerHubService - var resourceHandler = handler.NewResourceHandler(requestBouncer) - resourceHandler.ResourceControlService = server.ResourceControlService - var uploadHandler = handler.NewUploadHandler(requestBouncer) - uploadHandler.FileService = server.FileService - var stackHandler = handler.NewStackHandler(requestBouncer) - stackHandler.FileService = server.FileService - stackHandler.StackService = server.StackService - stackHandler.EndpointService = server.EndpointService - stackHandler.ResourceControlService = server.ResourceControlService - stackHandler.StackManager = server.StackManager - 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.EndpointGroupService = server.EndpointGroupService - storidgeHandler.TeamMembershipService = server.TeamMembershipService - storidgeHandler.ProxyManager = proxyManager server.Handler = &handler.Handler{ - AuthHandler: authHandler, - UserHandler: userHandler, - TeamHandler: teamHandler, - TeamMembershipHandler: teamMembershipHandler, - EndpointHandler: endpointHandler, - EndpointGroupHandler: endpointGroupHandler, - RegistryHandler: registryHandler, - DockerHubHandler: dockerHubHandler, - ResourceHandler: resourceHandler, - SettingsHandler: settingsHandler, - StatusHandler: statusHandler, - StackHandler: stackHandler, - TemplatesHandler: templatesHandler, - DockerHandler: dockerHandler, - AzureHandler: azureHandler, - WebSocketHandler: websocketHandler, - FileHandler: fileHandler, - UploadHandler: uploadHandler, - ExtensionHandler: extensionHandler, - StoridgeHandler: storidgeHandler, + AuthHandler: authHandler, + DockerHubHandler: dockerHubHandler, + EndpointGroupHandler: endpointGroupHandler, + EndpointHandler: endpointHandler, + EndpointProxyHandler: endpointProxyHandler, + FileHandler: fileHandler, + RegistryHandler: registryHandler, + ResourceControlHandler: resourceControlHandler, + SettingsHandler: settingsHandler, + StatusHandler: statusHandler, + StackHandler: stackHandler, + TeamHandler: teamHandler, + TeamMembershipHandler: teamMembershipHandler, + TemplatesHandler: templatesHandler, + UploadHandler: uploadHandler, + UserHandler: userHandler, + WebSocketHandler: websocketHandler, } if server.SSL { diff --git a/api/libcompose/compose_stack.go b/api/libcompose/compose_stack.go new file mode 100644 index 000000000..33d582f90 --- /dev/null +++ b/api/libcompose/compose_stack.go @@ -0,0 +1,91 @@ +package libcompose + +import ( + "context" + "path" + "path/filepath" + + "github.com/portainer/libcompose/docker" + "github.com/portainer/libcompose/docker/client" + "github.com/portainer/libcompose/docker/ctx" + "github.com/portainer/libcompose/lookup" + "github.com/portainer/libcompose/project" + "github.com/portainer/libcompose/project/options" + "github.com/portainer/portainer" +) + +// ComposeStackManager represents a service for managing compose stacks. +type ComposeStackManager struct { + dataPath string +} + +// NewComposeStackManager initializes a new ComposeStackManager service. +func NewComposeStackManager(dataPath string) *ComposeStackManager { + return &ComposeStackManager{ + dataPath: dataPath, + } +} + +// Up will deploy a compose stack (equivalent of docker-compose up) +func (manager *ComposeStackManager) Up(stack *portainer.Stack, endpoint *portainer.Endpoint) error { + clientFactory, err := client.NewDefaultFactory(client.Options{ + TLS: endpoint.TLSConfig.TLS, + TLSVerify: endpoint.TLSConfig.TLSSkipVerify, + Host: endpoint.URL, + TLSCAFile: endpoint.TLSCACertPath, + TLSCertFile: endpoint.TLSCertPath, + TLSKeyFile: endpoint.TLSKeyPath, + APIVersion: "1.24", + }) + if err != nil { + return err + } + + composeFilePath := path.Join(stack.ProjectPath, stack.EntryPoint) + proj, err := docker.NewProject(&ctx.Context{ + ConfigDir: manager.dataPath, + Context: project.Context{ + ComposeFiles: []string{composeFilePath}, + EnvironmentLookup: &lookup.EnvfileLookup{ + Path: filepath.Join(stack.ProjectPath, ".env"), + }, + ProjectName: stack.Name, + }, + ClientFactory: clientFactory, + }, nil) + if err != nil { + return err + } + + return proj.Up(context.Background(), options.Up{}) +} + +// Down will shutdown a compose stack (equivalent of docker-compose down) +func (manager *ComposeStackManager) Down(stack *portainer.Stack, endpoint *portainer.Endpoint) error { + clientFactory, err := client.NewDefaultFactory(client.Options{ + TLS: endpoint.TLSConfig.TLS, + TLSVerify: endpoint.TLSConfig.TLSSkipVerify, + Host: endpoint.URL, + TLSCAFile: endpoint.TLSCACertPath, + TLSCertFile: endpoint.TLSCertPath, + TLSKeyFile: endpoint.TLSKeyPath, + APIVersion: "1.24", + }) + if err != nil { + return err + } + + composeFilePath := path.Join(stack.ProjectPath, stack.EntryPoint) + proj, err := docker.NewProject(&ctx.Context{ + Context: project.Context{ + ComposeFiles: []string{composeFilePath}, + ProjectName: stack.Name, + }, + ClientFactory: clientFactory, + }, nil) + if err != nil { + return err + } + + return proj.Down(context.Background(), options.Down{RemoveVolume: true, RemoveOrphans: true}) +} diff --git a/api/portainer.go b/api/portainer.go index e064285fa..5053cfccd 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -1,7 +1,5 @@ package portainer -import "io" - type ( // Pair defines a key/value string pair Pair struct { @@ -133,14 +131,19 @@ type ( // StackID represents a stack identifier (it must be composed of Name + "_" + SwarmID to create a unique identifier). StackID string + // StackType represents the type of the stack (compose v2, stack deploy v3). + StackType int + // Stack represents a Docker stack created via docker stack deploy. Stack struct { - ID StackID `json:"Id"` - Name string `json:"Name"` - EntryPoint string `json:"EntryPoint"` - SwarmID string `json:"SwarmId"` + ID StackID `json:"Id"` + Name string `json:"Name"` + Type StackType `json:"Type"` + EndpointID EndpointID `json:"EndpointId"` + SwarmID string `json:"SwarmId"` + EntryPoint string `json:"EntryPoint"` + Env []Pair `json:"Env"` ProjectPath string - Env []Pair `json:"Env"` } // RegistryID represents a registry identifier. @@ -352,8 +355,8 @@ type ( // StackService represents a service for managing stack data. StackService interface { Stack(ID StackID) (*Stack, error) + StackByName(name string) (*Stack, error) Stacks() ([]Stack, error) - StacksBySwarmID(ID string) ([]Stack, error) CreateStack(stack *Stack) error UpdateStack(ID StackID, stack *Stack) error DeleteStack(ID StackID) error @@ -412,13 +415,12 @@ type ( FileService interface { GetFileContent(filePath string) (string, error) RemoveDirectory(directoryPath string) error - StoreTLSFile(folder string, fileType TLSFileType, r io.Reader) error + StoreTLSFileFromBytes(folder string, fileType TLSFileType, data []byte) (string, error) GetPathForTLSFile(folder string, fileType TLSFileType) (string, error) DeleteTLSFile(folder string, fileType TLSFileType) error DeleteTLSFiles(folder string) error GetStackProjectPath(stackIdentifier string) string - StoreStackFileFromString(stackIdentifier, fileName, stackFileContent string) (string, error) - StoreStackFileFromReader(stackIdentifier, fileName string, r io.Reader) (string, error) + StoreStackFileFromBytes(stackIdentifier, fileName string, data []byte) (string, error) KeyPairFilesExist() (bool, error) StoreKeyPair(private, public []byte, privatePEMHeader, publicPEMHeader string) error LoadKeyPair() ([]byte, []byte, error) @@ -442,13 +444,19 @@ type ( TestConnectivity(settings *LDAPSettings) error } - // StackManager represents a service to manage stacks. - StackManager interface { + // SwarmStackManager represents a service to manage Swarm stacks. + SwarmStackManager interface { Login(dockerhub *DockerHub, registries []Registry, endpoint *Endpoint) Logout(endpoint *Endpoint) error Deploy(stack *Stack, prune bool, endpoint *Endpoint) error Remove(stack *Stack, endpoint *Endpoint) error } + + // ComposeStackManager represents a service to manage Compose stacks. + ComposeStackManager interface { + Up(stack *Stack, endpoint *Endpoint) error + Down(stack *Stack, endpoint *Endpoint) error + } ) const ( @@ -543,3 +551,11 @@ const ( // AzureEnvironment represents an endpoint connected to an Azure environment AzureEnvironment ) + +const ( + _ StackType = iota + // DockerSwarmStack represents a stack managed via docker stack + DockerSwarmStack + // DockerComposeStack represents a stack managed via docker-compose + DockerComposeStack +) diff --git a/api/swagger.yaml b/api/swagger.yaml index 481a99930..13277649e 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -50,9 +50,7 @@ info: Instead, it acts as a reverse-proxy to the Docker HTTP API. This means that you can execute Docker requests **via** the Portainer HTTP API. - To do so, you can use the `/endpoints/{id}/docker` Portainer API endpoint (which is not documented below due to Swagger limitations). This - endpoint has a restricted access policy so you still need to be authenticated to be able to query this endpoint. Any query on this endpoint will be proxied to the - Docker API of the associated endpoint (requests and responses objects are the same as documented in the Docker API). + To do so, you can use the `/endpoints/{id}/docker` Portainer API endpoint (which is not documented below due to Swagger limitations). This endpoint has a restricted access policy so you still need to be authenticated to be able to query this endpoint. Any query on this endpoint will be proxied to the Docker API of the associated endpoint (requests and responses objects are the same as documented in the Docker API). **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). @@ -69,6 +67,8 @@ tags: description: "Manage how Portainer connects to the DockerHub" - name: "endpoints" description: "Manage Docker environments" +- name: "endpoint_groups" + description: "Manage endpoint groups" - name: "registries" description: "Manage Docker registries" - name: "resource_controls" @@ -87,6 +87,8 @@ tags: description: "Manage team memberships" - name: "templates" description: "Manage App Templates" +- name: "stacks" + description: "Manage stacks" - name: "upload" description: "Upload files" - name: "websocket" @@ -154,7 +156,7 @@ paths: 200: description: "Success" schema: - $ref: "#/definitions/DockerHubInspectResponse" + $ref: "#/definitions/DockerHubSubset" 500: description: "Server error" schema: @@ -181,6 +183,8 @@ paths: responses: 200: description: "Success" + schema: + $ref: "#/definitions/DockerHub" 400: description: "Invalid request" schema: @@ -233,11 +237,15 @@ paths: type: "string" description: "Name that will be used to identify this endpoint (example: my-endpoint)" required: true + - name: "EndpointType" + in: "formData" + type: "integer" + description: "Environment type. Value must be one of: 1 (Docker environment), 2 (Agent environment) or 3 (Azure environment)" + required: true - name: "URL" in: "formData" type: "string" - description: "URL or IP address of a Docker host (example: docker.mydomain.tld:2375)" - required: true + description: "URL or IP address of a Docker host (example: docker.mydomain.tld:2375). Required if endpoint type is set to 1 or 2." - name: "PublicURL" in: "formData" type: "string" @@ -267,6 +275,18 @@ paths: in: "formData" type: "file" description: "TLS client key file" + - name: "AzureApplicationID" + in: "formData" + type: "string" + description: "Azure application ID. Required if endpoint type is set to 3" + - name: "AzureTenantID" + in: "formData" + type: "string" + description: "Azure tenant ID. Required if endpoint type is set to 3" + - name: "AzureAuthenticationKey" + in: "formData" + type: "string" + description: "Azure authentication key. Required if endpoint type is set to 3" responses: 200: description: "Success" @@ -397,7 +417,7 @@ paths: required: true type: "integer" responses: - 200: + 204: description: "Success" 400: description: "Invalid request" @@ -452,6 +472,8 @@ paths: responses: 200: description: "Success" + schema: + $ref: "#/definitions/Endpoint" 400: description: "Invalid request" schema: @@ -470,76 +492,53 @@ paths: description: "Server error" schema: $ref: "#/definitions/GenericError" - - /endpoints/{endpointId}/stacks: + /endpoint_groups: get: tags: - - "stacks" - summary: "List stacks" + - "endpoint_groups" + summary: "List endpoint groups" description: | - List all stacks based on the current user authorizations. - Will return all stacks if using an administrator account otherwise it - will only return the list of stacks the user have access to. + List all endpoint groups based on the current user authorizations. Will + return all endpoint groups if using an administrator account otherwise it will + only return authorized endpoint groups. **Access policy**: restricted - operationId: "StackList" + operationId: "EndpointGroupList" produces: - "application/json" - parameters: - - name: "endpointId" - in: "path" - description: "Endpoint identifier" - required: true - type: "integer" + parameters: [] responses: 200: description: "Success" schema: - $ref: "#/definitions/StackListResponse" - 403: - description: "Unauthorized" - schema: - $ref: "#/definitions/GenericError" - examples: - application/json: - err: "Access denied to resource" + $ref: "#/definitions/EndpointGroupListResponse" 500: description: "Server error" schema: $ref: "#/definitions/GenericError" post: tags: - - "stacks" - summary: "Deploy a new stack" + - "endpoint_groups" + summary: "Create a new endpoint" description: | - Deploy a new stack into a Docker environment specified via the endpoint identifier. - **Access policy**: restricted - operationId: "StackCreate" + Create a new endpoint group. + **Access policy**: administrator + operationId: "EndpointGroupCreate" consumes: - "application/json" produces: - "application/json" parameters: - - name: "endpointId" - in: "path" - description: "Endpoint identifier" - required: true - type: "integer" - - name: "method" - in: "query" - description: "Stack deployment method. Possible values: string or repository." - required: true - type: "string" - in: "body" name: "body" - description: "Stack details. Used when" + description: "Registry details" required: true schema: - $ref: "#/definitions/StackCreateRequest" + $ref: "#/definitions/EndpointGroupCreateRequest" responses: 200: description: "Success" schema: - $ref: "#/definitions/StackCreateResponse" + $ref: "#/definitions/EndpointGroup" 400: description: "Invalid request" schema: @@ -547,44 +546,32 @@ paths: examples: application/json: err: "Invalid request data format" - 404: - description: "Endpoint not found" - schema: - $ref: "#/definitions/GenericError" - examples: - application/json: - err: "Endpoint not found" 500: description: "Server error" schema: $ref: "#/definitions/GenericError" - /endpoints/{endpointId}/stacks/{id}: + /endpoint_groups/{id}: get: tags: - - "stacks" - summary: "Inspect a stack" + - "endpoint_groups" + summary: "Inspect an endpoint group" description: | - Retrieve details about a stack. - **Access policy**: restricted - operationId: "StackInspect" + Retrieve details abount an endpoint group. + **Access policy**: administrator + operationId: "EndpointGroupInspect" produces: - "application/json" parameters: - - name: "endpointId" - in: "path" - description: "Endpoint identifier" - required: true - type: "integer" - name: "id" in: "path" - description: "Stack identifier" + description: "Endpoint group identifier" required: true - type: "string" + type: "integer" responses: 200: description: "Success" schema: - $ref: "#/definitions/Stack" + $ref: "#/definitions/EndpointGroup" 400: description: "Invalid request" schema: @@ -592,53 +579,46 @@ paths: examples: application/json: err: "Invalid request" - 403: - description: "Unauthorized" - schema: - $ref: "#/definitions/GenericError" 404: - description: "Stack not found" + description: "EndpointGroup not found" schema: $ref: "#/definitions/GenericError" examples: application/json: - err: "Stack not found" + err: "EndpointGroup not found" 500: description: "Server error" schema: $ref: "#/definitions/GenericError" put: tags: - - "stacks" - summary: "Update a stack" + - "endpoint_groups" + summary: "Update an endpoint group" description: | - Update a stack. - **Access policy**: restricted - operationId: "StackUpdate" + Update an endpoint group. + **Access policy**: administrator + operationId: "EndpointGroupUpdate" consumes: - "application/json" produces: - "application/json" parameters: - - name: "endpointId" - in: "path" - description: "Endpoint identifier" - required: true - type: "integer" - name: "id" in: "path" - description: "Stack identifier" + description: "EndpointGroup identifier" required: true - type: "string" + type: "integer" - in: "body" name: "body" - description: "Stack details" + description: "EndpointGroup details" required: true schema: - $ref: "#/definitions/StackUpdateRequest" + $ref: "#/definitions/EndpointGroupUpdateRequest" responses: 200: description: "Success" + schema: + $ref: "#/definitions/EndpointGroup" 400: description: "Invalid request" schema: @@ -647,37 +627,39 @@ paths: application/json: err: "Invalid request data format" 404: - description: "Stack not found" + description: "EndpointGroup not found" schema: $ref: "#/definitions/GenericError" examples: application/json: - err: "Stack not found" + err: "EndpointGroup not found" 500: description: "Server error" schema: $ref: "#/definitions/GenericError" + 503: + description: "EndpointGroup management disabled" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "EndpointGroup management is disabled" delete: tags: - - "stacks" - summary: "Remove a stack" + - "endpoint_groups" + summary: "Remove an endpoint group" description: | - Remove a stack. - **Access policy**: restricted - operationId: "StackDelete" + Remove an endpoint group. + **Access policy**: administrator + operationId: "EndpointGroupDelete" parameters: - - name: "endpointId" - in: "path" - description: "Endpoint identifier" - required: true - type: "integer" - name: "id" in: "path" - description: "Stack identifier" + description: "EndpointGroup identifier" required: true - type: "string" + type: "integer" responses: - 200: + 204: description: "Success" 400: description: "Invalid request" @@ -686,66 +668,68 @@ paths: examples: application/json: err: "Invalid request" - 403: - description: "Unauthorized" - schema: - $ref: "#/definitions/GenericError" 404: - description: "Stack not found" + description: "EndpointGroup not found" schema: $ref: "#/definitions/GenericError" examples: application/json: - err: "Stack not found" + err: "EndpointGroup not found" 500: description: "Server error" schema: $ref: "#/definitions/GenericError" - /endpoints/{endpointId}/stacks/{id}/stackfile: - get: + 503: + description: "EndpointGroup management disabled" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "EndpointGroup management is disabled" + /endpoint_groups/{id}/access: + put: tags: - - "stacks" - summary: "Retrieve the content of the Stack file for the specified stack" + - "endpoint_groups" + summary: "Manage accesses to an endpoint group" description: | - Get Stack file content. - **Access policy**: restricted - operationId: "StackFileInspect" + Manage user and team accesses to an endpoint group. + **Access policy**: administrator + operationId: "EndpointGroupAccessUpdate" + consumes: + - "application/json" produces: - "application/json" parameters: - - name: "endpointId" - in: "path" - description: "Endpoint identifier" - required: true - type: "integer" - name: "id" in: "path" - description: "Stack identifier" + description: "EndpointGroup identifier" required: true - type: "string" + type: "integer" + - in: "body" + name: "body" + description: "Authorizations details" + required: true + schema: + $ref: "#/definitions/EndpointGroupAccessUpdateRequest" responses: 200: description: "Success" schema: - $ref: "#/definitions/StackFileInspectResponse" + $ref: "#/definitions/EndpointGroup" 400: description: "Invalid request" schema: $ref: "#/definitions/GenericError" examples: application/json: - err: "Invalid request" - 403: - description: "Unauthorized" - schema: - $ref: "#/definitions/GenericError" + err: "Invalid request data format" 404: - description: "Stack not found" + description: "EndpointGroup not found" schema: $ref: "#/definitions/GenericError" examples: application/json: - err: "Stack not found" + err: "EndpointGroup not found" 500: description: "Server error" schema: @@ -796,7 +780,7 @@ paths: 200: description: "Success" schema: - $ref: "#/definitions/RegistryCreateResponse" + $ref: "#/definitions/Registry" 400: description: "Invalid request" schema: @@ -882,6 +866,8 @@ paths: responses: 200: description: "Success" + schema: + $ref: "#/definitions/Registry" 400: description: "Invalid request" schema: @@ -922,7 +908,7 @@ paths: required: true type: "integer" responses: - 200: + 204: description: "Success" 400: description: "Invalid request" @@ -970,6 +956,8 @@ paths: responses: 200: description: "Success" + schema: + $ref: "#/definitions/Registry" 400: description: "Invalid request" schema: @@ -1011,6 +999,8 @@ paths: responses: 200: description: "Success" + schema: + $ref: "#/definitions/ResourceControl" 400: description: "Invalid request" schema: @@ -1064,6 +1054,8 @@ paths: responses: 200: description: "Success" + schema: + $ref: "#/definitions/ResourceControl" 400: description: "Invalid request" schema: @@ -1104,7 +1096,7 @@ paths: required: true type: "integer" responses: - 200: + 204: description: "Success" 400: description: "Invalid request" @@ -1174,6 +1166,8 @@ paths: responses: 200: description: "Success" + schema: + $ref: "#/definitions/Settings" 400: description: "Invalid request" schema: @@ -1227,7 +1221,7 @@ paths: schema: $ref: "#/definitions/SettingsLDAPCheckRequest" responses: - 200: + 204: description: "Success" 400: description: "Invalid request" @@ -1261,6 +1255,305 @@ paths: description: "Server error" schema: $ref: "#/definitions/GenericError" + /stacks: + get: + tags: + - "stacks" + summary: "List stacks" + description: | + List all stacks based on the current user authorizations. + Will return all stacks if using an administrator account otherwise it + will only return the list of stacks the user have access to. + **Access policy**: restricted + operationId: "StackList" + produces: + - "application/json" + parameters: + - name: "filters" + in: "query" + description: | + Filters to process on the stack list. Encoded as JSON (a map[string]string). + For example, {"SwarmID": "jpofkc0i9uo9wtx1zesuk649w"} will only return stacks that are part + of the specified Swarm cluster. Available filters: EndpointID, SwarmID. + type: "string" + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/StackListResponse" + examples: + application/json: + err: "Access denied to resource" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + post: + tags: + - "stacks" + summary: "Deploy a new stack" + description: | + Deploy a new stack into a Docker environment specified via the endpoint identifier. + **Access policy**: restricted + operationId: "StackCreate" + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - name: "type" + in: "query" + description: "Stack deployment type. Possible values: 1 (Swarm stack) or 2 (Compose stack)." + required: true + type: "integer" + - name: "method" + in: "query" + description: "Stack deployment method. Possible values: file, string or repository." + required: true + type: "string" + - name: "endpointId" + in: "query" + description: "Identifier of the endpoint that will be used to deploy the stack." + required: true + type: "integer" + - in: "body" + name: "body" + description: "Stack details. Required when method equals string or repository." + schema: + $ref: "#/definitions/StackCreateRequest" + - name: "Name" + in: "formData" + type: "string" + description: "Name of the stack. Required when method equals file." + - name: "EndpointID" + in: "formData" + type: "string" + description: "Endpoint identifier used to deploy the stack. Required when method equals file." + - name: "SwarmID" + in: "formData" + type: "string" + description: "Swarm cluster identifier. Required when method equals file and type equals 1." + - name: "StackFileContent" + in: "formData" + type: "file" + description: "Stack file. Required when method equals file." + - name: "Env" + in: "formData" + type: "string" + description: "Environment variables passed during deployment, represented as a JSON array [{'name': 'name', 'value': 'value'}]. Optional, used when method equals file and type equals 1." + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/Stack" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request data format" + 403: + description: "Unauthorized" + schema: + $ref: "#/definitions/GenericError" + 404: + description: "Endpoint not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Endpoint not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + /stacks/{id}: + get: + tags: + - "stacks" + summary: "Inspect a stack" + description: | + Retrieve details about a stack. + **Access policy**: restricted + operationId: "StackInspect" + produces: + - "application/json" + parameters: + - name: "id" + in: "path" + description: "Stack identifier" + required: true + type: "string" + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/Stack" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request" + 403: + description: "Unauthorized" + schema: + $ref: "#/definitions/GenericError" + 404: + description: "Stack not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Stack not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + put: + tags: + - "stacks" + summary: "Update a stack" + description: | + Update a stack. + **Access policy**: restricted + operationId: "StackUpdate" + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - name: "id" + in: "path" + description: "Stack identifier" + required: true + type: "string" + - in: "body" + name: "body" + description: "Stack details" + required: true + schema: + $ref: "#/definitions/StackUpdateRequest" + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/Stack" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request data format" + 403: + description: "Unauthorized" + schema: + $ref: "#/definitions/GenericError" + 404: + description: "Stack not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Stack not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + delete: + tags: + - "stacks" + summary: "Remove a stack" + description: | + Remove a stack. + **Access policy**: restricted + operationId: "StackDelete" + parameters: + - name: "id" + in: "path" + description: "Stack identifier" + required: true + type: "string" + - name: "external" + in: "query" + description: "Set to true to delete an external stack. Only external Swarm stacks are supported." + type: "boolean" + - name: "endpointId" + in: "query" + description: "Endpoint identifier used to remove an external stack (required when external is set to true)" + type: "string" + responses: + 204: + description: "Success" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request" + 403: + description: "Unauthorized" + schema: + $ref: "#/definitions/GenericError" + 404: + description: "Stack not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Stack not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + /stacks/{id}/file: + get: + tags: + - "stacks" + summary: "Retrieve the content of the Stack file for the specified stack" + description: | + Get Stack file content. + **Access policy**: restricted + operationId: "StackFileInspect" + produces: + - "application/json" + parameters: + - name: "id" + in: "path" + description: "Stack identifier" + required: true + type: "string" + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/StackFileInspectResponse" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request" + 403: + description: "Unauthorized" + schema: + $ref: "#/definitions/GenericError" + 404: + description: "Stack not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Stack not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" /users: get: tags: @@ -1306,7 +1599,7 @@ paths: 200: description: "Success" schema: - $ref: "#/definitions/UserCreateResponse" + $ref: "#/definitions/UserSubset" 400: description: "Invalid request" schema: @@ -1399,6 +1692,8 @@ paths: responses: 200: description: "Success" + schema: + $ref: "#/definitions/User" 400: description: "Invalid request" schema: @@ -1439,7 +1734,7 @@ paths: required: true type: "integer" responses: - 200: + 204: description: "Success" 400: description: "Invalid request" @@ -1562,10 +1857,8 @@ paths: - "application/json" parameters: [] responses: - 200: + 204: description: "Success" - schema: - $ref: "#/definitions/UserListResponse" 404: description: "User not found" schema: @@ -1601,6 +1894,8 @@ paths: responses: 200: description: "Success" + schema: + $ref: "#/definitions/User" 400: description: "Invalid request" schema: @@ -1707,7 +2002,7 @@ paths: 200: description: "Success" schema: - $ref: "#/definitions/TeamCreateResponse" + $ref: "#/definitions/Team" 400: description: "Invalid request" schema: @@ -1840,7 +2135,7 @@ paths: required: true type: "integer" responses: - 200: + 204: description: "Success" 400: description: "Invalid request" @@ -1953,7 +2248,7 @@ paths: 200: description: "Success" schema: - $ref: "#/definitions/TeamMembershipCreateResponse" + $ref: "#/definitions/TeamMembership" 400: description: "Invalid request" schema: @@ -2007,6 +2302,8 @@ paths: responses: 200: description: "Success" + schema: + $ref: "#/definitions/TeamMembership" 400: description: "Invalid request" schema: @@ -2047,7 +2344,7 @@ paths: required: true type: "integer" responses: - 200: + 204: description: "Success" 400: description: "Invalid request" @@ -2144,6 +2441,21 @@ definitions: type: "integer" example: 1 description: "Team role (1 for team leader and 2 for team member)" + UserSubset: + type: "object" + properties: + Id: + type: "integer" + example: 1 + description: "User identifier" + Username: + type: "string" + example: "bob" + description: "Username" + Role: + type: "integer" + example: 1 + description: "User role (1 for administrator account and 2 for regular account)" User: type: "object" properties: @@ -2155,6 +2467,10 @@ definitions: type: "string" example: "bob" description: "Username" + Password: + type: "string" + example: "passwd" + description: "Password" Role: type: "integer" example: 1 @@ -2227,7 +2543,21 @@ definitions: type: "string" example: "/data/tls/key.pem" description: "Path to the TLS client key file" - + AzureCredentials: + type: "object" + properties: + ApplicationID: + type: "string" + example: "eag7cdo9-o09l-9i83-9dO9-f0b23oe78db4" + description: "Azure application ID" + TenantID: + type: "string" + example: "34ddc78d-4fel-2358-8cc1-df84c8o839f5" + description: "Azure tenant ID" + AuthenticationKey: + type: "string" + example: "cOrXoK/1D35w8YQ8nH1/8ZGwzz45JIYD5jxHKXEQknk=" + description: "Azure authentication key" LDAPSearchSettings: type: "object" properties: @@ -2317,6 +2647,14 @@ definitions: value: type: "string" example: "bar" + Pair: + properties: + name: + type: "string" + example: "name" + value: + type: "string" + example: "value" Registry: type: "object" properties: @@ -2358,6 +2696,76 @@ definitions: type: "integer" example: 1 description: "Team identifier" + RegistrySubset: + type: "object" + properties: + Id: + type: "integer" + example: 1 + description: "Registry identifier" + Name: + type: "string" + example: "my-registry" + description: "Registry name" + URL: + type: "string" + example: "registry.mydomain.tld:2375" + description: "URL or IP address of the Docker registry" + Authentication: + type: "boolean" + example: true + description: "Is authentication against this registry enabled" + Username: + type: "string" + example: "registry_user" + description: "Username used to authenticate against this registry" + AuthorizedUsers: + type: "array" + description: "List of user identifiers authorized to use this registry" + items: + type: "integer" + example: 1 + description: "User identifier" + AuthorizedTeams: + type: "array" + description: "List of team identifiers authorized to use this registry" + items: + type: "integer" + example: 1 + description: "Team identifier" + EndpointGroup: + type: "object" + properties: + Id: + type: "integer" + example: 1 + description: "Endpoint group identifier" + Name: + type: "string" + example: "my-endpoint-group" + description: "Endpoint group name" + Description: + type: "string" + example: "Description associated to the endpoint group" + description: "Endpoint group description" + AuthorizedUsers: + type: "array" + description: "List of user identifiers authorized to connect to this endpoint group. Will be inherited by endpoints that are part of the group" + items: + type: "integer" + example: 1 + description: "User identifier" + AuthorizedTeams: + type: "array" + description: "List of team identifiers authorized to connect to this endpoint. Will be inherited by endpoints that are part of the group" + items: + type: "integer" + example: 1 + description: "Team identifier" + Labels: + type: "array" + items: + $ref: "#/definitions/Pair" Endpoint: type: "object" properties: @@ -2372,7 +2780,7 @@ definitions: Type: type: "integer" example: 1 - description: "Endpoint environment type. 1 for a Docker environment, 2 for an agent on Docker environment." + description: "Endpoint environment type. 1 for a Docker environment, 2 for an agent on Docker environment or 3 for an Azure environment." URL: type: "string" example: "docker.mydomain.tld:2375" @@ -2399,6 +2807,53 @@ definitions: type: "integer" example: 1 description: "Team identifier" + TLSConfig: + $ref: "#/definitions/TLSConfiguration" + AzureCredentials: + $ref: "#/definitions/AzureCredentials" + EndpointSubset: + type: "object" + properties: + Id: + type: "integer" + example: 1 + description: "Endpoint identifier" + Name: + type: "string" + example: "my-endpoint" + description: "Endpoint name" + Type: + type: "integer" + example: 1 + description: "Endpoint environment type. 1 for a Docker environment, 2 for an agent on Docker environment, 3 for an Azure environment." + URL: + type: "string" + example: "docker.mydomain.tld:2375" + description: "URL or IP address of the Docker host associated to this endpoint" + PublicURL: + type: "string" + example: "docker.mydomain.tld:2375" + description: "URL or IP address where exposed containers will be reachable" + GroupID: + type: "integer" + example: 1 + description: "Endpoint group identifier" + AuthorizedUsers: + type: "array" + description: "List of user identifiers authorized to connect to this endpoint" + items: + type: "integer" + example: 1 + description: "User identifier" + AuthorizedTeams: + type: "array" + description: "List of team identifiers authorized to connect to this endpoint" + items: + type: "integer" + example: 1 + description: "Team identifier" + TLSConfig: + $ref: "#/definitions/TLSConfiguration" GenericError: type: "object" properties: @@ -2427,7 +2882,18 @@ definitions: type: "string" example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGUiOjEsImV4cCI6MTQ5OTM3NjE1NH0.NJ6vE8FY1WG6jsRQzfMqeatJ4vh2TWAeeYfDhP71YEE" description: "JWT token used to authenticate against the API" - DockerHubInspectResponse: + DockerHubSubset: + type: "object" + properties: + Authentication: + type: "boolean" + example: true + description: "Is authentication against DockerHub enabled" + Username: + type: "string" + example: "hub_user" + description: "Username used to authenticate against the DockerHub" + DockerHub: type: "object" properties: Authentication: @@ -2442,6 +2908,45 @@ definitions: type: "string" example: "hub_password" description: "Password used to authenticate against the DockerHub" + ResourceControl: + type: "object" + properties: + ResourceID: + type: "string" + example: "617c5f22bb9b023d6daab7cba43a57576f83492867bc767d1c59416b065e5f08" + description: "Docker resource identifier on which access control will be applied.\ + \ In the case of a resource control applied to a stack, use the stack name as identifier" + Type: + type: "string" + example: "container" + description: "Type of Docker resource. Valid values are: container, volume\ + \ service, secret, config or stack" + AdministratorsOnly: + type: "boolean" + example: true + description: "Restrict access to the associated resource to administrators\ + \ only" + Users: + type: "array" + description: "List of user identifiers with access to the associated resource" + items: + type: "integer" + example: 1 + description: "User identifier" + Teams: + type: "array" + description: "List of team identifiers with access to the associated resource" + items: + type: "integer" + example: 1 + description: "Team identifier" + SubResourceIDs: + type: "array" + description: "List of Docker resources that will inherit this access control" + items: + type: "string" + example: "617c5f22bb9b023d6daab7cba43a57576f83492867bc767d1c59416b065e5f08" + description: "Docker resource identifier" DockerHubUpdateRequest: type: "object" required: @@ -2464,7 +2969,11 @@ definitions: EndpointListResponse: type: "array" items: - $ref: "#/definitions/Endpoint" + $ref: "#/definitions/EndpointSubset" + EndpointGroupListResponse: + type: "array" + items: + $ref: "#/definitions/EndpointGroup" EndpointUpdateRequest: type: "object" properties: @@ -2481,6 +2990,10 @@ definitions: example: "docker.mydomain.tld:2375" description: "URL or IP address where exposed containers will be reachable.\ \ Defaults to URL if not specified" + GroupID: + type: "integer" + example: "1" + description: "Group identifier" TLS: type: "boolean" example: true @@ -2493,6 +3006,18 @@ definitions: type: "boolean" example: false description: "Skip client verification when using TLS" + ApplicationID: + type: "string" + example: "eag7cdo9-o09l-9i83-9dO9-f0b23oe78db4" + description: "Azure application ID" + TenantID: + type: "string" + example: "34ddc78d-4fel-2358-8cc1-df84c8o839f5" + description: "Azure tenant ID" + AuthenticationKey: + type: "string" + example: "cOrXoK/1D35w8YQ8nH1/8ZGwzz45JIYD5jxHKXEQknk=" + description: "Azure authentication key" EndpointAccessUpdateRequest: type: "object" properties: @@ -2510,6 +3035,23 @@ definitions: type: "integer" example: 1 description: "Team identifier" + EndpointGroupAccessUpdateRequest: + type: "object" + properties: + AuthorizedUsers: + type: "array" + description: "List of user identifiers authorized to connect to this endpoint" + items: + type: "integer" + example: 1 + description: "User identifier" + AuthorizedTeams: + type: "array" + description: "List of team identifiers authorized to connect to this endpoint" + items: + type: "integer" + example: 1 + description: "Team identifier" RegistryCreateRequest: type: "object" required: @@ -2539,17 +3081,10 @@ definitions: type: "string" example: "registry_password" description: "Password used to authenticate against this registry" - RegistryCreateResponse: - type: "object" - properties: - Id: - type: "integer" - example: 1 - description: "Id of the registry" RegistryListResponse: type: "array" items: - $ref: "#/definitions/Registry" + $ref: "#/definitions/RegistrySubset" RegistryUpdateRequest: type: "object" required: @@ -2699,6 +3234,52 @@ definitions: type: "boolean" example: true description: "Whether non-administrator users should be able to use privileged mode when creating containers" + EndpointGroupCreateRequest: + type: "object" + required: + - "Name" + properties: + Name: + type: "string" + example: "my-endpoint-group" + description: "Endpoint group name" + Description: + type: "string" + example: "Endpoint group description" + description: "Endpoint group description" + Labels: + type: "array" + items: + $ref: "#/definitions/Pair" + AssociatedEndpoints: + type: "array" + description: "List of endpoint identifiers that will be part of this group" + items: + type: "integer" + example: 1 + description: "Endpoint identifier" + EndpointGroupUpdateRequest: + type: "object" + properties: + Name: + type: "string" + example: "my-endpoint-group" + description: "Endpoint group name" + Description: + type: "string" + example: "Endpoint group description" + description: "Endpoint group description" + Labels: + type: "array" + items: + $ref: "#/definitions/Pair" + AssociatedEndpoints: + type: "array" + description: "List of endpoint identifiers that will be part of this group" + items: + type: "integer" + example: 1 + description: "Endpoint identifier" UserCreateRequest: type: "object" required: @@ -2718,17 +3299,10 @@ definitions: type: "integer" example: 1 description: "User role (1 for administrator account and 2 for regular account)" - UserCreateResponse: - type: "object" - properties: - Id: - type: "integer" - example: 1 - description: "Id of the user" UserListResponse: type: "array" items: - $ref: "#/definitions/User" + $ref: "#/definitions/UserSubset" UserUpdateRequest: type: "object" properties: @@ -2769,13 +3343,6 @@ definitions: type: "string" example: "developers" description: "Name" - TeamCreateResponse: - type: "object" - properties: - Id: - type: "integer" - example: 1 - description: "Id of the team" TeamListResponse: type: "array" items: @@ -2813,13 +3380,6 @@ definitions: type: "integer" example: 1 description: "Role for the user inside the team (1 for leader and 2 for regular member)" - TeamMembershipCreateResponse: - type: "object" - properties: - Id: - type: "integer" - example: 1 - description: "Id of the team membership" TeamMembershipListResponse: type: "array" items: @@ -2886,7 +3446,6 @@ definitions: type: "object" required: - "Name" - - "SwarmID" properties: Name: type: "string" @@ -2895,7 +3454,7 @@ definitions: SwarmID: type: "string" example: "jpofkc0i9uo9wtx1zesuk649w" - description: "Cluster identifier of the Swarm cluster" + description: "Swarm cluster identifier. Required when creating a Swarm stack (type 1)." StackFileContent: type: "string" example: "version: 3\n services:\n web:\n image:nginx" @@ -2907,7 +3466,7 @@ definitions: ComposeFilePathInRepository: type: "string" example: "docker-compose.yml" - description: "Path to the Stack file inside the Git repository. Required when using the 'repository' deployment method." + description: "Path to the Stack file inside the Git repository. Will default to 'docker-compose.yml' if not specified." RepositoryAuthentication: type: "boolean" example: true @@ -2933,13 +3492,6 @@ definitions: value: type: "string" example: "password" - StackCreateResponse: - type: "object" - properties: - Id: - type: "string" - example: "myStack_jpofkc0i9uo9wtx1zesuk649w" - description: "Id of the stack" StackListResponse: type: "array" items: @@ -2955,6 +3507,14 @@ definitions: type: "string" example: "myStack" description: "Stack name" + Type: + type: "integer" + example: "1" + description: "Stack type. 1 for a Swarm stack, 2 for a Compose stack" + EndpointID: + type: "integer" + example: "1" + description: "Endpoint identifier. Reference the endpoint that will be used for deployment " EntryPoint: type: "string" example: "docker-compose.yml" diff --git a/app/constants.js b/app/constants.js index 8a16181f7..a51ce323e 100644 --- a/app/constants.js +++ b/app/constants.js @@ -6,6 +6,7 @@ angular.module('portainer') .constant('API_ENDPOINT_REGISTRIES', 'api/registries') .constant('API_ENDPOINT_RESOURCE_CONTROLS', 'api/resource_controls') .constant('API_ENDPOINT_SETTINGS', 'api/settings') +.constant('API_ENDPOINT_STACKS', 'api/stacks') .constant('API_ENDPOINT_STATUS', 'api/status') .constant('API_ENDPOINT_USERS', 'api/users') .constant('API_ENDPOINT_TEAMS', 'api/teams') diff --git a/app/docker/__module.js b/app/docker/__module.js index 28ad5e52d..82041efeb 100644 --- a/app/docker/__module.js +++ b/app/docker/__module.js @@ -49,9 +49,6 @@ angular.module('portainer.docker', ['portainer.app']) templateUrl: 'app/docker/views/containers/containers.html', controller: 'ContainersController' } - }, - params: { - selectedContainers: [] } }; @@ -314,39 +311,6 @@ angular.module('portainer.docker', ['portainer.app']) } }; - var stacks = { - name: 'docker.stacks', - url: '/stacks', - views: { - 'content@': { - templateUrl: 'app/docker/views/stacks/stacks.html', - controller: 'StacksController' - } - } - }; - - var stack = { - name: 'docker.stacks.stack', - url: '/:id', - views: { - 'content@': { - templateUrl: 'app/docker/views/stacks/edit/stack.html', - controller: 'StackController' - } - } - }; - - var stackCreation = { - name: 'docker.stacks.new', - url: '/new', - views: { - 'content@': { - templateUrl: 'app/docker/views/stacks/create/createstack.html', - controller: 'CreateStackController' - } - } - }; - var swarm = { name: 'docker.swarm', url: '/swarm', @@ -489,9 +453,6 @@ angular.module('portainer.docker', ['portainer.app']) $stateRegistryProvider.register(service); $stateRegistryProvider.register(serviceCreation); $stateRegistryProvider.register(serviceLogs); - $stateRegistryProvider.register(stacks); - $stateRegistryProvider.register(stack); - $stateRegistryProvider.register(stackCreation); $stateRegistryProvider.register(swarm); $stateRegistryProvider.register(swarmVisualizer); $stateRegistryProvider.register(tasks); diff --git a/app/docker/components/datatables/containers-datatable/actions/containersDatatableActions.html b/app/docker/components/datatables/containers-datatable/actions/containersDatatableActions.html new file mode 100644 index 000000000..37bc895ed --- /dev/null +++ b/app/docker/components/datatables/containers-datatable/actions/containersDatatableActions.html @@ -0,0 +1,35 @@ +
+
+ + + + + + + +
+ +
diff --git a/app/docker/components/datatables/containers-datatable/actions/containersDatatableActions.js b/app/docker/components/datatables/containers-datatable/actions/containersDatatableActions.js new file mode 100644 index 000000000..996b5e2c6 --- /dev/null +++ b/app/docker/components/datatables/containers-datatable/actions/containersDatatableActions.js @@ -0,0 +1,12 @@ +angular.module('portainer.docker').component('containersDatatableActions', { + templateUrl: 'app/docker/components/datatables/containers-datatable/actions/containersDatatableActions.html', + controller: 'ContainersDatatableActionsController', + bindings: { + selectedItems: '=', + selectedItemCount: '=', + noStoppedItemsSelected: '=', + noRunningItemsSelected: '=', + noPausedItemsSelected: '=', + showAddAction: '<' + } +}); diff --git a/app/docker/components/datatables/containers-datatable/actions/containersDatatableActionsController.js b/app/docker/components/datatables/containers-datatable/actions/containersDatatableActionsController.js new file mode 100644 index 000000000..94ce439b3 --- /dev/null +++ b/app/docker/components/datatables/containers-datatable/actions/containersDatatableActionsController.js @@ -0,0 +1,104 @@ +angular.module('portainer.docker') +.controller('ContainersDatatableActionsController', ['$state', 'ContainerService', 'ModalService', 'Notifications', 'HttpRequestHelper', +function ($state, ContainerService, ModalService, Notifications, HttpRequestHelper) { + this.startAction = function(selectedItems) { + var successMessage = 'Container successfully started'; + var errorMessage = 'Unable to start container'; + executeActionOnContainerList(selectedItems, ContainerService.startContainer, successMessage, errorMessage); + }; + + this.stopAction = function(selectedItems) { + var successMessage = 'Container successfully stopped'; + var errorMessage = 'Unable to stop container'; + executeActionOnContainerList(selectedItems, ContainerService.stopContainer, successMessage, errorMessage); + }; + + this.restartAction = function(selectedItems) { + var successMessage = 'Container successfully restarted'; + var errorMessage = 'Unable to restart container'; + executeActionOnContainerList(selectedItems, ContainerService.restartContainer, successMessage, errorMessage); + }; + + this.killAction = function(selectedItems) { + var successMessage = 'Container successfully killed'; + var errorMessage = 'Unable to kill container'; + executeActionOnContainerList(selectedItems, ContainerService.killContainer, successMessage, errorMessage); + }; + + this.pauseAction = function(selectedItems) { + var successMessage = 'Container successfully paused'; + var errorMessage = 'Unable to pause container'; + executeActionOnContainerList(selectedItems, ContainerService.pauseContainer, successMessage, errorMessage); + }; + + this.resumeAction = function(selectedItems) { + var successMessage = 'Container successfully resumed'; + var errorMessage = 'Unable to resume container'; + executeActionOnContainerList(selectedItems, ContainerService.resumeContainer, successMessage, errorMessage); + }; + + this.removeAction = function(selectedItems) { + var isOneContainerRunning = false; + for (var i = 0; i < selectedItems.length; i++) { + var container = selectedItems[i]; + if (container.State === 'running') { + isOneContainerRunning = true; + break; + } + } + + var title = 'You are about to remove one or more container.'; + if (isOneContainerRunning) { + title = 'You are about to remove one or more running container.'; + } + + ModalService.confirmContainerDeletion(title, function (result) { + if(!result) { return; } + var cleanVolumes = false; + if (result[0]) { + cleanVolumes = true; + } + removeSelectedContainers(selectedItems, cleanVolumes); + } + ); + }; + + function executeActionOnContainerList(containers, action, successMessage, errorMessage) { + var actionCount = containers.length; + angular.forEach(containers, function (container) { + HttpRequestHelper.setPortainerAgentTargetHeader(container.NodeName); + action(container.Id) + .then(function success() { + Notifications.success(successMessage, container.Names[0]); + }) + .catch(function error(err) { + Notifications.error('Failure', err, errorMessage); + }) + .finally(function final() { + --actionCount; + if (actionCount === 0) { + $state.reload(); + } + }); + }); + } + + function removeSelectedContainers(containers, cleanVolumes) { + var actionCount = containers.length; + angular.forEach(containers, function (container) { + ContainerService.remove(container, cleanVolumes) + .then(function success() { + Notifications.success('Container successfully removed', container.Names[0]); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove container'); + }) + .finally(function final() { + --actionCount; + if (actionCount === 0) { + $state.reload(); + } + }); + }); + } +}]); diff --git a/app/docker/components/datatables/containers-datatable/containersDatatable.html b/app/docker/components/datatables/containers-datatable/containersDatatable.html index e07e89dbd..df073801e 100644 --- a/app/docker/components/datatables/containers-datatable/containersDatatable.html +++ b/app/docker/components/datatables/containers-datatable/containersDatatable.html @@ -51,41 +51,14 @@ -
-
- - - - - - - -
- -
+ -
-
- - -
- -
+ -