chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:08:39 +08:00
commit a0df89c693
5252 changed files with 523444 additions and 0 deletions
+114
View File
@@ -0,0 +1,114 @@
package factory
import (
"fmt"
"net"
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/crypto"
"github.com/portainer/portainer/api/http/proxy/factory/agent"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/url"
"github.com/portainer/portainer/pkg/libhttp/ssrf"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)
// ProxyServer provide an extended proxy with a local server to forward requests
type ProxyServer struct {
server *http.Server
Port int
}
// NewAgentProxy creates a new instance of ProxyServer that wrap http requests with agent headers
func (factory *ProxyFactory) NewAgentProxy(endpoint *portainer.Endpoint) (*ProxyServer, error) {
urlString := endpoint.URL
if endpointutils.IsEdgeEndpoint(endpoint) {
tunnelAddr, err := factory.reverseTunnelService.TunnelAddr(endpoint)
if err != nil {
return nil, errors.Wrap(err, "failed starting tunnel")
}
urlString = "http://" + tunnelAddr
}
endpointURL, err := url.ParseURL(urlString)
if err != nil {
return nil, errors.Wrapf(err, "failed parsing url %s", endpoint.URL)
}
endpointURL.Scheme = "http"
var innerTransport *http.Transport
if endpoint.TLSConfig.TLS || endpoint.TLSConfig.TLSSkipVerify {
tlsConfig, err := crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig)
if err != nil {
return nil, errors.WithMessage(err, "failed generating tls configuration")
}
endpointURL.Scheme = "https"
if endpointutils.IsEdgeEndpoint(endpoint) {
innerTransport = ssrf.NewInternalTransport(tlsConfig)
} else {
innerTransport = ssrf.NewTransport(tlsConfig)
innerTransport.Protocols = ssrf.HTTP1Only()
}
} else if endpointutils.IsEdgeEndpoint(endpoint) {
innerTransport = ssrf.NewInternalTransport(nil)
} else {
innerTransport = ssrf.NewTransport(nil)
}
proxy := NewSingleHostReverseProxyWithHostHeader(endpointURL)
proxy.Transport = agent.NewTransport(factory.signatureService, innerTransport)
proxyServer := &ProxyServer{
server: &http.Server{
Handler: proxy,
},
Port: 0,
}
if err := proxyServer.start(); err != nil {
return nil, errors.Wrap(err, "failed starting proxy server")
}
return proxyServer, nil
}
func (proxy *ProxyServer) start() error {
listener, err := net.Listen("tcp", ":0")
if err != nil {
return err
}
proxy.Port = listener.Addr().(*net.TCPAddr).Port
go func() {
proxyHost := fmt.Sprintf("127.0.0.1:%d", proxy.Port)
log.Debug().Str("host", proxyHost).Msg("starting proxy server")
err := proxy.server.Serve(listener)
log.Debug().Str("host", proxyHost).Msg("exiting proxy server")
if err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Debug().Str("host", proxyHost).Err(err).Msg("proxy server exited with an error")
}
}()
return nil
}
// Close shuts down the server
func (proxy *ProxyServer) Close() {
if proxy.server != nil {
if err := proxy.server.Close(); err != nil {
log.Warn().Err(err).Msg("failed to close proxy server")
}
}
}
+36
View File
@@ -0,0 +1,36 @@
package agent
import (
"net/http"
portainer "github.com/portainer/portainer/api"
)
// Transport is an http.Transport wrapper that adds custom http headers to communicate to an Agent
type Transport struct {
httpTransport *http.Transport
signatureService portainer.DigitalSignatureService
}
// NewTransport returns a new transport that can be used to send signed requests to a Portainer agent
func NewTransport(signatureService portainer.DigitalSignatureService, httpTransport *http.Transport) *Transport {
transport := &Transport{
httpTransport: httpTransport,
signatureService: signatureService,
}
return transport
}
// RoundTrip is the implementation of the http.RoundTripper interface
func (transport *Transport) RoundTrip(request *http.Request) (*http.Response, error) {
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)
return transport.httpTransport.RoundTrip(request)
}
+21
View File
@@ -0,0 +1,21 @@
package factory
import (
"net/http"
"net/url"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/proxy/factory/azure"
)
func newAzureProxy(endpoint *portainer.Endpoint, dataStore dataservices.DataStore) (http.Handler, error) {
remoteURL, err := url.Parse(azureAPIBaseURL)
if err != nil {
return nil, err
}
proxy := NewSingleHostReverseProxyWithHostHeader(remoteURL)
proxy.Transport = azure.NewTransport(&endpoint.AzureCredentials, dataStore, endpoint)
return proxy, nil
}
@@ -0,0 +1,151 @@
package azure
import (
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/rs/zerolog/log"
)
func (transport *Transport) createAzureRequestContext(request *http.Request) (*azureRequestContext, error) {
var err error
tokenData, err := security.RetrieveTokenData(request)
if err != nil {
return nil, err
}
resourceControls, err := transport.dataStore.ResourceControl().ReadAll()
if err != nil {
return nil, err
}
context := &azureRequestContext{
isAdmin: true,
userID: tokenData.ID,
resourceControls: resourceControls,
}
if tokenData.Role != portainer.AdministratorRole {
context.isAdmin = false
teamMemberships, err := transport.dataStore.TeamMembership().TeamMembershipsByUserID(tokenData.ID)
if err != nil {
return nil, err
}
context.userTeamIDs = authorization.TeamIDs(teamMemberships)
}
return context, nil
}
func decorateObject(object map[string]any, resourceControl *portainer.ResourceControl) map[string]any {
if object["Portainer"] == nil {
object["Portainer"] = make(map[string]any)
}
portainerMetadata := object["Portainer"].(map[string]any)
portainerMetadata["ResourceControl"] = resourceControl
return object
}
func (transport *Transport) createPrivateResourceControl(
resourceIdentifier string,
resourceType portainer.ResourceControlType,
userID portainer.UserID) (*portainer.ResourceControl, error) {
resourceControl := authorization.NewPrivateResourceControl(resourceIdentifier, resourceType, userID)
if err := transport.dataStore.ResourceControl().Create(resourceControl); err != nil {
log.Error().
Str("resource", resourceIdentifier).
Err(err).
Msg("unable to persist resource control")
return nil, err
}
return resourceControl, nil
}
func (transport *Transport) userCanDeleteContainerGroup(request *http.Request, context *azureRequestContext) bool {
if context.isAdmin {
return true
}
resourceIdentifier := request.URL.Path
resourceControl := transport.findResourceControl(resourceIdentifier, context)
return authorization.UserCanAccessResource(context.userID, context.userTeamIDs, resourceControl)
}
func (transport *Transport) decorateContainerGroups(containerGroups []any, context *azureRequestContext) []any {
decoratedContainerGroups := make([]any, 0)
for _, containerGroup := range containerGroups {
containerGroup = transport.decorateContainerGroup(containerGroup.(map[string]any), context)
decoratedContainerGroups = append(decoratedContainerGroups, containerGroup)
}
return decoratedContainerGroups
}
func (transport *Transport) decorateContainerGroup(containerGroup map[string]any, context *azureRequestContext) map[string]any {
containerGroupId, ok := containerGroup["id"].(string)
if ok {
resourceControl := transport.findResourceControl(containerGroupId, context)
if resourceControl != nil {
containerGroup = decorateObject(containerGroup, resourceControl)
}
} else {
log.Warn().Msg("unable to find resource id property in container group")
}
return containerGroup
}
func (transport *Transport) filterContainerGroups(containerGroups []any, context *azureRequestContext) []any {
filteredContainerGroups := make([]any, 0)
for _, containerGroup := range containerGroups {
userCanAccessResource := false
containerGroup := containerGroup.(map[string]any)
portainerObject, ok := containerGroup["Portainer"].(map[string]any)
if ok {
resourceControl, ok := portainerObject["ResourceControl"].(*portainer.ResourceControl)
if ok {
userCanAccessResource = authorization.UserCanAccessResource(context.userID, context.userTeamIDs, resourceControl)
}
}
if context.isAdmin || userCanAccessResource {
filteredContainerGroups = append(filteredContainerGroups, containerGroup)
}
}
return filteredContainerGroups
}
func (transport *Transport) removeResourceControl(containerGroup map[string]any, context *azureRequestContext) error {
containerGroupID, ok := containerGroup["id"].(string)
if !ok {
log.Debug().Msg("missing ID in container group")
return nil
}
if resourceControl := transport.findResourceControl(containerGroupID, context); resourceControl != nil {
return transport.dataStore.ResourceControl().Delete(resourceControl.ID)
}
return nil
}
func (transport *Transport) findResourceControl(containerGroupId string, context *azureRequestContext) *portainer.ResourceControl {
return authorization.GetResourceControlByResourceIDAndType(containerGroupId, portainer.ContainerGroupResourceControl, context.resourceControls)
}
@@ -0,0 +1,155 @@
package azure
import (
"errors"
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/utils"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/rs/zerolog/log"
)
// proxy for /subscriptions/*/resourceGroups/*/providers/Microsoft.ContainerInstance/containerGroups/*
func (transport *Transport) proxyContainerGroupRequest(request *http.Request) (*http.Response, error) {
switch request.Method {
case http.MethodPut:
return transport.proxyContainerGroupPutRequest(request)
case http.MethodGet:
return transport.proxyContainerGroupGetRequest(request)
case http.MethodDelete:
return transport.proxyContainerGroupDeleteRequest(request)
default:
return http.DefaultTransport.RoundTrip(request)
}
}
func (transport *Transport) proxyContainerGroupPutRequest(request *http.Request) (*http.Response, error) {
tokenData, err := security.RetrieveTokenData(request)
if err != nil {
return nil, httperror.Forbidden("Permission denied to access environment", err)
}
// Add a lock before processing existence check
transport.mutex.Lock()
defer transport.mutex.Unlock()
// Generate a temp http GET request based on the current PUT request
validationRequest := &http.Request{
Method: http.MethodGet,
URL: request.URL,
Header: http.Header{
"Authorization": []string{"Bearer " + tokenData.Token},
},
}
// Fire the request to Azure API to validate if there is an existing container instance with the same name
// positive - reject the request
// negative - continue the process
validationResponse, err := http.DefaultTransport.RoundTrip(validationRequest)
if err != nil {
return validationResponse, err
}
if validationResponse.StatusCode >= 200 && validationResponse.StatusCode < 300 {
resp := &http.Response{
Header: http.Header{
http.CanonicalHeaderKey("content-type"): []string{"application/json"},
},
}
errObj := map[string]string{
"message": "A container instance with the same name already exists inside the selected resource group",
}
err = utils.RewriteResponse(resp, errObj, http.StatusConflict)
return resp, err
}
response, err := http.DefaultTransport.RoundTrip(request)
if err != nil {
return response, err
}
responseObject, err := utils.GetResponseAsJSONObject(response)
if err != nil {
return response, err
}
containerGroupID, ok := responseObject["id"].(string)
if !ok {
return response, errors.New("Missing container group ID")
}
context, err := transport.createAzureRequestContext(request)
if err != nil {
return response, err
}
resourceControl, err := transport.createPrivateResourceControl(containerGroupID, portainer.ContainerGroupResourceControl, context.userID)
if err != nil {
return response, err
}
responseObject = decorateObject(responseObject, resourceControl)
err = utils.RewriteResponse(response, responseObject, http.StatusOK)
return response, err
}
func (transport *Transport) proxyContainerGroupGetRequest(request *http.Request) (*http.Response, error) {
response, err := http.DefaultTransport.RoundTrip(request)
if err != nil {
return response, err
}
responseObject, err := utils.GetResponseAsJSONObject(response)
if err != nil {
return nil, err
}
context, err := transport.createAzureRequestContext(request)
if err != nil {
return nil, err
}
responseObject = transport.decorateContainerGroup(responseObject, context)
if err := utils.RewriteResponse(response, responseObject, http.StatusOK); err != nil {
log.Warn().Err(err).Msg("failed to rewrite response")
}
return response, nil
}
func (transport *Transport) proxyContainerGroupDeleteRequest(request *http.Request) (*http.Response, error) {
context, err := transport.createAzureRequestContext(request)
if err != nil {
return nil, err
}
if !transport.userCanDeleteContainerGroup(request, context) {
return utils.WriteAccessDeniedResponse()
}
response, err := http.DefaultTransport.RoundTrip(request)
if err != nil {
return response, err
}
responseObject, err := utils.GetResponseAsJSONObject(response)
if err != nil {
return nil, err
}
if err := transport.removeResourceControl(responseObject, context); err != nil {
log.Warn().Err(err).Msg("failed to remove resource control")
}
if err := utils.RewriteResponse(response, responseObject, http.StatusOK); err != nil {
log.Warn().Err(err).Msg("failed to rewrite response")
}
return response, nil
}
@@ -0,0 +1,50 @@
package azure
import (
"errors"
"net/http"
"github.com/portainer/portainer/api/http/proxy/factory/utils"
)
// proxy for /subscriptions/*/providers/Microsoft.ContainerInstance/containerGroups
func (transport *Transport) proxyContainerGroupsRequest(request *http.Request) (*http.Response, error) {
switch request.Method {
case http.MethodGet:
return transport.proxyContainerGroupsGetRequest(request)
default:
return http.DefaultTransport.RoundTrip(request)
}
}
func (transport *Transport) proxyContainerGroupsGetRequest(request *http.Request) (*http.Response, error) {
response, err := http.DefaultTransport.RoundTrip(request)
if err != nil {
return nil, err
}
responseObject, err := utils.GetResponseAsJSONObject(response)
if err != nil {
return nil, err
}
value, ok := responseObject["value"].([]any)
if ok {
context, err := transport.createAzureRequestContext(request)
if err != nil {
return response, err
}
decoratedValue := transport.decorateContainerGroups(value, context)
filteredValue := transport.filterContainerGroups(decoratedValue, context)
responseObject["value"] = filteredValue
if err := utils.RewriteResponse(response, responseObject, http.StatusOK); err != nil {
return nil, err
}
} else {
return nil, errors.New("The container groups response has no value property")
}
return response, nil
}
+106
View File
@@ -0,0 +1,106 @@
package azure
import (
"net/http"
"path"
"strconv"
"sync"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"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
dataStore dataservices.DataStore
endpoint *portainer.Endpoint
}
azureRequestContext struct {
isAdmin bool
userID portainer.UserID
userTeamIDs []portainer.TeamID
resourceControls []portainer.ResourceControl
}
)
// 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, dataStore dataservices.DataStore, endpoint *portainer.Endpoint) *Transport {
return &Transport{
credentials: credentials,
client: client.NewHTTPClient(),
dataStore: dataStore,
endpoint: endpoint,
}
}
// RoundTrip is the implementation of the http.RoundTripper interface
func (transport *Transport) RoundTrip(request *http.Request) (*http.Response, error) {
return transport.proxyAzureRequest(request)
}
func (transport *Transport) proxyAzureRequest(request *http.Request) (*http.Response, error) {
requestPath := request.URL.Path
err := transport.retrieveAuthenticationToken()
if err != nil {
return nil, err
}
request.Header.Set("Authorization", "Bearer "+transport.token.value)
if match, _ := path.Match(portainer.AzurePathContainerGroups, requestPath); match {
return transport.proxyContainerGroupsRequest(request)
} else if match, _ := path.Match(portainer.AzurePathContainerGroup, requestPath); match {
return transport.proxyContainerGroupRequest(request)
}
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
}
+130
View File
@@ -0,0 +1,130 @@
package factory
import (
"io"
"net/http"
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/crypto"
"github.com/portainer/portainer/api/http/proxy/factory/docker"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/url"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/ssrf"
"github.com/rs/zerolog/log"
)
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.ParseURL(endpoint.URL)
if err != nil {
return nil, err
}
return factory.newOSBasedLocalProxy(endpointURL.Path, endpoint)
}
func (factory *ProxyFactory) newDockerHTTPProxy(endpoint *portainer.Endpoint) (http.Handler, error) {
rawURL := endpoint.URL
if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment {
tunnelAddr, err := factory.reverseTunnelService.TunnelAddr(endpoint)
if err != nil {
return nil, err
}
rawURL = "http://" + tunnelAddr
}
endpointURL, err := url.ParseURL(rawURL)
if err != nil {
return nil, err
}
endpointURL.Scheme = "http"
transportParameters := &docker.TransportParameters{
Endpoint: endpoint,
DataStore: factory.dataStore,
ReverseTunnelService: factory.reverseTunnelService,
SignatureService: factory.signatureService,
DockerClientFactory: factory.dockerClientFactory,
}
var innerTransport *http.Transport
if endpoint.TLSConfig.TLS || endpoint.TLSConfig.TLSSkipVerify {
tlsConfig, err := crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig)
if err != nil {
return nil, err
}
endpointURL.Scheme = "https"
if endpointutils.IsEdgeEndpoint(endpoint) {
innerTransport = ssrf.NewInternalTransport(tlsConfig)
} else {
innerTransport = ssrf.NewTransport(tlsConfig)
}
} else if endpointutils.IsEdgeEndpoint(endpoint) {
innerTransport = ssrf.NewInternalTransport(nil)
} else {
innerTransport = ssrf.NewTransport(nil)
}
dockerTransport, err := docker.NewTransport(transportParameters, innerTransport, factory.gitService, factory.snapshotService)
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 func() {
if err := res.Body.Close(); err != nil {
log.Warn().Err(err).Msg("proxy error: failed to close response body")
}
}()
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.Debug().Err(err).Msg("proxy error")
}
}
@@ -0,0 +1,348 @@
package docker
import (
"net/http"
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/docker/consts"
"github.com/portainer/portainer/api/http/proxy/factory/utils"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/slicesx"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/docker/docker/client"
"github.com/rs/zerolog/log"
)
const (
resourceLabelForPortainerTeamResourceControl = "io.portainer.accesscontrol.teams"
resourceLabelForPortainerUserResourceControl = "io.portainer.accesscontrol.users"
resourceLabelForPortainerPublicResourceControl = "io.portainer.accesscontrol.public"
)
type (
resourceLabelsObjectSelector func(map[string]any) map[string]any
resourceOperationParameters struct {
resourceIdentifierAttribute string
resourceType portainer.ResourceControlType
labelsObjectSelector resourceLabelsObjectSelector
}
)
func getUniqueElements(items string) []string {
xs := strings.Split(items, ",")
xs = slicesx.Map(xs, strings.TrimSpace)
xs = slicesx.FilterInPlace(xs, func(x string) bool { return len(x) > 0 })
return slicesx.Unique(xs)
}
func (transport *Transport) newResourceControlFromPortainerLabels(labelsObject map[string]any, resourceID string, resourceType portainer.ResourceControlType) (*portainer.ResourceControl, error) {
if labelsObject[resourceLabelForPortainerPublicResourceControl] != nil {
resourceControl := authorization.NewPublicResourceControl(resourceID, resourceType)
if err := transport.dataStore.ResourceControl().Create(resourceControl); 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 = getUniqueElements(concatenatedTeamNames)
}
if labelsObject[resourceLabelForPortainerUserResourceControl] != nil {
concatenatedUserNames := labelsObject[resourceLabelForPortainerUserResourceControl].(string)
userNames = getUniqueElements(concatenatedUserNames)
}
if len(teamNames) == 0 && len(userNames) == 0 {
return nil, nil
}
teamIDs := make([]portainer.TeamID, 0)
userIDs := make([]portainer.UserID, 0)
for _, name := range teamNames {
team, err := transport.dataStore.Team().TeamByName(name)
if err != nil {
log.Warn().
Str("name", name).
Str("resource_id", resourceID).
Msg("unknown team name in access control label, ignoring access control rule for this team")
continue
}
teamIDs = append(teamIDs, team.ID)
}
for _, name := range userNames {
user, err := transport.dataStore.User().UserByUsername(name)
if err != nil {
log.Warn().
Str("name", name).
Str("resource_id", resourceID).
Msg("unknown user name in access control label, ignoring access control rule for this user")
continue
}
userIDs = append(userIDs, user.ID)
}
resourceControl := authorization.NewRestrictedResourceControl(resourceID, resourceType, userIDs, teamIDs)
if err := transport.dataStore.ResourceControl().Create(resourceControl); err != nil {
return nil, err
}
return resourceControl, nil
}
func (transport *Transport) createPrivateResourceControl(resourceIdentifier string, resourceType portainer.ResourceControlType, userID portainer.UserID) (*portainer.ResourceControl, error) {
resourceControl := authorization.NewPrivateResourceControl(resourceIdentifier, resourceType, userID)
if err := transport.dataStore.ResourceControl().Create(resourceControl); err != nil {
log.Error().
Str("resource", resourceIdentifier).
Err(err).
Msg("unable to persist resource control")
return nil, err
}
return resourceControl, nil
}
func (transport *Transport) getInheritedResourceControlFromServiceOrStack(client *client.Client, resourceIdentifier string, resourceType portainer.ResourceControlType, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) {
switch resourceType {
case portainer.ContainerResourceControl:
return getInheritedResourceControlFromContainerLabels(client, transport.endpoint.ID, resourceIdentifier, resourceControls)
case portainer.NetworkResourceControl:
return getInheritedResourceControlFromNetworkLabels(client, transport.endpoint.ID, resourceIdentifier, resourceControls)
case portainer.VolumeResourceControl:
return getInheritedResourceControlFromVolumeLabels(client, transport.endpoint.ID, resourceIdentifier, resourceControls)
case portainer.ServiceResourceControl:
return getInheritedResourceControlFromServiceLabels(client, transport.endpoint.ID, resourceIdentifier, resourceControls)
case portainer.ConfigResourceControl:
return getInheritedResourceControlFromConfigLabels(client, transport.endpoint.ID, resourceIdentifier, resourceControls)
case portainer.SecretResourceControl:
return getInheritedResourceControlFromSecretLabels(client, transport.endpoint.ID, resourceIdentifier, resourceControls)
}
return nil, nil
}
func (transport *Transport) applyAccessControlOnResource(parameters *resourceOperationParameters, responseObject map[string]any, response *http.Response, executor *operationExecutor) error {
if responseObject[parameters.resourceIdentifierAttribute] == nil {
log.Warn().
Str("identifier_attribute", parameters.resourceIdentifierAttribute).
Msg("unable to find resource identifier property in resource object")
return nil
}
if parameters.resourceType == portainer.NetworkResourceControl {
systemResourceControl := findSystemNetworkResourceControl(responseObject)
if systemResourceControl != nil {
responseObject = decorateObject(responseObject, systemResourceControl)
return utils.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) {
return utils.RewriteResponse(response, responseObject, http.StatusOK)
}
if executor.operationContext.isAdmin || (resourceControl != nil && authorization.UserCanAccessResource(executor.operationContext.userID, executor.operationContext.userTeamIDs, resourceControl)) {
responseObject = decorateObject(responseObject, resourceControl)
return utils.RewriteResponse(response, responseObject, http.StatusOK)
}
return utils.RewriteAccessDeniedResponse(response)
}
func (transport *Transport) applyAccessControlOnResourceList(parameters *resourceOperationParameters, resourceData []any, executor *operationExecutor) ([]any, error) {
if executor.operationContext.isAdmin {
return transport.decorateResourceList(parameters, resourceData, executor.operationContext.resourceControls)
}
return transport.filterResourceList(parameters, resourceData, executor.operationContext)
}
func (transport *Transport) decorateResourceList(parameters *resourceOperationParameters, resourceData []any, resourceControls []portainer.ResourceControl) ([]any, error) {
decoratedResourceData := make([]any, 0)
for _, resource := range resourceData {
resourceObject := resource.(map[string]any)
if resourceObject[parameters.resourceIdentifierAttribute] == nil {
log.Warn().
Str("identifier_attribute", parameters.resourceIdentifierAttribute).
Msg("unable to find resource identifier property in resource list element")
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 []any, context *restrictedDockerOperationContext) ([]any, error) {
filteredResourceData := make([]any, 0)
for _, resource := range resourceData {
resourceObject := resource.(map[string]any)
if resourceObject[parameters.resourceIdentifierAttribute] == nil {
log.Warn().
Str("identifier_attribute", parameters.resourceIdentifierAttribute).
Msg("unable to find resource identifier property in resource list element")
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 {
filteredResourceData = append(filteredResourceData, resourceObject)
}
continue
}
if context.isAdmin || authorization.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]any, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) {
resourceControl := authorization.GetResourceControlByResourceIDAndType(resourceIdentifier, resourceType, resourceControls)
if resourceControl != nil {
return resourceControl, nil
}
if resourceLabelsObject == nil {
return nil, nil
}
if resourceLabelsObject[consts.SwarmServiceIDLabel] != nil {
inheritedServiceIdentifier := resourceLabelsObject[consts.SwarmServiceIDLabel].(string)
resourceControl = authorization.GetResourceControlByResourceIDAndType(inheritedServiceIdentifier, portainer.ServiceResourceControl, resourceControls)
if resourceControl != nil {
return resourceControl, nil
}
}
if resourceLabelsObject[consts.SwarmStackNameLabel] != nil {
stackName := resourceLabelsObject[consts.SwarmStackNameLabel].(string)
stackResourceID := stackutils.ResourceControlID(transport.endpoint.ID, stackName)
resourceControl = authorization.GetResourceControlByResourceIDAndType(stackResourceID, portainer.StackResourceControl, resourceControls)
if resourceControl != nil {
return resourceControl, nil
}
}
if resourceLabelsObject[consts.ComposeStackNameLabel] != nil {
stackName := resourceLabelsObject[consts.ComposeStackNameLabel].(string)
stackResourceID := stackutils.ResourceControlID(transport.endpoint.ID, stackName)
resourceControl = authorization.GetResourceControlByResourceIDAndType(stackResourceID, portainer.StackResourceControl, resourceControls)
if resourceControl != nil {
return resourceControl, nil
}
}
return transport.newResourceControlFromPortainerLabels(resourceLabelsObject, resourceIdentifier, resourceType)
}
func getStackResourceIDFromLabels(resourceLabelsObject map[string]string, endpointID portainer.EndpointID) string {
if resourceLabelsObject[consts.SwarmStackNameLabel] != "" {
stackName := resourceLabelsObject[consts.SwarmStackNameLabel]
return stackutils.ResourceControlID(endpointID, stackName)
}
if resourceLabelsObject[consts.ComposeStackNameLabel] != "" {
stackName := resourceLabelsObject[consts.ComposeStackNameLabel]
return stackutils.ResourceControlID(endpointID, stackName)
}
return ""
}
func decorateObject(object map[string]any, resourceControl *portainer.ResourceControl) map[string]any {
if object["Portainer"] == nil {
object["Portainer"] = make(map[string]any)
}
portainerMetadata := object["Portainer"].(map[string]any)
portainerMetadata["ResourceControl"] = resourceControl
return object
}
@@ -0,0 +1,64 @@
package docker
import (
"testing"
"github.com/stretchr/testify/assert"
)
func Test_getUniqueElements(t *testing.T) {
t.Parallel()
cases := []struct {
description string
input string
expected []string
}{
{
description: "no items padded",
input: " ",
expected: []string{},
},
{
description: "single item",
input: "a",
expected: []string{"a"},
},
{
description: "single item padded",
input: " a ",
expected: []string{"a"},
},
{
description: "multiple items",
input: "a,b",
expected: []string{"a", "b"},
},
{
description: "multiple items padded",
input: " a , b ",
expected: []string{"a", "b"},
},
{
description: "multiple items with empty values",
input: " a , ,b ",
expected: []string{"a", "b"},
},
{
description: "duplicates",
input: " a , a ",
expected: []string{"a"},
},
{
description: "mix with duplicates",
input: " a ,b, a ",
expected: []string{"a", "b"},
},
}
for _, tt := range cases {
t.Run(tt.description, func(t *testing.T) {
result := getUniqueElements(tt.input)
assert.ElementsMatch(t, result, tt.expected)
})
}
}
+129
View File
@@ -0,0 +1,129 @@
package docker
import (
"bytes"
"errors"
"io"
"mime"
"net/http"
"github.com/portainer/portainer/api/archive"
"github.com/portainer/portainer/api/logs"
"github.com/rs/zerolog/log"
"github.com/segmentio/encoding/json"
)
const OneMegabyte = 1024768
type postDockerfileRequest struct {
Content string
}
var ErrUploadedFilesNotFound = errors.New("uploaded files not found to build image")
// 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.
// !! THIS IS ONLY TRUE WHEN THE UPLOADED DOCKERFILE FILE HAS NO EXTENSION (the generated file.type in the frontend will be empty)
// If the Dockerfile is named like Dockerfile.yaml or has an internal type, a non-empty Content-Type header will be generated
//
// 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")
mediaType := ""
if contentTypeHeader != "" {
var err error
mediaType, _, err = mime.ParseMediaType(contentTypeHeader)
if err != nil {
return err
}
}
var buffer []byte
switch mediaType {
case "":
body, err := io.ReadAll(request.Body)
if err != nil {
return err
}
buffer, err = archive.TarFileInBuffer(body, "Dockerfile", 0600)
if err != nil {
return err
}
case "application/json":
var req postDockerfileRequest
err := json.NewDecoder(request.Body).Decode(&req)
if err != nil {
return err
}
buffer, err = archive.TarFileInBuffer([]byte(req.Content), "Dockerfile", 0600)
if err != nil {
return err
}
case "multipart/form-data":
if err := request.ParseMultipartForm(32 * OneMegabyte); err != nil {
return err
}
if request.MultipartForm == nil || request.MultipartForm.File == nil {
return ErrUploadedFilesNotFound
}
tfb := archive.NewTarFileInBuffer()
defer func() {
if err := tfb.Close(); err != nil {
log.Warn().Err(err).Msg("failed to close tar buffer")
}
}()
for k := range request.MultipartForm.File {
f, hdr, err := request.FormFile(k)
if err != nil {
return err
}
defer logs.CloseAndLogErr(f)
log.Info().Str("filename", hdr.Filename).Int64("size", hdr.Size).Msg("upload the file to build image")
content, err := io.ReadAll(f)
if err != nil {
return err
}
filename := hdr.Filename
if hdr.Filename == "blob" {
filename = "Dockerfile"
}
if err := tfb.Put(content, filename, 0600); err != nil {
return err
}
}
buffer = tfb.Bytes()
request.Form = nil
request.PostForm = nil
request.MultipartForm = nil
default:
return nil
}
request.Body = io.NopCloser(bytes.NewReader(buffer))
request.ContentLength = int64(len(buffer))
request.Header.Set("Content-Type", "application/x-tar")
return nil
}
+84
View File
@@ -0,0 +1,84 @@
package docker
import (
"context"
"net/http"
"github.com/docker/docker/client"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/utils"
"github.com/portainer/portainer/api/internal/authorization"
)
const configObjectIdentifier = "ID"
func getInheritedResourceControlFromConfigLabels(dockerClient *client.Client, endpointID portainer.EndpointID, configID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) {
config, _, err := dockerClient.ConfigInspectWithRaw(context.Background(), configID)
if err != nil {
return nil, err
}
stackResourceID := getStackResourceIDFromLabels(config.Spec.Labels, endpointID)
if stackResourceID != "" {
return authorization.GetResourceControlByResourceIDAndType(stackResourceID, 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 := utils.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 utils.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 := utils.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.37/#operation/ConfigList
// https://docs.docker.com/engine/api/v1.37/#operation/ConfigInspect
func selectorConfigLabels(responseObject map[string]any) map[string]any {
if secretSpec := utils.GetJSONObject(responseObject, "Spec"); secretSpec != nil {
return utils.GetJSONObject(secretSpec, "Labels")
}
return nil
}
+315
View File
@@ -0,0 +1,315 @@
package docker
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/docker/consts"
"github.com/portainer/portainer/api/http/proxy/factory/utils"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/docker/docker/client"
"github.com/segmentio/encoding/json"
)
const containerObjectIdentifier = "Id"
var (
ErrPrivilegedModeForbidden = errors.New("forbidden to use privileged mode")
ErrPIDHostNamespaceForbidden = errors.New("forbidden to use pid host namespace")
ErrDeviceMappingForbidden = errors.New("forbidden to use device mapping")
ErrSysCtlSettingsForbidden = errors.New("forbidden to use sysctl settings")
ErrSecurityOptSettingsForbidden = errors.New("forbidden to use security-opt settings")
ErrContainerCapabilitiesForbidden = errors.New("forbidden to use container capabilities")
ErrBindMountsForbidden = errors.New("forbidden to use bind mounts")
)
func getInheritedResourceControlFromContainerLabels(dockerClient *client.Client, endpointID portainer.EndpointID, containerID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) {
container, err := dockerClient.ContainerInspect(context.Background(), containerID)
if err != nil {
return nil, err
}
serviceName := container.Config.Labels[consts.SwarmServiceIDLabel]
if serviceName != "" {
serviceResourceControl := authorization.GetResourceControlByResourceIDAndType(serviceName, portainer.ServiceResourceControl, resourceControls)
if serviceResourceControl != nil {
return serviceResourceControl, nil
}
}
stackResourceID := getStackResourceIDFromLabels(container.Config.Labels, endpointID)
if stackResourceID != "" {
return authorization.GetResourceControlByResourceIDAndType(stackResourceID, 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 := utils.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
}
}
responseArray, err = transport.applyPortainerContainers(responseArray)
if err != nil {
return err
}
return utils.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 := utils.GetResponseAsJSONObject(response)
if err != nil {
return err
}
resourceOperationParameters := &resourceOperationParameters{
resourceIdentifierAttribute: containerObjectIdentifier,
resourceType: portainer.ContainerResourceControl,
labelsObjectSelector: selectorContainerLabelsFromContainerInspectOperation,
}
responseObject, _ = transport.applyPortainerContainer(responseObject)
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]any) map[string]any {
containerConfigObject := utils.GetJSONObject(responseObject, "Config")
if containerConfigObject != nil {
containerLabelsObject := utils.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]any) map[string]any {
containerLabelsObject := utils.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 []any, labelBlackList []portainer.Pair) ([]any, error) {
filteredContainerData := make([]any, 0)
for _, container := range containerData {
containerObject := container.(map[string]any)
containerLabels := selectorContainerLabelsFromContainerListOperation(containerObject)
if containerHasBlackListedLabel(containerLabels, labelBlackList) {
continue
}
filteredContainerData = append(filteredContainerData, containerObject)
}
return filteredContainerData, nil
}
func containerHasBlackListedLabel(containerLabels map[string]any, 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
}
func (transport *Transport) decorateContainerCreationOperation(request *http.Request, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType) (*http.Response, error) {
type PartialContainer struct {
HostConfig struct {
Privileged bool `json:"Privileged"`
PidMode string `json:"PidMode"`
Devices []any `json:"Devices"`
Sysctls map[string]any `json:"Sysctls"`
SecurityOpt []string `json:"SecurityOpt"`
CapAdd []string `json:"CapAdd"`
CapDrop []string `json:"CapDrop"`
Binds []string `json:"Binds"`
Mounts []struct {
Type string `json:"Type"`
} `json:"Mounts"`
} `json:"HostConfig"`
}
forbiddenResponse := &http.Response{
StatusCode: http.StatusForbidden,
Body: http.NoBody,
}
tokenData, err := security.RetrieveTokenData(request)
if err != nil {
return nil, err
}
isAdminOrEndpointAdmin, err := transport.isAdminOrEndpointAdmin(request)
if err != nil {
return nil, err
}
if !isAdminOrEndpointAdmin {
securitySettings, err := transport.fetchEndpointSecuritySettings()
if err != nil {
return nil, err
}
body, err := io.ReadAll(request.Body)
if err != nil {
return nil, err
}
partialContainer := &PartialContainer{}
if err := json.Unmarshal(body, partialContainer); err != nil {
return nil, err
}
if !securitySettings.AllowPrivilegedModeForRegularUsers && partialContainer.HostConfig.Privileged {
return forbiddenResponse, ErrPrivilegedModeForbidden
}
if !securitySettings.AllowHostNamespaceForRegularUsers && partialContainer.HostConfig.PidMode == "host" {
return forbiddenResponse, ErrPIDHostNamespaceForbidden
}
if !securitySettings.AllowDeviceMappingForRegularUsers && len(partialContainer.HostConfig.Devices) > 0 {
return forbiddenResponse, ErrDeviceMappingForbidden
}
if !securitySettings.AllowSysctlSettingForRegularUsers && len(partialContainer.HostConfig.Sysctls) > 0 {
return forbiddenResponse, ErrSysCtlSettingsForbidden
}
if !securitySettings.AllowSecurityOptForRegularUsers && len(partialContainer.HostConfig.SecurityOpt) > 0 {
return forbiddenResponse, ErrSecurityOptSettingsForbidden
}
if !securitySettings.AllowContainerCapabilitiesForRegularUsers && (len(partialContainer.HostConfig.CapAdd) > 0 || len(partialContainer.HostConfig.CapDrop) > 0) {
return nil, ErrContainerCapabilitiesForbidden
}
if !securitySettings.AllowBindMountsForRegularUsers && len(partialContainer.HostConfig.Binds) > 0 {
for _, bind := range partialContainer.HostConfig.Binds {
if strings.HasPrefix(bind, "/") {
return forbiddenResponse, ErrBindMountsForbidden
}
}
}
if !securitySettings.AllowBindMountsForRegularUsers && len(partialContainer.HostConfig.Mounts) > 0 {
for _, mount := range partialContainer.HostConfig.Mounts {
if mount.Type == "bind" {
return forbiddenResponse, ErrBindMountsForbidden
}
}
}
request.Body = io.NopCloser(bytes.NewBuffer(body))
}
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) decorateContainerUpdateOperation(request *http.Request, containerID string) (*http.Response, error) {
type PartialContainerUpdate struct {
Devices []any `json:"Devices"`
}
forbiddenResponse := &http.Response{
StatusCode: http.StatusForbidden,
}
isAdminOrEndpointAdmin, err := transport.isAdminOrEndpointAdmin(request)
if err != nil {
return nil, err
}
if isAdminOrEndpointAdmin {
return transport.restrictedResourceOperation(request, containerID, containerID, portainer.ContainerResourceControl, false)
}
securitySettings, err := transport.fetchEndpointSecuritySettings()
if err != nil {
return nil, err
}
body, err := io.ReadAll(request.Body)
if err != nil {
return nil, err
}
partialUpdate := &PartialContainerUpdate{}
if err := json.Unmarshal(body, partialUpdate); err != nil {
return nil, err
}
if !securitySettings.AllowDeviceMappingForRegularUsers && len(partialUpdate.Devices) > 0 {
return forbiddenResponse, ErrDeviceMappingForbidden
}
request.Body = io.NopCloser(bytes.NewBuffer(body))
return transport.restrictedResourceOperation(request, containerID, containerID, portainer.ContainerResourceControl, false)
}
@@ -0,0 +1,123 @@
package docker
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/security"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/require"
)
func TestDecorateContainerCreationOperation_BindMounts(t *testing.T) {
t.Parallel()
admin := portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}
regularUser := portainer.User{ID: 2, Username: "user", Role: portainer.StandardUserRole}
_, ds := datastore.MustNewTestStore(t, true, false)
err := ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
err := tx.User().Create(&admin)
require.NoError(t, err)
err = tx.User().Create(&regularUser)
require.NoError(t, err)
err = tx.Endpoint().Create(&portainer.Endpoint{
ID: 1,
Name: "test",
SecuritySettings: portainer.EndpointSecuritySettings{
AllowBindMountsForRegularUsers: false,
},
})
require.NoError(t, err)
return nil
})
require.NoError(t, err)
srv, version := mockDockerAPIServer(t, RoutesDefinition{
{http.MethodPost, "/containers/create"}: map[string]any{"Id": "abc123", "Warnings": []any{}},
})
defer srv.Close()
transport := &Transport{
endpoint: &portainer.Endpoint{ID: 1, URL: srv.URL},
dataStore: ds,
HTTPTransport: &http.Transport{},
}
adminToken := portainer.TokenData{ID: admin.ID, Username: admin.Username, Role: admin.Role}
userToken := portainer.TokenData{ID: regularUser.ID, Username: regularUser.Username, Role: regularUser.Role}
makeRequest := func(token portainer.TokenData, body any) *http.Request {
bodyBytes, err := json.Marshal(body)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, srv.URL+"/v"+version+"/containers/create", bytes.NewReader(bodyBytes))
req = req.WithContext(security.StoreTokenData(req, &token))
return req
}
// Admin bypasses security checks
req := makeRequest(adminToken, map[string]any{
"HostConfig": map[string]any{
"Mounts": []map[string]any{{"Type": "bind", "Source": "/", "Target": "/host"}},
},
})
resp, err := transport.decorateContainerCreationOperation(req, containerObjectIdentifier, portainer.ContainerResourceControl)
require.NoError(t, err)
require.NotNil(t, resp)
err = resp.Body.Close()
require.NoError(t, err)
// HostConfig.Binds with an absolute path is blocked for regular users
req = makeRequest(userToken, map[string]any{
"HostConfig": map[string]any{
"Binds": []string{"/:/host:ro"},
},
})
resp, err = transport.decorateContainerCreationOperation(req, containerObjectIdentifier, portainer.ContainerResourceControl)
require.ErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
// HostConfig.Mounts with type bind is blocked for regular users
req = makeRequest(userToken, map[string]any{
"HostConfig": map[string]any{
"Mounts": []map[string]any{{"Type": "bind", "Source": "/", "Target": "/host"}},
},
})
resp, err = transport.decorateContainerCreationOperation(req, containerObjectIdentifier, portainer.ContainerResourceControl)
require.ErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
// HostConfig.Mounts with a non-bind type is allowed for regular users
req = makeRequest(userToken, map[string]any{
"HostConfig": map[string]any{
"Mounts": []map[string]any{{"Type": "volume", "Source": "myvolume", "Target": "/data"}},
},
})
resp, err = transport.decorateContainerCreationOperation(req, containerObjectIdentifier, portainer.ContainerResourceControl)
require.NoError(t, err)
require.NotNil(t, resp)
err = resp.Body.Close()
require.NoError(t, err)
}
+103
View File
@@ -0,0 +1,103 @@
package docker
import (
"context"
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/client"
"github.com/portainer/portainer/api/http/proxy/factory/utils"
"github.com/portainer/portainer/api/internal/authorization"
)
const (
networkObjectIdentifier = "Id"
networkObjectName = "Name"
)
func getInheritedResourceControlFromNetworkLabels(dockerClient *client.Client, endpointID portainer.EndpointID, networkID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) {
network, err := dockerClient.NetworkInspect(context.Background(), networkID, network.InspectOptions{})
if err != nil {
return nil, err
}
stackResourceID := getStackResourceIDFromLabels(network.Labels, endpointID)
if stackResourceID != "" {
return authorization.GetResourceControlByResourceIDAndType(stackResourceID, 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 := utils.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 utils.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 := utils.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]any) *portainer.ResourceControl {
if networkObject[networkObjectName] == nil {
return nil
}
networkID := networkObject[networkObjectIdentifier].(string)
networkName := networkObject[networkObjectName].(string)
if networkName == "bridge" || networkName == "host" || networkName == "ingress" || networkName == "nat" || networkName == "none" {
return authorization.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]any) map[string]any {
return utils.GetJSONObject(responseObject, "Labels")
}
@@ -0,0 +1,47 @@
package docker
import (
"os"
)
var portainerContainerId string
func init() {
// use hostname as the current portainer id
// Reference issue: JIRA EE-917
// https://social.msdn.microsoft.com/Forums/en-US/5e5bff27-7511-4fb2-9ffa-207520d0ffb8/how-to-gain-windows-container-id-in-windows-container?forum=windowscontainers
// Because Windows container cannot obtain container ID from /proc/self/cgroups like linux container,
// as a workaround, we currently use hostname as container ID.
portainerContainerId, _ = os.Hostname()
}
func (transport *Transport) applyPortainerContainers(resources []any) ([]any, error) {
decoratedResourceData := make([]any, 0)
for _, resource := range resources {
responseObject, ok := resource.(map[string]any)
if !ok {
decoratedResourceData = append(decoratedResourceData, resource)
continue
}
responseObject, _ = transport.applyPortainerContainer(responseObject)
decoratedResourceData = append(decoratedResourceData, responseObject)
}
return decoratedResourceData, nil
}
func (transport *Transport) applyPortainerContainer(resourceObject map[string]any) (map[string]any, error) {
resourceId, ok := resourceObject["Id"].(string)
if !ok {
return resourceObject, nil
}
if len(resourceId) >= 12 && resourceId[0:12] == portainerContainerId {
resourceObject["IsPortainer"] = true
}
return resourceObject, nil
}
+67
View File
@@ -0,0 +1,67 @@
package docker
import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/registryutils"
)
type (
registryAccessContext struct {
isAdmin bool
user *portainer.User
endpointID portainer.EndpointID
teamMemberships []portainer.TeamMembership
registries []portainer.Registry
}
registryAuthenticationHeader struct {
Username string `json:"username"`
Password string `json:"password"`
Serveraddress string `json:"serveraddress"`
}
portainerRegistryAuthenticationHeader struct {
RegistryId *portainer.RegistryID `json:"registryId"`
}
)
func createRegistryAuthenticationHeader(
dataStore dataservices.DataStore,
registryID portainer.RegistryID,
accessContext *registryAccessContext,
) (authenticationHeader registryAuthenticationHeader, err error) {
if registryID == 0 { // dockerhub (anonymous)
authenticationHeader.Serveraddress = "docker.io"
return
}
// Any "custom" registry
var matchingRegistry *portainer.Registry
for _, registry := range accessContext.registries {
if registry.ID == registryID &&
(accessContext.isAdmin ||
security.AuthorizedRegistryAccess(&registry, accessContext.user, accessContext.teamMemberships, accessContext.endpointID)) {
matchingRegistry = &registry
break
}
}
if matchingRegistry == nil {
return
}
if err = registryutils.PrepareRegistryCredentials(dataStore, matchingRegistry); err != nil {
return
}
authenticationHeader.Serveraddress = matchingRegistry.URL
authenticationHeader.Username = matchingRegistry.Username
authenticationHeader.Password = matchingRegistry.Password
return
}
+87
View File
@@ -0,0 +1,87 @@
package docker
import (
"context"
"net/http"
"github.com/docker/docker/client"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/utils"
"github.com/portainer/portainer/api/internal/authorization"
)
const (
secretObjectIdentifier = "ID"
)
func getInheritedResourceControlFromSecretLabels(dockerClient *client.Client, endpointID portainer.EndpointID, secretID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) {
secret, _, err := dockerClient.SecretInspectWithRaw(context.Background(), secretID)
if err != nil {
return nil, err
}
stackResourceID := getStackResourceIDFromLabels(secret.Spec.Labels, endpointID)
if stackResourceID != "" {
return authorization.GetResourceControlByResourceIDAndType(stackResourceID, 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 := utils.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 utils.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 := utils.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.37/#operation/SecretList
// https://docs.docker.com/engine/api/v1.37/#operation/SecretInspect
func selectorSecretLabels(responseObject map[string]any) map[string]any {
secretSpec := utils.GetJSONObject(responseObject, "Spec")
if secretSpec != nil {
secretLabelsObject := utils.GetJSONObject(secretSpec, "Labels")
return secretLabelsObject
}
return nil
}
+218
View File
@@ -0,0 +1,218 @@
package docker
import (
"bytes"
"context"
"io"
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/utils"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/logs"
"github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/client"
"github.com/segmentio/encoding/json"
)
const serviceObjectIdentifier = "ID"
type partialServiceSpec struct {
TaskTemplate struct {
ContainerSpec struct {
CapabilityAdd []string `json:"CapabilityAdd"`
CapabilityDrop []string `json:"CapabilityDrop"`
Sysctls map[string]any `json:"Sysctls"`
Privileges *struct {
Seccomp *struct{ Mode string } `json:"Seccomp"`
AppArmor *struct{ Mode string } `json:"AppArmor"`
} `json:"Privileges"`
Mounts []struct {
Type string `json:"Type"`
VolumeOptions *struct {
DriverConfig *struct {
Options map[string]string `json:"Options"`
} `json:"DriverConfig"`
} `json:"VolumeOptions"`
} `json:"Mounts"`
} `json:"ContainerSpec"`
} `json:"TaskTemplate"`
}
func CheckServiceBodyRestrictions(request *http.Request, securitySettings *portainer.EndpointSecuritySettings) error {
defer logs.CloseAndLogErr(request.Body)
body, err := io.ReadAll(request.Body)
if err != nil {
return err
}
spec := &partialServiceSpec{}
if err := json.Unmarshal(body, spec); err != nil {
return err
}
containerSpec := spec.TaskTemplate.ContainerSpec
if !securitySettings.AllowContainerCapabilitiesForRegularUsers && (len(containerSpec.CapabilityAdd) > 0 || len(containerSpec.CapabilityDrop) > 0) {
return ErrContainerCapabilitiesForbidden
}
if !securitySettings.AllowSysctlSettingForRegularUsers && len(containerSpec.Sysctls) > 0 {
return ErrSysCtlSettingsForbidden
}
if !securitySettings.AllowSecurityOptForRegularUsers && containerSpec.Privileges != nil {
if containerSpec.Privileges.Seccomp != nil || containerSpec.Privileges.AppArmor != nil {
return ErrSecurityOptSettingsForbidden
}
}
if !securitySettings.AllowBindMountsForRegularUsers {
for _, mount := range containerSpec.Mounts {
if mount.Type == "bind" {
return ErrBindMountsForbidden
}
if mount.VolumeOptions != nil && mount.VolumeOptions.DriverConfig != nil {
if mount.VolumeOptions.DriverConfig.Options["type"] == "bind" {
return ErrBindMountsForbidden
}
}
}
}
request.Body = io.NopCloser(bytes.NewBuffer(body))
return nil
}
func getInheritedResourceControlFromServiceLabels(dockerClient *client.Client, endpointID portainer.EndpointID, serviceID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) {
service, _, err := dockerClient.ServiceInspectWithRaw(context.Background(), serviceID, swarm.ServiceInspectOptions{})
if err != nil {
return nil, err
}
stackResourceID := getStackResourceIDFromLabels(service.Spec.Labels, endpointID)
if stackResourceID != "" {
return authorization.GetResourceControlByResourceIDAndType(stackResourceID, 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 := utils.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 utils.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 := utils.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]any) map[string]any {
serviceSpecObject := utils.GetJSONObject(responseObject, "Spec")
if serviceSpecObject != nil {
return utils.GetJSONObject(serviceSpecObject, "Labels")
}
return nil
}
func (transport *Transport) decorateServiceCreationOperation(request *http.Request) (*http.Response, error) {
isAdminOrEndpointAdmin, err := transport.isAdminOrEndpointAdmin(request)
if err != nil {
return nil, err
}
if isAdminOrEndpointAdmin {
return transport.replaceRegistryAuthenticationHeader(request)
}
securitySettings, err := transport.fetchEndpointSecuritySettings()
if err != nil {
return nil, err
}
if err := CheckServiceBodyRestrictions(request, securitySettings); err != nil {
return &http.Response{
StatusCode: http.StatusForbidden,
Body: io.NopCloser(bytes.NewBufferString("Access denied: insufficient permissions to create service with specified configuration")),
}, err
}
return transport.replaceRegistryAuthenticationHeader(request)
}
func (transport *Transport) decorateServiceUpdateOperation(request *http.Request, serviceID string) (*http.Response, error) {
isAdminOrEndpointAdmin, err := transport.isAdminOrEndpointAdmin(request)
if err != nil {
return nil, err
}
if isAdminOrEndpointAdmin {
if err := transport.decorateRegistryAuthenticationHeader(request); err != nil {
return nil, err
}
return transport.executeDockerRequest(request)
}
securitySettings, err := transport.fetchEndpointSecuritySettings()
if err != nil {
return nil, err
}
if err := CheckServiceBodyRestrictions(request, securitySettings); err != nil {
return &http.Response{
StatusCode: http.StatusForbidden,
Body: io.NopCloser(bytes.NewBufferString("Access denied: insufficient permissions to update service with specified configuration")),
}, err
}
if err := transport.decorateRegistryAuthenticationHeader(request); err != nil {
return nil, err
}
return transport.restrictedResourceOperation(request, serviceID, serviceID, portainer.ServiceResourceControl, false)
}
@@ -0,0 +1,644 @@
package docker
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/security"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/swarm"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/require"
)
const serviceCreationAPIVersion = "1.51"
type serviceCreationFixtures struct {
dockerSrv *httptest.Server
ds dataservices.DataStore
stdUser portainer.User
adminUser portainer.User
endpointID portainer.EndpointID
}
func newServiceCreationFixtures(t *testing.T) *serviceCreationFixtures {
t.Helper()
const serviceID = "some-service-id"
dockerSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead && r.URL.Path == "/_ping" {
w.Header().Add("Api-Version", serviceCreationAPIVersion)
_, _ = w.Write([]byte{})
return
}
if r.Method == http.MethodPost {
data, err := json.Marshal(map[string]string{"ID": serviceID})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write(data)
return
}
http.NotFound(w, r)
}))
t.Cleanup(dockerSrv.Close)
_, store := datastore.MustNewTestStore(t, true, false)
f := &serviceCreationFixtures{
dockerSrv: dockerSrv,
ds: store,
stdUser: portainer.User{ID: 1, Username: "std", Role: portainer.StandardUserRole},
adminUser: portainer.User{ID: 2, Username: "admin", Role: portainer.AdministratorRole},
endpointID: portainer.EndpointID(1),
}
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
err := tx.User().Create(&f.stdUser)
require.NoError(t, err)
err = tx.User().Create(&f.adminUser)
require.NoError(t, err)
err = tx.Endpoint().Create(&portainer.Endpoint{ID: f.endpointID, Name: "test-env"})
require.NoError(t, err)
return nil
})
require.NoError(t, err)
return f
}
func (f *serviceCreationFixtures) setSecuritySettings(t *testing.T, settings portainer.EndpointSecuritySettings) {
t.Helper()
err := f.ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.Endpoint().UpdateEndpoint(f.endpointID, &portainer.Endpoint{
ID: f.endpointID,
Name: "test-env",
SecuritySettings: settings,
})
})
require.NoError(t, err)
}
func (f *serviceCreationFixtures) newTransport() *Transport {
return &Transport{
endpoint: &portainer.Endpoint{ID: f.endpointID},
dataStore: f.ds,
HTTPTransport: &http.Transport{},
}
}
func (f *serviceCreationFixtures) newRequest(t *testing.T, spec swarm.ServiceSpec, user portainer.User) *http.Request {
t.Helper()
data, err := json.Marshal(spec)
require.NoError(t, err)
req, err := http.NewRequestWithContext(
t.Context(),
http.MethodPost,
f.dockerSrv.URL+"/v"+serviceCreationAPIVersion+"/services/create",
bytes.NewReader(data),
)
require.NoError(t, err)
return req.WithContext(security.StoreTokenData(req, &portainer.TokenData{
ID: user.ID,
Username: user.Username,
Role: user.Role,
}))
}
var (
restrictiveSettings = portainer.EndpointSecuritySettings{
AllowContainerCapabilitiesForRegularUsers: false,
AllowSysctlSettingForRegularUsers: false,
AllowSecurityOptForRegularUsers: false,
AllowBindMountsForRegularUsers: false,
}
permissiveSettings = portainer.EndpointSecuritySettings{
AllowContainerCapabilitiesForRegularUsers: true,
AllowSysctlSettingForRegularUsers: true,
AllowSecurityOptForRegularUsers: true,
AllowBindMountsForRegularUsers: true,
}
)
func TestDecorateServiceCreationOperation_CapabilityAddForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
CapabilityAdd: []string{"NET_ADMIN"},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.ErrorIs(t, err, ErrContainerCapabilitiesForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_CapabilityDropForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
CapabilityDrop: []string{"MKNOD"},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.ErrorIs(t, err, ErrContainerCapabilitiesForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_CapabilitiesAllowed(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, permissiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
CapabilityAdd: []string{"NET_ADMIN"},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NoError(t, err)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_NoCapabilitiesAllowed(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
var spec swarm.ServiceSpec
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NotErrorIs(t, err, ErrContainerCapabilitiesForbidden)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_SysctlForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Sysctls: map[string]string{"net.ipv4.ip_forward": "1"},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.ErrorIs(t, err, ErrSysCtlSettingsForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_SysctlAllowed(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, permissiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Sysctls: map[string]string{"net.ipv4.ip_forward": "1"},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NoError(t, err)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_SeccompForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Privileges: &swarm.Privileges{
Seccomp: &swarm.SeccompOpts{Mode: swarm.SeccompModeCustom},
},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.ErrorIs(t, err, ErrSecurityOptSettingsForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_AppArmorForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Privileges: &swarm.Privileges{
AppArmor: &swarm.AppArmorOpts{Mode: swarm.AppArmorModeDefault},
},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.ErrorIs(t, err, ErrSecurityOptSettingsForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_NilPrivilegesNotForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
var spec swarm.ServiceSpec
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NotErrorIs(t, err, ErrSecurityOptSettingsForbidden)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_PrivilegesWithNilSeccompAndAppArmorNotForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Privileges: &swarm.Privileges{},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NotErrorIs(t, err, ErrSecurityOptSettingsForbidden)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_PrivilegesAllowed(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, permissiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Privileges: &swarm.Privileges{
Seccomp: &swarm.SeccompOpts{Mode: swarm.SeccompModeCustom},
},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NoError(t, err)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_BindMountForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Mounts: []mount.Mount{{Type: mount.TypeBind}},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.ErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_NonBindMountNotForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, portainer.EndpointSecuritySettings{
AllowContainerCapabilitiesForRegularUsers: true,
AllowSysctlSettingForRegularUsers: true,
AllowSecurityOptForRegularUsers: true,
AllowBindMountsForRegularUsers: false,
})
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Mounts: []mount.Mount{{Type: mount.TypeVolume}},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NotErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_BindMountAllowed(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, permissiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Mounts: []mount.Mount{{Type: mount.TypeBind}},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NoError(t, err)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_AdminBypassesAllSecurityChecks(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
CapabilityAdd: []string{"NET_ADMIN"},
CapabilityDrop: []string{"MKNOD"},
Sysctls: map[string]string{"net.ipv4.ip_forward": "1"},
Privileges: &swarm.Privileges{
Seccomp: &swarm.SeccompOpts{Mode: swarm.SeccompModeCustom},
},
Mounts: []mount.Mount{{Type: mount.TypeBind}},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.adminUser))
require.NotErrorIs(t, err, ErrContainerCapabilitiesForbidden)
require.NotErrorIs(t, err, ErrSysCtlSettingsForbidden)
require.NotErrorIs(t, err, ErrSecurityOptSettingsForbidden)
require.NotErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_StandardUserPermissiveSettingsSucceeds(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, permissiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
CapabilityAdd: []string{"NET_ADMIN"},
Sysctls: map[string]string{"net.core.somaxconn": "128"},
Mounts: []mount.Mount{{Type: mount.TypeBind}},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NoError(t, err)
require.NotNil(t, resp)
require.Equal(t, http.StatusCreated, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_VolumeWithBindDriverOptionForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Mounts: []mount.Mount{{
Type: mount.TypeVolume,
VolumeOptions: &mount.VolumeOptions{
DriverConfig: &mount.Driver{
Options: map[string]string{"type": "bind", "device": "/etc"},
},
},
}},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.ErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_VolumeWithBindDriverOptionAllowed(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, permissiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Mounts: []mount.Mount{{
Type: mount.TypeVolume,
VolumeOptions: &mount.VolumeOptions{
DriverConfig: &mount.Driver{
Options: map[string]string{"type": "bind", "device": "/etc"},
},
},
}},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NoError(t, err)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_VolumeWithNonBindDriverOptionNotForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Mounts: []mount.Mount{{
Type: mount.TypeVolume,
VolumeOptions: &mount.VolumeOptions{
DriverConfig: &mount.Driver{
Options: map[string]string{"type": "tmpfs"},
},
},
}},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NotErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceUpdateOperation_VolumeWithBindDriverOptionForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Mounts: []mount.Mount{{
Type: mount.TypeVolume,
VolumeOptions: &mount.VolumeOptions{
DriverConfig: &mount.Driver{
Options: map[string]string{"type": "bind", "device": "/etc"},
},
},
}},
},
},
}
resp, err := f.newTransport().decorateServiceUpdateOperation(f.newRequest(t, spec, f.stdUser), "test-service-id")
require.ErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
+25
View File
@@ -0,0 +1,25 @@
package docker
import (
"net/http"
"github.com/portainer/portainer/api/http/proxy/factory/utils"
)
// 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 := utils.GetResponseAsJSONObject(response)
if err != nil {
return err
}
if !executor.operationContext.isAdmin {
delete(responseObject, "JoinTokens")
delete(responseObject, "TLSInfo")
}
return utils.RewriteResponse(response, responseObject, http.StatusOK)
}
+49
View File
@@ -0,0 +1,49 @@
package docker
import (
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/utils"
)
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 := utils.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 utils.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]any) map[string]any {
taskSpecObject := utils.GetJSONObject(responseObject, "Spec")
if taskSpecObject != nil {
containerSpecObject := utils.GetJSONObject(taskSpecObject, "ContainerSpec")
if containerSpecObject != nil {
return utils.GetJSONObject(containerSpecObject, "Labels")
}
}
return nil
}
+979
View File
@@ -0,0 +1,979 @@
package docker
import (
"bytes"
"context"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"path"
"regexp"
"strconv"
"strings"
"sync"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/http/proxy/factory/utils"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/logs"
"github.com/portainer/portainer/api/slicesx"
"github.com/portainer/portainer/pkg/libhttp/ssrf"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/swarm"
dockerclient "github.com/docker/docker/client"
"github.com/rs/zerolog/log"
"github.com/segmentio/encoding/json"
)
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
dataStore dataservices.DataStore
signatureService portainer.DigitalSignatureService
reverseTunnelService portainer.ReverseTunnelService
dockerClientFactory *client.ClientFactory
gitService portainer.GitService
snapshotService portainer.SnapshotService
dockerID string
mu sync.Mutex
}
// TransportParameters is used to create a new Transport
TransportParameters struct {
Endpoint *portainer.Endpoint
DataStore dataservices.DataStore
SignatureService portainer.DigitalSignatureService
ReverseTunnelService portainer.ReverseTunnelService
DockerClientFactory *client.ClientFactory
}
restrictedDockerOperationContext struct {
isAdmin 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, gitService portainer.GitService, snapshotService portainer.SnapshotService) (*Transport, error) {
transport := &Transport{
endpoint: parameters.Endpoint,
dataStore: parameters.DataStore,
signatureService: parameters.SignatureService,
reverseTunnelService: parameters.ReverseTunnelService,
dockerClientFactory: parameters.DockerClientFactory,
HTTPTransport: httpTransport,
gitService: gitService,
snapshotService: snapshotService,
}
return transport, nil
}
// RoundTrip is the implementation of the http.RoundTripper interface
func (transport *Transport) RoundTrip(request *http.Request) (*http.Response, error) {
return transport.ProxyDockerRequest(request)
}
var prefixProxyFuncMap = map[string]func(*Transport, *http.Request, string) (*http.Response, error){
"build": (*Transport).proxyBuildRequest,
"configs": (*Transport).proxyConfigRequest,
"containers": (*Transport).proxyContainerRequest,
"exec": (*Transport).proxyExecRequest,
"images": (*Transport).proxyImageRequest,
"networks": (*Transport).proxyNetworkRequest,
"nodes": (*Transport).proxyNodeRequest,
"secrets": (*Transport).proxySecretRequest,
"services": (*Transport).proxyServiceRequest,
"swarm": (*Transport).proxySwarmRequest,
"tasks": (*Transport).proxyTaskRequest,
"v2": (*Transport).proxyAgentRequest,
"volumes": (*Transport).proxyVolumeRequest,
}
type route struct {
method string
pattern *regexp.Regexp
}
var adminOnlyRoutes = []route{
{http.MethodPost, regexp.MustCompile(`^/plugins/.+/enable$`)},
{http.MethodPost, regexp.MustCompile(`^/plugins/.+/disable$`)},
{http.MethodPost, regexp.MustCompile(`^/plugins/pull$`)},
{http.MethodPost, regexp.MustCompile(`^/plugins/.+/push$`)},
{http.MethodPost, regexp.MustCompile(`^/plugins/.+/upgrade$`)},
{http.MethodPost, regexp.MustCompile(`^/plugins/.+/set$`)},
{http.MethodPost, regexp.MustCompile(`^/plugins/create$`)},
{http.MethodDelete, regexp.MustCompile(`^/plugins/.+$`)},
}
func isAdminOnlyRoute(method string, path string) bool {
return slicesx.Some(adminOnlyRoutes, func(r route) bool {
return method == r.method && r.pattern.MatchString(path)
})
}
// ProxyDockerRequest intercepts a Docker API request and apply logic based
// on the requested operation.
func (transport *Transport) ProxyDockerRequest(request *http.Request) (*http.Response, error) {
// from : /v1.47/containers/{id}/json
// or : /containers/{id}/json
// to : /containers/{id}/json
unversionedPath := apiVersionRe.ReplaceAllString(request.URL.Path, "")
if transport.endpoint.Type == portainer.AgentOnDockerEnvironment || transport.endpoint.Type == portainer.EdgeAgentOnDockerEnvironment {
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)
}
// from : /containers/{id}/json
// trim to : containers/{id}/json
// pick : [ containers, {id}, json ][0]
// prefix : containers
prefix := strings.Split(strings.TrimPrefix(unversionedPath, "/"), "/")[0]
if proxyFunc := prefixProxyFuncMap[prefix]; proxyFunc != nil {
return proxyFunc(transport, request, unversionedPath)
}
if isAdminOnlyRoute(request.Method, unversionedPath) {
return transport.administratorOperation(request)
}
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.EdgeAgentOnDockerEnvironment {
return response, err
}
if err == nil {
transport.reverseTunnelService.UpdateLastActivity(transport.endpoint.ID)
}
return response, err
}
func (transport *Transport) proxyAgentRequest(r *http.Request, unversionedPath string) (*http.Response, error) {
requestPath := strings.TrimPrefix(unversionedPath, "/v2")
switch {
case strings.HasPrefix(requestPath, "/browse"):
// Host file browser request
volumeIDParameter, found := r.URL.Query()["volumeID"]
if !found || len(volumeIDParameter) < 1 {
return transport.administratorOperation(r)
}
volumeName := volumeIDParameter[0]
resourceID, err := transport.getVolumeResourceID(volumeName)
if err != nil {
return nil, err
}
// Volume browser request
return transport.restrictedResourceOperation(r, resourceID, volumeName, portainer.VolumeResourceControl, true)
case strings.HasPrefix(requestPath, "/dockerhub"):
requestPath, registryIdString := path.Split(r.URL.Path)
registryID, err := strconv.Atoi(registryIdString)
if err != nil {
return nil, fmt.Errorf("missing registry id: %w", err)
}
r.URL.Path = strings.TrimSuffix(requestPath, "/")
registry := &portainer.Registry{
Type: portainer.DockerHubRegistry,
}
if registryID != 0 {
registry, err = transport.dataStore.Registry().Read(portainer.RegistryID(registryID))
if err != nil {
return nil, fmt.Errorf("failed fetching registry: %w", err)
}
}
if registry.Type != portainer.DockerHubRegistry {
return nil, errors.New("Invalid registry type")
}
newBody, err := json.Marshal(registry)
if err != nil {
return nil, err
}
r.Method = http.MethodPost
r.Body = io.NopCloser(bytes.NewReader(newBody))
r.ContentLength = int64(len(newBody))
}
return transport.executeDockerRequest(r)
}
func (transport *Transport) proxyConfigRequest(request *http.Request, unversionedPath string) (*http.Response, error) {
requestPath := unversionedPath
switch 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)
switch request.Method {
case http.MethodGet:
return transport.rewriteOperation(request, transport.configInspectOperation)
case http.MethodDelete:
return transport.executeGenericResourceDeletionOperation(request, configID, configID, portainer.ConfigResourceControl)
}
return transport.restrictedResourceOperation(request, configID, configID, portainer.ConfigResourceControl, false)
}
}
func (transport *Transport) proxyContainerRequest(request *http.Request, unversionedPath string) (*http.Response, error) {
requestPath := unversionedPath
switch requestPath {
case "/containers/create":
return transport.decorateContainerCreationOperation(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)
}
if action == "update" {
return transport.decorateContainerUpdateOperation(request, containerID)
}
return transport.restrictedResourceOperation(request, containerID, 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, containerID, portainer.ContainerResourceControl)
}
return transport.restrictedResourceOperation(request, containerID, containerID, portainer.ContainerResourceControl, false)
} else if match, _ := path.Match("/containers/*/attach/ws", requestPath); match {
containerID := path.Base(path.Dir(path.Dir(requestPath)))
return transport.restrictedResourceOperation(request, containerID, containerID, portainer.ContainerResourceControl, false)
}
return transport.executeDockerRequest(request)
}
}
func (transport *Transport) proxyExecRequest(request *http.Request, unversionedPath string) (*http.Response, error) {
execID := path.Base(path.Dir(unversionedPath))
client, err := transport.dockerClientFactory.CreateClient(transport.endpoint, request.Header.Get(portainer.PortainerAgentTargetHeader), nil)
if err != nil {
return nil, err
}
defer logs.CloseAndLogErr(client)
execInspect, err := client.ContainerExecInspect(request.Context(), execID)
if err != nil {
return nil, err
}
return transport.restrictedResourceOperation(request, execInspect.ContainerID, execInspect.ContainerID, portainer.ContainerResourceControl, false)
}
func (transport *Transport) proxyServiceRequest(request *http.Request, unversionedPath string) (*http.Response, error) {
requestPath := unversionedPath
switch requestPath {
case "/services/create":
return transport.decorateServiceCreationOperation(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))
action := path.Base(requestPath)
if action == "update" {
return transport.decorateServiceUpdateOperation(request, serviceID)
}
if err := transport.decorateRegistryAuthenticationHeader(request); err != nil {
return nil, err
}
return transport.restrictedResourceOperation(request, serviceID, 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, serviceID, portainer.ServiceResourceControl)
}
return transport.restrictedResourceOperation(request, serviceID, serviceID, portainer.ServiceResourceControl, false)
}
return transport.executeDockerRequest(request)
}
}
func (transport *Transport) proxyVolumeRequest(request *http.Request, unversionedPath string) (*http.Response, error) {
requestPath := unversionedPath
switch requestPath {
case "/volumes/create":
return transport.decorateVolumeResourceCreationOperation(request, portainer.VolumeResourceControl)
case "/volumes/prune":
return transport.administratorOperation(request)
case "/volumes":
return transport.rewriteOperation(request, transport.volumeListOperation)
default:
// Assume /volumes/{name}
return transport.restrictedVolumeOperation(requestPath, request)
}
}
func match(requestPath string, pattern string) bool {
ok, err := path.Match(pattern, requestPath)
return err == nil && ok
}
func (transport *Transport) proxyNetworkRequest(request *http.Request, unversionedPath string) (*http.Response, error) {
requestPath := unversionedPath
switch {
case requestPath == "/networks/create":
return transport.decorateGenericResourceCreationOperation(request, networkObjectIdentifier, portainer.NetworkResourceControl)
case requestPath == "/networks":
return transport.rewriteOperation(request, transport.networkListOperation)
case request.Method == http.MethodPost && match(requestPath, "/networks/*/connect"),
request.Method == http.MethodPost && match(requestPath, "/networks/*/disconnect"):
networkID := path.Base(path.Dir(requestPath))
return transport.restrictedResourceOperation(request, networkID, networkID, portainer.NetworkResourceControl, false)
case request.Method == http.MethodGet && match(requestPath, "/networks/*"):
return transport.rewriteOperation(request, transport.networkInspectOperation)
case request.Method == http.MethodDelete && match(requestPath, "/networks/*"):
networkID := path.Base(requestPath)
return transport.executeGenericResourceDeletionOperation(request, networkID, networkID, portainer.NetworkResourceControl)
}
// Assume /networks/{id}
networkID := path.Base(requestPath)
return transport.restrictedResourceOperation(request, networkID, networkID, portainer.NetworkResourceControl, false)
}
func (transport *Transport) proxySecretRequest(request *http.Request, unversionedPath string) (*http.Response, error) {
requestPath := unversionedPath
switch 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)
switch request.Method {
case http.MethodGet:
return transport.rewriteOperation(request, transport.secretInspectOperation)
case http.MethodDelete:
return transport.executeGenericResourceDeletionOperation(request, secretID, secretID, portainer.SecretResourceControl)
}
return transport.restrictedResourceOperation(request, secretID, secretID, portainer.SecretResourceControl, false)
}
}
func (transport *Transport) proxyNodeRequest(request *http.Request, unversionedPath string) (*http.Response, error) {
requestPath := unversionedPath
// Assume /nodes/{id}
if path.Base(requestPath) != "nodes" {
return transport.administratorOperation(request)
}
return transport.executeDockerRequest(request)
}
func (transport *Transport) proxySwarmRequest(request *http.Request, unversionedPath string) (*http.Response, error) {
requestPath := unversionedPath
switch requestPath {
case "/swarm":
return transport.rewriteOperation(request, swarmInspectOperation)
default:
// Assume /swarm/{action}
return transport.administratorOperation(request)
}
}
func (transport *Transport) proxyTaskRequest(request *http.Request, unversionedPath string) (*http.Response, error) {
requestPath := unversionedPath
switch requestPath {
case "/tasks":
return transport.rewriteOperation(request, transport.taskListOperation)
default:
// Assume /tasks/{id}
return transport.executeDockerRequest(request)
}
}
func (transport *Transport) proxyBuildRequest(request *http.Request, unversionedPath string) (*http.Response, error) {
if unversionedPath == "/build/prune" {
return transport.administratorOperation(request)
}
if err := transport.updateDefaultGitBranch(request); err != nil {
return nil, err
}
return transport.interceptAndRewriteRequest(request, buildOperation)
}
func (transport *Transport) updateDefaultGitBranch(request *http.Request) error {
remote := request.URL.Query().Get("remote")
if !strings.HasSuffix(remote, ".git") {
return nil
}
repositoryURL := remote[:len(remote)-4]
if err := ssrf.CheckURL(request.Context(), repositoryURL); err != nil {
return err
}
latestCommitID, err := transport.gitService.LatestCommitID(
request.Context(),
repositoryURL,
"",
"",
"",
false,
)
if err != nil {
return err
}
newRemote := fmt.Sprintf("%s#%s", remote, latestCommitID)
q := request.URL.Query()
q.Set("remote", newRemote)
request.URL.RawQuery = q.Encode()
return nil
}
func (transport *Transport) proxyImageRequest(request *http.Request, unversionedPath string) (*http.Response, error) {
requestPath := unversionedPath
switch requestPath {
case "/images/create":
return transport.replaceRegistryAuthenticationHeader(request)
case "/images/prune":
return transport.administratorOperation(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) {
if err := transport.decorateRegistryAuthenticationHeader(request); err != nil {
return nil, err
}
return transport.decorateGenericResourceCreationOperation(request, serviceObjectIdentifier, portainer.ServiceResourceControl)
}
func (transport *Transport) decorateRegistryAuthenticationHeader(request *http.Request) error {
accessContext, err := transport.createRegistryAccessContext(request)
if err != nil {
return err
}
originalHeader := request.Header.Get("X-Registry-Auth")
if originalHeader == "" {
return nil
}
decodedHeaderData, err := base64.StdEncoding.DecodeString(originalHeader)
if err != nil {
return err
}
var originalHeaderData portainerRegistryAuthenticationHeader
if err := json.Unmarshal(decodedHeaderData, &originalHeaderData); err != nil {
return err
}
// Delete header and exist function without error if Front End
// passes empty json. This is to restore original behavior which
// never originally passed this header
if string(decodedHeaderData) == "{}" {
request.Header.Del("X-Registry-Auth")
return nil
}
// Only set X-Registry-Auth if registryId is defined
if originalHeaderData.RegistryId == nil {
return nil
}
authenticationHeader, err := createRegistryAuthenticationHeader(transport.dataStore, *originalHeaderData.RegistryId, accessContext)
if err != nil {
return err
}
headerData, err := json.Marshal(authenticationHeader)
if err != nil {
return err
}
request.Header.Set("X-Registry-Auth", base64.URLEncoding.EncodeToString(headerData))
return nil
}
func (transport *Transport) restrictedResourceOperation(request *http.Request, resourceID string, dockerResourceID string, resourceType portainer.ResourceControlType, volumeBrowseRestrictionCheck bool) (*http.Response, error) {
tokenData, err := security.RetrieveTokenData(request)
if err != nil {
return nil, err
}
if tokenData.Role == portainer.AdministratorRole {
return transport.executeDockerRequest(request)
}
if volumeBrowseRestrictionCheck {
securitySettings, err := transport.fetchEndpointSecuritySettings()
if err != nil {
return nil, err
}
if !securitySettings.AllowVolumeBrowserForRegularUsers {
return utils.WriteAccessDeniedResponse()
}
}
teamMemberships, err := transport.dataStore.TeamMembership().TeamMembershipsByUserID(tokenData.ID)
if err != nil {
return nil, err
}
userTeamIDs := authorization.TeamIDs(teamMemberships)
resourceControls, err := transport.dataStore.ResourceControl().ReadAll()
if err != nil {
return nil, err
}
resourceControl := authorization.GetResourceControlByResourceIDAndType(resourceID, resourceType, resourceControls)
if resourceControl != nil {
if !authorization.UserCanAccessResource(tokenData.ID, userTeamIDs, resourceControl) {
return utils.WriteAccessDeniedResponse()
}
return transport.executeDockerRequest(request)
}
client, err := transport.dockerClientFactory.CreateClient(transport.endpoint, request.Header.Get(portainer.PortainerAgentTargetHeader), nil)
if err != nil {
return nil, err
}
defer logs.CloseAndLogErr(client)
// the resourceID may be the resource name (as it's a valid proxy call to use the name and not the UUID)
// so get the real resource ID and retry with it
resourceID, err = getDockerResourceUUID(client, resourceType, resourceID)
if err != nil {
return nil, err
}
resourceControl = authorization.GetResourceControlByResourceIDAndType(resourceID, resourceType, resourceControls)
if resourceControl != nil {
if !authorization.UserCanAccessResource(tokenData.ID, userTeamIDs, resourceControl) {
return utils.WriteAccessDeniedResponse()
}
return transport.executeDockerRequest(request)
}
// If we still can't find the RC by provided ID or "real" (docker-extracted) ID
// it means this resource was created outside of portainer,
// is part of a Docker service or part of a Docker Swarm/Compose stack.
if dockerResourceID == "" {
dockerResourceID = resourceID
}
inheritedResourceControl, err := transport.getInheritedResourceControlFromServiceOrStack(client, dockerResourceID, resourceType, resourceControls)
if err != nil {
return nil, err
}
if inheritedResourceControl == nil || !authorization.UserCanAccessResource(tokenData.ID, userTeamIDs, inheritedResourceControl) {
return utils.WriteAccessDeniedResponse()
}
return transport.executeDockerRequest(request)
}
func getDockerResourceUUID(client *dockerclient.Client, resourceType portainer.ResourceControlType, resourceId string) (string, error) {
switch resourceType {
case portainer.NetworkResourceControl:
network, err := client.NetworkInspect(context.Background(), resourceId, network.InspectOptions{})
if err != nil {
return "", err
}
return network.ID, nil
case portainer.ContainerResourceControl:
container, err := client.ContainerInspect(context.Background(), resourceId)
if err != nil {
return "", err
}
return container.ID, nil
case portainer.VolumeResourceControl:
// volumes don't have an UUID and their UACresourceID has a particular construct that makes them unique
// e.g. fmt.Sprintf("%s_%s", volumeName, dockerID)
// see transport.getVolumeResourceID() / FetchDockerID()
// FetchDockerID fetches info.Swarm.Cluster.ID if environment(endpoint) is swarm and info.ID otherwise
// So: return empty ID but without error
return "", nil
case portainer.ServiceResourceControl:
service, _, err := client.ServiceInspectWithRaw(context.Background(), resourceId, swarm.ServiceInspectOptions{})
if err != nil {
return "", err
}
return service.ID, nil
case portainer.ConfigResourceControl:
config, _, err := client.ConfigInspectWithRaw(context.Background(), resourceId)
if err != nil {
return "", err
}
return config.ID, nil
case portainer.SecretResourceControl:
secret, _, err := client.SecretInspectWithRaw(context.Background(), resourceId)
if err != nil {
return "", err
}
return secret.ID, nil
}
return "", fmt.Errorf("Unknown resource type %v", resourceType)
}
// 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.dataStore.Settings().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) {
if err := operation(request); 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.37/#operation/ContainerCreate
// https://docs.docker.com/engine/api/v1.37/#operation/NetworkCreate
// https://docs.docker.com/engine/api/v1.37/#operation/VolumeCreate
// https://docs.docker.com/engine/api/v1.37/#operation/ServiceCreate
// https://docs.docker.com/engine/api/v1.37/#operation/SecretCreate
// https://docs.docker.com/engine/api/v1.37/#operation/ConfigCreate
func (transport *Transport) decorateGenericResourceCreationResponse(response *http.Response, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType, userID portainer.UserID) error {
responseObject, err := utils.GetResponseAsJSONObject(response)
if err != nil {
return err
}
if responseObject[resourceIdentifierAttribute] == nil {
log.Error().Msg("missing identifier in Docker resource creation response")
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 utils.RewriteResponse(response, responseObject, response.StatusCode)
}
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, volumeName string, resourceType portainer.ResourceControlType) (*http.Response, error) {
response, err := transport.restrictedResourceOperation(request, resourceIdentifierAttribute, volumeName, resourceType, false)
if err != nil {
return response, err
}
if response.StatusCode != http.StatusNoContent && response.StatusCode != http.StatusOK {
return response, nil
}
resourceControl, err := transport.dataStore.ResourceControl().ResourceControlByResourceIDAndType(resourceIdentifierAttribute, resourceType)
if dataservices.IsErrObjectNotFound(err) {
return response, nil
} else if err != nil {
return response, err
}
if resourceControl != nil {
err = transport.dataStore.ResourceControl().Delete(resourceControl.ID)
}
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
}
if response.StatusCode == http.StatusOK {
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 utils.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,
endpointID: transport.endpoint.ID,
}
user, err := transport.dataStore.User().Read(tokenData.ID)
if err != nil {
return nil, err
}
accessContext.user = user
registries, err := transport.dataStore.Registry().ReadAll()
if err != nil {
return nil, err
}
accessContext.registries = registries
if user.Role == portainer.AdministratorRole {
return accessContext, nil
}
accessContext.isAdmin = false
teamMemberships, err := transport.dataStore.TeamMembership().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.dataStore.ResourceControl().ReadAll()
if err != nil {
return nil, err
}
operationContext := &restrictedDockerOperationContext{
isAdmin: true,
userID: tokenData.ID,
resourceControls: resourceControls,
}
if tokenData.Role == portainer.AdministratorRole {
return operationContext, nil
}
operationContext.isAdmin = false
teamMemberships, err := transport.dataStore.TeamMembership().TeamMembershipsByUserID(tokenData.ID)
if err != nil {
return nil, err
}
operationContext.userTeamIDs = authorization.TeamIDs(teamMemberships)
return operationContext, nil
}
func (transport *Transport) isAdminOrEndpointAdmin(request *http.Request) (bool, error) {
tokenData, err := security.RetrieveTokenData(request)
if err != nil {
return false, err
}
return tokenData.Role == portainer.AdministratorRole, nil
}
func (transport *Transport) fetchEndpointSecuritySettings() (*portainer.EndpointSecuritySettings, error) {
endpoint, err := transport.dataStore.Endpoint().Endpoint(transport.endpoint.ID)
if err != nil {
return nil, err
}
return &endpoint.SecuritySettings, nil
}
@@ -0,0 +1,910 @@
package docker
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/swarm"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTransport_updateDefaultGitBranch(t *testing.T) {
t.Parallel()
type fields struct {
gitService portainer.GitService
}
type args struct {
request *http.Request
}
commitId := "my-latest-commit-id"
defaultFields := fields{
gitService: testhelpers.NewGitService(nil, commitId),
}
tests := []struct {
name string
fields fields
args args
wantErr bool
expectedQuery string
}{
{
name: "append commit ID",
fields: defaultFields,
args: args{
request: httptest.NewRequest(http.MethodPost, "http://unixsocket/build?dockerfile=Dockerfile&remote=https://my-host.com/my-user/my-repo.git&t=my-image", nil),
},
wantErr: false,
expectedQuery: fmt.Sprintf("dockerfile=Dockerfile&remote=https%%3A%%2F%%2Fmy-host.com%%2Fmy-user%%2Fmy-repo.git%%23%s&t=my-image", commitId),
},
{
name: "not append commit ID",
fields: defaultFields,
args: args{
request: httptest.NewRequest(http.MethodPost, "http://unixsocket/build?dockerfile=Dockerfile&remote=https://my-host.com/my-user/my-repo/my-file&t=my-image", nil),
},
wantErr: false,
expectedQuery: "dockerfile=Dockerfile&remote=https://my-host.com/my-user/my-repo/my-file&t=my-image",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
transport := &Transport{
gitService: tt.fields.gitService,
}
err := transport.updateDefaultGitBranch(tt.args.request)
if (err != nil) != tt.wantErr {
t.Errorf("updateDefaultGitBranch() error = %v, wantErr %v", err, tt.wantErr)
return
}
assert.Equal(t, tt.expectedQuery, tt.args.request.URL.RawQuery)
})
}
}
type RoutesDefinition map[[2]string]any
func mockDockerAPIServer(t *testing.T, routes RoutesDefinition) (*httptest.Server, string) {
version := "1.51"
v := func(path string) string {
return "/v" + version + path
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead && r.URL.Path == "/_ping" {
w.Header().Add("Api-Version", version)
_, _ = w.Write([]byte{})
return
}
for defs, rValue := range routes {
method, path := defs[0], defs[1]
if r.Method == method && r.URL.Path == v(path) {
_ = response.JSON(w, rValue)
return
}
}
http.NotFound(w, r)
}))
require.NotNil(t, srv)
return srv, version
}
func TestTransport_adminProxy(t *testing.T) {
t.Parallel()
admin := portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}
std1 := portainer.User{ID: 2, Username: "std1", Role: portainer.StandardUserRole}
std2 := portainer.User{ID: 3, Username: "std2", Role: portainer.StandardUserRole}
_, ds := datastore.MustNewTestStore(t, true, false)
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.User().Create(&admin))
require.NoError(t, tx.User().Create(&std1))
require.NoError(t, tx.User().Create(&std2))
require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{ID: 1, Name: "env",
UserAccessPolicies: portainer.UserAccessPolicies{std1.ID: portainer.AccessPolicy{RoleID: 1}},
}))
return nil
}))
srv, version := mockDockerAPIServer(t, RoutesDefinition{
// allowed routes
{http.MethodGet, "/plugins"}: nil,
{http.MethodGet, "/plugins/xxx/json"}: nil,
{http.MethodGet, "/plugins/privileges"}: nil,
// admin routes ; see `adminOnlyRoutes`
{http.MethodDelete, "/plugins/xxx"}: nil,
{http.MethodPost, "/plugins/sshfs/enable"}: nil, // simulate plugin "sshfs"
{http.MethodPost, "/plugins/vieux/sshfs/enable"}: nil, // simulate "vieux/sshfs"
{http.MethodPost, "/plugins/xxx/disable"}: nil,
{http.MethodPost, "/plugins/pull"}: nil,
{http.MethodPost, "/plugins/xxx/push"}: nil,
{http.MethodPost, "/plugins/xxx/upgrade"}: nil,
{http.MethodPost, "/plugins/xxx/set"}: nil,
{http.MethodPost, "/plugins/create"}: nil,
})
defer srv.Close()
transport := &Transport{
endpoint: &portainer.Endpoint{URL: srv.URL},
dataStore: ds,
HTTPTransport: &http.Transport{},
}
test := func(method string, url string, token portainer.TokenData) (*http.Response, error) {
req := httptest.NewRequest(method, srv.URL+"/v"+version+url, nil)
req = req.WithContext(security.StoreTokenData(req, &token))
require.NotNil(t, req)
return transport.ProxyDockerRequest(req)
}
adminToken := portainer.TokenData{ID: admin.ID, Username: admin.Username, Role: admin.Role}
std1Token := portainer.TokenData{ID: std1.ID, Username: std1.Username, Role: std1.Role}
std2Token := portainer.TokenData{ID: std2.ID, Username: std2.Username, Role: std2.Role}
{
r, err := test(http.MethodGet, "/plugins", adminToken)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodGet, "/plugins", std1Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodGet, "/plugins", std2Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/plugins/pull", adminToken)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/plugins/pull", std1Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusForbidden, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/plugins/pull", std2Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusForbidden, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/plugins/sshfs/enable", adminToken)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/plugins/sshfs/enable", std2Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusForbidden, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/plugins/vieux/sshfs/enable", adminToken)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/plugins/vieux/sshfs/enable", std2Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusForbidden, r.StatusCode)
require.NoError(t, r.Body.Close())
}
}
func TestTransport_getRealResourceID(t *testing.T) {
t.Parallel()
srv, _ := mockDockerAPIServer(t, RoutesDefinition{
{http.MethodGet, "/networks"}: []network.Summary{{ID: "16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4", Name: "mynetwork"}},
{http.MethodGet, "/networks/mynetwork"}: network.Inspect{ID: "16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4", Name: "mynetwork"},
{http.MethodGet, "/networks/16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4"}: network.Inspect{ID: "16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4", Name: "mynetwork"},
{http.MethodGet, "/containers/mycontainer/json"}: container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{ID: "545fc03ed1fd5008c3bfa2441209ff024e21e396acbeb58b2355930ad1295aa6", Name: "mycontainer"}},
{http.MethodGet, "/containers/545fc03ed1fd5008c3bfa2441209ff024e21e396acbeb58b2355930ad1295aa6/json"}: container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{ID: "545fc03ed1fd5008c3bfa2441209ff024e21e396acbeb58b2355930ad1295aa6", Name: "mycontainer"}},
{http.MethodGet, "/services/myservice"}: swarm.Service{ID: "ibt43uf5awhg06bxp8rkd7bhi", Spec: swarm.ServiceSpec{Annotations: swarm.Annotations{Name: "myservice"}}},
{http.MethodGet, "/services/ibt43uf5awhg06bxp8rkd7bhi"}: swarm.Service{ID: "ibt43uf5awhg06bxp8rkd7bhi", Spec: swarm.ServiceSpec{Annotations: swarm.Annotations{Name: "myservice"}}},
{http.MethodGet, "/configs/myconfig"}: swarm.Config{ID: "3mlqqza0k413ecebk0mfa11em", Spec: swarm.ConfigSpec{Annotations: swarm.Annotations{Name: "myconfig"}}},
{http.MethodGet, "/configs/3mlqqza0k413ecebk0mfa11em"}: swarm.Config{ID: "3mlqqza0k413ecebk0mfa11em", Spec: swarm.ConfigSpec{Annotations: swarm.Annotations{Name: "myconfig"}}},
{http.MethodGet, "/secrets/mysecret"}: swarm.Secret{ID: "v9i7o4ivg33u4z3jfyxto162d", Spec: swarm.SecretSpec{Annotations: swarm.Annotations{Name: "mysecret"}}},
{http.MethodGet, "/secrets/v9i7o4ivg33u4z3jfyxto162d"}: swarm.Secret{ID: "v9i7o4ivg33u4z3jfyxto162d", Spec: swarm.SecretSpec{Annotations: swarm.Annotations{Name: "mysecret"}}},
})
defer srv.Close()
transport := &Transport{
endpoint: &portainer.Endpoint{URL: srv.URL},
}
client, err := transport.dockerClientFactory.CreateClient(transport.endpoint, "", nil)
require.NoError(t, err)
require.NotNil(t, client)
test := func(rctype portainer.ResourceControlType, name string, id string, errOnUnknown bool) {
// by id
got, err := getDockerResourceUUID(client, rctype, id)
require.NoError(t, err)
require.Equal(t, id, got)
// by name
got, err = getDockerResourceUUID(client, rctype, name)
require.NoError(t, err)
require.Equal(t, id, got)
// unknown for this type
_, err = getDockerResourceUUID(client, rctype, "unknown")
if errOnUnknown {
require.Error(t, err)
} else {
require.NoError(t, err)
}
}
test(portainer.NetworkResourceControl, "mynetwork", "16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4", true)
test(portainer.ContainerResourceControl, "mycontainer", "545fc03ed1fd5008c3bfa2441209ff024e21e396acbeb58b2355930ad1295aa6", true)
test(portainer.VolumeResourceControl, "anything", "", false)
test(portainer.ServiceResourceControl, "myservice", "ibt43uf5awhg06bxp8rkd7bhi", true)
test(portainer.ConfigResourceControl, "myconfig", "3mlqqza0k413ecebk0mfa11em", true)
test(portainer.SecretResourceControl, "mysecret", "v9i7o4ivg33u4z3jfyxto162d", true)
// validate that other types are not supported
_, err = getDockerResourceUUID(client, portainer.ContainerGroupResourceControl, "")
require.Error(t, err)
}
func TestTransport_proxyNetworkRequest(t *testing.T) {
t.Parallel()
admin := portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}
std1 := portainer.User{ID: 2, Username: "std1", Role: portainer.StandardUserRole}
std2 := portainer.User{ID: 3, Username: "std2", Role: portainer.StandardUserRole}
_, ds := datastore.MustNewTestStore(t, true, false)
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.User().Create(&admin))
require.NoError(t, tx.User().Create(&std1))
require.NoError(t, tx.User().Create(&std2))
require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{ID: 1, Name: "env",
UserAccessPolicies: portainer.UserAccessPolicies{std1.ID: portainer.AccessPolicy{RoleID: 1}},
}))
require.NoError(t, tx.ResourceControl().Create(authorization.NewPrivateResourceControl("16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4", portainer.NetworkResourceControl, std1.ID)))
return nil
}))
srv, version := mockDockerAPIServer(t, RoutesDefinition{
{http.MethodGet, "/networks"}: []network.Summary{{ID: "16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4", Name: "mynetwork"}},
{http.MethodGet, "/networks/mynetwork"}: network.Inspect{ID: "16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4", Name: "mynetwork"},
{http.MethodGet, "/networks/16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4"}: network.Inspect{ID: "16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4", Name: "mynetwork"},
{http.MethodPost, "/networks/mynetwork/connect"}: struct{}{},
{http.MethodPost, "/networks/16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4/connect"}: struct{}{},
{http.MethodPost, "/networks/mynetwork/disconnect"}: struct{}{},
{http.MethodPost, "/networks/16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4/disconnect"}: struct{}{},
{http.MethodDelete, "/networks/mynetwork"}: struct{}{},
{http.MethodDelete, "/networks/16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4"}: struct{}{},
{http.MethodPost, "/networks/create"}: network.CreateResponse{ID: "16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4"},
{http.MethodPost, "/networks/prune"}: struct{}{},
})
defer srv.Close()
transport := &Transport{
endpoint: &portainer.Endpoint{URL: srv.URL},
dataStore: ds,
HTTPTransport: &http.Transport{},
}
test := func(method string, url string, token portainer.TokenData) (*http.Response, error) {
req := httptest.NewRequest(method, srv.URL+"/v"+version+url, nil)
req = req.WithContext(security.StoreTokenData(req, &token))
require.NotNil(t, req)
return transport.proxyNetworkRequest(req, url)
}
adminToken := portainer.TokenData{ID: admin.ID, Username: admin.Username, Role: admin.Role}
std1Token := portainer.TokenData{ID: std1.ID, Username: std1.Username, Role: std1.Role}
std2Token := portainer.TokenData{ID: std2.ID, Username: std2.Username, Role: std2.Role}
{
r, err := test(http.MethodGet, "/networks", adminToken)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
var resp []network.Summary
require.NoError(t, json.NewDecoder(r.Body).Decode(&resp))
require.Len(t, resp, 1)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodGet, "/networks", std1Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
var resp []network.Summary
require.NoError(t, json.NewDecoder(r.Body).Decode(&resp))
require.Len(t, resp, 1)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodGet, "/networks", std2Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
var resp []network.Summary
require.NoError(t, json.NewDecoder(r.Body).Decode(&resp))
require.Empty(t, resp)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodGet, "/networks/mynetwork", adminToken)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodGet, "/networks/mynetwork", std1Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodGet, "/networks/mynetwork", std2Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusForbidden, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodGet, "/networks/unknown", adminToken)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusNotFound, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/networks/mynetwork/connect", adminToken)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/networks/mynetwork/connect", std1Token)
require.NoError(t, err)
require.NotNil(t, r)
require.NoError(t, r.Body.Close())
require.Equal(t, http.StatusOK, r.StatusCode)
}
{
r, err := test(http.MethodPost, "/networks/mynetwork/connect", std2Token)
require.NoError(t, err)
require.NotNil(t, r)
require.NoError(t, r.Body.Close())
require.Equal(t, http.StatusForbidden, r.StatusCode)
}
{
r, err := test(http.MethodPost, "/networks/mynetwork/disconnect", adminToken)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/networks/mynetwork/disconnect", std1Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/networks/mynetwork/disconnect", std2Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusForbidden, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodDelete, "/networks/mynetwork", adminToken)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodDelete, "/networks/mynetwork", std1Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodDelete, "/networks/mynetwork", std2Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusForbidden, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/networks/create", adminToken)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/networks/create", std1Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/networks/create", std2Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/networks/prune", adminToken)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/networks/prune", std1Token)
require.Error(t, err)
require.Nil(t, r)
if r != nil {
require.NoError(t, r.Body.Close())
}
}
{
r, err := test(http.MethodPost, "/networks/prune", std2Token)
require.Error(t, err)
require.Nil(t, r)
if r != nil {
require.NoError(t, r.Body.Close())
}
}
}
func TestTransport_proxyExecRequest_accessControl(t *testing.T) {
t.Parallel()
admin := portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}
std1 := portainer.User{ID: 2, Username: "std1", Role: portainer.StandardUserRole}
std2 := portainer.User{ID: 3, Username: "std2", Role: portainer.StandardUserRole}
containerID := "1111"
execID := "2222"
_, ds := datastore.MustNewTestStore(t, true, false)
err := ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.User().Create(&admin))
require.NoError(t, tx.User().Create(&std1))
require.NoError(t, tx.User().Create(&std2))
require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{
ID: 1,
Name: "env",
UserAccessPolicies: portainer.UserAccessPolicies{
std1.ID: portainer.AccessPolicy{RoleID: 1},
std2.ID: portainer.AccessPolicy{RoleID: 1},
},
}))
// Only std1 owns the container, std2 has no resource control for it
require.NoError(t, tx.ResourceControl().Create(
authorization.NewPrivateResourceControl(containerID, portainer.ContainerResourceControl, std1.ID),
))
return nil
})
require.NoError(t, err)
srv, version := mockDockerAPIServer(t, RoutesDefinition{
{http.MethodGet, "/exec/" + execID + "/json"}: container.ExecInspect{ExecID: execID, ContainerID: containerID},
{http.MethodPost, "/exec/" + execID + "/start"}: struct{}{},
{http.MethodPost, "/exec/" + execID + "/resize"}: struct{}{},
})
defer srv.Close()
transport := &Transport{
endpoint: &portainer.Endpoint{URL: srv.URL},
dataStore: ds,
HTTPTransport: &http.Transport{},
}
test := func(method, url string, token portainer.TokenData) (*http.Response, error) {
req := httptest.NewRequest(method, srv.URL+"/v"+version+url, nil)
req = req.WithContext(security.StoreTokenData(req, &token))
return transport.ProxyDockerRequest(req)
}
adminToken := portainer.TokenData{ID: admin.ID, Username: admin.Username, Role: admin.Role}
std1Token := portainer.TokenData{ID: std1.ID, Username: std1.Username, Role: std1.Role}
std2Token := portainer.TokenData{ID: std2.ID, Username: std2.Username, Role: std2.Role}
// admin can exec into any container
r, err := test(http.MethodPost, "/exec/"+execID+"/start", adminToken)
require.NoError(t, err)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
// std1 owns the container, all exec operations allowed
r, err = test(http.MethodPost, "/exec/"+execID+"/start", std1Token)
require.NoError(t, err)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
r, err = test(http.MethodGet, "/exec/"+execID+"/json", std1Token)
require.NoError(t, err)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
r, err = test(http.MethodPost, "/exec/"+execID+"/resize", std1Token)
require.NoError(t, err)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
// std2 does NOT own the container, all exec operations must be blocked
r, err = test(http.MethodPost, "/exec/"+execID+"/start", std2Token)
require.NoError(t, err)
require.Equal(t, http.StatusForbidden, r.StatusCode)
require.NoError(t, r.Body.Close())
r, err = test(http.MethodGet, "/exec/"+execID+"/json", std2Token)
require.NoError(t, err)
require.Equal(t, http.StatusForbidden, r.StatusCode)
require.NoError(t, r.Body.Close())
r, err = test(http.MethodPost, "/exec/"+execID+"/resize", std2Token)
require.NoError(t, err)
require.Equal(t, http.StatusForbidden, r.StatusCode)
require.NoError(t, r.Body.Close())
}
func TestTransport_proxyExecRequest_createClientError(t *testing.T) {
t.Parallel()
// AzureEnvironment type causes CreateClient to return an error immediately
transport := &Transport{
endpoint: &portainer.Endpoint{Type: portainer.AzureEnvironment},
HTTPTransport: &http.Transport{},
}
req := httptest.NewRequest(http.MethodPost, "http://localhost/v1.51/exec/2222/start", nil)
resp, err := transport.ProxyDockerRequest(req)
require.Error(t, err)
require.Nil(t, resp)
if resp != nil {
require.NoError(t, resp.Body.Close())
}
}
func TestTransport_proxyExecRequest_inspectError(t *testing.T) {
t.Parallel()
// Mock server that handles /_ping (for Docker client negotiation) but no exec routes
srv, version := mockDockerAPIServer(t, RoutesDefinition{})
defer srv.Close()
transport := &Transport{
endpoint: &portainer.Endpoint{URL: srv.URL},
HTTPTransport: &http.Transport{},
}
req := httptest.NewRequest(http.MethodPost, srv.URL+"/v"+version+"/exec/2222/start", nil)
resp, err := transport.ProxyDockerRequest(req)
require.Error(t, err)
require.Nil(t, resp)
if resp != nil {
require.NoError(t, resp.Body.Close())
}
}
func TestTransport_proxyImageRequest_Prune(t *testing.T) {
t.Parallel()
admin := portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}
std1 := portainer.User{ID: 2, Username: "std1", Role: portainer.StandardUserRole}
_, ds := datastore.MustNewTestStore(t, true, false)
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.User().Create(&admin))
require.NoError(t, tx.User().Create(&std1))
require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{ID: 1, Name: "env",
UserAccessPolicies: portainer.UserAccessPolicies{std1.ID: portainer.AccessPolicy{RoleID: 1}},
}))
return nil
}))
srv, version := mockDockerAPIServer(t, RoutesDefinition{
{http.MethodPost, "/images/prune"}: struct {
ImagesDeleted []any `json:"ImagesDeleted"`
SpaceReclaimed int `json:"SpaceReclaimed"`
}{
ImagesDeleted: []any{},
SpaceReclaimed: 0,
},
})
defer srv.Close()
transport := &Transport{
endpoint: &portainer.Endpoint{URL: srv.URL},
dataStore: ds,
HTTPTransport: &http.Transport{},
}
test := func(method string, url string, token portainer.TokenData) (*http.Response, error) {
req := httptest.NewRequest(method, srv.URL+"/v"+version+url, nil)
req = req.WithContext(security.StoreTokenData(req, &token))
require.NotNil(t, req)
return transport.proxyImageRequest(req, url)
}
adminToken := portainer.TokenData{ID: admin.ID, Username: admin.Username, Role: admin.Role}
std1Token := portainer.TokenData{ID: std1.ID, Username: std1.Username, Role: std1.Role}
// Admin should be able to prune images
{
r, err := test(http.MethodPost, "/images/prune", adminToken)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
// Standard user should NOT be able to prune images (administrator operation)
{
r, err := test(http.MethodPost, "/images/prune", std1Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusForbidden, r.StatusCode)
require.NoError(t, r.Body.Close())
}
}
func TestTransport_proxyBuildRequest_Prune(t *testing.T) {
t.Parallel()
admin := portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}
std1 := portainer.User{ID: 2, Username: "std1", Role: portainer.StandardUserRole}
_, ds := datastore.MustNewTestStore(t, true, false)
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.User().Create(&admin))
require.NoError(t, tx.User().Create(&std1))
require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{ID: 1, Name: "env",
UserAccessPolicies: portainer.UserAccessPolicies{std1.ID: portainer.AccessPolicy{RoleID: 1}},
}))
return nil
}))
srv, version := mockDockerAPIServer(t, RoutesDefinition{
{http.MethodPost, "/build/prune"}: struct {
CachesDeleted []string `json:"CachesDeleted"`
SpaceReclaimed int `json:"SpaceReclaimed"`
}{
CachesDeleted: []string{},
SpaceReclaimed: 0,
},
})
defer srv.Close()
transport := &Transport{
endpoint: &portainer.Endpoint{URL: srv.URL},
dataStore: ds,
HTTPTransport: &http.Transport{},
}
test := func(method string, url string, token portainer.TokenData) (*http.Response, error) {
req := httptest.NewRequest(method, srv.URL+"/v"+version+url, nil)
req = req.WithContext(security.StoreTokenData(req, &token))
require.NotNil(t, req)
return transport.proxyBuildRequest(req, url)
}
adminToken := portainer.TokenData{ID: admin.ID, Username: admin.Username, Role: admin.Role}
std1Token := portainer.TokenData{ID: std1.ID, Username: std1.Username, Role: std1.Role}
// Admin should be able to prune build cache
{
r, err := test(http.MethodPost, "/build/prune", adminToken)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
// Standard user should NOT be able to prune build cache (administrator operation)
{
r, err := test(http.MethodPost, "/build/prune", std1Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusForbidden, r.StatusCode)
require.NoError(t, r.Body.Close())
}
}
func TestTransport_proxyContainerRequest(t *testing.T) {
t.Parallel()
const containerID = "1111"
admin := portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}
std1 := portainer.User{ID: 2, Username: "std1", Role: portainer.StandardUserRole}
std2 := portainer.User{ID: 3, Username: "std2", Role: portainer.StandardUserRole}
_, ds := datastore.MustNewTestStore(t, true, false)
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.User().Create(&admin))
require.NoError(t, tx.User().Create(&std1))
require.NoError(t, tx.User().Create(&std2))
require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{ID: 1, Name: "env",
UserAccessPolicies: portainer.UserAccessPolicies{std1.ID: portainer.AccessPolicy{RoleID: 1}},
}))
require.NoError(t, tx.ResourceControl().Create(authorization.NewPrivateResourceControl(containerID, portainer.ContainerResourceControl, std1.ID)))
return nil
}))
srv, version := mockDockerAPIServer(t, RoutesDefinition{
{http.MethodPost, "/containers/" + containerID + "/start"}: struct{}{},
{http.MethodGet, "/containers/" + containerID + "/attach/ws"}: struct{}{},
{http.MethodDelete, "/containers/" + containerID}: struct{}{},
{http.MethodPost, "/containers/prune"}: struct{}{},
})
defer srv.Close()
transport := &Transport{
endpoint: &portainer.Endpoint{ID: 1, URL: srv.URL},
dataStore: ds,
HTTPTransport: &http.Transport{},
}
test := func(method, url string, token portainer.TokenData) (*http.Response, error) {
req := httptest.NewRequest(method, srv.URL+"/v"+version+url, nil)
req = req.WithContext(security.StoreTokenData(req, &token))
return transport.proxyContainerRequest(req, url)
}
adminToken := portainer.TokenData{ID: admin.ID, Username: admin.Username, Role: admin.Role}
std1Token := portainer.TokenData{ID: std1.ID, Username: std1.Username, Role: std1.Role}
std2Token := portainer.TokenData{ID: std2.ID, Username: std2.Username, Role: std2.Role}
// /containers/{id}/start (2-segment): admin and owner allowed, non-owner denied
r, err := test(http.MethodPost, "/containers/"+containerID+"/start", adminToken)
require.NoError(t, err)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
r, err = test(http.MethodPost, "/containers/"+containerID+"/start", std1Token)
require.NoError(t, err)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
r, err = test(http.MethodPost, "/containers/"+containerID+"/start", std2Token)
require.NoError(t, err)
require.Equal(t, http.StatusForbidden, r.StatusCode)
require.NoError(t, r.Body.Close())
// /containers/{id}/attach/ws (3-segment): admin and owner allowed, non-owner denied
r, err = test(http.MethodGet, "/containers/"+containerID+"/attach/ws", adminToken)
require.NoError(t, err)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
r, err = test(http.MethodGet, "/containers/"+containerID+"/attach/ws", std1Token)
require.NoError(t, err)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
r, err = test(http.MethodGet, "/containers/"+containerID+"/attach/ws", std2Token)
require.NoError(t, err)
require.Equal(t, http.StatusForbidden, r.StatusCode)
require.NoError(t, r.Body.Close())
// DELETE /containers/{id}: non-owner denied, admin allowed
// std2 must be tested before admin: a successful delete removes the resource control from the datastore
r, err = test(http.MethodDelete, "/containers/"+containerID, std2Token)
require.NoError(t, err)
require.Equal(t, http.StatusForbidden, r.StatusCode)
require.NoError(t, r.Body.Close())
r, err = test(http.MethodDelete, "/containers/"+containerID, adminToken)
require.NoError(t, err)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
// /containers/prune: admin-only
r, err = test(http.MethodPost, "/containers/prune", adminToken)
require.NoError(t, err)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
r, err = test(http.MethodPost, "/containers/prune", std1Token)
require.NoError(t, err)
require.Equal(t, http.StatusForbidden, r.StatusCode)
require.NoError(t, r.Body.Close())
}
+305
View File
@@ -0,0 +1,305 @@
package docker
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"path"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/utils"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/snapshot"
"github.com/portainer/portainer/api/logs"
"github.com/docker/docker/client"
"github.com/segmentio/encoding/json"
)
const volumeObjectIdentifier = "ResourceID"
func getInheritedResourceControlFromVolumeLabels(dockerClient *client.Client, endpointID portainer.EndpointID, volumeID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) {
volume, err := dockerClient.VolumeInspect(context.Background(), volumeID)
if err != nil {
return nil, err
}
stackResourceID := getStackResourceIDFromLabels(volume.Labels, endpointID)
if stackResourceID != "" {
return authorization.GetResourceControlByResourceIDAndType(stackResourceID, 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 := utils.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 {
return utils.RewriteResponse(response, responseObject, http.StatusOK)
}
volumeData := responseObject["Volumes"].([]any)
for _, volumeObject := range volumeData {
volume := volumeObject.(map[string]any)
if err := transport.decorateVolumeResponseWithResourceID(volume); err != nil {
return fmt.Errorf("failed decorating volume response: %w", err)
}
}
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 utils.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 := utils.GetResponseAsJSONObject(response)
if err != nil {
return err
}
if err := transport.decorateVolumeResponseWithResourceID(responseObject); err != nil {
return fmt.Errorf("failed decorating volume response: %w", err)
}
resourceOperationParameters := &resourceOperationParameters{
resourceIdentifierAttribute: volumeObjectIdentifier,
resourceType: portainer.VolumeResourceControl,
labelsObjectSelector: selectorVolumeLabels,
}
return transport.applyAccessControlOnResource(resourceOperationParameters, responseObject, response, executor)
}
func (transport *Transport) decorateVolumeResponseWithResourceID(responseObject map[string]any) error {
if responseObject["Name"] == nil {
return errors.New("missing identifier in Docker resource detail response")
}
resourceID, err := transport.getVolumeResourceID(responseObject["Name"].(string))
if err != nil {
return fmt.Errorf("failed fetching resource id: %w", err)
}
responseObject[volumeObjectIdentifier] = resourceID
return nil
}
// 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]any) map[string]any {
return utils.GetJSONObject(responseObject, "Labels")
}
func CheckVolumeBodyRestrictions(request *http.Request) error {
defer logs.CloseAndLogErr(request.Body)
body, err := io.ReadAll(request.Body)
if err != nil {
return err
}
var volumeCreateBody struct {
DriverOpts map[string]string `json:"DriverOpts"`
}
if err := json.Unmarshal(body, &volumeCreateBody); err != nil {
return err
}
if volumeCreateBody.DriverOpts["type"] == "bind" {
return ErrBindMountsForbidden
}
request.Body = io.NopCloser(bytes.NewBuffer(body))
return nil
}
func (transport *Transport) decorateVolumeResourceCreationOperation(request *http.Request, resourceType portainer.ResourceControlType) (*http.Response, error) {
tokenData, err := security.RetrieveTokenData(request)
if err != nil {
return nil, err
}
isAdminOrEndpointAdmin, err := transport.isAdminOrEndpointAdmin(request)
if err != nil {
return nil, err
}
if !isAdminOrEndpointAdmin {
securitySettings, err := transport.fetchEndpointSecuritySettings()
if err != nil {
return nil, err
}
if !securitySettings.AllowBindMountsForRegularUsers {
if err := CheckVolumeBodyRestrictions(request); err != nil {
return &http.Response{
StatusCode: http.StatusForbidden,
Body: io.NopCloser(bytes.NewBufferString("Access denied: insufficient permissions to create volume with specified configuration")),
}, err
}
}
}
volumeID := request.Header.Get("X-Portainer-VolumeName")
if volumeID != "" {
agentTargetHeader := request.Header.Get(portainer.PortainerAgentTargetHeader)
cli, err := transport.dockerClientFactory.CreateClient(transport.endpoint, agentTargetHeader, nil)
if err != nil {
return nil, err
}
defer logs.CloseAndLogErr(cli)
if _, err := cli.VolumeInspect(context.Background(), volumeID); err == nil {
return &http.Response{
StatusCode: http.StatusConflict,
}, errors.New("a volume with the same name already exists")
}
}
response, err := transport.executeDockerRequest(request)
if err != nil {
return response, err
}
if response.StatusCode == http.StatusCreated {
err = transport.decorateVolumeCreationResponse(response, resourceType, tokenData.ID)
}
return response, err
}
func (transport *Transport) decorateVolumeCreationResponse(response *http.Response, resourceType portainer.ResourceControlType, userID portainer.UserID) error {
responseObject, err := utils.GetResponseAsJSONObject(response)
if err != nil {
return err
}
if responseObject["Name"] == nil {
return errors.New("missing identifier in Docker resource creation response")
}
resourceID, err := transport.getVolumeResourceID(responseObject["Name"].(string))
if err != nil {
return fmt.Errorf("failed fetching resource id: %w", err)
}
resourceControl, err := transport.createPrivateResourceControl(resourceID, resourceType, userID)
if err != nil {
return err
}
responseObject[volumeObjectIdentifier] = resourceID
responseObject = decorateObject(responseObject, resourceControl)
return utils.RewriteResponse(response, responseObject, http.StatusOK)
}
func (transport *Transport) restrictedVolumeOperation(requestPath string, request *http.Request) (*http.Response, error) {
if request.Method == http.MethodGet {
return transport.rewriteOperation(request, transport.volumeInspectOperation)
}
volumeName := path.Base(requestPath)
resourceID, err := transport.getVolumeResourceID(volumeName)
if err != nil {
return nil, err
}
if request.Method == http.MethodDelete {
return transport.executeGenericResourceDeletionOperation(request, resourceID, volumeName, portainer.VolumeResourceControl)
}
return transport.restrictedResourceOperation(request, resourceID, volumeName, portainer.VolumeResourceControl, false)
}
func (transport *Transport) getVolumeResourceID(volumeName string) (string, error) {
dockerID, err := transport.getDockerID()
if err != nil {
return "", fmt.Errorf("failed fetching docker id: %w", err)
}
return fmt.Sprintf("%s_%s", volumeName, dockerID), nil
}
func (transport *Transport) getDockerID() (string, error) {
transport.mu.Lock()
defer transport.mu.Unlock()
// Local cache
if transport.dockerID != "" {
return transport.dockerID, nil
}
// Snapshot cache
if transport.snapshotService != nil {
endpoint := portainer.Endpoint{ID: transport.endpoint.ID}
if err := transport.snapshotService.FillSnapshotData(&endpoint, true); err == nil && len(endpoint.Snapshots) > 0 {
if dockerID, err := snapshot.FetchDockerID(endpoint.Snapshots[0]); err == nil {
transport.dockerID = dockerID
return dockerID, nil
}
}
}
// Remote value
client, err := transport.dockerClientFactory.CreateClient(transport.endpoint, "", nil)
if err != nil {
return "", err
}
defer logs.CloseAndLogErr(client)
info, err := client.Info(context.Background())
if err != nil {
return "", err
}
if info.Swarm.Cluster != nil {
transport.dockerID = info.Swarm.Cluster.ID
return transport.dockerID, nil
}
transport.dockerID = info.ID
return transport.dockerID, nil
}
@@ -0,0 +1,226 @@
package docker
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/security"
"github.com/docker/docker/api/types/volume"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/require"
)
const volumeCreationAPIVersion = "1.51"
type volumeCreationFixtures struct {
dockerSrv *httptest.Server
ds dataservices.DataStore
stdUser portainer.User
adminUser portainer.User
endpointID portainer.EndpointID
}
func newVolumeCreationFixtures(t *testing.T) *volumeCreationFixtures {
t.Helper()
dockerSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead && r.URL.Path == "/_ping" {
w.Header().Add("Api-Version", volumeCreationAPIVersion)
_, _ = w.Write([]byte{})
return
}
if r.Method == http.MethodPost {
data, err := json.Marshal(map[string]string{"Name": "test-volume"})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write(data)
return
}
http.NotFound(w, r)
}))
t.Cleanup(dockerSrv.Close)
_, store := datastore.MustNewTestStore(t, true, false)
f := &volumeCreationFixtures{
dockerSrv: dockerSrv,
ds: store,
stdUser: portainer.User{ID: 1, Username: "std", Role: portainer.StandardUserRole},
adminUser: portainer.User{ID: 2, Username: "admin", Role: portainer.AdministratorRole},
endpointID: portainer.EndpointID(1),
}
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
err := tx.User().Create(&f.stdUser)
require.NoError(t, err)
err = tx.User().Create(&f.adminUser)
require.NoError(t, err)
err = tx.Endpoint().Create(&portainer.Endpoint{ID: f.endpointID, Name: "test-env"})
require.NoError(t, err)
return nil
})
require.NoError(t, err)
return f
}
func (f *volumeCreationFixtures) setSecuritySettings(t *testing.T, settings portainer.EndpointSecuritySettings) {
t.Helper()
err := f.ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.Endpoint().UpdateEndpoint(f.endpointID, &portainer.Endpoint{
ID: f.endpointID,
Name: "test-env",
SecuritySettings: settings,
})
})
require.NoError(t, err)
}
func (f *volumeCreationFixtures) newTransport() *Transport {
return &Transport{
endpoint: &portainer.Endpoint{ID: f.endpointID},
dataStore: f.ds,
HTTPTransport: &http.Transport{},
}
}
func (f *volumeCreationFixtures) newRequest(t *testing.T, body volume.CreateOptions, user portainer.User) *http.Request {
t.Helper()
data, err := json.Marshal(body)
require.NoError(t, err)
req, err := http.NewRequestWithContext(
t.Context(),
http.MethodPost,
f.dockerSrv.URL+"/v"+volumeCreationAPIVersion+"/volumes/create",
bytes.NewReader(data),
)
require.NoError(t, err)
return req.WithContext(security.StoreTokenData(req, &portainer.TokenData{
ID: user.ID,
Username: user.Username,
Role: user.Role,
}))
}
func TestDecorateVolumeResourceCreationOperation_BindDriverOptForbidden(t *testing.T) {
t.Parallel()
f := newVolumeCreationFixtures(t)
f.setSecuritySettings(t, portainer.EndpointSecuritySettings{
AllowBindMountsForRegularUsers: false,
})
body := volume.CreateOptions{
Name: "evil-volume",
Driver: "local",
DriverOpts: map[string]string{
"type": "bind",
"device": "/etc",
"o": "bind",
},
}
resp, err := f.newTransport().decorateVolumeResourceCreationOperation(f.newRequest(t, body, f.stdUser), portainer.VolumeResourceControl)
require.ErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateVolumeResourceCreationOperation_BindDriverOptAllowedForAdmin(t *testing.T) {
t.Parallel()
f := newVolumeCreationFixtures(t)
f.setSecuritySettings(t, portainer.EndpointSecuritySettings{
AllowBindMountsForRegularUsers: false,
})
body := volume.CreateOptions{
Name: "admin-volume",
Driver: "local",
DriverOpts: map[string]string{
"type": "bind",
"device": "/etc",
},
}
resp, err := f.newTransport().decorateVolumeResourceCreationOperation(f.newRequest(t, body, f.adminUser), portainer.VolumeResourceControl)
require.NotErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateVolumeResourceCreationOperation_BindDriverOptAllowedWhenSettingPermissive(t *testing.T) {
t.Parallel()
f := newVolumeCreationFixtures(t)
f.setSecuritySettings(t, portainer.EndpointSecuritySettings{
AllowBindMountsForRegularUsers: true,
})
body := volume.CreateOptions{
Name: "allowed-volume",
Driver: "local",
DriverOpts: map[string]string{
"type": "bind",
"device": "/data",
},
}
resp, err := f.newTransport().decorateVolumeResourceCreationOperation(f.newRequest(t, body, f.stdUser), portainer.VolumeResourceControl)
require.NotErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateVolumeResourceCreationOperation_NonBindDriverOptNotForbidden(t *testing.T) {
t.Parallel()
f := newVolumeCreationFixtures(t)
f.setSecuritySettings(t, portainer.EndpointSecuritySettings{
AllowBindMountsForRegularUsers: false,
})
body := volume.CreateOptions{
Name: "normal-volume",
Driver: "local",
DriverOpts: map[string]string{
"type": "tmpfs",
},
}
resp, err := f.newTransport().decorateVolumeResourceCreationOperation(f.newRequest(t, body, f.stdUser), portainer.VolumeResourceControl)
require.NotErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
+43
View File
@@ -0,0 +1,43 @@
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
package factory
import (
"context"
"net"
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/docker"
"github.com/portainer/portainer/pkg/libhttp/ssrf"
)
func (factory ProxyFactory) newOSBasedLocalProxy(path string, endpoint *portainer.Endpoint) (http.Handler, error) {
transportParameters := &docker.TransportParameters{
Endpoint: endpoint,
DataStore: factory.dataStore,
ReverseTunnelService: factory.reverseTunnelService,
SignatureService: factory.signatureService,
DockerClientFactory: factory.dockerClientFactory,
}
proxy := &dockerLocalProxy{}
dockerTransport, err := docker.NewTransport(transportParameters, newSocketTransport(path), factory.gitService, factory.snapshotService)
if err != nil {
return nil, err
}
proxy.transport = dockerTransport
return proxy, nil
}
func newSocketTransport(socketPath string) *http.Transport {
d := &net.Dialer{}
t := ssrf.NewInternalTransport(nil)
t.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) {
return d.DialContext(ctx, "unix", socketPath)
}
return t
}
+43
View File
@@ -0,0 +1,43 @@
//go:build windows
package factory
import (
"context"
"net"
"net/http"
"github.com/Microsoft/go-winio"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/docker"
"github.com/portainer/portainer/pkg/libhttp/ssrf"
)
func (factory ProxyFactory) newOSBasedLocalProxy(path string, endpoint *portainer.Endpoint) (http.Handler, error) {
transportParameters := &docker.TransportParameters{
Endpoint: endpoint,
DataStore: factory.dataStore,
ReverseTunnelService: factory.reverseTunnelService,
SignatureService: factory.signatureService,
DockerClientFactory: factory.dockerClientFactory,
}
proxy := &dockerLocalProxy{}
dockerTransport, err := docker.NewTransport(transportParameters, newNamedPipeTransport(path), factory.gitService, factory.snapshotService)
if err != nil {
return nil, err
}
proxy.transport = dockerTransport
return proxy, nil
}
func newNamedPipeTransport(namedPipePath string) *http.Transport {
t := ssrf.NewInternalTransport(nil)
t.DialContext = func(_ context.Context, _, _ string) (net.Conn, error) {
return winio.DialPipe(namedPipePath, nil)
}
return t
}
+61
View File
@@ -0,0 +1,61 @@
package factory
import (
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
dockerclient "github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
"github.com/portainer/portainer/api/kubernetes/cli"
)
const azureAPIBaseURL = "https://management.azure.com"
type (
// ProxyFactory is a factory to create reverse proxies
ProxyFactory struct {
dataStore dataservices.DataStore
signatureService portainer.DigitalSignatureService
reverseTunnelService portainer.ReverseTunnelService
dockerClientFactory *dockerclient.ClientFactory
kubernetesClientFactory *cli.ClientFactory
kubernetesTokenCacheManager *kubernetes.TokenCacheManager
gitService portainer.GitService
snapshotService portainer.SnapshotService
jwtService portainer.JWTService
}
)
// NewProxyFactory returns a pointer to a new instance of a ProxyFactory
func NewProxyFactory(dataStore dataservices.DataStore, signatureService portainer.DigitalSignatureService, tunnelService portainer.ReverseTunnelService, clientFactory *dockerclient.ClientFactory, kubernetesClientFactory *cli.ClientFactory, kubernetesTokenCacheManager *kubernetes.TokenCacheManager, gitService portainer.GitService, snapshotService portainer.SnapshotService, jwtService portainer.JWTService) *ProxyFactory {
return &ProxyFactory{
dataStore: dataStore,
signatureService: signatureService,
reverseTunnelService: tunnelService,
dockerClientFactory: clientFactory,
kubernetesClientFactory: kubernetesClientFactory,
kubernetesTokenCacheManager: kubernetesTokenCacheManager,
gitService: gitService,
snapshotService: snapshotService,
jwtService: jwtService,
}
}
// NewEndpointProxy returns a new reverse proxy (filesystem based or HTTP) to an environment(endpoint) API server
func (factory *ProxyFactory) NewEndpointProxy(endpoint *portainer.Endpoint) (http.Handler, error) {
switch endpoint.Type {
case portainer.AzureEnvironment:
return newAzureProxy(endpoint, factory.dataStore)
case portainer.EdgeAgentOnKubernetesEnvironment, portainer.AgentOnKubernetesEnvironment, portainer.KubernetesLocalEnvironment:
return factory.newKubernetesProxy(endpoint)
}
return factory.newDockerProxy(endpoint)
}
// NewGitlabProxy returns a new HTTP proxy to a Gitlab API server
func (factory *ProxyFactory) NewGitlabProxy(gitlabAPIUri string) (http.Handler, error) {
return newGitlabProxy(gitlabAPIUri)
}
+115
View File
@@ -0,0 +1,115 @@
package github
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/portainer/portainer/pkg/libhttp/ssrf"
"github.com/rs/zerolog/log"
"github.com/segmentio/encoding/json"
"oras.land/oras-go/v2/registry/remote/retry"
)
const GitHubAPIHost = "https://api.github.com"
// Package represents a GitHub container package
type Package struct {
Name string `json:"name"`
Owner struct {
Login string `json:"login"`
} `json:"owner"`
}
// Client represents a GitHub API client
type Client struct {
httpClient *http.Client
baseURL string
}
// NewClient creates a new GitHub API client
func NewClient(token string) *Client {
return &Client{
httpClient: NewHTTPClient(token),
baseURL: GitHubAPIHost,
}
}
// GetContainerPackages fetches container packages for the configured namespace
// It's a small http client wrapper instead of using the github client because listing repositories is the only known operation that isn't directly supported by oras
func (c *Client) GetContainerPackages(ctx context.Context, useOrganisation bool, organisationName string) ([]string, error) {
// Determine the namespace (user or organisation) for the request
namespace := "user"
if useOrganisation {
namespace = "orgs/" + organisationName
}
// Build the full URL for listing container packages
url := fmt.Sprintf("%s/%s/packages?package_type=container", c.baseURL, namespace)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute request: %w", err)
}
defer func() {
if err := resp.Body.Close(); err != nil {
log.Warn().Err(err).Msg("failed to close response body")
}
}()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
var packages []Package
if err := json.Unmarshal(body, &packages); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
// Extract repository names in the form "owner/name"
repositories := make([]string, len(packages))
for i, pkg := range packages {
repositories[i] = fmt.Sprintf("%s/%s", strings.ToLower(pkg.Owner.Login), strings.ToLower(pkg.Name))
}
return repositories, nil
}
// NewHTTPClient creates a new HTTP client configured for GitHub API requests
func NewHTTPClient(token string) *http.Client {
return &http.Client{
Transport: &tokenTransport{
token: token,
transport: retry.NewTransport(ssrf.NewTransport(nil)), // Use ORAS retry transport for consistent rate limiting and error handling
},
Timeout: 1 * time.Minute,
}
}
// tokenTransport automatically adds the Bearer token header to requests
type tokenTransport struct {
token string
transport http.RoundTripper
}
func (t *tokenTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if t.token != "" {
req.Header.Set("Authorization", "Bearer "+t.token)
req.Header.Set("Accept", "application/vnd.github+json")
}
return t.transport.RoundTrip(req)
}
+19
View File
@@ -0,0 +1,19 @@
package factory
import (
"net/http"
"net/url"
"github.com/portainer/portainer/api/http/proxy/factory/gitlab"
)
func newGitlabProxy(uri string) (http.Handler, error) {
url, err := url.Parse(uri)
if err != nil {
return nil, err
}
proxy := NewSingleHostReverseProxyWithHostHeader(url)
proxy.Transport = gitlab.NewTransport()
return proxy, nil
}
+137
View File
@@ -0,0 +1,137 @@
package gitlab
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"time"
"github.com/portainer/portainer/pkg/libhttp/ssrf"
"github.com/rs/zerolog/log"
"github.com/segmentio/encoding/json"
"oras.land/oras-go/v2/registry/remote/retry"
)
// Repository represents a GitLab registry repository
type Repository struct {
ID int `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
ProjectID int `json:"project_id"`
Location string `json:"location"`
CreatedAt string `json:"created_at"`
Status string `json:"status"`
}
// Client represents a GitLab API client
type Client struct {
httpClient *http.Client
baseURL string
}
// NewClient creates a new GitLab API client
// it currently is an http client because only GetRegistryRepositoryNames is needed (oras supports other commands).
// if we need to support other commands, consider using the gitlab client library.
func NewClient(baseURL, token string) *Client {
return &Client{
httpClient: NewHTTPClient(token),
baseURL: baseURL,
}
}
// GetRegistryRepositoryNames fetches registry repository names for a given project.
// It's a small http client wrapper instead of using the gitlab client library because listing repositories is the only known operation that isn't directly supported by oras
func (c *Client) GetRegistryRepositoryNames(ctx context.Context, projectID int) ([]string, error) {
url := fmt.Sprintf("%s/api/v4/projects/%d/registry/repositories", c.baseURL, projectID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute request: %w", err)
}
defer func() {
if err := resp.Body.Close(); err != nil {
log.Warn().Err(err).Msg("failed to close response body")
}
}()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitLab API returned status %d: %s", resp.StatusCode, resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
var repositories []Repository
if err := json.Unmarshal(body, &repositories); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
// Extract repository names
names := make([]string, len(repositories))
for i, repo := range repositories {
// the full path is required for further repo operations
names[i] = repo.Path
}
return names, nil
}
type Transport struct {
httpTransport *http.Transport
}
// NewTransport returns a pointer to a new instance of Transport that implements the HTTP Transport
// interface for proxying requests to the Gitlab API.
func NewTransport() *Transport {
return &Transport{
httpTransport: ssrf.NewTransport(nil),
}
}
// RoundTrip is the implementation of the http.RoundTripper interface
func (transport *Transport) RoundTrip(request *http.Request) (*http.Response, error) {
token := request.Header.Get("Private-Token")
if token == "" {
return nil, errors.New("no gitlab token provided")
}
r, err := http.NewRequest(request.Method, request.URL.String(), request.Body)
if err != nil {
return nil, err
}
r.Header.Set("Private-Token", token)
return transport.httpTransport.RoundTrip(r)
}
// NewHTTPClient creates a new HTTP client configured for GitLab API requests
func NewHTTPClient(token string) *http.Client {
return &http.Client{
Transport: &tokenTransport{
token: token,
transport: retry.NewTransport(ssrf.NewTransport(nil)), // Use ORAS retry transport for consistent rate limiting and error handling
},
Timeout: 1 * time.Minute,
}
}
// tokenTransport automatically adds the Private-Token header to requests
type tokenTransport struct {
token string
transport http.RoundTripper
}
func (t *tokenTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req.Header.Set("Private-Token", t.token)
return t.transport.RoundTrip(req)
}
+110
View File
@@ -0,0 +1,110 @@
package factory
import (
"net/http"
"net/url"
"github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
portainer "github.com/portainer/portainer/api"
)
func (factory *ProxyFactory) newKubernetesProxy(endpoint *portainer.Endpoint) (http.Handler, error) {
switch endpoint.Type {
case portainer.KubernetesLocalEnvironment:
return factory.newKubernetesLocalProxy(endpoint)
case portainer.EdgeAgentOnKubernetesEnvironment:
return factory.newKubernetesEdgeHTTPProxy(endpoint)
}
return factory.newKubernetesAgentHTTPSProxy(endpoint)
}
func (factory *ProxyFactory) newKubernetesLocalProxy(endpoint *portainer.Endpoint) (http.Handler, error) {
remoteURL, err := url.Parse(endpoint.URL)
if err != nil {
return nil, err
}
kubecli, err := factory.kubernetesClientFactory.GetPrivilegedKubeClient(endpoint)
if err != nil {
return nil, err
}
tokenCache := factory.kubernetesTokenCacheManager.GetOrCreateTokenCache(endpoint.ID)
tokenManager, err := kubernetes.NewTokenManager(kubecli, factory.dataStore, tokenCache, true)
if err != nil {
return nil, err
}
transport, err := kubernetes.NewLocalTransport(tokenManager, endpoint, factory.kubernetesClientFactory, factory.dataStore, factory.jwtService)
if err != nil {
return nil, err
}
proxy := NewSingleHostReverseProxyWithHostHeader(remoteURL)
proxy.Transport = transport
return proxy, nil
}
func (factory *ProxyFactory) newKubernetesEdgeHTTPProxy(endpoint *portainer.Endpoint) (http.Handler, error) {
tunnelAddr, err := factory.reverseTunnelService.TunnelAddr(endpoint)
if err != nil {
return nil, err
}
rawURL := "http://" + tunnelAddr
endpointURL, err := url.Parse(rawURL)
if err != nil {
return nil, err
}
kubecli, err := factory.kubernetesClientFactory.GetPrivilegedKubeClient(endpoint)
if err != nil {
return nil, err
}
tokenCache := factory.kubernetesTokenCacheManager.GetOrCreateTokenCache(endpoint.ID)
tokenManager, err := kubernetes.NewTokenManager(kubecli, factory.dataStore, tokenCache, false)
if err != nil {
return nil, err
}
endpointURL.Scheme = "http"
proxy := NewSingleHostReverseProxyWithHostHeader(endpointURL)
proxy.Transport = kubernetes.NewEdgeTransport(factory.dataStore, factory.signatureService, factory.reverseTunnelService, endpoint, tokenManager, factory.kubernetesClientFactory, factory.jwtService)
return proxy, nil
}
func (factory *ProxyFactory) newKubernetesAgentHTTPSProxy(endpoint *portainer.Endpoint) (http.Handler, error) {
endpointURL := "https://" + endpoint.URL
remoteURL, err := url.Parse(endpointURL)
if err != nil {
return nil, err
}
remoteURL.Scheme = "https"
kubecli, err := factory.kubernetesClientFactory.GetPrivilegedKubeClient(endpoint)
if err != nil {
return nil, err
}
tokenCache := factory.kubernetesTokenCacheManager.GetOrCreateTokenCache(endpoint.ID)
tokenManager, err := kubernetes.NewTokenManager(kubecli, factory.dataStore, tokenCache, false)
if err != nil {
return nil, err
}
transport, err := kubernetes.NewAgentTransport(factory.signatureService, tokenManager, endpoint, factory.kubernetesClientFactory, factory.dataStore, factory.jwtService)
if err != nil {
return nil, err
}
proxy := NewSingleHostReverseProxyWithHostHeader(remoteURL)
proxy.Transport = transport
return proxy, nil
}
@@ -0,0 +1,75 @@
package kubernetes
import (
"net/http"
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/crypto"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/kubernetes/cli"
"github.com/portainer/portainer/pkg/libhttp/ssrf"
)
type agentTransport struct {
*baseTransport
signatureService portainer.DigitalSignatureService
}
// NewAgentTransport returns a new transport that can be used to send signed requests to a Portainer agent
func NewAgentTransport(signatureService portainer.DigitalSignatureService, tokenManager *tokenManager, endpoint *portainer.Endpoint, k8sClientFactory *cli.ClientFactory, dataStore dataservices.DataStore, jwtService portainer.JWTService) (*agentTransport, error) {
tlsConfig, err := crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig)
if err != nil {
return nil, err
}
httpTransport := ssrf.NewTransport(tlsConfig)
httpTransport.Protocols = ssrf.HTTP1Only()
transport := &agentTransport{
baseTransport: newBaseTransport(
httpTransport,
tokenManager,
endpoint,
k8sClientFactory,
dataStore,
jwtService,
),
signatureService: signatureService,
}
return transport, nil
}
// RoundTrip is the implementation of the the http.RoundTripper interface
func (transport *agentTransport) RoundTrip(request *http.Request) (*http.Response, error) {
token, err := transport.getRoundTripToken(request, transport.tokenManager)
if err != nil {
return nil, err
}
request.Header.Set(portainer.PortainerAgentKubernetesSATokenHeader, token)
if strings.HasPrefix(request.URL.Path, "/v2") {
err := decorateAgentRequest(request, transport.dataStore)
if err != nil {
return nil, err
}
}
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)
response, err := transport.baseTransport.RoundTrip(request)
if err != nil {
return response, err
}
response.Header.Set(portainer.PortainerCacheHeader, "true")
return response, err
}
@@ -0,0 +1,18 @@
package kubernetes
import (
"net/http"
"github.com/rs/zerolog/log"
)
func (transport *baseTransport) proxyDeploymentsRequest(request *http.Request, namespace, requestPath string) (*http.Response, error) {
switch request.Method {
case http.MethodPost, http.MethodPatch, http.MethodPut:
if err := transport.refreshRegistry(request, namespace); err != nil {
log.Warn().Err(err).Msg("failed to refresh registry credentials")
}
}
return transport.executeKubernetesRequest(request)
}
@@ -0,0 +1,68 @@
package kubernetes
import (
"net/http"
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/kubernetes/cli"
"github.com/portainer/portainer/pkg/libhttp/ssrf"
)
type edgeTransport struct {
*baseTransport
signatureService portainer.DigitalSignatureService
reverseTunnelService portainer.ReverseTunnelService
}
// NewAgentTransport returns a new transport that can be used to send signed requests to a Portainer Edge agent
func NewEdgeTransport(dataStore dataservices.DataStore, signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, endpoint *portainer.Endpoint, tokenManager *tokenManager, k8sClientFactory *cli.ClientFactory, jwtService portainer.JWTService) *edgeTransport {
transport := &edgeTransport{
reverseTunnelService: reverseTunnelService,
signatureService: signatureService,
baseTransport: newBaseTransport(
ssrf.NewInternalTransport(nil),
tokenManager,
endpoint,
k8sClientFactory,
dataStore,
jwtService,
),
}
return transport
}
// RoundTrip is the implementation of the the http.RoundTripper interface
func (transport *edgeTransport) RoundTrip(request *http.Request) (*http.Response, error) {
token, err := transport.getRoundTripToken(request, transport.tokenManager)
if err != nil {
return nil, err
}
request.Header.Set(portainer.PortainerAgentKubernetesSATokenHeader, token)
if strings.HasPrefix(request.URL.Path, "/v2") {
err := decorateAgentRequest(request, transport.dataStore)
if err != nil {
return nil, err
}
}
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)
response, err := transport.baseTransport.RoundTrip(request)
if err == nil {
transport.reverseTunnelService.UpdateLastActivity(transport.endpoint.ID)
}
return response, err
}
@@ -0,0 +1,14 @@
package kubernetes
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestNewEdgeTransport(t *testing.T) {
t.Parallel()
transport := NewEdgeTransport(nil, nil, nil, nil, nil, nil, nil)
require.NotNil(t, transport)
}
@@ -0,0 +1,46 @@
package kubernetes
import (
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/crypto"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/kubernetes/cli"
"github.com/portainer/portainer/pkg/libhttp/ssrf"
)
type localTransport struct {
*baseTransport
}
// NewLocalTransport returns a new transport that can be used to send requests to the local Kubernetes API
func NewLocalTransport(tokenManager *tokenManager, endpoint *portainer.Endpoint, k8sClientFactory *cli.ClientFactory, dataStore dataservices.DataStore, jwtService portainer.JWTService) (*localTransport, error) {
config, err := crypto.CreateTLSConfigurationFromBytes(true, nil, nil, nil, true, true)
if err != nil {
return nil, err
}
transport := &localTransport{
baseTransport: newBaseTransport(
ssrf.NewInternalTransport(config),
tokenManager,
endpoint,
k8sClientFactory,
dataStore,
jwtService,
),
}
return transport, nil
}
// RoundTrip is the implementation of the the http.RoundTripper interface
func (transport *localTransport) RoundTrip(request *http.Request) (*http.Response, error) {
_, err := transport.prepareRoundTrip(request)
if err != nil {
return nil, err
}
return transport.baseTransport.RoundTrip(request)
}
@@ -0,0 +1,18 @@
package kubernetes
import (
"testing"
"github.com/portainer/portainer/pkg/fips"
"github.com/stretchr/testify/require"
)
func TestNewLocalTransport(t *testing.T) {
t.Parallel()
fips.InitFIPS(false)
transport, err := NewLocalTransport(nil, nil, nil, nil, nil)
require.NoError(t, err)
require.True(t, transport.httpTransport.TLSClientConfig.InsecureSkipVerify) //nolint:forbidigo
}
@@ -0,0 +1,64 @@
package kubernetes
import (
"net/http"
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
)
func (transport *baseTransport) proxyNamespaceDeleteOperation(request *http.Request, namespace string) (*http.Response, error) {
if err := transport.tokenManager.kubecli.NamespaceAccessPoliciesDeleteNamespace(namespace); err != nil {
return nil, errors.WithMessagef(err, "failed to delete a namespace [%s] from portainer config", namespace)
}
registries, err := transport.dataStore.Registry().ReadAll()
if err != nil {
return nil, err
}
for _, registry := range registries {
for endpointID, registryAccessPolicies := range registry.RegistryAccesses {
if endpointID != transport.endpoint.ID {
continue
}
namespaces := []string{}
for _, ns := range registryAccessPolicies.Namespaces {
if ns == namespace {
continue
}
namespaces = append(namespaces, ns)
}
if len(namespaces) != len(registryAccessPolicies.Namespaces) {
updatedAccessPolicies := portainer.RegistryAccessPolicies{
Namespaces: namespaces,
UserAccessPolicies: registryAccessPolicies.UserAccessPolicies,
TeamAccessPolicies: registryAccessPolicies.TeamAccessPolicies,
}
registry.RegistryAccesses[endpointID] = updatedAccessPolicies
err := transport.dataStore.Registry().Update(registry.ID, &registry)
if err != nil {
return nil, err
}
}
}
}
stacks, err := transport.dataStore.Stack().ReadAll()
if err != nil {
return nil, err
}
for _, s := range stacks {
if s.Namespace == namespace && s.EndpointID == transport.endpoint.ID {
if err := transport.dataStore.Stack().Delete(s.ID); err != nil {
return nil, err
}
}
}
return transport.executeKubernetesRequest(request)
}
+23
View File
@@ -0,0 +1,23 @@
package kubernetes
import (
"net/http"
"strings"
"github.com/rs/zerolog/log"
)
func (transport *baseTransport) proxyPodsRequest(request *http.Request, namespace string) (*http.Response, error) {
if request.Method == http.MethodDelete {
if err := transport.refreshRegistry(request, namespace); err != nil {
log.Warn().Err(err).Msg("failed to refresh registry credentials")
}
}
if request.Method == http.MethodPost && strings.Contains(request.URL.Path, "/exec") {
if err := transport.addTokenForExec(request); err != nil {
return nil, err
}
}
return transport.executeKubernetesRequest(request)
}
@@ -0,0 +1,18 @@
package kubernetes
import (
"net/http"
"github.com/portainer/portainer/api/internal/registryutils"
)
func (transport *baseTransport) refreshRegistry(request *http.Request, namespace string) (err error) {
cli, err := transport.k8sClientFactory.GetPrivilegedKubeClient(transport.endpoint)
if err != nil {
return
}
err = registryutils.RefreshEcrSecret(cli, transport.endpoint, transport.dataStore, namespace)
return
}
+110
View File
@@ -0,0 +1,110 @@
package kubernetes
import (
"fmt"
"os"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/rs/zerolog/log"
)
const defaultServiceAccountTokenFile = "/var/run/secrets/kubernetes.io/serviceaccount/token"
type tokenManager struct {
tokenCache *tokenCache
kubecli portainer.KubeClient
dataStore dataservices.DataStore
adminToken string
}
// NewTokenManager returns a pointer to a new instance of tokenManager.
// If the useLocalAdminToken parameter is set to true, it will search for the local admin service account
// and associate it to the manager.
func NewTokenManager(kubecli portainer.KubeClient, dataStore dataservices.DataStore, cache *tokenCache, setLocalAdminToken bool) (*tokenManager, error) {
tokenManager := &tokenManager{
tokenCache: cache,
kubecli: kubecli,
dataStore: dataStore,
adminToken: "",
}
if setLocalAdminToken {
token, err := os.ReadFile(defaultServiceAccountTokenFile)
if err != nil {
return nil, err
}
tokenManager.adminToken = string(token)
}
return tokenManager, nil
}
func (manager *tokenManager) GetAdminServiceAccountToken() string {
return manager.adminToken
}
func (manager *tokenManager) setupUserServiceAccounts(userID portainer.UserID, endpoint *portainer.Endpoint) error {
memberships, err := manager.dataStore.TeamMembership().TeamMembershipsByUserID(userID)
if err != nil {
return err
}
teamIds := make([]int, 0, len(memberships))
for _, membership := range memberships {
teamIds = append(teamIds, int(membership.TeamID))
}
restrictDefaultNamespace := endpoint.Kubernetes.Configuration.RestrictDefaultNamespace
err = manager.kubecli.SetupUserServiceAccount(int(userID), teamIds, restrictDefaultNamespace)
if err != nil {
return err
}
return nil
}
func (manager *tokenManager) UpdateUserServiceAccountsForEndpoint(endpointID portainer.EndpointID) {
endpoint, err := manager.dataStore.Endpoint().Endpoint(endpointID)
if err != nil {
log.Error().Err(err).Msgf("failed fetching environments %d", endpointID)
return
}
userIDs := make([]portainer.UserID, 0)
for u := range endpoint.UserAccessPolicies {
userIDs = append(userIDs, u)
}
for t := range endpoint.TeamAccessPolicies {
memberships, _ := manager.dataStore.TeamMembership().TeamMembershipsByTeamID(t)
for _, membership := range memberships {
userIDs = append(userIDs, membership.UserID)
}
}
for _, userID := range userIDs {
if err := manager.setupUserServiceAccounts(userID, endpoint); err != nil {
log.Error().Err(err).Msgf("failed setting-up service account for user %d", userID)
}
}
}
// GetUserServiceAccountToken setup a user's service account if it does not exist, then retrieve its token
func (manager *tokenManager) GetUserServiceAccountToken(userID int, endpointID portainer.EndpointID) (string, error) {
tokenFunc := func() (string, error) {
endpoint, err := manager.dataStore.Endpoint().Endpoint(endpointID)
if err != nil {
log.Error().Err(err).Msgf("failed fetching environment %d", endpointID)
return "", err
}
if err := manager.setupUserServiceAccounts(portainer.UserID(userID), endpoint); err != nil {
return "", fmt.Errorf("failed setting-up service account for user %d: %w", userID, err)
}
return manager.kubecli.GetServiceAccountBearerToken(userID)
}
return manager.tokenCache.getOrAddToken(portainer.UserID(userID), tokenFunc)
}
@@ -0,0 +1,78 @@
package kubernetes
import (
"sync"
portainer "github.com/portainer/portainer/api"
)
// TokenCacheManager represents a service used to manage multiple tokenCache objects.
type TokenCacheManager struct {
tokenCaches map[portainer.EndpointID]*tokenCache
mu sync.Mutex
}
type tokenCache struct {
userTokenCache map[portainer.UserID]string
mu sync.Mutex
}
// NewTokenCacheManager returns a pointer to a new instance of TokenCacheManager
func NewTokenCacheManager() *TokenCacheManager {
return &TokenCacheManager{
tokenCaches: make(map[portainer.EndpointID]*tokenCache),
}
}
// GetOrCreateTokenCache will get the tokenCache from the manager map of caches if it exists,
// otherwise it will create a new tokenCache object, associate it to the manager map of caches
// and return a pointer to that tokenCache instance.
func (manager *TokenCacheManager) GetOrCreateTokenCache(endpointID portainer.EndpointID) *tokenCache {
manager.mu.Lock()
defer manager.mu.Unlock()
if tc, ok := manager.tokenCaches[endpointID]; ok {
return tc
}
tc := &tokenCache{
userTokenCache: make(map[portainer.UserID]string),
}
manager.tokenCaches[endpointID] = tc
return tc
}
// RemoveUserFromCache will ensure that the specific userID is removed from all registered caches.
func (manager *TokenCacheManager) RemoveUserFromCache(userID portainer.UserID) {
manager.mu.Lock()
for _, tc := range manager.tokenCaches {
tc.removeToken(userID)
}
manager.mu.Unlock()
}
func (cache *tokenCache) getOrAddToken(userID portainer.UserID, tokenGetFunc func() (string, error)) (string, error) {
cache.mu.Lock()
defer cache.mu.Unlock()
if tok, ok := cache.userTokenCache[userID]; ok {
return tok, nil
}
tok, err := tokenGetFunc()
if err != nil {
return "", err
}
cache.userTokenCache[userID] = tok
return tok, nil
}
func (cache *tokenCache) removeToken(userID portainer.UserID) {
cache.mu.Lock()
delete(cache.userTokenCache, userID)
cache.mu.Unlock()
}
@@ -0,0 +1,104 @@
package kubernetes
import (
"errors"
"testing"
portainer "github.com/portainer/portainer/api"
)
func noTokFunc() (string, error) {
return "", errors.New("no token found")
}
func stringTok(tok string) func() (string, error) {
return func() (string, error) {
return tok, nil
}
}
func failFunc(t *testing.T) func() (string, error) {
return func() (string, error) {
t.FailNow()
return noTokFunc()
}
}
func TestTokenCacheDataRace(t *testing.T) {
t.Parallel()
ch := make(chan struct{})
for range 1000 {
var tokenCache1, tokenCache2 *tokenCache
mgr := NewTokenCacheManager()
go func() {
tokenCache1 = mgr.GetOrCreateTokenCache(1)
ch <- struct{}{}
}()
go func() {
tokenCache2 = mgr.GetOrCreateTokenCache(1)
ch <- struct{}{}
}()
<-ch
<-ch
if tokenCache1 != tokenCache2 {
t.FailNow()
}
}
}
func TestTokenCache(t *testing.T) {
t.Parallel()
mgr := NewTokenCacheManager()
tc1 := mgr.GetOrCreateTokenCache(1)
tc2 := mgr.GetOrCreateTokenCache(2)
tc3 := mgr.GetOrCreateTokenCache(3)
uid := portainer.UserID(2)
tokenString1 := "token-string-1"
tokenString2 := "token-string-2"
tok, err := tc1.getOrAddToken(uid, stringTok(tokenString1))
if err != nil || tok != tokenString1 {
t.FailNow()
}
tok, err = tc1.getOrAddToken(uid, failFunc(t))
if err != nil || tok != tokenString1 {
t.FailNow()
}
tok, err = tc2.getOrAddToken(uid, stringTok(tokenString2))
if err != nil || tok != tokenString2 {
t.FailNow()
}
_, err = tc3.getOrAddToken(uid, noTokFunc)
if err == nil {
t.FailNow()
}
// Remove one user from all the caches
mgr.RemoveUserFromCache(uid)
_, err = tc1.getOrAddToken(uid, noTokFunc)
if err == nil {
t.FailNow()
}
_, err = tc2.getOrAddToken(uid, noTokFunc)
if err == nil {
t.FailNow()
}
_, err = tc3.getOrAddToken(uid, noTokFunc)
if err == nil {
t.FailNow()
}
}
@@ -0,0 +1,227 @@
package kubernetes
import (
"bytes"
"fmt"
"io"
"net/http"
"path"
"regexp"
"strconv"
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/kubernetes/cli"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/segmentio/encoding/json"
)
type baseTransport struct {
httpTransport *http.Transport
tokenManager *tokenManager
endpoint *portainer.Endpoint
k8sClientFactory *cli.ClientFactory
dataStore dataservices.DataStore
jwtService portainer.JWTService
}
func newBaseTransport(httpTransport *http.Transport, tokenManager *tokenManager, endpoint *portainer.Endpoint, k8sClientFactory *cli.ClientFactory, dataStore dataservices.DataStore, jwtService portainer.JWTService) *baseTransport {
return &baseTransport{
httpTransport: httpTransport,
tokenManager: tokenManager,
endpoint: endpoint,
k8sClientFactory: k8sClientFactory,
dataStore: dataStore,
jwtService: jwtService,
}
}
// #region KUBERNETES PROXY
// proxyKubernetesRequest intercepts a Kubernetes API request and apply logic based
// on the requested operation.
func (transport *baseTransport) proxyKubernetesRequest(request *http.Request) (*http.Response, error) {
// URL path examples:
// http://localhost:9000/api/endpoints/3/kubernetes/api/v1/namespaces
// http://localhost:9000/api/endpoints/3/kubernetes/apis/apps/v1/namespaces/default/deployments
apiVersionRe := regexp.MustCompile(`^(/kubernetes)?/(api|apis/apps)/v[0-9](\.[0-9])?`)
requestPath := apiVersionRe.ReplaceAllString(request.URL.Path, "")
endpointRe := regexp.MustCompile(`([0-9]+)`)
endpointIDMatch := endpointRe.FindAllString(request.RequestURI, 1)
endpointID := 0
if len(endpointIDMatch) > 0 {
endpointID, _ = strconv.Atoi(endpointIDMatch[0])
}
switch {
case strings.EqualFold(requestPath, "/namespaces/portainer/configmaps/portainer-config") && (request.Method == "PUT" || request.Method == "POST"):
transport.k8sClientFactory.ClearClientCache()
defer transport.tokenManager.UpdateUserServiceAccountsForEndpoint(portainer.EndpointID(endpointID))
return transport.executeKubernetesRequest(request)
case strings.EqualFold(requestPath, "/namespaces"):
return transport.executeKubernetesRequest(request)
case strings.HasPrefix(requestPath, "/namespaces"):
return transport.proxyNamespacedRequest(request, requestPath)
default:
return transport.executeKubernetesRequest(request)
}
}
func (transport *baseTransport) proxyNamespacedRequest(request *http.Request, fullRequestPath string) (*http.Response, error) {
requestPath := strings.TrimPrefix(fullRequestPath, "/namespaces/")
split := strings.SplitN(requestPath, "/", 2)
namespace := split[0]
requestPath = ""
if len(split) > 1 {
requestPath = split[1]
}
switch {
case strings.HasPrefix(requestPath, "pods"):
return transport.proxyPodsRequest(request, namespace)
case strings.HasPrefix(requestPath, "deployments"):
return transport.proxyDeploymentsRequest(request, namespace, requestPath)
case requestPath == "" && request.Method == "DELETE":
return transport.proxyNamespaceDeleteOperation(request, namespace)
default:
return transport.executeKubernetesRequest(request)
}
}
// addTokenForExec injects a kubeconfig token into the request header
// this is only used with kubeconfig for kubectl exec requests
func (transport *baseTransport) addTokenForExec(request *http.Request) error {
tokenData, err := security.RetrieveTokenData(request)
if err != nil {
return err
}
token, err := transport.jwtService.GenerateTokenForKubeconfig(tokenData)
if err != nil {
return err
}
request.Header.Set("Authorization", "Bearer "+token)
return nil
}
func (transport *baseTransport) executeKubernetesRequest(request *http.Request) (*http.Response, error) {
resp, err := transport.httpTransport.RoundTrip(request)
// This fix was made to resolve a k8s e2e test, more detailed investigation should be done later.
if err == nil && resp.StatusCode == http.StatusMovedPermanently {
oldLocation := resp.Header.Get("Location")
if oldLocation != "" {
stripedPrefix := strings.TrimSuffix(request.RequestURI, request.URL.Path)
// local proxy strips "/kubernetes" but agent proxy and edge agent proxy do not
stripedPrefix = strings.TrimSuffix(stripedPrefix, "/kubernetes")
newLocation := stripedPrefix + "/kubernetes" + oldLocation
resp.Header.Set("Location", newLocation)
}
}
return resp, err
}
// #endregion
// #region ROUND TRIP
func (transport *baseTransport) prepareRoundTrip(request *http.Request) (string, error) {
token, err := transport.getRoundTripToken(request, transport.tokenManager)
if err != nil {
return "", err
}
request.Header.Set("Authorization", "Bearer "+token)
return token, nil
}
// RoundTrip is the implementation of the the http.RoundTripper interface
func (transport *baseTransport) RoundTrip(request *http.Request) (*http.Response, error) {
return transport.proxyKubernetesRequest(request)
}
func (transport *baseTransport) getRoundTripToken(request *http.Request, tokenManager *tokenManager) (string, error) {
tokenData, err := security.RetrieveTokenData(request)
if err != nil {
return "", err
}
var token string
if tokenData.Role == portainer.AdministratorRole {
token = tokenManager.GetAdminServiceAccountToken()
} else {
token, err = tokenManager.GetUserServiceAccountToken(int(tokenData.ID), transport.endpoint.ID)
if err != nil {
log.Error().
Err(err).
Msg("failed retrieving service account token")
return "", err
}
}
return token, nil
}
// #endregion
// #region DECORATE FUNCTIONS
func decorateAgentRequest(r *http.Request, dataStore dataservices.DataStore) error {
requestPath := strings.TrimPrefix(r.URL.Path, "/v2")
if strings.HasPrefix(requestPath, "/dockerhub") {
return decorateAgentDockerHubRequest(r, dataStore)
}
return nil
}
func decorateAgentDockerHubRequest(r *http.Request, dataStore dataservices.DataStore) error {
requestPath, registryIdString := path.Split(r.URL.Path)
registryID, err := strconv.Atoi(registryIdString)
if err != nil {
return fmt.Errorf("missing registry id: %w", err)
}
r.URL.Path = strings.TrimSuffix(requestPath, "/")
registry := &portainer.Registry{
Type: portainer.DockerHubRegistry,
}
if registryID != 0 {
registry, err = dataStore.Registry().Read(portainer.RegistryID(registryID))
if err != nil {
return fmt.Errorf("failed fetching registry: %w", err)
}
}
if registry.Type != portainer.DockerHubRegistry {
return errors.New("invalid registry type")
}
newBody, err := json.Marshal(registry)
if err != nil {
return fmt.Errorf("failed marshaling registry: %w", err)
}
r.Method = http.MethodPost
r.Body = io.NopCloser(bytes.NewReader(newBody))
r.ContentLength = int64(len(newBody))
return nil
}
// #endregion
@@ -0,0 +1,367 @@
package kubernetes
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/jwt"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// MockJWTService implements portainer.JWTService for testing
type MockJWTService struct {
generateTokenFunc func(data *portainer.TokenData) (string, error)
}
func (m *MockJWTService) GenerateToken(data *portainer.TokenData) (string, time.Time, error) {
if m.generateTokenFunc != nil {
token, err := m.generateTokenFunc(data)
return token, time.Now().Add(24 * time.Hour), err
}
return "mock-token", time.Now().Add(24 * time.Hour), nil
}
func (m *MockJWTService) GenerateTokenForKubeconfig(data *portainer.TokenData) (string, error) {
if m.generateTokenFunc != nil {
return m.generateTokenFunc(data)
}
return "mock-kubeconfig-token", nil
}
func (m *MockJWTService) ParseAndVerifyToken(token string) (*portainer.TokenData, string, time.Time, error) {
return &portainer.TokenData{ID: 1, Username: "mock", Role: portainer.AdministratorRole}, "mock-id", time.Now().Add(24 * time.Hour), nil
}
func (m *MockJWTService) SetUserSessionDuration(userSessionDuration time.Duration) {
// Mock implementation - not used in tests
}
func TestBaseTransport_AddTokenForExec(t *testing.T) {
t.Parallel()
// Setup test store and JWT service
_, store := datastore.MustNewTestStore(t, true, false)
// Create test users
adminUser := &portainer.User{
ID: 1,
Username: "admin",
Role: portainer.AdministratorRole,
}
err := store.User().Create(adminUser)
require.NoError(t, err)
standardUser := &portainer.User{
ID: 2,
Username: "standard",
Role: portainer.StandardUserRole,
}
err = store.User().Create(standardUser)
require.NoError(t, err)
// Create JWT service
jwtService, err := jwt.NewService("24h", store)
require.NoError(t, err)
// Create base transport
transport := &baseTransport{
jwtService: jwtService,
}
tests := []struct {
name string
tokenData *portainer.TokenData
setupRequest func(*http.Request) *http.Request
expectError bool
errorMsg string
expectPanic bool
verifyResponse func(*testing.T, *http.Request, *portainer.TokenData)
}{
{
name: "admin user - successful token generation",
tokenData: &portainer.TokenData{
ID: adminUser.ID,
Username: adminUser.Username,
Role: adminUser.Role,
},
setupRequest: func(req *http.Request) *http.Request {
return req.WithContext(security.StoreTokenData(req, &portainer.TokenData{
ID: adminUser.ID,
Username: adminUser.Username,
Role: adminUser.Role,
}))
},
expectError: false,
verifyResponse: func(t *testing.T, req *http.Request, tokenData *portainer.TokenData) {
authHeader := req.Header.Get("Authorization")
assert.NotEmpty(t, authHeader)
assert.True(t, strings.HasPrefix(authHeader, "Bearer "))
token := authHeader[7:] // Remove "Bearer " prefix
parsedTokenData, _, _, err := jwtService.ParseAndVerifyToken(token)
require.NoError(t, err)
assert.Equal(t, tokenData.ID, parsedTokenData.ID)
assert.Equal(t, tokenData.Username, parsedTokenData.Username)
assert.Equal(t, tokenData.Role, parsedTokenData.Role)
},
},
{
name: "standard user - successful token generation",
tokenData: &portainer.TokenData{
ID: standardUser.ID,
Username: standardUser.Username,
Role: standardUser.Role,
},
setupRequest: func(req *http.Request) *http.Request {
return req.WithContext(security.StoreTokenData(req, &portainer.TokenData{
ID: standardUser.ID,
Username: standardUser.Username,
Role: standardUser.Role,
}))
},
expectError: false,
verifyResponse: func(t *testing.T, req *http.Request, tokenData *portainer.TokenData) {
authHeader := req.Header.Get("Authorization")
assert.NotEmpty(t, authHeader)
assert.True(t, strings.HasPrefix(authHeader, "Bearer "))
token := authHeader[7:] // Remove "Bearer " prefix
parsedTokenData, _, _, err := jwtService.ParseAndVerifyToken(token)
require.NoError(t, err)
assert.Equal(t, tokenData.ID, parsedTokenData.ID)
assert.Equal(t, tokenData.Username, parsedTokenData.Username)
assert.Equal(t, tokenData.Role, parsedTokenData.Role)
},
},
{
name: "request without token data in context",
tokenData: nil,
setupRequest: func(req *http.Request) *http.Request {
return req // Don't add token data to context
},
expectError: true,
errorMsg: "Unable to find JWT data in request context",
},
{
name: "request with nil token data",
tokenData: nil,
setupRequest: func(req *http.Request) *http.Request {
return req.WithContext(security.StoreTokenData(req, nil))
},
expectPanic: true,
},
{
name: "JWT service failure",
tokenData: &portainer.TokenData{
ID: 1,
Username: "test",
Role: portainer.AdministratorRole,
},
setupRequest: func(req *http.Request) *http.Request {
return req.WithContext(security.StoreTokenData(req, &portainer.TokenData{
ID: 1,
Username: "test",
Role: portainer.AdministratorRole,
}))
},
expectPanic: true,
},
{
name: "verify authorization header format",
tokenData: &portainer.TokenData{
ID: adminUser.ID,
Username: adminUser.Username,
Role: adminUser.Role,
},
setupRequest: func(req *http.Request) *http.Request {
return req.WithContext(security.StoreTokenData(req, &portainer.TokenData{
ID: adminUser.ID,
Username: adminUser.Username,
Role: adminUser.Role,
}))
},
expectError: false,
verifyResponse: func(t *testing.T, req *http.Request, tokenData *portainer.TokenData) {
authHeader := req.Header.Get("Authorization")
assert.NotEmpty(t, authHeader)
assert.True(t, strings.HasPrefix(authHeader, "Bearer "))
token := authHeader[7:] // Remove "Bearer " prefix
assert.NotEmpty(t, token)
},
},
{
name: "verify header is overwritten on subsequent calls",
tokenData: &portainer.TokenData{
ID: adminUser.ID,
Username: adminUser.Username,
Role: adminUser.Role,
},
setupRequest: func(req *http.Request) *http.Request {
req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{
ID: adminUser.ID,
Username: adminUser.Username,
Role: adminUser.Role,
}))
// Set an existing Authorization header
req.Header.Set("Authorization", "Bearer old-token")
return req
},
expectError: false,
verifyResponse: func(t *testing.T, req *http.Request, tokenData *portainer.TokenData) {
authHeader := req.Header.Get("Authorization")
assert.NotEqual(t, "Bearer old-token", authHeader)
assert.True(t, strings.HasPrefix(authHeader, "Bearer "))
token := authHeader[7:] // Remove "Bearer " prefix
parsedTokenData, _, _, err := jwtService.ParseAndVerifyToken(token)
require.NoError(t, err)
assert.Equal(t, tokenData.ID, parsedTokenData.ID)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create request
request := httptest.NewRequest("GET", "/", nil)
request = tt.setupRequest(request)
// Determine which transport to use based on test case
var testTransport *baseTransport
if tt.name == "JWT service failure" {
testTransport = &baseTransport{
jwtService: nil,
}
} else {
testTransport = transport
}
// Call the function
if tt.expectPanic {
assert.Panics(t, func() {
_ = testTransport.addTokenForExec(request)
})
return
}
err := testTransport.addTokenForExec(request)
// Check results
if tt.expectError {
require.Error(t, err)
if tt.errorMsg != "" {
assert.Contains(t, err.Error(), tt.errorMsg)
}
} else {
require.NoError(t, err)
if tt.verifyResponse != nil {
tt.verifyResponse(t, request, tt.tokenData)
}
}
})
}
}
func TestBaseTransport_AddTokenForExec_Integration(t *testing.T) {
t.Parallel()
// Create a test HTTP server to capture requests
var capturedRequest *http.Request
var capturedHeaders http.Header
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedRequest = r
capturedHeaders = r.Header.Clone()
_, _ = w.Write([]byte("success"))
}))
defer testServer.Close()
// Create mock JWT service
mockJWTService := &MockJWTService{
generateTokenFunc: func(data *portainer.TokenData) (string, error) {
return "mock-token-" + data.Username, nil
},
}
// Create base transport
transport := &baseTransport{
httpTransport: &http.Transport{},
jwtService: mockJWTService,
}
tests := []struct {
name string
tokenData *portainer.TokenData
requestPath string
expectedToken string
}{
{
name: "admin user exec request",
tokenData: &portainer.TokenData{
ID: 1,
Username: "admin",
Role: portainer.AdministratorRole,
},
requestPath: "/api/endpoints/1/kubernetes/api/v1/namespaces/default/pods/test-pod/exec",
expectedToken: "mock-token-admin",
},
{
name: "standard user exec request",
tokenData: &portainer.TokenData{
ID: 2,
Username: "standard",
Role: portainer.StandardUserRole,
},
requestPath: "/api/endpoints/1/kubernetes/api/v1/namespaces/default/pods/test-pod/exec",
expectedToken: "mock-token-standard",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Reset captured data
capturedRequest = nil
capturedHeaders = nil
// Create request to the test server
request, err := http.NewRequest("POST", testServer.URL+tt.requestPath, strings.NewReader(""))
require.NoError(t, err)
// Add token data to request context
request = request.WithContext(security.StoreTokenData(request, tt.tokenData))
// Call proxyPodsRequest which triggers addTokenForExec for POST /exec requests
resp, err := transport.proxyPodsRequest(request, "default")
require.NoError(t, err)
defer func() {
err := resp.Body.Close()
require.NoError(t, err)
}()
// Verify the response
assert.Equal(t, http.StatusOK, resp.StatusCode)
// Verify the request was captured
assert.NotNil(t, capturedRequest)
assert.Equal(t, "POST", capturedRequest.Method)
assert.Equal(t, tt.requestPath, capturedRequest.URL.Path)
// Verify the authorization header was set correctly
capturedAuthHeader := capturedHeaders.Get("Authorization")
assert.NotEmpty(t, capturedAuthHeader)
assert.True(t, strings.HasPrefix(capturedAuthHeader, "Bearer "))
assert.Equal(t, "Bearer "+tt.expectedToken, capturedAuthHeader)
})
}
}
+88
View File
@@ -0,0 +1,88 @@
package factory
import (
"net/http"
"net/http/httputil"
"net/url"
"strings"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
)
// Note that we discard any non-canonical headers by design
var allowedHeaders = map[string]struct{}{
"Accept": {},
"Accept-Encoding": {},
"Accept-Language": {},
"Cache-Control": {},
"Connection": {},
"Content-Length": {},
"Content-Type": {},
"Private-Token": {},
"Upgrade": {},
"User-Agent": {},
"X-Portaineragent-Target": {},
"X-Portainer-Volumename": {},
"X-Registry-Auth": {},
"X-Stream-Protocol-Version": {},
// WebSocket headers those are required for kubectl exec/attach/port-forward operations
"Sec-Websocket-Key": {},
"Sec-Websocket-Version": {},
"Sec-Websocket-Protocol": {},
"Sec-Websocket-Extensions": {},
}
// 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 {
proxy := &httputil.ReverseProxy{Rewrite: createRewriteFn(target)}
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
httperror.WriteError(w, http.StatusBadGateway, "Proxy failure", err)
}
return proxy
}
func createRewriteFn(target *url.URL) func(*httputil.ProxyRequest) {
targetQuery := target.RawQuery
return func(proxyReq *httputil.ProxyRequest) {
proxyReq.Out.URL.Scheme = target.Scheme
proxyReq.Out.URL.Host = target.Host
proxyReq.Out.URL.Path = singleJoiningSlash(target.Path, proxyReq.In.URL.Path)
proxyReq.Out.URL.RawPath = ""
proxyReq.Out.Host = proxyReq.Out.URL.Host
if targetQuery == "" || proxyReq.Out.URL.RawQuery == "" {
proxyReq.Out.URL.RawQuery = targetQuery + proxyReq.Out.URL.RawQuery
} else {
proxyReq.Out.URL.RawQuery = targetQuery + "&" + proxyReq.Out.URL.RawQuery
}
if _, ok := proxyReq.Out.Header["User-Agent"]; !ok {
// explicitly disable User-Agent so it's not set to default value
proxyReq.Out.Header.Set("User-Agent", "")
}
for k := range proxyReq.Out.Header {
if _, ok := allowedHeaders[k]; !ok {
// We use delete here instead of req.Header.Del because we want to delete non canonical headers.
delete(proxyReq.Out.Header, k)
}
}
}
}
// 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
}
@@ -0,0 +1,227 @@
package factory
import (
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/url"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/require"
)
func Test_createRewriteFn(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
target *url.URL
req *http.Request
expectedReq *http.Request
}{
{
name: "base case",
target: createURL(t, "https://portainer.io/api/docker?a=5&b=6"),
req: createRequest(
t,
"GET",
"https://agent-portainer.io/test?c=7",
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": "something"},
true,
),
expectedReq: createRequest(
t,
"GET",
"https://portainer.io/api/docker/test?a=5&b=6&c=7",
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": "something"},
true,
),
},
{
name: "no User-Agent",
target: createURL(t, "https://portainer.io/api/docker?a=5&b=6"),
req: createRequest(
t,
"GET",
"https://agent-portainer.io/test?c=7",
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json"},
true,
),
expectedReq: createRequest(
t,
"GET",
"https://portainer.io/api/docker/test?a=5&b=6&c=7",
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": ""},
true,
),
},
{
name: "Sensitive Headers",
target: createURL(t, "https://portainer.io/api/docker?a=5&b=6"),
req: createRequest(
t,
"GET",
"https://agent-portainer.io/test?c=7",
map[string]string{
"Authorization": "secret",
"Proxy-Authorization": "secret",
"Cookie": "secret",
"X-Csrf-Token": "secret",
"X-Api-Key": "secret",
"Accept": "application/json",
"Accept-Encoding": "gzip",
"Accept-Language": "en-GB",
"Cache-Control": "None",
"Content-Length": "100",
"Content-Type": "application/json",
"Private-Token": "test-private-token",
"User-Agent": "test-user-agent",
"X-Portaineragent-Target": "test-agent-1",
"X-Portainer-Volumename": "test-volume-1",
"X-Registry-Auth": "test-registry-auth",
},
true,
),
expectedReq: createRequest(
t,
"GET",
"https://portainer.io/api/docker/test?a=5&b=6&c=7",
map[string]string{
"Accept": "application/json",
"Accept-Encoding": "gzip",
"Accept-Language": "en-GB",
"Cache-Control": "None",
"Content-Length": "100",
"Content-Type": "application/json",
"Private-Token": "test-private-token",
"User-Agent": "test-user-agent",
"X-Portaineragent-Target": "test-agent-1",
"X-Portainer-Volumename": "test-volume-1",
"X-Registry-Auth": "test-registry-auth",
},
true,
),
},
{
name: "Non canonical Headers",
target: createURL(t, "https://portainer.io/api/docker?a=5&b=6"),
req: createRequest(
t,
"GET",
"https://agent-portainer.io/test?c=7",
map[string]string{
"Accept": "application/json",
"Accept-Encoding": "gzip",
"Accept-Language": "en-GB",
"Cache-Control": "None",
"Content-Length": "100",
"Content-Type": "application/json",
"Private-Token": "test-private-token",
"User-Agent": "test-user-agent",
portainer.PortainerAgentTargetHeader: "test-agent-1",
"X-Portainer-VolumeName": "test-volume-1",
"X-Registry-Auth": "test-registry-auth",
},
false,
),
expectedReq: createRequest(
t,
"GET",
"https://portainer.io/api/docker/test?a=5&b=6&c=7",
map[string]string{
"Accept": "application/json",
"Accept-Encoding": "gzip",
"Accept-Language": "en-GB",
"Cache-Control": "None",
"Content-Length": "100",
"Content-Type": "application/json",
"Private-Token": "test-private-token",
"User-Agent": "test-user-agent",
"X-Registry-Auth": "test-registry-auth",
},
true,
),
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
rewriteFn := createRewriteFn(tc.target)
proxyRequest := httputil.ProxyRequest{
In: tc.req.Clone(t.Context()),
Out: tc.req.Clone(t.Context()),
}
rewriteFn(&proxyRequest)
if diff := cmp.Diff(proxyRequest.In, tc.req, cmp.Comparer(compareRequests)); diff != "" {
t.Fatalf("rewriteFn modified in request: \n%s", diff)
}
if diff := cmp.Diff(proxyRequest.Out, tc.expectedReq, cmp.Comparer(compareRequests)); diff != "" {
t.Fatalf("requests are different: \n%s", diff)
}
})
}
}
func createURL(t *testing.T, urlString string) *url.URL {
parsedURL, err := url.Parse(urlString)
if err != nil {
t.Fatalf("Failed to create url: %s", err)
}
return parsedURL
}
func createRequest(t *testing.T, method, url string, headers map[string]string, canonicalHeaders bool) *http.Request {
req, err := http.NewRequest(method, url, nil)
if err != nil {
t.Fatalf("Failed to create http request: %s", err)
} else {
for k, v := range headers {
if canonicalHeaders {
req.Header.Add(k, v)
} else {
req.Header[k] = []string{v}
}
}
}
return req
}
func compareRequests(a, b *http.Request) bool {
methodEqual := a.Method == b.Method
urlEqual := cmp.Diff(a.URL, b.URL) == ""
hostEqual := a.Host == b.Host
protoEqual := a.Proto == b.Proto && a.ProtoMajor == b.ProtoMajor && a.ProtoMinor == b.ProtoMinor
headersEqual := cmp.Diff(a.Header, b.Header) == ""
return methodEqual && urlEqual && hostEqual && protoEqual && headersEqual
}
func Test_createRewriteFn_staleRawPath(t *testing.T) {
t.Parallel()
var receivedURI string
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedURI = r.RequestURI
w.WriteHeader(http.StatusOK)
}))
defer backend.Close()
target, err := url.Parse(backend.URL)
require.NoError(t, err)
proxy := &httputil.ReverseProxy{Rewrite: createRewriteFn(target)}
frontend := httptest.NewServer(proxy)
defer frontend.Close()
resp, err := http.Get(frontend.URL + "/%63ontainers/json")
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, "/containers/json", receivedURI)
}
+224
View File
@@ -0,0 +1,224 @@
package factory
import (
"context"
"net/http"
"net/http/httptest"
"net/http/httputil"
"testing"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/docker"
"github.com/portainer/portainer/pkg/fips"
"github.com/portainer/portainer/pkg/libhttp/ssrf"
"github.com/stretchr/testify/require"
)
func init() {
fips.InitFIPS(false)
}
type stubTunnelService struct{}
func (s *stubTunnelService) StartTunnelServer(addr, port string, snapshotService portainer.SnapshotService) error {
return nil
}
func (s *stubTunnelService) StopTunnelServer() error { return nil }
func (s *stubTunnelService) GenerateEdgeKey(apiURL, tunnelAddr string, endpointIdentifier int) string {
return ""
}
func (s *stubTunnelService) Open(endpoint *portainer.Endpoint) error { return nil }
func (s *stubTunnelService) Config(endpointID portainer.EndpointID) portainer.TunnelDetails {
return portainer.TunnelDetails{}
}
func (s *stubTunnelService) TunnelAddr(endpoint *portainer.Endpoint) (string, error) {
return "127.0.0.1:9999", nil
}
func (s *stubTunnelService) UpdateLastActivity(endpointID portainer.EndpointID) {}
func (s *stubTunnelService) KeepTunnelAlive(endpointID portainer.EndpointID, ctx context.Context, maxKeepAlive time.Duration) {
}
type staticAllowListService struct {
parsed portainer.ParsedAllowList
}
func (s *staticAllowListService) ReadParsed(id portainer.AllowListKey) (*portainer.ParsedAllowList, error) {
return &s.parsed, nil
}
func enableSSRF(t *testing.T) {
t.Helper()
parsed := ssrf.ParseAllowedHosts([]string{"example.com"})
parsed.Mode = portainer.SSRFModeEnforce
err := ssrf.Configure(&staticAllowListService{parsed: parsed})
require.NoError(t, err)
t.Cleanup(func() {
err := ssrf.Configure(&staticAllowListService{})
require.NoError(t, err)
})
}
func TestNewDockerHTTPProxy_NonEdgeNoTLS(t *testing.T) {
enableSSRF(t)
f := &ProxyFactory{reverseTunnelService: &stubTunnelService{}}
endpoint := &portainer.Endpoint{
Type: portainer.DockerEnvironment,
URL: "tcp://192.168.1.100:2376",
}
handler, err := f.newDockerHTTPProxy(endpoint)
require.NoError(t, err)
proxy := handler.(*httputil.ReverseProxy)
dt := proxy.Transport.(*docker.Transport)
require.NotNil(t, dt.HTTPTransport.DialContext)
}
func TestNewDockerHTTPProxy_NonEdgeTLS(t *testing.T) {
enableSSRF(t)
f := &ProxyFactory{reverseTunnelService: &stubTunnelService{}}
endpoint := &portainer.Endpoint{
Type: portainer.DockerEnvironment,
URL: "tcp://192.168.1.100:2376",
TLSConfig: portainer.TLSConfiguration{
TLS: true,
TLSSkipVerify: true,
},
}
handler, err := f.newDockerHTTPProxy(endpoint)
require.NoError(t, err)
proxy := handler.(*httputil.ReverseProxy)
dt := proxy.Transport.(*docker.Transport)
require.NotNil(t, dt.HTTPTransport.DialContext)
}
func TestNewDockerHTTPProxy_EdgeNoTLS(t *testing.T) {
enableSSRF(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
f := &ProxyFactory{reverseTunnelService: &stubTunnelService{}}
endpoint := &portainer.Endpoint{
Type: portainer.EdgeAgentOnDockerEnvironment,
URL: "tcp://192.168.1.100:2376",
}
handler, err := f.newDockerHTTPProxy(endpoint)
require.NoError(t, err)
proxy := handler.(*httputil.ReverseProxy)
dt := proxy.Transport.(*docker.Transport)
resp, err := (&http.Client{Transport: dt.HTTPTransport}).Get(srv.URL)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
}
func TestNewDockerHTTPProxy_EdgeTLS(t *testing.T) {
enableSSRF(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
f := &ProxyFactory{reverseTunnelService: &stubTunnelService{}}
endpoint := &portainer.Endpoint{
Type: portainer.EdgeAgentOnDockerEnvironment,
URL: "tcp://192.168.1.100:2376",
TLSConfig: portainer.TLSConfiguration{
TLS: true,
TLSSkipVerify: true,
},
}
handler, err := f.newDockerHTTPProxy(endpoint)
require.NoError(t, err)
proxy := handler.(*httputil.ReverseProxy)
dt := proxy.Transport.(*docker.Transport)
resp, err := (&http.Client{Transport: dt.HTTPTransport}).Get(srv.URL)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
}
func TestNewAgentProxy_NonEdgeNoTLS(t *testing.T) {
f := &ProxyFactory{reverseTunnelService: &stubTunnelService{}}
endpoint := &portainer.Endpoint{
Type: portainer.AgentOnDockerEnvironment,
URL: "tcp://192.168.1.100:9001",
}
proxyServer, err := f.NewAgentProxy(endpoint)
require.NoError(t, err)
defer proxyServer.Close()
require.Positive(t, proxyServer.Port)
}
func TestNewAgentProxy_NonEdgeTLS(t *testing.T) {
f := &ProxyFactory{reverseTunnelService: &stubTunnelService{}}
endpoint := &portainer.Endpoint{
Type: portainer.AgentOnDockerEnvironment,
URL: "tcp://192.168.1.100:9001",
TLSConfig: portainer.TLSConfiguration{
TLS: true,
TLSSkipVerify: true,
},
}
proxyServer, err := f.NewAgentProxy(endpoint)
require.NoError(t, err)
defer proxyServer.Close()
require.Positive(t, proxyServer.Port)
}
func TestNewAgentProxy_EdgeNoTLS(t *testing.T) {
f := &ProxyFactory{reverseTunnelService: &stubTunnelService{}}
endpoint := &portainer.Endpoint{
Type: portainer.EdgeAgentOnDockerEnvironment,
URL: "tcp://192.168.1.100:9001",
}
proxyServer, err := f.NewAgentProxy(endpoint)
require.NoError(t, err)
defer proxyServer.Close()
require.Positive(t, proxyServer.Port)
}
func TestNewAgentProxy_EdgeTLS(t *testing.T) {
f := &ProxyFactory{reverseTunnelService: &stubTunnelService{}}
endpoint := &portainer.Endpoint{
Type: portainer.EdgeAgentOnDockerEnvironment,
URL: "tcp://192.168.1.100:9001",
TLSConfig: portainer.TLSConfiguration{
TLS: true,
TLSSkipVerify: true,
},
}
proxyServer, err := f.NewAgentProxy(endpoint)
require.NoError(t, err)
defer proxyServer.Close()
require.Positive(t, proxyServer.Port)
}
+94
View File
@@ -0,0 +1,94 @@
package utils
import (
"compress/gzip"
"errors"
"fmt"
"io"
"mime"
"github.com/portainer/portainer/api/logs"
"github.com/segmentio/encoding/json"
"go.yaml.in/yaml/v3"
)
// 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]any, property string) map[string]any {
object := jsonObject[property]
if object != nil {
return object.(map[string]any)
}
return nil
}
// GetArrayObject will extract an array from a specific property of another JSON object.
// Returns nil if nothing is associated to the specified key.
func GetArrayObject(jsonObject map[string]any, property string) []any {
object := jsonObject[property]
if object != nil {
return object.([]any)
}
return nil
}
func getBody(body io.ReadCloser, contentType string, isGzip bool) (any, error) {
if body == nil {
return nil, errors.New("unable to parse response: empty response body")
}
reader := body
if isGzip {
gzipReader, err := gzip.NewReader(reader)
if err != nil {
return nil, err
}
reader = gzipReader
}
defer logs.CloseAndLogErr(reader)
var data any
err := unmarshal(contentType, reader, &data)
if err != nil {
return nil, err
}
return data, nil
}
func marshal(contentType string, data any) ([]byte, error) {
// Note: contentType can look like: "application/json" or "application/json; charset=utf-8"
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil {
return nil, err
}
switch mediaType {
case "application/yaml":
return yaml.Marshal(data)
case "application/json", "":
return json.Marshal(data)
}
return nil, fmt.Errorf("content type is not supported for marshaling: %s", contentType)
}
func unmarshal(contentType string, body io.Reader, returnBody any) error {
// Note: contentType can look like: "application/json" or "application/json; charset=utf-8"
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil {
return err
}
switch mediaType {
case "application/yaml":
return yaml.NewDecoder(body).Decode(returnBody)
case "application/json", "":
return json.NewDecoder(body).Decode(returnBody)
}
return fmt.Errorf("content type is not supported for unmarshaling: %s", contentType)
}
+113
View File
@@ -0,0 +1,113 @@
package utils
import (
"bytes"
"fmt"
"io"
"net/http"
"strconv"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)
// GetResponseAsJSONObject returns the response content as a generic JSON object
func GetResponseAsJSONObject(response *http.Response) (map[string]any, error) {
responseData, err := getResponseBody(response)
if err != nil {
return nil, err
}
responseObject, ok := responseData.(map[string]any)
if !ok {
return nil, nil
}
return responseObject, nil
}
// GetResponseAsJSONArray returns the response content as an array of generic JSON object
func GetResponseAsJSONArray(response *http.Response) ([]any, error) {
responseData, err := getResponseBody(response)
if err != nil {
return nil, err
}
if responseData == nil {
return nil, nil
}
switch responseObject := responseData.(type) {
case []any:
return responseObject, nil
case map[string]any:
if responseObject["message"] != nil {
return nil, errors.New(responseObject["message"].(string))
}
log.Error().
Str("response", fmt.Sprintf("%+v", responseObject)).
Msg("invalid response format, expecting JSON array")
return nil, errors.New("unable to parse response: expected JSON array, got JSON object")
default:
log.Error().
Str("response", fmt.Sprintf("%+v", responseObject)).
Msg("invalid response format, expecting JSON array")
return nil, errors.New("unable to parse response: expected JSON array")
}
}
type errorResponse struct {
Message string `json:"message,omitempty"`
}
// WriteAccessDeniedResponse will create a new access denied response
func WriteAccessDeniedResponse() (*http.Response, error) {
header := http.Header{}
header.Add("Content-Type", "application/json")
response := &http.Response{Header: header}
err := RewriteResponse(response, errorResponse{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, errorResponse{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 any, statusCode int) error {
data, err := marshal(getContentType(response), newResponseData)
if err != nil {
return err
}
body := io.NopCloser(bytes.NewReader(data))
response.StatusCode = statusCode
response.Body = body
response.ContentLength = int64(len(data))
if response.Header == nil {
response.Header = make(http.Header)
}
response.Header.Set("Content-Length", strconv.Itoa(len(data)))
return nil
}
func getResponseBody(response *http.Response) (any, error) {
isGzip := response.Header.Get("Content-Encoding") == "gzip"
if isGzip {
response.Header.Del("Content-Encoding")
}
return getBody(response.Body, getContentType(response), isGzip)
}
func getContentType(response *http.Response) string {
return response.Header.Get("Content-type")
}
@@ -0,0 +1,22 @@
package utils
import (
"net/http"
"testing"
"github.com/stretchr/testify/require"
)
func TestWriteAccessDeniedResponse(t *testing.T) {
t.Parallel()
r, err := WriteAccessDeniedResponse()
require.NoError(t, err)
defer func() {
err = r.Body.Close()
require.NoError(t, err)
}()
require.NotNil(t, r)
require.Equal(t, "application/json", r.Header.Get("content-type"))
require.Equal(t, http.StatusForbidden, r.StatusCode)
}
+94
View File
@@ -0,0 +1,94 @@
package proxy
import (
"errors"
"fmt"
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
dockerclient "github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/http/proxy/factory"
"github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
"github.com/portainer/portainer/api/kubernetes/cli"
cmap "github.com/orcaman/concurrent-map"
)
var ErrProxyFactoryNotInitialized = errors.New("proxy factory not initialized")
// Manager represents a service used to manage proxies to environments (endpoints) and extensions.
type Manager struct {
proxyFactory *factory.ProxyFactory
endpointProxies cmap.ConcurrentMap
k8sClientFactory *cli.ClientFactory
}
// NewManager initializes a new proxy Service
func NewManager(kubernetesClientFactory *cli.ClientFactory) *Manager {
return &Manager{
endpointProxies: cmap.New(),
k8sClientFactory: kubernetesClientFactory,
}
}
func (manager *Manager) NewProxyFactory(dataStore dataservices.DataStore, signatureService portainer.DigitalSignatureService, tunnelService portainer.ReverseTunnelService, clientFactory *dockerclient.ClientFactory, kubernetesClientFactory *cli.ClientFactory, kubernetesTokenCacheManager *kubernetes.TokenCacheManager, gitService portainer.GitService, snapshotService portainer.SnapshotService, jwtService portainer.JWTService) {
manager.proxyFactory = factory.NewProxyFactory(dataStore, signatureService, tunnelService, clientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService, snapshotService, jwtService)
}
// CreateAndRegisterEndpointProxy creates a new HTTP reverse proxy based on environment(endpoint) properties and adds it to the registered proxies.
// It can also be used to create a new HTTP reverse proxy and replace an already registered proxy.
func (manager *Manager) CreateAndRegisterEndpointProxy(endpoint *portainer.Endpoint) (http.Handler, error) {
if manager.proxyFactory == nil {
return nil, ErrProxyFactoryNotInitialized
}
proxy, err := manager.proxyFactory.NewEndpointProxy(endpoint)
if err != nil {
return nil, err
}
manager.endpointProxies.Set(fmt.Sprint(endpoint.ID), proxy)
return proxy, nil
}
// CreateAgentProxyServer creates a new HTTP reverse proxy based on environment(endpoint) properties and and adds it to the registered proxies.
// It can also be used to create a new HTTP reverse proxy and replace an already registered proxy.
func (manager *Manager) CreateAgentProxyServer(endpoint *portainer.Endpoint) (*factory.ProxyServer, error) {
if manager.proxyFactory == nil {
return nil, ErrProxyFactoryNotInitialized
}
return manager.proxyFactory.NewAgentProxy(endpoint)
}
// GetEndpointProxy returns the proxy associated to a key
func (manager *Manager) GetEndpointProxy(endpoint *portainer.Endpoint) http.Handler {
proxy, ok := manager.endpointProxies.Get(fmt.Sprint(endpoint.ID))
if !ok {
return nil
}
return proxy.(http.Handler)
}
// DeleteEndpointProxy deletes the proxy associated to a key
// and cleans the k8s environment(endpoint) client cache. DeleteEndpointProxy
// is currently only called for edge connection clean up and when endpoint is updated
func (manager *Manager) DeleteEndpointProxy(endpointID portainer.EndpointID) {
manager.endpointProxies.Remove(fmt.Sprint(endpointID))
if manager.k8sClientFactory != nil {
manager.k8sClientFactory.RemoveKubeClient(endpointID)
}
}
// CreateGitlabProxy creates a new HTTP reverse proxy that can be used to send requests to the Gitlab API
func (manager *Manager) CreateGitlabProxy(url string) (http.Handler, error) {
if manager.proxyFactory == nil {
return nil, ErrProxyFactoryNotInitialized
}
return manager.proxyFactory.NewGitlabProxy(url)
}