1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-19 05:19:39 +02:00

Compare commits

...

67 commits

Author SHA1 Message Date
Ali
eaa2be017d fix(helm): ensure the form is not 'dirty', when the values are unchanged [r8s-421] (#901) 2025-07-17 12:07:11 +12:00
James Player
4e4c5ffdb6 fix(app/kubernetes): Fix listing of secrets and configmaps with same name [r8s-288] (#897) 2025-07-16 16:37:59 +12:00
James Player
383bcc4113 fix(docker/images): Fix image detail actions icon colours [be-12044] (#892) 2025-07-15 13:57:43 +12:00
James Player
9f906b7417 refactor(app/tests): Make createMockUsers more deterministic [r8s-406] (#887) 2025-07-14 17:16:33 +12:00
Cara Ryan
db2e168540 chore: bump version to 2.32.0 (#884) 2025-07-14 10:23:05 +12:00
Ali
2697d6c5d7 feat(oci): oci helm support [r8s-361] (#787) 2025-07-13 10:37:43 +12:00
andres-portainer
b6a6ce9aaf fix(endpointedge): fix a deadlock in createAsyncEdgeAgentEndpoint() BE-12039 (#883) 2025-07-11 18:54:05 -03:00
Ali
89f6a94bd8 chore(select): show data-cy react select [r8s-402] (#881) 2025-07-11 20:06:41 +12:00
Steven Kang
96f2d69ae5 feat(observability): alerting experimental feature (#801)
Co-authored-by: JamesPlayer <james.player@portainer.io>
2025-07-11 16:55:23 +12:00
Cara Ryan
b7e906701a fix(kubernetes): Namespace access permission changes role bindings not created [R8S-366] (#826) 2025-07-11 14:55:48 +12:00
Steven Kang
150d986179 fix: CVE-2025-53547 (#880) 2025-07-11 13:57:21 +12:00
James Player
ef10ea2a7d fix(ui): Fixed TagsDatatable name column link (#847) 2025-07-11 11:01:37 +12:00
Viktor Pettersson
3bf84e8b0c fix(tags): reconcile edge relations prior to deletion [BE-11969] (#867) 2025-07-10 10:52:12 +12:00
andres-portainer
ea4b334c7e feat(csp): enable CSP by default BE-11961 (#872) 2025-07-09 16:15:43 -03:00
Oscar Zhou
4d11aa8655 fix(tag): ignore "environment not found" when deleting tag [BE-11944] (#869) 2025-07-09 09:55:59 -03:00
andres-portainer
302deb8299 chore(dataservices): enhance ReadAll() so it takes predicates for filtering results BE-12016 (#866) 2025-07-07 14:29:56 -03:00
Viktor Pettersson
0c80b1067d fix(styles): update datetime picker styles for improved dark mode support [BE-11672] (#863) 2025-07-07 20:54:44 +12:00
Steven Kang
0a36d4fbfd fix: kubectl sdk - capture fatal error and return instead of exiting 1 [r7s-371] (#841) 2025-07-07 11:29:29 +12:00
Oscar Zhou
c20a8b5a68 fix(template): app template v3 error [BE-11998] (#854) 2025-07-04 11:49:33 -03:00
Devon Steenberg
8ffe4e284a fix(tls): set insecureSkipVerify to false in FIPS mode [BE-11932] (#849) 2025-07-04 10:48:54 +12:00
Steven Kang
1332f718ae feat: add warning events count next to the status badge (#828) 2025-07-04 10:07:57 +12:00
James Player
f4df51884c fix(tests): Fix ServicesDatatable tests - r8s-395 (#860) 2025-07-03 16:01:08 +12:00
James Carppe
ce86129478 Updates for release 2.31.3 (#859) 2025-07-03 15:17:50 +12:00
andres-portainer
097b125e3a fix(boltdb): change some options to increase performance BE-12002 (#848) 2025-07-02 18:17:19 -03:00
andres-portainer
5c6b53922a feat(go): upgrade to Go v1.24.4 BE-11774 (#855) 2025-07-02 18:14:29 -03:00
James Carppe
e1b9f23f73 Updates for release 2.27.9 (#853) 2025-07-02 17:45:59 +12:00
LP B
e1c480d3c3 feat(app/edge-stacks): summarize the edge stack statuses in the backend (#818) 2025-07-01 15:04:10 +02:00
Steven Kang
363a62d885 fix: bump the docker binary version to v28.3.0 [r8s-390] (#837) 2025-07-01 20:10:39 +12:00
James Player
c6ee9a5a52 feat(ui): Rebranding - r8s-374 (#840) 2025-07-01 12:58:31 +12:00
andres-portainer
cf5990ccba fix(edgestackstatus): improve error handling BE-11963 (#844) 2025-06-30 20:54:16 -03:00
Oscar Zhou
b6f3682a62 refactor(edge): init endpoint relation when endpoint is created [BE-11928] (#814) 2025-06-30 15:15:56 -03:00
LP B
b43f864511 fix(api/endpoints): filter out waiting room environments for non admins (#810) 2025-06-30 15:35:51 +02:00
Oscar Zhou
0556ffb4a1 feat(csrf): add trusted origins cli flags [BE-11972] (#836) 2025-06-27 17:41:10 -03:00
Ali
303047656e fix(k8s-services): avoid rerendering services table [r8s-387] (#832) 2025-06-27 22:48:40 +12:00
Steven Kang
8d29b5ae71 fix: kubeconfig download button inconsistency between http and https (#829) 2025-06-27 09:38:04 +12:00
James Carppe
7d7ae24351 Updates for release 2.31.2 (#834) 2025-06-26 15:41:23 +12:00
James Carppe
97838e614d Updates for release 2.27.8 (#827) 2025-06-25 17:11:58 +12:00
Steven Kang
c897baad20 fix: fetching values from both install and upgrade views - develop [R8S-368] (#820) 2025-06-24 15:46:10 +12:00
andres-portainer
d51e9205d9 fix(endpointrelation): use a read-write transaction for mutations BE-11964 (#819) 2025-06-20 20:03:35 -03:00
James Carppe
e051c86bb5 Updates for release 2.31.1 (#816) 2025-06-19 14:07:18 +12:00
Steven Kang
c2b48cd003 feat(k8s): CloudNativePG in applications list and details - [R8S-357] (#777) 2025-06-19 09:03:52 +12:00
James Carppe
a7009eb8d5 Update bug report template for 2.27.7 (#805) 2025-06-17 12:52:12 +12:00
andres-portainer
036b87b649 fix(middlewares): fix data race in WithEndpoint() BE-11949 (#803) 2025-06-16 12:56:51 -03:00
Steven Kang
f07a3b1875 security: cve-2025-22874 & cve-2025-22871 bump go to 1.23.10 (#798) 2025-06-12 17:30:53 +12:00
Yajith Dayarathna
6e89ccc0ae fix(api-documentation): swagger document genration error (#795) 2025-06-12 13:39:34 +12:00
James Carppe
cc67612432 Update bug report template for 2.31.0 (#793) 2025-06-12 13:26:25 +12:00
Malcolm Lockyer
17ebe221bb chore: bump version to 2.31.0 (#789) 2025-06-10 16:47:17 +12:00
Ali
1963edda66 feat(helm): add registry dropdown [r8s-340] (#779) 2025-06-09 20:08:50 +12:00
Cara Ryan
c9e3717ce3 fix(kubernetes): Display more than 10 workloads under Helm expandable in the Applications view [R8S-339] (#781) 2025-06-09 15:12:24 +12:00
Oscar Zhou
9a85246631 fix(edgestack): display deploying status by default after creating edgestack [BE-11924] (#783) 2025-06-07 09:06:57 +12:00
andres-portainer
75f165d1ff feat(edgestackstatus): optimize the Edge Stack structures BE-11740 (#756) 2025-06-05 19:46:10 -03:00
Viktor Pettersson
eaf0deb2f6 feat(update-schedules): new update schedules view [BE-11754, BE-11887] (#686) 2025-06-05 17:03:43 +12:00
Ali
a9061e5258 feat(helm): enhance helm chart install [r8s-341] (#766) 2025-06-05 13:13:45 +12:00
James Player
caac45b834 feat(UI): Add repository url to Helm chart installation list items (#769) 2025-06-05 10:14:39 +12:00
LP B
24ff7a7911 chore(deps): upgrade docker/cli to v28.2.1 | docker/docker to v28.2.1 | docker/compose to v2.36.2 (#758) 2025-05-30 09:12:27 +02:00
Devon Steenberg
b767dcb27e fix(proxy): whitelist headers for proxy to forward [BE-11819] (#665) 2025-05-30 11:49:23 +12:00
Cara Ryan
731afbee46 feat(helm): filter on chart versions at API level [R8S-324] (#754) 2025-05-27 15:20:28 +12:00
Cara Ryan
07dfd981a2 fix(kubernetes): events api to call the backend [R8S-243] (#563) 2025-05-27 13:55:31 +12:00
Cara Ryan
32ef208278 Revert "feat(helm): filter on chart versions at API level [R8S-324]" (#753) 2025-05-26 16:58:53 +12:00
Cara Ryan
a80b185e10 feat(helm): filter on chart versions at API level [R8S-324] (#747) 2025-05-26 14:10:38 +12:00
Malcolm Lockyer
b96328e098 fix(async-perf): In async poll snapshot handling, reduce redundant json marshal [be-11861] (#726) 2025-05-23 12:42:45 +12:00
Devon Steenberg
45471ce86d fix(docker): check len of device capabilities [BE-11898] (#750) 2025-05-22 14:27:14 +12:00
Viktor Pettersson
1bc91d0c7c fix(edge-update): set edge stack status to EdgeStackStatusError to avoid redeployment of portainer-updater [BE-11855] (#714) 2025-05-20 08:28:40 +02:00
James Carppe
799325d9f8 Update bug report template for 2.30.1 (#749) 2025-05-20 14:40:43 +12:00
James Carppe
b540709e03 Update bug report template for 2.30.0 (#737) 2025-05-15 12:09:28 +12:00
Oscar Zhou
44daab04ac fix(libclient): option to disable external http request [BE-11696] (#719) 2025-05-15 09:54:35 +12:00
Ali
ee65223ee7 chore: bump version to 2.30.0 (#735) 2025-05-14 17:35:05 +12:00
322 changed files with 11859 additions and 3419 deletions

View file

@ -94,11 +94,20 @@ body:
description: We only provide support for current versions of Portainer as per the lifecycle policy linked above. If you are on an older version of Portainer we recommend [updating first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed. description: We only provide support for current versions of Portainer as per the lifecycle policy linked above. If you are on an older version of Portainer we recommend [updating first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
multiple: false multiple: false
options: options:
- '2.31.3'
- '2.31.2'
- '2.31.1'
- '2.31.0'
- '2.30.1'
- '2.30.0'
- '2.29.2' - '2.29.2'
- '2.29.1' - '2.29.1'
- '2.29.0' - '2.29.0'
- '2.28.1' - '2.28.1'
- '2.28.0' - '2.28.0'
- '2.27.9'
- '2.27.8'
- '2.27.7'
- '2.27.6' - '2.27.6'
- '2.27.5' - '2.27.5'
- '2.27.4' - '2.27.4'

View file

@ -61,6 +61,8 @@ func CLIFlags() *portainer.CLIFlags {
LogMode: kingpin.Flag("log-mode", "Set the logging output mode").Default("PRETTY").Enum("NOCOLOR", "PRETTY", "JSON"), LogMode: kingpin.Flag("log-mode", "Set the logging output mode").Default("PRETTY").Enum("NOCOLOR", "PRETTY", "JSON"),
KubectlShellImage: kingpin.Flag("kubectl-shell-image", "Kubectl shell image").Envar(portainer.KubectlShellImageEnvVar).Default(portainer.DefaultKubectlShellImage).String(), KubectlShellImage: kingpin.Flag("kubectl-shell-image", "Kubectl shell image").Envar(portainer.KubectlShellImageEnvVar).Default(portainer.DefaultKubectlShellImage).String(),
PullLimitCheckDisabled: kingpin.Flag("pull-limit-check-disabled", "Pull limit check").Envar(portainer.PullLimitCheckDisabledEnvVar).Default(defaultPullLimitCheckDisabled).Bool(), PullLimitCheckDisabled: kingpin.Flag("pull-limit-check-disabled", "Pull limit check").Envar(portainer.PullLimitCheckDisabledEnvVar).Default(defaultPullLimitCheckDisabled).Bool(),
TrustedOrigins: kingpin.Flag("trusted-origins", "List of trusted origins for CSRF protection. Separate multiple origins with a comma.").Envar(portainer.TrustedOriginsEnvVar).String(),
CSP: kingpin.Flag("csp", "Content Security Policy (CSP) header").Envar(portainer.CSPEnvVar).Default("true").Bool(),
} }
} }

View file

@ -52,6 +52,7 @@ import (
"github.com/portainer/portainer/pkg/libhelm" "github.com/portainer/portainer/pkg/libhelm"
libhelmtypes "github.com/portainer/portainer/pkg/libhelm/types" libhelmtypes "github.com/portainer/portainer/pkg/libhelm/types"
"github.com/portainer/portainer/pkg/libstack/compose" "github.com/portainer/portainer/pkg/libstack/compose"
"github.com/portainer/portainer/pkg/validate"
"github.com/gofrs/uuid" "github.com/gofrs/uuid"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
@ -330,6 +331,18 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
featureflags.Parse(*flags.FeatureFlags, portainer.SupportedFeatureFlags) featureflags.Parse(*flags.FeatureFlags, portainer.SupportedFeatureFlags)
} }
trustedOrigins := []string{}
if *flags.TrustedOrigins != "" {
// validate if the trusted origins are valid urls
for _, origin := range strings.Split(*flags.TrustedOrigins, ",") {
if !validate.IsTrustedOrigin(origin) {
log.Fatal().Str("trusted_origin", origin).Msg("invalid url for trusted origin. Please check the trusted origins flag.")
}
trustedOrigins = append(trustedOrigins, origin)
}
}
fileService := initFileService(*flags.Data) fileService := initFileService(*flags.Data)
encryptionKey := loadEncryptionSecretKey(*flags.SecretKeyName) encryptionKey := loadEncryptionSecretKey(*flags.SecretKeyName)
if encryptionKey == nil { if encryptionKey == nil {
@ -370,7 +383,8 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
gitService := git.NewService(shutdownCtx) gitService := git.NewService(shutdownCtx)
openAMTService := openamt.NewService() // Setting insecureSkipVerify to true to preserve the old behaviour.
openAMTService := openamt.NewService(true)
cryptoService := &crypto.Service{} cryptoService := &crypto.Service{}
@ -545,6 +559,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
Status: applicationStatus, Status: applicationStatus,
BindAddress: *flags.Addr, BindAddress: *flags.Addr,
BindAddressHTTPS: *flags.AddrHTTPS, BindAddressHTTPS: *flags.AddrHTTPS,
CSP: *flags.CSP,
HTTPEnabled: sslDBSettings.HTTPEnabled, HTTPEnabled: sslDBSettings.HTTPEnabled,
AssetsPath: *flags.Assets, AssetsPath: *flags.Assets,
DataStore: dataStore, DataStore: dataStore,
@ -578,6 +593,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
PendingActionsService: pendingActionsService, PendingActionsService: pendingActionsService,
PlatformService: platformService, PlatformService: platformService,
PullLimitCheckDisabled: *flags.PullLimitCheckDisabled, PullLimitCheckDisabled: *flags.PullLimitCheckDisabled,
TrustedOrigins: trustedOrigins,
} }
} }

View file

@ -138,6 +138,8 @@ func (connection *DbConnection) Open() error {
db, err := bolt.Open(databasePath, 0600, &bolt.Options{ db, err := bolt.Open(databasePath, 0600, &bolt.Options{
Timeout: 1 * time.Second, Timeout: 1 * time.Second,
InitialMmapSize: connection.InitialMmapSize, InitialMmapSize: connection.InitialMmapSize,
FreelistType: bolt.FreelistMapType,
NoFreelistSync: true,
}) })
if err != nil { if err != nil {
return err return err

View file

@ -10,7 +10,7 @@ type BaseCRUD[T any, I constraints.Integer] interface {
Create(element *T) error Create(element *T) error
Read(ID I) (*T, error) Read(ID I) (*T, error)
Exists(ID I) (bool, error) Exists(ID I) (bool, error)
ReadAll() ([]T, error) ReadAll(predicates ...func(T) bool) ([]T, error)
Update(ID I, element *T) error Update(ID I, element *T) error
Delete(ID I) error Delete(ID I) error
} }
@ -56,12 +56,13 @@ func (service BaseDataService[T, I]) Exists(ID I) (bool, error) {
return exists, err return exists, err
} }
func (service BaseDataService[T, I]) ReadAll() ([]T, error) { // ReadAll retrieves all the elements that satisfy all the provided predicates.
func (service BaseDataService[T, I]) ReadAll(predicates ...func(T) bool) ([]T, error) {
var collection = make([]T, 0) var collection = make([]T, 0)
return collection, service.Connection.ViewTx(func(tx portainer.Transaction) error { return collection, service.Connection.ViewTx(func(tx portainer.Transaction) error {
var err error var err error
collection, err = service.Tx(tx).ReadAll() collection, err = service.Tx(tx).ReadAll(predicates...)
return err return err
}) })

View file

@ -0,0 +1,92 @@
package dataservices
import (
"strconv"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/slicesx"
"github.com/stretchr/testify/require"
)
type testObject struct {
ID int
Value int
}
type mockConnection struct {
store map[int]testObject
portainer.Connection
}
func (m mockConnection) UpdateObject(bucket string, key []byte, value interface{}) error {
obj := value.(*testObject)
m.store[obj.ID] = *obj
return nil
}
func (m mockConnection) GetAll(bucketName string, obj any, appendFn func(o any) (any, error)) error {
for _, v := range m.store {
if _, err := appendFn(&v); err != nil {
return err
}
}
return nil
}
func (m mockConnection) UpdateTx(fn func(portainer.Transaction) error) error {
return fn(m)
}
func (m mockConnection) ViewTx(fn func(portainer.Transaction) error) error {
return fn(m)
}
func (m mockConnection) ConvertToKey(v int) []byte {
return []byte(strconv.Itoa(v))
}
func TestReadAll(t *testing.T) {
service := BaseDataService[testObject, int]{
Bucket: "testBucket",
Connection: mockConnection{store: make(map[int]testObject)},
}
data := []testObject{
{ID: 1, Value: 1},
{ID: 2, Value: 2},
{ID: 3, Value: 3},
{ID: 4, Value: 4},
{ID: 5, Value: 5},
}
for _, item := range data {
err := service.Update(item.ID, &item)
require.NoError(t, err)
}
// ReadAll without predicates
result, err := service.ReadAll()
require.NoError(t, err)
expected := append([]testObject{}, data...)
require.ElementsMatch(t, expected, result)
// ReadAll with predicates
hasLowID := func(obj testObject) bool { return obj.ID < 3 }
isEven := func(obj testObject) bool { return obj.Value%2 == 0 }
result, err = service.ReadAll(hasLowID, isEven)
require.NoError(t, err)
expected = slicesx.Filter(expected, hasLowID)
expected = slicesx.Filter(expected, isEven)
require.ElementsMatch(t, expected, result)
}

View file

@ -34,13 +34,32 @@ func (service BaseDataServiceTx[T, I]) Exists(ID I) (bool, error) {
return service.Tx.KeyExists(service.Bucket, identifier) return service.Tx.KeyExists(service.Bucket, identifier)
} }
func (service BaseDataServiceTx[T, I]) ReadAll() ([]T, error) { // ReadAll retrieves all the elements that satisfy all the provided predicates.
func (service BaseDataServiceTx[T, I]) ReadAll(predicates ...func(T) bool) ([]T, error) {
var collection = make([]T, 0) var collection = make([]T, 0)
if len(predicates) == 0 {
return collection, service.Tx.GetAll(
service.Bucket,
new(T),
AppendFn(&collection),
)
}
filterFn := func(element T) bool {
for _, p := range predicates {
if !p(element) {
return false
}
}
return true
}
return collection, service.Tx.GetAll( return collection, service.Tx.GetAll(
service.Bucket, service.Bucket,
new(T), new(T),
AppendFn(&collection), FilterFn(&collection, filterFn),
) )
} }

View file

@ -0,0 +1,89 @@
package edgestackstatus
import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
)
var _ dataservices.EdgeStackStatusService = &Service{}
const BucketName = "edge_stack_status"
type Service struct {
conn portainer.Connection
}
func (service *Service) BucketName() string {
return BucketName
}
func NewService(connection portainer.Connection) (*Service, error) {
if err := connection.SetServiceName(BucketName); err != nil {
return nil, err
}
return &Service{conn: connection}, nil
}
func (s *Service) Tx(tx portainer.Transaction) ServiceTx {
return ServiceTx{
service: s,
tx: tx,
}
}
func (s *Service) Create(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID, status *portainer.EdgeStackStatusForEnv) error {
return s.conn.UpdateTx(func(tx portainer.Transaction) error {
return s.Tx(tx).Create(edgeStackID, endpointID, status)
})
}
func (s *Service) Read(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) (*portainer.EdgeStackStatusForEnv, error) {
var element *portainer.EdgeStackStatusForEnv
return element, s.conn.ViewTx(func(tx portainer.Transaction) error {
var err error
element, err = s.Tx(tx).Read(edgeStackID, endpointID)
return err
})
}
func (s *Service) ReadAll(edgeStackID portainer.EdgeStackID) ([]portainer.EdgeStackStatusForEnv, error) {
var collection = make([]portainer.EdgeStackStatusForEnv, 0)
return collection, s.conn.ViewTx(func(tx portainer.Transaction) error {
var err error
collection, err = s.Tx(tx).ReadAll(edgeStackID)
return err
})
}
func (s *Service) Update(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID, status *portainer.EdgeStackStatusForEnv) error {
return s.conn.UpdateTx(func(tx portainer.Transaction) error {
return s.Tx(tx).Update(edgeStackID, endpointID, status)
})
}
func (s *Service) Delete(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) error {
return s.conn.UpdateTx(func(tx portainer.Transaction) error {
return s.Tx(tx).Delete(edgeStackID, endpointID)
})
}
func (s *Service) DeleteAll(edgeStackID portainer.EdgeStackID) error {
return s.conn.UpdateTx(func(tx portainer.Transaction) error {
return s.Tx(tx).DeleteAll(edgeStackID)
})
}
func (s *Service) Clear(edgeStackID portainer.EdgeStackID, relatedEnvironmentsIDs []portainer.EndpointID) error {
return s.conn.UpdateTx(func(tx portainer.Transaction) error {
return s.Tx(tx).Clear(edgeStackID, relatedEnvironmentsIDs)
})
}
func (s *Service) key(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) []byte {
return append(s.conn.ConvertToKey(int(edgeStackID)), s.conn.ConvertToKey(int(endpointID))...)
}

View file

@ -0,0 +1,95 @@
package edgestackstatus
import (
"fmt"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
)
var _ dataservices.EdgeStackStatusService = &Service{}
type ServiceTx struct {
service *Service
tx portainer.Transaction
}
func (service ServiceTx) Create(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID, status *portainer.EdgeStackStatusForEnv) error {
identifier := service.service.key(edgeStackID, endpointID)
return service.tx.CreateObjectWithStringId(BucketName, identifier, status)
}
func (s ServiceTx) Read(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) (*portainer.EdgeStackStatusForEnv, error) {
var status portainer.EdgeStackStatusForEnv
identifier := s.service.key(edgeStackID, endpointID)
if err := s.tx.GetObject(BucketName, identifier, &status); err != nil {
return nil, err
}
return &status, nil
}
func (s ServiceTx) ReadAll(edgeStackID portainer.EdgeStackID) ([]portainer.EdgeStackStatusForEnv, error) {
keyPrefix := s.service.conn.ConvertToKey(int(edgeStackID))
statuses := make([]portainer.EdgeStackStatusForEnv, 0)
if err := s.tx.GetAllWithKeyPrefix(BucketName, keyPrefix, &portainer.EdgeStackStatusForEnv{}, dataservices.AppendFn(&statuses)); err != nil {
return nil, fmt.Errorf("unable to retrieve EdgeStackStatus for EdgeStack %d: %w", edgeStackID, err)
}
return statuses, nil
}
func (s ServiceTx) Update(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID, status *portainer.EdgeStackStatusForEnv) error {
identifier := s.service.key(edgeStackID, endpointID)
return s.tx.UpdateObject(BucketName, identifier, status)
}
func (s ServiceTx) Delete(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) error {
identifier := s.service.key(edgeStackID, endpointID)
return s.tx.DeleteObject(BucketName, identifier)
}
func (s ServiceTx) DeleteAll(edgeStackID portainer.EdgeStackID) error {
keyPrefix := s.service.conn.ConvertToKey(int(edgeStackID))
statuses := make([]portainer.EdgeStackStatusForEnv, 0)
if err := s.tx.GetAllWithKeyPrefix(BucketName, keyPrefix, &portainer.EdgeStackStatusForEnv{}, dataservices.AppendFn(&statuses)); err != nil {
return fmt.Errorf("unable to retrieve EdgeStackStatus for EdgeStack %d: %w", edgeStackID, err)
}
for _, status := range statuses {
if err := s.tx.DeleteObject(BucketName, s.service.key(edgeStackID, status.EndpointID)); err != nil {
return fmt.Errorf("unable to delete EdgeStackStatus for EdgeStack %d and Endpoint %d: %w", edgeStackID, status.EndpointID, err)
}
}
return nil
}
func (s ServiceTx) Clear(edgeStackID portainer.EdgeStackID, relatedEnvironmentsIDs []portainer.EndpointID) error {
for _, envID := range relatedEnvironmentsIDs {
existingStatus, err := s.Read(edgeStackID, envID)
if err != nil && !dataservices.IsErrObjectNotFound(err) {
return fmt.Errorf("unable to retrieve status for environment %d: %w", envID, err)
}
var deploymentInfo portainer.StackDeploymentInfo
if existingStatus != nil {
deploymentInfo = existingStatus.DeploymentInfo
}
if err := s.Update(edgeStackID, envID, &portainer.EdgeStackStatusForEnv{
EndpointID: envID,
Status: []portainer.EdgeStackDeploymentStatus{},
DeploymentInfo: deploymentInfo,
}); err != nil {
return err
}
}
return nil
}

View file

@ -112,13 +112,13 @@ func (service *Service) UpdateEndpointRelation(endpointID portainer.EndpointID,
} }
func (service *Service) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error { func (service *Service) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
return service.connection.ViewTx(func(tx portainer.Transaction) error { return service.connection.UpdateTx(func(tx portainer.Transaction) error {
return service.Tx(tx).AddEndpointRelationsForEdgeStack(endpointIDs, edgeStackID) return service.Tx(tx).AddEndpointRelationsForEdgeStack(endpointIDs, edgeStackID)
}) })
} }
func (service *Service) RemoveEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error { func (service *Service) RemoveEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
return service.connection.ViewTx(func(tx portainer.Transaction) error { return service.connection.UpdateTx(func(tx portainer.Transaction) error {
return service.Tx(tx).RemoveEndpointRelationsForEdgeStack(endpointIDs, edgeStackID) return service.Tx(tx).RemoveEndpointRelationsForEdgeStack(endpointIDs, edgeStackID)
}) })
} }

View file

@ -12,6 +12,7 @@ type (
EdgeGroup() EdgeGroupService EdgeGroup() EdgeGroupService
EdgeJob() EdgeJobService EdgeJob() EdgeJobService
EdgeStack() EdgeStackService EdgeStack() EdgeStackService
EdgeStackStatus() EdgeStackStatusService
Endpoint() EndpointService Endpoint() EndpointService
EndpointGroup() EndpointGroupService EndpointGroup() EndpointGroupService
EndpointRelation() EndpointRelationService EndpointRelation() EndpointRelationService
@ -39,8 +40,8 @@ type (
Open() (newStore bool, err error) Open() (newStore bool, err error)
Init() error Init() error
Close() error Close() error
UpdateTx(func(DataStoreTx) error) error UpdateTx(func(tx DataStoreTx) error) error
ViewTx(func(DataStoreTx) error) error ViewTx(func(tx DataStoreTx) error) error
MigrateData() error MigrateData() error
Rollback(force bool) error Rollback(force bool) error
CheckCurrentEdition() error CheckCurrentEdition() error
@ -89,6 +90,16 @@ type (
BucketName() string BucketName() string
} }
EdgeStackStatusService interface {
Create(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID, status *portainer.EdgeStackStatusForEnv) error
Read(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) (*portainer.EdgeStackStatusForEnv, error)
ReadAll(edgeStackID portainer.EdgeStackID) ([]portainer.EdgeStackStatusForEnv, error)
Update(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID, status *portainer.EdgeStackStatusForEnv) error
Delete(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) error
DeleteAll(edgeStackID portainer.EdgeStackID) error
Clear(edgeStackID portainer.EdgeStackID, relatedEnvironmentsIDs []portainer.EndpointID) error
}
// EndpointService represents a service for managing environment(endpoint) data // EndpointService represents a service for managing environment(endpoint) data
EndpointService interface { EndpointService interface {
Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, error) Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, error)

View file

@ -51,3 +51,20 @@ func (service *Service) ReadWithoutSnapshotRaw(ID portainer.EndpointID) (*portai
return snapshot, err return snapshot, err
} }
func (service *Service) ReadRawMessage(ID portainer.EndpointID) (*portainer.SnapshotRawMessage, error) {
var snapshot *portainer.SnapshotRawMessage
err := service.Connection.ViewTx(func(tx portainer.Transaction) error {
var err error
snapshot, err = service.Tx(tx).ReadRawMessage(ID)
return err
})
return snapshot, err
}
func (service *Service) CreateRawMessage(snapshot *portainer.SnapshotRawMessage) error {
return service.Connection.CreateObjectWithId(BucketName, int(snapshot.EndpointID), snapshot)
}

View file

@ -35,3 +35,19 @@ func (service ServiceTx) ReadWithoutSnapshotRaw(ID portainer.EndpointID) (*porta
return &snapshot.Snapshot, nil return &snapshot.Snapshot, nil
} }
func (service ServiceTx) ReadRawMessage(ID portainer.EndpointID) (*portainer.SnapshotRawMessage, error) {
var snapshot = portainer.SnapshotRawMessage{}
identifier := service.Connection.ConvertToKey(int(ID))
if err := service.Tx.GetObject(service.Bucket, identifier, &snapshot); err != nil {
return nil, err
}
return &snapshot, nil
}
func (service ServiceTx) CreateRawMessage(snapshot *portainer.SnapshotRawMessage) error {
return service.Tx.CreateObjectWithId(BucketName, int(snapshot.EndpointID), snapshot)
}

View file

@ -40,13 +40,11 @@ func (store *Store) MigrateData() error {
} }
// before we alter anything in the DB, create a backup // before we alter anything in the DB, create a backup
_, err = store.Backup("") if _, err := store.Backup(""); err != nil {
if err != nil {
return errors.Wrap(err, "while backing up database") return errors.Wrap(err, "while backing up database")
} }
err = store.FailSafeMigrate(migrator, version) if err := store.FailSafeMigrate(migrator, version); err != nil {
if err != nil {
err = errors.Wrap(err, "failed to migrate database") err = errors.Wrap(err, "failed to migrate database")
log.Warn().Err(err).Msg("migration failed, restoring database to previous version") log.Warn().Err(err).Msg("migration failed, restoring database to previous version")
@ -85,6 +83,7 @@ func (store *Store) newMigratorParameters(version *models.Version, flags *portai
DockerhubService: store.DockerHubService, DockerhubService: store.DockerHubService,
AuthorizationService: authorization.NewService(store), AuthorizationService: authorization.NewService(store),
EdgeStackService: store.EdgeStackService, EdgeStackService: store.EdgeStackService,
EdgeStackStatusService: store.EdgeStackStatusService,
EdgeJobService: store.EdgeJobService, EdgeJobService: store.EdgeJobService,
TunnelServerService: store.TunnelServerService, TunnelServerService: store.TunnelServerService,
PendingActionsService: store.PendingActionsService, PendingActionsService: store.PendingActionsService,
@ -140,8 +139,7 @@ func (store *Store) connectionRollback(force bool) error {
} }
} }
err := store.Restore() if err := store.Restore(); err != nil {
if err != nil {
return err return err
} }

View file

@ -0,0 +1,31 @@
package migrator
import portainer "github.com/portainer/portainer/api"
func (m *Migrator) migrateEdgeStacksStatuses_2_31_0() error {
edgeStacks, err := m.edgeStackService.EdgeStacks()
if err != nil {
return err
}
for _, edgeStack := range edgeStacks {
for envID, status := range edgeStack.Status {
if err := m.edgeStackStatusService.Create(edgeStack.ID, envID, &portainer.EdgeStackStatusForEnv{
EndpointID: envID,
Status: status.Status,
DeploymentInfo: status.DeploymentInfo,
ReadyRePullImage: status.ReadyRePullImage,
}); err != nil {
return err
}
}
edgeStack.Status = nil
if err := m.edgeStackService.UpdateEdgeStack(edgeStack.ID, &edgeStack); err != nil {
return err
}
}
return nil
}

View file

@ -0,0 +1,33 @@
package migrator
import (
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
perrors "github.com/portainer/portainer/api/dataservices/errors"
"github.com/portainer/portainer/api/internal/endpointutils"
)
func (m *Migrator) addEndpointRelationForEdgeAgents_2_32_0() error {
endpoints, err := m.endpointService.Endpoints()
if err != nil {
return err
}
for _, endpoint := range endpoints {
if endpointutils.IsEdgeEndpoint(&endpoint) {
_, err := m.endpointRelationService.EndpointRelation(endpoint.ID)
if err != nil && errors.Is(err, perrors.ErrObjectNotFound) {
relation := &portainer.EndpointRelation{
EndpointID: endpoint.ID,
EdgeStacks: make(map[portainer.EdgeStackID]bool),
}
if err := m.endpointRelationService.Create(relation); err != nil {
return err
}
}
}
}
return nil
}

View file

@ -3,12 +3,12 @@ package migrator
import ( import (
"errors" "errors"
"github.com/Masterminds/semver"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database/models" "github.com/portainer/portainer/api/database/models"
"github.com/portainer/portainer/api/dataservices/dockerhub" "github.com/portainer/portainer/api/dataservices/dockerhub"
"github.com/portainer/portainer/api/dataservices/edgejob" "github.com/portainer/portainer/api/dataservices/edgejob"
"github.com/portainer/portainer/api/dataservices/edgestack" "github.com/portainer/portainer/api/dataservices/edgestack"
"github.com/portainer/portainer/api/dataservices/edgestackstatus"
"github.com/portainer/portainer/api/dataservices/endpoint" "github.com/portainer/portainer/api/dataservices/endpoint"
"github.com/portainer/portainer/api/dataservices/endpointgroup" "github.com/portainer/portainer/api/dataservices/endpointgroup"
"github.com/portainer/portainer/api/dataservices/endpointrelation" "github.com/portainer/portainer/api/dataservices/endpointrelation"
@ -27,6 +27,8 @@ import (
"github.com/portainer/portainer/api/dataservices/user" "github.com/portainer/portainer/api/dataservices/user"
"github.com/portainer/portainer/api/dataservices/version" "github.com/portainer/portainer/api/dataservices/version"
"github.com/portainer/portainer/api/internal/authorization" "github.com/portainer/portainer/api/internal/authorization"
"github.com/Masterminds/semver"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@ -56,6 +58,7 @@ type (
authorizationService *authorization.Service authorizationService *authorization.Service
dockerhubService *dockerhub.Service dockerhubService *dockerhub.Service
edgeStackService *edgestack.Service edgeStackService *edgestack.Service
edgeStackStatusService *edgestackstatus.Service
edgeJobService *edgejob.Service edgeJobService *edgejob.Service
TunnelServerService *tunnelserver.Service TunnelServerService *tunnelserver.Service
pendingActionsService *pendingactions.Service pendingActionsService *pendingactions.Service
@ -84,6 +87,7 @@ type (
AuthorizationService *authorization.Service AuthorizationService *authorization.Service
DockerhubService *dockerhub.Service DockerhubService *dockerhub.Service
EdgeStackService *edgestack.Service EdgeStackService *edgestack.Service
EdgeStackStatusService *edgestackstatus.Service
EdgeJobService *edgejob.Service EdgeJobService *edgejob.Service
TunnelServerService *tunnelserver.Service TunnelServerService *tunnelserver.Service
PendingActionsService *pendingactions.Service PendingActionsService *pendingactions.Service
@ -114,6 +118,7 @@ func NewMigrator(parameters *MigratorParameters) *Migrator {
authorizationService: parameters.AuthorizationService, authorizationService: parameters.AuthorizationService,
dockerhubService: parameters.DockerhubService, dockerhubService: parameters.DockerhubService,
edgeStackService: parameters.EdgeStackService, edgeStackService: parameters.EdgeStackService,
edgeStackStatusService: parameters.EdgeStackStatusService,
edgeJobService: parameters.EdgeJobService, edgeJobService: parameters.EdgeJobService,
TunnelServerService: parameters.TunnelServerService, TunnelServerService: parameters.TunnelServerService,
pendingActionsService: parameters.PendingActionsService, pendingActionsService: parameters.PendingActionsService,
@ -242,6 +247,10 @@ func (m *Migrator) initMigrations() {
m.migratePendingActionsDataForDB130, m.migratePendingActionsDataForDB130,
) )
m.addMigrations("2.31.0", m.migrateEdgeStacksStatuses_2_31_0)
m.addMigrations("2.32.0", m.addEndpointRelationForEdgeAgents_2_32_0)
// Add new migrations above... // Add new migrations above...
// One function per migration, each versions migration funcs in the same file. // One function per migration, each versions migration funcs in the same file.
} }

View file

@ -13,6 +13,7 @@ import (
"github.com/portainer/portainer/api/dataservices/edgegroup" "github.com/portainer/portainer/api/dataservices/edgegroup"
"github.com/portainer/portainer/api/dataservices/edgejob" "github.com/portainer/portainer/api/dataservices/edgejob"
"github.com/portainer/portainer/api/dataservices/edgestack" "github.com/portainer/portainer/api/dataservices/edgestack"
"github.com/portainer/portainer/api/dataservices/edgestackstatus"
"github.com/portainer/portainer/api/dataservices/endpoint" "github.com/portainer/portainer/api/dataservices/endpoint"
"github.com/portainer/portainer/api/dataservices/endpointgroup" "github.com/portainer/portainer/api/dataservices/endpointgroup"
"github.com/portainer/portainer/api/dataservices/endpointrelation" "github.com/portainer/portainer/api/dataservices/endpointrelation"
@ -39,6 +40,8 @@ import (
"github.com/segmentio/encoding/json" "github.com/segmentio/encoding/json"
) )
var _ dataservices.DataStore = &Store{}
// Store defines the implementation of portainer.DataStore using // Store defines the implementation of portainer.DataStore using
// BoltDB as the storage system. // BoltDB as the storage system.
type Store struct { type Store struct {
@ -51,6 +54,7 @@ type Store struct {
EdgeGroupService *edgegroup.Service EdgeGroupService *edgegroup.Service
EdgeJobService *edgejob.Service EdgeJobService *edgejob.Service
EdgeStackService *edgestack.Service EdgeStackService *edgestack.Service
EdgeStackStatusService *edgestackstatus.Service
EndpointGroupService *endpointgroup.Service EndpointGroupService *endpointgroup.Service
EndpointService *endpoint.Service EndpointService *endpoint.Service
EndpointRelationService *endpointrelation.Service EndpointRelationService *endpointrelation.Service
@ -109,6 +113,12 @@ func (store *Store) initServices() error {
store.EdgeStackService = edgeStackService store.EdgeStackService = edgeStackService
endpointRelationService.RegisterUpdateStackFunction(edgeStackService.UpdateEdgeStackFunc, edgeStackService.UpdateEdgeStackFuncTx) endpointRelationService.RegisterUpdateStackFunction(edgeStackService.UpdateEdgeStackFunc, edgeStackService.UpdateEdgeStackFuncTx)
edgeStackStatusService, err := edgestackstatus.NewService(store.connection)
if err != nil {
return err
}
store.EdgeStackStatusService = edgeStackStatusService
edgeGroupService, err := edgegroup.NewService(store.connection) edgeGroupService, err := edgegroup.NewService(store.connection)
if err != nil { if err != nil {
return err return err
@ -269,6 +279,10 @@ func (store *Store) EdgeStack() dataservices.EdgeStackService {
return store.EdgeStackService return store.EdgeStackService
} }
func (store *Store) EdgeStackStatus() dataservices.EdgeStackStatusService {
return store.EdgeStackStatusService
}
// Environment(Endpoint) gives access to the Environment(Endpoint) data management layer // Environment(Endpoint) gives access to the Environment(Endpoint) data management layer
func (store *Store) Endpoint() dataservices.EndpointService { func (store *Store) Endpoint() dataservices.EndpointService {
return store.EndpointService return store.EndpointService

View file

@ -32,6 +32,10 @@ func (tx *StoreTx) EdgeStack() dataservices.EdgeStackService {
return tx.store.EdgeStackService.Tx(tx.tx) return tx.store.EdgeStackService.Tx(tx.tx)
} }
func (tx *StoreTx) EdgeStackStatus() dataservices.EdgeStackStatusService {
return tx.store.EdgeStackStatusService.Tx(tx.tx)
}
func (tx *StoreTx) Endpoint() dataservices.EndpointService { func (tx *StoreTx) Endpoint() dataservices.EndpointService {
return tx.store.EndpointService.Tx(tx.tx) return tx.store.EndpointService.Tx(tx.tx)
} }

View file

@ -8,6 +8,7 @@
} }
], ],
"edge_stack": null, "edge_stack": null,
"edge_stack_status": null,
"edgegroups": null, "edgegroups": null,
"edgejobs": null, "edgejobs": null,
"endpoint_groups": [ "endpoint_groups": [
@ -120,6 +121,10 @@
"Ecr": { "Ecr": {
"Region": "" "Region": ""
}, },
"Github": {
"OrganisationName": "",
"UseOrganisation": false
},
"Gitlab": { "Gitlab": {
"InstanceURL": "", "InstanceURL": "",
"ProjectId": 0, "ProjectId": 0,
@ -610,7 +615,7 @@
"RequiredPasswordLength": 12 "RequiredPasswordLength": 12
}, },
"KubeconfigExpiry": "0", "KubeconfigExpiry": "0",
"KubectlShellImage": "portainer/kubectl-shell:2.29.0", "KubectlShellImage": "portainer/kubectl-shell:2.32.0",
"LDAPSettings": { "LDAPSettings": {
"AnonymousMode": true, "AnonymousMode": true,
"AutoCreateUsers": true, "AutoCreateUsers": true,
@ -678,14 +683,11 @@
"Images": null, "Images": null,
"Info": { "Info": {
"Architecture": "", "Architecture": "",
"BridgeNfIp6tables": false,
"BridgeNfIptables": false,
"CDISpecDirs": null, "CDISpecDirs": null,
"CPUSet": false, "CPUSet": false,
"CPUShares": false, "CPUShares": false,
"CgroupDriver": "", "CgroupDriver": "",
"ContainerdCommit": { "ContainerdCommit": {
"Expected": "",
"ID": "" "ID": ""
}, },
"Containers": 0, "Containers": 0,
@ -709,7 +711,6 @@
"IndexServerAddress": "", "IndexServerAddress": "",
"InitBinary": "", "InitBinary": "",
"InitCommit": { "InitCommit": {
"Expected": "",
"ID": "" "ID": ""
}, },
"Isolation": "", "Isolation": "",
@ -738,7 +739,6 @@
}, },
"RegistryConfig": null, "RegistryConfig": null,
"RuncCommit": { "RuncCommit": {
"Expected": "",
"ID": "" "ID": ""
}, },
"Runtimes": null, "Runtimes": null,
@ -780,6 +780,7 @@
"ImageCount": 9, "ImageCount": 9,
"IsPodman": false, "IsPodman": false,
"NodeCount": 0, "NodeCount": 0,
"PerformanceMetrics": null,
"RunningContainerCount": 5, "RunningContainerCount": 5,
"ServiceCount": 0, "ServiceCount": 0,
"StackCount": 2, "StackCount": 2,
@ -943,7 +944,7 @@
} }
], ],
"version": { "version": {
"VERSION": "{\"SchemaVersion\":\"2.29.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}" "VERSION": "{\"SchemaVersion\":\"2.32.0\",\"MigratorCount\":1,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
}, },
"webhooks": null "webhooks": null
} }

View file

@ -32,9 +32,9 @@ type Service struct {
} }
// NewService initializes a new service. // NewService initializes a new service.
func NewService() *Service { func NewService(insecureSkipVerify bool) *Service {
tlsConfig := crypto.CreateTLSConfiguration() tlsConfig := crypto.CreateTLSConfiguration()
tlsConfig.InsecureSkipVerify = true tlsConfig.InsecureSkipVerify = insecureSkipVerify
return &Service{ return &Service{
httpsClient: &http.Client{ httpsClient: &http.Client{

View file

@ -2,6 +2,7 @@ package csrf
import ( import (
"crypto/rand" "crypto/rand"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
@ -9,7 +10,8 @@ import (
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error" httperror "github.com/portainer/portainer/pkg/libhttp/error"
gorillacsrf "github.com/gorilla/csrf" gcsrf "github.com/gorilla/csrf"
"github.com/rs/zerolog/log"
"github.com/urfave/negroni" "github.com/urfave/negroni"
) )
@ -19,7 +21,7 @@ func SkipCSRFToken(w http.ResponseWriter) {
w.Header().Set(csrfSkipHeader, "1") w.Header().Set(csrfSkipHeader, "1")
} }
func WithProtect(handler http.Handler) (http.Handler, error) { func WithProtect(handler http.Handler, trustedOrigins []string) (http.Handler, error) {
// IsDockerDesktopExtension is used to check if we should skip csrf checks in the request bouncer (ShouldSkipCSRFCheck) // IsDockerDesktopExtension is used to check if we should skip csrf checks in the request bouncer (ShouldSkipCSRFCheck)
// DOCKER_EXTENSION is set to '1' in build/docker-extension/docker-compose.yml // DOCKER_EXTENSION is set to '1' in build/docker-extension/docker-compose.yml
isDockerDesktopExtension := false isDockerDesktopExtension := false
@ -34,10 +36,12 @@ func WithProtect(handler http.Handler) (http.Handler, error) {
return nil, fmt.Errorf("failed to generate CSRF token: %w", err) return nil, fmt.Errorf("failed to generate CSRF token: %w", err)
} }
handler = gorillacsrf.Protect( handler = gcsrf.Protect(
token, token,
gorillacsrf.Path("/"), gcsrf.Path("/"),
gorillacsrf.Secure(false), gcsrf.Secure(false),
gcsrf.TrustedOrigins(trustedOrigins),
gcsrf.ErrorHandler(withErrorHandler(trustedOrigins)),
)(handler) )(handler)
return withSkipCSRF(handler, isDockerDesktopExtension), nil return withSkipCSRF(handler, isDockerDesktopExtension), nil
@ -55,7 +59,7 @@ func withSendCSRFToken(handler http.Handler) http.Handler {
} }
if statusCode := sw.Status(); statusCode >= 200 && statusCode < 300 { if statusCode := sw.Status(); statusCode >= 200 && statusCode < 300 {
sw.Header().Set("X-CSRF-Token", gorillacsrf.Token(r)) sw.Header().Set("X-CSRF-Token", gcsrf.Token(r))
} }
}) })
@ -73,9 +77,33 @@ func withSkipCSRF(handler http.Handler, isDockerDesktopExtension bool) http.Hand
} }
if skip { if skip {
r = gorillacsrf.UnsafeSkipCheck(r) r = gcsrf.UnsafeSkipCheck(r)
} }
handler.ServeHTTP(w, r) handler.ServeHTTP(w, r)
}) })
} }
func withErrorHandler(trustedOrigins []string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
err := gcsrf.FailureReason(r)
if errors.Is(err, gcsrf.ErrBadOrigin) || errors.Is(err, gcsrf.ErrBadReferer) || errors.Is(err, gcsrf.ErrNoReferer) {
log.Error().Err(err).
Str("request_url", r.URL.String()).
Str("host", r.Host).
Str("x_forwarded_proto", r.Header.Get("X-Forwarded-Proto")).
Str("forwarded", r.Header.Get("Forwarded")).
Str("origin", r.Header.Get("Origin")).
Str("referer", r.Header.Get("Referer")).
Strs("trusted_origins", trustedOrigins).
Msg("Failed to validate Origin or Referer")
}
http.Error(
w,
http.StatusText(http.StatusForbidden)+" - "+err.Error(),
http.StatusForbidden,
)
})
}

View file

@ -2,6 +2,7 @@ package auth
import ( import (
"net/http" "net/http"
"strconv"
"strings" "strings"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
@ -82,6 +83,11 @@ func (handler *Handler) authenticate(rw http.ResponseWriter, r *http.Request) *h
} }
} }
// Clear any existing user caches
if user != nil {
handler.KubernetesClientFactory.ClearUserClientCache(strconv.Itoa(int(user.ID)))
}
if user != nil && isUserInitialAdmin(user) || settings.AuthenticationMethod == portainer.AuthenticationInternal { if user != nil && isUserInitialAdmin(user) || settings.AuthenticationMethod == portainer.AuthenticationInternal {
return handler.authenticateInternal(rw, user, payload.Password) return handler.authenticateInternal(rw, user, payload.Password)
} }

View file

@ -8,6 +8,7 @@ import (
"github.com/portainer/portainer/api/http/proxy" "github.com/portainer/portainer/api/http/proxy"
"github.com/portainer/portainer/api/http/proxy/factory/kubernetes" "github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/kubernetes/cli"
httperror "github.com/portainer/portainer/pkg/libhttp/error" httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/gorilla/mux" "github.com/gorilla/mux"
@ -23,16 +24,18 @@ type Handler struct {
OAuthService portainer.OAuthService OAuthService portainer.OAuthService
ProxyManager *proxy.Manager ProxyManager *proxy.Manager
KubernetesTokenCacheManager *kubernetes.TokenCacheManager KubernetesTokenCacheManager *kubernetes.TokenCacheManager
KubernetesClientFactory *cli.ClientFactory
passwordStrengthChecker security.PasswordStrengthChecker passwordStrengthChecker security.PasswordStrengthChecker
bouncer security.BouncerService bouncer security.BouncerService
} }
// NewHandler creates a handler to manage authentication operations. // NewHandler creates a handler to manage authentication operations.
func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimiter, passwordStrengthChecker security.PasswordStrengthChecker) *Handler { func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimiter, passwordStrengthChecker security.PasswordStrengthChecker, kubernetesClientFactory *cli.ClientFactory) *Handler {
h := &Handler{ h := &Handler{
Router: mux.NewRouter(), Router: mux.NewRouter(),
passwordStrengthChecker: passwordStrengthChecker, passwordStrengthChecker: passwordStrengthChecker,
bouncer: bouncer, bouncer: bouncer,
KubernetesClientFactory: kubernetesClientFactory,
} }
h.Handle("/auth/oauth/validate", h.Handle("/auth/oauth/validate",

View file

@ -2,6 +2,7 @@ package auth
import ( import (
"net/http" "net/http"
"strconv"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/logoutcontext" "github.com/portainer/portainer/api/logoutcontext"
@ -23,6 +24,7 @@ func (handler *Handler) logout(w http.ResponseWriter, r *http.Request) *httperro
if tokenData != nil { if tokenData != nil {
handler.KubernetesTokenCacheManager.RemoveUserFromCache(tokenData.ID) handler.KubernetesTokenCacheManager.RemoveUserFromCache(tokenData.ID)
handler.KubernetesClientFactory.ClearUserClientCache(strconv.Itoa(int(tokenData.ID)))
logoutcontext.Cancel(tokenData.Token) logoutcontext.Cancel(tokenData.Token)
} }

View file

@ -71,7 +71,7 @@ func (handler *Handler) customTemplateList(w http.ResponseWriter, r *http.Reques
customTemplates = filterByType(customTemplates, templateTypes) customTemplates = filterByType(customTemplates, templateTypes)
if edge != nil { if edge != nil {
customTemplates = slicesx.Filter(customTemplates, func(customTemplate portainer.CustomTemplate) bool { customTemplates = slicesx.FilterInPlace(customTemplates, func(customTemplate portainer.CustomTemplate) bool {
return customTemplate.EdgeTemplate == *edge return customTemplate.EdgeTemplate == *edge
}) })
} }

View file

@ -6,6 +6,7 @@ import (
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/swarm" "github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/api/types/volume" "github.com/docker/docker/api/types/volume"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
@ -116,12 +117,12 @@ func (h *Handler) dashboard(w http.ResponseWriter, r *http.Request) *httperror.H
return err return err
} }
networks, err := cli.NetworkList(r.Context(), types.NetworkListOptions{}) networks, err := cli.NetworkList(r.Context(), network.ListOptions{})
if err != nil { if err != nil {
return httperror.InternalServerError("Unable to retrieve Docker networks", err) return httperror.InternalServerError("Unable to retrieve Docker networks", err)
} }
networks, err = utils.FilterByResourceControl(tx, networks, portainer.NetworkResourceControl, context, func(c types.NetworkResource) string { networks, err = utils.FilterByResourceControl(tx, networks, portainer.NetworkResourceControl, context, func(c network.Summary) string {
return c.Name return c.Name
}) })
if err != nil { if err != nil {

View file

@ -163,7 +163,7 @@ func (handler *Handler) edgeGroupUpdate(w http.ResponseWriter, r *http.Request)
func (handler *Handler) updateEndpointStacks(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack) error { func (handler *Handler) updateEndpointStacks(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack) error {
relation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID) relation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID)
if err != nil && !handler.DataStore.IsErrObjectNotFound(err) { if err != nil {
return err return err
} }
@ -179,12 +179,6 @@ func (handler *Handler) updateEndpointStacks(tx dataservices.DataStoreTx, endpoi
edgeStackSet[edgeStackID] = true edgeStackSet[edgeStackID] = true
} }
if relation == nil {
relation = &portainer.EndpointRelation{
EndpointID: endpoint.ID,
EdgeStacks: make(map[portainer.EdgeStackID]bool),
}
}
relation.EdgeStacks = edgeStackSet relation.EdgeStacks = edgeStackSet
return tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, relation) return tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, relation)

View file

@ -101,8 +101,7 @@ func (payload *edgeStackFromFileUploadPayload) Validate(r *http.Request) error {
// @router /edge_stacks/create/file [post] // @router /edge_stacks/create/file [post]
func (handler *Handler) createEdgeStackFromFileUpload(r *http.Request, tx dataservices.DataStoreTx, dryrun bool) (*portainer.EdgeStack, error) { func (handler *Handler) createEdgeStackFromFileUpload(r *http.Request, tx dataservices.DataStoreTx, dryrun bool) (*portainer.EdgeStack, error) {
payload := &edgeStackFromFileUploadPayload{} payload := &edgeStackFromFileUploadPayload{}
err := payload.Validate(r) if err := payload.Validate(r); err != nil {
if err != nil {
return nil, err return nil, err
} }

View file

@ -103,8 +103,7 @@ func (payload *edgeStackFromGitRepositoryPayload) Validate(r *http.Request) erro
// @router /edge_stacks/create/repository [post] // @router /edge_stacks/create/repository [post]
func (handler *Handler) createEdgeStackFromGitRepository(r *http.Request, tx dataservices.DataStoreTx, dryrun bool, userID portainer.UserID) (*portainer.EdgeStack, error) { func (handler *Handler) createEdgeStackFromGitRepository(r *http.Request, tx dataservices.DataStoreTx, dryrun bool, userID portainer.UserID) (*portainer.EdgeStack, error) {
var payload edgeStackFromGitRepositoryPayload var payload edgeStackFromGitRepositoryPayload
err := request.DecodeAndValidateJSONPayload(r, &payload) if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
if err != nil {
return nil, err return nil, err
} }
@ -137,11 +136,9 @@ func (handler *Handler) createEdgeStackFromGitRepository(r *http.Request, tx dat
} }
func (handler *Handler) storeManifestFromGitRepository(tx dataservices.DataStoreTx, stackFolder string, relatedEndpointIds []portainer.EndpointID, deploymentType portainer.EdgeStackDeploymentType, currentUserID portainer.UserID, repositoryConfig gittypes.RepoConfig) (composePath, manifestPath, projectPath string, err error) { func (handler *Handler) storeManifestFromGitRepository(tx dataservices.DataStoreTx, stackFolder string, relatedEndpointIds []portainer.EndpointID, deploymentType portainer.EdgeStackDeploymentType, currentUserID portainer.UserID, repositoryConfig gittypes.RepoConfig) (composePath, manifestPath, projectPath string, err error) {
hasWrongType, err := hasWrongEnvironmentType(tx.Endpoint(), relatedEndpointIds, deploymentType) if hasWrongType, err := hasWrongEnvironmentType(tx.Endpoint(), relatedEndpointIds, deploymentType); err != nil {
if err != nil {
return "", "", "", fmt.Errorf("unable to check for existence of non fitting environments: %w", err) return "", "", "", fmt.Errorf("unable to check for existence of non fitting environments: %w", err)
} } else if hasWrongType {
if hasWrongType {
return "", "", "", errors.New("edge stack with config do not match the environment type") return "", "", "", errors.New("edge stack with config do not match the environment type")
} }
@ -153,8 +150,7 @@ func (handler *Handler) storeManifestFromGitRepository(tx dataservices.DataStore
repositoryPassword = repositoryConfig.Authentication.Password repositoryPassword = repositoryConfig.Authentication.Password
} }
err = handler.GitService.CloneRepository(projectPath, repositoryConfig.URL, repositoryConfig.ReferenceName, repositoryUsername, repositoryPassword, repositoryConfig.TLSSkipVerify) if err := handler.GitService.CloneRepository(projectPath, repositoryConfig.URL, repositoryConfig.ReferenceName, repositoryUsername, repositoryPassword, repositoryConfig.TLSSkipVerify); err != nil {
if err != nil {
return "", "", "", err return "", "", "", err
} }

View file

@ -76,8 +76,7 @@ func (payload *edgeStackFromStringPayload) Validate(r *http.Request) error {
// @router /edge_stacks/create/string [post] // @router /edge_stacks/create/string [post]
func (handler *Handler) createEdgeStackFromFileContent(r *http.Request, tx dataservices.DataStoreTx, dryrun bool) (*portainer.EdgeStack, error) { func (handler *Handler) createEdgeStackFromFileContent(r *http.Request, tx dataservices.DataStoreTx, dryrun bool) (*portainer.EdgeStack, error) {
var payload edgeStackFromStringPayload var payload edgeStackFromStringPayload
err := request.DecodeAndValidateJSONPayload(r, &payload) if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
if err != nil {
return nil, err return nil, err
} }
@ -96,11 +95,9 @@ func (handler *Handler) createEdgeStackFromFileContent(r *http.Request, tx datas
} }
func (handler *Handler) storeFileContent(tx dataservices.DataStoreTx, stackFolder string, deploymentType portainer.EdgeStackDeploymentType, relatedEndpointIds []portainer.EndpointID, fileContent []byte) (composePath, manifestPath, projectPath string, err error) { func (handler *Handler) storeFileContent(tx dataservices.DataStoreTx, stackFolder string, deploymentType portainer.EdgeStackDeploymentType, relatedEndpointIds []portainer.EndpointID, fileContent []byte) (composePath, manifestPath, projectPath string, err error) {
hasWrongType, err := hasWrongEnvironmentType(tx.Endpoint(), relatedEndpointIds, deploymentType) if hasWrongType, err := hasWrongEnvironmentType(tx.Endpoint(), relatedEndpointIds, deploymentType); err != nil {
if err != nil {
return "", "", "", fmt.Errorf("unable to check for existence of non fitting environments: %w", err) return "", "", "", fmt.Errorf("unable to check for existence of non fitting environments: %w", err)
} } else if hasWrongType {
if hasWrongType {
return "", "", "", errors.New("edge stack with config do not match the environment type") return "", "", "", errors.New("edge stack with config do not match the environment type")
} }
@ -124,7 +121,6 @@ func (handler *Handler) storeFileContent(tx dataservices.DataStoreTx, stackFolde
} }
return "", manifestPath, projectPath, nil return "", manifestPath, projectPath, nil
} }
errMessage := fmt.Sprintf("invalid deployment type: %d", deploymentType) errMessage := fmt.Sprintf("invalid deployment type: %d", deploymentType)

View file

@ -8,6 +8,7 @@ import (
"testing" "testing"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/require"
"github.com/segmentio/encoding/json" "github.com/segmentio/encoding/json"
) )
@ -28,9 +29,7 @@ func TestCreateAndInspect(t *testing.T) {
} }
err := handler.DataStore.EdgeGroup().Create(&edgeGroup) err := handler.DataStore.EdgeGroup().Create(&edgeGroup)
if err != nil { require.NoError(t, err)
t.Fatal(err)
}
endpointRelation := portainer.EndpointRelation{ endpointRelation := portainer.EndpointRelation{
EndpointID: endpoint.ID, EndpointID: endpoint.ID,
@ -38,9 +37,7 @@ func TestCreateAndInspect(t *testing.T) {
} }
err = handler.DataStore.EndpointRelation().Create(&endpointRelation) err = handler.DataStore.EndpointRelation().Create(&endpointRelation)
if err != nil { require.NoError(t, err)
t.Fatal(err)
}
payload := edgeStackFromStringPayload{ payload := edgeStackFromStringPayload{
Name: "test-stack", Name: "test-stack",
@ -50,16 +47,14 @@ func TestCreateAndInspect(t *testing.T) {
} }
jsonPayload, err := json.Marshal(payload) jsonPayload, err := json.Marshal(payload)
if err != nil { require.NoError(t, err)
t.Fatal("JSON marshal error:", err)
}
r := bytes.NewBuffer(jsonPayload) r := bytes.NewBuffer(jsonPayload)
// Create EdgeStack // Create EdgeStack
req, err := http.NewRequest(http.MethodPost, "/edge_stacks/create/string", r) req, err := http.NewRequest(http.MethodPost, "/edge_stacks/create/string", r)
if err != nil { require.NoError(t, err)
t.Fatal("request error:", err)
}
req.Header.Add("x-api-key", rawAPIKey) req.Header.Add("x-api-key", rawAPIKey)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req) handler.ServeHTTP(rec, req)
@ -70,15 +65,11 @@ func TestCreateAndInspect(t *testing.T) {
data := portainer.EdgeStack{} data := portainer.EdgeStack{}
err = json.NewDecoder(rec.Body).Decode(&data) err = json.NewDecoder(rec.Body).Decode(&data)
if err != nil { require.NoError(t, err)
t.Fatal("error decoding response:", err)
}
// Inspect // Inspect
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", data.ID), nil) req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", data.ID), nil)
if err != nil { require.NoError(t, err)
t.Fatal("request error:", err)
}
req.Header.Add("x-api-key", rawAPIKey) req.Header.Add("x-api-key", rawAPIKey)
rec = httptest.NewRecorder() rec = httptest.NewRecorder()
@ -90,9 +81,7 @@ func TestCreateAndInspect(t *testing.T) {
data = portainer.EdgeStack{} data = portainer.EdgeStack{}
err = json.NewDecoder(rec.Body).Decode(&data) err = json.NewDecoder(rec.Body).Decode(&data)
if err != nil { require.NoError(t, err)
t.Fatal("error decoding response:", err)
}
if payload.Name != data.Name { if payload.Name != data.Name {
t.Fatalf("expected EdgeStack Name %s, found %s", payload.Name, data.Name) t.Fatalf("expected EdgeStack Name %s, found %s", payload.Name, data.Name)

View file

@ -30,10 +30,9 @@ func (handler *Handler) edgeStackDelete(w http.ResponseWriter, r *http.Request)
return httperror.BadRequest("Invalid edge stack identifier route variable", err) return httperror.BadRequest("Invalid edge stack identifier route variable", err)
} }
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
return handler.deleteEdgeStack(tx, portainer.EdgeStackID(edgeStackID)) return handler.deleteEdgeStack(tx, portainer.EdgeStackID(edgeStackID))
}) }); err != nil {
if err != nil {
var httpErr *httperror.HandlerError var httpErr *httperror.HandlerError
if errors.As(err, &httpErr) { if errors.As(err, &httpErr) {
return httpErr return httpErr

View file

@ -8,9 +8,10 @@ import (
"testing" "testing"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/assert"
"github.com/segmentio/encoding/json" "github.com/segmentio/encoding/json"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
// Delete // Delete
@ -23,9 +24,7 @@ func TestDeleteAndInspect(t *testing.T) {
// Inspect // Inspect
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil) req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil)
if err != nil { require.NoError(t, err)
t.Fatal("request error:", err)
}
req.Header.Add("x-api-key", rawAPIKey) req.Header.Add("x-api-key", rawAPIKey)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@ -37,9 +36,7 @@ func TestDeleteAndInspect(t *testing.T) {
data := portainer.EdgeStack{} data := portainer.EdgeStack{}
err = json.NewDecoder(rec.Body).Decode(&data) err = json.NewDecoder(rec.Body).Decode(&data)
if err != nil { require.NoError(t, err)
t.Fatal("error decoding response:", err)
}
if data.ID != edgeStack.ID { if data.ID != edgeStack.ID {
t.Fatalf("expected EdgeStackID %d, found %d", int(edgeStack.ID), data.ID) t.Fatalf("expected EdgeStackID %d, found %d", int(edgeStack.ID), data.ID)
@ -47,9 +44,7 @@ func TestDeleteAndInspect(t *testing.T) {
// Delete // Delete
req, err = http.NewRequest(http.MethodDelete, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil) req, err = http.NewRequest(http.MethodDelete, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil)
if err != nil { require.NoError(t, err)
t.Fatal("request error:", err)
}
req.Header.Add("x-api-key", rawAPIKey) req.Header.Add("x-api-key", rawAPIKey)
rec = httptest.NewRecorder() rec = httptest.NewRecorder()
@ -61,9 +56,7 @@ func TestDeleteAndInspect(t *testing.T) {
// Inspect // Inspect
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil) req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil)
if err != nil { require.NoError(t, err)
t.Fatal("request error:", err)
}
req.Header.Add("x-api-key", rawAPIKey) req.Header.Add("x-api-key", rawAPIKey)
rec = httptest.NewRecorder() rec = httptest.NewRecorder()
@ -117,15 +110,12 @@ func TestDeleteEdgeStack_RemoveProjectFolder(t *testing.T) {
} }
var buf bytes.Buffer var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(payload); err != nil { err := json.NewEncoder(&buf).Encode(payload)
t.Fatal("error encoding payload:", err) require.NoError(t, err)
}
// Create // Create
req, err := http.NewRequest(http.MethodPost, "/edge_stacks/create/string", &buf) req, err := http.NewRequest(http.MethodPost, "/edge_stacks/create/string", &buf)
if err != nil { require.NoError(t, err)
t.Fatal("request error:", err)
}
req.Header.Add("x-api-key", rawAPIKey) req.Header.Add("x-api-key", rawAPIKey)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@ -138,9 +128,8 @@ func TestDeleteEdgeStack_RemoveProjectFolder(t *testing.T) {
assert.DirExists(t, handler.FileService.GetEdgeStackProjectPath("1")) assert.DirExists(t, handler.FileService.GetEdgeStackProjectPath("1"))
// Delete // Delete
if req, err = http.NewRequest(http.MethodDelete, "/edge_stacks/1", nil); err != nil { req, err = http.NewRequest(http.MethodDelete, "/edge_stacks/1", nil)
t.Fatal("request error:", err) require.NoError(t, err)
}
req.Header.Add("x-api-key", rawAPIKey) req.Header.Add("x-api-key", rawAPIKey)
rec = httptest.NewRecorder() rec = httptest.NewRecorder()

View file

@ -4,6 +4,7 @@ import (
"net/http" "net/http"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
httperror "github.com/portainer/portainer/pkg/libhttp/error" httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response" "github.com/portainer/portainer/pkg/libhttp/response"
@ -33,5 +34,35 @@ func (handler *Handler) edgeStackInspect(w http.ResponseWriter, r *http.Request)
return handlerDBErr(err, "Unable to find an edge stack with the specified identifier inside the database") return handlerDBErr(err, "Unable to find an edge stack with the specified identifier inside the database")
} }
if err := fillEdgeStackStatus(handler.DataStore, edgeStack); err != nil {
return handlerDBErr(err, "Unable to retrieve edge stack status from the database")
}
return response.JSON(w, edgeStack) return response.JSON(w, edgeStack)
} }
func fillEdgeStackStatus(tx dataservices.DataStoreTx, edgeStack *portainer.EdgeStack) error {
status, err := tx.EdgeStackStatus().ReadAll(edgeStack.ID)
if err != nil {
return err
}
edgeStack.Status = make(map[portainer.EndpointID]portainer.EdgeStackStatus, len(status))
emptyStatus := make([]portainer.EdgeStackDeploymentStatus, 0)
for _, s := range status {
if s.Status == nil {
s.Status = emptyStatus
}
edgeStack.Status[s.EndpointID] = portainer.EdgeStackStatus{
Status: s.Status,
EndpointID: s.EndpointID,
DeploymentInfo: s.DeploymentInfo,
ReadyRePullImage: s.ReadyRePullImage,
}
}
return nil
}

View file

@ -3,10 +3,39 @@ package edgestacks
import ( import (
"net/http" "net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/slicesx"
httperror "github.com/portainer/portainer/pkg/libhttp/error" httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response" "github.com/portainer/portainer/pkg/libhttp/response"
) )
type aggregatedStatusesMap map[portainer.EdgeStackStatusType]int
type SummarizedStatus string
const (
sumStatusUnavailable SummarizedStatus = "Unavailable"
sumStatusDeploying SummarizedStatus = "Deploying"
sumStatusFailed SummarizedStatus = "Failed"
sumStatusPaused SummarizedStatus = "Paused"
sumStatusPartiallyRunning SummarizedStatus = "PartiallyRunning"
sumStatusCompleted SummarizedStatus = "Completed"
sumStatusRunning SummarizedStatus = "Running"
)
type edgeStackStatusSummary struct {
AggregatedStatus aggregatedStatusesMap
Status SummarizedStatus
Reason string
}
type edgeStackListResponseItem struct {
portainer.EdgeStack
StatusSummary edgeStackStatusSummary
}
// @id EdgeStackList // @id EdgeStackList
// @summary Fetches the list of EdgeStacks // @summary Fetches the list of EdgeStacks
// @description **Access policy**: administrator // @description **Access policy**: administrator
@ -14,16 +43,122 @@ import (
// @security ApiKeyAuth // @security ApiKeyAuth
// @security jwt // @security jwt
// @produce json // @produce json
// @param summarizeStatuses query boolean false "will summarize the statuses"
// @success 200 {array} portainer.EdgeStack // @success 200 {array} portainer.EdgeStack
// @failure 500 // @failure 500
// @failure 400 // @failure 400
// @failure 503 "Edge compute features are disabled" // @failure 503 "Edge compute features are disabled"
// @router /edge_stacks [get] // @router /edge_stacks [get]
func (handler *Handler) edgeStackList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { func (handler *Handler) edgeStackList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
summarizeStatuses, _ := request.RetrieveBooleanQueryParameter(r, "summarizeStatuses", true)
edgeStacks, err := handler.DataStore.EdgeStack().EdgeStacks() edgeStacks, err := handler.DataStore.EdgeStack().EdgeStacks()
if err != nil { if err != nil {
return httperror.InternalServerError("Unable to retrieve edge stacks from the database", err) return httperror.InternalServerError("Unable to retrieve edge stacks from the database", err)
} }
return response.JSON(w, edgeStacks) res := make([]edgeStackListResponseItem, len(edgeStacks))
for i := range edgeStacks {
res[i].EdgeStack = edgeStacks[i]
if summarizeStatuses {
if err := fillStatusSummary(handler.DataStore, &res[i]); err != nil {
return handlerDBErr(err, "Unable to retrieve edge stack status from the database")
}
} else if err := fillEdgeStackStatus(handler.DataStore, &res[i].EdgeStack); err != nil {
return handlerDBErr(err, "Unable to retrieve edge stack status from the database")
}
}
return response.JSON(w, res)
}
func fillStatusSummary(tx dataservices.DataStoreTx, edgeStack *edgeStackListResponseItem) error {
statuses, err := tx.EdgeStackStatus().ReadAll(edgeStack.ID)
if err != nil {
return err
}
aggregated := make(aggregatedStatusesMap)
for _, envStatus := range statuses {
for _, status := range envStatus.Status {
aggregated[status.Type]++
}
}
status, reason := SummarizeStatuses(statuses, edgeStack.NumDeployments)
edgeStack.StatusSummary = edgeStackStatusSummary{
AggregatedStatus: aggregated,
Status: status,
Reason: reason,
}
edgeStack.Status = map[portainer.EndpointID]portainer.EdgeStackStatus{}
return nil
}
func SummarizeStatuses(statuses []portainer.EdgeStackStatusForEnv, numDeployments int) (SummarizedStatus, string) {
if numDeployments == 0 {
return sumStatusUnavailable, "Your edge stack is currently unavailable due to the absence of an available environment in your edge group"
}
allStatuses := slicesx.FlatMap(statuses, func(x portainer.EdgeStackStatusForEnv) []portainer.EdgeStackDeploymentStatus {
return x.Status
})
lastStatuses := slicesx.Map(
slicesx.Filter(
statuses,
func(s portainer.EdgeStackStatusForEnv) bool {
return len(s.Status) > 0
},
),
func(x portainer.EdgeStackStatusForEnv) portainer.EdgeStackDeploymentStatus {
return x.Status[len(x.Status)-1]
},
)
if len(lastStatuses) == 0 {
return sumStatusDeploying, ""
}
if allFailed := slicesx.Every(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool {
return s.Type == portainer.EdgeStackStatusError
}); allFailed {
return sumStatusFailed, ""
}
if hasPaused := slicesx.Some(allStatuses, func(s portainer.EdgeStackDeploymentStatus) bool {
return s.Type == portainer.EdgeStackStatusPausedDeploying
}); hasPaused {
return sumStatusPaused, ""
}
if len(lastStatuses) < numDeployments {
return sumStatusDeploying, ""
}
hasDeploying := slicesx.Some(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { return s.Type == portainer.EdgeStackStatusDeploying })
hasRunning := slicesx.Some(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { return s.Type == portainer.EdgeStackStatusRunning })
hasFailed := slicesx.Some(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { return s.Type == portainer.EdgeStackStatusError })
if hasRunning && hasFailed && !hasDeploying {
return sumStatusPartiallyRunning, ""
}
if allCompleted := slicesx.Every(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { return s.Type == portainer.EdgeStackStatusCompleted }); allCompleted {
return sumStatusCompleted, ""
}
if allRunning := slicesx.Every(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool {
return s.Type == portainer.EdgeStackStatusRunning
}); allRunning {
return sumStatusRunning, ""
}
return sumStatusDeploying, ""
} }

View file

@ -9,11 +9,10 @@ import (
"time" "time"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
httperror "github.com/portainer/portainer/pkg/libhttp/error" httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response" "github.com/portainer/portainer/pkg/libhttp/response"
"github.com/rs/zerolog/log"
) )
type updateStatusPayload struct { type updateStatusPayload struct {
@ -78,12 +77,25 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment name: %s", err, endpoint.Name)) return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment name: %s", err, endpoint.Name))
} }
updateFn := func(stack *portainer.EdgeStack) (*portainer.EdgeStack, error) { var stack *portainer.EdgeStack
return handler.updateEdgeStackStatus(stack, stack.ID, payload)
}
stack, err := handler.stackCoordinator.UpdateStatus(r, portainer.EdgeStackID(stackID), updateFn) if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
if err != nil { var err error
stack, err = tx.EdgeStack().EdgeStack(portainer.EdgeStackID(stackID))
if err != nil {
if dataservices.IsErrObjectNotFound(err) {
return nil
}
return httperror.InternalServerError("Unable to retrieve Edge stack from the database", err)
}
if err := handler.updateEdgeStackStatus(tx, stack, stack.ID, payload); err != nil {
return httperror.InternalServerError("Unable to update Edge stack status", err)
}
return nil
}); err != nil {
var httpErr *httperror.HandlerError var httpErr *httperror.HandlerError
if errors.As(err, &httpErr) { if errors.As(err, &httpErr) {
return httpErr return httpErr
@ -96,43 +108,36 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
return nil return nil
} }
if err := fillEdgeStackStatus(handler.DataStore, stack); err != nil {
return handlerDBErr(err, "Unable to retrieve edge stack status from the database")
}
return response.JSON(w, stack) return response.JSON(w, stack)
} }
func (handler *Handler) updateEdgeStackStatus(stack *portainer.EdgeStack, stackID portainer.EdgeStackID, payload updateStatusPayload) (*portainer.EdgeStack, error) { func (handler *Handler) updateEdgeStackStatus(tx dataservices.DataStoreTx, stack *portainer.EdgeStack, stackID portainer.EdgeStackID, payload updateStatusPayload) error {
if payload.Version > 0 && payload.Version < stack.Version { if payload.Version > 0 && payload.Version < stack.Version {
return stack, nil return nil
} }
status := *payload.Status status := *payload.Status
log.Debug().
Int("stackID", int(stackID)).
Int("status", int(status)).
Msg("Updating stack status")
deploymentStatus := portainer.EdgeStackDeploymentStatus{ deploymentStatus := portainer.EdgeStackDeploymentStatus{
Type: status, Type: status,
Error: payload.Error, Error: payload.Error,
Time: payload.Time, Time: payload.Time,
} }
updateEnvStatus(payload.EndpointID, stack, deploymentStatus)
return stack, nil
}
func updateEnvStatus(environmentId portainer.EndpointID, stack *portainer.EdgeStack, deploymentStatus portainer.EdgeStackDeploymentStatus) {
if deploymentStatus.Type == portainer.EdgeStackStatusRemoved { if deploymentStatus.Type == portainer.EdgeStackStatusRemoved {
delete(stack.Status, environmentId) return tx.EdgeStackStatus().Delete(stackID, payload.EndpointID)
return
} }
environmentStatus, ok := stack.Status[environmentId] environmentStatus, err := tx.EdgeStackStatus().Read(stackID, payload.EndpointID)
if !ok { if err != nil && !tx.IsErrObjectNotFound(err) {
environmentStatus = portainer.EdgeStackStatus{ return err
EndpointID: environmentId, } else if tx.IsErrObjectNotFound(err) {
environmentStatus = &portainer.EdgeStackStatusForEnv{
EndpointID: payload.EndpointID,
Status: []portainer.EdgeStackDeploymentStatus{}, Status: []portainer.EdgeStackDeploymentStatus{},
} }
} }
@ -143,5 +148,5 @@ func updateEnvStatus(environmentId portainer.EndpointID, stack *portainer.EdgeSt
environmentStatus.Status = append(environmentStatus.Status, deploymentStatus) environmentStatus.Status = append(environmentStatus.Status, deploymentStatus)
} }
stack.Status[environmentId] = environmentStatus return tx.EdgeStackStatus().Update(stackID, payload.EndpointID, environmentStatus)
} }

View file

@ -1,155 +0,0 @@
package edgestacks
import (
"errors"
"fmt"
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/rs/zerolog/log"
)
type statusRequest struct {
respCh chan statusResponse
stackID portainer.EdgeStackID
updateFn statusUpdateFn
}
type statusResponse struct {
Stack *portainer.EdgeStack
Error error
}
type statusUpdateFn func(*portainer.EdgeStack) (*portainer.EdgeStack, error)
type EdgeStackStatusUpdateCoordinator struct {
updateCh chan statusRequest
dataStore dataservices.DataStore
}
var errAnotherStackUpdateInProgress = errors.New("another stack update is in progress")
func NewEdgeStackStatusUpdateCoordinator(dataStore dataservices.DataStore) *EdgeStackStatusUpdateCoordinator {
return &EdgeStackStatusUpdateCoordinator{
updateCh: make(chan statusRequest),
dataStore: dataStore,
}
}
func (c *EdgeStackStatusUpdateCoordinator) Start() {
for {
c.loop()
}
}
func (c *EdgeStackStatusUpdateCoordinator) loop() {
u := <-c.updateCh
respChs := []chan statusResponse{u.respCh}
var stack *portainer.EdgeStack
err := c.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
// 1. Load the edge stack
var err error
stack, err = loadEdgeStack(tx, u.stackID)
if err != nil {
return err
}
// Return early when the agent tries to update the status on a deleted stack
if stack == nil {
return nil
}
// 2. Mutate the edge stack opportunistically until there are no more pending updates
for {
stack, err = u.updateFn(stack)
if err != nil {
return err
}
if m, ok := c.getNextUpdate(stack.ID); ok {
u = m
} else {
break
}
respChs = append(respChs, u.respCh)
}
// 3. Save the changes back to the database
if err := tx.EdgeStack().UpdateEdgeStack(stack.ID, stack); err != nil {
return handlerDBErr(fmt.Errorf("unable to update Edge stack: %w.", err), "Unable to persist the stack changes inside the database")
}
return nil
})
// 4. Send back the responses
for _, ch := range respChs {
ch <- statusResponse{Stack: stack, Error: err}
}
}
func loadEdgeStack(tx dataservices.DataStoreTx, stackID portainer.EdgeStackID) (*portainer.EdgeStack, error) {
stack, err := tx.EdgeStack().EdgeStack(stackID)
if err != nil {
if dataservices.IsErrObjectNotFound(err) {
// Skip the error when the agent tries to update the status on a deleted stack
log.Debug().
Err(err).
Int("stackID", int(stackID)).
Msg("Unable to find a stack inside the database, skipping error")
return nil, nil
}
return nil, fmt.Errorf("unable to retrieve Edge stack from the database: %w.", err)
}
return stack, nil
}
func (c *EdgeStackStatusUpdateCoordinator) getNextUpdate(stackID portainer.EdgeStackID) (statusRequest, bool) {
for {
select {
case u := <-c.updateCh:
// Discard the update and let the agent retry
if u.stackID != stackID {
u.respCh <- statusResponse{Error: errAnotherStackUpdateInProgress}
continue
}
return u, true
default:
return statusRequest{}, false
}
}
}
func (c *EdgeStackStatusUpdateCoordinator) UpdateStatus(r *http.Request, stackID portainer.EdgeStackID, updateFn statusUpdateFn) (*portainer.EdgeStack, error) {
respCh := make(chan statusResponse)
defer close(respCh)
msg := statusRequest{
respCh: respCh,
stackID: stackID,
updateFn: updateFn,
}
select {
case c.updateCh <- msg:
r := <-respCh
return r.Stack, r.Error
case <-r.Context().Done():
return nil, r.Context().Err()
}
}

View file

@ -10,6 +10,7 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/segmentio/encoding/json" "github.com/segmentio/encoding/json"
"github.com/stretchr/testify/require"
) )
// Update Status // Update Status
@ -28,15 +29,11 @@ func TestUpdateStatusAndInspect(t *testing.T) {
} }
jsonPayload, err := json.Marshal(payload) jsonPayload, err := json.Marshal(payload)
if err != nil { require.NoError(t, err)
t.Fatal("request error:", err)
}
r := bytes.NewBuffer(jsonPayload) r := bytes.NewBuffer(jsonPayload)
req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d/status", edgeStack.ID), r) req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d/status", edgeStack.ID), r)
if err != nil { require.NoError(t, err)
t.Fatal("request error:", err)
}
req.Header.Set(portainer.PortainerAgentEdgeIDHeader, endpoint.EdgeID) req.Header.Set(portainer.PortainerAgentEdgeIDHeader, endpoint.EdgeID)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@ -48,9 +45,7 @@ func TestUpdateStatusAndInspect(t *testing.T) {
// Get updated edge stack // Get updated edge stack
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil) req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil)
if err != nil { require.NoError(t, err)
t.Fatal("request error:", err)
}
req.Header.Add("x-api-key", rawAPIKey) req.Header.Add("x-api-key", rawAPIKey)
rec = httptest.NewRecorder() rec = httptest.NewRecorder()
@ -62,14 +57,10 @@ func TestUpdateStatusAndInspect(t *testing.T) {
updatedStack := portainer.EdgeStack{} updatedStack := portainer.EdgeStack{}
err = json.NewDecoder(rec.Body).Decode(&updatedStack) err = json.NewDecoder(rec.Body).Decode(&updatedStack)
if err != nil { require.NoError(t, err)
t.Fatal("error decoding response:", err)
}
endpointStatus, ok := updatedStack.Status[payload.EndpointID] endpointStatus, ok := updatedStack.Status[payload.EndpointID]
if !ok { require.True(t, ok)
t.Fatal("Missing status")
}
lastStatus := endpointStatus.Status[len(endpointStatus.Status)-1] lastStatus := endpointStatus.Status[len(endpointStatus.Status)-1]
@ -84,8 +75,8 @@ func TestUpdateStatusAndInspect(t *testing.T) {
if endpointStatus.EndpointID != payload.EndpointID { if endpointStatus.EndpointID != payload.EndpointID {
t.Fatalf("expected EndpointID %d, found %d", payload.EndpointID, endpointStatus.EndpointID) t.Fatalf("expected EndpointID %d, found %d", payload.EndpointID, endpointStatus.EndpointID)
} }
} }
func TestUpdateStatusWithInvalidPayload(t *testing.T) { func TestUpdateStatusWithInvalidPayload(t *testing.T) {
handler, _ := setupHandler(t) handler, _ := setupHandler(t)
@ -136,15 +127,11 @@ func TestUpdateStatusWithInvalidPayload(t *testing.T) {
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.Name, func(t *testing.T) { t.Run(tc.Name, func(t *testing.T) {
jsonPayload, err := json.Marshal(tc.Payload) jsonPayload, err := json.Marshal(tc.Payload)
if err != nil { require.NoError(t, err)
t.Fatal("request error:", err)
}
r := bytes.NewBuffer(jsonPayload) r := bytes.NewBuffer(jsonPayload)
req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d/status", edgeStack.ID), r) req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d/status", edgeStack.ID), r)
if err != nil { require.NoError(t, err)
t.Fatal("request error:", err)
}
req.Header.Set(portainer.PortainerAgentEdgeIDHeader, endpoint.EdgeID) req.Header.Set(portainer.PortainerAgentEdgeIDHeader, endpoint.EdgeID)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()

View file

@ -17,6 +17,7 @@ import (
"github.com/portainer/portainer/api/jwt" "github.com/portainer/portainer/api/jwt"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/stretchr/testify/require"
) )
// Helpers // Helpers
@ -51,27 +52,21 @@ func setupHandler(t *testing.T) (*Handler, string) {
t.Fatal(err) t.Fatal(err)
} }
coord := NewEdgeStackStatusUpdateCoordinator(store)
go coord.Start()
handler := NewHandler( handler := NewHandler(
security.NewRequestBouncer(store, jwtService, apiKeyService), security.NewRequestBouncer(store, jwtService, apiKeyService),
store, store,
edgestacks.NewService(store), edgestacks.NewService(store),
coord,
) )
handler.FileService = fs handler.FileService = fs
settings, err := handler.DataStore.Settings().Settings() settings, err := handler.DataStore.Settings().Settings()
if err != nil { require.NoError(t, err)
t.Fatal(err)
}
settings.EnableEdgeComputeFeatures = true settings.EnableEdgeComputeFeatures = true
if err := handler.DataStore.Settings().UpdateSettings(settings); err != nil { err = handler.DataStore.Settings().UpdateSettings(settings)
t.Fatal(err) require.NoError(t, err)
}
handler.GitService = testhelpers.NewGitService(errors.New("Clone error"), "git-service-id") handler.GitService = testhelpers.NewGitService(errors.New("Clone error"), "git-service-id")
@ -90,9 +85,8 @@ func createEndpointWithId(t *testing.T, store dataservices.DataStore, endpointID
LastCheckInDate: time.Now().Unix(), LastCheckInDate: time.Now().Unix(),
} }
if err := store.Endpoint().Create(&endpoint); err != nil { err := store.Endpoint().Create(&endpoint)
t.Fatal(err) require.NoError(t, err)
}
return endpoint return endpoint
} }
@ -113,15 +107,13 @@ func createEdgeStack(t *testing.T, store dataservices.DataStore, endpointID port
PartialMatch: false, PartialMatch: false,
} }
if err := store.EdgeGroup().Create(&edgeGroup); err != nil { err := store.EdgeGroup().Create(&edgeGroup)
t.Fatal(err) require.NoError(t, err)
}
edgeStackID := portainer.EdgeStackID(14) edgeStackID := portainer.EdgeStackID(14)
edgeStack := portainer.EdgeStack{ edgeStack := portainer.EdgeStack{
ID: edgeStackID, ID: edgeStackID,
Name: "test-edge-stack-" + strconv.Itoa(int(edgeStackID)), Name: "test-edge-stack-" + strconv.Itoa(int(edgeStackID)),
Status: map[portainer.EndpointID]portainer.EdgeStackStatus{},
CreationDate: time.Now().Unix(), CreationDate: time.Now().Unix(),
EdgeGroups: []portainer.EdgeGroupID{edgeGroup.ID}, EdgeGroups: []portainer.EdgeGroupID{edgeGroup.ID},
ProjectPath: "/project/path", ProjectPath: "/project/path",
@ -138,13 +130,11 @@ func createEdgeStack(t *testing.T, store dataservices.DataStore, endpointID port
}, },
} }
if err := store.EdgeStack().Create(edgeStack.ID, &edgeStack); err != nil { err = store.EdgeStack().Create(edgeStack.ID, &edgeStack)
t.Fatal(err) require.NoError(t, err)
}
if err := store.EndpointRelation().Create(&endpointRelation); err != nil { err = store.EndpointRelation().Create(&endpointRelation)
t.Fatal(err) require.NoError(t, err)
}
return edgeStack return edgeStack
} }
@ -155,8 +145,8 @@ func createEdgeGroup(t *testing.T, store dataservices.DataStore) portainer.EdgeG
Name: "EdgeGroup 1", Name: "EdgeGroup 1",
} }
if err := store.EdgeGroup().Create(&edgeGroup); err != nil { err := store.EdgeGroup().Create(&edgeGroup)
t.Fatal(err) require.NoError(t, err)
}
return edgeGroup return edgeGroup
} }

View file

@ -74,6 +74,10 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request)
return httperror.InternalServerError("Unexpected error", err) return httperror.InternalServerError("Unexpected error", err)
} }
if err := fillEdgeStackStatus(handler.DataStore, stack); err != nil {
return handlerDBErr(err, "Unable to retrieve edge stack status from the database")
}
return response.JSON(w, stack) return response.JSON(w, stack)
} }
@ -120,7 +124,7 @@ func (handler *Handler) updateEdgeStack(tx dataservices.DataStoreTx, stackID por
stack.EdgeGroups = groupsIds stack.EdgeGroups = groupsIds
if payload.UpdateVersion { if payload.UpdateVersion {
if err := handler.updateStackVersion(stack, payload.DeploymentType, []byte(payload.StackFileContent), "", relatedEndpointIds); err != nil { if err := handler.updateStackVersion(tx, stack, payload.DeploymentType, []byte(payload.StackFileContent), "", relatedEndpointIds); err != nil {
return nil, httperror.InternalServerError("Unable to update stack version", err) return nil, httperror.InternalServerError("Unable to update stack version", err)
} }
} }

View file

@ -25,9 +25,8 @@ func TestUpdateAndInspect(t *testing.T) {
endpointID := portainer.EndpointID(6) endpointID := portainer.EndpointID(6)
newEndpoint := createEndpointWithId(t, handler.DataStore, endpointID) newEndpoint := createEndpointWithId(t, handler.DataStore, endpointID)
if err := handler.DataStore.Endpoint().Create(&newEndpoint); err != nil { err := handler.DataStore.Endpoint().Create(&newEndpoint)
t.Fatal(err) require.NoError(t, err)
}
endpointRelation := portainer.EndpointRelation{ endpointRelation := portainer.EndpointRelation{
EndpointID: endpointID, EndpointID: endpointID,
@ -36,9 +35,8 @@ func TestUpdateAndInspect(t *testing.T) {
}, },
} }
if err := handler.DataStore.EndpointRelation().Create(&endpointRelation); err != nil { err = handler.DataStore.EndpointRelation().Create(&endpointRelation)
t.Fatal(err) require.NoError(t, err)
}
newEdgeGroup := portainer.EdgeGroup{ newEdgeGroup := portainer.EdgeGroup{
ID: 2, ID: 2,
@ -49,9 +47,8 @@ func TestUpdateAndInspect(t *testing.T) {
PartialMatch: false, PartialMatch: false,
} }
if err := handler.DataStore.EdgeGroup().Create(&newEdgeGroup); err != nil { err = handler.DataStore.EdgeGroup().Create(&newEdgeGroup)
t.Fatal(err) require.NoError(t, err)
}
payload := updateEdgeStackPayload{ payload := updateEdgeStackPayload{
StackFileContent: "update-test", StackFileContent: "update-test",
@ -61,15 +58,11 @@ func TestUpdateAndInspect(t *testing.T) {
} }
jsonPayload, err := json.Marshal(payload) jsonPayload, err := json.Marshal(payload)
if err != nil { require.NoError(t, err)
t.Fatal("request error:", err)
}
r := bytes.NewBuffer(jsonPayload) r := bytes.NewBuffer(jsonPayload)
req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), r) req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), r)
if err != nil { require.NoError(t, err)
t.Fatal("request error:", err)
}
req.Header.Add("x-api-key", rawAPIKey) req.Header.Add("x-api-key", rawAPIKey)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@ -81,9 +74,7 @@ func TestUpdateAndInspect(t *testing.T) {
// Get updated edge stack // Get updated edge stack
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil) req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil)
if err != nil { require.NoError(t, err)
t.Fatal("request error:", err)
}
req.Header.Add("x-api-key", rawAPIKey) req.Header.Add("x-api-key", rawAPIKey)
rec = httptest.NewRecorder() rec = httptest.NewRecorder()
@ -94,9 +85,8 @@ func TestUpdateAndInspect(t *testing.T) {
} }
updatedStack := portainer.EdgeStack{} updatedStack := portainer.EdgeStack{}
if err := json.NewDecoder(rec.Body).Decode(&updatedStack); err != nil { err = json.NewDecoder(rec.Body).Decode(&updatedStack)
t.Fatal("error decoding response:", err) require.NoError(t, err)
}
if payload.UpdateVersion && updatedStack.Version != edgeStack.Version+1 { if payload.UpdateVersion && updatedStack.Version != edgeStack.Version+1 {
t.Fatalf("expected EdgeStack version %d, found %d", edgeStack.Version+1, updatedStack.Version+1) t.Fatalf("expected EdgeStack version %d, found %d", edgeStack.Version+1, updatedStack.Version+1)
@ -226,15 +216,11 @@ func TestUpdateWithInvalidPayload(t *testing.T) {
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.Name, func(t *testing.T) { t.Run(tc.Name, func(t *testing.T) {
jsonPayload, err := json.Marshal(tc.Payload) jsonPayload, err := json.Marshal(tc.Payload)
if err != nil { require.NoError(t, err)
t.Fatal("request error:", err)
}
r := bytes.NewBuffer(jsonPayload) r := bytes.NewBuffer(jsonPayload)
req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), r) req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), r)
if err != nil { require.NoError(t, err)
t.Fatal("request error:", err)
}
req.Header.Add("x-api-key", rawAPIKey) req.Header.Add("x-api-key", rawAPIKey)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()

View file

@ -22,17 +22,15 @@ type Handler struct {
GitService portainer.GitService GitService portainer.GitService
edgeStacksService *edgestackservice.Service edgeStacksService *edgestackservice.Service
KubernetesDeployer portainer.KubernetesDeployer KubernetesDeployer portainer.KubernetesDeployer
stackCoordinator *EdgeStackStatusUpdateCoordinator
} }
// NewHandler creates a handler to manage environment(endpoint) group operations. // NewHandler creates a handler to manage environment(endpoint) group operations.
func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, edgeStacksService *edgestackservice.Service, stackCoordinator *EdgeStackStatusUpdateCoordinator) *Handler { func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, edgeStacksService *edgestackservice.Service) *Handler {
h := &Handler{ h := &Handler{
Router: mux.NewRouter(), Router: mux.NewRouter(),
requestBouncer: bouncer, requestBouncer: bouncer,
DataStore: dataStore, DataStore: dataStore,
edgeStacksService: edgeStacksService, edgeStacksService: edgeStacksService,
stackCoordinator: stackCoordinator,
} }
h.Handle("/edge_stacks/create/{method}", h.Handle("/edge_stacks/create/{method}",

View file

@ -5,15 +5,18 @@ import (
"strconv" "strconv"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/filesystem" "github.com/portainer/portainer/api/filesystem"
edgestackutils "github.com/portainer/portainer/api/internal/edge/edgestacks"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
func (handler *Handler) updateStackVersion(stack *portainer.EdgeStack, deploymentType portainer.EdgeStackDeploymentType, config []byte, oldGitHash string, relatedEnvironmentsIDs []portainer.EndpointID) error { func (handler *Handler) updateStackVersion(tx dataservices.DataStoreTx, stack *portainer.EdgeStack, deploymentType portainer.EdgeStackDeploymentType, config []byte, oldGitHash string, relatedEnvironmentsIDs []portainer.EndpointID) error {
stack.Version = stack.Version + 1 stack.Version++
stack.Status = edgestackutils.NewStatus(stack.Status, relatedEnvironmentsIDs)
if err := tx.EdgeStackStatus().Clear(stack.ID, relatedEnvironmentsIDs); err != nil {
return err
}
return handler.storeStackFile(stack, deploymentType, config) return handler.storeStackFile(stack, deploymentType, config)
} }

View file

@ -264,9 +264,6 @@ func (handler *Handler) buildSchedules(tx dataservices.DataStoreTx, endpointID p
func (handler *Handler) buildEdgeStacks(tx dataservices.DataStoreTx, endpointID portainer.EndpointID) ([]stackStatusResponse, *httperror.HandlerError) { func (handler *Handler) buildEdgeStacks(tx dataservices.DataStoreTx, endpointID portainer.EndpointID) ([]stackStatusResponse, *httperror.HandlerError) {
relation, err := tx.EndpointRelation().EndpointRelation(endpointID) relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
if err != nil { if err != nil {
if tx.IsErrObjectNotFound(err) {
return nil, nil
}
return nil, httperror.InternalServerError("Unable to retrieve relation object from the database", err) return nil, httperror.InternalServerError("Unable to retrieve relation object from the database", err)
} }

View file

@ -287,11 +287,8 @@ func TestEdgeStackStatus(t *testing.T) {
edgeStackID := portainer.EdgeStackID(17) edgeStackID := portainer.EdgeStackID(17)
edgeStack := portainer.EdgeStack{ edgeStack := portainer.EdgeStack{
ID: edgeStackID, ID: edgeStackID,
Name: "test-edge-stack-17", Name: "test-edge-stack-17",
Status: map[portainer.EndpointID]portainer.EdgeStackStatus{
endpointID: {},
},
CreationDate: time.Now().Unix(), CreationDate: time.Now().Unix(),
EdgeGroups: []portainer.EdgeGroupID{1, 2}, EdgeGroups: []portainer.EdgeGroupID{1, 2},
ProjectPath: "/project/path", ProjectPath: "/project/path",

View file

@ -21,17 +21,10 @@ func (handler *Handler) updateEndpointRelations(tx dataservices.DataStoreTx, end
} }
endpointRelation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID) endpointRelation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID)
if err != nil && !tx.IsErrObjectNotFound(err) { if err != nil {
return err return err
} }
if endpointRelation == nil {
endpointRelation = &portainer.EndpointRelation{
EndpointID: endpoint.ID,
EdgeStacks: make(map[portainer.EdgeStackID]bool),
}
}
edgeGroups, err := tx.EdgeGroup().ReadAll() edgeGroups, err := tx.EdgeGroup().ReadAll()
if err != nil { if err != nil {
return err return err

View file

@ -563,6 +563,10 @@ func (handler *Handler) saveEndpointAndUpdateAuthorizations(tx dataservices.Data
return err return err
} }
if err := endpointutils.InitializeEdgeEndpointRelation(endpoint, tx); err != nil {
return err
}
for _, tagID := range endpoint.TagIDs { for _, tagID := range endpoint.TagIDs {
if err := tx.Tag().UpdateTagFunc(tagID, func(tag *portainer.Tag) { if err := tx.Tag().UpdateTagFunc(tagID, func(tag *portainer.Tag) {
tag.Endpoints[endpoint.ID] = true tag.Endpoints[endpoint.ID] = true

View file

@ -214,14 +214,9 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
log.Warn().Err(err).Msg("Unable to retrieve edge stacks from the database") log.Warn().Err(err).Msg("Unable to retrieve edge stacks from the database")
} }
for idx := range edgeStacks { for _, edgeStack := range edgeStacks {
edgeStack := &edgeStacks[idx] if err := tx.EdgeStackStatus().Delete(edgeStack.ID, endpoint.ID); err != nil {
if _, ok := edgeStack.Status[endpoint.ID]; ok { log.Warn().Err(err).Msg("Unable to delete edge stack status")
delete(edgeStack.Status, endpoint.ID)
if err := tx.EdgeStack().UpdateEdgeStack(edgeStack.ID, edgeStack); err != nil {
log.Warn().Err(err).Msg("Unable to update edge stack")
}
} }
} }

View file

@ -95,12 +95,11 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
return httperror.BadRequest("Invalid query parameters", err) return httperror.BadRequest("Invalid query parameters", err)
} }
filteredEndpoints := security.FilterEndpoints(endpoints, endpointGroups, securityContext) filteredEndpoints, totalAvailableEndpoints, err := handler.filterEndpointsByQuery(endpoints, query, endpointGroups, edgeGroups, settings, securityContext)
filteredEndpoints, totalAvailableEndpoints, err := handler.filterEndpointsByQuery(filteredEndpoints, query, endpointGroups, edgeGroups, settings)
if err != nil { if err != nil {
return httperror.InternalServerError("Unable to filter endpoints", err) return httperror.InternalServerError("Unable to filter endpoints", err)
} }
filteredEndpoints = security.FilterEndpoints(filteredEndpoints, endpointGroups, securityContext)
sortEnvironmentsByField(filteredEndpoints, endpointGroups, getSortKey(sortField), sortOrder == "desc") sortEnvironmentsByField(filteredEndpoints, endpointGroups, getSortKey(sortField), sortOrder == "desc")

View file

@ -75,7 +75,7 @@ func (handler *Handler) listRegistries(tx dataservices.DataStoreTx, r *http.Requ
return nil, httperror.InternalServerError("Unable to retrieve registries from the database", err) return nil, httperror.InternalServerError("Unable to retrieve registries from the database", err)
} }
registries, handleError := handler.filterRegistriesByAccess(r, registries, endpoint, user, securityContext.UserMemberships) registries, handleError := handler.filterRegistriesByAccess(tx, r, registries, endpoint, user, securityContext.UserMemberships)
if handleError != nil { if handleError != nil {
return nil, handleError return nil, handleError
} }
@ -87,15 +87,15 @@ func (handler *Handler) listRegistries(tx dataservices.DataStoreTx, r *http.Requ
return registries, err return registries, err
} }
func (handler *Handler) filterRegistriesByAccess(r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User, memberships []portainer.TeamMembership) ([]portainer.Registry, *httperror.HandlerError) { func (handler *Handler) filterRegistriesByAccess(tx dataservices.DataStoreTx, r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User, memberships []portainer.TeamMembership) ([]portainer.Registry, *httperror.HandlerError) {
if !endpointutils.IsKubernetesEndpoint(endpoint) { if !endpointutils.IsKubernetesEndpoint(endpoint) {
return security.FilterRegistries(registries, user, memberships, endpoint.ID), nil return security.FilterRegistries(registries, user, memberships, endpoint.ID), nil
} }
return handler.filterKubernetesEndpointRegistries(r, registries, endpoint, user, memberships) return handler.filterKubernetesEndpointRegistries(tx, r, registries, endpoint, user, memberships)
} }
func (handler *Handler) filterKubernetesEndpointRegistries(r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User, memberships []portainer.TeamMembership) ([]portainer.Registry, *httperror.HandlerError) { func (handler *Handler) filterKubernetesEndpointRegistries(tx dataservices.DataStoreTx, r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User, memberships []portainer.TeamMembership) ([]portainer.Registry, *httperror.HandlerError) {
namespaceParam, _ := request.RetrieveQueryParameter(r, "namespace", true) namespaceParam, _ := request.RetrieveQueryParameter(r, "namespace", true)
isAdmin, err := security.IsAdmin(r) isAdmin, err := security.IsAdmin(r)
if err != nil { if err != nil {
@ -116,7 +116,7 @@ func (handler *Handler) filterKubernetesEndpointRegistries(r *http.Request, regi
return registries, nil return registries, nil
} }
return handler.filterKubernetesRegistriesByUserRole(r, registries, endpoint, user) return handler.filterKubernetesRegistriesByUserRole(tx, r, registries, endpoint, user)
} }
func (handler *Handler) isNamespaceAuthorized(endpoint *portainer.Endpoint, namespace string, userId portainer.UserID, memberships []portainer.TeamMembership, isAdmin bool) (bool, error) { func (handler *Handler) isNamespaceAuthorized(endpoint *portainer.Endpoint, namespace string, userId portainer.UserID, memberships []portainer.TeamMembership, isAdmin bool) (bool, error) {
@ -169,7 +169,7 @@ func registryAccessPoliciesContainsNamespace(registryAccess portainer.RegistryAc
return false return false
} }
func (handler *Handler) filterKubernetesRegistriesByUserRole(r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User) ([]portainer.Registry, *httperror.HandlerError) { func (handler *Handler) filterKubernetesRegistriesByUserRole(tx dataservices.DataStoreTx, r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User) ([]portainer.Registry, *httperror.HandlerError) {
err := handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) err := handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
if errors.Is(err, security.ErrAuthorizationRequired) { if errors.Is(err, security.ErrAuthorizationRequired) {
return nil, httperror.Forbidden("User is not authorized", err) return nil, httperror.Forbidden("User is not authorized", err)
@ -178,7 +178,7 @@ func (handler *Handler) filterKubernetesRegistriesByUserRole(r *http.Request, re
return nil, httperror.InternalServerError("Unable to retrieve info from request context", err) return nil, httperror.InternalServerError("Unable to retrieve info from request context", err)
} }
userNamespaces, err := handler.userNamespaces(endpoint, user) userNamespaces, err := handler.userNamespaces(tx, endpoint, user)
if err != nil { if err != nil {
return nil, httperror.InternalServerError("unable to retrieve user namespaces", err) return nil, httperror.InternalServerError("unable to retrieve user namespaces", err)
} }
@ -186,7 +186,7 @@ func (handler *Handler) filterKubernetesRegistriesByUserRole(r *http.Request, re
return filterRegistriesByNamespaces(registries, endpoint.ID, userNamespaces), nil return filterRegistriesByNamespaces(registries, endpoint.ID, userNamespaces), nil
} }
func (handler *Handler) userNamespaces(endpoint *portainer.Endpoint, user *portainer.User) ([]string, error) { func (handler *Handler) userNamespaces(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint, user *portainer.User) ([]string, error) {
kcl, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint) kcl, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint)
if err != nil { if err != nil {
return nil, err return nil, err
@ -197,7 +197,7 @@ func (handler *Handler) userNamespaces(endpoint *portainer.Endpoint, user *porta
return nil, err return nil, err
} }
userMemberships, err := handler.DataStore.TeamMembership().TeamMembershipsByUserID(user.ID) userMemberships, err := tx.TeamMembership().TeamMembershipsByUserID(user.ID)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -11,6 +11,7 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/handler/edgegroups" "github.com/portainer/portainer/api/http/handler/edgegroups"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/edge" "github.com/portainer/portainer/api/internal/edge"
"github.com/portainer/portainer/api/internal/endpointutils" "github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/slicesx" "github.com/portainer/portainer/api/slicesx"
@ -140,6 +141,7 @@ func (handler *Handler) filterEndpointsByQuery(
groups []portainer.EndpointGroup, groups []portainer.EndpointGroup,
edgeGroups []portainer.EdgeGroup, edgeGroups []portainer.EdgeGroup,
settings *portainer.Settings, settings *portainer.Settings,
context *security.RestrictedRequestContext,
) ([]portainer.Endpoint, int, error) { ) ([]portainer.Endpoint, int, error) {
totalAvailableEndpoints := len(filteredEndpoints) totalAvailableEndpoints := len(filteredEndpoints)
@ -181,11 +183,16 @@ func (handler *Handler) filterEndpointsByQuery(
} }
// filter edge environments by trusted/untrusted // filter edge environments by trusted/untrusted
// only portainer admins are allowed to see untrusted environments
filteredEndpoints = filter(filteredEndpoints, func(endpoint portainer.Endpoint) bool { filteredEndpoints = filter(filteredEndpoints, func(endpoint portainer.Endpoint) bool {
if !endpointutils.IsEdgeEndpoint(&endpoint) { if !endpointutils.IsEdgeEndpoint(&endpoint) {
return true return true
} }
if query.edgeDeviceUntrusted {
return !endpoint.UserTrusted && context.IsAdmin
}
return endpoint.UserTrusted == !query.edgeDeviceUntrusted return endpoint.UserTrusted == !query.edgeDeviceUntrusted
}) })
@ -247,19 +254,17 @@ func (handler *Handler) filterEndpointsByQuery(
return filteredEndpoints, totalAvailableEndpoints, nil return filteredEndpoints, totalAvailableEndpoints, nil
} }
func endpointStatusInStackMatchesFilter(edgeStackStatus map[portainer.EndpointID]portainer.EdgeStackStatus, envId portainer.EndpointID, statusFilter portainer.EdgeStackStatusType) bool { func endpointStatusInStackMatchesFilter(stackStatus *portainer.EdgeStackStatusForEnv, envId portainer.EndpointID, statusFilter portainer.EdgeStackStatusType) bool {
status, ok := edgeStackStatus[envId]
// consider that if the env has no status in the stack it is in Pending state // consider that if the env has no status in the stack it is in Pending state
if statusFilter == portainer.EdgeStackStatusPending { if statusFilter == portainer.EdgeStackStatusPending {
return !ok || len(status.Status) == 0 return stackStatus == nil || len(stackStatus.Status) == 0
} }
if !ok { if stackStatus == nil {
return false return false
} }
return slices.ContainsFunc(status.Status, func(s portainer.EdgeStackDeploymentStatus) bool { return slices.ContainsFunc(stackStatus.Status, func(s portainer.EdgeStackDeploymentStatus) bool {
return s.Type == statusFilter return s.Type == statusFilter
}) })
} }
@ -291,7 +296,14 @@ func filterEndpointsByEdgeStack(endpoints []portainer.Endpoint, edgeStackId port
if statusFilter != nil { if statusFilter != nil {
n := 0 n := 0
for _, envId := range envIds { for _, envId := range envIds {
if endpointStatusInStackMatchesFilter(stack.Status, envId, *statusFilter) { edgeStackStatus, err := datastore.EdgeStackStatus().Read(edgeStackId, envId)
if dataservices.IsErrObjectNotFound(err) {
continue
} else if err != nil {
return nil, errors.WithMessagef(err, "Unable to retrieve edge stack status for environment %d", envId)
}
if endpointStatusInStackMatchesFilter(edgeStackStatus, envId, *statusFilter) {
envIds[n] = envId envIds[n] = envId
n++ n++
} }

View file

@ -6,6 +6,7 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore" "github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers" "github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/slicesx" "github.com/portainer/portainer/api/slicesx"
@ -263,6 +264,7 @@ func runTest(t *testing.T, test filterTest, handler *Handler, endpoints []portai
[]portainer.EndpointGroup{}, []portainer.EndpointGroup{},
[]portainer.EdgeGroup{}, []portainer.EdgeGroup{},
&portainer.Settings{}, &portainer.Settings{},
&security.RestrictedRequestContext{IsAdmin: true},
) )
is.NoError(err) is.NoError(err)

View file

@ -17,17 +17,7 @@ func (handler *Handler) updateEdgeRelations(tx dataservices.DataStoreTx, endpoin
relation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID) relation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID)
if err != nil { if err != nil {
if !tx.IsErrObjectNotFound(err) { return errors.WithMessage(err, "Unable to retrieve environment relation inside the database")
return errors.WithMessage(err, "Unable to retrieve environment relation inside the database")
}
relation = &portainer.EndpointRelation{
EndpointID: endpoint.ID,
EdgeStacks: map[portainer.EdgeStackID]bool{},
}
if err := tx.EndpointRelation().Create(relation); err != nil {
return errors.WithMessage(err, "Unable to create environment relation inside the database")
}
} }
endpointGroup, err := tx.EndpointGroup().Read(endpoint.GroupID) endpointGroup, err := tx.EndpointGroup().Read(endpoint.GroupID)

View file

@ -17,12 +17,12 @@ type Handler struct {
} }
// NewHandler creates a handler to serve static files. // NewHandler creates a handler to serve static files.
func NewHandler(assetPublicPath string, wasInstanceDisabled func() bool) *Handler { func NewHandler(assetPublicPath string, csp bool, wasInstanceDisabled func() bool) *Handler {
h := &Handler{ h := &Handler{
Handler: security.MWSecureHeaders( Handler: security.MWSecureHeaders(
gzhttp.GzipHandler(http.FileServer(http.Dir(assetPublicPath))), gzhttp.GzipHandler(http.FileServer(http.Dir(assetPublicPath))),
featureflags.IsEnabled("hsts"), featureflags.IsEnabled("hsts"),
featureflags.IsEnabled("csp"), csp,
), ),
wasInstanceDisabled: wasInstanceDisabled, wasInstanceDisabled: wasInstanceDisabled,
} }
@ -36,6 +36,7 @@ func isHTML(acceptContent []string) bool {
return true return true
} }
} }
return false return false
} }
@ -43,11 +44,13 @@ func (handler *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if handler.wasInstanceDisabled() { if handler.wasInstanceDisabled() {
if r.RequestURI == "/" || r.RequestURI == "/index.html" { if r.RequestURI == "/" || r.RequestURI == "/index.html" {
http.Redirect(w, r, "/timeout.html", http.StatusTemporaryRedirect) http.Redirect(w, r, "/timeout.html", http.StatusTemporaryRedirect)
return return
} }
} else { } else {
if strings.HasPrefix(r.RequestURI, "/timeout.html") { if strings.HasPrefix(r.RequestURI, "/timeout.html") {
http.Redirect(w, r, "/", http.StatusTemporaryRedirect) http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
return return
} }
} }

View file

@ -81,7 +81,7 @@ type Handler struct {
} }
// @title PortainerCE API // @title PortainerCE API
// @version 2.29.0 // @version 2.32.0
// @description.markdown api-description.md // @description.markdown api-description.md
// @termsOfService // @termsOfService

View file

@ -7,6 +7,7 @@ import (
"github.com/portainer/portainer/pkg/libhelm/options" "github.com/portainer/portainer/pkg/libhelm/options"
httperror "github.com/portainer/portainer/pkg/libhttp/error" httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
@ -17,6 +18,8 @@ import (
// @description **Access policy**: authenticated // @description **Access policy**: authenticated
// @tags helm // @tags helm
// @param repo query string true "Helm repository URL" // @param repo query string true "Helm repository URL"
// @param chart query string false "Helm chart name"
// @param useCache query string false "If true will use cache to search"
// @security ApiKeyAuth // @security ApiKeyAuth
// @security jwt // @security jwt
// @produce json // @produce json
@ -32,13 +35,19 @@ func (handler *Handler) helmRepoSearch(w http.ResponseWriter, r *http.Request) *
return httperror.BadRequest("Bad request", errors.New("missing `repo` query parameter")) return httperror.BadRequest("Bad request", errors.New("missing `repo` query parameter"))
} }
chart, _ := request.RetrieveQueryParameter(r, "chart", false)
// If true will useCache to search, will always add to cache after
useCache, _ := request.RetrieveBooleanQueryParameter(r, "useCache", false)
_, err := url.ParseRequestURI(repo) _, err := url.ParseRequestURI(repo)
if err != nil { if err != nil {
return httperror.BadRequest("Bad request", errors.Wrap(err, fmt.Sprintf("provided URL %q is not valid", repo))) return httperror.BadRequest("Bad request", errors.Wrap(err, fmt.Sprintf("provided URL %q is not valid", repo)))
} }
searchOpts := options.SearchRepoOptions{ searchOpts := options.SearchRepoOptions{
Repo: repo, Repo: repo,
Chart: chart,
UseCache: useCache,
} }
result, err := handler.helmPackageManager.SearchRepo(searchOpts) result, err := handler.helmPackageManager.SearchRepo(searchOpts)

View file

@ -20,6 +20,7 @@ import (
// @tags helm // @tags helm
// @param repo query string true "Helm repository URL" // @param repo query string true "Helm repository URL"
// @param chart query string true "Chart name" // @param chart query string true "Chart name"
// @param version query string true "Chart version"
// @param command path string true "chart/values/readme" // @param command path string true "chart/values/readme"
// @security ApiKeyAuth // @security ApiKeyAuth
// @security jwt // @security jwt
@ -45,6 +46,11 @@ func (handler *Handler) helmShow(w http.ResponseWriter, r *http.Request) *httper
return httperror.BadRequest("Bad request", errors.New("missing `chart` query parameter")) return httperror.BadRequest("Bad request", errors.New("missing `chart` query parameter"))
} }
version, err := request.RetrieveQueryParameter(r, "version", true)
if err != nil {
return httperror.BadRequest("Bad request", errors.Wrap(err, fmt.Sprintf("provided version %q is not valid", version)))
}
cmd, err := request.RetrieveRouteVariableValue(r, "command") cmd, err := request.RetrieveRouteVariableValue(r, "command")
if err != nil { if err != nil {
cmd = "all" cmd = "all"
@ -55,6 +61,7 @@ func (handler *Handler) helmShow(w http.ResponseWriter, r *http.Request) *httper
OutputFormat: options.ShowOutputFormat(cmd), OutputFormat: options.ShowOutputFormat(cmd),
Chart: chart, Chart: chart,
Repo: repo, Repo: repo,
Version: version,
} }
result, err := handler.helmPackageManager.Show(showOptions) result, err := handler.helmPackageManager.Show(showOptions)
if err != nil { if err != nil {

View file

@ -2,8 +2,10 @@ package kubernetes
import ( import (
"net/http" "net/http"
"strconv"
"github.com/portainer/portainer/api/http/middlewares" "github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/kubernetes/cli" "github.com/portainer/portainer/api/kubernetes/cli"
httperror "github.com/portainer/portainer/pkg/libhttp/error" httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
@ -25,13 +27,19 @@ func (handler *Handler) prepareKubeClient(r *http.Request) (*cli.KubeClient, *ht
return nil, httperror.NotFound("Unable to find the Kubernetes endpoint associated to the request.", err) return nil, httperror.NotFound("Unable to find the Kubernetes endpoint associated to the request.", err)
} }
pcli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint) tokenData, err := security.RetrieveTokenData(r)
if err != nil {
log.Error().Err(err).Str("context", "prepareKubeClient").Msg("Unable to retrieve token data associated to the request.")
return nil, httperror.InternalServerError("Unable to retrieve token data associated to the request.", err)
}
pcli, err := handler.KubernetesClientFactory.GetPrivilegedUserKubeClient(endpoint, strconv.Itoa(int(tokenData.ID)))
if err != nil { if err != nil {
log.Error().Err(err).Str("context", "prepareKubeClient").Msg("Unable to get a privileged Kubernetes client for the user.") log.Error().Err(err).Str("context", "prepareKubeClient").Msg("Unable to get a privileged Kubernetes client for the user.")
return nil, httperror.InternalServerError("Unable to get a privileged Kubernetes client for the user.", err) return nil, httperror.InternalServerError("Unable to get a privileged Kubernetes client for the user.", err)
} }
pcli.IsKubeAdmin = cli.IsKubeAdmin pcli.SetIsKubeAdmin(cli.GetIsKubeAdmin())
pcli.NonAdminNamespaces = cli.NonAdminNamespaces pcli.SetClientNonAdminNamespaces(cli.GetClientNonAdminNamespaces())
return pcli, nil return pcli, nil
} }

View file

@ -32,7 +32,7 @@ func (handler *Handler) getAllKubernetesClusterRoleBindings(w http.ResponseWrite
return httperror.Forbidden("User is not authorized to fetch cluster role bindings from the Kubernetes cluster.", httpErr) return httperror.Forbidden("User is not authorized to fetch cluster role bindings from the Kubernetes cluster.", httpErr)
} }
if !cli.IsKubeAdmin { if !cli.GetIsKubeAdmin() {
log.Error().Str("context", "getAllKubernetesClusterRoleBindings").Msg("user is not authorized to fetch cluster role bindings from the Kubernetes cluster.") log.Error().Str("context", "getAllKubernetesClusterRoleBindings").Msg("user is not authorized to fetch cluster role bindings from the Kubernetes cluster.")
return httperror.Forbidden("User is not authorized to fetch cluster role bindings from the Kubernetes cluster.", nil) return httperror.Forbidden("User is not authorized to fetch cluster role bindings from the Kubernetes cluster.", nil)
} }

View file

@ -32,7 +32,7 @@ func (handler *Handler) getAllKubernetesClusterRoles(w http.ResponseWriter, r *h
return httperror.Forbidden("User is not authorized to fetch cluster roles from the Kubernetes cluster.", httpErr) return httperror.Forbidden("User is not authorized to fetch cluster roles from the Kubernetes cluster.", httpErr)
} }
if !cli.IsKubeAdmin { if !cli.GetIsKubeAdmin() {
log.Error().Str("context", "getAllKubernetesClusterRoles").Msg("user is not authorized to fetch cluster roles from the Kubernetes cluster.") log.Error().Str("context", "getAllKubernetesClusterRoles").Msg("user is not authorized to fetch cluster roles from the Kubernetes cluster.")
return httperror.Forbidden("User is not authorized to fetch cluster roles from the Kubernetes cluster.", nil) return httperror.Forbidden("User is not authorized to fetch cluster roles from the Kubernetes cluster.", nil)
} }

View file

@ -0,0 +1,102 @@
package kubernetes
import (
"net/http"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/rs/zerolog/log"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
)
// @id getKubernetesEventsForNamespace
// @summary Gets kubernetes events for namespace
// @description Get events by optional query param resourceId for a given namespace.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @produce json
// @param id path int true "Environment identifier"
// @param namespace path string true "The namespace name the events are associated to"
// @param resourceId query string false "The resource id of the involved kubernetes object" example:"e5b021b6-4bce-4c06-bd3b-6cca906797aa"
// @success 200 {object} []kubernetes.K8sEvent "Success"
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
// @failure 500 "Server error occurred while attempting to retrieve the events within the specified namespace."
// @router /kubernetes/{id}/namespaces/{namespace}/events [get]
func (handler *Handler) getKubernetesEventsForNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil {
log.Error().Err(err).Str("context", "getKubernetesEvents").Str("namespace", namespace).Msg("Unable to retrieve namespace identifier route variable")
return httperror.BadRequest("Unable to retrieve namespace identifier route variable", err)
}
resourceId, err := request.RetrieveQueryParameter(r, "resourceId", true)
if err != nil {
log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unable to retrieve resourceId query parameter")
return httperror.BadRequest("Unable to retrieve resourceId query parameter", err)
}
cli, httpErr := handler.getProxyKubeClient(r)
if httpErr != nil {
log.Error().Err(httpErr).Str("context", "getKubernetesEvents").Str("resourceId", resourceId).Msg("Unable to get a Kubernetes client for the user")
return httperror.InternalServerError("Unable to get a Kubernetes client for the user", httpErr)
}
events, err := cli.GetEvents(namespace, resourceId)
if err != nil {
if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) {
log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unauthorized access to the Kubernetes API")
return httperror.Forbidden("Unauthorized access to the Kubernetes API", err)
}
log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unable to retrieve events")
return httperror.InternalServerError("Unable to retrieve events", err)
}
return response.JSON(w, events)
}
// @id getAllKubernetesEvents
// @summary Gets kubernetes events
// @description Get events by query param resourceId
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @produce json
// @param id path int true "Environment identifier"
// @param resourceId query string false "The resource id of the involved kubernetes object" example:"e5b021b6-4bce-4c06-bd3b-6cca906797aa"
// @success 200 {object} []kubernetes.K8sEvent "Success"
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
// @failure 500 "Server error occurred while attempting to retrieve the events."
// @router /kubernetes/{id}/events [get]
func (handler *Handler) getAllKubernetesEvents(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
resourceId, err := request.RetrieveQueryParameter(r, "resourceId", true)
if err != nil {
log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unable to retrieve resourceId query parameter")
return httperror.BadRequest("Unable to retrieve resourceId query parameter", err)
}
cli, httpErr := handler.getProxyKubeClient(r)
if httpErr != nil {
log.Error().Err(httpErr).Str("context", "getKubernetesEvents").Str("resourceId", resourceId).Msg("Unable to get a Kubernetes client for the user")
return httperror.InternalServerError("Unable to get a Kubernetes client for the user", httpErr)
}
events, err := cli.GetEvents("", resourceId)
if err != nil {
if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) {
log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unauthorized access to the Kubernetes API")
return httperror.Forbidden("Unauthorized access to the Kubernetes API", err)
}
log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unable to retrieve events")
return httperror.InternalServerError("Unable to retrieve events", err)
}
return response.JSON(w, events)
}

View file

@ -0,0 +1,60 @@
package kubernetes
import (
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/jwt"
"github.com/portainer/portainer/api/kubernetes"
kubeClient "github.com/portainer/portainer/api/kubernetes/cli"
"github.com/stretchr/testify/assert"
)
// Currently this test just tests the HTTP Handler is setup correctly, in the future we should move the ClientFactory to a mock in order
// test the logic in event.go
func TestGetKubernetesEvents(t *testing.T) {
is := assert.New(t)
_, store := datastore.MustNewTestStore(t, true, true)
err := store.Endpoint().Create(&portainer.Endpoint{
ID: 1,
Type: portainer.AgentOnKubernetesEnvironment,
},
)
is.NoError(err, "error creating environment")
err = store.User().Create(&portainer.User{Username: "admin", Role: portainer.AdministratorRole})
is.NoError(err, "error creating a user")
jwtService, err := jwt.NewService("1h", store)
is.NoError(err, "Error initiating jwt service")
tk, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: 1, Username: "admin", Role: portainer.AdministratorRole})
kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "")
cli := testhelpers.NewKubernetesClient()
factory, _ := kubeClient.NewClientFactory(nil, nil, store, "", "", "")
authorizationService := authorization.NewService(store)
handler := NewHandler(testhelpers.NewTestRequestBouncer(), authorizationService, store, jwtService, kubeClusterAccessService,
factory, cli)
is.NotNil(handler, "Handler should not fail")
req := httptest.NewRequest(http.MethodGet, "/kubernetes/1/events?resourceId=8", nil)
ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1})
req = req.WithContext(ctx)
testhelpers.AddTestSecurityCookie(req, tk)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
is.Equal(http.StatusOK, rr.Code, "Status should be 200")
}

View file

@ -58,6 +58,7 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
endpointRouter.Handle("/configmaps/count", httperror.LoggerHandler(h.getAllKubernetesConfigMapsCount)).Methods(http.MethodGet) endpointRouter.Handle("/configmaps/count", httperror.LoggerHandler(h.getAllKubernetesConfigMapsCount)).Methods(http.MethodGet)
endpointRouter.Handle("/cron_jobs", httperror.LoggerHandler(h.getAllKubernetesCronJobs)).Methods(http.MethodGet) endpointRouter.Handle("/cron_jobs", httperror.LoggerHandler(h.getAllKubernetesCronJobs)).Methods(http.MethodGet)
endpointRouter.Handle("/cron_jobs/delete", httperror.LoggerHandler(h.deleteKubernetesCronJobs)).Methods(http.MethodPost) endpointRouter.Handle("/cron_jobs/delete", httperror.LoggerHandler(h.deleteKubernetesCronJobs)).Methods(http.MethodPost)
endpointRouter.Handle("/events", httperror.LoggerHandler(h.getAllKubernetesEvents)).Methods(http.MethodGet)
endpointRouter.Handle("/jobs", httperror.LoggerHandler(h.getAllKubernetesJobs)).Methods(http.MethodGet) endpointRouter.Handle("/jobs", httperror.LoggerHandler(h.getAllKubernetesJobs)).Methods(http.MethodGet)
endpointRouter.Handle("/jobs/delete", httperror.LoggerHandler(h.deleteKubernetesJobs)).Methods(http.MethodPost) endpointRouter.Handle("/jobs/delete", httperror.LoggerHandler(h.deleteKubernetesJobs)).Methods(http.MethodPost)
endpointRouter.Handle("/cluster_roles", httperror.LoggerHandler(h.getAllKubernetesClusterRoles)).Methods(http.MethodGet) endpointRouter.Handle("/cluster_roles", httperror.LoggerHandler(h.getAllKubernetesClusterRoles)).Methods(http.MethodGet)
@ -110,6 +111,7 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
// to keep it simple, we've decided to leave it like this. // to keep it simple, we've decided to leave it like this.
namespaceRouter := endpointRouter.PathPrefix("/namespaces/{namespace}").Subrouter() namespaceRouter := endpointRouter.PathPrefix("/namespaces/{namespace}").Subrouter()
namespaceRouter.Handle("/configmaps/{configmap}", httperror.LoggerHandler(h.getKubernetesConfigMap)).Methods(http.MethodGet) namespaceRouter.Handle("/configmaps/{configmap}", httperror.LoggerHandler(h.getKubernetesConfigMap)).Methods(http.MethodGet)
namespaceRouter.Handle("/events", httperror.LoggerHandler(h.getKubernetesEventsForNamespace)).Methods(http.MethodGet)
namespaceRouter.Handle("/system", bouncer.RestrictedAccess(httperror.LoggerHandler(h.namespacesToggleSystem))).Methods(http.MethodPut) namespaceRouter.Handle("/system", bouncer.RestrictedAccess(httperror.LoggerHandler(h.namespacesToggleSystem))).Methods(http.MethodPut)
namespaceRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.getKubernetesIngressControllersByNamespace)).Methods(http.MethodGet) namespaceRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.getKubernetesIngressControllersByNamespace)).Methods(http.MethodGet)
namespaceRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.updateKubernetesIngressControllersByNamespace)).Methods(http.MethodPut) namespaceRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.updateKubernetesIngressControllersByNamespace)).Methods(http.MethodPut)
@ -133,7 +135,7 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
// getProxyKubeClient gets a kubeclient for the user. It's generally what you want as it retrieves the kubeclient // getProxyKubeClient gets a kubeclient for the user. It's generally what you want as it retrieves the kubeclient
// from the Authorization token of the currently logged in user. The kubeclient that is not from the proxy is actually using // from the Authorization token of the currently logged in user. The kubeclient that is not from the proxy is actually using
// admin permissions. If you're unsure which one to use, use this. // admin permissions. If you're unsure which one to use, use this.
func (h *Handler) getProxyKubeClient(r *http.Request) (*cli.KubeClient, *httperror.HandlerError) { func (h *Handler) getProxyKubeClient(r *http.Request) (portainer.KubeClient, *httperror.HandlerError) {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil { if err != nil {
return nil, httperror.BadRequest(fmt.Sprintf("an error occurred during the getProxyKubeClient operation, the environment identifier route variable is invalid for /api/kubernetes/%d. Error: ", endpointID), err) return nil, httperror.BadRequest(fmt.Sprintf("an error occurred during the getProxyKubeClient operation, the environment identifier route variable is invalid for /api/kubernetes/%d. Error: ", endpointID), err)
@ -144,7 +146,7 @@ func (h *Handler) getProxyKubeClient(r *http.Request) (*cli.KubeClient, *httperr
return nil, httperror.Forbidden(fmt.Sprintf("an error occurred during the getProxyKubeClient operation, permission denied to access the environment /api/kubernetes/%d. Error: ", endpointID), err) return nil, httperror.Forbidden(fmt.Sprintf("an error occurred during the getProxyKubeClient operation, permission denied to access the environment /api/kubernetes/%d. Error: ", endpointID), err)
} }
cli, ok := h.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), tokenData.Token) cli, ok := h.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), strconv.Itoa(int(tokenData.ID)))
if !ok { if !ok {
return nil, httperror.InternalServerError("an error occurred during the getProxyKubeClient operation,failed to get proxy KubeClient", nil) return nil, httperror.InternalServerError("an error occurred during the getProxyKubeClient operation,failed to get proxy KubeClient", nil)
} }
@ -177,7 +179,7 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
} }
// Check if we have a kubeclient against this auth token already, otherwise generate a new one // Check if we have a kubeclient against this auth token already, otherwise generate a new one
_, ok := handler.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), tokenData.Token) _, ok := handler.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), strconv.Itoa(int(tokenData.ID)))
if ok { if ok {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
@ -253,7 +255,7 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
return return
} }
serverURL.Scheme = "https" serverURL.Scheme = "https"
serverURL.Host = "localhost" + handler.KubernetesClientFactory.AddrHTTPS serverURL.Host = "localhost" + handler.KubernetesClientFactory.GetAddrHTTPS()
config.Clusters[0].Cluster.Server = serverURL.String() config.Clusters[0].Cluster.Server = serverURL.String()
yaml, err := cli.GenerateYAML(config) yaml, err := cli.GenerateYAML(config)
@ -267,7 +269,7 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
return return
} }
handler.KubernetesClientFactory.SetProxyKubeClient(strconv.Itoa(int(endpoint.ID)), tokenData.Token, kubeCli) handler.KubernetesClientFactory.SetProxyKubeClient(strconv.Itoa(int(endpoint.ID)), strconv.Itoa(int(tokenData.ID)), kubeCli)
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
}) })
} }

View file

@ -22,6 +22,7 @@ import (
// @produce json // @produce json
// @param id path int true "Environment identifier" // @param id path int true "Environment identifier"
// @param withResourceQuota query boolean true "When set to true, include the resource quota information as part of the Namespace information. Default is false" // @param withResourceQuota query boolean true "When set to true, include the resource quota information as part of the Namespace information. Default is false"
// @param withUnhealthyEvents query boolean true "When set to true, include the unhealthy events information as part of the Namespace information. Default is false"
// @success 200 {array} portainer.K8sNamespaceInfo "Success" // @success 200 {array} portainer.K8sNamespaceInfo "Success"
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." // @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." // @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
@ -36,6 +37,12 @@ func (handler *Handler) getKubernetesNamespaces(w http.ResponseWriter, r *http.R
return httperror.BadRequest("an error occurred during the GetKubernetesNamespaces operation, invalid query parameter withResourceQuota. Error: ", err) return httperror.BadRequest("an error occurred during the GetKubernetesNamespaces operation, invalid query parameter withResourceQuota. Error: ", err)
} }
withUnhealthyEvents, err := request.RetrieveBooleanQueryParameter(r, "withUnhealthyEvents", true)
if err != nil {
log.Error().Err(err).Str("context", "GetKubernetesNamespaces").Msg("Invalid query parameter withUnhealthyEvents")
return httperror.BadRequest("an error occurred during the GetKubernetesNamespaces operation, invalid query parameter withUnhealthyEvents. Error: ", err)
}
cli, httpErr := handler.prepareKubeClient(r) cli, httpErr := handler.prepareKubeClient(r)
if httpErr != nil { if httpErr != nil {
log.Error().Err(httpErr).Str("context", "GetKubernetesNamespaces").Msg("Unable to get a Kubernetes client for the user") log.Error().Err(httpErr).Str("context", "GetKubernetesNamespaces").Msg("Unable to get a Kubernetes client for the user")
@ -48,6 +55,14 @@ func (handler *Handler) getKubernetesNamespaces(w http.ResponseWriter, r *http.R
return httperror.InternalServerError("an error occurred during the GetKubernetesNamespaces operation, unable to retrieve namespaces from the Kubernetes cluster. Error: ", err) return httperror.InternalServerError("an error occurred during the GetKubernetesNamespaces operation, unable to retrieve namespaces from the Kubernetes cluster. Error: ", err)
} }
if withUnhealthyEvents {
namespaces, err = cli.CombineNamespacesWithUnhealthyEvents(namespaces)
if err != nil {
log.Error().Err(err).Str("context", "GetKubernetesNamespaces").Msg("Unable to combine namespaces with unhealthy events")
return httperror.InternalServerError("an error occurred during the GetKubernetesNamespaces operation, unable to combine namespaces with unhealthy events. Error: ", err)
}
}
if withResourceQuota { if withResourceQuota {
return cli.CombineNamespacesWithResourceQuotas(namespaces, w) return cli.CombineNamespacesWithResourceQuotas(namespaces, w)
} }

View file

@ -7,7 +7,9 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/client" "github.com/portainer/portainer/api/http/client"
"github.com/portainer/portainer/pkg/libcrypto" "github.com/portainer/portainer/pkg/libcrypto"
libclient "github.com/portainer/portainer/pkg/libhttp/client"
"github.com/portainer/portainer/pkg/libhttp/response" "github.com/portainer/portainer/pkg/libhttp/response"
"github.com/rs/zerolog/log"
"github.com/segmentio/encoding/json" "github.com/segmentio/encoding/json"
) )
@ -37,6 +39,12 @@ type motdData struct {
// @success 200 {object} motdResponse // @success 200 {object} motdResponse
// @router /motd [get] // @router /motd [get]
func (handler *Handler) motd(w http.ResponseWriter, r *http.Request) { func (handler *Handler) motd(w http.ResponseWriter, r *http.Request) {
if err := libclient.ExternalRequestDisabled(portainer.MessageOfTheDayURL); err != nil {
log.Debug().Err(err).Msg("External request disabled: MOTD")
response.JSON(w, &motdResponse{Message: ""})
return
}
motd, err := client.Get(portainer.MessageOfTheDayURL, 0) motd, err := client.Get(portainer.MessageOfTheDayURL, 0)
if err != nil { if err != nil {
response.JSON(w, &motdResponse{Message: ""}) response.JSON(w, &motdResponse{Message: ""})

View file

@ -5,10 +5,10 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/proxy" "github.com/portainer/portainer/api/http/proxy"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/endpointutils" "github.com/portainer/portainer/api/internal/registryutils/access"
"github.com/portainer/portainer/api/kubernetes"
"github.com/portainer/portainer/api/kubernetes/cli" "github.com/portainer/portainer/api/kubernetes/cli"
"github.com/portainer/portainer/api/pendingactions" "github.com/portainer/portainer/api/pendingactions"
httperror "github.com/portainer/portainer/pkg/libhttp/error" httperror "github.com/portainer/portainer/pkg/libhttp/error"
@ -17,6 +17,7 @@ import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/rs/zerolog/log"
) )
func hideFields(registry *portainer.Registry, hideAccesses bool) { func hideFields(registry *portainer.Registry, hideAccesses bool) {
@ -56,17 +57,20 @@ func newHandler(bouncer security.BouncerService) *Handler {
func (handler *Handler) initRouter(bouncer accessGuard) { func (handler *Handler) initRouter(bouncer accessGuard) {
adminRouter := handler.NewRoute().Subrouter() adminRouter := handler.NewRoute().Subrouter()
adminRouter.Use(bouncer.AdminAccess) adminRouter.Use(bouncer.AdminAccess)
authenticatedRouter := handler.NewRoute().Subrouter()
authenticatedRouter.Use(bouncer.AuthenticatedAccess)
adminRouter.Handle("/registries", httperror.LoggerHandler(handler.registryList)).Methods(http.MethodGet) adminRouter.Handle("/registries", httperror.LoggerHandler(handler.registryList)).Methods(http.MethodGet)
adminRouter.Handle("/registries", httperror.LoggerHandler(handler.registryCreate)).Methods(http.MethodPost) adminRouter.Handle("/registries", httperror.LoggerHandler(handler.registryCreate)).Methods(http.MethodPost)
adminRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryUpdate)).Methods(http.MethodPut) adminRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryUpdate)).Methods(http.MethodPut)
adminRouter.Handle("/registries/{id}/configure", httperror.LoggerHandler(handler.registryConfigure)).Methods(http.MethodPost) adminRouter.Handle("/registries/{id}/configure", httperror.LoggerHandler(handler.registryConfigure)).Methods(http.MethodPost)
adminRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryDelete)).Methods(http.MethodDelete) adminRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryDelete)).Methods(http.MethodDelete)
authenticatedRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryInspect)).Methods(http.MethodGet) // Use registry-specific access bouncer for inspect and repositories endpoints
registryAccessRouter := handler.NewRoute().Subrouter()
registryAccessRouter.Use(bouncer.AuthenticatedAccess, handler.RegistryAccess)
registryAccessRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryInspect)).Methods(http.MethodGet)
// Keep the gitlab proxy on the regular authenticated router as it doesn't require specific registry access
authenticatedRouter := handler.NewRoute().Subrouter()
authenticatedRouter.Use(bouncer.AuthenticatedAccess)
authenticatedRouter.PathPrefix("/registries/proxies/gitlab").Handler(httperror.LoggerHandler(handler.proxyRequestsToGitlabAPIWithoutRegistry)) authenticatedRouter.PathPrefix("/registries/proxies/gitlab").Handler(httperror.LoggerHandler(handler.proxyRequestsToGitlabAPIWithoutRegistry))
} }
@ -88,9 +92,7 @@ func (handler *Handler) registriesHaveSameURLAndCredentials(r1, r2 *portainer.Re
} }
// this function validates that // this function validates that
//
// 1. user has the appropriate authorizations to perform the request // 1. user has the appropriate authorizations to perform the request
//
// 2. user has a direct or indirect access to the registry // 2. user has a direct or indirect access to the registry
func (handler *Handler) userHasRegistryAccess(r *http.Request, registry *portainer.Registry) (hasAccess bool, isAdmin bool, err error) { func (handler *Handler) userHasRegistryAccess(r *http.Request, registry *portainer.Registry) (hasAccess bool, isAdmin bool, err error) {
securityContext, err := security.RetrieveRestrictedRequestContext(r) securityContext, err := security.RetrieveRestrictedRequestContext(r)
@ -98,11 +100,6 @@ func (handler *Handler) userHasRegistryAccess(r *http.Request, registry *portain
return false, false, err return false, false, err
} }
user, err := handler.DataStore.User().Read(securityContext.UserID)
if err != nil {
return false, false, err
}
// Portainer admins always have access to everything // Portainer admins always have access to everything
if securityContext.IsAdmin { if securityContext.IsAdmin {
return true, true, nil return true, true, nil
@ -128,47 +125,68 @@ func (handler *Handler) userHasRegistryAccess(r *http.Request, registry *portain
return false, false, err return false, false, err
} }
memberships, err := handler.DataStore.TeamMembership().TeamMembershipsByUserID(user.ID) // Use the enhanced registry access utility function that includes namespace validation
_, err = access.GetAccessibleRegistry(
handler.DataStore,
handler.K8sClientFactory,
securityContext.UserID,
endpointId,
registry.ID,
)
if err != nil { if err != nil {
return false, false, nil return false, false, nil // No access
} }
// validate access for kubernetes namespaces (leverage registry.RegistryAccesses[endpointId].Namespaces) return true, false, nil
if endpointutils.IsKubernetesEndpoint(endpoint) { }
kcl, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint)
if err != nil { // RegistryAccess defines a security check for registry-specific API endpoints.
return false, false, errors.Wrap(err, "unable to retrieve kubernetes client to validate registry access") // Authentication is required to access these endpoints.
} // The user must have direct or indirect access to the specific registry being requested.
accessPolicies, err := kcl.GetNamespaceAccessPolicies() // This bouncer validates registry access using the userHasRegistryAccess logic.
if err != nil { func (handler *Handler) RegistryAccess(next http.Handler) http.Handler {
return false, false, errors.Wrap(err, "unable to retrieve environment's namespaces policies to validate registry access") return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
} // First ensure the user is authenticated
tokenData, err := security.RetrieveTokenData(r)
authorizedNamespaces := registry.RegistryAccesses[endpointId].Namespaces if err != nil {
httperror.WriteError(w, http.StatusUnauthorized, "Authentication required", httperrors.ErrUnauthorized)
for _, namespace := range authorizedNamespaces { return
// when the default namespace is authorized to use a registry, all users have the ability to use it }
// unless the default namespace is restricted: in this case continue to search for other potential accesses authorizations
if namespace == kubernetes.DefaultNamespace && !endpoint.Kubernetes.Configuration.RestrictDefaultNamespace { // Extract registry ID from the route
return true, false, nil registryID, err := request.RetrieveNumericRouteVariableValue(r, "id")
} if err != nil {
httperror.WriteError(w, http.StatusBadRequest, "Invalid registry identifier route variable", err)
namespacePolicy := accessPolicies[namespace] return
if security.AuthorizedAccess(user.ID, memberships, namespacePolicy.UserAccessPolicies, namespacePolicy.TeamAccessPolicies) { }
return true, false, nil
} // Get the registry from the database
} registry, err := handler.DataStore.Registry().Read(portainer.RegistryID(registryID))
return false, false, nil if handler.DataStore.IsErrObjectNotFound(err) {
} httperror.WriteError(w, http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err)
return
// validate access for docker environments } else if err != nil {
// leverage registry.RegistryAccesses[endpointId].UserAccessPolicies (direct access) httperror.WriteError(w, http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err)
// and registry.RegistryAccesses[endpointId].TeamAccessPolicies (indirect access via his teams) return
if security.AuthorizedRegistryAccess(registry, user, memberships, endpoint.ID) { }
return true, false, nil
} // Check if user has access to this registry
hasAccess, _, err := handler.userHasRegistryAccess(r, registry)
// when user has no access via their role, direct grant or indirect grant if err != nil {
// then they don't have access to the registry httperror.WriteError(w, http.StatusInternalServerError, "Unable to retrieve info from request context", err)
return false, false, nil return
}
if !hasAccess {
log.Debug().
Int("registry_id", registryID).
Str("registry_name", registry.Name).
Int("user_id", int(tokenData.ID)).
Str("context", "RegistryAccessBouncer").
Msg("User access denied to registry")
httperror.WriteError(w, http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied)
return
}
next.ServeHTTP(w, r)
})
} }

View file

@ -0,0 +1,89 @@
package registries
import (
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/gorilla/mux"
"github.com/stretchr/testify/assert"
)
func Test_RegistryAccess_RequiresAuthentication(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, true)
registry := &portainer.Registry{
ID: 1,
Name: "test-registry",
URL: "https://registry.test.com",
}
err := store.Registry().Create(registry)
assert.NoError(t, err)
handler := &Handler{
DataStore: store,
}
req := httptest.NewRequest(http.MethodGet, "/registries/1", nil)
req = mux.SetURLVars(req, map[string]string{"id": "1"})
rr := httptest.NewRecorder()
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
bouncer := handler.RegistryAccess(testHandler)
bouncer.ServeHTTP(rr, req)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
}
func Test_RegistryAccess_InvalidRegistryID(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, true)
user := &portainer.User{ID: 1, Username: "test", Role: portainer.StandardUserRole}
err := store.User().Create(user)
assert.NoError(t, err)
handler := &Handler{
DataStore: store,
}
req := httptest.NewRequest(http.MethodGet, "/registries/invalid", nil)
req = mux.SetURLVars(req, map[string]string{"id": "invalid"})
tokenData := &portainer.TokenData{ID: 1, Role: portainer.StandardUserRole}
req = req.WithContext(security.StoreTokenData(req, tokenData))
rr := httptest.NewRecorder()
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
bouncer := handler.RegistryAccess(testHandler)
bouncer.ServeHTTP(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code)
}
func Test_RegistryAccess_RegistryNotFound(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, true)
user := &portainer.User{ID: 1, Username: "test", Role: portainer.StandardUserRole}
err := store.User().Create(user)
assert.NoError(t, err)
handler := &Handler{
DataStore: store,
requestBouncer: testhelpers.NewTestRequestBouncer(),
}
req := httptest.NewRequest(http.MethodGet, "/registries/999", nil)
req = mux.SetURLVars(req, map[string]string{"id": "999"})
tokenData := &portainer.TokenData{ID: 1, Role: portainer.StandardUserRole}
req = req.WithContext(security.StoreTokenData(req, tokenData))
rr := httptest.NewRecorder()
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
bouncer := handler.RegistryAccess(testHandler)
bouncer.ServeHTTP(rr, req)
assert.Equal(t, http.StatusNotFound, rr.Code)
}

View file

@ -4,10 +4,12 @@ import (
"net/http" "net/http"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error" httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response" "github.com/portainer/portainer/pkg/libhttp/response"
"github.com/rs/zerolog/log"
) )
// @id RegistryInspect // @id RegistryInspect
@ -31,6 +33,11 @@ func (handler *Handler) registryInspect(w http.ResponseWriter, r *http.Request)
return httperror.BadRequest("Invalid registry identifier route variable", err) return httperror.BadRequest("Invalid registry identifier route variable", err)
} }
log.Debug().
Int("registry_id", registryID).
Str("context", "RegistryInspectHandler").
Msg("Starting registry inspection")
registry, err := handler.DataStore.Registry().Read(portainer.RegistryID(registryID)) registry, err := handler.DataStore.Registry().Read(portainer.RegistryID(registryID))
if handler.DataStore.IsErrObjectNotFound(err) { if handler.DataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find a registry with the specified identifier inside the database", err) return httperror.NotFound("Unable to find a registry with the specified identifier inside the database", err)
@ -38,14 +45,12 @@ func (handler *Handler) registryInspect(w http.ResponseWriter, r *http.Request)
return httperror.InternalServerError("Unable to find a registry with the specified identifier inside the database", err) return httperror.InternalServerError("Unable to find a registry with the specified identifier inside the database", err)
} }
hasAccess, isAdmin, err := handler.userHasRegistryAccess(r, registry) // Check if user is admin to determine if we should hide sensitive fields
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil { if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err) return httperror.InternalServerError("Unable to retrieve info from request context", err)
} }
if !hasAccess {
return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied)
}
hideFields(registry, !isAdmin) hideFields(registry, !securityContext.IsAdmin)
return response.JSON(w, registry) return response.JSON(w, registry)
} }

View file

@ -7,6 +7,7 @@ import (
"github.com/portainer/portainer/api/http/client" "github.com/portainer/portainer/api/http/client"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/pkg/build" "github.com/portainer/portainer/pkg/build"
libclient "github.com/portainer/portainer/pkg/libhttp/client"
httperror "github.com/portainer/portainer/pkg/libhttp/error" httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/response" "github.com/portainer/portainer/pkg/libhttp/response"
@ -69,10 +70,14 @@ func (handler *Handler) version(w http.ResponseWriter, r *http.Request) *httperr
} }
func GetLatestVersion() string { func GetLatestVersion() string {
if err := libclient.ExternalRequestDisabled(portainer.VersionCheckURL); err != nil {
log.Debug().Err(err).Msg("External request disabled: Version check")
return ""
}
motd, err := client.Get(portainer.VersionCheckURL, 5) motd, err := client.Get(portainer.VersionCheckURL, 5)
if err != nil { if err != nil {
log.Debug().Err(err).Msg("couldn't fetch latest Portainer release version") log.Debug().Err(err).Msg("couldn't fetch latest Portainer release version")
return "" return ""
} }

View file

@ -8,6 +8,7 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/internal/edge" "github.com/portainer/portainer/api/internal/edge"
"github.com/portainer/portainer/api/internal/endpointutils"
httperror "github.com/portainer/portainer/pkg/libhttp/error" httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response" "github.com/portainer/portainer/pkg/libhttp/response"
@ -58,6 +59,9 @@ func deleteTag(tx dataservices.DataStoreTx, tagID portainer.TagID) error {
for endpointID := range tag.Endpoints { for endpointID := range tag.Endpoints {
endpoint, err := tx.Endpoint().Endpoint(endpointID) endpoint, err := tx.Endpoint().Endpoint(endpointID)
if tx.IsErrObjectNotFound(err) {
continue
}
if err != nil { if err != nil {
return httperror.InternalServerError("Unable to retrieve environment from the database", err) return httperror.InternalServerError("Unable to retrieve environment from the database", err)
} }
@ -103,15 +107,10 @@ func deleteTag(tx dataservices.DataStoreTx, tagID portainer.TagID) error {
return httperror.InternalServerError("Unable to retrieve edge stacks from the database", err) return httperror.InternalServerError("Unable to retrieve edge stacks from the database", err)
} }
for _, endpoint := range endpoints { edgeJobs, err := tx.EdgeJob().ReadAll()
if (tag.Endpoints[endpoint.ID] || tag.EndpointGroups[endpoint.GroupID]) && (endpoint.Type == portainer.EdgeAgentOnDockerEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment) { if err != nil {
err = updateEndpointRelations(tx, endpoint, edgeGroups, edgeStacks) return httperror.InternalServerError("Unable to retrieve edge job configurations from the database", err)
if err != nil {
return httperror.InternalServerError("Unable to update environment relations in the database", err)
}
}
} }
for _, edgeGroup := range edgeGroups { for _, edgeGroup := range edgeGroups {
edgeGroup.TagIDs = slices.DeleteFunc(edgeGroup.TagIDs, func(t portainer.TagID) bool { edgeGroup.TagIDs = slices.DeleteFunc(edgeGroup.TagIDs, func(t portainer.TagID) bool {
return t == tagID return t == tagID
@ -123,6 +122,16 @@ func deleteTag(tx dataservices.DataStoreTx, tagID portainer.TagID) error {
} }
} }
for _, endpoint := range endpoints {
if (!tag.Endpoints[endpoint.ID] && !tag.EndpointGroups[endpoint.GroupID]) || !endpointutils.IsEdgeEndpoint(&endpoint) {
continue
}
if err := updateEndpointRelations(tx, endpoint, edgeGroups, edgeStacks, edgeJobs); err != nil {
return httperror.InternalServerError("Unable to update environment relations in the database", err)
}
}
err = tx.Tag().Delete(tagID) err = tx.Tag().Delete(tagID)
if err != nil { if err != nil {
return httperror.InternalServerError("Unable to remove the tag from the database", err) return httperror.InternalServerError("Unable to remove the tag from the database", err)
@ -131,19 +140,12 @@ func deleteTag(tx dataservices.DataStoreTx, tagID portainer.TagID) error {
return nil return nil
} }
func updateEndpointRelations(tx dataservices.DataStoreTx, endpoint portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack) error { func updateEndpointRelations(tx dataservices.DataStoreTx, endpoint portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack, edgeJobs []portainer.EdgeJob) error {
endpointRelation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID) endpointRelation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID)
if err != nil && !tx.IsErrObjectNotFound(err) { if err != nil {
return err return err
} }
if endpointRelation == nil {
endpointRelation = &portainer.EndpointRelation{
EndpointID: endpoint.ID,
EdgeStacks: make(map[portainer.EdgeStackID]bool),
}
}
endpointGroup, err := tx.EndpointGroup().Read(endpoint.GroupID) endpointGroup, err := tx.EndpointGroup().Read(endpoint.GroupID)
if err != nil { if err != nil {
return err return err
@ -157,5 +159,25 @@ func updateEndpointRelations(tx dataservices.DataStoreTx, endpoint portainer.End
endpointRelation.EdgeStacks = stacksSet endpointRelation.EdgeStacks = stacksSet
return tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, endpointRelation) if err := tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, endpointRelation); err != nil {
return err
}
for _, edgeJob := range edgeJobs {
endpoints, err := edge.GetEndpointsFromEdgeGroups(edgeJob.EdgeGroups, tx)
if err != nil {
return err
}
if slices.Contains(endpoints, endpoint.ID) {
continue
}
delete(edgeJob.GroupLogsCollection, endpoint.ID)
if err := tx.EdgeJob().Update(edgeJob.ID, &edgeJob); err != nil {
return err
}
}
return nil
} }

View file

@ -1,6 +1,7 @@
package tags package tags
import ( import (
"github.com/portainer/portainer/api/dataservices"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strconv" "strconv"
@ -8,23 +9,18 @@ import (
"testing" "testing"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
portainerDsErrors "github.com/portainer/portainer/api/dataservices/errors"
"github.com/portainer/portainer/api/datastore" "github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/internal/testhelpers" "github.com/portainer/portainer/api/internal/testhelpers"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestTagDeleteEdgeGroupsConcurrently(t *testing.T) { func TestTagDeleteEdgeGroupsConcurrently(t *testing.T) {
const tagsCount = 100 const tagsCount = 100
_, store := datastore.MustNewTestStore(t, true, false) handler, store := setUpHandler(t)
user := &portainer.User{ID: 2, Username: "admin", Role: portainer.AdministratorRole}
if err := store.User().Create(user); err != nil {
t.Fatal("could not create admin user:", err)
}
handler := NewHandler(testhelpers.NewTestRequestBouncer())
handler.DataStore = store
// Create all the tags and add them to the same edge group // Create all the tags and add them to the same edge group
var tagIDs []portainer.TagID var tagIDs []portainer.TagID
@ -84,3 +80,132 @@ func TestTagDeleteEdgeGroupsConcurrently(t *testing.T) {
t.Fatal("the edge group is not consistent") t.Fatal("the edge group is not consistent")
} }
} }
func TestHandler_tagDelete(t *testing.T) {
t.Run("should delete tag and update related endpoints and edge groups", func(t *testing.T) {
handler, store := setUpHandler(t)
tag := &portainer.Tag{
ID: 1,
Name: "tag-1",
Endpoints: make(map[portainer.EndpointID]bool),
EndpointGroups: make(map[portainer.EndpointGroupID]bool),
}
require.NoError(t, store.Tag().Create(tag))
endpointGroup := &portainer.EndpointGroup{
ID: 2,
Name: "endpoint-group-1",
TagIDs: []portainer.TagID{tag.ID},
}
require.NoError(t, store.EndpointGroup().Create(endpointGroup))
endpoint1 := &portainer.Endpoint{
ID: 1,
Name: "endpoint-1",
GroupID: endpointGroup.ID,
}
require.NoError(t, store.Endpoint().Create(endpoint1))
endpoint2 := &portainer.Endpoint{
ID: 2,
Name: "endpoint-2",
TagIDs: []portainer.TagID{tag.ID},
}
require.NoError(t, store.Endpoint().Create(endpoint2))
tag.Endpoints[endpoint2.ID] = true
tag.EndpointGroups[endpointGroup.ID] = true
require.NoError(t, store.Tag().Update(tag.ID, tag))
dynamicEdgeGroup := &portainer.EdgeGroup{
ID: 1,
Name: "edgegroup-1",
TagIDs: []portainer.TagID{tag.ID},
Dynamic: true,
}
require.NoError(t, store.EdgeGroup().Create(dynamicEdgeGroup))
staticEdgeGroup := &portainer.EdgeGroup{
ID: 2,
Name: "edgegroup-2",
Endpoints: []portainer.EndpointID{endpoint2.ID},
}
require.NoError(t, store.EdgeGroup().Create(staticEdgeGroup))
req, err := http.NewRequest(http.MethodDelete, "/tags/"+strconv.Itoa(int(tag.ID)), nil)
if err != nil {
t.Fail()
return
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
require.Equal(t, http.StatusNoContent, rec.Code)
// Check that the tag is deleted
_, err = store.Tag().Read(tag.ID)
require.ErrorIs(t, err, portainerDsErrors.ErrObjectNotFound)
// Check that the endpoints are updated
endpoint1, err = store.Endpoint().Endpoint(endpoint1.ID)
require.NoError(t, err)
assert.Len(t, endpoint1.TagIDs, 0, "endpoint-1 should not have any tags")
assert.Equal(t, endpoint1.GroupID, endpointGroup.ID, "endpoint-1 should still belong to the endpoint group")
endpoint2, err = store.Endpoint().Endpoint(endpoint2.ID)
require.NoError(t, err)
assert.Len(t, endpoint2.TagIDs, 0, "endpoint-2 should not have any tags")
// Check that the dynamic edge group is updated
dynamicEdgeGroup, err = store.EdgeGroup().Read(dynamicEdgeGroup.ID)
require.NoError(t, err)
assert.Len(t, dynamicEdgeGroup.TagIDs, 0, "dynamic edge group should not have any tags")
assert.Len(t, dynamicEdgeGroup.Endpoints, 0, "dynamic edge group should not have any endpoints")
// Check that the static edge group is not updated
staticEdgeGroup, err = store.EdgeGroup().Read(staticEdgeGroup.ID)
require.NoError(t, err)
assert.Len(t, staticEdgeGroup.TagIDs, 0, "static edge group should not have any tags")
assert.Len(t, staticEdgeGroup.Endpoints, 1, "static edge group should have one endpoint")
assert.Equal(t, endpoint2.ID, staticEdgeGroup.Endpoints[0], "static edge group should have the endpoint-2")
})
// Test the tx.IsErrObjectNotFound logic when endpoint is not found during cleanup
t.Run("should continue gracefully when endpoint not found during cleanup", func(t *testing.T) {
_, store := setUpHandler(t)
// Create a tag with a reference to a non-existent endpoint
tag := &portainer.Tag{
ID: 1,
Name: "test-tag",
Endpoints: map[portainer.EndpointID]bool{999: true}, // Non-existent endpoint
EndpointGroups: make(map[portainer.EndpointGroupID]bool),
}
err := store.Tag().Create(tag)
if err != nil {
t.Fatal("could not create tag:", err)
}
err = deleteTag(store, 1)
if err != nil {
t.Fatal("could not delete tag:", err)
}
})
}
func setUpHandler(t *testing.T) (*Handler, dataservices.DataStore) {
_, store := datastore.MustNewTestStore(t, true, false)
user := &portainer.User{ID: 2, Username: "admin", Role: portainer.AdministratorRole}
if err := store.User().Create(user); err != nil {
t.Fatal("could not create admin user:", err)
}
handler := NewHandler(testhelpers.NewTestRequestBouncer())
handler.DataStore = store
return handler, store
}

View file

@ -4,7 +4,9 @@ import (
"net/http" "net/http"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
libclient "github.com/portainer/portainer/pkg/libhttp/client"
httperror "github.com/portainer/portainer/pkg/libhttp/error" httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/rs/zerolog/log"
"github.com/segmentio/encoding/json" "github.com/segmentio/encoding/json"
) )
@ -24,18 +26,27 @@ func (handler *Handler) fetchTemplates() (*listResponse, *httperror.HandlerError
templatesURL = portainer.DefaultTemplatesURL templatesURL = portainer.DefaultTemplatesURL
} }
var body *listResponse
if err := libclient.ExternalRequestDisabled(templatesURL); err != nil {
if templatesURL == portainer.DefaultTemplatesURL {
log.Debug().Err(err).Msg("External request disabled: Default templates")
return body, nil
}
}
resp, err := http.Get(templatesURL) resp, err := http.Get(templatesURL)
if err != nil { if err != nil {
return nil, httperror.InternalServerError("Unable to retrieve templates via the network", err) return nil, httperror.InternalServerError("Unable to retrieve templates via the network", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
var body *listResponse if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
err = json.NewDecoder(resp.Body).Decode(&body)
if err != nil {
return nil, httperror.InternalServerError("Unable to parse template file", err) return nil, httperror.InternalServerError("Unable to parse template file", err)
} }
for i := range body.Templates {
body.Templates[i].ID = portainer.TemplateID(i + 1)
}
return body, nil return body, nil
} }

View file

@ -80,7 +80,7 @@ func (handler *Handler) webhookCreate(w http.ResponseWriter, r *http.Request) *h
return httperror.InternalServerError("Unable to retrieve user authentication token", err) return httperror.InternalServerError("Unable to retrieve user authentication token", err)
} }
_, err = access.GetAccessibleRegistry(handler.DataStore, tokenData.ID, endpointID, payload.RegistryID) _, err = access.GetAccessibleRegistry(handler.DataStore, nil, tokenData.ID, endpointID, payload.RegistryID)
if err != nil { if err != nil {
return httperror.Forbidden("Permission deny to access registry", err) return httperror.Forbidden("Permission deny to access registry", err)
} }

View file

@ -69,7 +69,7 @@ func (handler *Handler) webhookUpdate(w http.ResponseWriter, r *http.Request) *h
return httperror.InternalServerError("Unable to retrieve user authentication token", err) return httperror.InternalServerError("Unable to retrieve user authentication token", err)
} }
_, err = access.GetAccessibleRegistry(handler.DataStore, tokenData.ID, webhook.EndpointID, payload.RegistryID) _, err = access.GetAccessibleRegistry(handler.DataStore, nil, tokenData.ID, webhook.EndpointID, payload.RegistryID)
if err != nil { if err != nil {
return httperror.Forbidden("Permission deny to access registry", err) return httperror.Forbidden("Permission deny to access registry", err)
} }

View file

@ -25,12 +25,12 @@ type key int
const contextEndpoint key = 0 const contextEndpoint key = 0
func WithEndpoint(endpointService dataservices.EndpointService, endpointIDParam string) mux.MiddlewareFunc { func WithEndpoint(endpointService dataservices.EndpointService, endpointIDParam string) mux.MiddlewareFunc {
if endpointIDParam == "" {
endpointIDParam = "id"
}
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, request *http.Request) { return http.HandlerFunc(func(rw http.ResponseWriter, request *http.Request) {
if endpointIDParam == "" {
endpointIDParam = "id"
}
endpointID, err := requesthelpers.RetrieveNumericRouteVariableValue(request, endpointIDParam) endpointID, err := requesthelpers.RetrieveNumericRouteVariableValue(request, endpointIDParam)
if err != nil { if err != nil {
httperror.WriteError(rw, http.StatusBadRequest, "Invalid environment identifier route variable", err) httperror.WriteError(rw, http.StatusBadRequest, "Invalid environment identifier route variable", err)
@ -51,7 +51,6 @@ func WithEndpoint(endpointService dataservices.EndpointService, endpointIDParam
ctx := context.WithValue(request.Context(), contextEndpoint, endpoint) ctx := context.WithValue(request.Context(), contextEndpoint, endpoint)
next.ServeHTTP(rw, request.WithContext(ctx)) next.ServeHTTP(rw, request.WithContext(ctx))
}) })
} }
} }

View file

@ -3,6 +3,7 @@ package middlewares
import ( import (
"net/http" "net/http"
"slices" "slices"
"strings"
"github.com/gorilla/csrf" "github.com/gorilla/csrf"
) )
@ -16,6 +17,45 @@ type plainTextHTTPRequestHandler struct {
next http.Handler next http.Handler
} }
// parseForwardedHeaderProto parses the Forwarded header and extracts the protocol.
// The Forwarded header format supports:
// - Single proxy: Forwarded: by=<identifier>;for=<identifier>;host=<host>;proto=<http|https>
// - Multiple proxies: Forwarded: for=192.0.2.43, for=198.51.100.17
// We take the first (leftmost) entry as it represents the original client
func parseForwardedHeaderProto(forwarded string) string {
if forwarded == "" {
return ""
}
// Parse the first part (leftmost proxy, closest to original client)
firstPart, _, _ := strings.Cut(forwarded, ",")
firstPart = strings.TrimSpace(firstPart)
// Split by semicolon to get key-value pairs within this proxy entry
// Format: key=value;key=value;key=value
pairs := strings.Split(firstPart, ";")
for _, pair := range pairs {
// Split by equals sign to separate key and value
key, value, found := strings.Cut(pair, "=")
if !found {
continue
}
if strings.EqualFold(strings.TrimSpace(key), "proto") {
return strings.Trim(strings.TrimSpace(value), `"'`)
}
}
return ""
}
// isHTTPSRequest checks if the original request was made over HTTPS
// by examining both X-Forwarded-Proto and Forwarded headers
func isHTTPSRequest(r *http.Request) bool {
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") ||
strings.EqualFold(parseForwardedHeaderProto(r.Header.Get("Forwarded")), "https")
}
func (h *plainTextHTTPRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h *plainTextHTTPRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if slices.Contains(safeMethods, r.Method) { if slices.Contains(safeMethods, r.Method) {
h.next.ServeHTTP(w, r) h.next.ServeHTTP(w, r)
@ -24,7 +64,7 @@ func (h *plainTextHTTPRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.R
req := r req := r
// If original request was HTTPS (via proxy), keep CSRF checks. // If original request was HTTPS (via proxy), keep CSRF checks.
if xfproto := r.Header.Get("X-Forwarded-Proto"); xfproto != "https" { if !isHTTPSRequest(r) {
req = csrf.PlaintextHTTPRequest(r) req = csrf.PlaintextHTTPRequest(r)
} }

View file

@ -0,0 +1,173 @@
package middlewares
import (
"testing"
)
var tests = []struct {
name string
forwarded string
expected string
}{
{
name: "empty header",
forwarded: "",
expected: "",
},
{
name: "single proxy with proto=https",
forwarded: "proto=https",
expected: "https",
},
{
name: "single proxy with proto=http",
forwarded: "proto=http",
expected: "http",
},
{
name: "single proxy with multiple directives",
forwarded: "for=192.0.2.60;proto=https;by=203.0.113.43",
expected: "https",
},
{
name: "single proxy with proto in middle",
forwarded: "for=192.0.2.60;proto=https;host=example.com",
expected: "https",
},
{
name: "single proxy with proto at end",
forwarded: "for=192.0.2.60;host=example.com;proto=https",
expected: "https",
},
{
name: "multiple proxies - takes first",
forwarded: "proto=https, proto=http",
expected: "https",
},
{
name: "multiple proxies with complex format",
forwarded: "for=192.0.2.43;proto=https, for=198.51.100.17;proto=http",
expected: "https",
},
{
name: "multiple proxies with for directive only",
forwarded: "for=192.0.2.43, for=198.51.100.17",
expected: "",
},
{
name: "multiple proxies with proto only in second",
forwarded: "for=192.0.2.43, proto=https",
expected: "",
},
{
name: "multiple proxies with proto only in first",
forwarded: "proto=https, for=198.51.100.17",
expected: "https",
},
{
name: "quoted protocol value",
forwarded: "proto=\"https\"",
expected: "https",
},
{
name: "single quoted protocol value",
forwarded: "proto='https'",
expected: "https",
},
{
name: "mixed case protocol",
forwarded: "proto=HTTPS",
expected: "HTTPS",
},
{
name: "no proto directive",
forwarded: "for=192.0.2.60;by=203.0.113.43",
expected: "",
},
{
name: "empty proto value",
forwarded: "proto=",
expected: "",
},
{
name: "whitespace around values",
forwarded: " proto = https ",
expected: "https",
},
{
name: "whitespace around semicolons",
forwarded: "for=192.0.2.60 ; proto=https ; by=203.0.113.43",
expected: "https",
},
{
name: "whitespace around commas",
forwarded: "proto=https , proto=http",
expected: "https",
},
{
name: "IPv6 address in for directive",
forwarded: "for=\"[2001:db8:cafe::17]:4711\";proto=https",
expected: "https",
},
{
name: "complex multiple proxies with IPv6",
forwarded: "for=192.0.2.43;proto=https, for=\"[2001:db8:cafe::17]\";proto=http",
expected: "https",
},
{
name: "obfuscated identifiers",
forwarded: "for=_mdn;proto=https",
expected: "https",
},
{
name: "unknown identifier",
forwarded: "for=unknown;proto=https",
expected: "https",
},
{
name: "malformed key-value pair",
forwarded: "proto",
expected: "",
},
{
name: "malformed key-value pair with equals",
forwarded: "proto=",
expected: "",
},
{
name: "multiple equals signs",
forwarded: "proto=https=extra",
expected: "https=extra",
},
{
name: "mixed case directive name",
forwarded: "PROTO=https",
expected: "https",
},
{
name: "mixed case directive name with spaces",
forwarded: " Proto = https ",
expected: "https",
},
}
func TestParseForwardedHeaderProto(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := parseForwardedHeaderProto(tt.forwarded)
if result != tt.expected {
t.Errorf("parseForwardedHeader(%q) = %q, want %q", tt.forwarded, result, tt.expected)
}
})
}
}
func FuzzParseForwardedHeaderProto(f *testing.F) {
for _, t := range tests {
f.Add(t.forwarded)
}
f.Fuzz(func(t *testing.T, forwarded string) {
parseForwardedHeaderProto(forwarded)
})
}

View file

@ -38,14 +38,30 @@ type K8sApplication struct {
Labels map[string]string `json:"Labels,omitempty"` Labels map[string]string `json:"Labels,omitempty"`
Resource K8sApplicationResource `json:"Resource,omitempty"` Resource K8sApplicationResource `json:"Resource,omitempty"`
HorizontalPodAutoscaler *autoscalingv2.HorizontalPodAutoscaler `json:"HorizontalPodAutoscaler,omitempty"` HorizontalPodAutoscaler *autoscalingv2.HorizontalPodAutoscaler `json:"HorizontalPodAutoscaler,omitempty"`
CustomResourceMetadata CustomResourceMetadata `json:"CustomResourceMetadata,omitempty"`
} }
type Metadata struct { type Metadata struct {
Labels map[string]string `json:"labels"` Labels map[string]string `json:"labels"`
} }
type CustomResourceMetadata struct {
Kind string `json:"kind"`
APIVersion string `json:"apiVersion"`
Plural string `json:"plural"`
}
type Pod struct { type Pod struct {
Status string `json:"Status"` Name string `json:"Name"`
ContainerName string `json:"ContainerName"`
Image string `json:"Image"`
ImagePullPolicy string `json:"ImagePullPolicy"`
Status string `json:"Status"`
NodeName string `json:"NodeName"`
PodIP string `json:"PodIP"`
UID string `json:"Uid"`
Resource K8sApplicationResource `json:"Resource,omitempty"`
CreationDate time.Time `json:"CreationDate"`
} }
type Configuration struct { type Configuration struct {
@ -72,8 +88,8 @@ type TLSInfo struct {
// Existing types // Existing types
type K8sApplicationResource struct { type K8sApplicationResource struct {
CPURequest float64 `json:"CpuRequest"` CPURequest float64 `json:"CpuRequest,omitempty"`
CPULimit float64 `json:"CpuLimit"` CPULimit float64 `json:"CpuLimit,omitempty"`
MemoryRequest int64 `json:"MemoryRequest"` MemoryRequest int64 `json:"MemoryRequest,omitempty"`
MemoryLimit int64 `json:"MemoryLimit"` MemoryLimit int64 `json:"MemoryLimit,omitempty"`
} }

View file

@ -0,0 +1,25 @@
package kubernetes
import "time"
type K8sEvent struct {
Type string `json:"type"`
Name string `json:"name"`
Reason string `json:"reason"`
Message string `json:"message"`
Namespace string `json:"namespace"`
EventTime time.Time `json:"eventTime"`
Kind string `json:"kind,omitempty"`
Count int32 `json:"count"`
FirstTimestamp *time.Time `json:"firstTimestamp,omitempty"`
LastTimestamp *time.Time `json:"lastTimestamp,omitempty"`
UID string `json:"uid"`
InvolvedObjectKind K8sEventInvolvedObject `json:"involvedObject"`
}
type K8sEventInvolvedObject struct {
Kind string `json:"kind,omitempty"`
UID string `json:"uid"`
Name string `json:"name"`
Namespace string `json:"namespace"`
}

View file

@ -35,7 +35,7 @@ type (
func getUniqueElements(items string) []string { func getUniqueElements(items string) []string {
xs := strings.Split(items, ",") xs := strings.Split(items, ",")
xs = slicesx.Map(xs, strings.TrimSpace) xs = slicesx.Map(xs, strings.TrimSpace)
xs = slicesx.Filter(xs, func(x string) bool { return len(x) > 0 }) xs = slicesx.FilterInPlace(xs, func(x string) bool { return len(x) > 0 })
return slicesx.Unique(xs) return slicesx.Unique(xs)
} }

View file

@ -6,7 +6,7 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types/network"
"github.com/docker/docker/client" "github.com/docker/docker/client"
@ -20,7 +20,7 @@ const (
) )
func getInheritedResourceControlFromNetworkLabels(dockerClient *client.Client, endpointID portainer.EndpointID, networkID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) { func getInheritedResourceControlFromNetworkLabels(dockerClient *client.Client, endpointID portainer.EndpointID, networkID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) {
network, err := dockerClient.NetworkInspect(context.Background(), networkID, types.NetworkInspectOptions{}) network, err := dockerClient.NetworkInspect(context.Background(), networkID, network.InspectOptions{})
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -55,12 +55,13 @@ func createRegistryAuthenticationHeader(
return return
} }
if err = registryutils.EnsureRegTokenValid(dataStore, matchingRegistry); err != nil { if err = registryutils.PrepareRegistryCredentials(dataStore, matchingRegistry); err != nil {
return return
} }
authenticationHeader.Serveraddress = matchingRegistry.URL authenticationHeader.Serveraddress = matchingRegistry.URL
authenticationHeader.Username, authenticationHeader.Password, err = registryutils.GetRegEffectiveCredential(matchingRegistry) authenticationHeader.Username = matchingRegistry.Username
authenticationHeader.Password = matchingRegistry.Password
return return
} }

View file

@ -0,0 +1,108 @@
package github
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/segmentio/encoding/json"
"oras.land/oras-go/v2/registry/remote/retry"
)
const GitHubAPIHost = "https://api.github.com"
// Package represents a GitHub container package
type Package struct {
Name string `json:"name"`
Owner struct {
Login string `json:"login"`
} `json:"owner"`
}
// Client represents a GitHub API client
type Client struct {
httpClient *http.Client
baseURL string
}
// NewClient creates a new GitHub API client
func NewClient(token string) *Client {
return &Client{
httpClient: NewHTTPClient(token),
baseURL: GitHubAPIHost,
}
}
// GetContainerPackages fetches container packages for the configured namespace
// It's a small http client wrapper instead of using the github client because listing repositories is the only known operation that isn't directly supported by oras
func (c *Client) GetContainerPackages(ctx context.Context, useOrganisation bool, organisationName string) ([]string, error) {
// Determine the namespace (user or organisation) for the request
namespace := "user"
if useOrganisation {
namespace = "orgs/" + organisationName
}
// Build the full URL for listing container packages
url := fmt.Sprintf("%s/%s/packages?package_type=container", c.baseURL, namespace)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
var packages []Package
if err := json.Unmarshal(body, &packages); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
// Extract repository names in the form "owner/name"
repositories := make([]string, len(packages))
for i, pkg := range packages {
repositories[i] = fmt.Sprintf("%s/%s", strings.ToLower(pkg.Owner.Login), strings.ToLower(pkg.Name))
}
return repositories, nil
}
// NewHTTPClient creates a new HTTP client configured for GitHub API requests
func NewHTTPClient(token string) *http.Client {
return &http.Client{
Transport: &tokenTransport{
token: token,
transport: retry.NewTransport(&http.Transport{}), // Use ORAS retry transport for consistent rate limiting and error handling
},
Timeout: 1 * time.Minute,
}
}
// tokenTransport automatically adds the Bearer token header to requests
type tokenTransport struct {
token string
transport http.RoundTripper
}
func (t *tokenTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if t.token != "" {
req.Header.Set("Authorization", "Bearer "+t.token)
req.Header.Set("Accept", "application/vnd.github+json")
}
return t.transport.RoundTrip(req)
}

View file

@ -0,0 +1,130 @@
package gitlab
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"time"
"github.com/segmentio/encoding/json"
"oras.land/oras-go/v2/registry/remote/retry"
)
// Repository represents a GitLab registry repository
type Repository struct {
ID int `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
ProjectID int `json:"project_id"`
Location string `json:"location"`
CreatedAt string `json:"created_at"`
Status string `json:"status"`
}
// Client represents a GitLab API client
type Client struct {
httpClient *http.Client
baseURL string
}
// NewClient creates a new GitLab API client
// it currently is an http client because only GetRegistryRepositoryNames is needed (oras supports other commands).
// if we need to support other commands, consider using the gitlab client library.
func NewClient(baseURL, token string) *Client {
return &Client{
httpClient: NewHTTPClient(token),
baseURL: baseURL,
}
}
// GetRegistryRepositoryNames fetches registry repository names for a given project.
// It's a small http client wrapper instead of using the gitlab client library because listing repositories is the only known operation that isn't directly supported by oras
func (c *Client) GetRegistryRepositoryNames(ctx context.Context, projectID int) ([]string, error) {
url := fmt.Sprintf("%s/api/v4/projects/%d/registry/repositories", c.baseURL, projectID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitLab API returned status %d: %s", resp.StatusCode, resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
var repositories []Repository
if err := json.Unmarshal(body, &repositories); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
// Extract repository names
names := make([]string, len(repositories))
for i, repo := range repositories {
// the full path is required for further repo operations
names[i] = repo.Path
}
return names, nil
}
type Transport struct {
httpTransport *http.Transport
}
// NewTransport returns a pointer to a new instance of Transport that implements the HTTP Transport
// interface for proxying requests to the Gitlab API.
func NewTransport() *Transport {
return &Transport{
httpTransport: &http.Transport{},
}
}
// RoundTrip is the implementation of the http.RoundTripper interface
func (transport *Transport) RoundTrip(request *http.Request) (*http.Response, error) {
token := request.Header.Get("Private-Token")
if token == "" {
return nil, errors.New("no gitlab token provided")
}
r, err := http.NewRequest(request.Method, request.URL.String(), request.Body)
if err != nil {
return nil, err
}
r.Header.Set("Private-Token", token)
return transport.httpTransport.RoundTrip(r)
}
// NewHTTPClient creates a new HTTP client configured for GitLab API requests
func NewHTTPClient(token string) *http.Client {
return &http.Client{
Transport: &tokenTransport{
token: token,
transport: retry.NewTransport(&http.Transport{}), // Use ORAS retry transport for consistent rate limiting and error handling
},
Timeout: 1 * time.Minute,
}
}
// tokenTransport automatically adds the Private-Token header to requests
type tokenTransport struct {
token string
transport http.RoundTripper
}
func (t *tokenTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req.Header.Set("Private-Token", t.token)
return t.transport.RoundTrip(req)
}

View file

@ -1,34 +0,0 @@
package gitlab
import (
"errors"
"net/http"
)
type Transport struct {
httpTransport *http.Transport
}
// NewTransport returns a pointer to a new instance of Transport that implements the HTTP Transport
// interface for proxying requests to the Gitlab API.
func NewTransport() *Transport {
return &Transport{
httpTransport: &http.Transport{},
}
}
// RoundTrip is the implementation of the http.RoundTripper interface
func (transport *Transport) RoundTrip(request *http.Request) (*http.Response, error) {
token := request.Header.Get("Private-Token")
if token == "" {
return nil, errors.New("no gitlab token provided")
}
r, err := http.NewRequest(request.Method, request.URL.String(), request.Body)
if err != nil {
return nil, err
}
r.Header.Set("Private-Token", token)
return transport.httpTransport.RoundTrip(r)
}

View file

@ -58,6 +58,7 @@ func (transport *baseTransport) proxyKubernetesRequest(request *http.Request) (*
switch { switch {
case strings.EqualFold(requestPath, "/namespaces/portainer/configmaps/portainer-config") && (request.Method == "PUT" || request.Method == "POST"): case strings.EqualFold(requestPath, "/namespaces/portainer/configmaps/portainer-config") && (request.Method == "PUT" || request.Method == "POST"):
transport.k8sClientFactory.ClearClientCache()
defer transport.tokenManager.UpdateUserServiceAccountsForEndpoint(portainer.EndpointID(endpointID)) defer transport.tokenManager.UpdateUserServiceAccountsForEndpoint(portainer.EndpointID(endpointID))
return transport.executeKubernetesRequest(request) return transport.executeKubernetesRequest(request)
case strings.EqualFold(requestPath, "/namespaces"): case strings.EqualFold(requestPath, "/namespaces"):

View file

@ -7,6 +7,21 @@ import (
"strings" "strings"
) )
// Note that we discard any non-canonical headers by design
var allowedHeaders = map[string]struct{}{
"Accept": {},
"Accept-Encoding": {},
"Accept-Language": {},
"Cache-Control": {},
"Content-Length": {},
"Content-Type": {},
"Private-Token": {},
"User-Agent": {},
"X-Portaineragent-Target": {},
"X-Portainer-Volumename": {},
"X-Registry-Auth": {},
}
// newSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy // newSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy
// from golang.org/src/net/http/httputil/reverseproxy.go and merely sets the Host // from golang.org/src/net/http/httputil/reverseproxy.go and merely sets the Host
// HTTP header, which NewSingleHostReverseProxy deliberately preserves. // HTTP header, which NewSingleHostReverseProxy deliberately preserves.
@ -15,7 +30,6 @@ func NewSingleHostReverseProxyWithHostHeader(target *url.URL) *httputil.ReverseP
} }
func createDirector(target *url.URL) func(*http.Request) { func createDirector(target *url.URL) func(*http.Request) {
sensitiveHeaders := []string{"Cookie", "X-Csrf-Token"}
targetQuery := target.RawQuery targetQuery := target.RawQuery
return func(req *http.Request) { return func(req *http.Request) {
req.URL.Scheme = target.Scheme req.URL.Scheme = target.Scheme
@ -32,8 +46,11 @@ func createDirector(target *url.URL) func(*http.Request) {
req.Header.Set("User-Agent", "") req.Header.Set("User-Agent", "")
} }
for _, header := range sensitiveHeaders { for k := range req.Header {
delete(req.Header, header) if _, ok := allowedHeaders[k]; !ok {
// We use delete here instead of req.Header.Del because we want to delete non canonical headers.
delete(req.Header, k)
}
} }
} }
} }

View file

@ -6,6 +6,7 @@ import (
"testing" "testing"
"github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp"
portainer "github.com/portainer/portainer/api"
) )
func Test_createDirector(t *testing.T) { func Test_createDirector(t *testing.T) {
@ -23,12 +24,14 @@ func Test_createDirector(t *testing.T) {
"GET", "GET",
"https://agent-portainer.io/test?c=7", "https://agent-portainer.io/test?c=7",
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": "something"}, map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": "something"},
true,
), ),
expectedReq: createRequest( expectedReq: createRequest(
t, t,
"GET", "GET",
"https://portainer.io/api/docker/test?a=5&b=6&c=7", "https://portainer.io/api/docker/test?a=5&b=6&c=7",
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": "something"}, map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": "something"},
true,
), ),
}, },
{ {
@ -39,12 +42,14 @@ func Test_createDirector(t *testing.T) {
"GET", "GET",
"https://agent-portainer.io/test?c=7", "https://agent-portainer.io/test?c=7",
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json"}, map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json"},
true,
), ),
expectedReq: createRequest( expectedReq: createRequest(
t, t,
"GET", "GET",
"https://portainer.io/api/docker/test?a=5&b=6&c=7", "https://portainer.io/api/docker/test?a=5&b=6&c=7",
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": ""}, map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": ""},
true,
), ),
}, },
{ {
@ -55,18 +60,83 @@ func Test_createDirector(t *testing.T) {
"GET", "GET",
"https://agent-portainer.io/test?c=7", "https://agent-portainer.io/test?c=7",
map[string]string{ map[string]string{
"Accept-Encoding": "gzip", "Authorization": "secret",
"Accept": "application/json", "Proxy-Authorization": "secret",
"User-Agent": "something", "Cookie": "secret",
"Cookie": "junk", "X-Csrf-Token": "secret",
"X-Csrf-Token": "junk", "X-Api-Key": "secret",
"Accept": "application/json",
"Accept-Encoding": "gzip",
"Accept-Language": "en-GB",
"Cache-Control": "None",
"Content-Length": "100",
"Content-Type": "application/json",
"Private-Token": "test-private-token",
"User-Agent": "test-user-agent",
"X-Portaineragent-Target": "test-agent-1",
"X-Portainer-Volumename": "test-volume-1",
"X-Registry-Auth": "test-registry-auth",
}, },
true,
), ),
expectedReq: createRequest( expectedReq: createRequest(
t, t,
"GET", "GET",
"https://portainer.io/api/docker/test?a=5&b=6&c=7", "https://portainer.io/api/docker/test?a=5&b=6&c=7",
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": "something"}, map[string]string{
"Accept": "application/json",
"Accept-Encoding": "gzip",
"Accept-Language": "en-GB",
"Cache-Control": "None",
"Content-Length": "100",
"Content-Type": "application/json",
"Private-Token": "test-private-token",
"User-Agent": "test-user-agent",
"X-Portaineragent-Target": "test-agent-1",
"X-Portainer-Volumename": "test-volume-1",
"X-Registry-Auth": "test-registry-auth",
},
true,
),
},
{
name: "Non canonical Headers",
target: createURL(t, "https://portainer.io/api/docker?a=5&b=6"),
req: createRequest(
t,
"GET",
"https://agent-portainer.io/test?c=7",
map[string]string{
"Accept": "application/json",
"Accept-Encoding": "gzip",
"Accept-Language": "en-GB",
"Cache-Control": "None",
"Content-Length": "100",
"Content-Type": "application/json",
"Private-Token": "test-private-token",
"User-Agent": "test-user-agent",
portainer.PortainerAgentTargetHeader: "test-agent-1",
"X-Portainer-VolumeName": "test-volume-1",
"X-Registry-Auth": "test-registry-auth",
},
false,
),
expectedReq: createRequest(
t,
"GET",
"https://portainer.io/api/docker/test?a=5&b=6&c=7",
map[string]string{
"Accept": "application/json",
"Accept-Encoding": "gzip",
"Accept-Language": "en-GB",
"Cache-Control": "None",
"Content-Length": "100",
"Content-Type": "application/json",
"Private-Token": "test-private-token",
"User-Agent": "test-user-agent",
"X-Registry-Auth": "test-registry-auth",
},
true,
), ),
}, },
} }
@ -92,13 +162,17 @@ func createURL(t *testing.T, urlString string) *url.URL {
return parsedURL return parsedURL
} }
func createRequest(t *testing.T, method, url string, headers map[string]string) *http.Request { func createRequest(t *testing.T, method, url string, headers map[string]string, canonicalHeaders bool) *http.Request {
req, err := http.NewRequest(method, url, nil) req, err := http.NewRequest(method, url, nil)
if err != nil { if err != nil {
t.Fatalf("Failed to create http request: %s", err) t.Fatalf("Failed to create http request: %s", err)
} else { } else {
for k, v := range headers { for k, v := range headers {
req.Header.Add(k, v) if canonicalHeaders {
req.Header.Add(k, v)
} else {
req.Header[k] = []string{v}
}
} }
} }

View file

@ -35,6 +35,7 @@ type (
JWTAuthLookup(*http.Request) (*portainer.TokenData, error) JWTAuthLookup(*http.Request) (*portainer.TokenData, error)
TrustedEdgeEnvironmentAccess(dataservices.DataStoreTx, *portainer.Endpoint) error TrustedEdgeEnvironmentAccess(dataservices.DataStoreTx, *portainer.Endpoint) error
RevokeJWT(string) RevokeJWT(string)
DisableCSP()
} }
// RequestBouncer represents an entity that manages API request accesses // RequestBouncer represents an entity that manages API request accesses
@ -72,7 +73,7 @@ func NewRequestBouncer(dataStore dataservices.DataStore, jwtService portainer.JW
jwtService: jwtService, jwtService: jwtService,
apiKeyService: apiKeyService, apiKeyService: apiKeyService,
hsts: featureflags.IsEnabled("hsts"), hsts: featureflags.IsEnabled("hsts"),
csp: featureflags.IsEnabled("csp"), csp: true,
} }
go b.cleanUpExpiredJWT() go b.cleanUpExpiredJWT()
@ -80,6 +81,11 @@ func NewRequestBouncer(dataStore dataservices.DataStore, jwtService portainer.JW
return b return b
} }
// DisableCSP disables Content Security Policy
func (bouncer *RequestBouncer) DisableCSP() {
bouncer.csp = false
}
// PublicAccess defines a security check for public API endpoints. // PublicAccess defines a security check for public API endpoints.
// No authentication is required to access these endpoints. // No authentication is required to access these endpoints.
func (bouncer *RequestBouncer) PublicAccess(h http.Handler) http.Handler { func (bouncer *RequestBouncer) PublicAccess(h http.Handler) http.Handler {
@ -528,7 +534,7 @@ func MWSecureHeaders(next http.Handler, hsts, csp bool) http.Handler {
} }
if csp { if csp {
w.Header().Set("Content-Security-Policy", "script-src 'self' cdn.matomo.cloud") w.Header().Set("Content-Security-Policy", "script-src 'self' cdn.matomo.cloud; frame-ancestors 'none';")
} }
w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Content-Type-Options", "nosniff")

View file

@ -530,3 +530,34 @@ func TestJWTRevocation(t *testing.T) {
require.Equal(t, 1, revokeLen()) require.Equal(t, 1, revokeLen())
} }
func TestCSPHeaderDefault(t *testing.T) {
b := NewRequestBouncer(nil, nil, nil)
srv := httptest.NewServer(
b.PublicAccess(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})),
)
defer srv.Close()
resp, err := http.Get(srv.URL + "/")
require.NoError(t, err)
defer resp.Body.Close()
require.Contains(t, resp.Header, "Content-Security-Policy")
}
func TestCSPHeaderDisabled(t *testing.T) {
b := NewRequestBouncer(nil, nil, nil)
b.DisableCSP()
srv := httptest.NewServer(
b.PublicAccess(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})),
)
defer srv.Close()
resp, err := http.Get(srv.URL + "/")
require.NoError(t, err)
defer resp.Body.Close()
require.NotContains(t, resp.Header, "Content-Security-Policy")
}

View file

@ -77,6 +77,7 @@ type Server struct {
AuthorizationService *authorization.Service AuthorizationService *authorization.Service
BindAddress string BindAddress string
BindAddressHTTPS string BindAddressHTTPS string
CSP bool
HTTPEnabled bool HTTPEnabled bool
AssetsPath string AssetsPath string
Status *portainer.Status Status *portainer.Status
@ -113,6 +114,7 @@ type Server struct {
PendingActionsService *pendingactions.PendingActionsService PendingActionsService *pendingactions.PendingActionsService
PlatformService platform.Service PlatformService platform.Service
PullLimitCheckDisabled bool PullLimitCheckDisabled bool
TrustedOrigins []string
} }
// Start starts the HTTP server // Start starts the HTTP server
@ -120,13 +122,16 @@ func (server *Server) Start() error {
kubernetesTokenCacheManager := server.KubernetesTokenCacheManager kubernetesTokenCacheManager := server.KubernetesTokenCacheManager
requestBouncer := security.NewRequestBouncer(server.DataStore, server.JWTService, server.APIKeyService) requestBouncer := security.NewRequestBouncer(server.DataStore, server.JWTService, server.APIKeyService)
if !server.CSP {
requestBouncer.DisableCSP()
}
rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour) rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour)
offlineGate := offlinegate.NewOfflineGate() offlineGate := offlinegate.NewOfflineGate()
passwordStrengthChecker := security.NewPasswordStrengthChecker(server.DataStore.Settings()) passwordStrengthChecker := security.NewPasswordStrengthChecker(server.DataStore.Settings())
var authHandler = auth.NewHandler(requestBouncer, rateLimiter, passwordStrengthChecker) var authHandler = auth.NewHandler(requestBouncer, rateLimiter, passwordStrengthChecker, server.KubernetesClientFactory)
authHandler.DataStore = server.DataStore authHandler.DataStore = server.DataStore
authHandler.CryptoService = server.CryptoService authHandler.CryptoService = server.CryptoService
authHandler.JWTService = server.JWTService authHandler.JWTService = server.JWTService
@ -161,10 +166,7 @@ func (server *Server) Start() error {
edgeJobsHandler.FileService = server.FileService edgeJobsHandler.FileService = server.FileService
edgeJobsHandler.ReverseTunnelService = server.ReverseTunnelService edgeJobsHandler.ReverseTunnelService = server.ReverseTunnelService
edgeStackCoordinator := edgestacks.NewEdgeStackStatusUpdateCoordinator(server.DataStore) var edgeStacksHandler = edgestacks.NewHandler(requestBouncer, server.DataStore, server.EdgeStacksService)
go edgeStackCoordinator.Start()
var edgeStacksHandler = edgestacks.NewHandler(requestBouncer, server.DataStore, server.EdgeStacksService, edgeStackCoordinator)
edgeStacksHandler.FileService = server.FileService edgeStacksHandler.FileService = server.FileService
edgeStacksHandler.GitService = server.GitService edgeStacksHandler.GitService = server.GitService
edgeStacksHandler.KubernetesDeployer = server.KubernetesDeployer edgeStacksHandler.KubernetesDeployer = server.KubernetesDeployer
@ -202,7 +204,7 @@ func (server *Server) Start() error {
var dockerHandler = dockerhandler.NewHandler(requestBouncer, server.AuthorizationService, server.DataStore, server.DockerClientFactory, containerService) var dockerHandler = dockerhandler.NewHandler(requestBouncer, server.AuthorizationService, server.DataStore, server.DockerClientFactory, containerService)
var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public"), adminMonitor.WasInstanceDisabled) var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public"), server.CSP, adminMonitor.WasInstanceDisabled)
var endpointHelmHandler = helm.NewHandler(requestBouncer, server.DataStore, server.JWTService, server.KubernetesDeployer, server.HelmPackageManager, server.KubeClusterAccessService) var endpointHelmHandler = helm.NewHandler(requestBouncer, server.DataStore, server.JWTService, server.KubernetesDeployer, server.HelmPackageManager, server.KubeClusterAccessService)
@ -339,7 +341,7 @@ func (server *Server) Start() error {
handler = middlewares.WithPanicLogger(middlewares.WithSlowRequestsLogger(handler)) handler = middlewares.WithPanicLogger(middlewares.WithSlowRequestsLogger(handler))
handler, err := csrf.WithProtect(handler) handler, err := csrf.WithProtect(handler, server.TrustedOrigins)
if err != nil { if err != nil {
return errors.Wrap(err, "failed to create CSRF middleware") return errors.Wrap(err, "failed to create CSRF middleware")
} }

View file

@ -49,7 +49,6 @@ func (service *Service) BuildEdgeStack(
DeploymentType: deploymentType, DeploymentType: deploymentType,
CreationDate: time.Now().Unix(), CreationDate: time.Now().Unix(),
EdgeGroups: edgeGroups, EdgeGroups: edgeGroups,
Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus, 0),
Version: 1, Version: 1,
UseManifestNamespaces: useManifestNamespaces, UseManifestNamespaces: useManifestNamespaces,
}, nil }, nil
@ -104,6 +103,14 @@ func (service *Service) PersistEdgeStack(
return nil, err return nil, err
} }
for _, endpointID := range relatedEndpointIds {
status := &portainer.EdgeStackStatusForEnv{EndpointID: endpointID}
if err := tx.EdgeStackStatus().Create(stack.ID, endpointID, status); err != nil {
return nil, err
}
}
if err := tx.EndpointRelation().AddEndpointRelationsForEdgeStack(relatedEndpointIds, stack.ID); err != nil { if err := tx.EndpointRelation().AddEndpointRelationsForEdgeStack(relatedEndpointIds, stack.ID); err != nil {
return nil, fmt.Errorf("unable to add endpoint relations: %w", err) return nil, fmt.Errorf("unable to add endpoint relations: %w", err)
} }
@ -122,9 +129,6 @@ func (service *Service) updateEndpointRelations(tx dataservices.DataStoreTx, edg
for _, endpointID := range relatedEndpointIds { for _, endpointID := range relatedEndpointIds {
relation, err := endpointRelationService.EndpointRelation(endpointID) relation, err := endpointRelationService.EndpointRelation(endpointID)
if err != nil { if err != nil {
if tx.IsErrObjectNotFound(err) {
continue
}
return fmt.Errorf("unable to find endpoint relation in database: %w", err) return fmt.Errorf("unable to find endpoint relation in database: %w", err)
} }
@ -158,5 +162,9 @@ func (service *Service) DeleteEdgeStack(tx dataservices.DataStoreTx, edgeStackID
return errors.WithMessage(err, "Unable to remove the edge stack from the database") return errors.WithMessage(err, "Unable to remove the edge stack from the database")
} }
if err := tx.EdgeStackStatus().DeleteAll(edgeStackID); err != nil {
return errors.WithMessage(err, "unable to remove edge stack statuses from the database")
}
return nil return nil
} }

View file

@ -1,26 +0,0 @@
package edgestacks
import (
portainer "github.com/portainer/portainer/api"
)
// NewStatus returns a new status object for an Edge stack
func NewStatus(oldStatus map[portainer.EndpointID]portainer.EdgeStackStatus, relatedEnvironmentIDs []portainer.EndpointID) map[portainer.EndpointID]portainer.EdgeStackStatus {
status := map[portainer.EndpointID]portainer.EdgeStackStatus{}
for _, environmentID := range relatedEnvironmentIDs {
newEnvStatus := portainer.EdgeStackStatus{
Status: []portainer.EdgeStackDeploymentStatus{},
EndpointID: environmentID,
}
oldEnvStatus, ok := oldStatus[environmentID]
if ok {
newEnvStatus.DeploymentInfo = oldEnvStatus.DeploymentInfo
}
status[environmentID] = newEnvStatus
}
return status
}

View file

@ -249,3 +249,19 @@ func getEndpointCheckinInterval(endpoint *portainer.Endpoint, settings *portaine
return defaultInterval return defaultInterval
} }
func InitializeEdgeEndpointRelation(endpoint *portainer.Endpoint, tx dataservices.DataStoreTx) error {
if !IsEdgeEndpoint(endpoint) {
return nil
}
relation := &portainer.EndpointRelation{
EndpointID: endpoint.ID,
EdgeStacks: make(map[portainer.EdgeStackID]bool),
}
if err := tx.EndpointRelation().Create(relation); err != nil {
return err
}
return nil
}

View file

@ -2,40 +2,82 @@ package access
import ( import (
"errors" "errors"
"fmt"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/kubernetes"
"github.com/portainer/portainer/api/kubernetes/cli"
) )
func hasPermission( func hasPermission(
dataStore dataservices.DataStore, dataStore dataservices.DataStore,
k8sClientFactory *cli.ClientFactory,
userID portainer.UserID, userID portainer.UserID,
endpointID portainer.EndpointID, endpointID portainer.EndpointID,
registry *portainer.Registry, registry *portainer.Registry,
) (hasPermission bool, err error) { ) (hasPermission bool, err error) {
user, err := dataStore.User().Read(userID) user, err := dataStore.User().Read(userID)
if err != nil { if err != nil {
return return false, err
} }
if user.Role == portainer.AdministratorRole { if user.Role == portainer.AdministratorRole {
return true, err return true, nil
}
endpoint, err := dataStore.Endpoint().Endpoint(endpointID)
if err != nil {
return false, err
} }
teamMemberships, err := dataStore.TeamMembership().TeamMembershipsByUserID(userID) teamMemberships, err := dataStore.TeamMembership().TeamMembershipsByUserID(userID)
if err != nil { if err != nil {
return return false, err
} }
// validate access for kubernetes namespaces (leverage registry.RegistryAccesses[endpointId].Namespaces)
if endpointutils.IsKubernetesEndpoint(endpoint) && k8sClientFactory != nil {
kcl, err := k8sClientFactory.GetPrivilegedKubeClient(endpoint)
if err != nil {
return false, fmt.Errorf("unable to retrieve kubernetes client to validate registry access: %w", err)
}
accessPolicies, err := kcl.GetNamespaceAccessPolicies()
if err != nil {
return false, fmt.Errorf("unable to retrieve environment's namespaces policies to validate registry access: %w", err)
}
authorizedNamespaces := registry.RegistryAccesses[endpointID].Namespaces
for _, namespace := range authorizedNamespaces {
// when the default namespace is authorized to use a registry, all users have the ability to use it
// unless the default namespace is restricted: in this case continue to search for other potential accesses authorizations
if namespace == kubernetes.DefaultNamespace && !endpoint.Kubernetes.Configuration.RestrictDefaultNamespace {
return true, nil
}
namespacePolicy := accessPolicies[namespace]
if security.AuthorizedAccess(user.ID, teamMemberships, namespacePolicy.UserAccessPolicies, namespacePolicy.TeamAccessPolicies) {
return true, nil
}
}
return false, nil
}
// validate access for docker environments
// leverage registry.RegistryAccesses[endpointId].UserAccessPolicies (direct access)
// and registry.RegistryAccesses[endpointId].TeamAccessPolicies (indirect access via his teams)
hasPermission = security.AuthorizedRegistryAccess(registry, user, teamMemberships, endpointID) hasPermission = security.AuthorizedRegistryAccess(registry, user, teamMemberships, endpointID)
return return hasPermission, nil
} }
// GetAccessibleRegistry get the registry if the user has permission // GetAccessibleRegistry get the registry if the user has permission
func GetAccessibleRegistry( func GetAccessibleRegistry(
dataStore dataservices.DataStore, dataStore dataservices.DataStore,
k8sClientFactory *cli.ClientFactory,
userID portainer.UserID, userID portainer.UserID,
endpointID portainer.EndpointID, endpointID portainer.EndpointID,
registryID portainer.RegistryID, registryID portainer.RegistryID,
@ -46,7 +88,7 @@ func GetAccessibleRegistry(
return return
} }
hasPermission, err := hasPermission(dataStore, userID, endpointID, registry) hasPermission, err := hasPermission(dataStore, k8sClientFactory, userID, endpointID, registry)
if err != nil { if err != nil {
return return
} }

View file

@ -62,3 +62,26 @@ func GetRegEffectiveCredential(registry *portainer.Registry) (username, password
return return
} }
// PrepareRegistryCredentials consolidates the common pattern of ensuring valid ECR token
// and setting effective credentials on the registry when authentication is enabled.
// This function modifies the registry in-place by setting Username and Password to the effective values.
func PrepareRegistryCredentials(tx dataservices.DataStoreTx, registry *portainer.Registry) error {
if !registry.Authentication {
return nil
}
if err := EnsureRegTokenValid(tx, registry); err != nil {
return err
}
username, password, err := GetRegEffectiveCredential(registry)
if err != nil {
return err
}
registry.Username = username
registry.Password = password
return nil
}

View file

@ -7,6 +7,7 @@ import (
"github.com/portainer/portainer/api/database" "github.com/portainer/portainer/api/database"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/errors" "github.com/portainer/portainer/api/dataservices/errors"
"github.com/portainer/portainer/api/slicesx"
) )
var _ dataservices.DataStore = &testDatastore{} var _ dataservices.DataStore = &testDatastore{}
@ -16,6 +17,7 @@ type testDatastore struct {
edgeGroup dataservices.EdgeGroupService edgeGroup dataservices.EdgeGroupService
edgeJob dataservices.EdgeJobService edgeJob dataservices.EdgeJobService
edgeStack dataservices.EdgeStackService edgeStack dataservices.EdgeStackService
edgeStackStatus dataservices.EdgeStackStatusService
endpoint dataservices.EndpointService endpoint dataservices.EndpointService
endpointGroup dataservices.EndpointGroupService endpointGroup dataservices.EndpointGroupService
endpointRelation dataservices.EndpointRelationService endpointRelation dataservices.EndpointRelationService
@ -53,8 +55,11 @@ func (d *testDatastore) CustomTemplate() dataservices.CustomTemplateService { re
func (d *testDatastore) EdgeGroup() dataservices.EdgeGroupService { return d.edgeGroup } func (d *testDatastore) EdgeGroup() dataservices.EdgeGroupService { return d.edgeGroup }
func (d *testDatastore) EdgeJob() dataservices.EdgeJobService { return d.edgeJob } func (d *testDatastore) EdgeJob() dataservices.EdgeJobService { return d.edgeJob }
func (d *testDatastore) EdgeStack() dataservices.EdgeStackService { return d.edgeStack } func (d *testDatastore) EdgeStack() dataservices.EdgeStackService { return d.edgeStack }
func (d *testDatastore) Endpoint() dataservices.EndpointService { return d.endpoint } func (d *testDatastore) EdgeStackStatus() dataservices.EdgeStackStatusService {
func (d *testDatastore) EndpointGroup() dataservices.EndpointGroupService { return d.endpointGroup } return d.edgeStackStatus
}
func (d *testDatastore) Endpoint() dataservices.EndpointService { return d.endpoint }
func (d *testDatastore) EndpointGroup() dataservices.EndpointGroupService { return d.endpointGroup }
func (d *testDatastore) EndpointRelation() dataservices.EndpointRelationService { func (d *testDatastore) EndpointRelation() dataservices.EndpointRelationService {
return d.endpointRelation return d.endpointRelation
@ -148,8 +153,17 @@ type stubUserService struct {
users []portainer.User users []portainer.User
} }
func (s *stubUserService) BucketName() string { return "users" } func (s *stubUserService) BucketName() string { return "users" }
func (s *stubUserService) ReadAll() ([]portainer.User, error) { return s.users, nil } func (s *stubUserService) ReadAll(predicates ...func(portainer.User) bool) ([]portainer.User, error) {
filtered := s.users
for _, p := range predicates {
filtered = slicesx.Filter(filtered, p)
}
return filtered, nil
}
func (s *stubUserService) UsersByRole(role portainer.UserRole) ([]portainer.User, error) { func (s *stubUserService) UsersByRole(role portainer.UserRole) ([]portainer.User, error) {
return s.users, nil return s.users, nil
} }
@ -167,8 +181,16 @@ type stubEdgeJobService struct {
jobs []portainer.EdgeJob jobs []portainer.EdgeJob
} }
func (s *stubEdgeJobService) BucketName() string { return "edgejobs" } func (s *stubEdgeJobService) BucketName() string { return "edgejobs" }
func (s *stubEdgeJobService) ReadAll() ([]portainer.EdgeJob, error) { return s.jobs, nil } func (s *stubEdgeJobService) ReadAll(predicates ...func(portainer.EdgeJob) bool) ([]portainer.EdgeJob, error) {
filtered := s.jobs
for _, p := range predicates {
filtered = slicesx.Filter(filtered, p)
}
return filtered, nil
}
// WithEdgeJobs option will instruct testDatastore to return provided jobs // WithEdgeJobs option will instruct testDatastore to return provided jobs
func WithEdgeJobs(js []portainer.EdgeJob) datastoreOption { func WithEdgeJobs(js []portainer.EdgeJob) datastoreOption {
@ -358,8 +380,14 @@ func (s *stubStacksService) Read(ID portainer.StackID) (*portainer.Stack, error)
return nil, errors.ErrObjectNotFound return nil, errors.ErrObjectNotFound
} }
func (s *stubStacksService) ReadAll() ([]portainer.Stack, error) { func (s *stubStacksService) ReadAll(predicates ...func(portainer.Stack) bool) ([]portainer.Stack, error) {
return s.stacks, nil filtered := s.stacks
for _, p := range predicates {
filtered = slicesx.Filter(filtered, p)
}
return filtered, nil
} }
func (s *stubStacksService) StacksByEndpointID(endpointID portainer.EndpointID) ([]portainer.Stack, error) { func (s *stubStacksService) StacksByEndpointID(endpointID portainer.EndpointID) ([]portainer.Stack, error) {

View file

@ -0,0 +1,19 @@
package testhelpers
import (
portainer "github.com/portainer/portainer/api"
models "github.com/portainer/portainer/api/http/models/kubernetes"
)
type testKubeClient struct {
portainer.KubeClient
}
func NewKubernetesClient() portainer.KubeClient {
return &testKubeClient{}
}
// Event
func (kcl *testKubeClient) GetEvents(namespace string, resourceId string) ([]models.K8sEvent, error) {
return nil, nil
}

View file

@ -60,6 +60,8 @@ func (testRequestBouncer) JWTAuthLookup(r *http.Request) (*portainer.TokenData,
func (testRequestBouncer) RevokeJWT(jti string) {} func (testRequestBouncer) RevokeJWT(jti string) {}
func (testRequestBouncer) DisableCSP() {}
// AddTestSecurityCookie adds a security cookie to the request // AddTestSecurityCookie adds a security cookie to the request
func AddTestSecurityCookie(r *http.Request, jwt string) { func AddTestSecurityCookie(r *http.Request, jwt string) {
r.AddCookie(&http.Cookie{ r.AddCookie(&http.Cookie{

Some files were not shown because too many files have changed in this diff Show more