chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(®ularUser)
|
||||
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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(®istry, accessContext.user, accessContext.teamMemberships, accessContext.endpointID)) {
|
||||
matchingRegistry = ®istry
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 := ®istryAccessContext{
|
||||
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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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, ®istry)
|
||||
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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user