1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-05 13:55:21 +02:00

feat(api): rewrite access control management in Docker (#3337)

* feat(api): decorate Docker resource creation response with resource control

* fix(api): fix a potential resource control conflict between stacks/volumes

* feat(api): generate a default private resource control instead of admin only

* fix(api): fix default RC value

* fix(api): update RC authorizations check to support admin only flag

* refactor(api): relocate access control related methods

* fix(api): fix a potential conflict when fetching RC from database

* refactor(api): refactor access control logic

* refactor(api): remove the concept of DecoratedStack

* feat(api): automatically remove RC when removing a Docker resource

* refactor(api): update filter resource methods documentation

* refactor(api): update proxy package structure

* refactor(api): renamed proxy/misc package

* feat(api): re-introduce ResourceControlDelete operation as admin restricted

* refactor(api): relocate default endpoint authorizations

* feat(api): migrate RBAC data

* feat(app): ResourceControl management refactor

* fix(api): fix access control issue on stack deletion and automatically delete RC

* fix(api): fix stack filtering

* fix(api): fix UpdateResourceControl operation checks

* refactor(api): introduce a NewTransport builder method

* refactor(api): inject endpoint in Docker transport

* refactor(api): introduce Docker client into Docker transport

* refactor(api): refactor http/proxy package

* feat(api): inspect a Docker resource labels during access control validation

* fix(api): only apply automatic resource control creation on success response

* fix(api): fix stack access control check

* fix(api): use StatusCreated instead of StatusOK for automatic resource control creation

* fix(app): resource control fixes

* fix(api): fix an issue preventing administrator to inspect a resource with a RC

* refactor(api): remove useless error return

* refactor(api): document DecorateStacks function

* fix(api): fix invalid resource control type for container deletion

* feat(api): support Docker system networks

* feat(api): update Swagger docs

* refactor(api): rename transport variable

* refactor(api): rename transport variable

* feat(networks): add system tag for system networks

* feat(api): add support for resource control labels

* feat(api): upgrade to DBVersion 22

* refactor(api): refactor access control management in Docker proxy

* refactor(api): re-implement docker proxy taskListOperation

* refactor(api): review parameters declaration

* refactor(api): remove extra blank line

* refactor(api): review method comments

* fix(api): fix invalid ServerAddress property and review method visibility

* feat(api): update error message

* feat(api): update restrictedVolumeBrowserOperation method

* refactor(api): refactor method parameters

* refactor(api): minor refactor

* refactor(api): change Azure transport visibility

* refactor(api): update struct documentation

* refactor(api): update struct documentation

* feat(api): review restrictedResourceOperation method

* refactor(api): remove unused authorization methods

* feat(api): apply RBAC when enabled on stack operations

* fix(api): fix invalid data migration procedure for DBVersion = 22

* fix(app): RC duplicate on private resource

* feat(api): change Docker API version logic for libcompose/client factory

* fix(api): update access denied error message to be Docker API compliant

* fix(api): update volume browsing authorizations data migration

* fix(api): fix an issue with access control in multi-node agent Swarm cluster
This commit is contained in:
Anthony Lapenna 2019-11-13 12:41:42 +13:00 committed by GitHub
parent 198e92c734
commit 19d4db13be
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
118 changed files with 3600 additions and 3020 deletions

View file

@ -0,0 +1,20 @@
package factory
import (
"net/http"
"net/url"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/azure"
)
func newAzureProxy(endpoint *portainer.Endpoint) (http.Handler, error) {
remoteURL, err := url.Parse(azureAPIBaseURL)
if err != nil {
return nil, err
}
proxy := newSingleHostReverseProxyWithHostHeader(remoteURL)
proxy.Transport = azure.NewTransport(&endpoint.AzureCredentials)
return proxy, nil
}

View file

@ -0,0 +1,80 @@
package azure
import (
"net/http"
"strconv"
"sync"
"time"
"github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/client"
)
type (
azureAPIToken struct {
value string
expirationTime time.Time
}
Transport struct {
credentials *portainer.AzureCredentials
client *client.HTTPClient
token *azureAPIToken
mutex sync.Mutex
}
)
// NewTransport returns a pointer to a new instance of Transport that implements the HTTP Transport
// interface for proxying requests to the Azure API.
func NewTransport(credentials *portainer.AzureCredentials) *Transport {
return &Transport{
credentials: credentials,
client: client.NewHTTPClient(),
}
}
// RoundTrip is the implementation of the Transport interface.
func (transport *Transport) RoundTrip(request *http.Request) (*http.Response, error) {
err := transport.retrieveAuthenticationToken()
if err != nil {
return nil, err
}
request.Header.Set("Authorization", "Bearer "+transport.token.value)
return http.DefaultTransport.RoundTrip(request)
}
func (transport *Transport) authenticate() error {
token, err := transport.client.ExecuteAzureAuthenticationRequest(transport.credentials)
if err != nil {
return err
}
expiresOn, err := strconv.ParseInt(token.ExpiresOn, 10, 64)
if err != nil {
return err
}
transport.token = &azureAPIToken{
value: token.AccessToken,
expirationTime: time.Unix(expiresOn, 0),
}
return nil
}
func (transport *Transport) retrieveAuthenticationToken() error {
transport.mutex.Lock()
defer transport.mutex.Unlock()
if transport.token == nil {
return transport.authenticate()
}
timeLimit := time.Now().Add(-5 * time.Minute)
if timeLimit.After(transport.token.expirationTime) {
return transport.authenticate()
}
return nil
}

View file

@ -0,0 +1,118 @@
package factory
import (
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
httperror "github.com/portainer/libhttp/error"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/crypto"
"github.com/portainer/portainer/api/http/proxy/factory/docker"
)
func (factory *ProxyFactory) newDockerProxy(endpoint *portainer.Endpoint) (http.Handler, error) {
if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") {
return factory.newDockerLocalProxy(endpoint)
}
return factory.newDockerHTTPProxy(endpoint)
}
func (factory *ProxyFactory) newDockerLocalProxy(endpoint *portainer.Endpoint) (http.Handler, error) {
endpointURL, err := url.Parse(endpoint.URL)
if err != nil {
return nil, err
}
return factory.newOSBasedLocalProxy(endpointURL.Path, endpoint)
}
func (factory *ProxyFactory) newDockerHTTPProxy(endpoint *portainer.Endpoint) (http.Handler, error) {
if endpoint.Type == portainer.EdgeAgentEnvironment {
tunnel := factory.reverseTunnelService.GetTunnelDetails(endpoint.ID)
endpoint.URL = fmt.Sprintf("http://localhost:%d", tunnel.Port)
}
endpointURL, err := url.Parse(endpoint.URL)
if err != nil {
return nil, err
}
endpointURL.Scheme = "http"
httpTransport := &http.Transport{}
if endpoint.TLSConfig.TLS || endpoint.TLSConfig.TLSSkipVerify {
config, err := crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig.TLSCACertPath, endpoint.TLSConfig.TLSCertPath, endpoint.TLSConfig.TLSKeyPath, endpoint.TLSConfig.TLSSkipVerify)
if err != nil {
return nil, err
}
httpTransport.TLSClientConfig = config
endpointURL.Scheme = "https"
}
transportParameters := &docker.TransportParameters{
Endpoint: endpoint,
ResourceControlService: factory.resourceControlService,
UserService: factory.userService,
TeamService: factory.teamService,
TeamMembershipService: factory.teamMembershipService,
RegistryService: factory.registryService,
DockerHubService: factory.dockerHubService,
SettingsService: factory.settingsService,
ReverseTunnelService: factory.reverseTunnelService,
ExtensionService: factory.extensionService,
SignatureService: factory.signatureService,
DockerClientFactory: factory.dockerClientFactory,
}
dockerTransport, err := docker.NewTransport(transportParameters, httpTransport)
if err != nil {
return nil, err
}
proxy := newSingleHostReverseProxyWithHostHeader(endpointURL)
proxy.Transport = dockerTransport
return proxy, nil
}
type dockerLocalProxy struct {
transport *docker.Transport
}
// ServeHTTP is the http.Handler interface implementation
// for a local (Unix socket or Windows named pipe) Docker proxy.
func (proxy *dockerLocalProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Force URL/domain to http/unixsocket to be able to
// use http.transport RoundTrip to do the requests via the socket
r.URL.Scheme = "http"
r.URL.Host = "unixsocket"
res, err := proxy.transport.ProxyDockerRequest(r)
if err != nil {
code := http.StatusInternalServerError
if res != nil && res.StatusCode != 0 {
code = res.StatusCode
}
httperror.WriteError(w, code, "Unable to proxy the request via the Docker socket", err)
return
}
defer res.Body.Close()
for k, vv := range res.Header {
for _, v := range vv {
w.Header().Add(k, v)
}
}
w.WriteHeader(res.StatusCode)
if _, err := io.Copy(w, res.Body); err != nil {
log.Printf("proxy error: %s\n", err)
}
}

View file

@ -0,0 +1,306 @@
package docker
import (
"log"
"net/http"
"strings"
"github.com/portainer/portainer/api/http/proxy/factory/responseutils"
"github.com/portainer/portainer/api"
)
const (
resourceLabelForPortainerTeamResourceControl = "io.portainer.accesscontrol.teams"
resourceLabelForPortainerUserResourceControl = "io.portainer.accesscontrol.users"
resourceLabelForPortainerPublicResourceControl = "io.portainer.accesscontrol.public"
resourceLabelForDockerSwarmStackName = "com.docker.stack.namespace"
resourceLabelForDockerServiceID = "com.docker.swarm.service.id"
resourceLabelForDockerComposeStackName = "com.docker.compose.project"
)
type (
resourceLabelsObjectSelector func(map[string]interface{}) map[string]interface{}
resourceOperationParameters struct {
resourceIdentifierAttribute string
resourceType portainer.ResourceControlType
labelsObjectSelector resourceLabelsObjectSelector
}
)
func (transport *Transport) newResourceControlFromPortainerLabels(labelsObject map[string]interface{}, resourceID string, resourceType portainer.ResourceControlType) (*portainer.ResourceControl, error) {
if labelsObject[resourceLabelForPortainerPublicResourceControl] != nil {
resourceControl := portainer.NewPublicResourceControl(resourceID, resourceType)
err := transport.resourceControlService.CreateResourceControl(resourceControl)
if err != nil {
return nil, err
}
return resourceControl, nil
}
teamNames := make([]string, 0)
userNames := make([]string, 0)
if labelsObject[resourceLabelForPortainerTeamResourceControl] != nil {
concatenatedTeamNames := labelsObject[resourceLabelForPortainerTeamResourceControl].(string)
teamNames = strings.Split(concatenatedTeamNames, ",")
}
if labelsObject[resourceLabelForPortainerUserResourceControl] != nil {
concatenatedUserNames := labelsObject[resourceLabelForPortainerUserResourceControl].(string)
userNames = strings.Split(concatenatedUserNames, ",")
}
if len(teamNames) > 0 || len(userNames) > 0 {
teamIDs := make([]portainer.TeamID, 0)
userIDs := make([]portainer.UserID, 0)
for _, name := range teamNames {
team, err := transport.teamService.TeamByName(name)
if err != nil {
log.Printf("[WARN] [http,proxy,docker] [message: unknown team name in access control label, ignoring access control rule for this team] [name: %s] [resource_id: %s]", name, resourceID)
continue
}
teamIDs = append(teamIDs, team.ID)
}
for _, name := range userNames {
user, err := transport.userService.UserByUsername(name)
if err != nil {
log.Printf("[WARN] [http,proxy,docker] [message: unknown user name in access control label, ignoring access control rule for this user] [name: %s] [resource_id: %s]", name, resourceID)
continue
}
userIDs = append(userIDs, user.ID)
}
resourceControl := portainer.NewRestrictedResourceControl(resourceID, resourceType, userIDs, teamIDs)
err := transport.resourceControlService.CreateResourceControl(resourceControl)
if err != nil {
return nil, err
}
return resourceControl, nil
}
return nil, nil
}
func (transport *Transport) createPrivateResourceControl(resourceIdentifier string, resourceType portainer.ResourceControlType, userID portainer.UserID) (*portainer.ResourceControl, error) {
resourceControl := portainer.NewPrivateResourceControl(resourceIdentifier, resourceType, userID)
err := transport.resourceControlService.CreateResourceControl(resourceControl)
if err != nil {
log.Printf("[ERROR] [http,proxy,docker,transport] [message: unable to persist resource control] [resource: %s] [err: %s]", resourceIdentifier, err)
return nil, err
}
return resourceControl, nil
}
func (transport *Transport) getInheritedResourceControlFromServiceOrStack(resourceIdentifier, nodeName string, resourceType portainer.ResourceControlType, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) {
client := transport.dockerClient
if nodeName != "" {
dockerClient, err := transport.dockerClientFactory.CreateClient(transport.endpoint, nodeName)
if err != nil {
return nil, err
}
defer dockerClient.Close()
client = dockerClient
}
switch resourceType {
case portainer.ContainerResourceControl:
return getInheritedResourceControlFromContainerLabels(client, resourceIdentifier, resourceControls)
case portainer.NetworkResourceControl:
return getInheritedResourceControlFromNetworkLabels(client, resourceIdentifier, resourceControls)
case portainer.VolumeResourceControl:
return getInheritedResourceControlFromVolumeLabels(client, resourceIdentifier, resourceControls)
case portainer.ServiceResourceControl:
return getInheritedResourceControlFromServiceLabels(client, resourceIdentifier, resourceControls)
case portainer.ConfigResourceControl:
return getInheritedResourceControlFromConfigLabels(client, resourceIdentifier, resourceControls)
case portainer.SecretResourceControl:
return getInheritedResourceControlFromSecretLabels(client, resourceIdentifier, resourceControls)
}
return nil, nil
}
func (transport *Transport) applyAccessControlOnResource(parameters *resourceOperationParameters, responseObject map[string]interface{}, response *http.Response, executor *operationExecutor) error {
if responseObject[parameters.resourceIdentifierAttribute] == nil {
log.Printf("[WARN] [message: unable to find resource identifier property in resource object] [identifier_attribute: %s]", parameters.resourceIdentifierAttribute)
return nil
}
if parameters.resourceType == portainer.NetworkResourceControl {
systemResourceControl := findSystemNetworkResourceControl(responseObject)
if systemResourceControl != nil {
responseObject = decorateObject(responseObject, systemResourceControl)
return responseutils.RewriteResponse(response, responseObject, http.StatusOK)
}
}
resourceIdentifier := responseObject[parameters.resourceIdentifierAttribute].(string)
resourceLabelsObject := parameters.labelsObjectSelector(responseObject)
resourceControl, err := transport.findResourceControl(resourceIdentifier, parameters.resourceType, resourceLabelsObject, executor.operationContext.resourceControls)
if err != nil {
return err
}
if resourceControl == nil && (executor.operationContext.isAdmin || executor.operationContext.endpointResourceAccess) {
return responseutils.RewriteResponse(response, responseObject, http.StatusOK)
}
if executor.operationContext.isAdmin || executor.operationContext.endpointResourceAccess || portainer.UserCanAccessResource(executor.operationContext.userID, executor.operationContext.userTeamIDs, resourceControl) {
responseObject = decorateObject(responseObject, resourceControl)
return responseutils.RewriteResponse(response, responseObject, http.StatusOK)
}
return responseutils.RewriteAccessDeniedResponse(response)
}
func (transport *Transport) applyAccessControlOnResourceList(parameters *resourceOperationParameters, resourceData []interface{}, executor *operationExecutor) ([]interface{}, error) {
if executor.operationContext.isAdmin || executor.operationContext.endpointResourceAccess {
return transport.decorateResourceList(parameters, resourceData, executor.operationContext.resourceControls)
}
return transport.filterResourceList(parameters, resourceData, executor.operationContext)
}
func (transport *Transport) decorateResourceList(parameters *resourceOperationParameters, resourceData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) {
decoratedResourceData := make([]interface{}, 0)
for _, resource := range resourceData {
resourceObject := resource.(map[string]interface{})
if resourceObject[parameters.resourceIdentifierAttribute] == nil {
log.Printf("[WARN] [http,proxy,docker,decorate] [message: unable to find resource identifier property in resource list element] [identifier_attribute: %s]", parameters.resourceIdentifierAttribute)
continue
}
if parameters.resourceType == portainer.NetworkResourceControl {
systemResourceControl := findSystemNetworkResourceControl(resourceObject)
if systemResourceControl != nil {
resourceObject = decorateObject(resourceObject, systemResourceControl)
decoratedResourceData = append(decoratedResourceData, resourceObject)
continue
}
}
resourceIdentifier := resourceObject[parameters.resourceIdentifierAttribute].(string)
resourceLabelsObject := parameters.labelsObjectSelector(resourceObject)
resourceControl, err := transport.findResourceControl(resourceIdentifier, parameters.resourceType, resourceLabelsObject, resourceControls)
if err != nil {
return nil, err
}
if resourceControl != nil {
resourceObject = decorateObject(resourceObject, resourceControl)
}
decoratedResourceData = append(decoratedResourceData, resourceObject)
}
return decoratedResourceData, nil
}
func (transport *Transport) filterResourceList(parameters *resourceOperationParameters, resourceData []interface{}, context *restrictedDockerOperationContext) ([]interface{}, error) {
filteredResourceData := make([]interface{}, 0)
for _, resource := range resourceData {
resourceObject := resource.(map[string]interface{})
if resourceObject[parameters.resourceIdentifierAttribute] == nil {
log.Printf("[WARN] [http,proxy,docker,filter] [message: unable to find resource identifier property in resource list element] [identifier_attribute: %s]", parameters.resourceIdentifierAttribute)
continue
}
resourceIdentifier := resourceObject[parameters.resourceIdentifierAttribute].(string)
resourceLabelsObject := parameters.labelsObjectSelector(resourceObject)
if parameters.resourceType == portainer.NetworkResourceControl {
systemResourceControl := findSystemNetworkResourceControl(resourceObject)
if systemResourceControl != nil {
resourceObject = decorateObject(resourceObject, systemResourceControl)
filteredResourceData = append(filteredResourceData, resourceObject)
continue
}
}
resourceControl, err := transport.findResourceControl(resourceIdentifier, parameters.resourceType, resourceLabelsObject, context.resourceControls)
if err != nil {
return nil, err
}
if resourceControl == nil {
if context.isAdmin || context.endpointResourceAccess {
filteredResourceData = append(filteredResourceData, resourceObject)
}
continue
}
if context.isAdmin || context.endpointResourceAccess || portainer.UserCanAccessResource(context.userID, context.userTeamIDs, resourceControl) {
resourceObject = decorateObject(resourceObject, resourceControl)
filteredResourceData = append(filteredResourceData, resourceObject)
}
}
return filteredResourceData, nil
}
func (transport *Transport) findResourceControl(resourceIdentifier string, resourceType portainer.ResourceControlType, resourceLabelsObject map[string]interface{}, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) {
resourceControl := portainer.GetResourceControlByResourceIDAndType(resourceIdentifier, resourceType, resourceControls)
if resourceControl != nil {
return resourceControl, nil
}
if resourceLabelsObject != nil {
if resourceLabelsObject[resourceLabelForDockerServiceID] != nil {
inheritedServiceIdentifier := resourceLabelsObject[resourceLabelForDockerServiceID].(string)
resourceControl = portainer.GetResourceControlByResourceIDAndType(inheritedServiceIdentifier, portainer.ServiceResourceControl, resourceControls)
if resourceControl != nil {
return resourceControl, nil
}
}
if resourceLabelsObject[resourceLabelForDockerSwarmStackName] != nil {
inheritedSwarmStackIdentifier := resourceLabelsObject[resourceLabelForDockerSwarmStackName].(string)
resourceControl = portainer.GetResourceControlByResourceIDAndType(inheritedSwarmStackIdentifier, portainer.StackResourceControl, resourceControls)
if resourceControl != nil {
return resourceControl, nil
}
}
if resourceLabelsObject[resourceLabelForDockerComposeStackName] != nil {
inheritedComposeStackIdentifier := resourceLabelsObject[resourceLabelForDockerComposeStackName].(string)
resourceControl = portainer.GetResourceControlByResourceIDAndType(inheritedComposeStackIdentifier, portainer.StackResourceControl, resourceControls)
if resourceControl != nil {
return resourceControl, nil
}
}
return transport.newResourceControlFromPortainerLabels(resourceLabelsObject, resourceIdentifier, resourceType)
}
return nil, nil
}
func decorateObject(object map[string]interface{}, resourceControl *portainer.ResourceControl) map[string]interface{} {
if object["Portainer"] == nil {
object["Portainer"] = make(map[string]interface{})
}
portainerMetadata := object["Portainer"].(map[string]interface{})
portainerMetadata["ResourceControl"] = resourceControl
return object
}

View file

@ -0,0 +1,56 @@
package docker
import (
"bytes"
"encoding/json"
"io/ioutil"
"net/http"
"strings"
"github.com/portainer/portainer/api/archive"
)
type postDockerfileRequest struct {
Content string
}
// buildOperation inspects the "Content-Type" header to determine if it needs to alter the request.
// If the value of the header is empty, it means that a Dockerfile is posted via upload, the function
// will extract the file content from the request body, tar it, and rewrite the body.
// If the value of the header contains "application/json", it means that the content of a Dockerfile is posted
// in the request payload as JSON, the function will create a new file called Dockerfile inside a tar archive and
// rewrite the body of the request.
// In any other case, it will leave the request unaltered.
func buildOperation(request *http.Request) error {
contentTypeHeader := request.Header.Get("Content-Type")
if contentTypeHeader != "" && !strings.Contains(contentTypeHeader, "application/json") {
return nil
}
var dockerfileContent []byte
if contentTypeHeader == "" {
body, err := ioutil.ReadAll(request.Body)
if err != nil {
return err
}
dockerfileContent = body
} else {
var req postDockerfileRequest
if err := json.NewDecoder(request.Body).Decode(&req); err != nil {
return err
}
dockerfileContent = []byte(req.Content)
}
buffer, err := archive.TarFileInBuffer(dockerfileContent, "Dockerfile", 0600)
if err != nil {
return err
}
request.Body = ioutil.NopCloser(bytes.NewReader(buffer))
request.ContentLength = int64(len(buffer))
request.Header.Set("Content-Type", "application/x-tar")
return nil
}

View file

@ -0,0 +1,86 @@
package docker
import (
"context"
"net/http"
"github.com/docker/docker/client"
"github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/responseutils"
)
const (
configObjectIdentifier = "ID"
)
func getInheritedResourceControlFromConfigLabels(dockerClient *client.Client, configID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) {
config, _, err := dockerClient.ConfigInspectWithRaw(context.Background(), configID)
if err != nil {
return nil, err
}
swarmStackName := config.Spec.Labels[resourceLabelForDockerSwarmStackName]
if swarmStackName != "" {
return portainer.GetResourceControlByResourceIDAndType(swarmStackName, portainer.StackResourceControl, resourceControls), nil
}
return nil, nil
}
// configListOperation extracts the response as a JSON object, loop through the configs array
// decorate and/or filter the configs based on resource controls before rewriting the response.
func (transport *Transport) configListOperation(response *http.Response, executor *operationExecutor) error {
// ConfigList response is a JSON array
// https://docs.docker.com/engine/api/v1.30/#operation/ConfigList
responseArray, err := responseutils.GetResponseAsJSONArray(response)
if err != nil {
return err
}
resourceOperationParameters := &resourceOperationParameters{
resourceIdentifierAttribute: configObjectIdentifier,
resourceType: portainer.ConfigResourceControl,
labelsObjectSelector: selectorConfigLabels,
}
responseArray, err = transport.applyAccessControlOnResourceList(resourceOperationParameters, responseArray, executor)
if err != nil {
return err
}
return responseutils.RewriteResponse(response, responseArray, http.StatusOK)
}
// configInspectOperation extracts the response as a JSON object, verify that the user
// has access to the config based on resource control and either rewrite an access denied response or a decorated config.
func (transport *Transport) configInspectOperation(response *http.Response, executor *operationExecutor) error {
// ConfigInspect response is a JSON object
// https://docs.docker.com/engine/api/v1.30/#operation/ConfigInspect
responseObject, err := responseutils.GetResponseAsJSONOBject(response)
if err != nil {
return err
}
resourceOperationParameters := &resourceOperationParameters{
resourceIdentifierAttribute: configObjectIdentifier,
resourceType: portainer.ConfigResourceControl,
labelsObjectSelector: selectorConfigLabels,
}
return transport.applyAccessControlOnResource(resourceOperationParameters, responseObject, response, executor)
}
// selectorConfigLabels retrieve the labels object associated to the config object.
// Labels are available under the "Spec.Labels" property.
// API schema references:
// https://docs.docker.com/engine/api/v1.40/#operation/ConfigList
// https://docs.docker.com/engine/api/v1.40/#operation/ConfigInspect
func selectorConfigLabels(responseObject map[string]interface{}) map[string]interface{} {
secretSpec := responseutils.GetJSONObject(responseObject, "Spec")
if secretSpec != nil {
secretLabelsObject := responseutils.GetJSONObject(secretSpec, "Labels")
return secretLabelsObject
}
return nil
}

View file

@ -0,0 +1,146 @@
package docker
import (
"context"
"net/http"
"github.com/docker/docker/client"
"github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/responseutils"
)
const (
containerObjectIdentifier = "Id"
)
func getInheritedResourceControlFromContainerLabels(dockerClient *client.Client, containerID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) {
container, err := dockerClient.ContainerInspect(context.Background(), containerID)
if err != nil {
return nil, err
}
swarmStackName := container.Config.Labels[resourceLabelForDockerSwarmStackName]
if swarmStackName != "" {
return portainer.GetResourceControlByResourceIDAndType(swarmStackName, portainer.StackResourceControl, resourceControls), nil
}
serviceName := container.Config.Labels[resourceLabelForDockerServiceID]
if serviceName != "" {
return portainer.GetResourceControlByResourceIDAndType(serviceName, portainer.ServiceResourceControl, resourceControls), nil
}
composeStackName := container.Config.Labels[resourceLabelForDockerComposeStackName]
if composeStackName != "" {
return portainer.GetResourceControlByResourceIDAndType(composeStackName, portainer.StackResourceControl, resourceControls), nil
}
return nil, nil
}
// containerListOperation extracts the response as a JSON array, loop through the containers array
// decorate and/or filter the containers based on resource controls before rewriting the response.
func (transport *Transport) containerListOperation(response *http.Response, executor *operationExecutor) error {
// ContainerList response is a JSON array
// https://docs.docker.com/engine/api/v1.28/#operation/ContainerList
responseArray, err := responseutils.GetResponseAsJSONArray(response)
if err != nil {
return err
}
resourceOperationParameters := &resourceOperationParameters{
resourceIdentifierAttribute: containerObjectIdentifier,
resourceType: portainer.ContainerResourceControl,
labelsObjectSelector: selectorContainerLabelsFromContainerListOperation,
}
responseArray, err = transport.applyAccessControlOnResourceList(resourceOperationParameters, responseArray, executor)
if err != nil {
return err
}
if executor.labelBlackList != nil {
responseArray, err = filterContainersWithBlackListedLabels(responseArray, executor.labelBlackList)
if err != nil {
return err
}
}
return responseutils.RewriteResponse(response, responseArray, http.StatusOK)
}
// containerInspectOperation extracts the response as a JSON object, verify that the user
// has access to the container based on resource control and either rewrite an access denied response or a decorated container.
func (transport *Transport) containerInspectOperation(response *http.Response, executor *operationExecutor) error {
//ContainerInspect response is a JSON object
// https://docs.docker.com/engine/api/v1.28/#operation/ContainerInspect
responseObject, err := responseutils.GetResponseAsJSONOBject(response)
if err != nil {
return err
}
resourceOperationParameters := &resourceOperationParameters{
resourceIdentifierAttribute: containerObjectIdentifier,
resourceType: portainer.ContainerResourceControl,
labelsObjectSelector: selectorContainerLabelsFromContainerInspectOperation,
}
return transport.applyAccessControlOnResource(resourceOperationParameters, responseObject, response, executor)
}
// selectorContainerLabelsFromContainerInspectOperation retrieve the labels object associated to the container object.
// This selector is specific to the containerInspect Docker operation.
// Labels are available under the "Config.Labels" property.
// API schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerInspect
func selectorContainerLabelsFromContainerInspectOperation(responseObject map[string]interface{}) map[string]interface{} {
containerConfigObject := responseutils.GetJSONObject(responseObject, "Config")
if containerConfigObject != nil {
containerLabelsObject := responseutils.GetJSONObject(containerConfigObject, "Labels")
return containerLabelsObject
}
return nil
}
// selectorContainerLabelsFromContainerListOperation retrieve the labels object associated to the container object.
// This selector is specific to the containerList Docker operation.
// Labels are available under the "Labels" property.
// API schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerList
func selectorContainerLabelsFromContainerListOperation(responseObject map[string]interface{}) map[string]interface{} {
containerLabelsObject := responseutils.GetJSONObject(responseObject, "Labels")
return containerLabelsObject
}
// filterContainersWithLabels loops through a list of containers, and filters containers that do not contains
// any labels in the labels black list.
func filterContainersWithBlackListedLabels(containerData []interface{}, labelBlackList []portainer.Pair) ([]interface{}, error) {
filteredContainerData := make([]interface{}, 0)
for _, container := range containerData {
containerObject := container.(map[string]interface{})
containerLabels := selectorContainerLabelsFromContainerListOperation(containerObject)
if containerLabels != nil {
if !containerHasBlackListedLabel(containerLabels, labelBlackList) {
filteredContainerData = append(filteredContainerData, containerObject)
}
} else {
filteredContainerData = append(filteredContainerData, containerObject)
}
}
return filteredContainerData, nil
}
func containerHasBlackListedLabel(containerLabels map[string]interface{}, labelBlackList []portainer.Pair) bool {
for key, value := range containerLabels {
labelName := key
labelValue := value.(string)
for _, blackListedLabel := range labelBlackList {
if blackListedLabel.Name == labelName && blackListedLabel.Value == labelValue {
return true
}
}
}
return false
}

View file

@ -0,0 +1,101 @@
package docker
import (
"context"
"net/http"
"github.com/docker/docker/api/types"
"github.com/docker/docker/client"
"github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/responseutils"
)
const (
networkObjectIdentifier = "Id"
networkObjectName = "Name"
)
func getInheritedResourceControlFromNetworkLabels(dockerClient *client.Client, networkID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) {
network, err := dockerClient.NetworkInspect(context.Background(), networkID, types.NetworkInspectOptions{})
if err != nil {
return nil, err
}
swarmStackName := network.Labels[resourceLabelForDockerSwarmStackName]
if swarmStackName != "" {
return portainer.GetResourceControlByResourceIDAndType(swarmStackName, portainer.StackResourceControl, resourceControls), nil
}
return nil, nil
}
// networkListOperation extracts the response as a JSON object, loop through the networks array
// decorate and/or filter the networks based on resource controls before rewriting the response.
func (transport *Transport) networkListOperation(response *http.Response, executor *operationExecutor) error {
// NetworkList response is a JSON array
// https://docs.docker.com/engine/api/v1.28/#operation/NetworkList
responseArray, err := responseutils.GetResponseAsJSONArray(response)
if err != nil {
return err
}
resourceOperationParameters := &resourceOperationParameters{
resourceIdentifierAttribute: networkObjectIdentifier,
resourceType: portainer.NetworkResourceControl,
labelsObjectSelector: selectorNetworkLabels,
}
responseArray, err = transport.applyAccessControlOnResourceList(resourceOperationParameters, responseArray, executor)
if err != nil {
return err
}
return responseutils.RewriteResponse(response, responseArray, http.StatusOK)
}
// networkInspectOperation extracts the response as a JSON object, verify that the user
// has access to the network based on resource control and either rewrite an access denied response or a decorated network.
func (transport *Transport) networkInspectOperation(response *http.Response, executor *operationExecutor) error {
// NetworkInspect response is a JSON object
// https://docs.docker.com/engine/api/v1.28/#operation/NetworkInspect
responseObject, err := responseutils.GetResponseAsJSONOBject(response)
if err != nil {
return err
}
resourceOperationParameters := &resourceOperationParameters{
resourceIdentifierAttribute: networkObjectIdentifier,
resourceType: portainer.NetworkResourceControl,
labelsObjectSelector: selectorNetworkLabels,
}
return transport.applyAccessControlOnResource(resourceOperationParameters, responseObject, response, executor)
}
// findSystemNetworkResourceControl will check if the network object is a system network
// and will return a system resource control if that's the case.
func findSystemNetworkResourceControl(networkObject map[string]interface{}) *portainer.ResourceControl {
if networkObject[networkObjectName] == nil {
return nil
}
networkID := networkObject[networkObjectIdentifier].(string)
networkName := networkObject[networkObjectName].(string)
if networkName == "bridge" || networkName == "host" || networkName == "none" {
return portainer.NewSystemResourceControl(networkID, portainer.NetworkResourceControl)
}
return nil
}
// selectorNetworkLabels retrieve the labels object associated to the network object.
// Labels are available under the "Labels" property.
// API schema references:
// https://docs.docker.com/engine/api/v1.28/#operation/NetworkInspect
// https://docs.docker.com/engine/api/v1.28/#operation/NetworkList
func selectorNetworkLabels(responseObject map[string]interface{}) map[string]interface{} {
return responseutils.GetJSONObject(responseObject, "Labels")
}

View file

@ -0,0 +1,52 @@
package docker
import (
"github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/security"
)
type (
registryAccessContext struct {
isAdmin bool
userID portainer.UserID
teamMemberships []portainer.TeamMembership
registries []portainer.Registry
dockerHub *portainer.DockerHub
}
registryAuthenticationHeader struct {
Username string `json:"username"`
Password string `json:"password"`
Serveraddress string `json:"serveraddress"`
}
)
func createRegistryAuthenticationHeader(serverAddress string, accessContext *registryAccessContext) *registryAuthenticationHeader {
var authenticationHeader *registryAuthenticationHeader
if serverAddress == "" {
authenticationHeader = &registryAuthenticationHeader{
Username: accessContext.dockerHub.Username,
Password: accessContext.dockerHub.Password,
Serveraddress: "docker.io",
}
} else {
var matchingRegistry *portainer.Registry
for _, registry := range accessContext.registries {
if registry.URL == serverAddress &&
(accessContext.isAdmin || (!accessContext.isAdmin && security.AuthorizedRegistryAccess(&registry, accessContext.userID, accessContext.teamMemberships))) {
matchingRegistry = &registry
break
}
}
if matchingRegistry != nil {
authenticationHeader = &registryAuthenticationHeader{
Username: matchingRegistry.Username,
Password: matchingRegistry.Password,
Serveraddress: matchingRegistry.URL,
}
}
}
return authenticationHeader
}

View file

@ -0,0 +1,87 @@
package docker
import (
"context"
"net/http"
"github.com/docker/docker/client"
"github.com/portainer/portainer/api/http/proxy/factory/responseutils"
"github.com/portainer/portainer/api"
)
const (
secretObjectIdentifier = "ID"
)
func getInheritedResourceControlFromSecretLabels(dockerClient *client.Client, secretID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) {
secret, _, err := dockerClient.SecretInspectWithRaw(context.Background(), secretID)
if err != nil {
return nil, err
}
swarmStackName := secret.Spec.Labels[resourceLabelForDockerSwarmStackName]
if swarmStackName != "" {
return portainer.GetResourceControlByResourceIDAndType(swarmStackName, portainer.StackResourceControl, resourceControls), nil
}
return nil, nil
}
// secretListOperation extracts the response as a JSON object, loop through the secrets array
// decorate and/or filter the secrets based on resource controls before rewriting the response.
func (transport *Transport) secretListOperation(response *http.Response, executor *operationExecutor) error {
// SecretList response is a JSON array
// https://docs.docker.com/engine/api/v1.28/#operation/SecretList
responseArray, err := responseutils.GetResponseAsJSONArray(response)
if err != nil {
return err
}
resourceOperationParameters := &resourceOperationParameters{
resourceIdentifierAttribute: secretObjectIdentifier,
resourceType: portainer.SecretResourceControl,
labelsObjectSelector: selectorSecretLabels,
}
responseArray, err = transport.applyAccessControlOnResourceList(resourceOperationParameters, responseArray, executor)
if err != nil {
return err
}
return responseutils.RewriteResponse(response, responseArray, http.StatusOK)
}
// secretInspectOperation extracts the response as a JSON object, verify that the user
// has access to the secret based on resource control and either rewrite an access denied response or a decorated secret.
func (transport *Transport) secretInspectOperation(response *http.Response, executor *operationExecutor) error {
// SecretInspect response is a JSON object
// https://docs.docker.com/engine/api/v1.28/#operation/SecretInspect
responseObject, err := responseutils.GetResponseAsJSONOBject(response)
if err != nil {
return err
}
resourceOperationParameters := &resourceOperationParameters{
resourceIdentifierAttribute: secretObjectIdentifier,
resourceType: portainer.SecretResourceControl,
labelsObjectSelector: selectorSecretLabels,
}
return transport.applyAccessControlOnResource(resourceOperationParameters, responseObject, response, executor)
}
// selectorSecretLabels retrieve the labels object associated to the secret object.
// Labels are available under the "Spec.Labels" property.
// API schema references:
// https://docs.docker.com/engine/api/v1.40/#operation/SecretList
// https://docs.docker.com/engine/api/v1.40/#operation/SecretInspect
func selectorSecretLabels(responseObject map[string]interface{}) map[string]interface{} {
secretSpec := responseutils.GetJSONObject(responseObject, "Spec")
if secretSpec != nil {
secretLabelsObject := responseutils.GetJSONObject(secretSpec, "Labels")
return secretLabelsObject
}
return nil
}

View file

@ -0,0 +1,86 @@
package docker
import (
"context"
"net/http"
"github.com/docker/docker/api/types"
"github.com/docker/docker/client"
"github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/responseutils"
)
const (
serviceObjectIdentifier = "ID"
)
func getInheritedResourceControlFromServiceLabels(dockerClient *client.Client, serviceID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) {
service, _, err := dockerClient.ServiceInspectWithRaw(context.Background(), serviceID, types.ServiceInspectOptions{})
if err != nil {
return nil, err
}
swarmStackName := service.Spec.Labels[resourceLabelForDockerSwarmStackName]
if swarmStackName != "" {
return portainer.GetResourceControlByResourceIDAndType(swarmStackName, portainer.StackResourceControl, resourceControls), nil
}
return nil, nil
}
// serviceListOperation extracts the response as a JSON array, loop through the service array
// decorate and/or filter the services based on resource controls before rewriting the response.
func (transport *Transport) serviceListOperation(response *http.Response, executor *operationExecutor) error {
// ServiceList response is a JSON array
// https://docs.docker.com/engine/api/v1.28/#operation/ServiceList
responseArray, err := responseutils.GetResponseAsJSONArray(response)
if err != nil {
return err
}
resourceOperationParameters := &resourceOperationParameters{
resourceIdentifierAttribute: serviceObjectIdentifier,
resourceType: portainer.ServiceResourceControl,
labelsObjectSelector: selectorServiceLabels,
}
responseArray, err = transport.applyAccessControlOnResourceList(resourceOperationParameters, responseArray, executor)
if err != nil {
return err
}
return responseutils.RewriteResponse(response, responseArray, http.StatusOK)
}
// serviceInspectOperation extracts the response as a JSON object, verify that the user
// has access to the service based on resource control and either rewrite an access denied response or a decorated service.
func (transport *Transport) serviceInspectOperation(response *http.Response, executor *operationExecutor) error {
//ServiceInspect response is a JSON object
//https://docs.docker.com/engine/api/v1.28/#operation/ServiceInspect
responseObject, err := responseutils.GetResponseAsJSONOBject(response)
if err != nil {
return err
}
resourceOperationParameters := &resourceOperationParameters{
resourceIdentifierAttribute: serviceObjectIdentifier,
resourceType: portainer.ServiceResourceControl,
labelsObjectSelector: selectorServiceLabels,
}
return transport.applyAccessControlOnResource(resourceOperationParameters, responseObject, response, executor)
}
// selectorServiceLabels retrieve the labels object associated to the service object.
// Labels are available under the "Spec.Labels" property.
// API schema references:
// https://docs.docker.com/engine/api/v1.28/#operation/ServiceInspect
// https://docs.docker.com/engine/api/v1.28/#operation/ServiceList
func selectorServiceLabels(responseObject map[string]interface{}) map[string]interface{} {
serviceSpecObject := responseutils.GetJSONObject(responseObject, "Spec")
if serviceSpecObject != nil {
return responseutils.GetJSONObject(serviceSpecObject, "Labels")
}
return nil
}

View file

@ -0,0 +1,25 @@
package docker
import (
"net/http"
"github.com/portainer/portainer/api/http/proxy/factory/responseutils"
)
// swarmInspectOperation extracts the response as a JSON object and rewrites the response based
// on the current user role. Sensitive fields are deleted from the response for non-administrator users.
func swarmInspectOperation(response *http.Response, executor *operationExecutor) error {
// SwarmInspect response is a JSON object
// https://docs.docker.com/engine/api/v1.30/#operation/SwarmInspect
responseObject, err := responseutils.GetResponseAsJSONOBject(response)
if err != nil {
return err
}
if !executor.operationContext.isAdmin {
delete(responseObject, "JoinTokens")
delete(responseObject, "TLSInfo")
}
return responseutils.RewriteResponse(response, responseObject, http.StatusOK)
}

View file

@ -0,0 +1,50 @@
package docker
import (
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/responseutils"
)
const (
taskServiceObjectIdentifier = "ServiceID"
)
// taskListOperation extracts the response as a JSON array, loop through the tasks array
// and filter the containers based on resource controls before rewriting the response.
func (transport *Transport) taskListOperation(response *http.Response, executor *operationExecutor) error {
// TaskList response is a JSON array
// https://docs.docker.com/engine/api/v1.28/#operation/TaskList
responseArray, err := responseutils.GetResponseAsJSONArray(response)
if err != nil {
return err
}
resourceOperationParameters := &resourceOperationParameters{
resourceIdentifierAttribute: taskServiceObjectIdentifier,
resourceType: portainer.ServiceResourceControl,
labelsObjectSelector: selectorTaskLabels,
}
responseArray, err = transport.applyAccessControlOnResourceList(resourceOperationParameters, responseArray, executor)
if err != nil {
return err
}
return responseutils.RewriteResponse(response, responseArray, http.StatusOK)
}
// selectorServiceLabels retrieve the labels object associated to the task object.
// Labels are available under the "Spec.ContainerSpec.Labels" property.
// API schema reference: https://docs.docker.com/engine/api/v1.28/#operation/TaskList
func selectorTaskLabels(responseObject map[string]interface{}) map[string]interface{} {
taskSpecObject := responseutils.GetJSONObject(responseObject, "Spec")
if taskSpecObject != nil {
containerSpecObject := responseutils.GetJSONObject(taskSpecObject, "ContainerSpec")
if containerSpecObject != nil {
return responseutils.GetJSONObject(containerSpecObject, "Labels")
}
}
return nil
}

View file

@ -0,0 +1,725 @@
package docker
import (
"encoding/base64"
"encoding/json"
"errors"
"log"
"net/http"
"path"
"regexp"
"strings"
"github.com/portainer/portainer/api/docker"
"github.com/docker/docker/client"
"github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/responseutils"
"github.com/portainer/portainer/api/http/security"
)
var apiVersionRe = regexp.MustCompile(`(/v[0-9]\.[0-9]*)?`)
type (
// Transport is a custom transport for Docker API reverse proxy. It allows
// interception of requests and rewriting of responses.
Transport struct {
HTTPTransport *http.Transport
endpoint *portainer.Endpoint
resourceControlService portainer.ResourceControlService
userService portainer.UserService
teamService portainer.TeamService
teamMembershipService portainer.TeamMembershipService
registryService portainer.RegistryService
dockerHubService portainer.DockerHubService
settingsService portainer.SettingsService
signatureService portainer.DigitalSignatureService
reverseTunnelService portainer.ReverseTunnelService
extensionService portainer.ExtensionService
dockerClient *client.Client
dockerClientFactory *docker.ClientFactory
}
// TransportParameters is used to create a new Transport
TransportParameters struct {
Endpoint *portainer.Endpoint
ResourceControlService portainer.ResourceControlService
UserService portainer.UserService
TeamService portainer.TeamService
TeamMembershipService portainer.TeamMembershipService
RegistryService portainer.RegistryService
DockerHubService portainer.DockerHubService
SettingsService portainer.SettingsService
SignatureService portainer.DigitalSignatureService
ReverseTunnelService portainer.ReverseTunnelService
ExtensionService portainer.ExtensionService
DockerClientFactory *docker.ClientFactory
}
restrictedDockerOperationContext struct {
isAdmin bool
endpointResourceAccess bool
userID portainer.UserID
userTeamIDs []portainer.TeamID
resourceControls []portainer.ResourceControl
}
operationExecutor struct {
operationContext *restrictedDockerOperationContext
labelBlackList []portainer.Pair
}
restrictedOperationRequest func(*http.Response, *operationExecutor) error
operationRequest func(*http.Request) error
)
// NewTransport returns a pointer to a new Transport instance.
func NewTransport(parameters *TransportParameters, httpTransport *http.Transport) (*Transport, error) {
dockerClient, err := parameters.DockerClientFactory.CreateClient(parameters.Endpoint, "")
if err != nil {
return nil, err
}
transport := &Transport{
endpoint: parameters.Endpoint,
resourceControlService: parameters.ResourceControlService,
userService: parameters.UserService,
teamService: parameters.TeamService,
teamMembershipService: parameters.TeamMembershipService,
registryService: parameters.RegistryService,
dockerHubService: parameters.DockerHubService,
settingsService: parameters.SettingsService,
signatureService: parameters.SignatureService,
reverseTunnelService: parameters.ReverseTunnelService,
extensionService: parameters.ExtensionService,
dockerClientFactory: parameters.DockerClientFactory,
HTTPTransport: httpTransport,
dockerClient: dockerClient,
}
return transport, nil
}
// RoundTrip is the implementation of the the http.RoundTripper interface
func (transport *Transport) RoundTrip(request *http.Request) (*http.Response, error) {
return transport.ProxyDockerRequest(request)
}
// ProxyDockerRequest intercepts a Docker API request and apply logic based
// on the requested operation.
func (transport *Transport) ProxyDockerRequest(request *http.Request) (*http.Response, error) {
requestPath := apiVersionRe.ReplaceAllString(request.URL.Path, "")
request.URL.Path = requestPath
if transport.endpoint.Type == portainer.AgentOnDockerEnvironment {
signature, err := transport.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
if err != nil {
return nil, err
}
request.Header.Set(portainer.PortainerAgentPublicKeyHeader, transport.signatureService.EncodedPublicKey())
request.Header.Set(portainer.PortainerAgentSignatureHeader, signature)
}
switch {
case strings.HasPrefix(requestPath, "/configs"):
return transport.proxyConfigRequest(request)
case strings.HasPrefix(requestPath, "/containers"):
return transport.proxyContainerRequest(request)
case strings.HasPrefix(requestPath, "/services"):
return transport.proxyServiceRequest(request)
case strings.HasPrefix(requestPath, "/volumes"):
return transport.proxyVolumeRequest(request)
case strings.HasPrefix(requestPath, "/networks"):
return transport.proxyNetworkRequest(request)
case strings.HasPrefix(requestPath, "/secrets"):
return transport.proxySecretRequest(request)
case strings.HasPrefix(requestPath, "/swarm"):
return transport.proxySwarmRequest(request)
case strings.HasPrefix(requestPath, "/nodes"):
return transport.proxyNodeRequest(request)
case strings.HasPrefix(requestPath, "/tasks"):
return transport.proxyTaskRequest(request)
case strings.HasPrefix(requestPath, "/build"):
return transport.proxyBuildRequest(request)
case strings.HasPrefix(requestPath, "/images"):
return transport.proxyImageRequest(request)
case strings.HasPrefix(requestPath, "/v2"):
return transport.proxyAgentRequest(request)
default:
return transport.executeDockerRequest(request)
}
}
func (transport *Transport) executeDockerRequest(request *http.Request) (*http.Response, error) {
response, err := transport.HTTPTransport.RoundTrip(request)
if transport.endpoint.Type != portainer.EdgeAgentEnvironment {
return response, err
}
if err == nil {
transport.reverseTunnelService.SetTunnelStatusToActive(transport.endpoint.ID)
} else {
transport.reverseTunnelService.SetTunnelStatusToIdle(transport.endpoint.ID)
}
return response, err
}
func (transport *Transport) proxyAgentRequest(r *http.Request) (*http.Response, error) {
requestPath := strings.TrimPrefix(r.URL.Path, "/v2")
switch {
case strings.HasPrefix(requestPath, "/browse"):
volumeIDParameter, found := r.URL.Query()["volumeID"]
if !found || len(volumeIDParameter) < 1 {
return transport.administratorOperation(r)
}
return transport.restrictedResourceOperation(r, volumeIDParameter[0], portainer.VolumeResourceControl, true)
}
return transport.executeDockerRequest(r)
}
func (transport *Transport) proxyConfigRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath {
case "/configs/create":
return transport.decorateGenericResourceCreationOperation(request, configObjectIdentifier, portainer.ConfigResourceControl)
case "/configs":
return transport.rewriteOperation(request, transport.configListOperation)
default:
// assume /configs/{id}
configID := path.Base(requestPath)
if request.Method == http.MethodGet {
return transport.rewriteOperation(request, transport.configInspectOperation)
} else if request.Method == http.MethodDelete {
return transport.executeGenericResourceDeletionOperation(request, configID, portainer.ConfigResourceControl)
}
return transport.restrictedResourceOperation(request, configID, portainer.ConfigResourceControl, false)
}
}
func (transport *Transport) proxyContainerRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath {
case "/containers/create":
return transport.decorateGenericResourceCreationOperation(request, containerObjectIdentifier, portainer.ContainerResourceControl)
case "/containers/prune":
return transport.administratorOperation(request)
case "/containers/json":
return transport.rewriteOperationWithLabelFiltering(request, transport.containerListOperation)
default:
// This section assumes /containers/**
if match, _ := path.Match("/containers/*/*", requestPath); match {
// Handle /containers/{id}/{action} requests
containerID := path.Base(path.Dir(requestPath))
action := path.Base(requestPath)
if action == "json" {
return transport.rewriteOperation(request, transport.containerInspectOperation)
}
return transport.restrictedResourceOperation(request, containerID, portainer.ContainerResourceControl, false)
} else if match, _ := path.Match("/containers/*", requestPath); match {
// Handle /containers/{id} requests
containerID := path.Base(requestPath)
if request.Method == http.MethodDelete {
return transport.executeGenericResourceDeletionOperation(request, containerID, portainer.ContainerResourceControl)
}
return transport.restrictedResourceOperation(request, containerID, portainer.ContainerResourceControl, false)
}
return transport.executeDockerRequest(request)
}
}
func (transport *Transport) proxyServiceRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath {
case "/services/create":
return transport.replaceRegistryAuthenticationHeader(request)
case "/services":
return transport.rewriteOperation(request, transport.serviceListOperation)
default:
// This section assumes /services/**
if match, _ := path.Match("/services/*/*", requestPath); match {
// Handle /services/{id}/{action} requests
serviceID := path.Base(path.Dir(requestPath))
return transport.restrictedResourceOperation(request, serviceID, portainer.ServiceResourceControl, false)
} else if match, _ := path.Match("/services/*", requestPath); match {
// Handle /services/{id} requests
serviceID := path.Base(requestPath)
switch request.Method {
case http.MethodGet:
return transport.rewriteOperation(request, transport.serviceInspectOperation)
case http.MethodDelete:
return transport.executeGenericResourceDeletionOperation(request, serviceID, portainer.ServiceResourceControl)
}
return transport.restrictedResourceOperation(request, serviceID, portainer.ServiceResourceControl, false)
}
return transport.executeDockerRequest(request)
}
}
func (transport *Transport) proxyVolumeRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath {
case "/volumes/create":
return transport.decorateGenericResourceCreationOperation(request, volumeObjectIdentifier, portainer.VolumeResourceControl)
case "/volumes/prune":
return transport.administratorOperation(request)
case "/volumes":
return transport.rewriteOperation(request, transport.volumeListOperation)
default:
// assume /volumes/{name}
volumeID := path.Base(requestPath)
if request.Method == http.MethodGet {
return transport.rewriteOperation(request, transport.volumeInspectOperation)
} else if request.Method == http.MethodDelete {
return transport.executeGenericResourceDeletionOperation(request, volumeID, portainer.VolumeResourceControl)
}
return transport.restrictedResourceOperation(request, volumeID, portainer.VolumeResourceControl, false)
}
}
func (transport *Transport) proxyNetworkRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath {
case "/networks/create":
return transport.decorateGenericResourceCreationOperation(request, networkObjectIdentifier, portainer.NetworkResourceControl)
case "/networks":
return transport.rewriteOperation(request, transport.networkListOperation)
default:
// assume /networks/{id}
networkID := path.Base(requestPath)
if request.Method == http.MethodGet {
return transport.rewriteOperation(request, transport.networkInspectOperation)
} else if request.Method == http.MethodDelete {
return transport.executeGenericResourceDeletionOperation(request, networkID, portainer.NetworkResourceControl)
}
return transport.restrictedResourceOperation(request, networkID, portainer.NetworkResourceControl, false)
}
}
func (transport *Transport) proxySecretRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath {
case "/secrets/create":
return transport.decorateGenericResourceCreationOperation(request, secretObjectIdentifier, portainer.SecretResourceControl)
case "/secrets":
return transport.rewriteOperation(request, transport.secretListOperation)
default:
// assume /secrets/{id}
secretID := path.Base(requestPath)
if request.Method == http.MethodGet {
return transport.rewriteOperation(request, transport.secretInspectOperation)
} else if request.Method == http.MethodDelete {
return transport.executeGenericResourceDeletionOperation(request, secretID, portainer.SecretResourceControl)
}
return transport.restrictedResourceOperation(request, secretID, portainer.SecretResourceControl, false)
}
}
func (transport *Transport) proxyNodeRequest(request *http.Request) (*http.Response, error) {
requestPath := request.URL.Path
// assume /nodes/{id}
if path.Base(requestPath) != "nodes" {
return transport.administratorOperation(request)
}
return transport.executeDockerRequest(request)
}
func (transport *Transport) proxySwarmRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath {
case "/swarm":
return transport.rewriteOperation(request, swarmInspectOperation)
default:
// assume /swarm/{action}
return transport.administratorOperation(request)
}
}
func (transport *Transport) proxyTaskRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath {
case "/tasks":
return transport.rewriteOperation(request, transport.taskListOperation)
default:
// assume /tasks/{id}
return transport.executeDockerRequest(request)
}
}
func (transport *Transport) proxyBuildRequest(request *http.Request) (*http.Response, error) {
return transport.interceptAndRewriteRequest(request, buildOperation)
}
func (transport *Transport) proxyImageRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath {
case "/images/create":
return transport.replaceRegistryAuthenticationHeader(request)
default:
if path.Base(requestPath) == "push" && request.Method == http.MethodPost {
return transport.replaceRegistryAuthenticationHeader(request)
}
return transport.executeDockerRequest(request)
}
}
func (transport *Transport) replaceRegistryAuthenticationHeader(request *http.Request) (*http.Response, error) {
accessContext, err := transport.createRegistryAccessContext(request)
if err != nil {
return nil, err
}
originalHeader := request.Header.Get("X-Registry-Auth")
if originalHeader != "" {
decodedHeaderData, err := base64.StdEncoding.DecodeString(originalHeader)
if err != nil {
return nil, err
}
var originalHeaderData registryAuthenticationHeader
err = json.Unmarshal(decodedHeaderData, &originalHeaderData)
if err != nil {
return nil, err
}
authenticationHeader := createRegistryAuthenticationHeader(originalHeaderData.Serveraddress, accessContext)
headerData, err := json.Marshal(authenticationHeader)
if err != nil {
return nil, err
}
header := base64.StdEncoding.EncodeToString(headerData)
request.Header.Set("X-Registry-Auth", header)
}
return transport.decorateGenericResourceCreationOperation(request, serviceObjectIdentifier, portainer.ServiceResourceControl)
}
func (transport *Transport) restrictedResourceOperation(request *http.Request, resourceID string, resourceType portainer.ResourceControlType, volumeBrowseRestrictionCheck bool) (*http.Response, error) {
var err error
tokenData, err := security.RetrieveTokenData(request)
if err != nil {
return nil, err
}
if tokenData.Role != portainer.AdministratorRole {
rbacExtension, err := transport.extensionService.Extension(portainer.RBACExtension)
if err != nil && err != portainer.ErrObjectNotFound {
return nil, err
}
if volumeBrowseRestrictionCheck {
settings, err := transport.settingsService.Settings()
if err != nil {
return nil, err
}
if rbacExtension != nil && !settings.AllowVolumeBrowserForRegularUsers {
return responseutils.WriteAccessDeniedResponse()
}
}
user, err := transport.userService.User(tokenData.ID)
if err != nil {
return nil, err
}
endpointResourceAccess := false
_, ok := user.EndpointAuthorizations[transport.endpoint.ID][portainer.EndpointResourcesAccess]
if ok {
endpointResourceAccess = true
}
if rbacExtension != nil && endpointResourceAccess {
return transport.executeDockerRequest(request)
}
teamMemberships, err := transport.teamMembershipService.TeamMembershipsByUserID(tokenData.ID)
if err != nil {
return nil, err
}
userTeamIDs := make([]portainer.TeamID, 0)
for _, membership := range teamMemberships {
userTeamIDs = append(userTeamIDs, membership.TeamID)
}
resourceControls, err := transport.resourceControlService.ResourceControls()
if err != nil {
return nil, err
}
resourceControl := portainer.GetResourceControlByResourceIDAndType(resourceID, resourceType, resourceControls)
if resourceControl == nil {
agentTargetHeader := request.Header.Get(portainer.PortainerAgentTargetHeader)
// This resource was created outside of portainer,
// is part of a Docker service or part of a Docker Swarm/Compose stack.
inheritedResourceControl, err := transport.getInheritedResourceControlFromServiceOrStack(resourceID, agentTargetHeader, resourceType, resourceControls)
if err != nil {
return nil, err
}
if inheritedResourceControl == nil || !portainer.UserCanAccessResource(tokenData.ID, userTeamIDs, inheritedResourceControl) {
return responseutils.WriteAccessDeniedResponse()
}
}
if resourceControl != nil && !portainer.UserCanAccessResource(tokenData.ID, userTeamIDs, resourceControl) {
return responseutils.WriteAccessDeniedResponse()
}
}
return transport.executeDockerRequest(request)
}
// rewriteOperationWithLabelFiltering will create a new operation context with data that will be used
// to decorate the original request's response as well as retrieve all the black listed labels
// to filter the resources.
func (transport *Transport) rewriteOperationWithLabelFiltering(request *http.Request, operation restrictedOperationRequest) (*http.Response, error) {
operationContext, err := transport.createOperationContext(request)
if err != nil {
return nil, err
}
settings, err := transport.settingsService.Settings()
if err != nil {
return nil, err
}
executor := &operationExecutor{
operationContext: operationContext,
labelBlackList: settings.BlackListedLabels,
}
return transport.executeRequestAndRewriteResponse(request, operation, executor)
}
// rewriteOperation will create a new operation context with data that will be used
// to decorate the original request's response.
func (transport *Transport) rewriteOperation(request *http.Request, operation restrictedOperationRequest) (*http.Response, error) {
operationContext, err := transport.createOperationContext(request)
if err != nil {
return nil, err
}
executor := &operationExecutor{
operationContext: operationContext,
}
return transport.executeRequestAndRewriteResponse(request, operation, executor)
}
func (transport *Transport) interceptAndRewriteRequest(request *http.Request, operation operationRequest) (*http.Response, error) {
err := operation(request)
if err != nil {
return nil, err
}
return transport.executeDockerRequest(request)
}
// decorateGenericResourceCreationResponse extracts the response as a JSON object, extracts the resource identifier from that object based
// on the resourceIdentifierAttribute parameter then generate a new resource control associated to that resource
// with a random token and rewrites the response by decorating the original response with a ResourceControl object.
// The generic Docker API response format is JSON object:
// https://docs.docker.com/engine/api/v1.40/#operation/ContainerCreate
// https://docs.docker.com/engine/api/v1.40/#operation/NetworkCreate
// https://docs.docker.com/engine/api/v1.40/#operation/VolumeCreate
// https://docs.docker.com/engine/api/v1.40/#operation/ServiceCreate
// https://docs.docker.com/engine/api/v1.40/#operation/SecretCreate
// https://docs.docker.com/engine/api/v1.40/#operation/ConfigCreate
func (transport *Transport) decorateGenericResourceCreationResponse(response *http.Response, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType, userID portainer.UserID) error {
responseObject, err := responseutils.GetResponseAsJSONOBject(response)
if err != nil {
return err
}
if responseObject[resourceIdentifierAttribute] == nil {
log.Printf("[ERROR] [proxy,docker]")
return errors.New("missing identifier in Docker resource creation response")
}
resourceID := responseObject[resourceIdentifierAttribute].(string)
resourceControl, err := transport.createPrivateResourceControl(resourceID, resourceType, userID)
if err != nil {
return err
}
responseObject = decorateObject(responseObject, resourceControl)
return responseutils.RewriteResponse(response, responseObject, http.StatusOK)
}
func (transport *Transport) decorateGenericResourceCreationOperation(request *http.Request, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType) (*http.Response, error) {
tokenData, err := security.RetrieveTokenData(request)
if err != nil {
return nil, err
}
response, err := transport.executeDockerRequest(request)
if err != nil {
return response, err
}
if response.StatusCode == http.StatusCreated {
err = transport.decorateGenericResourceCreationResponse(response, resourceIdentifierAttribute, resourceType, tokenData.ID)
}
return response, err
}
func (transport *Transport) executeGenericResourceDeletionOperation(request *http.Request, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType) (*http.Response, error) {
response, err := transport.restrictedResourceOperation(request, resourceIdentifierAttribute, resourceType, false)
if err != nil {
return response, err
}
resourceControl, err := transport.resourceControlService.ResourceControlByResourceIDAndType(resourceIdentifierAttribute, resourceType)
if err != nil {
return response, err
}
if resourceControl != nil {
err = transport.resourceControlService.DeleteResourceControl(resourceControl.ID)
if err != nil {
return response, err
}
}
return response, err
}
func (transport *Transport) executeRequestAndRewriteResponse(request *http.Request, operation restrictedOperationRequest, executor *operationExecutor) (*http.Response, error) {
response, err := transport.executeDockerRequest(request)
if err != nil {
return response, err
}
err = operation(response, executor)
return response, err
}
// administratorOperation ensures that the user has administrator privileges
// before executing the original request.
func (transport *Transport) administratorOperation(request *http.Request) (*http.Response, error) {
tokenData, err := security.RetrieveTokenData(request)
if err != nil {
return nil, err
}
if tokenData.Role != portainer.AdministratorRole {
return responseutils.WriteAccessDeniedResponse()
}
return transport.executeDockerRequest(request)
}
func (transport *Transport) createRegistryAccessContext(request *http.Request) (*registryAccessContext, error) {
tokenData, err := security.RetrieveTokenData(request)
if err != nil {
return nil, err
}
accessContext := &registryAccessContext{
isAdmin: true,
userID: tokenData.ID,
}
hub, err := transport.dockerHubService.DockerHub()
if err != nil {
return nil, err
}
accessContext.dockerHub = hub
registries, err := transport.registryService.Registries()
if err != nil {
return nil, err
}
accessContext.registries = registries
if tokenData.Role != portainer.AdministratorRole {
accessContext.isAdmin = false
teamMemberships, err := transport.teamMembershipService.TeamMembershipsByUserID(tokenData.ID)
if err != nil {
return nil, err
}
accessContext.teamMemberships = teamMemberships
}
return accessContext, nil
}
func (transport *Transport) createOperationContext(request *http.Request) (*restrictedDockerOperationContext, error) {
var err error
tokenData, err := security.RetrieveTokenData(request)
if err != nil {
return nil, err
}
resourceControls, err := transport.resourceControlService.ResourceControls()
if err != nil {
return nil, err
}
operationContext := &restrictedDockerOperationContext{
isAdmin: true,
userID: tokenData.ID,
resourceControls: resourceControls,
endpointResourceAccess: false,
}
if tokenData.Role != portainer.AdministratorRole {
operationContext.isAdmin = false
user, err := transport.userService.User(operationContext.userID)
if err != nil {
return nil, err
}
_, ok := user.EndpointAuthorizations[transport.endpoint.ID][portainer.EndpointResourcesAccess]
if ok {
operationContext.endpointResourceAccess = true
}
teamMemberships, err := transport.teamMembershipService.TeamMembershipsByUserID(tokenData.ID)
if err != nil {
return nil, err
}
userTeamIDs := make([]portainer.TeamID, 0)
for _, membership := range teamMemberships {
userTeamIDs = append(userTeamIDs, membership.TeamID)
}
operationContext.userTeamIDs = userTeamIDs
}
return operationContext, nil
}

View file

@ -0,0 +1,89 @@
package docker
import (
"context"
"net/http"
"github.com/docker/docker/client"
"github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/responseutils"
)
const (
volumeObjectIdentifier = "Name"
)
func getInheritedResourceControlFromVolumeLabels(dockerClient *client.Client, volumeID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) {
volume, err := dockerClient.VolumeInspect(context.Background(), volumeID)
if err != nil {
return nil, err
}
swarmStackName := volume.Labels[resourceLabelForDockerSwarmStackName]
if swarmStackName != "" {
return portainer.GetResourceControlByResourceIDAndType(swarmStackName, portainer.StackResourceControl, resourceControls), nil
}
return nil, nil
}
// volumeListOperation extracts the response as a JSON object, loop through the volume array
// decorate and/or filter the volumes based on resource controls before rewriting the response.
func (transport *Transport) volumeListOperation(response *http.Response, executor *operationExecutor) error {
// VolumeList response is a JSON object
// https://docs.docker.com/engine/api/v1.28/#operation/VolumeList
responseObject, err := responseutils.GetResponseAsJSONOBject(response)
if err != nil {
return err
}
// The "Volumes" field contains the list of volumes as an array of JSON objects
if responseObject["Volumes"] != nil {
volumeData := responseObject["Volumes"].([]interface{})
resourceOperationParameters := &resourceOperationParameters{
resourceIdentifierAttribute: volumeObjectIdentifier,
resourceType: portainer.VolumeResourceControl,
labelsObjectSelector: selectorVolumeLabels,
}
volumeData, err = transport.applyAccessControlOnResourceList(resourceOperationParameters, volumeData, executor)
if err != nil {
return err
}
// Overwrite the original volume list
responseObject["Volumes"] = volumeData
}
return responseutils.RewriteResponse(response, responseObject, http.StatusOK)
}
// volumeInspectOperation extracts the response as a JSON object, verify that the user
// has access to the volume based on any existing resource control and either rewrite an access denied response or a decorated volume.
func (transport *Transport) volumeInspectOperation(response *http.Response, executor *operationExecutor) error {
// VolumeInspect response is a JSON object
// https://docs.docker.com/engine/api/v1.28/#operation/VolumeInspect
responseObject, err := responseutils.GetResponseAsJSONOBject(response)
if err != nil {
return err
}
resourceOperationParameters := &resourceOperationParameters{
resourceIdentifierAttribute: volumeObjectIdentifier,
resourceType: portainer.VolumeResourceControl,
labelsObjectSelector: selectorVolumeLabels,
}
return transport.applyAccessControlOnResource(resourceOperationParameters, responseObject, response, executor)
}
// selectorVolumeLabels retrieve the labels object associated to the volume object.
// Labels are available under the "Labels" property.
// API schema references:
// https://docs.docker.com/engine/api/v1.28/#operation/VolumeInspect
// https://docs.docker.com/engine/api/v1.28/#operation/VolumeList
func selectorVolumeLabels(responseObject map[string]interface{}) map[string]interface{} {
return responseutils.GetJSONObject(responseObject, "Labels")
}

View file

@ -0,0 +1,46 @@
// +build aix darwin dragonfly freebsd linux netbsd openbsd solaris
package factory
import (
"net"
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/docker"
)
func (factory ProxyFactory) newOSBasedLocalProxy(path string, endpoint *portainer.Endpoint) (http.Handler, error) {
transportParameters := &docker.TransportParameters{
Endpoint: endpoint,
ResourceControlService: factory.resourceControlService,
UserService: factory.userService,
TeamService: factory.teamService,
TeamMembershipService: factory.teamMembershipService,
RegistryService: factory.registryService,
DockerHubService: factory.dockerHubService,
SettingsService: factory.settingsService,
ReverseTunnelService: factory.reverseTunnelService,
ExtensionService: factory.extensionService,
SignatureService: factory.signatureService,
DockerClientFactory: factory.dockerClientFactory,
}
proxy := &dockerLocalProxy{}
dockerTransport, err := docker.NewTransport(transportParameters, newSocketTransport(path))
if err != nil {
return nil, err
}
proxy.transport = dockerTransport
return proxy, nil
}
func newSocketTransport(socketPath string) *http.Transport {
return &http.Transport{
Dial: func(proto, addr string) (conn net.Conn, err error) {
return net.Dial("unix", socketPath)
},
}
}

View file

@ -0,0 +1,47 @@
// +build windows
package factory
import (
"net"
"net/http"
"github.com/Microsoft/go-winio"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/docker"
)
func (factory ProxyFactory) newOSBasedLocalProxy(path string, endpoint *portainer.Endpoint) (http.Handler, error) {
transportParameters := &docker.TransportParameters{
Endpoint: endpoint,
ResourceControlService: factory.resourceControlService,
UserService: factory.userService,
TeamService: factory.teamService,
TeamMembershipService: factory.teamMembershipService,
RegistryService: factory.registryService,
DockerHubService: factory.dockerHubService,
SettingsService: factory.settingsService,
ReverseTunnelService: factory.reverseTunnelService,
ExtensionService: factory.extensionService,
SignatureService: factory.signatureService,
DockerClientFactory: factory.dockerClientFactory,
}
proxy := &dockerLocalProxy{}
dockerTransport, err := docker.NewTransport(transportParameters, newSocketTransport(path))
if err != nil {
return nil, err
}
proxy.transport = dockerTransport
return proxy, nil
}
func newNamedPipeTransport(namedPipePath string) *http.Transport {
return &http.Transport{
Dial: func(proto, addr string) (conn net.Conn, err error) {
return winio.DialPipe(namedPipePath, nil)
},
}
}

View file

@ -0,0 +1,109 @@
package factory
import (
"fmt"
"net/http"
"net/http/httputil"
"net/url"
"github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/docker"
)
const azureAPIBaseURL = "https://management.azure.com"
var extensionPorts = map[portainer.ExtensionID]string{
portainer.RegistryManagementExtension: "7001",
portainer.OAuthAuthenticationExtension: "7002",
portainer.RBACExtension: "7003",
}
type (
// ProxyFactory is a factory to create reverse proxies to Docker endpoints and extensions
ProxyFactory struct {
resourceControlService portainer.ResourceControlService
userService portainer.UserService
teamService portainer.TeamService
teamMembershipService portainer.TeamMembershipService
settingsService portainer.SettingsService
registryService portainer.RegistryService
dockerHubService portainer.DockerHubService
signatureService portainer.DigitalSignatureService
reverseTunnelService portainer.ReverseTunnelService
extensionService portainer.ExtensionService
dockerClientFactory *docker.ClientFactory
}
// ProxyFactoryParameters is used to create a new ProxyFactory
ProxyFactoryParameters struct {
ResourceControlService portainer.ResourceControlService
UserService portainer.UserService
TeamService portainer.TeamService
TeamMembershipService portainer.TeamMembershipService
SettingsService portainer.SettingsService
RegistryService portainer.RegistryService
DockerHubService portainer.DockerHubService
SignatureService portainer.DigitalSignatureService
ReverseTunnelService portainer.ReverseTunnelService
ExtensionService portainer.ExtensionService
DockerClientFactory *docker.ClientFactory
}
)
// NewProxyFactory returns a pointer to a new instance of a ProxyFactory
func NewProxyFactory(parameters *ProxyFactoryParameters) *ProxyFactory {
return &ProxyFactory{
resourceControlService: parameters.ResourceControlService,
userService: parameters.UserService,
teamService: parameters.TeamService,
teamMembershipService: parameters.TeamMembershipService,
settingsService: parameters.SettingsService,
registryService: parameters.RegistryService,
dockerHubService: parameters.DockerHubService,
signatureService: parameters.SignatureService,
reverseTunnelService: parameters.ReverseTunnelService,
extensionService: parameters.ExtensionService,
dockerClientFactory: parameters.DockerClientFactory,
}
}
// BuildExtensionURL returns the URL to an extension server
func BuildExtensionURL(extensionID portainer.ExtensionID) string {
return fmt.Sprintf("http://%s:%s", portainer.ExtensionServer, extensionPorts[extensionID])
}
// NewExtensionProxy returns a new HTTP proxy to an extension server
func (factory *ProxyFactory) NewExtensionProxy(extensionID portainer.ExtensionID) (http.Handler, error) {
address := "http://" + portainer.ExtensionServer + ":" + extensionPorts[extensionID]
extensionURL, err := url.Parse(address)
if err != nil {
return nil, err
}
extensionURL.Scheme = "http"
proxy := httputil.NewSingleHostReverseProxy(extensionURL)
return proxy, nil
}
// NewLegacyExtensionProxy returns a new HTTP proxy to a legacy extension server (Storidge)
func (factory *ProxyFactory) NewLegacyExtensionProxy(extensionAPIURL string) (http.Handler, error) {
extensionURL, err := url.Parse(extensionAPIURL)
if err != nil {
return nil, err
}
extensionURL.Scheme = "http"
proxy := httputil.NewSingleHostReverseProxy(extensionURL)
return proxy, nil
}
// NewEndpointProxy returns a new reverse proxy (filesystem based or HTTP) to an endpoint API server
func (factory *ProxyFactory) NewEndpointProxy(endpoint *portainer.Endpoint) (http.Handler, error) {
switch endpoint.Type {
case portainer.AzureEnvironment:
return newAzureProxy(endpoint)
}
return factory.newDockerProxy(endpoint)
}

View file

@ -0,0 +1,11 @@
package responseutils
// GetJSONObject will extract an object from a specific property of another JSON object.
// Returns nil if nothing is associated to the specified key.
func GetJSONObject(jsonObject map[string]interface{}, property string) map[string]interface{} {
object := jsonObject[property]
if object != nil {
return object.(map[string]interface{})
}
return nil
}

View file

@ -0,0 +1,106 @@
package responseutils
import (
"bytes"
"encoding/json"
"errors"
"io/ioutil"
"log"
"net/http"
"strconv"
)
// GetResponseAsJSONOBject returns the response content as a generic JSON object
func GetResponseAsJSONOBject(response *http.Response) (map[string]interface{}, error) {
responseData, err := getResponseBodyAsGenericJSON(response)
if err != nil {
return nil, err
}
responseObject := responseData.(map[string]interface{})
return responseObject, nil
}
// GetResponseAsJSONArray returns the response content as an array of generic JSON object
func GetResponseAsJSONArray(response *http.Response) ([]interface{}, error) {
responseData, err := getResponseBodyAsGenericJSON(response)
if err != nil {
return nil, err
}
switch responseObject := responseData.(type) {
case []interface{}:
return responseObject, nil
case map[string]interface{}:
if responseObject["message"] != nil {
return nil, errors.New(responseObject["message"].(string))
}
log.Printf("[ERROR] [http,proxy,response] [message: invalid response format, expecting JSON array] [response: %+v]", responseObject)
return nil, errors.New("unable to parse response: expected JSON array, got JSON object")
default:
log.Printf("[ERROR] [http,proxy,response] [message: invalid response format, expecting JSON array] [response: %+v]", responseObject)
return nil, errors.New("unable to parse response: expected JSON array")
}
}
func getResponseBodyAsGenericJSON(response *http.Response) (interface{}, error) {
if response.Body == nil {
return nil, errors.New("unable to parse response: empty response body")
}
var data interface{}
body, err := ioutil.ReadAll(response.Body)
if err != nil {
return nil, err
}
err = response.Body.Close()
if err != nil {
return nil, err
}
err = json.Unmarshal(body, &data)
if err != nil {
return nil, err
}
return data, nil
}
type dockerErrorResponse struct {
Message string `json:"message,omitempty"`
}
// WriteAccessDeniedResponse will create a new access denied response
func WriteAccessDeniedResponse() (*http.Response, error) {
response := &http.Response{}
err := RewriteResponse(response, dockerErrorResponse{Message: "access denied to resource"}, http.StatusForbidden)
return response, err
}
// RewriteAccessDeniedResponse will overwrite the existing response with an access denied response
func RewriteAccessDeniedResponse(response *http.Response) error {
return RewriteResponse(response, dockerErrorResponse{Message: "access denied to resource"}, http.StatusForbidden)
}
// RewriteResponse will replace the existing response body and status code with the one specified
// in parameters
func RewriteResponse(response *http.Response, newResponseData interface{}, statusCode int) error {
jsonData, err := json.Marshal(newResponseData)
if err != nil {
return err
}
body := ioutil.NopCloser(bytes.NewReader(jsonData))
response.StatusCode = statusCode
response.Body = body
response.ContentLength = int64(len(jsonData))
if response.Header == nil {
response.Header = make(http.Header)
}
response.Header.Set("Content-Length", strconv.Itoa(len(jsonData)))
return nil
}

View file

@ -0,0 +1,46 @@
package factory
import (
"net/http"
"net/http/httputil"
"net/url"
"strings"
)
// newSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy
// from golang.org/src/net/http/httputil/reverseproxy.go and merely sets the Host
// HTTP header, which NewSingleHostReverseProxy deliberately preserves.
func newSingleHostReverseProxyWithHostHeader(target *url.URL) *httputil.ReverseProxy {
targetQuery := target.RawQuery
director := func(req *http.Request) {
req.URL.Scheme = target.Scheme
req.URL.Host = target.Host
req.URL.Path = singleJoiningSlash(target.Path, req.URL.Path)
req.Host = req.URL.Host
if targetQuery == "" || req.URL.RawQuery == "" {
req.URL.RawQuery = targetQuery + req.URL.RawQuery
} else {
req.URL.RawQuery = targetQuery + "&" + req.URL.RawQuery
}
if _, ok := req.Header["User-Agent"]; !ok {
// explicitly disable User-Agent so it's not set to default value
req.Header.Set("User-Agent", "")
}
}
return &httputil.ReverseProxy{Director: director}
}
// singleJoiningSlash from golang.org/src/net/http/httputil/reverseproxy.go
// included here for use in NewSingleHostReverseProxyWithHostHeader
// because its used in NewSingleHostReverseProxy from golang.org/src/net/http/httputil/reverseproxy.go
func singleJoiningSlash(a, b string) string {
aslash := strings.HasSuffix(a, "/")
bslash := strings.HasPrefix(b, "/")
switch {
case aslash && bslash:
return a + b[1:]
case !aslash && !bslash:
return a + "/" + b
}
return a + b
}