chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
package endpointproxy
|
||||
|
||||
import (
|
||||
"github.com/gorilla/mux"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/http/proxy"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
)
|
||||
|
||||
// Handler is the HTTP handler used to proxy requests to external APIs.
|
||||
type Handler struct {
|
||||
*mux.Router
|
||||
DataStore dataservices.DataStore
|
||||
requestBouncer security.BouncerService
|
||||
ProxyManager *proxy.Manager
|
||||
ReverseTunnelService portainer.ReverseTunnelService
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to proxy requests to external APIs.
|
||||
func NewHandler(bouncer security.BouncerService) *Handler {
|
||||
h := &Handler{
|
||||
Router: mux.NewRouter(),
|
||||
requestBouncer: bouncer,
|
||||
}
|
||||
h.PathPrefix("/{id}/azure").Handler(
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToAzureAPI)))
|
||||
h.PathPrefix("/{id}/docker").Handler(
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToDockerAPI)))
|
||||
h.PathPrefix("/{id}/kubernetes").Handler(
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToKubernetesAPI)))
|
||||
h.PathPrefix("/{id}/agent/docker").Handler(
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToDockerAPI)))
|
||||
h.PathPrefix("/{id}/agent/kubernetes").Handler(
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToKubernetesAPI)))
|
||||
h.PathPrefix("/{id}/agent/host").Handler(httperror.LoggerHandler(h.proxyRequestsToAgentHostAPI))
|
||||
return h
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package endpointproxy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
)
|
||||
|
||||
func (handler *Handler) proxyRequestsToAgentHostAPI(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid environment identifier route variable", err)
|
||||
}
|
||||
|
||||
var endpoint *portainer.Endpoint
|
||||
if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
endpoint, err = tx.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
||||
return err
|
||||
}); handler.DataStore.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err)
|
||||
} else if err != nil {
|
||||
return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
|
||||
if err != nil {
|
||||
return httperror.Forbidden("Permission denied to access environment", err)
|
||||
}
|
||||
|
||||
if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment {
|
||||
if endpoint.EdgeID == "" {
|
||||
return httperror.InternalServerError("No Edge agent registered with the environment", errors.New("No agent available"))
|
||||
}
|
||||
|
||||
_, err := handler.ReverseTunnelService.TunnelAddr(endpoint)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to get the active tunnel", err)
|
||||
}
|
||||
}
|
||||
|
||||
var proxy http.Handler
|
||||
proxy = handler.ProxyManager.GetEndpointProxy(endpoint)
|
||||
if proxy == nil {
|
||||
proxy, err = handler.ProxyManager.CreateAndRegisterEndpointProxy(endpoint)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to create proxy", err)
|
||||
}
|
||||
}
|
||||
|
||||
id := strconv.Itoa(endpointID)
|
||||
http.StripPrefix("/"+id+"/agent", proxy).ServeHTTP(w, r)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package endpointproxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/http/proxy"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// stubTunnelService is a minimal ReverseTunnelService that controls TunnelAddr behavior.
|
||||
type stubTunnelService struct {
|
||||
tunnelAddr string
|
||||
tunnelAddrErr error
|
||||
}
|
||||
|
||||
func (s *stubTunnelService) StartTunnelServer(_, _ string, _ portainer.SnapshotService) error {
|
||||
return nil
|
||||
}
|
||||
func (s *stubTunnelService) StopTunnelServer() error { return nil }
|
||||
func (s *stubTunnelService) GenerateEdgeKey(_, _ string, _ int) string {
|
||||
return ""
|
||||
}
|
||||
func (s *stubTunnelService) Open(_ *portainer.Endpoint) error { return nil }
|
||||
func (s *stubTunnelService) Config(_ portainer.EndpointID) portainer.TunnelDetails {
|
||||
return portainer.TunnelDetails{}
|
||||
}
|
||||
func (s *stubTunnelService) TunnelAddr(_ *portainer.Endpoint) (string, error) {
|
||||
return s.tunnelAddr, s.tunnelAddrErr
|
||||
}
|
||||
func (s *stubTunnelService) UpdateLastActivity(_ portainer.EndpointID) {}
|
||||
func (s *stubTunnelService) KeepTunnelAlive(_ portainer.EndpointID, _ context.Context, _ time.Duration) {
|
||||
}
|
||||
|
||||
// denyBouncer wraps the test bouncer but rejects AuthorizedEndpointOperation.
|
||||
// Used to test the 403 path without setting up a full JWT stack.
|
||||
type denyBouncer struct {
|
||||
security.BouncerService
|
||||
}
|
||||
|
||||
func (denyBouncer) AuthorizedEndpointOperation(_ *http.Request, _ *portainer.Endpoint) error {
|
||||
return errors.New("access denied to environment")
|
||||
}
|
||||
|
||||
// setupProxyHandler builds a Handler backed by a real (empty) test datastore.
|
||||
// The real datastore is required because proxyRequestsToAgentHostAPI uses ViewTx,
|
||||
// which must execute its callback to populate the endpoint variable.
|
||||
func setupProxyHandler(t *testing.T, bouncer security.BouncerService) (*Handler, *datastore.Store) {
|
||||
t.Helper()
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, false, false)
|
||||
|
||||
h := NewHandler(bouncer)
|
||||
h.DataStore = store
|
||||
h.ProxyManager = proxy.NewManager(nil)
|
||||
h.ReverseTunnelService = &stubTunnelService{}
|
||||
|
||||
return h, store
|
||||
}
|
||||
|
||||
func TestProxyAgentHostAPI_InvalidEndpointID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A non-numeric environment ID in the URL (e.g. caused by a typo or path-traversal attempt)
|
||||
// must be rejected immediately with 400 Bad Request.
|
||||
h, _ := setupProxyHandler(t, testhelpers.NewTestRequestBouncer())
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/abc/agent/host/docker-storage", nil)
|
||||
|
||||
h.ServeHTTP(rw, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, rw.Code)
|
||||
}
|
||||
|
||||
func TestProxyAgentHostAPI_EndpointNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The environment was deleted from the database while the user still has a
|
||||
// browser tab open. The server should return 404, not a 500 or panic.
|
||||
h, _ := setupProxyHandler(t, testhelpers.NewTestRequestBouncer())
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/99/agent/host/docker-storage", nil)
|
||||
|
||||
h.ServeHTTP(rw, req)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, rw.Code)
|
||||
}
|
||||
|
||||
func TestProxyAgentHostAPI_PermissionDenied(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A standard user without access to this environment must receive 403 Forbidden.
|
||||
bouncer := denyBouncer{BouncerService: testhelpers.NewTestRequestBouncer()}
|
||||
h, store := setupProxyHandler(t, bouncer)
|
||||
|
||||
require.NoError(t, store.Endpoint().Create(&portainer.Endpoint{
|
||||
ID: 1,
|
||||
Name: "env-1",
|
||||
Type: portainer.AgentOnDockerEnvironment,
|
||||
}))
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/1/agent/host/docker-storage", nil)
|
||||
|
||||
h.ServeHTTP(rw, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, rw.Code)
|
||||
}
|
||||
|
||||
func TestProxyAgentHostAPI_EdgeNoEdgeID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// An Edge environment that was registered in Portainer but whose agent has never
|
||||
// connected (EdgeID is empty) cannot be contacted — the server returns 500.
|
||||
h, store := setupProxyHandler(t, testhelpers.NewTestRequestBouncer())
|
||||
|
||||
require.NoError(t, store.Endpoint().Create(&portainer.Endpoint{
|
||||
ID: 2,
|
||||
Name: "edge-env-no-id",
|
||||
Type: portainer.EdgeAgentOnDockerEnvironment,
|
||||
EdgeID: "",
|
||||
}))
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/2/agent/host/docker-storage", nil)
|
||||
|
||||
h.ServeHTTP(rw, req)
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, rw.Code)
|
||||
}
|
||||
|
||||
func TestProxyAgentHostAPI_EdgeTunnelUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The Edge agent was registered and has an EdgeID but is currently offline
|
||||
// (tunnel establishment fails). The user receives 500 rather than a hang.
|
||||
_, store := datastore.MustNewTestStore(t, false, false)
|
||||
|
||||
h := NewHandler(testhelpers.NewTestRequestBouncer())
|
||||
h.DataStore = store
|
||||
h.ProxyManager = proxy.NewManager(nil)
|
||||
h.ReverseTunnelService = &stubTunnelService{
|
||||
tunnelAddrErr: errors.New("no active tunnel for edge agent"),
|
||||
}
|
||||
|
||||
require.NoError(t, store.Endpoint().Create(&portainer.Endpoint{
|
||||
ID: 3,
|
||||
Name: "edge-env-offline",
|
||||
Type: portainer.EdgeAgentOnDockerEnvironment,
|
||||
EdgeID: "registered-edge-id",
|
||||
}))
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/3/agent/host/docker-storage", nil)
|
||||
|
||||
h.ServeHTTP(rw, req)
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, rw.Code)
|
||||
}
|
||||
|
||||
func TestProxyAgentHostAPI_ProxyCreationFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// When a proxy for the environment has not been cached yet and the proxy factory is
|
||||
// uninitialised (e.g. a misconfigured server), the handler returns 500 rather than panicking.
|
||||
h, store := setupProxyHandler(t, testhelpers.NewTestRequestBouncer())
|
||||
|
||||
require.NoError(t, store.Endpoint().Create(&portainer.Endpoint{
|
||||
ID: 4,
|
||||
Name: "env-4",
|
||||
Type: portainer.AgentOnDockerEnvironment,
|
||||
URL: "tcp://agent:9001",
|
||||
}))
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/4/agent/host/docker-storage", nil)
|
||||
|
||||
h.ServeHTTP(rw, req)
|
||||
|
||||
// proxy.NewManager(nil) without NewProxyFactory → ErrProxyFactoryNotInitialized → 500
|
||||
assert.Equal(t, http.StatusInternalServerError, rw.Code)
|
||||
}
|
||||
|
||||
// Verify the stubTunnelService satisfies the interface at compile time.
|
||||
var _ portainer.ReverseTunnelService = (*stubTunnelService)(nil)
|
||||
|
||||
// Verify denyBouncer satisfies the interface at compile time.
|
||||
var _ security.BouncerService = denyBouncer{}
|
||||
@@ -0,0 +1,42 @@
|
||||
package endpointproxy
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
)
|
||||
|
||||
func (handler *Handler) proxyRequestsToAzureAPI(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid environment identifier route variable", err)
|
||||
}
|
||||
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err)
|
||||
} else if err != nil {
|
||||
return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
|
||||
if err != nil {
|
||||
return httperror.Forbidden("Permission denied to access environment", err)
|
||||
}
|
||||
|
||||
var proxy http.Handler
|
||||
proxy = handler.ProxyManager.GetEndpointProxy(endpoint)
|
||||
if proxy == nil {
|
||||
proxy, err = handler.ProxyManager.CreateAndRegisterEndpointProxy(endpoint)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to create proxy", err)
|
||||
}
|
||||
}
|
||||
|
||||
id := strconv.Itoa(endpointID)
|
||||
http.StripPrefix("/"+id+"/azure", proxy).ServeHTTP(w, r)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package endpointproxy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
)
|
||||
|
||||
func (handler *Handler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid environment identifier route variable", err)
|
||||
}
|
||||
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err)
|
||||
} else if err != nil {
|
||||
return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
|
||||
if err != nil {
|
||||
return httperror.Forbidden("Permission denied to access environment", err)
|
||||
}
|
||||
|
||||
if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment {
|
||||
if endpoint.EdgeID == "" {
|
||||
return httperror.InternalServerError("No Edge agent registered with the environment", errors.New("No agent available"))
|
||||
}
|
||||
|
||||
_, err := handler.ReverseTunnelService.TunnelAddr(endpoint)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to get the active tunnel", err)
|
||||
}
|
||||
}
|
||||
|
||||
var proxy http.Handler
|
||||
proxy = handler.ProxyManager.GetEndpointProxy(endpoint)
|
||||
if proxy == nil {
|
||||
proxy, err = handler.ProxyManager.CreateAndRegisterEndpointProxy(endpoint)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to create proxy", err)
|
||||
}
|
||||
}
|
||||
|
||||
id := strconv.Itoa(endpointID)
|
||||
|
||||
prefix := "/" + id + "/agent/docker"
|
||||
if !strings.HasPrefix(r.URL.Path, prefix) {
|
||||
prefix = "/" + id + "/docker"
|
||||
}
|
||||
|
||||
http.StripPrefix(prefix, proxy).ServeHTTP(w, r)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package endpointproxy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
)
|
||||
|
||||
func (handler *Handler) proxyRequestsToKubernetesAPI(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid environment identifier route variable", err)
|
||||
}
|
||||
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err)
|
||||
} else if err != nil {
|
||||
return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
|
||||
if err != nil {
|
||||
return httperror.Forbidden("Permission denied to access environment", err)
|
||||
}
|
||||
|
||||
if endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment {
|
||||
if endpoint.EdgeID == "" {
|
||||
return httperror.InternalServerError("No Edge agent registered with the environment", errors.New("No agent available"))
|
||||
}
|
||||
|
||||
_, err := handler.ReverseTunnelService.TunnelAddr(endpoint)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to get the active tunnel", err)
|
||||
}
|
||||
}
|
||||
|
||||
var proxy http.Handler
|
||||
proxy = handler.ProxyManager.GetEndpointProxy(endpoint)
|
||||
if proxy == nil {
|
||||
proxy, err = handler.ProxyManager.CreateAndRegisterEndpointProxy(endpoint)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to create proxy", err)
|
||||
}
|
||||
}
|
||||
|
||||
// For KubernetesLocalEnvironment
|
||||
requestPrefix := fmt.Sprintf("/%d/kubernetes", endpointID)
|
||||
|
||||
if endpoint.Type == portainer.AgentOnKubernetesEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment {
|
||||
requestPrefix = fmt.Sprintf("/%d", endpointID)
|
||||
|
||||
agentPrefix := fmt.Sprintf("/%d/agent/kubernetes", endpointID)
|
||||
if strings.HasPrefix(r.URL.Path, agentPrefix) {
|
||||
requestPrefix = agentPrefix
|
||||
}
|
||||
}
|
||||
|
||||
http.StripPrefix(requestPrefix, proxy).ServeHTTP(w, r)
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user