Compare commits

..

53 Commits

Author SHA1 Message Date
Ali 439714f93d fix(app): ensure placement errors surface per node [EE-7065] (#11821)
ci / build_images (map[arch:amd64 platform:linux version:]) (push) Has been cancelled
ci / build_images (map[arch:amd64 platform:windows version:ltsc2022]) (push) Has been cancelled
ci / build_images (map[arch:arm platform:linux version:]) (push) Has been cancelled
ci / build_images (map[arch:arm64 platform:linux version:]) (push) Has been cancelled
ci / build_images (map[arch:ppc64le platform:linux version:]) (push) Has been cancelled
ci / build_images (map[arch:s390x platform:linux version:]) (push) Has been cancelled
ci / build_manifests (push) Has been cancelled
/ triage (push) Has been cancelled
Lint / Run linters (push) Has been cancelled
Test / test-client (push) Has been cancelled
Test / test-server (map[arch:amd64 platform:linux]) (push) Has been cancelled
Test / test-server (map[arch:amd64 platform:windows version:1809]) (push) Has been cancelled
Test / test-server (map[arch:amd64 platform:windows version:ltsc2022]) (push) Has been cancelled
Test / test-server (map[arch:arm64 platform:linux]) (push) Has been cancelled
ci / build_images (map[arch:amd64 platform:windows version:1809]) (push) Has been cancelled
Co-authored-by: testa113 <testa113>
2024-05-14 15:03:15 +12:00
Oscar Zhou 2745e63527 fix(image): github registry image truncated [EE-7021] (#11767) 2024-05-10 09:01:42 +12:00
Oscar Zhou 24e0318280 fix(api): list docker volume performance [EE-6896] (#11754) 2024-05-09 13:02:42 +12:00
Matt Hook 9a079a83fa fix(pending-action): pending action data format [EE-7064] (#11793)
Co-authored-by: Prabhat Khera <91852476+prabhat-portainer@users.noreply.github.com>
2024-05-09 08:15:33 +12:00
Ali 1df6087c8e fix(auth logs): fix typo in search keyword [EE-6742] (#11791)
Co-authored-by: testa113 <testa113>
2024-05-08 09:16:02 +12:00
Ali ae705bc245 fix(be-overlay): consistency overlay with variants [EE-6742] (#11775)
Co-authored-by: testa113 <testa113>
2024-05-07 16:16:52 +12:00
Ali d725b5e3b6 fix(app): show one tooltip to describe rollback feature [EE-6825] (#11778)
Co-authored-by: testa113 <testa113>
2024-05-07 15:27:25 +12:00
cmeng 1b33b1f5dd fix(container): specify node name when get a container EE-6981 (#11750) 2024-05-07 11:34:37 +12:00
Steven Kang b70f0fe3d2 fix: windows container capability [EE-5814] (#11765) 2024-05-03 10:56:38 +12:00
Ali 55ef46edb6 fix(namespace): wait for system ns setting to load before selecting existing ns [EE-6917] (#11709)
Co-authored-by: testa113 <testa113>
2024-05-02 16:43:08 +12:00
Prabhat Khera c2654d55b3 fix(images): consider stopped containers for unused label [EE-6983] (#11630) 2024-05-02 14:35:28 +12:00
Prabhat Khera 7fab352dbf chore(version-bump): bump version to 2.20.3 [EE-7063] (#11756) 2024-05-02 14:33:41 +12:00
Matt Hook 0dcb5113f7 fix(kube): correctly extract namespace from namespace manifest [EE-6555] (#11674)
Co-authored-by: Prabhat Khera <prabhat.khera@portainer.io>
2024-05-02 14:28:01 +12:00
Ali a1b0634d86 fix(kube): fix text in activity and authentication logs teasers [EE-6742] (#11746)
Co-authored-by: testa113 <testa113>
2024-05-02 14:23:47 +12:00
Ali da134c3e3f fix(app): avoid 'no label' error when deleting external app [EE-6019] (#11697)
Co-authored-by: testa113 <testa113>
2024-05-02 14:22:12 +12:00
Ali 5191fc9220 fix(app): explain rollback tooltip [EE-6825] (#11699)
Co-authored-by: testa113 <testa113>
2024-05-02 14:10:40 +12:00
Ali af4e362c5c fix(version): reduce github requests [EE-7017] (#11678) 2024-05-02 14:08:44 +12:00
Matt Hook eb5b9ef069 Revert "fix(kube): fix text in activity and authentication logs teasers [EE-6742]" (#11727) 2024-05-01 09:00:13 +12:00
Matt Hook a74c6dbd24 fix(kube): fix text in activity and authentication logs teasers [EE-6742] (#11680) 2024-04-30 19:16:47 +12:00
cmeng 6451ccce94 fix(edge-stack): add completed status EE-6210 (#11633) 2024-04-30 13:44:18 +12:00
Ali 6dd5150e23 Revert "fix(app): avoid 'no label' error when deleting external app [EE-6019]" (#11696) 2024-04-26 08:51:46 +12:00
Ali 441db15cfd fix(app): avoid 'no label' error when deleting external app [EE-6019] (#11672) 2024-04-26 08:42:13 +12:00
Chaim Lev-Ari b44fabaefe fix(users): return json from create token [EE-6856] (#11576) 2024-04-25 10:10:39 +03:00
Ali ddeddc723e fix(migration): run post init migrations for edge after server starts [EE-6905] (#11547)
Co-authored-by: testa113 <testa113>
2024-04-23 16:15:33 +12:00
Matt Hook e980ce3d6a fix(settings): fix crash during settings update when not using oauth [EE-7031] (#11660) 2024-04-23 12:58:13 +12:00
Oscar Zhou 123a138278 feat(setting/oauth): add authstyle option [EE-6038] (#11609) 2024-04-22 10:35:14 +12:00
Oscar Zhou cc3ec3cebd fix(stack/git): option to overwrite target path during dir move [EE-6871] (#11623) 2024-04-22 10:34:44 +12:00
cmeng 5dab7a1df4 fix(docker-client): explicitly set docker client scheme EE-6935 (#11518) 2024-04-22 09:00:49 +12:00
Matt Hook ed0cf4d79c chore(kubectl): update kubectl to latest point release [EE-7018] (#11621) 2024-04-19 11:47:11 +12:00
andres-portainer aa4b8ad5e3 fix(workflows): upgrade Go to v1.21.9 EE-6939 (#11643) 2024-04-18 19:03:25 -03:00
Prabhat Khera 81811f669d fix(stack): fix stack env variable link [EE-6902] (#11625) 2024-04-19 07:00:19 +12:00
andres-portainer 3ae55d8c3e fix(mingit): upgrade to v2.44.0.1 EE-7023 (#11640) 2024-04-18 15:22:25 -03:00
andres-portainer 933c2a7002 fix(docker): upgrade to v24.0.9 EE-7016 (#11619) 2024-04-17 19:38:23 -03:00
andres-portainer 1641642695 fix(go): upgrade Go to v1.21.9 in the nightly security scan EE-6939 (#11616) 2024-04-17 18:09:41 -03:00
Matt Hook f80b1ed53a fix(auth): prevent user enumeration attack [EE-6832] (#11587) 2024-04-17 16:08:56 +12:00
Prabhat Khera d04da7898d fix(pending-actions): clean pending actions for deleted environment [EE-6545] (#11599) 2024-04-17 08:32:25 +12:00
Matt Hook ec83d02afa chore(docker): bump docker client to 26.0.1 [EE-6941] (#11594) 2024-04-16 08:27:35 +12:00
Prabhat Khera 05265dda47 fix(stacks): update info text for stack environment variables [EE-6902] (#11557) 2024-04-16 08:03:46 +12:00
Prabhat Khera 74e1ff5e2d fix(pending-actions): fix create kubeclient to check endpoint status [EE-6545] (#11585) 2024-04-16 07:40:45 +12:00
Matt Hook 795d812652 chore(api): bump docker and protobuf pkgs [EE-6941] (#11549) 2024-04-15 10:52:52 +12:00
Prabhat Khera 46b1d5b528 fix(compose): update compose to 2.26.1 [EE-6546] (#11537)
* update compose to 2.24

* chore(unpacker): use APIVersion as unpacker image tag [EE-6974] (#11538)

---------

Co-authored-by: hookenz <hookenz@gmail.com>
2024-04-15 10:39:28 +12:00
Matt Hook cf7672d59e bump helm version (#11563) 2024-04-15 09:18:18 +12:00
andres-portainer 9c8a30693a fix(protobuf): upgrade protobuf to v1.33 EE-6945 (#11568) 2024-04-12 17:52:52 -03:00
andres-portainer 023945cbd2 fix(go): upgrade Go to v1.21.9 EE-6939 (#11556) 2024-04-12 17:08:27 -03:00
Matt Hook 498ba46863 fix(backups): improved archive encryption [EE-6764] (#11482) 2024-04-10 10:58:16 +12:00
Matt Hook 399ddaea3b fix(services): speed up service count on the kubernetes dashboard [EE-6967] (#11524) 2024-04-09 15:50:39 +12:00
cmeng 13cee9975c feat(version): bump to 2.20.2 EE-6979 (#11517) 2024-04-08 12:27:51 +12:00
Matt Hook f8927851e4 fix(apikey): don't authenticate api key for external auth [EE-6932] (#11461) 2024-04-08 11:06:34 +12:00
Oscar Zhou b284d7094a fix(stack): filter out orphan stacks that have same name as normal stacks [EE-6791] (#11471) 2024-04-03 09:53:36 +13:00
LP B 7bb54bcbe6 fix(app): replace fields removed by Docker 25 and 26 (#11469)
* fix(app/volume): make optional Container and ContainerConfig fields removed in docker 26

* fix(app/image): use image.Size instead of image.VirtualSize removed in Docker 25
2024-03-29 13:57:18 +01:00
cmeng b3c489366f fix(edge-stack): avoid reference of undefined EE-6914 (#11465) 2024-03-27 16:02:25 +13:00
cmeng 5eca761883 feat(version): bump to 2.20.1 EE-6933 (#11459) 2024-03-27 15:41:45 +13:00
andres-portainer bea8acce1f fix(kubernetes): avoid a deadlock EE-6901 (#11446) 2024-03-25 14:19:33 -03:00
421 changed files with 5117 additions and 8044 deletions
+14 -4
View File
@@ -22,7 +22,7 @@ on:
env:
DOCKER_HUB_REPO: portainerci/portainer-ce
EXTENSION_HUB_REPO: portainerci/portainer-docker-extension
GO_VERSION: 1.21.11
GO_VERSION: 1.21.9
NODE_VERSION: 18.x
jobs:
@@ -34,6 +34,7 @@ jobs:
- { platform: linux, arch: arm64, version: "" }
- { platform: linux, arch: arm, version: "" }
- { platform: linux, arch: ppc64le, version: "" }
- { platform: linux, arch: s390x, version: "" }
- { platform: windows, arch: amd64, version: 1809 }
- { platform: windows, arch: amd64, version: ltsc2022 }
runs-on: ubuntu-latest
@@ -145,22 +146,31 @@ jobs:
# for instance, feature/1.0.0 -> feature-1.0.0
CONTAINER_IMAGE_TAG=$(echo $GITHUB_REF_NAME | sed 's/\//-/g')
fi
docker buildx imagetools create -t "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}" \
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-amd64" \
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm64" \
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm" \
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-ppc64le" \
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-s390x" \
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-windows1809-amd64" \
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-windowsltsc2022-amd64"
docker buildx imagetools create -t "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-alpine" \
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-amd64-alpine" \
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm64-alpine" \
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm-alpine" \
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-ppc64le-alpine"
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm-alpine"
if [[ "${GITHUB_REF_NAME}" =~ ^release/.*$ ]]; then
docker buildx imagetools create -t "${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}" \
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-amd64" \
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm64"
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm64" \
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm" \
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-ppc64le" \
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-s390x"
docker buildx imagetools create -t "${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-alpine" \
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-amd64-alpine" \
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm64-alpine" \
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm-alpine"
fi
+5 -25
View File
@@ -5,7 +5,6 @@ env:
NODE_VERSION: 18.x
on:
workflow_dispatch:
pull_request:
branches:
- master
@@ -28,22 +27,15 @@ jobs:
if: github.event.pull_request.draft == false
steps:
- name: 'checkout the current branch'
uses: actions/checkout@v4.1.1
with:
ref: ${{ github.event.inputs.branch }}
- name: 'set up node.js'
uses: actions/setup-node@v4.0.1
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'yarn'
- run: yarn --frozen-lockfile
- name: Run tests
run: make test-client ARGS="--maxWorkers=2 --minWorkers=1"
test-server:
strategy:
matrix:
@@ -56,21 +48,9 @@ jobs:
if: github.event.pull_request.draft == false
steps:
- name: 'checkout the current branch'
uses: actions/checkout@v4.1.1
with:
ref: ${{ github.event.inputs.branch }}
- name: 'set up golang'
uses: actions/setup-go@v5.0.0
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
go-version: ${{ env.GO_VERSION }}
- name: 'install dependencies'
run: make test-deps PLATFORM=linux ARCH=amd64
- name: 'update $PATH'
run: echo "$(pwd)/dist" >> $GITHUB_PATH
- name: 'run tests'
- name: Run tests
run: make test-server
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
cd $(dirname -- "$0") && yarn lint-staged
yarn lint-staged
-3
View File
@@ -64,9 +64,6 @@ clean: ## Remove all build and download artifacts
.PHONY: test test-client test-server
test: test-server test-client ## Run all tests
test-deps: init-dist
./build/download_docker_compose_binary.sh $(PLATFORM) $(ARCH) $(shell jq -r '.dockerCompose' < "./binary-version.json")
test-client: ## Run client tests
yarn test $(ARGS)
+1 -2
View File
@@ -82,8 +82,7 @@ func CreateBackupArchive(password string, gate *offlinegate.OfflineGate, datasto
}
func backupDb(backupDirPath string, datastore dataservices.DataStore) error {
dbFileName := datastore.Connection().GetDatabaseFileName()
_, err := datastore.Backup(filepath.Join(backupDirPath, dbFileName))
_, err := datastore.Backup(filepath.Join(backupDirPath, "portainer.db"))
return err
}
+16 -23
View File
@@ -5,17 +5,6 @@ import (
"github.com/portainer/portainer/api/internal/edge/cache"
)
// EdgeJobs retrieves the edge jobs for the given environment
func (service *Service) EdgeJobs(endpointID portainer.EndpointID) []portainer.EdgeJob {
service.mu.RLock()
defer service.mu.RUnlock()
return append(
make([]portainer.EdgeJob, 0, len(service.edgeJobs[endpointID])),
service.edgeJobs[endpointID]...,
)
}
// AddEdgeJob register an EdgeJob inside the tunnel details associated to an environment(endpoint).
func (service *Service) AddEdgeJob(endpoint *portainer.Endpoint, edgeJob *portainer.EdgeJob) {
if endpoint.Edge.AsyncMode {
@@ -23,10 +12,10 @@ func (service *Service) AddEdgeJob(endpoint *portainer.Endpoint, edgeJob *portai
}
service.mu.Lock()
defer service.mu.Unlock()
tunnel := service.getTunnelDetails(endpoint.ID)
existingJobIndex := -1
for idx, existingJob := range service.edgeJobs[endpoint.ID] {
for idx, existingJob := range tunnel.Jobs {
if existingJob.ID == edgeJob.ID {
existingJobIndex = idx
@@ -35,28 +24,30 @@ func (service *Service) AddEdgeJob(endpoint *portainer.Endpoint, edgeJob *portai
}
if existingJobIndex == -1 {
service.edgeJobs[endpoint.ID] = append(service.edgeJobs[endpoint.ID], *edgeJob)
tunnel.Jobs = append(tunnel.Jobs, *edgeJob)
} else {
service.edgeJobs[endpoint.ID][existingJobIndex] = *edgeJob
tunnel.Jobs[existingJobIndex] = *edgeJob
}
cache.Del(endpoint.ID)
service.mu.Unlock()
}
// RemoveEdgeJob will remove the specified Edge job from each tunnel it was registered with.
func (service *Service) RemoveEdgeJob(edgeJobID portainer.EdgeJobID) {
service.mu.Lock()
for endpointID := range service.edgeJobs {
for endpointID, tunnel := range service.tunnelDetailsMap {
n := 0
for _, edgeJob := range service.edgeJobs[endpointID] {
for _, edgeJob := range tunnel.Jobs {
if edgeJob.ID != edgeJobID {
service.edgeJobs[endpointID][n] = edgeJob
tunnel.Jobs[n] = edgeJob
n++
}
}
service.edgeJobs[endpointID] = service.edgeJobs[endpointID][:n]
tunnel.Jobs = tunnel.Jobs[:n]
cache.Del(endpointID)
}
@@ -66,17 +57,19 @@ func (service *Service) RemoveEdgeJob(edgeJobID portainer.EdgeJobID) {
func (service *Service) RemoveEdgeJobFromEndpoint(endpointID portainer.EndpointID, edgeJobID portainer.EdgeJobID) {
service.mu.Lock()
defer service.mu.Unlock()
tunnel := service.getTunnelDetails(endpointID)
n := 0
for _, edgeJob := range service.edgeJobs[endpointID] {
for _, edgeJob := range tunnel.Jobs {
if edgeJob.ID != edgeJobID {
service.edgeJobs[endpointID][n] = edgeJob
tunnel.Jobs[n] = edgeJob
n++
}
}
service.edgeJobs[endpointID] = service.edgeJobs[endpointID][:n]
tunnel.Jobs = tunnel.Jobs[:n]
cache.Del(endpointID)
service.mu.Unlock()
}
+128 -134
View File
@@ -19,6 +19,7 @@ import (
const (
tunnelCleanupInterval = 10 * time.Second
requiredTimeout = 15 * time.Second
activeTimeout = 4*time.Minute + 30*time.Second
pingTimeout = 3 * time.Second
)
@@ -27,54 +28,32 @@ const (
// It is used to start a reverse tunnel server and to manage the connection status of each tunnel
// connected to the tunnel server.
type Service struct {
serverFingerprint string
serverPort string
activeTunnels map[portainer.EndpointID]*portainer.TunnelDetails
edgeJobs map[portainer.EndpointID][]portainer.EdgeJob
dataStore dataservices.DataStore
snapshotService portainer.SnapshotService
chiselServer *chserver.Server
shutdownCtx context.Context
ProxyManager *proxy.Manager
mu sync.RWMutex
fileService portainer.FileService
defaultCheckinInterval int
serverFingerprint string
serverPort string
tunnelDetailsMap map[portainer.EndpointID]*portainer.TunnelDetails
dataStore dataservices.DataStore
snapshotService portainer.SnapshotService
chiselServer *chserver.Server
shutdownCtx context.Context
ProxyManager *proxy.Manager
mu sync.Mutex
fileService portainer.FileService
}
// NewService returns a pointer to a new instance of Service
func NewService(dataStore dataservices.DataStore, shutdownCtx context.Context, fileService portainer.FileService) *Service {
defaultCheckinInterval := portainer.DefaultEdgeAgentCheckinIntervalInSeconds
settings, err := dataStore.Settings().Settings()
if err == nil {
defaultCheckinInterval = settings.EdgeAgentCheckinInterval
} else {
log.Error().Err(err).Msg("unable to retrieve the settings from the database")
}
return &Service{
activeTunnels: make(map[portainer.EndpointID]*portainer.TunnelDetails),
edgeJobs: make(map[portainer.EndpointID][]portainer.EdgeJob),
dataStore: dataStore,
shutdownCtx: shutdownCtx,
fileService: fileService,
defaultCheckinInterval: defaultCheckinInterval,
tunnelDetailsMap: make(map[portainer.EndpointID]*portainer.TunnelDetails),
dataStore: dataStore,
shutdownCtx: shutdownCtx,
fileService: fileService,
}
}
// pingAgent ping the given agent so that the agent can keep the tunnel alive
func (service *Service) pingAgent(endpointID portainer.EndpointID) error {
endpoint, err := service.dataStore.Endpoint().Endpoint(endpointID)
if err != nil {
return err
}
tunnelAddr, err := service.TunnelAddr(endpoint)
if err != nil {
return err
}
requestURL := fmt.Sprintf("http://%s/ping", tunnelAddr)
tunnel := service.GetTunnelDetails(endpointID)
requestURL := fmt.Sprintf("http://127.0.0.1:%d/ping", tunnel.Port)
req, err := http.NewRequest(http.MethodHead, requestURL, nil)
if err != nil {
return err
@@ -97,49 +76,47 @@ func (service *Service) pingAgent(endpointID portainer.EndpointID) error {
// KeepTunnelAlive keeps the tunnel of the given environment for maxAlive duration, or until ctx is done
func (service *Service) KeepTunnelAlive(endpointID portainer.EndpointID, ctx context.Context, maxAlive time.Duration) {
go service.keepTunnelAlive(endpointID, ctx, maxAlive)
}
go func() {
log.Debug().
Int("endpoint_id", int(endpointID)).
Float64("max_alive_minutes", maxAlive.Minutes()).
Msg("KeepTunnelAlive: start")
func (service *Service) keepTunnelAlive(endpointID portainer.EndpointID, ctx context.Context, maxAlive time.Duration) {
log.Debug().
Int("endpoint_id", int(endpointID)).
Float64("max_alive_minutes", maxAlive.Minutes()).
Msg("KeepTunnelAlive: start")
maxAliveTicker := time.NewTicker(maxAlive)
defer maxAliveTicker.Stop()
maxAliveTicker := time.NewTicker(maxAlive)
defer maxAliveTicker.Stop()
pingTicker := time.NewTicker(tunnelCleanupInterval)
defer pingTicker.Stop()
pingTicker := time.NewTicker(tunnelCleanupInterval)
defer pingTicker.Stop()
for {
select {
case <-pingTicker.C:
service.SetTunnelStatusToActive(endpointID)
err := service.pingAgent(endpointID)
if err != nil {
log.Debug().
Int("endpoint_id", int(endpointID)).
Err(err).
Msg("KeepTunnelAlive: ping agent")
}
case <-maxAliveTicker.C:
log.Debug().
Int("endpoint_id", int(endpointID)).
Float64("timeout_minutes", maxAlive.Minutes()).
Msg("KeepTunnelAlive: tunnel keep alive timeout")
for {
select {
case <-pingTicker.C:
service.UpdateLastActivity(endpointID)
if err := service.pingAgent(endpointID); err != nil {
return
case <-ctx.Done():
err := ctx.Err()
log.Debug().
Int("endpoint_id", int(endpointID)).
Err(err).
Msg("KeepTunnelAlive: ping agent")
Msg("KeepTunnelAlive: tunnel stop")
return
}
case <-maxAliveTicker.C:
log.Debug().
Int("endpoint_id", int(endpointID)).
Float64("timeout_minutes", maxAlive.Minutes()).
Msg("KeepTunnelAlive: tunnel keep alive timeout")
return
case <-ctx.Done():
err := ctx.Err()
log.Debug().
Int("endpoint_id", int(endpointID)).
Err(err).
Msg("KeepTunnelAlive: tunnel stop")
return
}
}
}()
}
// StartTunnelServer starts a tunnel server on the specified addr and port.
@@ -149,6 +126,7 @@ func (service *Service) keepTunnelAlive(endpointID portainer.EndpointID, ctx con
// The snapshotter is used in the tunnel status verification process.
func (service *Service) StartTunnelServer(addr, port string, snapshotService portainer.SnapshotService) error {
privateKeyFile, err := service.retrievePrivateKeyFile()
if err != nil {
return err
}
@@ -166,21 +144,21 @@ func (service *Service) StartTunnelServer(addr, port string, snapshotService por
service.serverFingerprint = chiselServer.GetFingerprint()
service.serverPort = port
if err := chiselServer.Start(addr, port); err != nil {
err = chiselServer.Start(addr, port)
if err != nil {
return err
}
service.chiselServer = chiselServer
// TODO: work-around Chisel default behavior.
// By default, Chisel will allow anyone to connect if no user exists.
username, password := generateRandomCredentials()
if err = service.chiselServer.AddUser(username, password, "127.0.0.1"); err != nil {
err = service.chiselServer.AddUser(username, password, "127.0.0.1")
if err != nil {
return err
}
service.snapshotService = snapshotService
go service.startTunnelVerificationLoop()
return nil
@@ -194,39 +172,37 @@ func (service *Service) StopTunnelServer() error {
func (service *Service) retrievePrivateKeyFile() (string, error) {
privateKeyFile := service.fileService.GetDefaultChiselPrivateKeyPath()
if exists, _ := service.fileService.FileExists(privateKeyFile); exists {
exist, _ := service.fileService.FileExists(privateKeyFile)
if !exist {
log.Debug().
Str("private-key", privateKeyFile).
Msg("Chisel private key file does not exist")
privateKey, err := ccrypto.GenerateKey("")
if err != nil {
log.Error().
Err(err).
Msg("Failed to generate chisel private key")
return "", err
}
err = service.fileService.StoreChiselPrivateKey(privateKey)
if err != nil {
log.Error().
Err(err).
Msg("Failed to save Chisel private key to disk")
return "", err
} else {
log.Info().
Str("private-key", privateKeyFile).
Msg("Generated a new Chisel private key file")
}
} else {
log.Info().
Str("private-key", privateKeyFile).
Msg("found Chisel private key file on disk")
return privateKeyFile, nil
Msg("Found Chisel private key file on disk")
}
log.Debug().
Str("private-key", privateKeyFile).
Msg("chisel private key file does not exist")
privateKey, err := ccrypto.GenerateKey("")
if err != nil {
log.Error().
Err(err).
Msg("failed to generate chisel private key")
return "", err
}
if err = service.fileService.StoreChiselPrivateKey(privateKey); err != nil {
log.Error().
Err(err).
Msg("failed to save Chisel private key to disk")
return "", err
}
log.Info().
Str("private-key", privateKeyFile).
Msg("generated a new Chisel private key file")
return privateKeyFile, nil
}
@@ -254,45 +230,63 @@ func (service *Service) startTunnelVerificationLoop() {
}
}
// checkTunnels finds the first tunnel that has not had any activity recently
// and attempts to take a snapshot, then closes it and returns
func (service *Service) checkTunnels() {
service.mu.RLock()
tunnels := make(map[portainer.EndpointID]portainer.TunnelDetails)
for endpointID, tunnel := range service.activeTunnels {
elapsed := time.Since(tunnel.LastActivity)
log.Debug().
Int("endpoint_id", int(endpointID)).
Float64("last_activity_seconds", elapsed.Seconds()).
Msg("environment tunnel monitoring")
if tunnel.Status == portainer.EdgeAgentManagementRequired && elapsed < activeTimeout {
service.mu.Lock()
for key, tunnel := range service.tunnelDetailsMap {
if tunnel.LastActivity.IsZero() || tunnel.Status == portainer.EdgeAgentIdle {
continue
}
tunnelPort := tunnel.Port
service.mu.RUnlock()
log.Debug().
Int("endpoint_id", int(endpointID)).
Float64("last_activity_seconds", elapsed.Seconds()).
Float64("timeout_seconds", activeTimeout.Seconds()).
Msg("last activity timeout exceeded")
if err := service.snapshotEnvironment(endpointID, tunnelPort); err != nil {
log.Error().
Int("endpoint_id", int(endpointID)).
Err(err).
Msg("unable to snapshot Edge environment")
if tunnel.Status == portainer.EdgeAgentManagementRequired && time.Since(tunnel.LastActivity) < requiredTimeout {
continue
}
service.close(portainer.EndpointID(endpointID))
if tunnel.Status == portainer.EdgeAgentActive && time.Since(tunnel.LastActivity) < activeTimeout {
continue
}
return
tunnels[key] = *tunnel
}
service.mu.Unlock()
service.mu.RUnlock()
for endpointID, tunnel := range tunnels {
elapsed := time.Since(tunnel.LastActivity)
log.Debug().
Int("endpoint_id", int(endpointID)).
Str("status", tunnel.Status).
Float64("status_time_seconds", elapsed.Seconds()).
Msg("environment tunnel monitoring")
if tunnel.Status == portainer.EdgeAgentManagementRequired && elapsed > requiredTimeout {
log.Debug().
Int("endpoint_id", int(endpointID)).
Str("status", tunnel.Status).
Float64("status_time_seconds", elapsed.Seconds()).
Float64("timeout_seconds", requiredTimeout.Seconds()).
Msg("REQUIRED state timeout exceeded")
}
if tunnel.Status == portainer.EdgeAgentActive && elapsed > activeTimeout {
log.Debug().
Int("endpoint_id", int(endpointID)).
Str("status", tunnel.Status).
Float64("status_time_seconds", elapsed.Seconds()).
Float64("timeout_seconds", activeTimeout.Seconds()).
Msg("ACTIVE state timeout exceeded")
err := service.snapshotEnvironment(endpointID, tunnel.Port)
if err != nil {
log.Error().
Int("endpoint_id", int(endpointID)).
Err(err).
Msg("unable to snapshot Edge environment")
}
}
service.SetTunnelStatusToIdle(portainer.EndpointID(endpointID))
}
}
func (service *Service) snapshotEnvironment(endpointID portainer.EndpointID, tunnelPort int) error {
+5 -14
View File
@@ -7,22 +7,14 @@ import (
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/stretchr/testify/require"
)
func TestPingAgentPanic(t *testing.T) {
endpoint := &portainer.Endpoint{
ID: 1,
EdgeID: "test-edge-id",
Type: portainer.EdgeAgentOnDockerEnvironment,
UserTrusted: true,
}
endpointID := portainer.EndpointID(1)
_, store := datastore.MustNewTestStore(t, true, true)
s := NewService(store, nil, nil)
s := NewService(nil, nil, nil)
defer func() {
require.Nil(t, recover())
@@ -40,9 +32,8 @@ func TestPingAgentPanic(t *testing.T) {
require.NoError(t, http.Serve(ln, mux))
}()
err = s.Open(endpoint)
require.NoError(t, err)
s.activeTunnels[endpoint.ID].Port = ln.Addr().(*net.TCPAddr).Port
s.getTunnelDetails(endpointID)
s.tunnelDetailsMap[endpointID].Port = ln.Addr().(*net.TCPAddr).Port
require.Error(t, s.pingAgent(endpoint.ID))
require.Error(t, s.pingAgent(endpointID))
}
+144 -179
View File
@@ -5,18 +5,14 @@ import (
"errors"
"fmt"
"math/rand"
"net"
"strings"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/internal/edge"
"github.com/portainer/portainer/api/internal/edge/cache"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/pkg/libcrypto"
"github.com/dchest/uniuri"
"github.com/rs/zerolog/log"
)
const (
@@ -24,191 +20,18 @@ const (
maxAvailablePort = 65535
)
var (
ErrNonEdgeEnv = errors.New("cannot open a tunnel for non-edge environments")
ErrAsyncEnv = errors.New("cannot open a tunnel for async edge environments")
ErrInvalidEnv = errors.New("cannot open a tunnel for an invalid environment")
)
// Open will mark the tunnel as REQUIRED so the agent opens it
func (s *Service) Open(endpoint *portainer.Endpoint) error {
if !endpointutils.IsEdgeEndpoint(endpoint) {
return ErrNonEdgeEnv
}
if endpoint.Edge.AsyncMode {
return ErrAsyncEnv
}
if endpoint.ID == 0 || endpoint.EdgeID == "" || !endpoint.UserTrusted {
return ErrInvalidEnv
}
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.activeTunnels[endpoint.ID]; ok {
return nil
}
defer cache.Del(endpoint.ID)
tun := &portainer.TunnelDetails{
Status: portainer.EdgeAgentManagementRequired,
Port: s.getUnusedPort(),
LastActivity: time.Now(),
}
username, password := generateRandomCredentials()
if s.chiselServer != nil {
authorizedRemote := fmt.Sprintf("^R:0.0.0.0:%d$", tun.Port)
if err := s.chiselServer.AddUser(username, password, authorizedRemote); err != nil {
return err
}
}
credentials, err := encryptCredentials(username, password, endpoint.EdgeID)
if err != nil {
return err
}
tun.Credentials = credentials
s.activeTunnels[endpoint.ID] = tun
return nil
}
// close removes the tunnel from the map so the agent will close it
func (s *Service) close(endpointID portainer.EndpointID) {
s.mu.Lock()
defer s.mu.Unlock()
tun, ok := s.activeTunnels[endpointID]
if !ok {
return
}
if len(tun.Credentials) > 0 && s.chiselServer != nil {
user, _, _ := strings.Cut(tun.Credentials, ":")
s.chiselServer.DeleteUser(user)
}
if s.ProxyManager != nil {
s.ProxyManager.DeleteEndpointProxy(endpointID)
}
delete(s.activeTunnels, endpointID)
cache.Del(endpointID)
}
// Config returns the tunnel details needed for the agent to connect
func (s *Service) Config(endpointID portainer.EndpointID) portainer.TunnelDetails {
s.mu.RLock()
defer s.mu.RUnlock()
if tun, ok := s.activeTunnels[endpointID]; ok {
return *tun
}
return portainer.TunnelDetails{Status: portainer.EdgeAgentIdle}
}
// TunnelAddr returns the address of the local tunnel, including the port, it
// will block until the tunnel is ready
func (s *Service) TunnelAddr(endpoint *portainer.Endpoint) (string, error) {
if err := s.Open(endpoint); err != nil {
return "", err
}
tun := s.Config(endpoint.ID)
checkinInterval := time.Duration(s.tryEffectiveCheckinInterval(endpoint)) * time.Second
for t0 := time.Now(); ; {
if time.Since(t0) > 2*checkinInterval {
s.close(endpoint.ID)
return "", errors.New("unable to open the tunnel")
}
// Check if the tunnel is established
conn, err := net.DialTCP("tcp", nil, &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: tun.Port})
if err != nil {
time.Sleep(checkinInterval / 100)
continue
}
conn.Close()
break
}
s.UpdateLastActivity(endpoint.ID)
return fmt.Sprintf("127.0.0.1:%d", tun.Port), nil
}
// tryEffectiveCheckinInterval avoids a potential deadlock by returning a
// previous known value after a timeout
func (s *Service) tryEffectiveCheckinInterval(endpoint *portainer.Endpoint) int {
ch := make(chan int, 1)
go func() {
ch <- edge.EffectiveCheckinInterval(s.dataStore, endpoint)
}()
select {
case <-time.After(50 * time.Millisecond):
s.mu.RLock()
defer s.mu.RUnlock()
return s.defaultCheckinInterval
case i := <-ch:
s.mu.Lock()
s.defaultCheckinInterval = i
s.mu.Unlock()
return i
}
}
// UpdateLastActivity sets the current timestamp to avoid the tunnel timeout
func (s *Service) UpdateLastActivity(endpointID portainer.EndpointID) {
s.mu.Lock()
defer s.mu.Unlock()
if tun, ok := s.activeTunnels[endpointID]; ok {
tun.LastActivity = time.Now()
}
}
// NOTE: it needs to be called with the lock acquired
// getUnusedPort is used to generate an unused random port in the dynamic port range.
// Dynamic ports (also called private ports) are 49152 to 65535.
func (service *Service) getUnusedPort() int {
port := randomInt(minAvailablePort, maxAvailablePort)
for _, tunnel := range service.activeTunnels {
for _, tunnel := range service.tunnelDetailsMap {
if tunnel.Port == port {
return service.getUnusedPort()
}
}
conn, err := net.DialTCP("tcp", nil, &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: port})
if err == nil {
conn.Close()
log.Debug().
Int("port", port).
Msg("selected port is in use, trying a different one")
return service.getUnusedPort()
}
return port
}
@@ -216,10 +39,152 @@ func randomInt(min, max int) int {
return min + rand.Intn(max-min)
}
// NOTE: it needs to be called with the lock acquired
func (service *Service) getTunnelDetails(endpointID portainer.EndpointID) *portainer.TunnelDetails {
if tunnel, ok := service.tunnelDetailsMap[endpointID]; ok {
return tunnel
}
tunnel := &portainer.TunnelDetails{
Status: portainer.EdgeAgentIdle,
}
service.tunnelDetailsMap[endpointID] = tunnel
cache.Del(endpointID)
return tunnel
}
// GetTunnelDetails returns information about the tunnel associated to an environment(endpoint).
func (service *Service) GetTunnelDetails(endpointID portainer.EndpointID) portainer.TunnelDetails {
service.mu.Lock()
defer service.mu.Unlock()
return *service.getTunnelDetails(endpointID)
}
// GetActiveTunnel retrieves an active tunnel which allows communicating with edge agent
func (service *Service) GetActiveTunnel(endpoint *portainer.Endpoint) (portainer.TunnelDetails, error) {
if endpoint.Edge.AsyncMode {
return portainer.TunnelDetails{}, errors.New("cannot open tunnel on async endpoint")
}
tunnel := service.GetTunnelDetails(endpoint.ID)
if tunnel.Status == portainer.EdgeAgentActive {
// update the LastActivity
service.SetTunnelStatusToActive(endpoint.ID)
}
if tunnel.Status == portainer.EdgeAgentIdle || tunnel.Status == portainer.EdgeAgentManagementRequired {
err := service.SetTunnelStatusToRequired(endpoint.ID)
if err != nil {
return portainer.TunnelDetails{}, fmt.Errorf("failed opening tunnel to endpoint: %w", err)
}
if endpoint.EdgeCheckinInterval == 0 {
settings, err := service.dataStore.Settings().Settings()
if err != nil {
return portainer.TunnelDetails{}, fmt.Errorf("failed fetching settings from db: %w", err)
}
endpoint.EdgeCheckinInterval = settings.EdgeAgentCheckinInterval
}
time.Sleep(2 * time.Duration(endpoint.EdgeCheckinInterval) * time.Second)
}
return service.GetTunnelDetails(endpoint.ID), nil
}
// SetTunnelStatusToActive update the status of the tunnel associated to the specified environment(endpoint).
// It sets the status to ACTIVE.
func (service *Service) SetTunnelStatusToActive(endpointID portainer.EndpointID) {
service.mu.Lock()
tunnel := service.getTunnelDetails(endpointID)
tunnel.Status = portainer.EdgeAgentActive
tunnel.Credentials = ""
tunnel.LastActivity = time.Now()
service.mu.Unlock()
cache.Del(endpointID)
}
// SetTunnelStatusToIdle update the status of the tunnel associated to the specified environment(endpoint).
// It sets the status to IDLE.
// It removes any existing credentials associated to the tunnel.
func (service *Service) SetTunnelStatusToIdle(endpointID portainer.EndpointID) {
service.mu.Lock()
tunnel := service.getTunnelDetails(endpointID)
tunnel.Status = portainer.EdgeAgentIdle
tunnel.Port = 0
tunnel.LastActivity = time.Now()
credentials := tunnel.Credentials
if credentials != "" {
tunnel.Credentials = ""
if service.chiselServer != nil {
service.chiselServer.DeleteUser(strings.Split(credentials, ":")[0])
}
}
service.ProxyManager.DeleteEndpointProxy(endpointID)
service.mu.Unlock()
cache.Del(endpointID)
}
// SetTunnelStatusToRequired update the status of the tunnel associated to the specified environment(endpoint).
// It sets the status to REQUIRED.
// If no port is currently associated to the tunnel, it will associate a random unused port to the tunnel
// and generate temporary credentials that can be used to establish a reverse tunnel on that port.
// Credentials are encrypted using the Edge ID associated to the environment(endpoint).
func (service *Service) SetTunnelStatusToRequired(endpointID portainer.EndpointID) error {
defer cache.Del(endpointID)
tunnel := service.getTunnelDetails(endpointID)
service.mu.Lock()
defer service.mu.Unlock()
if tunnel.Port == 0 {
endpoint, err := service.dataStore.Endpoint().Endpoint(endpointID)
if err != nil {
return err
}
tunnel.Status = portainer.EdgeAgentManagementRequired
tunnel.Port = service.getUnusedPort()
tunnel.LastActivity = time.Now()
username, password := generateRandomCredentials()
authorizedRemote := fmt.Sprintf("^R:0.0.0.0:%d$", tunnel.Port)
if service.chiselServer != nil {
err = service.chiselServer.AddUser(username, password, authorizedRemote)
if err != nil {
return err
}
}
credentials, err := encryptCredentials(username, password, endpoint.EdgeID)
if err != nil {
return err
}
tunnel.Credentials = credentials
}
return nil
}
func generateRandomCredentials() (string, string) {
username := uniuri.NewLen(8)
password := uniuri.NewLen(8)
return username, password
}
+1 -1
View File
@@ -62,7 +62,7 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
MaxBatchDelay: kingpin.Flag("max-batch-delay", "Maximum delay before a batch starts").Duration(),
SecretKeyName: kingpin.Flag("secret-key-name", "Secret key name for encryption and will be used as /run/secrets/<secret-key-name>.").Default(defaultSecretKeyName).String(),
LogLevel: kingpin.Flag("log-level", "Set the minimum logging level to show").Default("INFO").Enum("DEBUG", "INFO", "WARN", "ERROR"),
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("PRETTY", "JSON"),
}
kingpin.Parse()
-7
View File
@@ -42,13 +42,6 @@ func setLoggingMode(mode string) {
TimeFormat: "2006/01/02 03:04PM",
FormatMessage: formatMessage,
})
case "NOCOLOR":
log.Logger = log.Output(zerolog.ConsoleWriter{
Out: os.Stderr,
TimeFormat: "2006/01/02 03:04PM",
FormatMessage: formatMessage,
NoColor: true,
})
case "JSON":
log.Logger = log.Output(os.Stderr)
}
-6
View File
@@ -22,12 +22,6 @@ func CreateTLSConfiguration() *tls.Config {
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
tls.TLS_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA,
tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA,
tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
},
}
}
-1
View File
@@ -36,7 +36,6 @@ type (
}
DataStore interface {
Connection() portainer.Connection
Open() (newStore bool, err error)
Init() error
Close() error
+2 -11
View File
@@ -19,7 +19,8 @@ type Service struct {
// NewService creates a new instance of a service.
func NewService(connection portainer.Connection) (*Service, error) {
if err := connection.SetServiceName(BucketName); err != nil {
err := connection.SetServiceName(BucketName)
if err != nil {
return nil, err
}
@@ -31,16 +32,6 @@ func NewService(connection portainer.Connection) (*Service, error) {
}, nil
}
func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
return ServiceTx{
BaseDataServiceTx: dataservices.BaseDataServiceTx[portainer.Team, portainer.TeamID]{
Bucket: BucketName,
Connection: service.Connection,
Tx: tx,
},
}
}
// TeamByName returns a team by name.
func (service *Service) TeamByName(name string) (*portainer.Team, error) {
var t portainer.Team
-48
View File
@@ -1,48 +0,0 @@
package team
import (
"errors"
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
dserrors "github.com/portainer/portainer/api/dataservices/errors"
)
type ServiceTx struct {
dataservices.BaseDataServiceTx[portainer.Team, portainer.TeamID]
}
// TeamByName returns a team by name.
func (service ServiceTx) TeamByName(name string) (*portainer.Team, error) {
var t portainer.Team
err := service.Tx.GetAll(
BucketName,
&portainer.Team{},
dataservices.FirstFn(&t, func(e portainer.Team) bool {
return strings.EqualFold(e.Name, name)
}),
)
if errors.Is(err, dataservices.ErrStop) {
return &t, nil
}
if err == nil {
return nil, dserrors.ErrObjectNotFound
}
return nil, err
}
// CreateTeam creates a new Team.
func (service ServiceTx) Create(team *portainer.Team) error {
return service.Tx.CreateObject(
BucketName,
func(id uint64) (int, any) {
team.ID = portainer.TeamID(id)
return int(team.ID), team
},
)
}
+1 -1
View File
@@ -402,6 +402,7 @@ type storeExport struct {
}
func (store *Store) Export(filename string) (err error) {
backup := storeExport{}
if c, err := store.CustomTemplate().ReadAll(); err != nil {
@@ -605,7 +606,6 @@ func (store *Store) Export(filename string) (err error) {
if err != nil {
return err
}
return os.WriteFile(filename, b, 0600)
}
+1 -4
View File
@@ -80,10 +80,7 @@ func (tx *StoreTx) TeamMembership() dataservices.TeamMembershipService {
return tx.store.TeamMembershipService.Tx(tx.tx)
}
func (tx *StoreTx) Team() dataservices.TeamService {
return tx.store.TeamService.Tx(tx.tx)
}
func (tx *StoreTx) Team() dataservices.TeamService { return nil }
func (tx *StoreTx) TunnelServer() dataservices.TunnelServerService { return nil }
func (tx *StoreTx) User() dataservices.UserService {
@@ -941,6 +941,6 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.21.4\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.20.3\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
}
}
+3 -2
View File
@@ -3,6 +3,7 @@ package client
import (
"bytes"
"errors"
"fmt"
"io"
"maps"
"net/http"
@@ -49,12 +50,12 @@ func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint, nodeNam
case portainer.AgentOnDockerEnvironment:
return createAgentClient(endpoint, endpoint.URL, factory.signatureService, nodeName, timeout)
case portainer.EdgeAgentOnDockerEnvironment:
tunnelAddr, err := factory.reverseTunnelService.TunnelAddr(endpoint)
tunnel, err := factory.reverseTunnelService.GetActiveTunnel(endpoint)
if err != nil {
return nil, err
}
endpointURL := "http://" + tunnelAddr
endpointURL := fmt.Sprintf("http://127.0.0.1:%d", tunnel.Port)
return createAgentClient(endpoint, endpointURL, factory.signatureService, nodeName, timeout)
}
+10 -57
View File
@@ -4,17 +4,15 @@ import (
"context"
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
dockerclient "github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/docker/images"
"github.com/Masterminds/semver"
"github.com/docker/docker/api/types"
dockercontainer "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/client"
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
dockerclient "github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/docker/images"
"github.com/rs/zerolog/log"
)
@@ -32,44 +30,6 @@ func NewContainerService(factory *dockerclient.ClientFactory, dataStore dataserv
}
}
// applyVersionConstraint uses the version to apply a transformation function to
// the value when the constraint is satisfied
func applyVersionConstraint[T any](currentVersion, versionConstraint string, value T, transform func(T) T) (T, error) {
newValue := value
constraint, err := semver.NewConstraint(versionConstraint)
if err != nil {
return newValue, errors.New("invalid version constraint specified")
}
currentVer, err := semver.NewVersion(currentVersion)
if err != nil {
log.Warn().Err(err).Msg("Unable to parse the Docker client version")
return newValue, nil
}
if satisfiesConstraint, _ := constraint.Validate(currentVer); satisfiesConstraint {
newValue = transform(value)
}
return newValue, nil
}
func clearMacAddrs(n network.NetworkingConfig) network.NetworkingConfig {
netConfig := network.NetworkingConfig{
EndpointsConfig: make(map[string]*network.EndpointSettings),
}
for k := range n.EndpointsConfig {
endpointConfig := n.EndpointsConfig[k].Copy()
endpointConfig.MacAddress = ""
netConfig.EndpointsConfig[k] = endpointConfig
}
return netConfig
}
// Recreate a container
func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.Endpoint, containerId string, forcePullImage bool, imageTag, nodeName string) (*types.ContainerJSON, error) {
cli, err := c.factory.CreateClient(endpoint, nodeName, nil)
@@ -130,7 +90,7 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
return nil, errors.Wrap(err, "rename container error")
}
initialNetwork := network.NetworkingConfig{
networkWithCreation := network.NetworkingConfig{
EndpointsConfig: make(map[string]*network.EndpointSettings),
}
@@ -143,10 +103,10 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
}
// 5. get the first network attached to the current container
if len(initialNetwork.EndpointsConfig) == 0 {
if len(networkWithCreation.EndpointsConfig) == 0 {
// Retrieve the first network that is linked to the present container, which
// will be utilized when creating the container.
initialNetwork.EndpointsConfig[name] = network
networkWithCreation.EndpointsConfig[name] = network
}
}
c.sr.enable()
@@ -170,15 +130,7 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
// to retain the same network settings we have to connect on creation to one of the old
// container's networks, and connect to the other networks after creation.
// see: https://portainer.atlassian.net/browse/EE-5448
// Docker API < 1.44 does not support specifying MAC addresses
// https://github.com/moby/moby/blob/6aea26b431ea152a8b085e453da06ea403f89886/client/container_create.go#L44-L46
initialNetwork, err = applyVersionConstraint(cli.ClientVersion(), "< 1.44", initialNetwork, clearMacAddrs)
if err != nil {
return nil, err
}
create, err := cli.ContainerCreate(ctx, container.Config, container.HostConfig, &initialNetwork, nil, container.Name)
create, err := cli.ContainerCreate(ctx, container.Config, container.HostConfig, &networkWithCreation, nil, container.Name)
c.sr.push(func() {
log.Debug().Str("container_id", create.ID).Msg("removing the new container")
@@ -198,7 +150,8 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
log.Debug().Str("container_id", newContainerId).Msg("connecting networks to container")
networks := container.NetworkSettings.Networks
for key, network := range networks {
if _, ok := initialNetwork.EndpointsConfig[key]; ok {
_, ok := networkWithCreation.EndpointsConfig[key]
if ok {
// skip the network that is used during container creation
continue
}
-52
View File
@@ -1,52 +0,0 @@
package docker
import (
"testing"
"github.com/docker/docker/api/types/network"
"github.com/stretchr/testify/require"
)
func TestApplyVersionConstraint(t *testing.T) {
initialNet := network.NetworkingConfig{
EndpointsConfig: map[string]*network.EndpointSettings{
"key1": {
MacAddress: "mac1",
EndpointID: "endpointID1",
},
"key2": {
MacAddress: "mac2",
EndpointID: "endpointID2",
},
},
}
f := func(currentVer string, constraint string, success, emptyMac bool) {
t.Helper()
transformedNet, err := applyVersionConstraint(currentVer, constraint, initialNet, clearMacAddrs)
if success {
require.NoError(t, err)
} else {
require.Error(t, err)
}
require.Len(t, transformedNet.EndpointsConfig, len(initialNet.EndpointsConfig))
for k := range initialNet.EndpointsConfig {
if emptyMac {
require.NotEqual(t, initialNet.EndpointsConfig[k], transformedNet.EndpointsConfig[k])
require.Empty(t, transformedNet.EndpointsConfig[k].MacAddress)
continue
}
require.Equal(t, initialNet.EndpointsConfig[k], transformedNet.EndpointsConfig[k])
}
}
f("1.45", "< 1.44", true, false) // No transformation
f("1.43", "< 1.44", true, true) // Transformation
f("a.b.", "< 1.44", true, false) // Invalid current version
f("1.45", "z 1.44", false, false) // Invalid version constraint
}
+3 -2
View File
@@ -3,6 +3,7 @@ package exec
import (
"bytes"
"errors"
"fmt"
"os"
"os/exec"
"path"
@@ -185,11 +186,11 @@ func (manager *SwarmStackManager) prepareDockerCommandAndArgs(binaryPath, config
endpointURL := endpoint.URL
if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment {
tunnelAddr, err := manager.reverseTunnelService.TunnelAddr(endpoint)
tunnel, err := manager.reverseTunnelService.GetActiveTunnel(endpoint)
if err != nil {
return "", nil, err
}
endpointURL = "tcp://" + tunnelAddr
endpointURL = fmt.Sprintf("tcp://127.0.0.1:%d", tunnel.Port)
}
args = append(args, "-H", endpointURL)
-4
View File
@@ -143,7 +143,6 @@ func (c *gitClient) listFiles(ctx context.Context, opt fetchOption) ([]string, e
ReferenceName: plumbing.ReferenceName(opt.referenceName),
Auth: getAuth(opt.username, opt.password),
InsecureSkipTLS: opt.tlsSkipVerify,
Tags: git.NoTags,
}
repo, err := git.Clone(memory.NewStorage(), nil, cloneOption)
@@ -167,10 +166,7 @@ func (c *gitClient) listFiles(ctx context.Context, opt fetchOption) ([]string, e
}
var allPaths []string
w := object.NewTreeWalker(tree, true, nil)
defer w.Close()
for {
name, entry, err := w.Next()
if err != nil {
-23
View File
@@ -91,29 +91,6 @@ func Test_latestCommitID(t *testing.T) {
assert.Equal(t, "68dcaa7bd452494043c64252ab90db0f98ecf8d2", id)
}
func Test_ListRefs(t *testing.T) {
service := Service{git: NewGitClient(true)}
repositoryURL := setup(t)
fs, err := service.ListRefs(repositoryURL, "", "", false, false)
assert.NoError(t, err)
assert.Equal(t, []string{"refs/heads/main"}, fs)
}
func Test_ListFiles(t *testing.T) {
service := Service{git: NewGitClient(true)}
repositoryURL := setup(t)
referenceName := "refs/heads/main"
fs, err := service.ListFiles(repositoryURL, referenceName, "", "", false, false, []string{".yml"}, false)
assert.NoError(t, err)
assert.Equal(t, []string{"docker-compose.yml"}, fs)
}
func getCommitHistoryLength(t *testing.T, err error, dir string) int {
repo, err := git.PlainOpen(dir)
if err != nil {
+12 -19
View File
@@ -9,7 +9,6 @@ import (
lru "github.com/hashicorp/golang-lru"
"github.com/rs/zerolog/log"
"golang.org/x/sync/singleflight"
)
const (
@@ -224,23 +223,11 @@ func (service *Service) ListRefs(repositoryURL, username, password string, hardR
return refs, nil
}
var singleflightGroup = &singleflight.Group{}
// ListFiles will list all the files of the target repository with specific extensions.
// If extension is not provided, it will list all the files under the target repository
func (service *Service) ListFiles(repositoryURL, referenceName, username, password string, dirOnly, hardRefresh bool, includedExts []string, tlsSkipVerify bool) ([]string, error) {
repoKey := generateCacheKey(repositoryURL, referenceName, username, password, strconv.FormatBool(tlsSkipVerify), strconv.FormatBool(dirOnly))
fs, err, _ := singleflightGroup.Do(repoKey, func() (any, error) {
return service.listFiles(repositoryURL, referenceName, username, password, dirOnly, hardRefresh, tlsSkipVerify)
})
return filterFiles(fs.([]string), includedExts), err
}
func (service *Service) listFiles(repositoryURL, referenceName, username, password string, dirOnly, hardRefresh bool, tlsSkipVerify bool) ([]string, error) {
repoKey := generateCacheKey(repositoryURL, referenceName, username, password, strconv.FormatBool(tlsSkipVerify), strconv.FormatBool(dirOnly))
if service.cacheEnabled && hardRefresh {
// Should remove the cache explicitly, so that the following normal list can show the correct result
service.repoFileCache.Remove(repoKey)
@@ -248,9 +235,14 @@ func (service *Service) listFiles(repositoryURL, referenceName, username, passwo
if service.repoFileCache != nil {
// lookup the files cache first
if cache, ok := service.repoFileCache.Get(repoKey); ok {
if files, ok := cache.([]string); ok {
return files, nil
cache, ok := service.repoFileCache.Get(repoKey)
if ok {
files, success := cache.([]string)
if success {
// For the case while searching files in a repository without include extensions for the first time,
// but with include extensions for the second time
includedFiles := filterFiles(files, includedExts)
return includedFiles, nil
}
}
}
@@ -282,11 +274,12 @@ func (service *Service) listFiles(repositoryURL, referenceName, username, passwo
}
}
includedFiles := filterFiles(files, includedExts)
if service.cacheEnabled && service.repoFileCache != nil {
service.repoFileCache.Add(repoKey, files)
service.repoFileCache.Add(repoKey, includedFiles)
return includedFiles, nil
}
return files, nil
return includedFiles, nil
}
func (service *Service) purgeCache() {
+3 -11
View File
@@ -4,7 +4,6 @@ import (
"crypto/rand"
"fmt"
"net/http"
"os"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
@@ -14,13 +13,6 @@ import (
)
func WithProtect(handler http.Handler) (http.Handler, error) {
// 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
isDockerDesktopExtension := false
if val, ok := os.LookupEnv("DOCKER_EXTENSION"); ok && val == "1" {
isDockerDesktopExtension = true
}
handler = withSendCSRFToken(handler)
token := make([]byte, 32)
@@ -35,7 +27,7 @@ func WithProtect(handler http.Handler) (http.Handler, error) {
gorillacsrf.Secure(false),
)(handler)
return withSkipCSRF(handler, isDockerDesktopExtension), nil
return withSkipCSRF(handler), nil
}
func withSendCSRFToken(handler http.Handler) http.Handler {
@@ -56,10 +48,10 @@ func withSendCSRFToken(handler http.Handler) http.Handler {
})
}
func withSkipCSRF(handler http.Handler, isDockerDesktopExtension bool) http.Handler {
func withSkipCSRF(handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
skip, err := security.ShouldSkipCSRFCheck(r, isDockerDesktopExtension)
skip, err := security.ShouldSkipCSRFCheck(r)
if err != nil {
httperror.WriteError(w, http.StatusForbidden, err.Error(), err)
return
-2
View File
@@ -28,7 +28,5 @@ func (handler *Handler) logout(w http.ResponseWriter, r *http.Request) *httperro
security.RemoveAuthCookie(w)
handler.bouncer.RevokeJWT(tokenData.Token)
return response.Empty(w)
}
@@ -70,7 +70,8 @@ func (handler *Handler) customTemplateGitFetch(w http.ResponseWriter, r *http.Re
if err != nil {
log.Warn().Err(err).Msg("failed to download git repository")
if rbErr := rollbackCustomTemplate(backupPath, customTemplate.ProjectPath); rbErr != nil {
if err != nil {
rbErr := rollbackCustomTemplate(backupPath, customTemplate.ProjectPath)
return httperror.InternalServerError("Failed to rollback the custom template folder", rbErr)
}
@@ -2,7 +2,6 @@ package customtemplates
import (
"bytes"
"errors"
"io"
"io/fs"
"net/http"
@@ -20,7 +19,6 @@ import (
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/jwt"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/assert"
@@ -51,19 +49,6 @@ func (f *TestFileService) GetFileContent(projectPath, configFilePath string) ([]
return os.ReadFile(filepath.Join(projectPath, configFilePath))
}
type InvalidTestGitService struct {
portainer.GitService
targetFilePath string
}
func (g *InvalidTestGitService) CloneRepository(dest, repoUrl, refName, username, password string, tlsSkipVerify bool) error {
return errors.New("simulate network error")
}
func (g *InvalidTestGitService) LatestCommitID(repositoryURL, referenceName, username, password string, tlsSkipVerify bool) (string, error) {
return "", nil
}
func createTestFile(targetPath string) error {
f, err := os.Create(targetPath)
if err != nil {
@@ -189,28 +174,4 @@ func Test_customTemplateGitFetch(t *testing.T) {
singleAPIRequest(h, jwt2, is, "gfedcba")
})
t.Run("restore git repository if it is failed to download the new git repository", func(t *testing.T) {
invalidGitService := &InvalidTestGitService{
targetFilePath: filepath.Join(template1.ProjectPath, template1.GitConfig.ConfigFilePath),
}
h := NewHandler(requestBouncer, store, fileService, invalidGitService)
req := httptest.NewRequest(http.MethodPut, "/custom_templates/1/git_fetch", bytes.NewBuffer([]byte("{}")))
testhelpers.AddTestSecurityCookie(req, jwt1)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
is.Equal(http.StatusInternalServerError, rr.Code)
var errResp httperror.HandlerError
err = json.NewDecoder(rr.Body).Decode(&errResp)
assert.NoError(t, err, "failed to parse error body")
assert.FileExists(t, gitService.targetFilePath, "previous git repository is not restored")
fileContent, err := os.ReadFile(gitService.targetFilePath)
assert.NoError(t, err, "failed to read target file")
assert.Equal(t, "gfedcba", string(fileContent))
})
}
@@ -7,7 +7,6 @@ import (
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/docker"
dockerclient "github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
)
@@ -31,7 +30,7 @@ func NewHandler(routePrefix string, bouncer security.BouncerService, dataStore d
}
router := h.PathPrefix(routePrefix).Subrouter()
router.Use(bouncer.AuthenticatedAccess, middlewares.CheckEndpointAuthorization(bouncer))
router.Use(bouncer.AuthenticatedAccess)
router.Handle("/{containerId}/gpus", httperror.LoggerHandler(h.containerGpusInspect)).Methods(http.MethodGet)
router.Handle("/{containerId}/recreate", httperror.LoggerHandler(h.recreate)).Methods(http.MethodPost)
+3 -3
View File
@@ -40,14 +40,14 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
}
// endpoints
endpointRouter := h.PathPrefix("/docker/{id}").Subrouter()
endpointRouter := h.PathPrefix("/{id}").Subrouter()
endpointRouter.Use(middlewares.WithEndpoint(dataStore.Endpoint(), "id"))
endpointRouter.Use(dockerOnlyMiddleware)
containersHandler := containers.NewHandler("/docker/{id}/containers", bouncer, dataStore, dockerClientFactory, containerService)
containersHandler := containers.NewHandler("/{id}/containers", bouncer, dataStore, dockerClientFactory, containerService)
endpointRouter.PathPrefix("/containers").Handler(containersHandler)
imagesHandler := images.NewHandler("/docker/{id}/images", bouncer, dockerClientFactory)
imagesHandler := images.NewHandler("/{id}/images", bouncer, dockerClientFactory)
endpointRouter.PathPrefix("/images").Handler(imagesHandler)
return h
}
+1 -2
View File
@@ -4,7 +4,6 @@ import (
"net/http"
"github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
@@ -26,7 +25,7 @@ func NewHandler(routePrefix string, bouncer security.BouncerService, dockerClien
}
router := h.PathPrefix(routePrefix).Subrouter()
router.Use(bouncer.AuthenticatedAccess, middlewares.CheckEndpointAuthorization(bouncer))
router.Use(bouncer.AuthenticatedAccess)
router.Handle("", httperror.LoggerHandler(h.imagesList)).Methods(http.MethodGet)
return h
@@ -78,7 +78,7 @@ func (handler *Handler) imagesList(w http.ResponseWriter, r *http.Request) *http
imagesList := make([]ImageResponse, len(images))
for i, image := range images {
if len(image.RepoTags) == 0 && len(image.RepoDigests) > 0 {
if (image.RepoTags == nil || len(image.RepoTags) == 0) && (image.RepoDigests != nil && len(image.RepoDigests) > 0) {
for _, repoDigest := range image.RepoDigests {
image.RepoTags = append(image.RepoTags, repoDigest[0:strings.Index(repoDigest, "@")]+":<none>")
}
@@ -19,9 +19,8 @@ import (
// @security jwt
// @param id path int true "EdgeGroup Id"
// @success 204
// @failure 409 "Edge group is in use by an Edge stack or Edge job"
// @failure 503 "Edge compute features are disabled"
// @failure 500 "Server error"
// @failure 500
// @router /edge_groups/{id} [delete]
func (handler *Handler) edgeGroupDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
edgeGroupID, err := request.RetrieveNumericRouteVariableValue(r, "id")
@@ -2,7 +2,6 @@ package edgestacks
import (
"errors"
"fmt"
"net/http"
"time"
@@ -64,8 +63,9 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
}
var payload updateStatusPayload
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
return httperror.BadRequest("Invalid request payload", fmt.Errorf("edge polling error: %w. Environment ID: %d", err, payload.EndpointID))
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
var stack *portainer.EdgeStack
@@ -98,16 +98,17 @@ func (handler *Handler) updateEdgeStackStatus(tx dataservices.DataStoreTx, r *ht
return nil, nil
}
return nil, fmt.Errorf("unable to retrieve Edge stack from the database: %w. Environment ID: %d", err, payload.EndpointID)
return nil, err
}
endpoint, err := tx.Endpoint().Endpoint(payload.EndpointID)
if err != nil {
return nil, handler.handlerDBErr(fmt.Errorf("unable to find the environment from the database: %w. Environment ID: %d", err, payload.EndpointID), "unable to find the environment")
return nil, handler.handlerDBErr(err, "Unable to find an environment with the specified identifier inside the database")
}
if err := handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint); err != nil {
return nil, httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment name: %s", err, endpoint.Name))
err = handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint)
if err != nil {
return nil, httperror.Forbidden("Permission denied to access environment", err)
}
status := *payload.Status
@@ -125,8 +126,9 @@ func (handler *Handler) updateEdgeStackStatus(tx dataservices.DataStoreTx, r *ht
updateEnvStatus(payload.EndpointID, stack, deploymentStatus)
if err := tx.EdgeStack().UpdateEdgeStack(stackID, stack); err != nil {
return nil, handler.handlerDBErr(fmt.Errorf("unable to update Edge stack to the database: %w. Environment name: %s", err, endpoint.Name), "unable to update Edge stack")
err = tx.EdgeStack().UpdateEdgeStack(stackID, stack)
if err != nil {
return nil, handler.handlerDBErr(err, "Unable to persist the stack changes inside the database")
}
return stack, nil
@@ -2,7 +2,6 @@ package endpointedge
import (
"errors"
"fmt"
"net/http"
"strconv"
@@ -40,30 +39,32 @@ func (handler *Handler) endpointEdgeJobsLogs(w http.ResponseWriter, r *http.Requ
return httperror.BadRequest("Unable to find an environment on request context", err)
}
if err := handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint); err != nil {
return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment name: %s", err, endpoint.Name))
err = handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint)
if err != nil {
return httperror.Forbidden("Permission denied to access environment", err)
}
edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "jobID")
if err != nil {
return httperror.BadRequest("Invalid edge job identifier route variable", fmt.Errorf("invalid Edge job route variable: %w. Environment name: %s", err, endpoint.Name))
return httperror.BadRequest("Invalid edge job identifier route variable", err)
}
var payload logsPayload
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
return httperror.BadRequest("Invalid request payload", fmt.Errorf("invalid Edge job request payload: %w. Environment name: %s", err, endpoint.Name))
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
return handler.getEdgeJobLobs(tx, endpoint.ID, portainer.EdgeJobID(edgeJobID), payload)
}); err != nil {
})
if err != nil {
var httpErr *httperror.HandlerError
if errors.As(err, &httpErr) {
httpErr.Err = fmt.Errorf("edge polling error: %w. Environment name: %s", httpErr.Err, endpoint.Name)
return httpErr
}
return httperror.InternalServerError("Unexpected error", fmt.Errorf("edge polling error: %w. Environment name: %s", err, endpoint.Name))
return httperror.InternalServerError("Unexpected error", err)
}
return response.JSON(w, nil)
@@ -1,7 +1,7 @@
package endpointedge
import (
"fmt"
"errors"
"net/http"
portainer "github.com/portainer/portainer/api"
@@ -33,26 +33,27 @@ func (handler *Handler) endpointEdgeStackInspect(w http.ResponseWriter, r *http.
return httperror.BadRequest("Unable to find an environment on request context", err)
}
if err := handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint); err != nil {
return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment name: %s", err, endpoint.Name))
err = handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint)
if err != nil {
return httperror.Forbidden("Permission denied to access environment", err)
}
edgeStackID, err := request.RetrieveNumericRouteVariableValue(r, "stackId")
if err != nil {
return httperror.BadRequest("Invalid edge stack identifier route variable", fmt.Errorf("invalid Edge stack route variable: %w. Environment name: %s", err, endpoint.Name))
return httperror.BadRequest("Invalid edge stack identifier route variable", err)
}
edgeStack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(edgeStackID))
if handler.DataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find an edge stack with the specified identifier inside the database", fmt.Errorf("unable to find the Edge stack from database: %w. Environment name: %s", err, endpoint.Name))
return httperror.NotFound("Unable to find an edge stack with the specified identifier inside the database", err)
} else if err != nil {
return httperror.InternalServerError("Unable to find an edge stack with the specified identifier inside the database", fmt.Errorf("failed to find the Edge stack from database: %w. Environment name: %s", err, endpoint.Name))
return httperror.InternalServerError("Unable to find an edge stack with the specified identifier inside the database", err)
}
fileName := edgeStack.EntryPoint
if endpointutils.IsDockerEndpoint(endpoint) {
if fileName == "" {
return httperror.BadRequest("Docker is not supported by this stack", fmt.Errorf("no filename is provided for the Docker endpoint. Environment name: %s", endpoint.Name))
return httperror.BadRequest("Docker is not supported by this stack", errors.New("Docker is not supported by this stack"))
}
}
@@ -65,18 +66,18 @@ func (handler *Handler) endpointEdgeStackInspect(w http.ResponseWriter, r *http.
fileName = edgeStack.ManifestPath
if fileName == "" {
return httperror.BadRequest("Kubernetes is not supported by this stack", fmt.Errorf("no filename is provided for the Kubernetes endpoint. Environment name: %s", endpoint.Name))
return httperror.BadRequest("Kubernetes is not supported by this stack", errors.New("Kubernetes is not supported by this stack"))
}
}
dirEntries, err := filesystem.LoadDir(edgeStack.ProjectPath)
if err != nil {
return httperror.InternalServerError("Unable to load repository", fmt.Errorf("failed to load project directory: %w. Environment name: %s", err, endpoint.Name))
return httperror.InternalServerError("Unable to load repository", err)
}
fileContent, err := filesystem.FilterDirForCompatibility(dirEntries, fileName, endpoint.Agent.Version)
if err != nil {
return httperror.InternalServerError("File not found", fmt.Errorf("unable to find file: %w. Environment name: %s", err, endpoint.Name))
return httperror.InternalServerError("File not found", err)
}
dirEntries = filesystem.FilterDirForEntryFile(dirEntries, fileName)
@@ -15,13 +15,10 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/internal/edge"
"github.com/portainer/portainer/api/internal/edge/cache"
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"
)
type stackStatusResponse struct {
@@ -86,48 +83,45 @@ func (handler *Handler) endpointEdgeStatusInspect(w http.ResponseWriter, r *http
if _, ok := handler.DataStore.Endpoint().Heartbeat(portainer.EndpointID(endpointID)); !ok {
// EE-5190
return httperror.Forbidden("Permission denied to access environment. The device has not been trusted yet", fmt.Errorf("unable to retrieve endpoint heartbeat. Environment ID: %d", endpointID))
return httperror.Forbidden("Permission denied to access environment", errors.New("the device has not been trusted yet"))
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if err != nil {
// EE-5190
return httperror.Forbidden("Permission denied to access environment. The device has not been trusted yet", fmt.Errorf("unable to retrieve endpoint from database: %w. Environment ID: %d", err, endpointID))
return httperror.Forbidden("Permission denied to access environment", errors.New("the device has not been trusted yet"))
}
firstConn := endpoint.LastCheckInDate == 0
err = handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint)
if err != nil {
return httperror.Forbidden("Permission denied to access environment. The device has not been trusted yet", fmt.Errorf("unauthorized Edge endpoint operation: %w. Environment name: %s", err, endpoint.Name))
return httperror.Forbidden("Permission denied to access environment", err)
}
handler.DataStore.Endpoint().UpdateHeartbeat(endpoint.ID)
err = handler.requestBouncer.TrustedEdgeEnvironmentAccess(handler.DataStore, endpoint)
if err != nil {
return httperror.Forbidden("Permission denied to access environment. The device has not been trusted yet", fmt.Errorf("untrusted Edge environment access: %w. Environment name: %s", err, endpoint.Name))
return httperror.Forbidden("Permission denied to access environment", err)
}
var statusResponse *endpointEdgeStatusInspectResponse
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
statusResponse, err = handler.inspectStatus(tx, r, portainer.EndpointID(endpointID), firstConn)
statusResponse, err = handler.inspectStatus(tx, r, portainer.EndpointID(endpointID))
return err
})
if err != nil {
var httpErr *httperror.HandlerError
if errors.As(err, &httpErr) {
httpErr.Err = fmt.Errorf("edge polling error: %w. Environment name: %s", httpErr.Err, endpoint.Name)
return httpErr
}
return httperror.InternalServerError("Unexpected error", fmt.Errorf("edge polling error: %w. Environment name: %s", err, endpoint.Name))
return httperror.InternalServerError("Unexpected error", err)
}
return cacheResponse(w, endpoint.ID, *statusResponse)
}
func (handler *Handler) inspectStatus(tx dataservices.DataStoreTx, r *http.Request, endpointID portainer.EndpointID, firstConn bool) (*endpointEdgeStatusInspectResponse, error) {
func (handler *Handler) inspectStatus(tx dataservices.DataStoreTx, r *http.Request, endpointID portainer.EndpointID) (*endpointEdgeStatusInspectResponse, error) {
endpoint, err := tx.Endpoint().Endpoint(endpointID)
if err != nil {
return nil, err
@@ -139,10 +133,8 @@ func (handler *Handler) inspectStatus(tx dataservices.DataStoreTx, r *http.Reque
}
// Take an initial snapshot
if firstConn {
if err := handler.ReverseTunnelService.Open(endpoint); err != nil {
log.Error().Err(err).Msg("could not open the tunnel")
}
if endpoint.LastCheckInDate == 0 {
handler.ReverseTunnelService.SetTunnelStatusToRequired(endpoint.ID)
}
agentPlatform, agentPlatformErr := parseAgentPlatform(r)
@@ -161,21 +153,34 @@ func (handler *Handler) inspectStatus(tx dataservices.DataStoreTx, r *http.Reque
return nil, httperror.InternalServerError("Unable to persist environment changes inside the database", err)
}
tunnel := handler.ReverseTunnelService.Config(endpoint.ID)
checkinInterval := endpoint.EdgeCheckinInterval
if endpoint.EdgeCheckinInterval == 0 {
settings, err := tx.Settings().Settings()
if err != nil {
return nil, httperror.InternalServerError("Unable to retrieve settings from the database", err)
}
checkinInterval = settings.EdgeAgentCheckinInterval
}
tunnel := handler.ReverseTunnelService.GetTunnelDetails(endpoint.ID)
statusResponse := endpointEdgeStatusInspectResponse{
Status: tunnel.Status,
Port: tunnel.Port,
CheckinInterval: edge.EffectiveCheckinInterval(tx, endpoint),
CheckinInterval: checkinInterval,
Credentials: tunnel.Credentials,
}
schedules, handlerErr := handler.buildSchedules(endpoint.ID)
schedules, handlerErr := handler.buildSchedules(endpoint.ID, tunnel)
if handlerErr != nil {
return nil, handlerErr
}
statusResponse.Schedules = schedules
if tunnel.Status == portainer.EdgeAgentManagementRequired {
handler.ReverseTunnelService.SetTunnelStatusToActive(endpoint.ID)
}
edgeStacksStatus, handlerErr := handler.buildEdgeStacks(tx, endpoint.ID)
if handlerErr != nil {
return nil, handlerErr
@@ -208,9 +213,9 @@ func parseAgentPlatform(r *http.Request) (portainer.EndpointType, error) {
}
}
func (handler *Handler) buildSchedules(endpointID portainer.EndpointID) ([]edgeJobResponse, *httperror.HandlerError) {
func (handler *Handler) buildSchedules(endpointID portainer.EndpointID, tunnel portainer.TunnelDetails) ([]edgeJobResponse, *httperror.HandlerError) {
schedules := []edgeJobResponse{}
for _, job := range handler.ReverseTunnelService.EdgeJobs(endpointID) {
for _, job := range tunnel.Jobs {
var collectLogs bool
if _, ok := job.GroupLogsCollection[endpointID]; ok {
collectLogs = job.GroupLogsCollection[endpointID].CollectLogs
@@ -266,7 +271,6 @@ func cacheResponse(w http.ResponseWriter, endpointID portainer.EndpointID, statu
httpErr := response.JSON(rr, statusResponse)
if httpErr != nil {
httpErr.Err = fmt.Errorf("failed to cache response: %w. Environment ID: %d", httpErr.Err, endpointID)
return httpErr
}
@@ -154,7 +154,7 @@ func TestMissingEdgeIdentifier(t *testing.T) {
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected a %d response, found: %d without Edge identifier", http.StatusForbidden, rec.Code)
t.Fatalf(fmt.Sprintf("expected a %d response, found: %d without Edge identifier", http.StatusForbidden, rec.Code))
}
}
@@ -179,7 +179,7 @@ func TestWithEndpoints(t *testing.T) {
handler.ServeHTTP(rec, req)
if rec.Code != test.expectedStatusCode {
t.Fatalf("expected a %d response, found: %d for endpoint ID: %d", test.expectedStatusCode, rec.Code, test.endpoint.ID)
t.Fatalf(fmt.Sprintf("expected a %d response, found: %d for endpoint ID: %d", test.expectedStatusCode, rec.Code, test.endpoint.ID))
}
}
}
@@ -219,7 +219,7 @@ func TestLastCheckInDateIncreases(t *testing.T) {
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code)
t.Fatalf(fmt.Sprintf("expected a %d response, found: %d", http.StatusOK, rec.Code))
}
updatedEndpoint, err := handler.DataStore.Endpoint().Endpoint(endpoint.ID)
@@ -262,7 +262,7 @@ func TestEmptyEdgeIdWithAgentPlatformHeader(t *testing.T) {
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected a %d response, found: %d with empty edge ID", http.StatusOK, rec.Code)
t.Fatalf(fmt.Sprintf("expected a %d response, found: %d with empty edge ID", http.StatusOK, rec.Code))
}
updatedEndpoint, err := handler.DataStore.Endpoint().Endpoint(endpoint.ID)
@@ -326,7 +326,7 @@ func TestEdgeStackStatus(t *testing.T) {
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code)
t.Fatalf(fmt.Sprintf("expected a %d response, found: %d", http.StatusOK, rec.Code))
}
var data endpointEdgeStatusInspectResponse
@@ -391,7 +391,7 @@ func TestEdgeJobsResponse(t *testing.T) {
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code)
t.Fatalf(fmt.Sprintf("expected a %d response, found: %d", http.StatusOK, rec.Code))
}
var data endpointEdgeStatusInspectResponse
@@ -96,8 +96,8 @@ func (handler *Handler) updateEndpointGroup(tx dataservices.DataStoreTx, endpoin
payloadTagSet := tag.Set(payload.TagIDs)
endpointGroupTagSet := tag.Set((endpointGroup.TagIDs))
union := tag.Union(payloadTagSet, endpointGroupTagSet)
intersection := tag.IntersectionCount(payloadTagSet, endpointGroupTagSet)
tagsChanged = len(union) > intersection
intersection := tag.Intersection(payloadTagSet, endpointGroupTagSet)
tagsChanged = len(union) > len(intersection)
if tagsChanged {
removeTags := tag.Difference(endpointGroupTagSet, payloadTagSet)
@@ -34,7 +34,7 @@ func (handler *Handler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http.
return httperror.InternalServerError("No Edge agent registered with the environment", errors.New("No agent available"))
}
_, err := handler.ReverseTunnelService.TunnelAddr(endpoint)
_, err := handler.ReverseTunnelService.GetActiveTunnel(endpoint)
if err != nil {
return httperror.InternalServerError("Unable to get the active tunnel", err)
}
@@ -34,7 +34,7 @@ func (handler *Handler) proxyRequestsToKubernetesAPI(w http.ResponseWriter, r *h
return httperror.InternalServerError("No Edge agent registered with the environment", errors.New("No agent available"))
}
_, err := handler.ReverseTunnelService.TunnelAddr(endpoint)
_, err := handler.ReverseTunnelService.GetActiveTunnel(endpoint)
if err != nil {
return httperror.InternalServerError("Unable to get the active tunnel", err)
}
@@ -59,6 +59,8 @@ func (handler *Handler) endpointAssociationDelete(w http.ResponseWriter, r *http
return httperror.InternalServerError("Failed persisting environment in database", err)
}
handler.ReverseTunnelService.SetTunnelStatusToIdle(endpoint.ID)
return response.Empty(w)
}
@@ -201,7 +201,6 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
// @param Gpus formData string false "List of GPUs - json stringified array of {name, value} structs"
// @success 200 {object} portainer.Endpoint "Success"
// @failure 400 "Invalid request"
// @failure 409 "Name is not unique"
// @failure 500 "Server error"
// @router /endpoints [post]
func (handler *Handler) endpointCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
+39 -112
View File
@@ -2,7 +2,6 @@ package endpoints
import (
"errors"
"fmt"
"net/http"
"slices"
"strconv"
@@ -18,40 +17,19 @@ import (
"github.com/rs/zerolog/log"
)
type endpointDeleteRequest struct {
ID int `json:"id"`
DeleteCluster bool `json:"deleteCluster"`
}
type endpointDeleteBatchPayload struct {
Endpoints []endpointDeleteRequest `json:"endpoints"`
}
type endpointDeleteBatchPartialResponse struct {
Deleted []int `json:"deleted"`
Errors []int `json:"errors"`
}
func (payload *endpointDeleteBatchPayload) Validate(r *http.Request) error {
if payload == nil || len(payload.Endpoints) == 0 {
return fmt.Errorf("invalid request payload. You must provide a list of environments to delete")
}
return nil
}
// @id EndpointDelete
// @summary Remove an environment
// @description Remove the environment associated to the specified identifier and optionally clean-up associated resources.
// @description **Access policy**: Administrator only.
// @summary Remove an environment(endpoint)
// @description Remove an environment(endpoint).
// @description **Access policy**: administrator
// @tags endpoints
// @security ApiKeyAuth || jwt
// @security ApiKeyAuth
// @security jwt
// @param id path int true "Environment(Endpoint) identifier"
// @success 204 "Environment successfully deleted."
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 403 "Unauthorized access or operation not allowed."
// @failure 404 "Unable to find the environment with the specified identifier inside the database."
// @failure 500 "Server error occurred while attempting to delete the environment."
// @success 204 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied"
// @failure 404 "Environment(Endpoint) not found"
// @failure 500 "Server error"
// @router /endpoints/{id} [delete]
func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
@@ -84,63 +62,6 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) *
return response.Empty(w)
}
// @id EndpointDeleteBatch
// @summary Remove multiple environments
// @description Remove multiple environments and optionally clean-up associated resources.
// @description **Access policy**: Administrator only.
// @tags endpoints
// @security ApiKeyAuth || jwt
// @accept json
// @produce json
// @param body body endpointDeleteBatchPayload true "List of environments to delete, with optional deleteCluster flag to clean-up assocaited resources (cloud environments only)"
// @success 204 "Environment(s) successfully deleted."
// @failure 207 {object} endpointDeleteBatchPartialResponse "Partial success. Some environments were deleted successfully, while others failed."
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 403 "Unauthorized access or operation not allowed."
// @failure 500 "Server error occurred while attempting to delete the specified environments."
// @router /endpoints [delete]
func (handler *Handler) endpointDeleteBatch(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var p endpointDeleteBatchPayload
if err := request.DecodeAndValidateJSONPayload(r, &p); err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
resp := endpointDeleteBatchPartialResponse{
Deleted: []int{},
Errors: []int{},
}
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
for _, e := range p.Endpoints {
if handler.demoService.IsDemoEnvironment(portainer.EndpointID(e.ID)) {
resp.Errors = append(resp.Errors, e.ID)
log.Warn().Err(httperrors.ErrNotAvailableInDemo).Msgf("Unable to remove demo environment %d", e.ID)
continue
}
if err := handler.deleteEndpoint(tx, portainer.EndpointID(e.ID), e.DeleteCluster); err != nil {
resp.Errors = append(resp.Errors, e.ID)
log.Warn().Err(err).Int("environment_id", e.ID).Msg("Unable to remove environment")
continue
}
resp.Deleted = append(resp.Deleted, e.ID)
}
return nil
}); err != nil {
return httperror.InternalServerError("Unable to delete environments", err)
}
if len(resp.Errors) > 0 {
return response.JSONWithStatus(w, resp, http.StatusPartialContent)
}
return response.Empty(w)
}
func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID portainer.EndpointID, deleteCluster bool) error {
endpoint, err := tx.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if tx.IsErrObjectNotFound(err) {
@@ -157,20 +78,23 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
}
}
if err := tx.Snapshot().Delete(endpointID); err != nil {
log.Warn().Err(err).Msg("Unable to remove the snapshot from the database")
err = tx.Snapshot().Delete(endpointID)
if err != nil {
log.Warn().Err(err).Msgf("Unable to remove the snapshot from the database")
}
handler.ProxyManager.DeleteEndpointProxy(endpoint.ID)
if len(endpoint.UserAccessPolicies) > 0 || len(endpoint.TeamAccessPolicies) > 0 {
if err := handler.AuthorizationService.UpdateUsersAuthorizationsTx(tx); err != nil {
log.Warn().Err(err).Msg("Unable to update user authorizations")
err = handler.AuthorizationService.UpdateUsersAuthorizationsTx(tx)
if err != nil {
log.Warn().Err(err).Msgf("Unable to update user authorizations")
}
}
if err := tx.EndpointRelation().DeleteEndpointRelation(endpoint.ID); err != nil {
log.Warn().Err(err).Msg("Unable to remove environment relation from the database")
err = tx.EndpointRelation().DeleteEndpointRelation(endpoint.ID)
if err != nil {
log.Warn().Err(err).Msgf("Unable to remove environment relation from the database")
}
for _, tagID := range endpoint.TagIDs {
@@ -182,9 +106,9 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
}
if handler.DataStore.IsErrObjectNotFound(err) {
log.Warn().Err(err).Msg("Unable to find tag inside the database")
log.Warn().Err(err).Msgf("Unable to find tag inside the database")
} else if err != nil {
log.Warn().Err(err).Msg("Unable to delete tag relation from the database")
log.Warn().Err(err).Msgf("Unable to delete tag relation from the database")
}
}
@@ -198,39 +122,40 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
return e == endpoint.ID
})
if err := tx.EdgeGroup().Update(edgeGroup.ID, &edgeGroup); err != nil {
log.Warn().Err(err).Msg("Unable to update edge group")
err = tx.EdgeGroup().Update(edgeGroup.ID, &edgeGroup)
if err != nil {
log.Warn().Err(err).Msgf("Unable to update edge group")
}
}
edgeStacks, err := tx.EdgeStack().EdgeStacks()
if err != nil {
log.Warn().Err(err).Msg("Unable to retrieve edge stacks from the database")
log.Warn().Err(err).Msgf("Unable to retrieve edge stacks from the database")
}
for idx := range edgeStacks {
edgeStack := &edgeStacks[idx]
if _, ok := edgeStack.Status[endpoint.ID]; ok {
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")
err = tx.EdgeStack().UpdateEdgeStack(edgeStack.ID, edgeStack)
if err != nil {
log.Warn().Err(err).Msgf("Unable to update edge stack")
}
}
}
registries, err := tx.Registry().ReadAll()
if err != nil {
log.Warn().Err(err).Msg("Unable to retrieve registries from the database")
log.Warn().Err(err).Msgf("Unable to retrieve registries from the database")
}
for idx := range registries {
registry := &registries[idx]
if _, ok := registry.RegistryAccesses[endpoint.ID]; ok {
delete(registry.RegistryAccesses, endpoint.ID)
if err := tx.Registry().Update(registry.ID, registry); err != nil {
log.Warn().Err(err).Msg("Unable to update registry accesses")
err = tx.Registry().Update(registry.ID, registry)
if err != nil {
log.Warn().Err(err).Msgf("Unable to update registry accesses")
}
}
}
@@ -238,7 +163,7 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
if endpointutils.IsEdgeEndpoint(endpoint) {
edgeJobs, err := handler.DataStore.EdgeJob().ReadAll()
if err != nil {
log.Warn().Err(err).Msg("Unable to retrieve edge jobs from the database")
log.Warn().Err(err).Msgf("Unable to retrieve edge jobs from the database")
}
for idx := range edgeJobs {
@@ -246,16 +171,18 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
if _, ok := edgeJob.Endpoints[endpoint.ID]; ok {
delete(edgeJob.Endpoints, endpoint.ID)
if err := tx.EdgeJob().Update(edgeJob.ID, edgeJob); err != nil {
log.Warn().Err(err).Msg("Unable to update edge job")
err = tx.EdgeJob().Update(edgeJob.ID, edgeJob)
if err != nil {
log.Warn().Err(err).Msgf("Unable to update edge job")
}
}
}
}
// delete the pending actions
if err := tx.PendingActions().DeleteByEndpointID(endpoint.ID); err != nil {
log.Warn().Err(err).Int("endpointId", int(endpoint.ID)).Msg("Unable to delete pending actions")
err = tx.PendingActions().DeleteByEndpointID(endpoint.ID)
if err != nil {
log.Warn().Err(err).Int("endpointId", int(endpoint.ID)).Msgf("Unable to delete pending actions")
}
err = tx.Endpoint().DeleteEndpoint(portainer.EndpointID(endpointID))
@@ -3,16 +3,15 @@ package endpoints
import (
"net/http"
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/kubernetes"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/pkg/errors"
)
// @id endpointRegistriesList
@@ -128,7 +127,7 @@ func (handler *Handler) isNamespaceAuthorized(endpoint *portainer.Endpoint, name
return true, nil
}
if !endpoint.Kubernetes.Configuration.RestrictDefaultNamespace && namespace == kubernetes.DefaultNamespace {
if namespace == "default" {
return true, nil
}
@@ -69,7 +69,6 @@ func (payload *endpointUpdatePayload) Validate(r *http.Request) error {
// @success 200 {object} portainer.Endpoint "Success"
// @failure 400 "Invalid request"
// @failure 404 "Environment(Endpoint) not found"
// @failure 409 "Name is not unique"
// @failure 500 "Server error"
// @router /endpoints/{id} [put]
func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
+29 -49
View File
@@ -193,7 +193,7 @@ func (handler *Handler) filterEndpointsByQuery(
return nil, 0, errors.WithMessage(err, "Unable to retrieve tags from the database")
}
tagsMap := make(map[portainer.TagID]string, len(tags))
tagsMap := make(map[portainer.TagID]string)
for _, tag := range tags {
tagsMap[tag.ID] = tag.Name
}
@@ -302,7 +302,8 @@ func filterEndpointsBySearchCriteria(
) []portainer.Endpoint {
n := 0
for _, endpoint := range endpoints {
if endpointMatchSearchCriteria(&endpoint, tagsMap, searchCriteria) {
endpointTags := convertTagIDsToTags(tagsMap, endpoint.TagIDs)
if endpointMatchSearchCriteria(&endpoint, endpointTags, searchCriteria) {
endpoints[n] = endpoint
n++
@@ -316,7 +317,7 @@ func filterEndpointsBySearchCriteria(
continue
}
if edgeGroupMatchSearchCriteria(&endpoint, edgeGroups, searchCriteria, endpointGroups) {
if edgeGroupMatchSearchCriteria(&endpoint, edgeGroups, searchCriteria, endpoints, endpointGroups) {
endpoints[n] = endpoint
n++
@@ -333,16 +334,11 @@ func filterEndpointsByStatuses(endpoints []portainer.Endpoint, statuses []portai
status := endpoint.Status
if endpointutils.IsEdgeEndpoint(&endpoint) {
isCheckValid := false
edgeCheckinInterval := endpoint.EdgeCheckinInterval
if edgeCheckinInterval == 0 {
if endpoint.EdgeCheckinInterval == 0 {
edgeCheckinInterval = settings.EdgeAgentCheckinInterval
}
if endpoint.Edge.AsyncMode {
edgeCheckinInterval = getShortestAsyncInterval(&endpoint, settings)
}
if edgeCheckinInterval != 0 && endpoint.LastCheckInDate != 0 {
isCheckValid = time.Now().Unix()-endpoint.LastCheckInDate <= int64(edgeCheckinInterval*EdgeDeviceIntervalMultiplier+EdgeDeviceIntervalAdd)
}
@@ -362,7 +358,7 @@ func filterEndpointsByStatuses(endpoints []portainer.Endpoint, statuses []portai
return endpoints[:n]
}
func endpointMatchSearchCriteria(endpoint *portainer.Endpoint, tagsMap map[portainer.TagID]string, searchCriteria string) bool {
func endpointMatchSearchCriteria(endpoint *portainer.Endpoint, tags []string, searchCriteria string) bool {
if strings.Contains(strings.ToLower(endpoint.Name), searchCriteria) {
return true
}
@@ -377,8 +373,8 @@ func endpointMatchSearchCriteria(endpoint *portainer.Endpoint, tagsMap map[porta
return true
}
for _, tagID := range endpoint.TagIDs {
if strings.Contains(strings.ToLower(tagsMap[tagID]), searchCriteria) {
for _, tag := range tags {
if strings.Contains(strings.ToLower(tag), searchCriteria) {
return true
}
}
@@ -388,18 +384,17 @@ func endpointMatchSearchCriteria(endpoint *portainer.Endpoint, tagsMap map[porta
func endpointGroupMatchSearchCriteria(endpoint *portainer.Endpoint, endpointGroups []portainer.EndpointGroup, tagsMap map[portainer.TagID]string, searchCriteria string) bool {
for _, group := range endpointGroups {
if group.ID != endpoint.GroupID {
continue
}
if strings.Contains(strings.ToLower(group.Name), searchCriteria) {
return true
}
for _, tagID := range group.TagIDs {
if strings.Contains(strings.ToLower(tagsMap[tagID]), searchCriteria) {
if group.ID == endpoint.GroupID {
if strings.Contains(strings.ToLower(group.Name), searchCriteria) {
return true
}
tags := convertTagIDsToTags(tagsMap, group.TagIDs)
for _, tag := range tags {
if strings.Contains(strings.ToLower(tag), searchCriteria) {
return true
}
}
}
}
@@ -411,10 +406,11 @@ func edgeGroupMatchSearchCriteria(
endpoint *portainer.Endpoint,
edgeGroups []portainer.EdgeGroup,
searchCriteria string,
endpoints []portainer.Endpoint,
endpointGroups []portainer.EndpointGroup,
) bool {
for _, edgeGroup := range edgeGroups {
relatedEndpointIDs := edge.EdgeGroupRelatedEndpoints(&edgeGroup, []portainer.Endpoint{*endpoint}, endpointGroups)
relatedEndpointIDs := edge.EdgeGroupRelatedEndpoints(&edgeGroup, endpoints, endpointGroups)
for _, endpointID := range relatedEndpointIDs {
if endpointID == endpoint.ID {
@@ -445,6 +441,16 @@ func filterEndpointsByTypes(endpoints []portainer.Endpoint, endpointTypes []port
return endpoints[:n]
}
func convertTagIDsToTags(tagsMap map[portainer.TagID]string, tagIDs []portainer.TagID) []string {
tags := make([]string, 0, len(tagIDs))
for _, tagID := range tagIDs {
tags = append(tags, tagsMap[tagID])
}
return tags
}
func filteredEndpointsByTags(endpoints []portainer.Endpoint, tagIDs []portainer.TagID, endpointGroups []portainer.EndpointGroup, partialMatch bool) []portainer.Endpoint {
n := 0
for _, endpoint := range endpoints {
@@ -623,29 +629,3 @@ func getEdgeStackStatusParam(r *http.Request) (*portainer.EdgeStackStatusType, e
return &edgeStackStatus, nil
}
func getShortestAsyncInterval(endpoint *portainer.Endpoint, settings *portainer.Settings) int {
var edgeIntervalUseDefault int = -1
pingInterval := endpoint.Edge.PingInterval
if pingInterval == edgeIntervalUseDefault {
pingInterval = settings.Edge.PingInterval
}
shortestAsyncInterval := pingInterval
snapshotInterval := endpoint.Edge.SnapshotInterval
if snapshotInterval == edgeIntervalUseDefault {
snapshotInterval = settings.Edge.SnapshotInterval
}
if shortestAsyncInterval > snapshotInterval {
shortestAsyncInterval = snapshotInterval
}
commandInterval := endpoint.Edge.CommandInterval
if commandInterval == edgeIntervalUseDefault {
commandInterval = settings.Edge.CommandInterval
}
if shortestAsyncInterval > commandInterval {
shortestAsyncInterval = commandInterval
}
return shortestAsyncInterval
}
-98
View File
@@ -1,7 +1,6 @@
package endpoints
import (
"strconv"
"testing"
portainer "github.com/portainer/portainer/api"
@@ -149,103 +148,6 @@ func Test_Filter_excludeIDs(t *testing.T) {
runTests(tests, t, handler, environments)
}
func BenchmarkFilterEndpointsBySearchCriteria_PartialMatch(b *testing.B) {
n := 10000
endpointIDs := []portainer.EndpointID{}
endpoints := []portainer.Endpoint{}
for i := 0; i < n; i++ {
endpoints = append(endpoints, portainer.Endpoint{
ID: portainer.EndpointID(i + 1),
Name: "endpoint-" + strconv.Itoa(i+1),
GroupID: 1,
TagIDs: []portainer.TagID{1},
Type: portainer.EdgeAgentOnDockerEnvironment,
})
endpointIDs = append(endpointIDs, portainer.EndpointID(i+1))
}
endpointGroups := []portainer.EndpointGroup{}
edgeGroups := []portainer.EdgeGroup{}
for i := 0; i < 1000; i++ {
edgeGroups = append(edgeGroups, portainer.EdgeGroup{
ID: portainer.EdgeGroupID(i + 1),
Name: "edge-group-" + strconv.Itoa(i+1),
Endpoints: append([]portainer.EndpointID{}, endpointIDs...),
Dynamic: true,
TagIDs: []portainer.TagID{1, 2, 3},
PartialMatch: true,
})
}
tagsMap := map[portainer.TagID]string{}
for i := 0; i < 10; i++ {
tagsMap[portainer.TagID(i+1)] = "tag-" + strconv.Itoa(i+1)
}
searchString := "edge-group"
b.ResetTimer()
for i := 0; i < b.N; i++ {
e := filterEndpointsBySearchCriteria(endpoints, endpointGroups, edgeGroups, tagsMap, searchString)
if len(e) != n {
b.FailNow()
}
}
}
func BenchmarkFilterEndpointsBySearchCriteria_FullMatch(b *testing.B) {
n := 10000
endpointIDs := []portainer.EndpointID{}
endpoints := []portainer.Endpoint{}
for i := 0; i < n; i++ {
endpoints = append(endpoints, portainer.Endpoint{
ID: portainer.EndpointID(i + 1),
Name: "endpoint-" + strconv.Itoa(i+1),
GroupID: 1,
TagIDs: []portainer.TagID{1, 2, 3},
Type: portainer.EdgeAgentOnDockerEnvironment,
})
endpointIDs = append(endpointIDs, portainer.EndpointID(i+1))
}
endpointGroups := []portainer.EndpointGroup{}
edgeGroups := []portainer.EdgeGroup{}
for i := 0; i < 1000; i++ {
edgeGroups = append(edgeGroups, portainer.EdgeGroup{
ID: portainer.EdgeGroupID(i + 1),
Name: "edge-group-" + strconv.Itoa(i+1),
Endpoints: append([]portainer.EndpointID{}, endpointIDs...),
Dynamic: true,
TagIDs: []portainer.TagID{1},
})
}
tagsMap := map[portainer.TagID]string{}
for i := 0; i < 10; i++ {
tagsMap[portainer.TagID(i+1)] = "tag-" + strconv.Itoa(i+1)
}
searchString := "edge-group"
b.ResetTimer()
for i := 0; i < b.N; i++ {
e := filterEndpointsBySearchCriteria(endpoints, endpointGroups, edgeGroups, tagsMap, searchString)
if len(e) != n {
b.FailNow()
}
}
}
func runTests(tests []filterTest, t *testing.T, handler *Handler, endpoints []portainer.Endpoint) {
for _, test := range tests {
t.Run(test.title, func(t *testing.T) {
-2
View File
@@ -71,8 +71,6 @@ func NewHandler(bouncer security.BouncerService, demoService *demo.Service) *Han
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointUpdate))).Methods(http.MethodPut)
h.Handle("/endpoints/{id}",
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDelete))).Methods(http.MethodDelete)
h.Handle("/endpoints",
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDeleteBatch))).Methods(http.MethodDelete)
h.Handle("/endpoints/{id}/dockerhub/{registryId}",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointDockerhubStatus))).Methods(http.MethodGet)
h.Handle("/endpoints/{id}/snapshot",
+4 -7
View File
@@ -4,9 +4,6 @@ import (
"net/http"
"strings"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/pkg/featureflags"
"github.com/gorilla/handlers"
)
@@ -19,10 +16,8 @@ type Handler struct {
// NewHandler creates a handler to serve static files.
func NewHandler(assetPublicPath string, wasInstanceDisabled func() bool) *Handler {
h := &Handler{
Handler: security.MWSecureHeaders(
handlers.CompressHandler(http.FileServer(http.Dir(assetPublicPath))),
featureflags.IsEnabled("hsts"),
featureflags.IsEnabled("csp"),
Handler: handlers.CompressHandler(
http.FileServer(http.Dir(assetPublicPath)),
),
wasInstanceDisabled: wasInstanceDisabled,
}
@@ -58,5 +53,7 @@ func (handler *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
}
w.Header().Add("X-XSS-Protection", "1; mode=block")
w.Header().Add("X-Content-Type-Options", "nosniff")
handler.Handler.ServeHTTP(w, r)
}
+2 -2
View File
@@ -85,7 +85,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.21.4
// @version 2.20.3
// @description.markdown api-description.md
// @termsOfService
@@ -199,7 +199,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
case strings.HasPrefix(r.URL.Path, "/api/kubernetes"):
http.StripPrefix("/api", h.KubernetesHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/docker"):
http.StripPrefix("/api", h.DockerHandler).ServeHTTP(w, r)
http.StripPrefix("/api/docker", h.DockerHandler).ServeHTTP(w, r)
// Helm subpath under kubernetes -> /api/endpoints/{id}/kubernetes/helm
case strings.HasPrefix(r.URL.Path, "/api/endpoints/") && strings.Contains(r.URL.Path, "/kubernetes/helm"):
+8 -71
View File
@@ -7,16 +7,12 @@ import (
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/proxy"
"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"
"github.com/portainer/portainer/api/pendingactions"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/gorilla/mux"
"github.com/pkg/errors"
)
func hideFields(registry *portainer.Registry, hideAccesses bool) {
@@ -87,88 +83,29 @@ func (handler *Handler) registriesHaveSameURLAndCredentials(r1, r2 *portainer.Re
return hasSameUrl && hasSameCredentials && r1.Gitlab.ProjectPath == r2.Gitlab.ProjectPath
}
// this function validates that
//
// 1. user has the appropriate authorizations to perform the request
//
// 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) (hasAccess bool, isAdmin bool, err error) {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
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
if securityContext.IsAdmin {
return true, true, nil
}
// mandatory query param that should become a path param
endpointIdStr, err := request.RetrieveNumericQueryParameter(r, "endpointId", false)
endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false)
if err != nil {
return false, false, err
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if err != nil {
return false, false, err
}
endpointId := portainer.EndpointID(endpointIdStr)
endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointId)
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
if err != nil {
return false, false, err
}
// validate that the request is allowed for the user (READ/WRITE authorization on request path)
if err := handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint); errors.Is(err, security.ErrAuthorizationRequired) {
return false, false, nil
} else if err != nil {
return false, false, err
}
memberships, err := handler.DataStore.TeamMembership().TeamMembershipsByUserID(user.ID)
if err != nil {
return false, false, nil
}
// validate access for kubernetes namespaces (leverage registry.RegistryAccesses[endpointId].Namespaces)
if endpointutils.IsKubernetesEndpoint(endpoint) {
kcl, err := handler.K8sClientFactory.GetKubeClient(endpoint)
if err != nil {
return false, false, errors.Wrap(err, "unable to retrieve kubernetes client to validate registry access")
}
accessPolicies, err := kcl.GetNamespaceAccessPolicies()
if err != nil {
return false, false, errors.Wrap(err, "unable to retrieve environment's namespaces policies to validate registry access")
}
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, false, nil
}
namespacePolicy := accessPolicies[namespace]
if security.AuthorizedAccess(user.ID, memberships, namespacePolicy.UserAccessPolicies, namespacePolicy.TeamAccessPolicies) {
return true, false, nil
}
}
return false, false, nil
}
// validate access for docker environments
// leverage registry.RegistryAccesses[endpointId].UserAccessPolicies (direct access)
// and registry.RegistryAccesses[endpointId].TeamAccessPolicies (indirect access via his teams)
if security.AuthorizedRegistryAccess(registry, user, memberships, endpoint.ID) {
return true, false, nil
}
// when user has no access via their role, direct grant or indirect grant
// then they don't have access to the registry
return false, false, nil
return true, false, nil
}
@@ -89,7 +89,6 @@ func (payload *registryCreatePayload) Validate(_ *http.Request) error {
// @param body body registryCreatePayload true "Registry details"
// @success 200 {object} portainer.Registry "Success"
// @failure 400 "Invalid request"
// @failure 409 "Another registry with the same name or same URL & credentials already exists"
// @failure 500 "Server error"
// @router /registries [post]
func (handler *Handler) registryCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
@@ -26,6 +26,14 @@ import (
// @failure 500 "Server error"
// @router /registries/{id} [get]
func (handler *Handler) registryInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
hasAccess, isAdmin, err := handler.userHasRegistryAccess(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
if !hasAccess {
return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied)
}
registryID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return httperror.BadRequest("Invalid registry identifier route variable", err)
@@ -38,14 +46,6 @@ 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)
}
hasAccess, isAdmin, err := handler.userHasRegistryAccess(r, registry)
if err != nil {
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)
return response.JSON(w, registry)
}
@@ -52,7 +52,7 @@ func (payload *registryUpdatePayload) Validate(r *http.Request) error {
// @success 200 {object} portainer.Registry "Success"
// @failure 400 "Invalid request"
// @failure 404 "Registry not found"
// @failure 409 "Another registry with the same name or same URL & credentials already exists"
// @failure 409 "Another registry with the same URL already exists"
// @failure 500 "Server error"
// @router /registries/{id} [put]
func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
@@ -63,7 +63,7 @@ func (payload *resourceControlCreatePayload) Validate(r *http.Request) error {
// @param body body resourceControlCreatePayload true "Resource control details"
// @success 200 {object} portainer.ResourceControl "Success"
// @failure 400 "Invalid request"
// @failure 409 "A resource control is already associated to this resource"
// @failure 409 "Resource control already exists"
// @failure 500 "Server error"
// @router /resource_controls [post]
func (handler *Handler) resourceControlCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
@@ -229,7 +229,6 @@ func (payload *composeStackFromGitRepositoryPayload) Validate(r *http.Request) e
// @param body body composeStackFromGitRepositoryPayload true "stack config"
// @success 200 {object} portainer.Stack
// @failure 400 "Invalid request"
// @failure 409 "Stack name or webhook ID already exists"
// @failure 500 "Server error"
// @router /stacks/create/standalone/repository [post]
func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError {
@@ -195,7 +195,6 @@ func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWrit
// @param endpointId query int true "Identifier of the environment that will be used to deploy the stack"
// @success 200 {object} portainer.Stack
// @failure 400 "Invalid request"
// @failure 409 "Stack name or webhook ID already exists"
// @failure 500 "Server error"
// @router /stacks/create/kubernetes/repository [post]
func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError {
@@ -188,7 +188,6 @@ func createStackPayloadFromSwarmGitPayload(name, swarmID, repoUrl, repoReference
// @param body body swarmStackFromGitRepositoryPayload true "stack config"
// @success 200 {object} portainer.Stack
// @failure 400 "Invalid request"
// @failure 409 "Stack name or webhook ID already exists"
// @failure 500 "Server error"
// @router /stacks/create/swarm/repository [post]
func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError {
+1 -2
View File
@@ -1,7 +1,6 @@
package stacks
import (
"errors"
"fmt"
"net/http"
"time"
@@ -96,7 +95,7 @@ func (handler *Handler) stackAssociate(w http.ResponseWriter, r *http.Request) *
}
if !canManage {
errMsg := "Stack management is disabled for non-admin users"
return httperror.Forbidden(errMsg, errors.New(errMsg))
return httperror.Forbidden(errMsg, fmt.Errorf(errMsg))
}
stack.EndpointID = portainer.EndpointID(endpointID)
+2 -2
View File
@@ -111,7 +111,7 @@ func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *htt
}
if !canManage {
errMsg := "stack deletion is disabled for non-admin users"
return httperror.Forbidden(errMsg, errors.New(errMsg))
return httperror.Forbidden(errMsg, fmt.Errorf(errMsg))
}
// stop scheduler updates of the stack before removal
@@ -338,7 +338,7 @@ func (handler *Handler) stackDeleteKubernetesByName(w http.ResponseWriter, r *ht
}
if !canManage {
errMsg := "stack deletion is disabled for non-admin users"
return httperror.Forbidden(errMsg, errors.New(errMsg))
return httperror.Forbidden(errMsg, fmt.Errorf(errMsg))
}
stacksToDelete = append(stacksToDelete, stack)
-1
View File
@@ -46,7 +46,6 @@ func (payload *stackMigratePayload) Validate(r *http.Request) error {
// @failure 400 "Invalid request"
// @failure 403 "Permission denied"
// @failure 404 "Stack not found"
// @failure 409 "A stack with the same name is already running on the target environment(endpoint)"
// @failure 500 "Server error"
// @router /stacks/{id}/migrate [post]
func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
-1
View File
@@ -29,7 +29,6 @@ import (
// @failure 400 "Invalid request"
// @failure 403 "Permission denied"
// @failure 404 "Not found"
// @failure 409 "Stack name is not unique"
// @failure 500 "Server error"
// @router /stacks/{id}/start [post]
func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
+1 -1
View File
@@ -19,7 +19,7 @@ import (
// @param webhookID path string true "Stack identifier"
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 409 "Autoupdate for the stack isn't available"
// @failure 409 "Conflict"
// @failure 500 "Server error"
// @router /stacks/webhooks/{webhookID} [post]
func (handler *Handler) webhookInvoke(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
+1 -1
View File
@@ -35,7 +35,7 @@ func (payload *tagCreatePayload) Validate(r *http.Request) error {
// @produce json
// @param body body tagCreatePayload true "Tag details"
// @success 200 {object} portainer.Tag "Success"
// @failure 409 "This name is already associated to a tag"
// @failure 409 "Tag name exists"
// @failure 500 "Server error"
// @router /tags [post]
func (handler *Handler) tagCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
+17 -34
View File
@@ -5,7 +5,6 @@ import (
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -24,7 +23,6 @@ func (payload *teamCreatePayload) Validate(r *http.Request) error {
if govalidator.IsNull(payload.Name) {
return errors.New("Invalid team name")
}
return nil
}
@@ -40,47 +38,31 @@ func (payload *teamCreatePayload) Validate(r *http.Request) error {
// @param body body teamCreatePayload true "details"
// @success 200 {object} portainer.Team "Success"
// @failure 400 "Invalid request"
// @failure 409 "A team with the same name already exists"
// @failure 409 "Team already exists"
// @failure 500 "Server error"
// @router /teams [post]
func (handler *Handler) teamCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload teamCreatePayload
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
var team *portainer.Team
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
var err error
team, err = createTeam(tx, payload)
return err
}); err != nil {
var httpErr *httperror.HandlerError
if errors.As(err, &httpErr) {
return httpErr
}
return httperror.InternalServerError("Unexpected error", err)
}
return response.JSON(w, team)
}
func createTeam(tx dataservices.DataStoreTx, payload teamCreatePayload) (*portainer.Team, error) {
team, err := tx.Team().TeamByName(payload.Name)
if err != nil && !tx.IsErrObjectNotFound(err) {
return nil, httperror.InternalServerError("Unable to retrieve teams from the database", err)
team, err := handler.DataStore.Team().TeamByName(payload.Name)
if err != nil && !handler.DataStore.IsErrObjectNotFound(err) {
return httperror.InternalServerError("Unable to retrieve teams from the database", err)
}
if team != nil {
return nil, httperror.Conflict("A team with the same name already exists", errors.New("Team already exists"))
return httperror.Conflict("A team with the same name already exists", errors.New("Team already exists"))
}
team = &portainer.Team{Name: payload.Name}
team = &portainer.Team{
Name: payload.Name,
}
if err := tx.Team().Create(team); err != nil {
return nil, httperror.InternalServerError("Unable to persist the team inside the database", err)
err = handler.DataStore.Team().Create(team)
if err != nil {
return httperror.InternalServerError("Unable to persist the team inside the database", err)
}
for _, teamLeader := range payload.TeamLeaders {
@@ -90,10 +72,11 @@ func createTeam(tx dataservices.DataStoreTx, payload teamCreatePayload) (*portai
Role: portainer.TeamLeader,
}
if err := tx.TeamMembership().Create(membership); err != nil {
return nil, httperror.InternalServerError("Unable to persist team leadership inside the database", err)
err = handler.DataStore.TeamMembership().Create(membership)
if err != nil {
return httperror.InternalServerError("Unable to persist team leadership inside the database", err)
}
}
return team, nil
return response.JSON(w, team)
}
@@ -1,65 +0,0 @@
package teams
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/portainer/portainer/api/datastore"
"github.com/stretchr/testify/require"
"golang.org/x/sync/errgroup"
)
func TestConcurrentTeamCreation(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, false)
h := &Handler{
DataStore: store,
}
tcp := teamCreatePayload{
Name: "portainer",
}
m, err := json.Marshal(tcp)
require.NoError(t, err)
errGroup := &errgroup.Group{}
n := 100
for i := 0; i < n; i++ {
errGroup.Go(func() error {
req, err := http.NewRequest(http.MethodPost, "/teams", bytes.NewReader(m))
if err != nil {
return err
}
if err := h.teamCreate(httptest.NewRecorder(), req); err != nil {
return err
}
return nil
})
}
err = errGroup.Wait()
require.Error(t, err)
teams, err := store.Team().ReadAll()
require.NotEmpty(t, teams)
require.NoError(t, err)
teamCreated := false
for _, team := range teams {
if team.Name == tcp.Name {
require.False(t, teamCreated)
teamCreated = true
}
}
require.True(t, teamCreated)
}
+15 -37
View File
@@ -5,7 +5,6 @@ import (
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -55,33 +54,12 @@ func (handler *Handler) userCreate(w http.ResponseWriter, r *http.Request) *http
return httperror.BadRequest("Invalid request payload", err)
}
var user *portainer.User
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
var err error
user, err = handler.createUser(tx, payload)
return err
}); err != nil {
var httpErr *httperror.HandlerError
if errors.As(err, &httpErr) {
return httpErr
}
return httperror.InternalServerError("Unexpected error", err)
user, err := handler.DataStore.User().UserByUsername(payload.Username)
if err != nil && !handler.DataStore.IsErrObjectNotFound(err) {
return httperror.InternalServerError("Unable to retrieve users from the database", err)
}
return response.JSON(w, user)
}
func (handler *Handler) createUser(tx dataservices.DataStoreTx, payload userCreatePayload) (*portainer.User, error) {
user, err := tx.User().UserByUsername(payload.Username)
if err != nil && !tx.IsErrObjectNotFound(err) {
return nil, httperror.InternalServerError("Unable to retrieve users from the database", err)
}
if user != nil {
return nil, httperror.Conflict("Another user with the same username already exists", errUserAlreadyExists)
return httperror.Conflict("Another user with the same username already exists", errUserAlreadyExists)
}
user = &portainer.User{
@@ -89,33 +67,33 @@ func (handler *Handler) createUser(tx dataservices.DataStoreTx, payload userCrea
Role: portainer.UserRole(payload.Role),
}
settings, err := tx.Settings().Settings()
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return nil, httperror.InternalServerError("Unable to retrieve settings from the database", err)
return httperror.InternalServerError("Unable to retrieve settings from the database", err)
}
// When LDAP/OAuth is on, can only add users without password
// when ldap/oauth is on, can only add users without password
if (settings.AuthenticationMethod == portainer.AuthenticationLDAP || settings.AuthenticationMethod == portainer.AuthenticationOAuth) && payload.Password != "" {
errMsg := "a user with password can not be created when authentication method is Oauth or LDAP"
return nil, httperror.BadRequest(errMsg, errors.New(errMsg))
errMsg := "A user with password can not be created when authentication method is Oauth or LDAP"
return httperror.BadRequest(errMsg, errors.New(errMsg))
}
if settings.AuthenticationMethod == portainer.AuthenticationInternal {
if !handler.passwordStrengthChecker.Check(payload.Password) {
return nil, httperror.BadRequest("Password does not meet the requirements", nil)
return httperror.BadRequest("Password does not meet the requirements", nil)
}
user.Password, err = handler.CryptoService.Hash(payload.Password)
if err != nil {
return nil, httperror.InternalServerError("Unable to hash user password", errCryptoHashFailure)
return httperror.InternalServerError("Unable to hash user password", errCryptoHashFailure)
}
}
if err := tx.User().Create(user); err != nil {
return nil, httperror.InternalServerError("Unable to persist user inside the database", err)
err = handler.DataStore.User().Create(user)
if err != nil {
return httperror.InternalServerError("Unable to persist user inside the database", err)
}
hideFields(user)
return user, nil
return response.JSON(w, user)
}
@@ -118,9 +118,9 @@ func (handler *Handler) userCreateAccessToken(w http.ResponseWriter, r *http.Req
return response.JSONWithStatus(w, accessTokenResponse{rawAPIKey, *apiKey}, http.StatusCreated)
}
func (handler *Handler) usesInternalAuthentication(userID portainer.UserID) (bool, error) {
// userID 1 is the admin user and always uses internal auth
if userID == 1 {
func (handler *Handler) usesInternalAuthentication(userid portainer.UserID) (bool, error) {
// userid 1 is the admin user and always uses internal auth
if userid == 1 {
return true, nil
}
@@ -1,77 +0,0 @@
package users
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/crypto"
"github.com/portainer/portainer/api/datastore"
"github.com/stretchr/testify/require"
"golang.org/x/sync/errgroup"
)
type mockPasswordStrengthChecker struct{}
func (m *mockPasswordStrengthChecker) Check(string) bool {
return true
}
func TestConcurrentUserCreation(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, false)
h := &Handler{
passwordStrengthChecker: &mockPasswordStrengthChecker{},
CryptoService: &crypto.Service{},
DataStore: store,
}
ucp := userCreatePayload{
Username: "portainer",
Password: "password",
Role: int(portainer.AdministratorRole),
}
m, err := json.Marshal(ucp)
require.NoError(t, err)
errGroup := &errgroup.Group{}
n := 100
for i := 0; i < n; i++ {
errGroup.Go(func() error {
req, err := http.NewRequest(http.MethodPost, "/users", bytes.NewReader(m))
if err != nil {
return err
}
if err := h.userCreate(httptest.NewRecorder(), req); err != nil {
return err
}
return nil
})
}
err = errGroup.Wait()
require.Error(t, err)
users, err := store.User().ReadAll()
require.NotEmpty(t, users)
require.NoError(t, err)
userCreated := false
for _, u := range users {
if u.Username == ucp.Username {
require.False(t, userCreated)
userCreated = true
}
}
require.True(t, userCreated)
}
+3 -3
View File
@@ -45,9 +45,9 @@ func (payload *webhookCreatePayload) Validate(r *http.Request) error {
// @produce json
// @param body body webhookCreatePayload true "Webhook data"
// @success 200 {object} portainer.Webhook
// @failure 400 "Invalid request"
// @failure 409 "A webhook for this resource already exists"
// @failure 500 "Server error"
// @failure 400
// @failure 409
// @failure 500
// @router /webhooks [post]
func (handler *Handler) webhookCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload webhookCreatePayload
@@ -122,7 +122,6 @@ func (handler *Handler) executeServiceWebhook(
_ = rc.Close()
}(rc)
}
_, err = dockerClient.ServiceUpdate(context.Background(), resourceID, service.Version, service.Spec, serviceUpdateOptions)
if err != nil {
+3 -3
View File
@@ -18,12 +18,12 @@ import (
)
func (handler *Handler) proxyEdgeAgentWebsocketRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error {
tunnelAddr, err := handler.ReverseTunnelService.TunnelAddr(params.endpoint)
tunnel, err := handler.ReverseTunnelService.GetActiveTunnel(params.endpoint)
if err != nil {
return err
}
agentURL, err := url.Parse("http://" + tunnelAddr)
agentURL, err := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", tunnel.Port))
if err != nil {
return err
}
@@ -93,7 +93,7 @@ func (handler *Handler) doProxyWebsocketRequest(
}
if isEdge {
handler.ReverseTunnelService.UpdateLastActivity(params.endpoint.ID)
handler.ReverseTunnelService.SetTunnelStatusToActive(params.endpoint.ID)
handler.ReverseTunnelService.KeepTunnelAlive(params.endpoint.ID, r.Context(), portainer.WebSocketKeepAlive)
}
-20
View File
@@ -7,7 +7,6 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
requesthelpers "github.com/portainer/portainer/pkg/libhttp/request"
@@ -64,22 +63,3 @@ func FetchEndpoint(request *http.Request) (*portainer.Endpoint, error) {
return contextData.(*portainer.Endpoint), nil
}
func CheckEndpointAuthorization(bouncer security.BouncerService) mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
endpoint, err := FetchEndpoint(r)
if err != nil {
httperror.WriteError(w, http.StatusNotFound, "Unable to find an environment on request context", err)
return
}
if err = bouncer.AuthorizedEndpointOperation(r, endpoint); err != nil {
httperror.WriteError(w, http.StatusForbidden, "Permission denied to access environment", err)
return
}
next.ServeHTTP(w, r)
})
}
}
+2 -2
View File
@@ -26,12 +26,12 @@ func (factory *ProxyFactory) NewAgentProxy(endpoint *portainer.Endpoint) (*Proxy
urlString := endpoint.URL
if endpointutils.IsEdgeEndpoint(endpoint) {
tunnelAddr, err := factory.reverseTunnelService.TunnelAddr(endpoint)
tunnel, err := factory.reverseTunnelService.GetActiveTunnel(endpoint)
if err != nil {
return nil, errors.Wrap(err, "failed starting tunnel")
}
urlString = "http://" + tunnelAddr
urlString = fmt.Sprintf("http://127.0.0.1:%d", tunnel.Port)
}
endpointURL, err := url.ParseURL(urlString)
+3 -5
View File
@@ -1,6 +1,7 @@
package factory
import (
"fmt"
"io"
"net/http"
"strings"
@@ -34,11 +35,8 @@ func (factory *ProxyFactory) newDockerLocalProxy(endpoint *portainer.Endpoint) (
func (factory *ProxyFactory) newDockerHTTPProxy(endpoint *portainer.Endpoint) (http.Handler, error) {
rawURL := endpoint.URL
if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment {
tunnelAddr, err := factory.reverseTunnelService.TunnelAddr(endpoint)
if err != nil {
return nil, err
}
rawURL = "http://" + tunnelAddr
tunnel := factory.reverseTunnelService.GetTunnelDetails(endpoint.ID)
rawURL = fmt.Sprintf("http://127.0.0.1:%d", tunnel.Port)
}
endpointURL, err := url.ParseURL(rawURL)
-5
View File
@@ -20,16 +20,11 @@ type postDockerfileRequest struct {
}
// buildOperation inspects the "Content-Type" header to determine if it needs to alter the request.
//
// If the value of the header is empty, it means that a Dockerfile is posted via upload, the function
// will extract the file content from the request body, tar it, and rewrite the body.
// !! THIS IS ONLY TRUE WHEN THE UPLOADED DOCKERFILE FILE HAS NO EXTENSION (the generated file.type in the frontend will be empty)
// If the Dockerfile is named like Dockerfile.yaml or has an internal type, a non-empty Content-Type header will be generated
//
// If the value of the header contains "application/json", it means that the content of a Dockerfile is posted
// in the request payload as JSON, the function will create a new file called Dockerfile inside a tar archive and
// rewrite the body of the request.
//
// In any other case, it will leave the request unaltered.
func buildOperation(request *http.Request) error {
contentTypeHeader := request.Header.Get("Content-Type")
+55 -70
View File
@@ -84,28 +84,11 @@ func (transport *Transport) RoundTrip(request *http.Request) (*http.Response, er
return transport.ProxyDockerRequest(request)
}
var prefixProxyFuncMap = map[string]func(*Transport, *http.Request, string) (*http.Response, error){
"configs": (*Transport).proxyConfigRequest,
"containers": (*Transport).proxyContainerRequest,
"services": (*Transport).proxyServiceRequest,
"volumes": (*Transport).proxyVolumeRequest,
"networks": (*Transport).proxyNetworkRequest,
"secrets": (*Transport).proxySecretRequest,
"swarm": (*Transport).proxySwarmRequest,
"nodes": (*Transport).proxyNodeRequest,
"tasks": (*Transport).proxyTaskRequest,
"build": (*Transport).proxyBuildRequest,
"images": (*Transport).proxyImageRequest,
"v2": (*Transport).proxyAgentRequest,
}
// ProxyDockerRequest intercepts a Docker API request and apply logic based
// on the requested operation.
func (transport *Transport) ProxyDockerRequest(request *http.Request) (*http.Response, error) {
// from : /v1.41/containers/{id}/json
// or : /containers/{id}/json
// to : /containers/{id}/json
unversionedPath := apiVersionRe.ReplaceAllString(request.URL.Path, "")
requestPath := apiVersionRe.ReplaceAllString(request.URL.Path, "")
request.URL.Path = requestPath
if transport.endpoint.Type == portainer.AgentOnDockerEnvironment || transport.endpoint.Type == portainer.EdgeAgentOnDockerEnvironment {
signature, err := transport.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
@@ -117,16 +100,34 @@ func (transport *Transport) ProxyDockerRequest(request *http.Request) (*http.Res
request.Header.Set(portainer.PortainerAgentSignatureHeader, signature)
}
// from : /containers/{id}/json
// trim to : containers/{id}/json
// pick : [ containers, {id}, json ][0]
// prefix : containers
prefix := strings.Split(strings.TrimPrefix(unversionedPath, "/"), "/")[0]
if proxyFunc := prefixProxyFuncMap[prefix]; proxyFunc != nil {
return proxyFunc(transport, request, unversionedPath)
switch {
case strings.HasPrefix(requestPath, "/configs"):
return transport.proxyConfigRequest(request)
case strings.HasPrefix(requestPath, "/containers"):
return transport.proxyContainerRequest(request)
case strings.HasPrefix(requestPath, "/services"):
return transport.proxyServiceRequest(request)
case strings.HasPrefix(requestPath, "/volumes"):
return transport.proxyVolumeRequest(request)
case strings.HasPrefix(requestPath, "/networks"):
return transport.proxyNetworkRequest(request)
case strings.HasPrefix(requestPath, "/secrets"):
return transport.proxySecretRequest(request)
case strings.HasPrefix(requestPath, "/swarm"):
return transport.proxySwarmRequest(request)
case strings.HasPrefix(requestPath, "/nodes"):
return transport.proxyNodeRequest(request)
case strings.HasPrefix(requestPath, "/tasks"):
return transport.proxyTaskRequest(request)
case strings.HasPrefix(requestPath, "/build"):
return transport.proxyBuildRequest(request)
case strings.HasPrefix(requestPath, "/images"):
return transport.proxyImageRequest(request)
case strings.HasPrefix(requestPath, "/v2"):
return transport.proxyAgentRequest(request)
default:
return transport.executeDockerRequest(request)
}
return transport.executeDockerRequest(request)
}
func (transport *Transport) executeDockerRequest(request *http.Request) (*http.Response, error) {
@@ -137,14 +138,16 @@ func (transport *Transport) executeDockerRequest(request *http.Request) (*http.R
}
if err == nil {
transport.reverseTunnelService.UpdateLastActivity(transport.endpoint.ID)
transport.reverseTunnelService.SetTunnelStatusToActive(transport.endpoint.ID)
} else {
transport.reverseTunnelService.SetTunnelStatusToIdle(transport.endpoint.ID)
}
return response, err
}
func (transport *Transport) proxyAgentRequest(r *http.Request, unversionedPath string) (*http.Response, error) {
requestPath := strings.TrimPrefix(unversionedPath, "/v2")
func (transport *Transport) proxyAgentRequest(r *http.Request) (*http.Response, error) {
requestPath := strings.TrimPrefix(r.URL.Path, "/v2")
switch {
case strings.HasPrefix(requestPath, "/browse"):
@@ -202,10 +205,8 @@ func (transport *Transport) proxyAgentRequest(r *http.Request, unversionedPath s
return transport.executeDockerRequest(r)
}
func (transport *Transport) proxyConfigRequest(request *http.Request, unversionedPath string) (*http.Response, error) {
requestPath := unversionedPath
switch requestPath {
func (transport *Transport) proxyConfigRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath {
case "/configs/create":
return transport.decorateGenericResourceCreationOperation(request, configObjectIdentifier, portainer.ConfigResourceControl)
@@ -226,10 +227,8 @@ func (transport *Transport) proxyConfigRequest(request *http.Request, unversione
}
}
func (transport *Transport) proxyContainerRequest(request *http.Request, unversionedPath string) (*http.Response, error) {
requestPath := unversionedPath
switch requestPath {
func (transport *Transport) proxyContainerRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath {
case "/containers/create":
return transport.decorateContainerCreationOperation(request, containerObjectIdentifier, portainer.ContainerResourceControl)
@@ -264,10 +263,8 @@ func (transport *Transport) proxyContainerRequest(request *http.Request, unversi
}
}
func (transport *Transport) proxyServiceRequest(request *http.Request, unversionedPath string) (*http.Response, error) {
requestPath := unversionedPath
switch requestPath {
func (transport *Transport) proxyServiceRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath {
case "/services/create":
return transport.decorateServiceCreationOperation(request)
@@ -297,10 +294,8 @@ func (transport *Transport) proxyServiceRequest(request *http.Request, unversion
}
}
func (transport *Transport) proxyVolumeRequest(request *http.Request, unversionedPath string) (*http.Response, error) {
requestPath := unversionedPath
switch requestPath {
func (transport *Transport) proxyVolumeRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath {
case "/volumes/create":
return transport.decorateVolumeResourceCreationOperation(request, portainer.VolumeResourceControl)
@@ -316,10 +311,8 @@ func (transport *Transport) proxyVolumeRequest(request *http.Request, unversione
}
}
func (transport *Transport) proxyNetworkRequest(request *http.Request, unversionedPath string) (*http.Response, error) {
requestPath := unversionedPath
switch requestPath {
func (transport *Transport) proxyNetworkRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath {
case "/networks/create":
return transport.decorateGenericResourceCreationOperation(request, networkObjectIdentifier, portainer.NetworkResourceControl)
@@ -339,10 +332,8 @@ func (transport *Transport) proxyNetworkRequest(request *http.Request, unversion
}
}
func (transport *Transport) proxySecretRequest(request *http.Request, unversionedPath string) (*http.Response, error) {
requestPath := unversionedPath
switch requestPath {
func (transport *Transport) proxySecretRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath {
case "/secrets/create":
return transport.decorateGenericResourceCreationOperation(request, secretObjectIdentifier, portainer.SecretResourceControl)
@@ -362,8 +353,8 @@ func (transport *Transport) proxySecretRequest(request *http.Request, unversione
}
}
func (transport *Transport) proxyNodeRequest(request *http.Request, unversionedPath string) (*http.Response, error) {
requestPath := unversionedPath
func (transport *Transport) proxyNodeRequest(request *http.Request) (*http.Response, error) {
requestPath := request.URL.Path
// assume /nodes/{id}
if path.Base(requestPath) != "nodes" {
@@ -373,10 +364,8 @@ func (transport *Transport) proxyNodeRequest(request *http.Request, unversionedP
return transport.executeDockerRequest(request)
}
func (transport *Transport) proxySwarmRequest(request *http.Request, unversionedPath string) (*http.Response, error) {
requestPath := unversionedPath
switch requestPath {
func (transport *Transport) proxySwarmRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath {
case "/swarm":
return transport.rewriteOperation(request, swarmInspectOperation)
default:
@@ -385,10 +374,8 @@ func (transport *Transport) proxySwarmRequest(request *http.Request, unversioned
}
}
func (transport *Transport) proxyTaskRequest(request *http.Request, unversionedPath string) (*http.Response, error) {
requestPath := unversionedPath
switch requestPath {
func (transport *Transport) proxyTaskRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath {
case "/tasks":
return transport.rewriteOperation(request, transport.taskListOperation)
default:
@@ -397,7 +384,7 @@ func (transport *Transport) proxyTaskRequest(request *http.Request, unversionedP
}
}
func (transport *Transport) proxyBuildRequest(request *http.Request, _ string) (*http.Response, error) {
func (transport *Transport) proxyBuildRequest(request *http.Request) (*http.Response, error) {
err := transport.updateDefaultGitBranch(request)
if err != nil {
return nil, err
@@ -423,10 +410,8 @@ func (transport *Transport) updateDefaultGitBranch(request *http.Request) error
return nil
}
func (transport *Transport) proxyImageRequest(request *http.Request, unversionedPath string) (*http.Response, error) {
requestPath := unversionedPath
switch requestPath {
func (transport *Transport) proxyImageRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath {
case "/images/create":
return transport.replaceRegistryAuthenticationHeader(request)
default:
+2 -5
View File
@@ -51,11 +51,8 @@ func (factory *ProxyFactory) newKubernetesLocalProxy(endpoint *portainer.Endpoin
}
func (factory *ProxyFactory) newKubernetesEdgeHTTPProxy(endpoint *portainer.Endpoint) (http.Handler, error) {
tunnelAddr, err := factory.reverseTunnelService.TunnelAddr(endpoint)
if err != nil {
return nil, err
}
rawURL := "http://" + tunnelAddr
tunnel := factory.reverseTunnelService.GetTunnelDetails(endpoint.ID)
rawURL := fmt.Sprintf("http://127.0.0.1:%d", tunnel.Port)
endpointURL, err := url.Parse(rawURL)
if err != nil {
@@ -59,7 +59,9 @@ func (transport *edgeTransport) RoundTrip(request *http.Request) (*http.Response
response, err := transport.baseTransport.RoundTrip(request)
if err == nil {
transport.reverseTunnelService.UpdateLastActivity(transport.endpoint.ID)
transport.reverseTunnelService.SetTunnelStatusToActive(transport.endpoint.ID)
} else {
transport.reverseTunnelService.SetTunnelStatusToIdle(transport.endpoint.ID)
}
return response, err
+13 -76
View File
@@ -4,23 +4,18 @@ import (
"fmt"
"net/http"
"strings"
"sync"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/apikey"
"github.com/portainer/portainer/api/dataservices"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/pkg/featureflags"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/rs/zerolog/log"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)
const apiKeyHeader = "X-API-KEY"
const jwtTokenHeader = "Authorization"
type (
BouncerService interface {
PublicAccess(http.Handler) http.Handler
@@ -35,7 +30,6 @@ type (
TrustedEdgeEnvironmentAccess(dataservices.DataStoreTx, *portainer.Endpoint) error
CookieAuthLookup(*http.Request) (*portainer.TokenData, error)
JWTAuthLookup(*http.Request) (*portainer.TokenData, error)
RevokeJWT(string)
}
// RequestBouncer represents an entity that manages API request accesses
@@ -43,9 +37,6 @@ type (
dataStore dataservices.DataStore
jwtService portainer.JWTService
apiKeyService apikey.APIKeyService
revokedJWT sync.Map
hsts bool
csp bool
}
// RestrictedRequestContext is a data structure containing information
@@ -61,30 +52,22 @@ type (
tokenLookup func(*http.Request) (*portainer.TokenData, error)
)
var (
ErrInvalidKey = errors.New("Invalid API key")
ErrRevokedJWT = errors.New("the JWT has been revoked")
)
const apiKeyHeader = "X-API-KEY"
const jwtTokenHeader = "Authorization"
// NewRequestBouncer initializes a new RequestBouncer
func NewRequestBouncer(dataStore dataservices.DataStore, jwtService portainer.JWTService, apiKeyService apikey.APIKeyService) *RequestBouncer {
b := &RequestBouncer{
return &RequestBouncer{
dataStore: dataStore,
jwtService: jwtService,
apiKeyService: apiKeyService,
hsts: featureflags.IsEnabled("hsts"),
csp: featureflags.IsEnabled("csp"),
}
go b.cleanUpExpiredJWT()
return b
}
// PublicAccess defines a security check for public API endpoints.
// No authentication is required to access these endpoints.
func (bouncer *RequestBouncer) PublicAccess(h http.Handler) http.Handler {
return MWSecureHeaders(h, bouncer.hsts, bouncer.csp)
return mwSecureHeaders(h)
}
// AdminAccess defines a security check for API endpoints that require an authorization check.
@@ -213,8 +196,7 @@ func (bouncer *RequestBouncer) mwAuthenticatedUser(h http.Handler) http.Handler
bouncer.CookieAuthLookup,
bouncer.JWTAuthLookup,
}, h)
h = MWSecureHeaders(h, bouncer.hsts, bouncer.csp)
h = mwSecureHeaders(h)
return h
}
@@ -335,15 +317,11 @@ func (bouncer *RequestBouncer) CookieAuthLookup(r *http.Request) (*portainer.Tok
return nil, nil
}
tokenData, jti, _, err := bouncer.jwtService.ParseAndVerifyToken(token)
tokenData, err := bouncer.jwtService.ParseAndVerifyToken(token)
if err != nil {
return nil, err
}
if _, ok := bouncer.revokedJWT.Load(jti); ok {
return nil, ErrRevokedJWT
}
return tokenData, nil
}
@@ -355,44 +333,15 @@ func (bouncer *RequestBouncer) JWTAuthLookup(r *http.Request) (*portainer.TokenD
return nil, nil
}
tokenData, jti, _, err := bouncer.jwtService.ParseAndVerifyToken(token)
tokenData, err := bouncer.jwtService.ParseAndVerifyToken(token)
if err != nil {
return nil, err
}
if _, ok := bouncer.revokedJWT.Load(jti); ok {
return nil, ErrRevokedJWT
}
return tokenData, nil
}
func (bouncer *RequestBouncer) RevokeJWT(token string) {
_, jti, exp, err := bouncer.jwtService.ParseAndVerifyToken(token)
if err != nil {
return
}
bouncer.revokedJWT.Store(jti, exp)
}
func (bouncer *RequestBouncer) cleanUpExpiredJWTPass() {
bouncer.revokedJWT.Range(func(key, value any) bool {
if time.Now().After(value.(time.Time)) {
bouncer.revokedJWT.Delete(key)
}
return true
})
}
func (bouncer *RequestBouncer) cleanUpExpiredJWT() {
ticker := time.NewTicker(time.Hour)
for range ticker.C {
bouncer.cleanUpExpiredJWTPass()
}
}
var ErrInvalidKey = errors.New("Invalid API key")
// apiKeyLookup looks up an verifies an api-key by:
// - computing the digest of the raw api-key
@@ -512,17 +461,10 @@ func extractAPIKey(r *http.Request) (string, bool) {
return "", false
}
// MWSecureHeaders provides secure headers middleware for handlers.
func MWSecureHeaders(next http.Handler, hsts, csp bool) http.Handler {
// mwSecureHeaders provides secure headers middleware for handlers.
func mwSecureHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if hsts {
w.Header().Set("Strict-Transport-Security", "max-age=31536000") // 365 days
}
if csp {
w.Header().Set("Content-Security-Policy", "script-src 'self' cdn.matomo.cloud")
}
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("X-Content-Type-Options", "nosniff")
next.ServeHTTP(w, r)
})
@@ -586,12 +528,7 @@ func (bouncer *RequestBouncer) EdgeComputeOperation(next http.Handler) http.Hand
// - public routes
// - kubectl - a bearer token is needed, and no csrf token can be sent
// - api token
// - docker desktop extension
func ShouldSkipCSRFCheck(r *http.Request, isDockerDesktopExtension bool) (bool, error) {
if isDockerDesktopExtension {
return true, nil
}
func ShouldSkipCSRFCheck(r *http.Request) (bool, error) {
cookie, _ := r.Cookie(portainer.AuthCookieKey)
hasCookie := cookie != nil && cookie.Value != ""
+25 -96
View File
@@ -5,7 +5,6 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/apikey"
@@ -15,7 +14,6 @@ import (
"github.com/portainer/portainer/api/jwt"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// testHandler200 is a simple handler which returns HTTP status 200 OK
@@ -388,52 +386,40 @@ func Test_apiKeyLookup(t *testing.T) {
func Test_ShouldSkipCSRFCheck(t *testing.T) {
tt := []struct {
name string
cookieValue string
apiKey string
authHeader string
isDockerDesktopExtension bool
expectedResult bool
expectedError bool
name string
cookieValue string
apiKey string
authHeader string
expectedResult bool
expectedError bool
}{
{
name: "Should return false (not skip) when cookie is present",
cookieValue: "test-cookie",
isDockerDesktopExtension: false,
name: "Should return false when cookie is present",
cookieValue: "test-cookie",
},
{
name: "Should return true (skip) when cookie is present and docker desktop extension is true",
cookieValue: "test-cookie",
isDockerDesktopExtension: true,
expectedResult: true,
name: "Should return true when cookie is not present",
cookieValue: "",
expectedResult: true,
},
{
name: "Should return true (skip) when cookie is not present",
cookieValue: "",
isDockerDesktopExtension: false,
expectedResult: true,
name: "Should return true when api key is present",
cookieValue: "",
apiKey: "test-api-key",
expectedResult: true,
},
{
name: "Should return true (skip) when api key is present",
cookieValue: "",
apiKey: "test-api-key",
isDockerDesktopExtension: false,
expectedResult: true,
name: "Should return true when auth header is present",
cookieValue: "",
authHeader: "test-auth-header",
expectedResult: true,
},
{
name: "Should return true (skip) when auth header is present",
cookieValue: "",
authHeader: "test-auth-header",
isDockerDesktopExtension: false,
expectedResult: true,
},
{
name: "Should return false (not skip) and error when both api key and auth header are present",
cookieValue: "",
apiKey: "test-api-key",
authHeader: "test-auth-header",
isDockerDesktopExtension: false,
expectedError: true,
name: "Should return false and error when both api key and auth header are present",
cookieValue: "",
apiKey: "test-api-key",
authHeader: "test-auth-header",
expectedError: true,
},
}
@@ -451,7 +437,7 @@ func Test_ShouldSkipCSRFCheck(t *testing.T) {
req.Header.Set(jwtTokenHeader, test.authHeader)
}
result, err := ShouldSkipCSRFCheck(req, test.isDockerDesktopExtension)
result, err := ShouldSkipCSRFCheck(req)
is.Equal(test.expectedResult, result)
if test.expectedError {
is.Error(err)
@@ -461,60 +447,3 @@ func Test_ShouldSkipCSRFCheck(t *testing.T) {
})
}
}
func TestJWTRevocation(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, true)
jwtService, err := jwt.NewService("1h", store)
require.NoError(t, err)
err = store.User().Create(&portainer.User{ID: 1})
require.NoError(t, err)
jwtService.SetUserSessionDuration(time.Second)
token, _, err := jwtService.GenerateToken(&portainer.TokenData{ID: 1})
require.NoError(t, err)
apiKeyService := apikey.NewAPIKeyService(nil, nil)
bouncer := NewRequestBouncer(store, jwtService, apiKeyService)
r, err := http.NewRequest(http.MethodGet, "url", nil)
require.NoError(t, err)
r.Header.Add(jwtTokenHeader, "Bearer "+token)
r.AddCookie(&http.Cookie{Name: portainer.AuthCookieKey, Value: token})
_, err = bouncer.JWTAuthLookup(r)
require.NoError(t, err)
_, err = bouncer.CookieAuthLookup(r)
require.NoError(t, err)
bouncer.RevokeJWT(token)
revokeLen := func() (l int) {
bouncer.revokedJWT.Range(func(key, value any) bool {
l++
return true
})
return l
}
require.Equal(t, 1, revokeLen())
_, err = bouncer.JWTAuthLookup(r)
require.Error(t, err)
_, err = bouncer.CookieAuthLookup(r)
require.Error(t, err)
time.Sleep(time.Second)
bouncer.cleanUpExpiredJWTPass()
require.Equal(t, 0, revokeLen())
}
+3 -2
View File
@@ -61,7 +61,6 @@ import (
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
edgestackservice "github.com/portainer/portainer/api/internal/edge/edgestacks"
"github.com/portainer/portainer/api/internal/snapshot"
"github.com/portainer/portainer/api/internal/ssl"
"github.com/portainer/portainer/api/internal/upgrade"
k8s "github.com/portainer/portainer/api/kubernetes"
@@ -381,7 +380,9 @@ func (server *Server) Start() error {
}
go shutdown(server.ShutdownCtx, httpsServer)
go snapshot.NewBackgroundSnapshotter(server.DataStore, server.ReverseTunnelService)
// Temporarily disable for EE-6905 until we have a solution for the snapshotter
// go snapshot.NewBackgroundSnapshotter(server.DataStore, server.ReverseTunnelService)
return httpsServer.ListenAndServeTLS("", "")
}
@@ -430,155 +430,6 @@ func DefaultPortainerAuthorizations() portainer.Authorizations {
}
}
// RemoveTeamAccessPolicies will remove all existing access policies associated to the specified team
func (service *Service) RemoveTeamAccessPolicies(tx dataservices.DataStoreTx, teamID portainer.TeamID) error {
endpoints, err := tx.Endpoint().Endpoints()
if err != nil {
return err
}
for _, endpoint := range endpoints {
for policyTeamID := range endpoint.TeamAccessPolicies {
if policyTeamID == teamID {
delete(endpoint.TeamAccessPolicies, policyTeamID)
err := tx.Endpoint().UpdateEndpoint(endpoint.ID, &endpoint)
if err != nil {
return err
}
break
}
}
}
endpointGroups, err := tx.EndpointGroup().ReadAll()
if err != nil {
return err
}
for _, endpointGroup := range endpointGroups {
for policyTeamID := range endpointGroup.TeamAccessPolicies {
if policyTeamID == teamID {
delete(endpointGroup.TeamAccessPolicies, policyTeamID)
err := tx.EndpointGroup().Update(endpointGroup.ID, &endpointGroup)
if err != nil {
return err
}
break
}
}
}
registries, err := tx.Registry().ReadAll()
if err != nil {
return err
}
// iterate over all environments for all registries
// and evict all direct accesses to the registries the team had
// we could have built a range of the teams's environments accesses while removing them above
// but ranging over all environments (registryAccessPolicy is indexed by environmentId)
// makes sure we cleanup all resources in case an access was not removed when a team was removed from an env
for _, registry := range registries {
updateRegistry := false
for _, registryAccessPolicy := range registry.RegistryAccesses {
if _, ok := registryAccessPolicy.TeamAccessPolicies[teamID]; ok {
delete(registryAccessPolicy.TeamAccessPolicies, teamID)
updateRegistry = true
}
}
if updateRegistry {
if err := tx.Registry().Update(registry.ID, &registry); err != nil {
return err
}
}
}
return service.UpdateUsersAuthorizationsTx(tx)
}
// RemoveUserAccessPolicies will remove all existing access policies associated to the specified user
func (service *Service) RemoveUserAccessPolicies(tx dataservices.DataStoreTx, userID portainer.UserID) error {
endpoints, err := tx.Endpoint().Endpoints()
if err != nil {
return err
}
for _, endpoint := range endpoints {
for policyUserID := range endpoint.UserAccessPolicies {
if policyUserID == userID {
delete(endpoint.UserAccessPolicies, policyUserID)
err := tx.Endpoint().UpdateEndpoint(endpoint.ID, &endpoint)
if err != nil {
return err
}
break
}
}
}
endpointGroups, err := tx.EndpointGroup().ReadAll()
if err != nil {
return err
}
for _, endpointGroup := range endpointGroups {
for policyUserID := range endpointGroup.UserAccessPolicies {
if policyUserID == userID {
delete(endpointGroup.UserAccessPolicies, policyUserID)
err := tx.EndpointGroup().Update(endpointGroup.ID, &endpointGroup)
if err != nil {
return err
}
break
}
}
}
registries, err := tx.Registry().ReadAll()
if err != nil {
return err
}
// iterate over all environments for all registries
// and evict all direct accesses to the registries the user had
// we could have built a range of the user's environments accesses while removing them above
// but ranging over all environments (registryAccessPolicy is indexed by environmentId)
// makes sure we cleanup all resources in case an access was not removed when a user was removed from an env
for _, registry := range registries {
updateRegistry := false
for _, registryAccessPolicy := range registry.RegistryAccesses {
if _, ok := registryAccessPolicy.UserAccessPolicies[userID]; ok {
delete(registryAccessPolicy.UserAccessPolicies, userID)
updateRegistry = true
}
}
if updateRegistry {
if err := tx.Registry().Update(registry.ID, &registry); err != nil {
return err
}
}
}
return nil
}
// UpdateUserAuthorizations will update the authorizations for the provided userid
func (service *Service) UpdateUserAuthorizations(tx dataservices.DataStoreTx, userID portainer.UserID) error {
err := service.updateUserAuthorizations(tx, userID)
if err != nil {
return err
}
return nil
}
// UpdateUsersAuthorizations will trigger an update of the authorizations for all the users.
func (service *Service) UpdateUsersAuthorizations() error {
return service.UpdateUsersAuthorizationsTx(service.dataStore)
+4 -3
View File
@@ -77,7 +77,6 @@ func edgeGroupRelatedToEndpoint(edgeGroup *portainer.EdgeGroup, endpoint *portai
return true
}
}
return false
}
@@ -85,10 +84,12 @@ func edgeGroupRelatedToEndpoint(edgeGroup *portainer.EdgeGroup, endpoint *portai
if endpointGroup.TagIDs != nil {
endpointTags = tag.Union(endpointTags, tag.Set(endpointGroup.TagIDs))
}
edgeGroupTags := tag.Set(edgeGroup.TagIDs)
if edgeGroup.PartialMatch {
return tag.PartialMatch(edgeGroup.TagIDs, endpointTags)
intersection := tag.Intersection(endpointTags, edgeGroupTags)
return len(intersection) != 0
}
return tag.FullMatch(edgeGroup.TagIDs, endpointTags)
return tag.FullMatch(edgeGroupTags, endpointTags)
}
+1 -2
View File
@@ -6,7 +6,6 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/internal/unique"
)
var ErrEdgeGroupNotFound = errors.New("edge group was not found")
@@ -33,7 +32,7 @@ func EdgeStackRelatedEndpoints(edgeGroupIDs []portainer.EdgeGroupID, endpoints [
edgeStackEndpoints = append(edgeStackEndpoints, EdgeGroupRelatedEndpoints(edgeGroup, endpoints, endpointGroups)...)
}
return unique.Unique(edgeStackEndpoints), nil
return edgeStackEndpoints, nil
}
type EndpointRelationsConfig struct {
+1 -16
View File
@@ -1,9 +1,6 @@
package edge
import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
)
import portainer "github.com/portainer/portainer/api"
// EndpointRelatedEdgeStacks returns a list of Edge stacks related to this Environment(Endpoint)
func EndpointRelatedEdgeStacks(endpoint *portainer.Endpoint, endpointGroup *portainer.EndpointGroup, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack) []portainer.EdgeStackID {
@@ -27,15 +24,3 @@ func EndpointRelatedEdgeStacks(endpoint *portainer.Endpoint, endpointGroup *port
return relatedEdgeStacks
}
func EffectiveCheckinInterval(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint) int {
if endpoint.EdgeCheckinInterval != 0 {
return endpoint.EdgeCheckinInterval
}
if settings, err := tx.Settings().Settings(); err == nil {
return settings.EdgeAgentCheckinInterval
}
return portainer.DefaultEdgeAgentCheckinIntervalInSeconds
}
+32 -36
View File
@@ -79,26 +79,21 @@ func InitialIngressClassDetection(endpoint *portainer.Endpoint, endpointService
if endpoint.Kubernetes.Flags.IsServerIngressClassDetected {
return
}
defer func() {
endpoint.Kubernetes.Flags.IsServerIngressClassDetected = true
if err := endpointService.UpdateEndpoint(endpoint.ID, endpoint); err != nil {
log.Debug().Err(err).Msg("unable to store found IngressClasses inside the database")
}
endpointService.UpdateEndpoint(
portainer.EndpointID(endpoint.ID),
endpoint,
)
}()
cli, err := factory.GetKubeClient(endpoint)
if err != nil {
log.Debug().Err(err).Msg("unable to create kubernetes client for ingress class detection")
return
}
controllers, err := cli.GetIngressControllers()
if err != nil {
log.Debug().Err(err).Msg("failed to fetch ingressclasses")
return
}
@@ -111,68 +106,69 @@ func InitialIngressClassDetection(endpoint *portainer.Endpoint, endpointService
}
endpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
err = endpointService.UpdateEndpoint(
portainer.EndpointID(endpoint.ID),
endpoint,
)
if err != nil {
log.Debug().Err(err).Msg("unable to store found IngressClasses inside the database")
return
}
}
func InitialMetricsDetection(endpoint *portainer.Endpoint, endpointService dataservices.EndpointService, factory *cli.ClientFactory) {
if endpoint.Kubernetes.Flags.IsServerMetricsDetected {
return
}
defer func() {
endpoint.Kubernetes.Flags.IsServerMetricsDetected = true
if err := endpointService.UpdateEndpoint(endpoint.ID, endpoint); err != nil {
log.Debug().Err(err).Msg("unable to enable UseServerMetrics inside the database")
}
endpointService.UpdateEndpoint(
portainer.EndpointID(endpoint.ID),
endpoint,
)
}()
cli, err := factory.GetKubeClient(endpoint)
if err != nil {
log.Debug().Err(err).Msg("unable to create kubernetes client for initial metrics detection")
return
}
if _, err := cli.GetMetrics(); err != nil {
_, err = cli.GetMetrics()
if err != nil {
log.Debug().Err(err).Msg("unable to fetch metrics: leaving metrics collection disabled.")
return
}
endpoint.Kubernetes.Configuration.UseServerMetrics = true
if err != nil {
log.Debug().Err(err).Msg("unable to enable UseServerMetrics inside the database")
return
}
}
func storageDetect(endpoint *portainer.Endpoint, endpointService dataservices.EndpointService, factory *cli.ClientFactory) error {
if endpoint.Kubernetes.Flags.IsServerStorageDetected {
return nil
}
defer func() {
endpoint.Kubernetes.Flags.IsServerStorageDetected = true
if err := endpointService.UpdateEndpoint(endpoint.ID, endpoint); err != nil {
log.Info().Err(err).Msg("unable to enable storage class inside the database")
}
}()
cli, err := factory.GetKubeClient(endpoint)
if err != nil {
log.Debug().Err(err).Msg("unable to create Kubernetes client for initial storage detection")
return err
}
storage, err := cli.GetStorage()
if err != nil {
log.Debug().Err(err).Msg("unable to fetch storage classes: leaving storage classes disabled")
return err
} else if len(storage) == 0 {
}
if len(storage) == 0 {
log.Info().Err(err).Msg("zero storage classes found: they may be still building, retrying in 30 seconds")
return fmt.Errorf("zero storage classes found: they may be still building, retrying in 30 seconds")
}
endpoint.Kubernetes.Configuration.StorageClasses = storage
err = endpointService.UpdateEndpoint(
portainer.EndpointID(endpoint.ID),
endpoint,
)
if err != nil {
log.Debug().Err(err).Msg("unable to enable storage class inside the database")
return err
}
return nil
}
+13 -5
View File
@@ -57,6 +57,8 @@ func NewService(
// NewBackgroundSnapshotter queues snapshots of existing edge environments that
// do not have one already
func NewBackgroundSnapshotter(dataStore dataservices.DataStore, tunnelService portainer.ReverseTunnelService) {
var endpointIDs []portainer.EndpointID
err := dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
endpoints, err := tx.Endpoint().Endpoints()
if err != nil {
@@ -64,16 +66,14 @@ func NewBackgroundSnapshotter(dataStore dataservices.DataStore, tunnelService po
}
for _, e := range endpoints {
if !endpointutils.IsEdgeEndpoint(&e) || e.Edge.AsyncMode || !e.UserTrusted {
if !endpointutils.IsEdgeEndpoint(&e) {
continue
}
s, err := tx.Snapshot().Read(e.ID)
if dataservices.IsErrObjectNotFound(err) ||
(err == nil && s.Docker == nil && s.Kubernetes == nil) {
if err := tunnelService.Open(&e); err != nil {
log.Error().Err(err).Msg("could not open the tunnel")
}
endpointIDs = append(endpointIDs, e.ID)
}
}
@@ -83,6 +83,11 @@ func NewBackgroundSnapshotter(dataStore dataservices.DataStore, tunnelService po
log.Error().Err(err).Msg("background snapshotter failure")
return
}
for _, endpointID := range endpointIDs {
tunnelService.SetTunnelStatusToActive(endpointID)
time.Sleep(10 * time.Second)
}
}
func parseSnapshotFrequency(snapshotInterval string, dataStore dataservices.DataStore) (float64, error) {
@@ -307,7 +312,10 @@ func updateEndpointStatus(tx dataservices.DataStoreTx, endpoint *portainer.Endpo
// Run the pending actions
if latestEndpointReference.Status == portainer.EndpointStatusUp {
pendingActionsService.Execute(endpoint.ID)
err = pendingActionsService.Execute(endpoint.ID)
if err != nil {
log.Error().Err(err).Msg("background schedule error (environment snapshot), unable to execute pending actions")
}
}
}
+27 -26
View File
@@ -1,63 +1,64 @@
package tag
import (
portainer "github.com/portainer/portainer/api"
)
import portainer "github.com/portainer/portainer/api"
type tagSet map[portainer.TagID]struct{}
type tagSet map[portainer.TagID]bool
// Set converts an array of ids to a set
func Set(tagIDs []portainer.TagID) tagSet {
set := map[portainer.TagID]struct{}{}
set := map[portainer.TagID]bool{}
for _, tagID := range tagIDs {
set[tagID] = struct{}{}
set[tagID] = true
}
return set
}
// IntersectionCount returns the element count of the intersection of the sets
func IntersectionCount(setA, setB tagSet) int {
if len(setA) > len(setB) {
setA, setB = setB, setA
// Intersection returns a set intersection of the provided sets
func Intersection(sets ...tagSet) tagSet {
intersection := tagSet{}
if len(sets) == 0 {
return intersection
}
count := 0
setA := sets[0]
for tag := range setA {
if _, ok := setB[tag]; ok {
count++
inAll := true
for _, setB := range sets {
if !setB[tag] {
inAll = false
break
}
}
if inAll {
intersection[tag] = true
}
}
return count
return intersection
}
// Union returns a set union of provided sets
func Union(sets ...tagSet) tagSet {
union := tagSet{}
for _, set := range sets {
for tag := range set {
union[tag] = struct{}{}
union[tag] = true
}
}
return union
}
// Contains return true if setA contains setB
func Contains(setA tagSet, setB []portainer.TagID) bool {
func Contains(setA tagSet, setB tagSet) bool {
if len(setA) == 0 || len(setB) == 0 {
return false
}
for _, tag := range setB {
if _, ok := setA[tag]; !ok {
for tag := range setB {
if !setA[tag] {
return false
}
}
return true
}
@@ -66,8 +67,8 @@ func Difference(setA tagSet, setB tagSet) tagSet {
set := tagSet{}
for tag := range setA {
if _, ok := setB[tag]; !ok {
set[tag] = struct{}{}
if !setB[tag] {
set[tag] = true
}
}
+3 -11
View File
@@ -1,19 +1,11 @@
package tag
import portainer "github.com/portainer/portainer/api"
// FullMatch returns true if environment tags matches all edge group tags
func FullMatch(edgeGroupTags []portainer.TagID, environmentTags tagSet) bool {
func FullMatch(edgeGroupTags tagSet, environmentTags tagSet) bool {
return Contains(environmentTags, edgeGroupTags)
}
// PartialMatch returns true if environment tags matches at least one edge group tag
func PartialMatch(edgeGroupTags []portainer.TagID, environmentTags tagSet) bool {
for _, tagID := range edgeGroupTags {
if _, ok := environmentTags[tagID]; ok {
return true
}
}
return false
func PartialMatch(edgeGroupTags tagSet, environmentTags tagSet) bool {
return len(Intersection(edgeGroupTags, environmentTags)) != 0
}
+17 -17
View File
@@ -9,49 +9,49 @@ import (
func TestFullMatch(t *testing.T) {
cases := []struct {
name string
edgeGroupTags []portainer.TagID
edgeGroupTags tagSet
environmentTag tagSet
expected bool
}{
{
name: "environment tag partially match edge group tags",
edgeGroupTags: []portainer.TagID{1, 2, 3},
edgeGroupTags: Set([]portainer.TagID{1, 2, 3}),
environmentTag: Set([]portainer.TagID{1, 2}),
expected: false,
},
{
name: "edge group tags equal to environment tags",
edgeGroupTags: []portainer.TagID{1, 2},
edgeGroupTags: Set([]portainer.TagID{1, 2}),
environmentTag: Set([]portainer.TagID{1, 2}),
expected: true,
},
{
name: "environment tags fully match edge group tags",
edgeGroupTags: []portainer.TagID{1, 2},
edgeGroupTags: Set([]portainer.TagID{1, 2}),
environmentTag: Set([]portainer.TagID{1, 2, 3}),
expected: true,
},
{
name: "environment tags do not match edge group tags",
edgeGroupTags: []portainer.TagID{1, 2},
edgeGroupTags: Set([]portainer.TagID{1, 2}),
environmentTag: Set([]portainer.TagID{3, 4}),
expected: false,
},
{
name: "edge group has no tags and environment has tags",
edgeGroupTags: []portainer.TagID{},
edgeGroupTags: Set([]portainer.TagID{}),
environmentTag: Set([]portainer.TagID{1, 2}),
expected: false,
},
{
name: "edge group has tags and environment has no tags",
edgeGroupTags: []portainer.TagID{1, 2},
edgeGroupTags: Set([]portainer.TagID{1, 2}),
environmentTag: Set([]portainer.TagID{}),
expected: false,
},
{
name: "both edge group and environment have no tags",
edgeGroupTags: []portainer.TagID{},
edgeGroupTags: Set([]portainer.TagID{}),
environmentTag: Set([]portainer.TagID{}),
expected: false,
},
@@ -70,55 +70,55 @@ func TestFullMatch(t *testing.T) {
func TestPartialMatch(t *testing.T) {
cases := []struct {
name string
edgeGroupTags []portainer.TagID
edgeGroupTags tagSet
environmentTag tagSet
expected bool
}{
{
name: "environment tags partially match edge group tags 1",
edgeGroupTags: []portainer.TagID{1, 2, 3},
edgeGroupTags: Set([]portainer.TagID{1, 2, 3}),
environmentTag: Set([]portainer.TagID{1, 2}),
expected: true,
},
{
name: "environment tags partially match edge group tags 2",
edgeGroupTags: []portainer.TagID{1, 2, 3},
edgeGroupTags: Set([]portainer.TagID{1, 2, 3}),
environmentTag: Set([]portainer.TagID{1, 4, 5}),
expected: true,
},
{
name: "edge group tags equal to environment tags",
edgeGroupTags: []portainer.TagID{1, 2},
edgeGroupTags: Set([]portainer.TagID{1, 2}),
environmentTag: Set([]portainer.TagID{1, 2}),
expected: true,
},
{
name: "environment tags fully match edge group tags",
edgeGroupTags: []portainer.TagID{1, 2},
edgeGroupTags: Set([]portainer.TagID{1, 2}),
environmentTag: Set([]portainer.TagID{1, 2, 3}),
expected: true,
},
{
name: "environment tags do not match edge group tags",
edgeGroupTags: []portainer.TagID{1, 2},
edgeGroupTags: Set([]portainer.TagID{1, 2}),
environmentTag: Set([]portainer.TagID{3, 4}),
expected: false,
},
{
name: "edge group has no tags and environment has tags",
edgeGroupTags: []portainer.TagID{},
edgeGroupTags: Set([]portainer.TagID{}),
environmentTag: Set([]portainer.TagID{1, 2}),
expected: false,
},
{
name: "edge group has tags and environment has no tags",
edgeGroupTags: []portainer.TagID{1, 2},
edgeGroupTags: Set([]portainer.TagID{1, 2}),
environmentTag: Set([]portainer.TagID{}),
expected: false,
},
{
name: "both edge group and environment have no tags",
edgeGroupTags: []portainer.TagID{},
edgeGroupTags: Set([]portainer.TagID{}),
environmentTag: Set([]portainer.TagID{}),
expected: false,
},
+17 -17
View File
@@ -7,49 +7,49 @@ import (
portainer "github.com/portainer/portainer/api"
)
func TestIntersectionCount(t *testing.T) {
func TestIntersection(t *testing.T) {
cases := []struct {
name string
setA tagSet
setB tagSet
expected int
expected tagSet
}{
{
name: "positive numbers set intersection",
setA: Set([]portainer.TagID{1, 2, 3, 4, 5}),
setB: Set([]portainer.TagID{4, 5, 6, 7}),
expected: 2,
expected: Set([]portainer.TagID{4, 5}),
},
{
name: "empty setA intersection",
setA: Set([]portainer.TagID{1, 2, 3}),
setB: Set([]portainer.TagID{}),
expected: 0,
expected: Set([]portainer.TagID{}),
},
{
name: "empty setB intersection",
setA: Set([]portainer.TagID{}),
setB: Set([]portainer.TagID{1, 2, 3}),
expected: 0,
expected: Set([]portainer.TagID{}),
},
{
name: "no common elements sets intersection",
setA: Set([]portainer.TagID{1, 2, 3}),
setB: Set([]portainer.TagID{4, 5, 6}),
expected: 0,
expected: Set([]portainer.TagID{}),
},
{
name: "equal sets intersection",
setA: Set([]portainer.TagID{1, 2, 3}),
setB: Set([]portainer.TagID{1, 2, 3}),
expected: 3,
expected: Set([]portainer.TagID{1, 2, 3}),
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
result := IntersectionCount(tc.setA, tc.setB)
if result != tc.expected {
result := Intersection(tc.setA, tc.setB)
if !reflect.DeepEqual(result, tc.expected) {
t.Errorf("Expected %v, got %v", tc.expected, result)
}
})
@@ -109,49 +109,49 @@ func TestContains(t *testing.T) {
cases := []struct {
name string
setA tagSet
setB []portainer.TagID
setB tagSet
expected bool
}{
{
name: "setA contains setB",
setA: Set([]portainer.TagID{1, 2, 3}),
setB: []portainer.TagID{1, 2},
setB: Set([]portainer.TagID{1, 2}),
expected: true,
},
{
name: "setA equals to setB",
setA: Set([]portainer.TagID{1, 2}),
setB: []portainer.TagID{1, 2},
setB: Set([]portainer.TagID{1, 2}),
expected: true,
},
{
name: "setA contains parts of setB",
setA: Set([]portainer.TagID{1, 2}),
setB: []portainer.TagID{1, 2, 3},
setB: Set([]portainer.TagID{1, 2, 3}),
expected: false,
},
{
name: "setA does not contain setB",
setA: Set([]portainer.TagID{1, 2}),
setB: []portainer.TagID{3, 4},
setB: Set([]portainer.TagID{3, 4}),
expected: false,
},
{
name: "setA is empty and setB is not empty",
setA: Set([]portainer.TagID{}),
setB: []portainer.TagID{1, 2},
setB: Set([]portainer.TagID{1, 2}),
expected: false,
},
{
name: "setA is not empty and setB is empty",
setA: Set([]portainer.TagID{1, 2}),
setB: []portainer.TagID{},
setB: Set([]portainer.TagID{}),
expected: false,
},
{
name: "setA is empty and setB is empty",
setA: Set([]portainer.TagID{}),
setB: []portainer.TagID{},
setB: Set([]portainer.TagID{}),
expected: false,
},
}
+1 -8
View File
@@ -4,7 +4,6 @@ import (
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/errors"
)
@@ -35,7 +34,6 @@ type testDatastore struct {
version dataservices.VersionService
webhook dataservices.WebhookService
pendingActionsService dataservices.PendingActionsService
connection portainer.Connection
}
func (d *testDatastore) Backup(path string) (string, error) { return "", nil }
@@ -90,10 +88,6 @@ func (d *testDatastore) PendingActions() dataservices.PendingActionsService {
return d.pendingActionsService
}
func (d *testDatastore) Connection() portainer.Connection {
return d.connection
}
func (d *testDatastore) IsErrObjectNotFound(e error) bool {
return false
}
@@ -111,8 +105,7 @@ type datastoreOption = func(d *testDatastore)
// NewDatastore creates new instance of testDatastore.
// Will apply options before returning, opts will be applied from left to right.
func NewDatastore(options ...datastoreOption) *testDatastore {
conn, _ := database.NewDatabase("boltdb", "", nil)
d := testDatastore{connection: conn}
d := testDatastore{}
for _, o := range options {
o(&d)
}
@@ -58,8 +58,6 @@ func (testRequestBouncer) JWTAuthLookup(r *http.Request) (*portainer.TokenData,
return nil, nil
}
func (testRequestBouncer) RevokeJWT(jti string) {}
// AddTestSecurityCookie adds a security cookie to the request
func AddTestSecurityCookie(r *http.Request, jwt string) {
r.AddCookie(&http.Cookie{
+6 -14
View File
@@ -7,10 +7,9 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/internal/securecookie"
"github.com/gofrs/uuid"
"github.com/golang-jwt/jwt/v4"
"github.com/portainer/portainer/api/internal/securecookie"
"github.com/rs/zerolog/log"
)
@@ -104,7 +103,7 @@ func (service *Service) GenerateToken(data *portainer.TokenData) (string, time.T
}
// ParseAndVerifyToken parses a JWT token and verify its validity. It returns an error if token is invalid.
func (service *Service) ParseAndVerifyToken(token string) (*portainer.TokenData, string, time.Time, error) {
func (service *Service) ParseAndVerifyToken(token string) (*portainer.TokenData, error) {
scope := parseScope(token)
secret := service.secrets[scope]
parsedToken, err := jwt.ParseWithClaims(token, &claims{}, func(token *jwt.Token) (interface{}, error) {
@@ -120,10 +119,10 @@ func (service *Service) ParseAndVerifyToken(token string) (*portainer.TokenData,
user, err := service.dataStore.User().Read(portainer.UserID(cl.UserID))
if err != nil {
return nil, "", time.Time{}, errInvalidJWTToken
return nil, errInvalidJWTToken
}
if user.TokenIssueAt > cl.StandardClaims.IssuedAt {
return nil, "", time.Time{}, errInvalidJWTToken
return nil, errInvalidJWTToken
}
return &portainer.TokenData{
@@ -132,11 +131,10 @@ func (service *Service) ParseAndVerifyToken(token string) (*portainer.TokenData,
Role: portainer.UserRole(cl.Role),
Token: token,
ForceChangePassword: cl.ForceChangePassword,
}, cl.Id, time.Unix(cl.ExpiresAt, 0), nil
}, nil
}
}
return nil, "", time.Time{}, errInvalidJWTToken
return nil, errInvalidJWTToken
}
// parse a JWT token, fallback to defaultScope if no scope is present in the JWT
@@ -175,11 +173,6 @@ func (service *Service) generateSignedToken(data *portainer.TokenData, expiresAt
expiresAt = time.Now().Add(time.Hour * 8760 * 99).Unix()
}
uuid, err := uuid.NewV4()
if err != nil {
return "", fmt.Errorf("unable to generate the JWT ID: %w", err)
}
cl := claims{
UserID: int(data.ID),
Username: data.Username,
@@ -187,7 +180,6 @@ func (service *Service) generateSignedToken(data *portainer.TokenData, expiresAt
Scope: scope,
ForceChangePassword: data.ForceChangePassword,
StandardClaims: jwt.StandardClaims{
Id: uuid.String(),
ExpiresAt: expiresAt,
IssuedAt: time.Now().Unix(),
},
-55
View File
@@ -6,10 +6,8 @@ import (
"github.com/golang-jwt/jwt/v4"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
i "github.com/portainer/portainer/api/internal/testhelpers"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGenerateSignedToken(t *testing.T) {
@@ -57,56 +55,3 @@ func TestGenerateSignedToken_InvalidScope(t *testing.T) {
assert.Error(t, err)
assert.Equal(t, "invalid scope: testing", err.Error())
}
func TestGenerationAndParsing(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, false)
err := store.User().Create(&portainer.User{ID: 1})
require.NoError(t, err)
service, err := NewService("1h", store)
require.NoError(t, err)
expectedToken := &portainer.TokenData{
Username: "User",
ID: 1,
Role: 1,
}
tokenString, _, err := service.GenerateToken(expectedToken)
require.NoError(t, err)
expectedToken.Token = tokenString
token, _, _, err := service.ParseAndVerifyToken(tokenString)
require.NoError(t, err)
require.Equal(t, expectedToken, token)
}
func TestExpiration(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, false)
err := store.User().Create(&portainer.User{ID: 1})
require.NoError(t, err)
service, err := NewService("1h", store)
require.NoError(t, err)
expectedToken := &portainer.TokenData{
Username: "User",
ID: 1,
Role: 1,
}
service.SetUserSessionDuration(time.Second)
tokenString, _, err := service.GenerateToken(expectedToken)
require.NoError(t, err)
expectedToken.Token = tokenString
time.Sleep(2 * time.Second)
_, _, _, err = service.ParseAndVerifyToken(tokenString)
require.Error(t, err)
}
+2 -2
View File
@@ -240,11 +240,11 @@ func (factory *ClientFactory) buildAgentConfig(endpoint *portainer.Endpoint) (*r
}
func (factory *ClientFactory) buildEdgeConfig(endpoint *portainer.Endpoint) (*rest.Config, error) {
tunnelAddr, err := factory.reverseTunnelService.TunnelAddr(endpoint)
tunnel, err := factory.reverseTunnelService.GetActiveTunnel(endpoint)
if err != nil {
return nil, errors.Wrap(err, "failed activating tunnel")
}
endpointURL := fmt.Sprintf("http://%s/kubernetes", tunnelAddr)
endpointURL := fmt.Sprintf("http://127.0.0.1:%d/kubernetes", tunnel.Port)
config, err := clientcmd.BuildConfigFromFlags(endpointURL, "")
if err != nil {
+2 -3
View File
@@ -8,7 +8,6 @@ import (
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/rs/zerolog/log"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
@@ -65,8 +64,8 @@ func (kcl *KubeClient) GetNamespace(name string) (portainer.K8sNamespaceInfo, er
// CreateNamespace creates a new ingress in a given namespace in a k8s endpoint.
func (kcl *KubeClient) CreateNamespace(info models.K8sNamespaceDetails) error {
portainerLabels := map[string]string{
"io.portainer.kubernetes.resourcepool.name": stackutils.SanitizeLabel(info.Name),
"io.portainer.kubernetes.resourcepool.owner": stackutils.SanitizeLabel(info.Owner),
"io.portainer.kubernetes.resourcepool.name": info.Name,
"io.portainer.kubernetes.resourcepool.owner": info.Owner,
}
var ns v1.Namespace

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