chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user