chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:08:39 +08:00
commit a0df89c693
5252 changed files with 523444 additions and 0 deletions
+139
View File
@@ -0,0 +1,139 @@
package websocket
import (
"net"
"net/http"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/logs"
"github.com/portainer/portainer/api/ws"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/validate"
"github.com/rs/zerolog/log"
"github.com/gorilla/websocket"
)
// @summary Attach a websocket
// @description If the nodeName query parameter is present, the request will be proxied to the underlying agent environment(endpoint).
// @description If the nodeName query parameter is not specified, the request will be upgraded to the websocket protocol and
// @description an AttachStart operation HTTP request will be created and hijacked.
// @description **Access policy**: authenticated
// @security ApiKeyAuth
// @security jwt
// @tags websocket
// @accept json
// @produce json
// @param endpointId query int true "environment(endpoint) ID of the environment(endpoint) where the resource is located"
// @param nodeName query string false "node name"
// @success 200
// @failure 400
// @failure 403
// @failure 404
// @failure 500
// @router /websocket/attach [get]
func (handler *Handler) websocketAttach(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
attachID, err := request.RetrieveQueryParameter(r, "id", false)
if err != nil {
return httperror.BadRequest("Invalid query parameter: id", err)
}
if !validate.IsHexadecimal(attachID) {
return httperror.BadRequest("Invalid query parameter: id (must be hexadecimal identifier)", err)
}
endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false)
if err != nil {
return httperror.BadRequest("Invalid query parameter: endpointId", err)
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if handler.DataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find the environment associated to the stack inside the database", err)
} else if err != nil {
return httperror.InternalServerError("Unable to find the environment associated to the stack inside the database", err)
}
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
if err != nil {
return httperror.Forbidden("Permission denied to access environment", err)
}
params := &webSocketRequestParams{
endpoint: endpoint,
ID: attachID,
nodeName: r.FormValue("nodeName"),
}
err = handler.handleAttachRequest(w, r, params)
if err != nil {
return httperror.InternalServerError("An error occurred during websocket attach operation", err)
}
return nil
}
func (handler *Handler) handleAttachRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error {
r.Header.Del("Origin")
if params.endpoint.Type == portainer.AgentOnDockerEnvironment {
return handler.proxyAgentWebsocketRequest(w, r, params)
} else if params.endpoint.Type == portainer.EdgeAgentOnDockerEnvironment {
return handler.proxyEdgeAgentWebsocketRequest(w, r, params)
}
websocketConn, err := handler.connectionUpgrader.Upgrade(w, r, nil)
if err != nil {
return err
}
defer logs.CloseAndLogErr(websocketConn)
return hijackAttachStartOperation(websocketConn, params.endpoint, params.ID)
}
func hijackAttachStartOperation(
websocketConn *websocket.Conn,
endpoint *portainer.Endpoint,
attachID string,
) error {
conn, err := initDial(endpoint)
if err != nil {
return err
}
// When we set up a TCP connection for hijack, there could be long periods
// of inactivity (a long running command with no output) that in certain
// network setups may cause ECONNTIMEOUT, leaving the client in an unknown
// state. Setting TCP KeepAlive on the socket connection will prohibit
// ECONNTIMEOUT unless the socket connection truly is broken
if tcpConn, ok := conn.(*net.TCPConn); ok {
if err := tcpConn.SetKeepAlive(true); err != nil {
log.Warn().Err(err).Msg("failed to set TCP keep-alive on connection")
}
if err := tcpConn.SetKeepAlivePeriod(30 * time.Second); err != nil {
log.Warn().Err(err).Msg("failed to set TCP keep-alive period on connection")
}
}
attachStartRequest, err := createAttachStartRequest(attachID)
if err != nil {
return err
}
return ws.HijackRequest(websocketConn, conn, attachStartRequest)
}
func createAttachStartRequest(attachID string) (*http.Request, error) {
request, err := http.NewRequest("POST", "/containers/"+attachID+"/attach?stdin=1&stdout=1&stderr=1&stream=1", nil)
if err != nil {
return nil, err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Connection", "Upgrade")
request.Header.Set("Upgrade", "tcp")
return request, nil
}
+34
View File
@@ -0,0 +1,34 @@
package websocket
import (
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestWebsocketAttach_deniesUnauthorizedEndpoint asserts a non-admin with no access policy on
// the environment is rejected with 403 — the environment-access (L2) gate (BE-13027).
func TestWebsocketAttach_deniesUnauthorizedEndpoint(t *testing.T) {
handler, _ := newWebsocketTestHandler(t)
user := &portainer.User{Username: "restricted", Role: portainer.StandardUserRole}
err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.User().Create(user)
})
require.NoError(t, err)
// attach requires a hexadecimal `id` query parameter to reach the authorization check.
req := httptest.NewRequest(http.MethodGet, "/websocket/attach?id=abcdef&endpointId=2", nil)
req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: user.ID, Role: portainer.StandardUserRole}))
handlerErr := handler.websocketAttach(httptest.NewRecorder(), req)
require.NotNil(t, handlerErr, "expected an authorization error for a denied environment")
assert.Equal(t, http.StatusForbidden, handlerErr.StatusCode)
}
+11
View File
@@ -0,0 +1,11 @@
//go:build !windows
package websocket
import (
"net"
)
func createDial(scheme, host string) (net.Conn, error) {
return net.Dial(scheme, host)
}
@@ -0,0 +1,16 @@
//go:build windows
package websocket
import (
"net"
"github.com/Microsoft/go-winio"
)
func createDial(scheme, host string) (net.Conn, error) {
if scheme == "npipe" {
return winio.DialPipe(host, nil)
}
return net.Dial(scheme, host)
}
+139
View File
@@ -0,0 +1,139 @@
package websocket
import (
"bytes"
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/logs"
"github.com/portainer/portainer/api/ws"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/validate"
"github.com/gorilla/websocket"
"github.com/segmentio/encoding/json"
)
type execStartOperationPayload struct {
Tty bool
Detach bool
}
// @summary Execute a websocket
// @description If the nodeName query parameter is present, the request will be proxied to the underlying agent environment(endpoint).
// @description If the nodeName query parameter is not specified, the request will be upgraded to the websocket protocol and
// @description an ExecStart operation HTTP request will be created and hijacked.
// @**Access policy**: authenticated
// @security ApiKeyAuth
// @security jwt
// @tags websocket
// @accept json
// @produce json
// @param endpointId query int true "environment(endpoint) ID of the environment(endpoint) where the resource is located"
// @param nodeName query string false "node name"
// @success 200
// @failure 400
// @failure 409
// @failure 500
// @router /websocket/exec [get]
func (handler *Handler) websocketExec(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
execID, err := request.RetrieveQueryParameter(r, "id", false)
if err != nil {
return httperror.BadRequest("Invalid query parameter: id", err)
}
if !validate.IsHexadecimal(execID) {
return httperror.BadRequest("Invalid query parameter: id (must be hexadecimal identifier)", err)
}
endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false)
if err != nil {
return httperror.BadRequest("Invalid query parameter: endpointId", err)
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if handler.DataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find the environment associated to the stack inside the database", err)
} else if err != nil {
return httperror.InternalServerError("Unable to find the environment associated to the stack inside the database", err)
}
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
if err != nil {
return httperror.Forbidden("Permission denied to access environment", err)
}
params := &webSocketRequestParams{
endpoint: endpoint,
ID: execID,
nodeName: r.FormValue("nodeName"),
}
err = handler.handleExecRequest(w, r, params)
if err != nil {
return httperror.InternalServerError("An error occurred during websocket exec operation", err)
}
return nil
}
func (handler *Handler) handleExecRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error {
r.Header.Del("Origin")
if params.endpoint.Type == portainer.AgentOnDockerEnvironment {
return handler.proxyAgentWebsocketRequest(w, r, params)
} else if params.endpoint.Type == portainer.EdgeAgentOnDockerEnvironment {
return handler.proxyEdgeAgentWebsocketRequest(w, r, params)
}
websocketConn, err := handler.connectionUpgrader.Upgrade(w, r, nil)
if err != nil {
return err
}
defer logs.CloseAndLogErr(websocketConn)
return hijackExecStartOperation(websocketConn, params.endpoint, params.ID)
}
func hijackExecStartOperation(
websocketConn *websocket.Conn,
endpoint *portainer.Endpoint,
execID string,
) error {
conn, err := initDial(endpoint)
if err != nil {
return err
}
execStartRequest, err := createExecStartRequest(execID)
if err != nil {
return err
}
return ws.HijackRequest(websocketConn, conn, execStartRequest)
}
func createExecStartRequest(execID string) (*http.Request, error) {
execStartOperationPayload := &execStartOperationPayload{
Tty: true,
Detach: false,
}
encodedBody := bytes.NewBuffer(nil)
err := json.NewEncoder(encodedBody).Encode(execStartOperationPayload)
if err != nil {
return nil, err
}
request, err := http.NewRequest("POST", "/exec/"+execID+"/start", encodedBody)
if err != nil {
return nil, err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Connection", "Upgrade")
request.Header.Set("Upgrade", "tcp")
return request, nil
}
+34
View File
@@ -0,0 +1,34 @@
package websocket
import (
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestWebsocketExec_deniesUnauthorizedEndpoint asserts a non-admin with no access policy on
// the environment is rejected with 403 — the environment-access (L2) gate (BE-13027).
func TestWebsocketExec_deniesUnauthorizedEndpoint(t *testing.T) {
handler, _ := newWebsocketTestHandler(t)
user := &portainer.User{Username: "restricted", Role: portainer.StandardUserRole}
err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.User().Create(user)
})
require.NoError(t, err)
// exec requires a hexadecimal `id` query parameter to reach the authorization check.
req := httptest.NewRequest(http.MethodGet, "/websocket/exec?id=abcdef&endpointId=2", nil)
req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: user.ID, Role: portainer.StandardUserRole}))
handlerErr := handler.websocketExec(httptest.NewRecorder(), req)
require.NotNil(t, handlerErr, "expected an authorization error for a denied environment")
assert.Equal(t, http.StatusForbidden, handlerErr.StatusCode)
}
+44
View File
@@ -0,0 +1,44 @@
package websocket
import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/kubernetes/cli"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/gorilla/mux"
"github.com/gorilla/websocket"
)
// Handler is the HTTP handler used to handle websocket operations.
type Handler struct {
*mux.Router
DataStore dataservices.DataStore
SignatureService portainer.DigitalSignatureService
ReverseTunnelService portainer.ReverseTunnelService
KubernetesClientFactory *cli.ClientFactory
requestBouncer security.BouncerService
connectionUpgrader websocket.Upgrader
kubernetesTokenCacheManager *kubernetes.TokenCacheManager
}
// NewHandler creates a handler to manage websocket operations.
func NewHandler(kubernetesTokenCacheManager *kubernetes.TokenCacheManager, bouncer security.BouncerService) *Handler {
h := &Handler{
Router: mux.NewRouter(),
connectionUpgrader: websocket.Upgrader{},
requestBouncer: bouncer,
kubernetesTokenCacheManager: kubernetesTokenCacheManager,
}
h.PathPrefix("/websocket/exec").Handler(
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.websocketExec)))
h.PathPrefix("/websocket/attach").Handler(
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.websocketAttach)))
h.PathPrefix("/websocket/pod").Handler(
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.websocketPodExec)))
h.PathPrefix("/websocket/kubernetes-shell").Handler(
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.websocketShellPodExec)))
return h
}
+34
View File
@@ -0,0 +1,34 @@
package websocket
import (
"crypto/tls"
"net"
"net/url"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/crypto"
)
func initDial(endpoint *portainer.Endpoint) (net.Conn, error) {
url, err := url.Parse(endpoint.URL)
if err != nil {
return nil, err
}
host := url.Host
if url.Scheme == "unix" || url.Scheme == "npipe" {
host = url.Path
}
if !endpoint.TLSConfig.TLS {
return createDial(url.Scheme, host)
}
tlsConfig, err := crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig)
if err != nil {
return nil, err
}
return tls.Dial(url.Scheme, host, tlsConfig)
}
@@ -0,0 +1,64 @@
package websocket
import (
"net/http"
"net/http/httptest"
"net/url"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/pkg/fips"
"github.com/stretchr/testify/require"
)
func TestInitDial(t *testing.T) {
t.Parallel()
fips.InitFIPS(false)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
defer srv.Close()
tlsSrv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
defer tlsSrv.Close()
f := func(srvURL string) {
u, err := url.Parse(srvURL)
require.NoError(t, err)
isTLS := u.Scheme == "https"
u.Scheme = "tcp"
endpoint := &portainer.Endpoint{
URL: u.String(),
TLSConfig: portainer.TLSConfiguration{
TLS: isTLS,
TLSSkipVerify: true,
},
}
// Valid configuration
conn, err := initDial(endpoint)
require.NoError(t, err)
require.NotNil(t, conn)
err = conn.Close()
require.NoError(t, err)
if !isTLS {
return
}
// Invalid TLS configuration
endpoint.TLSConfig.TLSCertPath = "/invalid/path/client.crt"
endpoint.TLSConfig.TLSKeyPath = "/invalid/path/client.key"
conn, err = initDial(endpoint)
require.Error(t, err)
require.Nil(t, conn)
}
f(srv.URL)
f(tlsSrv.URL)
}
+214
View File
@@ -0,0 +1,214 @@
package websocket
import (
"errors"
"io"
"net/http"
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
"github.com/portainer/portainer/api/http/security"
kubecli "github.com/portainer/portainer/api/kubernetes/cli"
"github.com/portainer/portainer/api/logs"
"github.com/portainer/portainer/api/ws"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/gorilla/websocket"
"github.com/rs/zerolog/log"
)
// @summary Execute a websocket on pod
// @description The request will be upgraded to the websocket protocol.
// @description **Access policy**: authenticated
// @security ApiKeyAuth
// @security jwt
// @tags websocket
// @accept json
// @produce json
// @param endpointId query int true "environment(endpoint) ID of the environment(endpoint) where the resource is located"
// @param namespace query string true "namespace where the container is located"
// @param podName query string true "name of the pod containing the container"
// @param containerName query string true "name of the container"
// @param command query string true "command to execute in the container"
// @success 200
// @failure 400
// @failure 403
// @failure 404
// @failure 500
// @router /websocket/pod [get]
func (handler *Handler) websocketPodExec(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false)
if err != nil {
return httperror.BadRequest("Invalid query parameter: endpointId", err)
}
namespace, err := request.RetrieveQueryParameter(r, "namespace", false)
if err != nil {
return httperror.BadRequest("Invalid query parameter: namespace", err)
}
podName, err := request.RetrieveQueryParameter(r, "podName", false)
if err != nil {
return httperror.BadRequest("Invalid query parameter: podName", err)
}
containerName, err := request.RetrieveQueryParameter(r, "containerName", false)
if err != nil {
return httperror.BadRequest("Invalid query parameter: containerName", err)
}
command, err := request.RetrieveQueryParameter(r, "command", false)
if err != nil {
return httperror.BadRequest("Invalid query parameter: command", err)
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if handler.DataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find the environment associated to the stack inside the database", err)
} else if err != nil {
return httperror.InternalServerError("Unable to find the environment associated to the stack inside the database", err)
}
if err := handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint); err != nil {
return httperror.Forbidden("Permission denied to access environment", err)
}
serviceAccountToken, isAdminToken, err := handler.getToken(r, endpoint, false)
if err != nil {
return httperror.InternalServerError("Unable to get user service account token", err)
}
params := &webSocketRequestParams{
endpoint: endpoint,
token: serviceAccountToken,
}
r.Header.Del("Origin")
if endpoint.Type == portainer.AgentOnKubernetesEnvironment {
if err := handler.proxyAgentWebsocketRequest(w, r, params); err != nil {
return httperror.InternalServerError("Unable to proxy websocket request to agent", err)
}
return nil
} else if endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment {
if err := handler.proxyEdgeAgentWebsocketRequest(w, r, params); err != nil {
return httperror.InternalServerError("Unable to proxy websocket request to Edge agent", err)
}
return nil
}
cli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint)
if err != nil {
return httperror.InternalServerError("Unable to create Kubernetes client", err)
}
handlerErr := handler.hijackPodExecStartOperation(w, r, cli, serviceAccountToken, isAdminToken, endpoint, namespace, podName, containerName, command)
if handlerErr != nil {
return handlerErr
}
return nil
}
func (handler *Handler) hijackPodExecStartOperation(
w http.ResponseWriter,
r *http.Request,
cli portainer.KubeClient,
serviceAccountToken string,
isAdminToken bool,
endpoint *portainer.Endpoint,
namespace, podName, containerName, command string,
) *httperror.HandlerError {
commandArray := strings.Split(command, " ")
websocketConn, err := handler.connectionUpgrader.Upgrade(w, r, nil)
if err != nil {
return httperror.InternalServerError("Unable to upgrade the connection", err)
}
defer logs.CloseAndLogErr(websocketConn)
stdinReader, stdinWriter := io.Pipe()
defer logs.CloseAndLogErr(stdinWriter)
stdoutReader, stdoutWriter := io.Pipe()
defer logs.CloseAndLogErr(stdoutWriter)
// errorChan is used to propagate errors from the go routines to the caller.
errorChan := make(chan error, 3)
sizeQueue := kubecli.NewTerminalSizeQueue()
defer sizeQueue.Close()
go ws.StreamFromWebsocketToWriter(websocketConn, stdinWriter, errorChan, ws.ResizeHandler(sizeQueue))
go ws.StreamFromReaderToWebsocket(websocketConn, stdoutReader, errorChan)
// StartExecProcess is a blocking operation which streams IO to/from pod;
// this must execute in asynchronously, since the websocketConn could return errors (e.g. client disconnects) before
// the blocking operation is completed.
go cli.StartExecProcess(portainer.KubeExecParams{
Token: serviceAccountToken,
UseAdminToken: isAdminToken,
Namespace: namespace,
PodName: podName,
ContainerName: containerName,
Command: commandArray,
Stdin: stdinReader,
Stdout: stdoutWriter,
ResizeQueue: sizeQueue,
ErrChan: errorChan,
})
err = <-errorChan
if err == nil || errors.Is(err, io.EOF) {
// exec process ended normally (shell exited) - send a clean close frame to the browser
_ = websocketConn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
return nil
}
// websocket client successfully disconnected
if websocket.IsUnexpectedCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) {
log.Debug().Err(err).Msg("websocket error")
return nil
}
return httperror.InternalServerError("Unable to start exec process inside container", err)
}
func (handler *Handler) getToken(request *http.Request, endpoint *portainer.Endpoint, setLocalAdminToken bool) (string, bool, error) {
tokenData, err := security.RetrieveTokenData(request)
if err != nil {
return "", false, err
}
kubecli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint)
if err != nil {
return "", false, err
}
tokenCache := handler.kubernetesTokenCacheManager.GetOrCreateTokenCache(endpoint.ID)
tokenManager, err := kubernetes.NewTokenManager(kubecli, handler.DataStore, tokenCache, setLocalAdminToken)
if err != nil {
return "", false, err
}
if tokenData.Role == portainer.AdministratorRole {
return tokenManager.GetAdminServiceAccountToken(), true, nil
}
token, err := tokenManager.GetUserServiceAccountToken(int(tokenData.ID), endpoint.ID)
if err != nil {
return "", false, err
}
if token == "" {
return "", false, errors.New("can not get a valid user service account token")
}
return token, false, nil
}
+62
View File
@@ -0,0 +1,62 @@
package websocket
import (
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// podExecQuery is the minimal set of query parameters required to reach the authorization
// check in websocketPodExec.
const podExecQuery = "/websocket/pod?endpointId=2&namespace=default&podName=p&containerName=c&command=sh"
// TestWebsocketPodExec_deniesUnauthorizedEndpoint asserts a non-admin with no access policy on
// the environment is rejected with 403 — the environment-access (L2) gate (BE-13027).
func TestWebsocketPodExec_deniesUnauthorizedEndpoint(t *testing.T) {
handler, _ := newWebsocketTestHandler(t)
user := &portainer.User{Username: "restricted", Role: portainer.StandardUserRole}
err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.User().Create(user)
})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, podExecQuery, nil)
req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: user.ID, Role: portainer.StandardUserRole}))
handlerErr := handler.websocketPodExec(httptest.NewRecorder(), req)
require.NotNil(t, handlerErr, "expected an authorization error for a denied environment")
assert.Equal(t, http.StatusForbidden, handlerErr.StatusCode)
}
// TestWebsocketPodExec_allowsAuthorizedNonAdmin asserts a non-admin granted environment access
// passes authorization (reaching the nil client via getToken and panicking). CE has no
// operation-level (L3) layer, so environment access is the only gate (BE-13027).
func TestWebsocketPodExec_allowsAuthorizedNonAdmin(t *testing.T) {
handler, endpoint := newWebsocketTestHandler(t)
user := &portainer.User{Username: "standard", Role: portainer.StandardUserRole}
err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
if err := tx.User().Create(user); err != nil {
return err
}
// Access is by membership; the access policy's role is irrelevant to the CE access decision.
endpoint.UserAccessPolicies = portainer.UserAccessPolicies{user.ID: {}}
return tx.Endpoint().UpdateEndpoint(endpoint.ID, endpoint)
})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, podExecQuery, nil)
req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: user.ID, Role: portainer.StandardUserRole}))
assert.Panics(t, func() {
_ = handler.websocketPodExec(httptest.NewRecorder(), req)
})
}
+107
View File
@@ -0,0 +1,107 @@
package websocket
import (
"net"
"net/http"
"net/url"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/crypto"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/logoutcontext"
"github.com/gorilla/websocket"
"github.com/koding/websocketproxy"
"github.com/rs/zerolog/log"
)
func (handler *Handler) proxyEdgeAgentWebsocketRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error {
tunnelAddr, err := handler.ReverseTunnelService.TunnelAddr(params.endpoint)
if err != nil {
return err
}
agentURL, err := url.Parse("http://" + tunnelAddr)
if err != nil {
return err
}
return handler.doProxyWebsocketRequest(w, r, params, agentURL, true)
}
func (handler *Handler) proxyAgentWebsocketRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error {
endpointURL := params.endpoint.URL
if params.endpoint.Type == portainer.AgentOnKubernetesEnvironment {
endpointURL = "http://" + params.endpoint.URL
}
agentURL, err := url.Parse(endpointURL)
if err != nil {
return err
}
return handler.doProxyWebsocketRequest(w, r, params, agentURL, false)
}
func (handler *Handler) doProxyWebsocketRequest(
w http.ResponseWriter,
r *http.Request,
params *webSocketRequestParams,
agentURL *url.URL,
isEdge bool,
) error {
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
log.
Warn().
Err(err).
Msg("unable to retrieve user details from authentication token")
return err
}
enableTLS := !isEdge && (params.endpoint.TLSConfig.TLS || params.endpoint.TLSConfig.TLSSkipVerify)
agentURL.Scheme = "ws"
if enableTLS {
agentURL.Scheme = "wss"
}
proxy := websocketproxy.NewProxy(agentURL)
proxyDialer := *websocket.DefaultDialer
proxy.Dialer = &proxyDialer
if enableTLS {
proxyDialer.TLSClientConfig = crypto.CreateTLSConfiguration(params.endpoint.TLSConfig.TLSSkipVerify)
}
signature, err := handler.SignatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
if err != nil {
return err
}
proxy.Director = func(incoming *http.Request, out http.Header) {
out.Set(portainer.PortainerAgentPublicKeyHeader, handler.SignatureService.EncodedPublicKey())
out.Set(portainer.PortainerAgentSignatureHeader, signature)
out.Set(portainer.PortainerAgentTargetHeader, params.nodeName)
out.Set(portainer.PortainerAgentKubernetesSATokenHeader, params.token)
}
if isEdge {
handler.ReverseTunnelService.UpdateLastActivity(params.endpoint.ID)
handler.ReverseTunnelService.KeepTunnelAlive(params.endpoint.ID, r.Context(), portainer.WebSocketKeepAlive)
}
proxy.Dialer.NetDial = func(network, addr string) (net.Conn, error) {
netDialer := &net.Dialer{}
logoutCtx := logoutcontext.GetContext(tokenData.Token)
conn, err := netDialer.DialContext(logoutCtx, network, addr)
return conn, err
}
proxy.ServeHTTP(w, r)
return nil
}
+122
View File
@@ -0,0 +1,122 @@
package websocket
import (
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
)
// @summary Execute a websocket on kubectl shell pod
// @description The request will be upgraded to the websocket protocol. The request will proxy input from the client to the pod via long-lived websocket connection.
// @description **Access policy**: authenticated
// @security ApiKeyAuth
// @security jwt
// @tags websocket
// @accept json
// @produce json
// @param endpointId query int true "environment(endpoint) ID of the environment(endpoint) where the resource is located"
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied"
// @failure 404 "Environment not found"
// @failure 500 "Server error"
// @router /websocket/kubernetes-shell [get]
func (handler *Handler) websocketShellPodExec(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false)
if err != nil {
return httperror.BadRequest("Invalid query parameter: endpointId", err)
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if handler.DataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find the environment associated to the stack inside the database", err)
} else if err != nil {
return httperror.InternalServerError("Unable to find the environment associated to the stack inside the database", err)
}
if err := handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint); err != nil {
return httperror.Forbidden("Permission denied to access environment", err)
}
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve user authentication token", err)
}
cli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint)
if err != nil {
return httperror.InternalServerError("Unable to create Kubernetes client", err)
}
serviceAccount, err := cli.GetPortainerUserServiceAccount(tokenData)
if err != nil {
return httperror.InternalServerError("Unable to find serviceaccount associated with user", err)
}
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return httperror.InternalServerError("Unable read settings", err)
}
shellPod, err := cli.CreateUserShellPod(r.Context(), serviceAccount.Name, settings.KubectlShellImage)
if err != nil {
return httperror.InternalServerError("Unable to create user shell", err)
}
// Modifying request params mid-flight before forewarding to K8s API server (websocket)
q := r.URL.Query()
q.Add("namespace", shellPod.Namespace)
q.Add("podName", shellPod.PodName)
q.Add("containerName", shellPod.ContainerName)
q.Add("command", shellPod.ShellExecCommand)
r.URL.RawQuery = q.Encode()
// Modify url path mid-flight before forewarding to k8s API server (websocket)
r.URL.Path = "/websocket/pod"
/*
Note: The following websocket proxying logic is duplicated from `api/http/handler/websocket/pod.go`
*/
params := &webSocketRequestParams{
endpoint: endpoint,
}
r.Header.Del("Origin")
if endpoint.Type == portainer.AgentOnKubernetesEnvironment {
err := handler.proxyAgentWebsocketRequest(w, r, params)
if err != nil {
return httperror.InternalServerError("Unable to proxy websocket request to agent", err)
}
return nil
} else if endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment {
err := handler.proxyEdgeAgentWebsocketRequest(w, r, params)
if err != nil {
return httperror.InternalServerError("Unable to proxy websocket request to Edge agent", err)
}
return nil
}
handlerErr := handler.hijackPodExecStartOperation(
w,
r,
cli,
"",
true,
endpoint,
shellPod.Namespace,
shellPod.PodName,
shellPod.ContainerName,
shellPod.ShellExecCommand,
)
if handlerErr != nil {
return handlerErr
}
return nil
}
@@ -0,0 +1,101 @@
package websocket
import (
"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/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// newWebsocketTestHandler builds a websocket Handler backed by a test store with a single
// Kubernetes environment (ID 2) and a real bouncer, returning both the handler and that
// endpoint so callers can grant access via its UserAccessPolicies. KubernetesClientFactory is
// left nil so any handler that proceeds past authorization trips a clear panic. Shared by the
// exec/attach/pod/kubernetes-shell L2 tests (BE-13027).
func newWebsocketTestHandler(t *testing.T) (*Handler, *portainer.Endpoint) {
t.Helper()
_, store := datastore.MustNewTestStore(t, true, false)
endpoint := &portainer.Endpoint{
ID: 2,
Name: "target-env",
Type: portainer.AgentOnKubernetesEnvironment,
GroupID: 1,
}
require.NoError(t, store.Endpoint().Create(endpoint))
bouncer := security.NewRequestBouncer(t.Context(), store, nil, nil)
handler := &Handler{
DataStore: store,
requestBouncer: bouncer,
// KubernetesClientFactory intentionally left nil.
}
return handler, endpoint
}
// TestWebsocketShellPodExec_deniesUnauthorizedEndpoint asserts a non-admin with no access
// policy on the environment is rejected with 403 — the environment-access (L2) gate (BE-13027).
func TestWebsocketShellPodExec_deniesUnauthorizedEndpoint(t *testing.T) {
handler, _ := newWebsocketTestHandler(t)
user := &portainer.User{Username: "restricted", Role: portainer.StandardUserRole}
err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.User().Create(user)
})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/websocket/kubernetes-shell?endpointId=2", nil)
req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: user.ID, Role: portainer.StandardUserRole}))
handlerErr := handler.websocketShellPodExec(httptest.NewRecorder(), req)
require.NotNil(t, handlerErr, "expected an authorization error for a denied environment")
assert.Equal(t, http.StatusForbidden, handlerErr.StatusCode)
}
// TestWebsocketShellPodExec_allowsAuthorizedEndpoint asserts an admin passes authorization and
// reaches the nil KubernetesClientFactory (panic proves auth did not block the request) (BE-13027).
func TestWebsocketShellPodExec_allowsAuthorizedEndpoint(t *testing.T) {
handler, _ := newWebsocketTestHandler(t)
req := httptest.NewRequest(http.MethodGet, "/websocket/kubernetes-shell?endpointId=2", nil)
req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}))
assert.Panics(t, func() {
_ = handler.websocketShellPodExec(httptest.NewRecorder(), req)
})
}
// TestWebsocketShellPodExec_allowsAuthorizedNonAdmin asserts a non-admin granted environment
// access passes authorization (reaching the nil client and panicking). CE has no operation-level
// (L3) layer, so environment access is the only gate (BE-13027).
func TestWebsocketShellPodExec_allowsAuthorizedNonAdmin(t *testing.T) {
handler, endpoint := newWebsocketTestHandler(t)
user := &portainer.User{Username: "standard", Role: portainer.StandardUserRole}
err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
if err := tx.User().Create(user); err != nil {
return err
}
// Access is by membership; the access policy's role is irrelevant to the CE access decision.
endpoint.UserAccessPolicies = portainer.UserAccessPolicies{user.ID: {}}
return tx.Endpoint().UpdateEndpoint(endpoint.ID, endpoint)
})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/websocket/kubernetes-shell?endpointId=2", nil)
req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: user.ID, Role: portainer.StandardUserRole}))
assert.Panics(t, func() {
_ = handler.websocketShellPodExec(httptest.NewRecorder(), req)
})
}
+10
View File
@@ -0,0 +1,10 @@
package websocket
import portainer "github.com/portainer/portainer/api"
type webSocketRequestParams struct {
ID string
nodeName string
endpoint *portainer.Endpoint
token string
}