mirror of
https://github.com/portainer/portainer.git
synced 2025-07-29 18:29:44 +02:00
fix(transport): portainer generated kubeconfig causes kubectl exec fail [R8S-430] (#929)
This commit is contained in:
parent
bba3751268
commit
bdb2e2f417
12 changed files with 417 additions and 25 deletions
|
@ -451,7 +451,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||||
|
|
||||||
snapshotService.Start()
|
snapshotService.Start()
|
||||||
|
|
||||||
proxyManager.NewProxyFactory(dataStore, signatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService, snapshotService)
|
proxyManager.NewProxyFactory(dataStore, signatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService, snapshotService, jwtService)
|
||||||
|
|
||||||
helmPackageManager, err := initHelmPackageManager()
|
helmPackageManager, err := initHelmPackageManager()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -22,7 +22,7 @@ func TestEndpointDeleteEdgeGroupsConcurrently(t *testing.T) {
|
||||||
handler := NewHandler(testhelpers.NewTestRequestBouncer())
|
handler := NewHandler(testhelpers.NewTestRequestBouncer())
|
||||||
handler.DataStore = store
|
handler.DataStore = store
|
||||||
handler.ProxyManager = proxy.NewManager(nil)
|
handler.ProxyManager = proxy.NewManager(nil)
|
||||||
handler.ProxyManager.NewProxyFactory(nil, nil, nil, nil, nil, nil, nil, nil)
|
handler.ProxyManager.NewProxyFactory(nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
// Create all the environments and add them to the same edge group
|
// Create all the environments and add them to the same edge group
|
||||||
|
|
||||||
|
|
|
@ -24,11 +24,12 @@ type (
|
||||||
kubernetesTokenCacheManager *kubernetes.TokenCacheManager
|
kubernetesTokenCacheManager *kubernetes.TokenCacheManager
|
||||||
gitService portainer.GitService
|
gitService portainer.GitService
|
||||||
snapshotService portainer.SnapshotService
|
snapshotService portainer.SnapshotService
|
||||||
|
jwtService portainer.JWTService
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewProxyFactory returns a pointer to a new instance of a ProxyFactory
|
// 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) *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{
|
return &ProxyFactory{
|
||||||
dataStore: dataStore,
|
dataStore: dataStore,
|
||||||
signatureService: signatureService,
|
signatureService: signatureService,
|
||||||
|
@ -38,6 +39,7 @@ func NewProxyFactory(dataStore dataservices.DataStore, signatureService portaine
|
||||||
kubernetesTokenCacheManager: kubernetesTokenCacheManager,
|
kubernetesTokenCacheManager: kubernetesTokenCacheManager,
|
||||||
gitService: gitService,
|
gitService: gitService,
|
||||||
snapshotService: snapshotService,
|
snapshotService: snapshotService,
|
||||||
|
jwtService: jwtService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -38,7 +38,7 @@ func (factory *ProxyFactory) newKubernetesLocalProxy(endpoint *portainer.Endpoin
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
transport, err := kubernetes.NewLocalTransport(tokenManager, endpoint, factory.kubernetesClientFactory, factory.dataStore)
|
transport, err := kubernetes.NewLocalTransport(tokenManager, endpoint, factory.kubernetesClientFactory, factory.dataStore, factory.jwtService)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -74,7 +74,7 @@ func (factory *ProxyFactory) newKubernetesEdgeHTTPProxy(endpoint *portainer.Endp
|
||||||
|
|
||||||
endpointURL.Scheme = "http"
|
endpointURL.Scheme = "http"
|
||||||
proxy := NewSingleHostReverseProxyWithHostHeader(endpointURL)
|
proxy := NewSingleHostReverseProxyWithHostHeader(endpointURL)
|
||||||
proxy.Transport = kubernetes.NewEdgeTransport(factory.dataStore, factory.signatureService, factory.reverseTunnelService, endpoint, tokenManager, factory.kubernetesClientFactory)
|
proxy.Transport = kubernetes.NewEdgeTransport(factory.dataStore, factory.signatureService, factory.reverseTunnelService, endpoint, tokenManager, factory.kubernetesClientFactory, factory.jwtService)
|
||||||
|
|
||||||
return proxy, nil
|
return proxy, nil
|
||||||
}
|
}
|
||||||
|
@ -105,7 +105,7 @@ func (factory *ProxyFactory) newKubernetesAgentHTTPSProxy(endpoint *portainer.En
|
||||||
}
|
}
|
||||||
|
|
||||||
proxy := NewSingleHostReverseProxyWithHostHeader(remoteURL)
|
proxy := NewSingleHostReverseProxyWithHostHeader(remoteURL)
|
||||||
proxy.Transport = kubernetes.NewAgentTransport(factory.signatureService, tlsConfig, tokenManager, endpoint, factory.kubernetesClientFactory, factory.dataStore)
|
proxy.Transport = kubernetes.NewAgentTransport(factory.signatureService, tlsConfig, tokenManager, endpoint, factory.kubernetesClientFactory, factory.dataStore, factory.jwtService)
|
||||||
|
|
||||||
return proxy, nil
|
return proxy, nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,7 +16,7 @@ type agentTransport struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAgentTransport returns a new transport that can be used to send signed requests to a Portainer agent
|
// NewAgentTransport returns a new transport that can be used to send signed requests to a Portainer agent
|
||||||
func NewAgentTransport(signatureService portainer.DigitalSignatureService, tlsConfig *tls.Config, tokenManager *tokenManager, endpoint *portainer.Endpoint, k8sClientFactory *cli.ClientFactory, dataStore dataservices.DataStore) *agentTransport {
|
func NewAgentTransport(signatureService portainer.DigitalSignatureService, tlsConfig *tls.Config, tokenManager *tokenManager, endpoint *portainer.Endpoint, k8sClientFactory *cli.ClientFactory, dataStore dataservices.DataStore, jwtService portainer.JWTService) *agentTransport {
|
||||||
transport := &agentTransport{
|
transport := &agentTransport{
|
||||||
baseTransport: newBaseTransport(
|
baseTransport: newBaseTransport(
|
||||||
&http.Transport{
|
&http.Transport{
|
||||||
|
@ -26,6 +26,7 @@ func NewAgentTransport(signatureService portainer.DigitalSignatureService, tlsCo
|
||||||
endpoint,
|
endpoint,
|
||||||
k8sClientFactory,
|
k8sClientFactory,
|
||||||
dataStore,
|
dataStore,
|
||||||
|
jwtService,
|
||||||
),
|
),
|
||||||
signatureService: signatureService,
|
signatureService: signatureService,
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,7 +16,7 @@ type edgeTransport struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAgentTransport returns a new transport that can be used to send signed requests to a Portainer Edge agent
|
// 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) *edgeTransport {
|
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{
|
transport := &edgeTransport{
|
||||||
reverseTunnelService: reverseTunnelService,
|
reverseTunnelService: reverseTunnelService,
|
||||||
signatureService: signatureService,
|
signatureService: signatureService,
|
||||||
|
@ -26,6 +26,7 @@ func NewEdgeTransport(dataStore dataservices.DataStore, signatureService portain
|
||||||
endpoint,
|
endpoint,
|
||||||
k8sClientFactory,
|
k8sClientFactory,
|
||||||
dataStore,
|
dataStore,
|
||||||
|
jwtService,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -14,7 +14,7 @@ type localTransport struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLocalTransport returns a new transport that can be used to send requests to the local Kubernetes API
|
// 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) (*localTransport, error) {
|
func NewLocalTransport(tokenManager *tokenManager, endpoint *portainer.Endpoint, k8sClientFactory *cli.ClientFactory, dataStore dataservices.DataStore, jwtService portainer.JWTService) (*localTransport, error) {
|
||||||
config, err := crypto.CreateTLSConfigurationFromBytes(nil, nil, nil, true, true)
|
config, err := crypto.CreateTLSConfigurationFromBytes(nil, nil, nil, true, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -29,6 +29,7 @@ func NewLocalTransport(tokenManager *tokenManager, endpoint *portainer.Endpoint,
|
||||||
endpoint,
|
endpoint,
|
||||||
k8sClientFactory,
|
k8sClientFactory,
|
||||||
dataStore,
|
dataStore,
|
||||||
|
jwtService,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,12 +2,18 @@ package kubernetes
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (transport *baseTransport) proxyPodsRequest(request *http.Request, namespace, requestPath string) (*http.Response, error) {
|
func (transport *baseTransport) proxyPodsRequest(request *http.Request, namespace string) (*http.Response, error) {
|
||||||
if request.Method == http.MethodDelete {
|
if request.Method == http.MethodDelete {
|
||||||
transport.refreshRegistry(request, namespace)
|
transport.refreshRegistry(request, namespace)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
return transport.executeKubernetesRequest(request)
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,15 +26,17 @@ type baseTransport struct {
|
||||||
endpoint *portainer.Endpoint
|
endpoint *portainer.Endpoint
|
||||||
k8sClientFactory *cli.ClientFactory
|
k8sClientFactory *cli.ClientFactory
|
||||||
dataStore dataservices.DataStore
|
dataStore dataservices.DataStore
|
||||||
|
jwtService portainer.JWTService
|
||||||
}
|
}
|
||||||
|
|
||||||
func newBaseTransport(httpTransport *http.Transport, tokenManager *tokenManager, endpoint *portainer.Endpoint, k8sClientFactory *cli.ClientFactory, dataStore dataservices.DataStore) *baseTransport {
|
func newBaseTransport(httpTransport *http.Transport, tokenManager *tokenManager, endpoint *portainer.Endpoint, k8sClientFactory *cli.ClientFactory, dataStore dataservices.DataStore, jwtService portainer.JWTService) *baseTransport {
|
||||||
return &baseTransport{
|
return &baseTransport{
|
||||||
httpTransport: httpTransport,
|
httpTransport: httpTransport,
|
||||||
tokenManager: tokenManager,
|
tokenManager: tokenManager,
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
k8sClientFactory: k8sClientFactory,
|
k8sClientFactory: k8sClientFactory,
|
||||||
dataStore: dataStore,
|
dataStore: dataStore,
|
||||||
|
jwtService: jwtService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -82,7 +84,7 @@ func (transport *baseTransport) proxyNamespacedRequest(request *http.Request, fu
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(requestPath, "pods"):
|
case strings.HasPrefix(requestPath, "pods"):
|
||||||
return transport.proxyPodsRequest(request, namespace, requestPath)
|
return transport.proxyPodsRequest(request, namespace)
|
||||||
case strings.HasPrefix(requestPath, "deployments"):
|
case strings.HasPrefix(requestPath, "deployments"):
|
||||||
return transport.proxyDeploymentsRequest(request, namespace, requestPath)
|
return transport.proxyDeploymentsRequest(request, namespace, requestPath)
|
||||||
case requestPath == "" && request.Method == "DELETE":
|
case requestPath == "" && request.Method == "DELETE":
|
||||||
|
@ -92,6 +94,23 @@ func (transport *baseTransport) proxyNamespacedRequest(request *http.Request, fu
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
func (transport *baseTransport) executeKubernetesRequest(request *http.Request) (*http.Response, error) {
|
||||||
|
|
||||||
resp, err := transport.httpTransport.RoundTrip(request)
|
resp, err := transport.httpTransport.RoundTrip(request)
|
||||||
|
|
359
api/http/proxy/factory/kubernetes/transport_test.go
Normal file
359
api/http/proxy/factory/kubernetes/transport_test.go
Normal file
|
@ -0,0 +1,359 @@
|
||||||
|
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) {
|
||||||
|
// 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)
|
||||||
|
assert.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)
|
||||||
|
assert.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)
|
||||||
|
assert.Greater(t, len(token), 0, "Token should not be empty")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
assert.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 {
|
||||||
|
assert.Error(t, err)
|
||||||
|
if tt.errorMsg != "" {
|
||||||
|
assert.Contains(t, err.Error(), tt.errorMsg)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
if tt.verifyResponse != nil {
|
||||||
|
tt.verifyResponse(t, request, tt.tokenData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBaseTransport_AddTokenForExec_Integration(t *testing.T) {
|
||||||
|
// 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.WriteHeader(http.StatusOK)
|
||||||
|
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 resp.Body.Close()
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -9,17 +9,20 @@ import (
|
||||||
|
|
||||||
// Note that we discard any non-canonical headers by design
|
// Note that we discard any non-canonical headers by design
|
||||||
var allowedHeaders = map[string]struct{}{
|
var allowedHeaders = map[string]struct{}{
|
||||||
"Accept": {},
|
"Accept": {},
|
||||||
"Accept-Encoding": {},
|
"Accept-Encoding": {},
|
||||||
"Accept-Language": {},
|
"Accept-Language": {},
|
||||||
"Cache-Control": {},
|
"Cache-Control": {},
|
||||||
"Content-Length": {},
|
"Connection": {},
|
||||||
"Content-Type": {},
|
"Content-Length": {},
|
||||||
"Private-Token": {},
|
"Content-Type": {},
|
||||||
"User-Agent": {},
|
"Private-Token": {},
|
||||||
"X-Portaineragent-Target": {},
|
"Upgrade": {},
|
||||||
"X-Portainer-Volumename": {},
|
"User-Agent": {},
|
||||||
"X-Registry-Auth": {},
|
"X-Portaineragent-Target": {},
|
||||||
|
"X-Portainer-Volumename": {},
|
||||||
|
"X-Registry-Auth": {},
|
||||||
|
"X-Stream-Protocol-Version": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
// newSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy
|
// newSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy
|
||||||
|
|
|
@ -32,8 +32,8 @@ func NewManager(kubernetesClientFactory *cli.ClientFactory) *Manager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
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)
|
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.
|
// CreateAndRegisterEndpointProxy creates a new HTTP reverse proxy based on environment(endpoint) properties and adds it to the registered proxies.
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue