Compare commits

...

54 Commits

Author SHA1 Message Date
andres-portainer
0253a09c80 fix(tls): add support for more cipher suites EE-7150 2024-06-03 09:12:00 -03:00
Ali
439714f93d fix(app): ensure placement errors surface per node [EE-7065] (#11821)
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
134 changed files with 2243 additions and 784 deletions

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.6
GO_VERSION: 1.21.9
NODE_VERSION: 18.x
jobs:

View File

@@ -18,7 +18,7 @@ on:
- ready_for_review
env:
GO_VERSION: 1.21.6
GO_VERSION: 1.21.9
NODE_VERSION: 18.x
jobs:

View File

@@ -6,7 +6,7 @@ on:
workflow_dispatch:
env:
GO_VERSION: 1.21.6
GO_VERSION: 1.21.9
jobs:
client-dependencies:

View File

@@ -14,7 +14,7 @@ on:
- '.github/workflows/pr-security.yml'
env:
GO_VERSION: 1.21.6
GO_VERSION: 1.21.9
NODE_VERSION: 18.x
jobs:

View File

@@ -1,7 +1,7 @@
name: Test
env:
GO_VERSION: 1.21.6
GO_VERSION: 1.21.9
NODE_VERSION: 18.x
on:

View File

@@ -13,7 +13,7 @@ on:
- ready_for_review
env:
GO_VERSION: 1.21.6
GO_VERSION: 1.21.9
NODE_VERSION: 18.x
jobs:

View File

@@ -8,7 +8,6 @@ import { QueryClient, QueryClientProvider } from 'react-query';
initMSW(
{
onUnhandledRequest: ({ method, url }) => {
console.log(method, url);
if (url.startsWith('/api')) {
console.error(`Unhandled ${method} request to ${url}.

View File

@@ -26,7 +26,7 @@ func RestoreArchive(archive io.Reader, password string, filestorePath string, ga
if password != "" {
archive, err = decrypt(archive, password)
if err != nil {
return errors.Wrap(err, "failed to decrypt the archive")
return errors.Wrap(err, "failed to decrypt the archive. Please ensure the password is correct and try again")
}
}

View File

@@ -19,6 +19,7 @@ import (
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/datastore/migrator"
"github.com/portainer/portainer/api/datastore/postinit"
"github.com/portainer/portainer/api/demo"
"github.com/portainer/portainer/api/docker"
dockerclient "github.com/portainer/portainer/api/docker/client"
@@ -457,19 +458,11 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
authorizationService := authorization.NewService(dataStore)
authorizationService.K8sClientFactory = kubernetesClientFactory
pendingActionsService := pendingactions.NewService(dataStore, kubernetesClientFactory, authorizationService, shutdownCtx)
snapshotService, err := initSnapshotService(*flags.SnapshotInterval, dataStore, dockerClientFactory, kubernetesClientFactory, shutdownCtx, pendingActionsService)
if err != nil {
log.Fatal().Err(err).Msg("failed initializing snapshot service")
}
snapshotService.Start()
kubernetesTokenCacheManager := kubeproxy.NewTokenCacheManager()
kubeClusterAccessService := kubernetes.NewKubeClusterAccessService(*flags.BaseURL, *flags.AddrHTTPS, sslSettings.CertPath)
proxyManager := proxy.NewManager(dataStore, digitalSignatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService)
proxyManager := proxy.NewManager(kubernetesClientFactory)
reverseTunnelService.ProxyManager = proxyManager
@@ -489,6 +482,16 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
kubernetesDeployer := initKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, digitalSignatureService, proxyManager, *flags.Assets)
pendingActionsService := pendingactions.NewService(dataStore, kubernetesClientFactory, dockerClientFactory, authorizationService, shutdownCtx, *flags.Assets, kubernetesDeployer)
snapshotService, err := initSnapshotService(*flags.SnapshotInterval, dataStore, dockerClientFactory, kubernetesClientFactory, shutdownCtx, pendingActionsService)
if err != nil {
log.Fatal().Err(err).Msg("failed initializing snapshot service")
}
snapshotService.Start()
proxyManager.NewProxyFactory(dataStore, digitalSignatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService, snapshotService)
helmPackageManager, err := initHelmPackageManager(*flags.Assets)
if err != nil {
log.Fatal().Err(err).Msg("failed initializing helm package manager")
@@ -578,10 +581,12 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
// but some more complex migrations require access to a kubernetes or docker
// client. Therefore we run a separate migration process just before
// starting the server.
postInitMigrator := datastore.NewPostInitMigrator(
postInitMigrator := postinit.NewPostInitMigrator(
kubernetesClientFactory,
dockerClientFactory,
dataStore,
*flags.Assets,
kubernetesDeployer,
)
if err := postInitMigrator.PostInitMigrate(); err != nil {
log.Fatal().Err(err).Msg("failure during post init migrations")
@@ -650,6 +655,7 @@ func main() {
Msg("starting Portainer")
err := server.Start()
log.Info().Err(err).Msg("HTTP server exited")
}
}

View File

@@ -1,52 +1,216 @@
package crypto
import (
"bufio"
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"errors"
"fmt"
"io"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/scrypt"
)
// NOTE: has to go with what is considered to be a simplistic in that it omits any
// authentication of the encrypted data.
// Person with better knowledge is welcomed to improve it.
// sourced from https://golang.org/src/crypto/cipher/example_test.go
const (
// AES GCM settings
aesGcmHeader = "AES256-GCM" // The encrypted file header
aesGcmBlockSize = 1024 * 1024 // 1MB block for aes gcm
var emptySalt []byte = make([]byte, 0)
// Argon2 settings
// Recommded settings lower memory hardware according to current OWASP recommendations
// Considering some people run portainer on a NAS I think it's prudent not to assume we're on server grade hardware
// https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#argon2id
argon2MemoryCost = 12 * 1024
argon2TimeCost = 3
argon2Threads = 1
argon2KeyLength = 32
)
// AesEncrypt reads from input, encrypts with AES-256 and writes to the output.
// passphrase is used to generate an encryption key.
// AesEncrypt reads from input, encrypts with AES-256 and writes to output. passphrase is used to generate an encryption key
func AesEncrypt(input io.Reader, output io.Writer, passphrase []byte) error {
// making a 32 bytes key that would correspond to AES-256
// don't necessarily need a salt, so just kept in empty
key, err := scrypt.Key(passphrase, emptySalt, 32768, 8, 1, 32)
err := aesEncryptGCM(input, output, passphrase)
if err != nil {
return err
}
block, err := aes.NewCipher(key)
if err != nil {
return err
}
// If the key is unique for each ciphertext, then it's ok to use a zero
// IV.
var iv [aes.BlockSize]byte
stream := cipher.NewOFB(block, iv[:])
writer := &cipher.StreamWriter{S: stream, W: output}
// Copy the input to the output, encrypting as we go.
if _, err := io.Copy(writer, input); err != nil {
return err
return fmt.Errorf("error encrypting file: %w", err)
}
return nil
}
// AesDecrypt reads from input, decrypts with AES-256 and returns the reader to a read decrypted content from.
// passphrase is used to generate an encryption key.
// AesDecrypt reads from input, decrypts with AES-256 and returns the reader to read the decrypted content from
func AesDecrypt(input io.Reader, passphrase []byte) (io.Reader, error) {
// Read file header to determine how it was encrypted
inputReader := bufio.NewReader(input)
header, err := inputReader.Peek(len(aesGcmHeader))
if err != nil {
return nil, fmt.Errorf("error reading encrypted backup file header: %w", err)
}
if string(header) == aesGcmHeader {
reader, err := aesDecryptGCM(inputReader, passphrase)
if err != nil {
return nil, fmt.Errorf("error decrypting file: %w", err)
}
return reader, nil
}
// Use the previous decryption routine which has no header (to support older archives)
reader, err := aesDecryptOFB(inputReader, passphrase)
if err != nil {
return nil, fmt.Errorf("error decrypting legacy file backup: %w", err)
}
return reader, nil
}
// aesEncryptGCM reads from input, encrypts with AES-256 and writes to output. passphrase is used to generate an encryption key.
func aesEncryptGCM(input io.Reader, output io.Writer, passphrase []byte) error {
// Derive key using argon2 with a random salt
salt := make([]byte, 16) // 16 bytes salt
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
return err
}
key := argon2.IDKey(passphrase, salt, argon2TimeCost, argon2MemoryCost, argon2Threads, 32)
block, err := aes.NewCipher(key)
if err != nil {
return err
}
aesgcm, err := cipher.NewGCM(block)
if err != nil {
return err
}
// Generate nonce
nonce, err := NewRandomNonce(aesgcm.NonceSize())
if err != nil {
return err
}
// write the header
if _, err := output.Write([]byte(aesGcmHeader)); err != nil {
return err
}
// Write nonce and salt to the output file
if _, err := output.Write(salt); err != nil {
return err
}
if _, err := output.Write(nonce.Value()); err != nil {
return err
}
// Buffer for reading plaintext blocks
buf := make([]byte, aesGcmBlockSize) // Adjust buffer size as needed
ciphertext := make([]byte, len(buf)+aesgcm.Overhead())
// Encrypt plaintext in blocks
for {
n, err := io.ReadFull(input, buf)
if n == 0 {
break // end of plaintext input
}
if err != nil && !(errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)) {
return err
}
// Seal encrypts the plaintext using the nonce returning the updated slice.
ciphertext = aesgcm.Seal(ciphertext[:0], nonce.Value(), buf[:n], nil)
_, err = output.Write(ciphertext)
if err != nil {
return err
}
nonce.Increment()
}
return nil
}
// aesDecryptGCM reads from input, decrypts with AES-256 and returns the reader to read the decrypted content from.
func aesDecryptGCM(input io.Reader, passphrase []byte) (io.Reader, error) {
// Reader & verify header
header := make([]byte, len(aesGcmHeader))
if _, err := io.ReadFull(input, header); err != nil {
return nil, err
}
if string(header) != aesGcmHeader {
return nil, fmt.Errorf("invalid header")
}
// Read salt
salt := make([]byte, 16) // Salt size
if _, err := io.ReadFull(input, salt); err != nil {
return nil, err
}
key := argon2.IDKey(passphrase, salt, argon2TimeCost, argon2MemoryCost, argon2Threads, 32)
// Initialize AES cipher block
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
// Create GCM mode with the cipher block
aesgcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
// Read nonce from the input reader
nonce := NewNonce(aesgcm.NonceSize())
if err := nonce.Read(input); err != nil {
return nil, err
}
// Initialize a buffer to store decrypted data
buf := bytes.Buffer{}
plaintext := make([]byte, aesGcmBlockSize)
// Decrypt the ciphertext in blocks
for {
// Read a block of ciphertext from the input reader
ciphertextBlock := make([]byte, aesGcmBlockSize+aesgcm.Overhead()) // Adjust block size as needed
n, err := io.ReadFull(input, ciphertextBlock)
if n == 0 {
break // end of ciphertext
}
if err != nil && !(errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)) {
return nil, err
}
// Decrypt the block of ciphertext
plaintext, err = aesgcm.Open(plaintext[:0], nonce.Value(), ciphertextBlock[:n], nil)
if err != nil {
return nil, err
}
_, err = buf.Write(plaintext)
if err != nil {
return nil, err
}
nonce.Increment()
}
return &buf, nil
}
// aesDecryptOFB reads from input, decrypts with AES-256 and returns the reader to a read decrypted content from.
// passphrase is used to generate an encryption key.
// note: This function used to decrypt files that were encrypted without a header i.e. old archives
func aesDecryptOFB(input io.Reader, passphrase []byte) (io.Reader, error) {
var emptySalt []byte = make([]byte, 0)
// making a 32 bytes key that would correspond to AES-256
// don't necessarily need a salt, so just kept in empty
key, err := scrypt.Key(passphrase, emptySalt, 32768, 8, 1, 32)
@@ -59,11 +223,9 @@ func AesDecrypt(input io.Reader, passphrase []byte) (io.Reader, error) {
return nil, err
}
// If the key is unique for each ciphertext, then it's ok to use a zero
// IV.
// If the key is unique for each ciphertext, then it's ok to use a zero IV.
var iv [aes.BlockSize]byte
stream := cipher.NewOFB(block, iv[:])
reader := &cipher.StreamReader{S: stream, R: input}
return reader, nil

View File

@@ -2,6 +2,7 @@ package crypto
import (
"io"
"math/rand"
"os"
"path/filepath"
"testing"
@@ -9,7 +10,19 @@ import (
"github.com/stretchr/testify/assert"
)
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
func randBytes(n int) []byte {
b := make([]byte, n)
for i := range b {
b[i] = letterBytes[rand.Intn(len(letterBytes))]
}
return b
}
func Test_encryptAndDecrypt_withTheSamePassword(t *testing.T) {
const passphrase = "passphrase"
tmpdir := t.TempDir()
var (
@@ -18,17 +31,99 @@ func Test_encryptAndDecrypt_withTheSamePassword(t *testing.T) {
decryptedFilePath = filepath.Join(tmpdir, "decrypted")
)
content := []byte("content")
content := randBytes(1024*1024*100 + 523)
os.WriteFile(originFilePath, content, 0600)
originFile, _ := os.Open(originFilePath)
defer originFile.Close()
encryptedFileWriter, _ := os.Create(encryptedFilePath)
err := AesEncrypt(originFile, encryptedFileWriter, []byte(passphrase))
assert.Nil(t, err, "Failed to encrypt a file")
encryptedFileWriter.Close()
encryptedContent, err := os.ReadFile(encryptedFilePath)
assert.Nil(t, err, "Couldn't read encrypted file")
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
encryptedFileReader, _ := os.Open(encryptedFilePath)
defer encryptedFileReader.Close()
decryptedFileWriter, _ := os.Create(decryptedFilePath)
defer decryptedFileWriter.Close()
decryptedReader, err := AesDecrypt(encryptedFileReader, []byte(passphrase))
assert.Nil(t, err, "Failed to decrypt file")
io.Copy(decryptedFileWriter, decryptedReader)
decryptedContent, _ := os.ReadFile(decryptedFilePath)
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
}
func Test_encryptAndDecrypt_withStrongPassphrase(t *testing.T) {
const passphrase = "A strong passphrase with special characters: !@#$%^&*()_+"
tmpdir := t.TempDir()
var (
originFilePath = filepath.Join(tmpdir, "origin2")
encryptedFilePath = filepath.Join(tmpdir, "encrypted2")
decryptedFilePath = filepath.Join(tmpdir, "decrypted2")
)
content := randBytes(500)
os.WriteFile(originFilePath, content, 0600)
originFile, _ := os.Open(originFilePath)
defer originFile.Close()
encryptedFileWriter, _ := os.Create(encryptedFilePath)
err := AesEncrypt(originFile, encryptedFileWriter, []byte(passphrase))
assert.Nil(t, err, "Failed to encrypt a file")
encryptedFileWriter.Close()
encryptedContent, err := os.ReadFile(encryptedFilePath)
assert.Nil(t, err, "Couldn't read encrypted file")
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
encryptedFileReader, _ := os.Open(encryptedFilePath)
defer encryptedFileReader.Close()
decryptedFileWriter, _ := os.Create(decryptedFilePath)
defer decryptedFileWriter.Close()
decryptedReader, err := AesDecrypt(encryptedFileReader, []byte(passphrase))
assert.Nil(t, err, "Failed to decrypt file")
io.Copy(decryptedFileWriter, decryptedReader)
decryptedContent, _ := os.ReadFile(decryptedFilePath)
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
}
func Test_encryptAndDecrypt_withTheSamePasswordSmallFile(t *testing.T) {
tmpdir := t.TempDir()
var (
originFilePath = filepath.Join(tmpdir, "origin2")
encryptedFilePath = filepath.Join(tmpdir, "encrypted2")
decryptedFilePath = filepath.Join(tmpdir, "decrypted2")
)
content := randBytes(500)
os.WriteFile(originFilePath, content, 0600)
originFile, _ := os.Open(originFilePath)
defer originFile.Close()
encryptedFileWriter, _ := os.Create(encryptedFilePath)
defer encryptedFileWriter.Close()
err := AesEncrypt(originFile, encryptedFileWriter, []byte("passphrase"))
assert.Nil(t, err, "Failed to encrypt a file")
encryptedFileWriter.Close()
encryptedContent, err := os.ReadFile(encryptedFilePath)
assert.Nil(t, err, "Couldn't read encrypted file")
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
@@ -57,7 +152,7 @@ func Test_encryptAndDecrypt_withEmptyPassword(t *testing.T) {
decryptedFilePath = filepath.Join(tmpdir, "decrypted")
)
content := []byte("content")
content := randBytes(1024 * 50)
os.WriteFile(originFilePath, content, 0600)
originFile, _ := os.Open(originFilePath)
@@ -96,7 +191,7 @@ func Test_decryptWithDifferentPassphrase_shouldProduceWrongResult(t *testing.T)
decryptedFilePath = filepath.Join(tmpdir, "decrypted")
)
content := []byte("content")
content := randBytes(1034)
os.WriteFile(originFilePath, content, 0600)
originFile, _ := os.Open(originFilePath)
@@ -117,11 +212,6 @@ func Test_decryptWithDifferentPassphrase_shouldProduceWrongResult(t *testing.T)
decryptedFileWriter, _ := os.Create(decryptedFilePath)
defer decryptedFileWriter.Close()
decryptedReader, err := AesDecrypt(encryptedFileReader, []byte("garbage"))
assert.Nil(t, err, "Should allow to decrypt with wrong passphrase")
io.Copy(decryptedFileWriter, decryptedReader)
decryptedContent, _ := os.ReadFile(decryptedFilePath)
assert.NotEqual(t, content, decryptedContent, "Original and decrypted content should NOT match")
_, err = AesDecrypt(encryptedFileReader, []byte("garbage"))
assert.NotNil(t, err, "Should not allow decrypt with wrong passphrase")
}

61
api/crypto/nonce.go Normal file
View File

@@ -0,0 +1,61 @@
package crypto
import (
"crypto/rand"
"errors"
"io"
)
type Nonce struct {
val []byte
}
func NewNonce(size int) *Nonce {
return &Nonce{val: make([]byte, size)}
}
// NewRandomNonce generates a new initial nonce with the lower byte set to a random value
// This ensures there are plenty of nonce values availble before rolling over
// Based on ideas from the Secure Programming Cookbook for C and C++ by John Viega, Matt Messier
// https://www.oreilly.com/library/view/secure-programming-cookbook/0596003943/ch04s09.html
func NewRandomNonce(size int) (*Nonce, error) {
randomBytes := 1
if size <= randomBytes {
return nil, errors.New("nonce size must be greater than the number of random bytes")
}
randomPart := make([]byte, randomBytes)
if _, err := rand.Read(randomPart); err != nil {
return nil, err
}
zeroPart := make([]byte, size-randomBytes)
nonceVal := append(randomPart, zeroPart...)
return &Nonce{val: nonceVal}, nil
}
func (n *Nonce) Read(stream io.Reader) error {
_, err := io.ReadFull(stream, n.val)
return err
}
func (n *Nonce) Value() []byte {
return n.val
}
func (n *Nonce) Increment() error {
// Start incrementing from the least significant byte
for i := len(n.val) - 1; i >= 0; i-- {
// Increment the current byte
n.val[i]++
// Check for overflow
if n.val[i] != 0 {
// No overflow, nonce is successfully incremented
return nil
}
}
// If we reach here, it means the nonce has overflowed
return errors.New("nonce overflow")
}

View File

@@ -22,6 +22,12 @@ 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,
},
}
}

View File

@@ -73,6 +73,7 @@ type (
PendingActionsService interface {
BaseCRUD[portainer.PendingActions, portainer.PendingActionsID]
GetNextIdentifier() int
DeleteByEndpointID(ID portainer.EndpointID) error
}
// EdgeStackService represents a service to manage Edge stacks

View File

@@ -1,10 +1,12 @@
package pendingactions
import (
"fmt"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/rs/zerolog/log"
)
const (
@@ -45,6 +47,12 @@ func (s Service) Update(ID portainer.PendingActionsID, config *portainer.Pending
})
}
func (s Service) DeleteByEndpointID(ID portainer.EndpointID) error {
return s.Connection.UpdateTx(func(tx portainer.Transaction) error {
return s.Tx(tx).DeleteByEndpointID(ID)
})
}
func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
return ServiceTx{
BaseDataServiceTx: dataservices.BaseDataServiceTx[portainer.PendingActions, portainer.PendingActionsID]{
@@ -68,6 +76,29 @@ func (s ServiceTx) Update(ID portainer.PendingActionsID, config *portainer.Pendi
return s.BaseDataServiceTx.Update(ID, config)
}
func (s ServiceTx) DeleteByEndpointID(ID portainer.EndpointID) error {
log.Debug().Int("endpointId", int(ID)).Msg("deleting pending actions for endpoint")
pendingActions, err := s.BaseDataServiceTx.ReadAll()
if err != nil {
return fmt.Errorf("failed to retrieve pending-actions for endpoint (%d): %w", ID, err)
}
for _, pendingAction := range pendingActions {
if pendingAction.EndpointID == ID {
err := s.BaseDataServiceTx.Delete(pendingAction.ID)
if err != nil {
log.Debug().Int("endpointId", int(ID)).Msgf("failed to delete pending action: %v", err)
}
}
}
return nil
}
// GetNextIdentifier returns the next identifier for a custom template.
func (service ServiceTx) GetNextIdentifier() int {
return service.Tx.GetNextIdentifier(BucketName)
}
// GetNextIdentifier returns the next identifier for a custom template.
func (service *Service) GetNextIdentifier() int {
return service.Connection.GetNextIdentifier(BucketName)

View File

@@ -86,6 +86,7 @@ func (store *Store) newMigratorParameters(version *models.Version) *migrator.Mig
EdgeStackService: store.EdgeStackService,
EdgeJobService: store.EdgeJobService,
TunnelServerService: store.TunnelServerService,
PendingActionsService: store.PendingActionsService,
}
}

View File

@@ -1,117 +0,0 @@
package datastore
import (
"context"
"github.com/docker/docker/api/types"
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/kubernetes/cli"
"github.com/rs/zerolog/log"
)
type PostInitMigrator struct {
kubeFactory *cli.ClientFactory
dockerFactory *dockerclient.ClientFactory
dataStore dataservices.DataStore
}
func NewPostInitMigrator(kubeFactory *cli.ClientFactory, dockerFactory *dockerclient.ClientFactory, dataStore dataservices.DataStore) *PostInitMigrator {
return &PostInitMigrator{
kubeFactory: kubeFactory,
dockerFactory: dockerFactory,
dataStore: dataStore,
}
}
func (migrator *PostInitMigrator) PostInitMigrate() error {
if err := migrator.PostInitMigrateIngresses(); err != nil {
return err
}
migrator.PostInitMigrateGPUs()
return nil
}
func (migrator *PostInitMigrator) PostInitMigrateIngresses() error {
endpoints, err := migrator.dataStore.Endpoint().Endpoints()
if err != nil {
return err
}
for i := range endpoints {
// Early exit if we do not need to migrate!
if !endpoints[i].PostInitMigrations.MigrateIngresses {
return nil
}
err := migrator.kubeFactory.MigrateEndpointIngresses(&endpoints[i])
if err != nil {
log.Debug().Err(err).Msg("failure migrating endpoint ingresses")
}
}
return nil
}
// PostInitMigrateGPUs will check all docker endpoints for containers with GPUs and set EnableGPUManagement to true if any are found
// If there's an error getting the containers, we'll log it and move on
func (migrator *PostInitMigrator) PostInitMigrateGPUs() {
environments, err := migrator.dataStore.Endpoint().Endpoints()
if err != nil {
log.Err(err).Msg("failure getting endpoints")
return
}
for i := range environments {
if environments[i].Type == portainer.DockerEnvironment {
// // Early exit if we do not need to migrate!
if !environments[i].PostInitMigrations.MigrateGPUs {
return
}
// set the MigrateGPUs flag to false so we don't run this again
environments[i].PostInitMigrations.MigrateGPUs = false
migrator.dataStore.Endpoint().UpdateEndpoint(environments[i].ID, &environments[i])
// create a docker client
dockerClient, err := migrator.dockerFactory.CreateClient(&environments[i], "", nil)
if err != nil {
log.Err(err).Msg("failure creating docker client for environment: " + environments[i].Name)
return
}
defer dockerClient.Close()
// get all containers
containers, err := dockerClient.ContainerList(context.Background(), types.ContainerListOptions{All: true})
if err != nil {
log.Err(err).Msg("failed to list containers")
return
}
// check for a gpu on each container. If even one GPU is found, set EnableGPUManagement to true for the whole endpoint
containersLoop:
for _, container := range containers {
// https://www.sobyte.net/post/2022-10/go-docker/ has nice documentation on the docker client with GPUs
containerDetails, err := dockerClient.ContainerInspect(context.Background(), container.ID)
if err != nil {
log.Err(err).Msg("failed to inspect container")
return
}
deviceRequests := containerDetails.HostConfig.Resources.DeviceRequests
for _, deviceRequest := range deviceRequests {
if deviceRequest.Driver == "nvidia" {
environments[i].EnableGPUManagement = true
migrator.dataStore.Endpoint().UpdateEndpoint(environments[i].ID, &environments[i])
break containersLoop
}
}
}
}
}
}

View File

@@ -0,0 +1,32 @@
package migrator
import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/rs/zerolog/log"
)
func (migrator *Migrator) cleanPendingActionsForDeletedEndpointsForDB111() error {
log.Info().Msg("cleaning up pending actions for deleted endpoints")
pendingActions, err := migrator.pendingActionsService.ReadAll()
if err != nil {
return err
}
endpoints := make(map[portainer.EndpointID]struct{})
for _, action := range pendingActions {
endpoints[action.EndpointID] = struct{}{}
}
for endpointId := range endpoints {
_, err := migrator.endpointService.Endpoint(endpointId)
if dataservices.IsErrObjectNotFound(err) {
err := migrator.pendingActionsService.DeleteByEndpointID(endpointId)
if err != nil {
return err
}
}
}
return nil
}

View File

@@ -14,6 +14,7 @@ import (
"github.com/portainer/portainer/api/dataservices/endpointrelation"
"github.com/portainer/portainer/api/dataservices/extension"
"github.com/portainer/portainer/api/dataservices/fdoprofile"
"github.com/portainer/portainer/api/dataservices/pendingactions"
"github.com/portainer/portainer/api/dataservices/registry"
"github.com/portainer/portainer/api/dataservices/resourcecontrol"
"github.com/portainer/portainer/api/dataservices/role"
@@ -58,6 +59,7 @@ type (
edgeStackService *edgestack.Service
edgeJobService *edgejob.Service
TunnelServerService *tunnelserver.Service
pendingActionsService *pendingactions.Service
}
// MigratorParameters represents the required parameters to create a new Migrator instance.
@@ -85,6 +87,7 @@ type (
EdgeStackService *edgestack.Service
EdgeJobService *edgejob.Service
TunnelServerService *tunnelserver.Service
PendingActionsService *pendingactions.Service
}
)
@@ -114,6 +117,7 @@ func NewMigrator(parameters *MigratorParameters) *Migrator {
edgeStackService: parameters.EdgeStackService,
edgeJobService: parameters.EdgeJobService,
TunnelServerService: parameters.TunnelServerService,
pendingActionsService: parameters.PendingActionsService,
}
migrator.initMigrations()
@@ -232,6 +236,9 @@ func (m *Migrator) initMigrations() {
m.updateAppTemplatesVersionForDB110,
m.updateResourceOverCommitToDB110,
)
m.addMigrations("2.20.2",
m.cleanPendingActionsForDeletedEndpointsForDB111,
)
// Add new migrations below...
// One function per migration, each versions migration funcs in the same file.

View File

@@ -0,0 +1,95 @@
package datastore
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/pendingactions/actions"
)
func Test_ConvertCleanNAPWithOverridePoliciesPayload(t *testing.T) {
t.Run("test ConvertCleanNAPWithOverridePoliciesPayload", func(t *testing.T) {
_, store := MustNewTestStore(t, true, false)
defer store.Close()
testData := []struct {
Name string
PendingAction portainer.PendingActions
Expected *actions.CleanNAPWithOverridePoliciesPayload
Err bool
}{
{
Name: "test actiondata with EndpointGroupID 1",
PendingAction: portainer.PendingActions{
EndpointID: 1,
Action: "CleanNAPWithOverridePolicies",
ActionData: &actions.CleanNAPWithOverridePoliciesPayload{
EndpointGroupID: 1,
},
},
Expected: &actions.CleanNAPWithOverridePoliciesPayload{
EndpointGroupID: 1,
},
},
{
Name: "test actionData nil",
PendingAction: portainer.PendingActions{
EndpointID: 2,
Action: "CleanNAPWithOverridePolicies",
ActionData: nil,
},
Expected: nil,
},
{
Name: "test actionData empty and expected error",
PendingAction: portainer.PendingActions{
EndpointID: 2,
Action: "CleanNAPWithOverridePolicies",
ActionData: "",
},
Expected: nil,
Err: true,
},
}
for _, d := range testData {
err := store.PendingActions().Create(&d.PendingAction)
if err != nil {
t.Error(err)
return
}
pendingActions, err := store.PendingActions().ReadAll()
if err != nil {
t.Error(err)
return
}
for _, endpointPendingAction := range pendingActions {
t.Run(d.Name, func(t *testing.T) {
if endpointPendingAction.Action == "CleanNAPWithOverridePolicies" {
actionData, err := actions.ConvertCleanNAPWithOverridePoliciesPayload(endpointPendingAction.ActionData)
if d.Err && err == nil {
t.Error(err)
}
if d.Expected == nil && actionData != nil {
t.Errorf("expected nil , got %d", actionData)
}
if d.Expected != nil && actionData == nil {
t.Errorf("expected not nil , got %d", actionData)
}
if d.Expected != nil && actionData.EndpointGroupID != d.Expected.EndpointGroupID {
t.Errorf("expected EndpointGroupID %d , got %d", d.Expected.EndpointGroupID, actionData.EndpointGroupID)
}
}
})
}
store.PendingActions().Delete(d.PendingAction.ID)
}
})
}

View File

@@ -0,0 +1,203 @@
package postinit
import (
"context"
"fmt"
"reflect"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
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/internal/endpointutils"
"github.com/portainer/portainer/api/kubernetes/cli"
"github.com/portainer/portainer/api/pendingactions/actions"
"github.com/rs/zerolog/log"
)
type PostInitMigrator struct {
kubeFactory *cli.ClientFactory
dockerFactory *dockerClient.ClientFactory
dataStore dataservices.DataStore
assetsPath string
kubernetesDeployer portainer.KubernetesDeployer
}
func NewPostInitMigrator(
kubeFactory *cli.ClientFactory,
dockerFactory *dockerClient.ClientFactory,
dataStore dataservices.DataStore,
assetsPath string,
kubernetesDeployer portainer.KubernetesDeployer,
) *PostInitMigrator {
return &PostInitMigrator{
kubeFactory: kubeFactory,
dockerFactory: dockerFactory,
dataStore: dataStore,
assetsPath: assetsPath,
kubernetesDeployer: kubernetesDeployer,
}
}
// PostInitMigrate will run all post-init migrations, which require docker/kube clients for all edge or non-edge environments
func (postInitMigrator *PostInitMigrator) PostInitMigrate() error {
environments, err := postInitMigrator.dataStore.Endpoint().Endpoints()
if err != nil {
log.Error().Err(err).Msg("Error getting environments")
return err
}
for _, environment := range environments {
// edge environments will run after the server starts, in pending actions
if endpointutils.IsEdgeEndpoint(&environment) {
log.Info().Msgf("Adding pending action 'PostInitMigrateEnvironment' for environment %d", environment.ID)
err = postInitMigrator.createPostInitMigrationPendingAction(environment.ID)
if err != nil {
log.Error().Err(err).Msgf("Error creating pending action for environment %d", environment.ID)
}
} else {
// non-edge environments will run before the server starts.
err = postInitMigrator.MigrateEnvironment(&environment)
if err != nil {
log.Error().Err(err).Msgf("Error running post-init migrations for non-edge environment %d", environment.ID)
}
}
}
return nil
}
// try to create a post init migration pending action. If it already exists, do nothing
// this function exists for readability, not reusability
// TODO: This should be moved into pending actions as part of the pending action migration
func (postInitMigrator *PostInitMigrator) createPostInitMigrationPendingAction(environmentID portainer.EndpointID) error {
migrateEnvPendingAction := portainer.PendingActions{
EndpointID: environmentID,
Action: actions.PostInitMigrateEnvironment,
}
// Get all pending actions and filter them by endpoint, action and action args that are equal to the migrateEnvPendingAction
pendingActions, err := postInitMigrator.dataStore.PendingActions().ReadAll()
if err != nil {
log.Error().Err(err).Msgf("Error retrieving pending actions")
return fmt.Errorf("failed to retrieve pending actions for environment %d: %w", environmentID, err)
}
for _, pendingAction := range pendingActions {
if pendingAction.EndpointID == environmentID &&
pendingAction.Action == migrateEnvPendingAction.Action &&
reflect.DeepEqual(pendingAction.ActionData, migrateEnvPendingAction.ActionData) {
log.Debug().Msgf("Migration pending action for environment %d already exists, skipping creating another", environmentID)
return nil
}
}
// If there are no pending actions for the given endpoint, create one
err = postInitMigrator.dataStore.PendingActions().Create(&migrateEnvPendingAction)
if err != nil {
log.Error().Err(err).Msgf("Error creating pending action for environment %d", environmentID)
}
return nil
}
// MigrateEnvironment runs migrations on a single environment
func (migrator *PostInitMigrator) MigrateEnvironment(environment *portainer.Endpoint) error {
log.Info().Msgf("Executing post init migration for environment %d", environment.ID)
switch {
case endpointutils.IsKubernetesEndpoint(environment):
// get the kubeclient for the environment, and skip all kube migrations if there's an error
kubeclient, err := migrator.kubeFactory.GetKubeClient(environment)
if err != nil {
log.Error().Err(err).Msgf("Error creating kubeclient for environment: %d", environment.ID)
return err
}
// if one environment fails, it is logged and the next migration runs. The error is returned at the end and handled by pending actions
err = migrator.MigrateIngresses(*environment, kubeclient)
if err != nil {
return err
}
return nil
case endpointutils.IsDockerEndpoint(environment):
// get the docker client for the environment, and skip all docker migrations if there's an error
dockerClient, err := migrator.dockerFactory.CreateClient(environment, "", nil)
if err != nil {
log.Error().Err(err).Msgf("Error creating docker client for environment: %d", environment.ID)
return err
}
defer dockerClient.Close()
migrator.MigrateGPUs(*environment, dockerClient)
}
return nil
}
func (migrator *PostInitMigrator) MigrateIngresses(environment portainer.Endpoint, kubeclient *cli.KubeClient) error {
// Early exit if we do not need to migrate!
if !environment.PostInitMigrations.MigrateIngresses {
return nil
}
log.Debug().Msgf("Migrating ingresses for environment %d", environment.ID)
err := migrator.kubeFactory.MigrateEndpointIngresses(&environment, migrator.dataStore, kubeclient)
if err != nil {
log.Error().Err(err).Msgf("Error migrating ingresses for environment %d", environment.ID)
return err
}
return nil
}
// MigrateGPUs will check all docker endpoints for containers with GPUs and set EnableGPUManagement to true if any are found
// If there's an error getting the containers, we'll log it and move on
func (migrator *PostInitMigrator) MigrateGPUs(e portainer.Endpoint, dockerClient *client.Client) error {
return migrator.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
environment, err := tx.Endpoint().Endpoint(e.ID)
if err != nil {
log.Error().Err(err).Msgf("Error getting environment %d", environment.ID)
return err
}
// Early exit if we do not need to migrate!
if !environment.PostInitMigrations.MigrateGPUs {
return nil
}
log.Debug().Msgf("Migrating GPUs for environment %d", e.ID)
// get all containers
containers, err := dockerClient.ContainerList(context.Background(), container.ListOptions{All: true})
if err != nil {
log.Error().Err(err).Msgf("failed to list containers for environment %d", environment.ID)
return err
}
// check for a gpu on each container. If even one GPU is found, set EnableGPUManagement to true for the whole environment
containersLoop:
for _, container := range containers {
// https://www.sobyte.net/post/2022-10/go-docker/ has nice documentation on the docker client with GPUs
containerDetails, err := dockerClient.ContainerInspect(context.Background(), container.ID)
if err != nil {
log.Error().Err(err).Msg("failed to inspect container")
continue
}
deviceRequests := containerDetails.HostConfig.Resources.DeviceRequests
for _, deviceRequest := range deviceRequests {
if deviceRequest.Driver == "nvidia" {
environment.EnableGPUManagement = true
break containersLoop
}
}
}
// set the MigrateGPUs flag to false so we don't run this again
environment.PostInitMigrations.MigrateGPUs = false
err = tx.Endpoint().UpdateEndpoint(environment.ID, environment)
if err != nil {
log.Error().Err(err).Msgf("Error updating EnableGPUManagement flag for environment %d", environment.ID)
return err
}
return nil
})
}

View File

@@ -16,7 +16,9 @@ func (tx *StoreTx) IsErrObjectNotFound(err error) bool {
func (tx *StoreTx) CustomTemplate() dataservices.CustomTemplateService { return nil }
func (tx *StoreTx) PendingActions() dataservices.PendingActionsService { return nil }
func (tx *StoreTx) PendingActions() dataservices.PendingActionsService {
return tx.store.PendingActionsService.Tx(tx.tx)
}
func (tx *StoreTx) EdgeGroup() dataservices.EdgeGroupService {
return tx.store.EdgeGroupService.Tx(tx.tx)

View File

@@ -631,6 +631,7 @@
"LogoURL": "",
"OAuthSettings": {
"AccessTokenURI": "",
"AuthStyle": 0,
"AuthorizationURI": "",
"ClientID": "",
"DefaultTeamID": 0,
@@ -677,6 +678,7 @@
"Architecture": "",
"BridgeNfIp6tables": false,
"BridgeNfIptables": false,
"CDISpecDirs": null,
"CPUSet": false,
"CPUShares": false,
"CgroupDriver": "",
@@ -939,6 +941,6 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.20.0\",\"MigratorCount\":2,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.20.3\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
}
}

View File

@@ -13,7 +13,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/crypto"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/client"
"github.com/segmentio/encoding/json"
)
@@ -93,11 +93,17 @@ func createTCPClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*cli
return nil, err
}
return client.NewClientWithOpts(
opts := []client.Opt{
client.WithHost(endpoint.URL),
client.WithAPIVersionNegotiation(),
client.WithHTTPClient(httpCli),
)
}
if nnTransport, ok := httpCli.Transport.(*NodeNameTransport); ok && nnTransport.TLSClientConfig != nil {
opts = append(opts, client.WithScheme("https"))
}
return client.NewClientWithOpts(opts...)
}
func createAgentClient(endpoint *portainer.Endpoint, endpointURL string, signatureService portainer.DigitalSignatureService, nodeName string, timeout *time.Duration) (*client.Client, error) {
@@ -159,7 +165,7 @@ func (t *NodeNameTransport) RoundTrip(req *http.Request) (*http.Response, error)
resp.Body = io.NopCloser(bytes.NewReader(body))
var rs []struct {
types.ImageSummary
image.Summary
Portainer struct {
Agent struct {
NodeName string

View File

@@ -119,7 +119,7 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
for _, network := range container.NetworkSettings.Networks {
cli.NetworkConnect(ctx, network.NetworkID, containerId, network)
}
cli.ContainerStart(ctx, containerId, types.ContainerStartOptions{})
cli.ContainerStart(ctx, containerId, dockercontainer.StartOptions{})
})
log.Debug().Str("container", strings.Split(container.Name, "/")[1]).Msg("starting to create a new container")
@@ -135,7 +135,7 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
c.sr.push(func() {
log.Debug().Str("container_id", create.ID).Msg("removing the new container")
cli.ContainerStop(ctx, create.ID, dockercontainer.StopOptions{})
cli.ContainerRemove(ctx, create.ID, types.ContainerRemoveOptions{})
cli.ContainerRemove(ctx, create.ID, dockercontainer.RemoveOptions{})
})
if err != nil {
@@ -164,14 +164,14 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
// 8. start the new container
log.Debug().Str("container_id", newContainerId).Msg("starting the new container")
err = cli.ContainerStart(ctx, newContainerId, types.ContainerStartOptions{})
err = cli.ContainerStart(ctx, newContainerId, dockercontainer.StartOptions{})
if err != nil {
return nil, errors.Wrap(err, "start container error")
}
// 9. delete the old container
log.Debug().Str("container_id", containerId).Msg("starting to remove the old container")
_ = cli.ContainerRemove(ctx, containerId, types.ContainerRemoveOptions{})
_ = cli.ContainerRemove(ctx, containerId, dockercontainer.RemoveOptions{})
c.sr.disable()

View File

@@ -7,6 +7,7 @@ import (
"time"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
portainer "github.com/portainer/portainer/api"
consts "github.com/portainer/portainer/api/docker/consts"
@@ -157,7 +158,7 @@ func (c *DigestClient) ServiceImageStatus(ctx context.Context, serviceID string,
return Error, nil
}
containers, err := cli.ContainerList(ctx, types.ContainerListOptions{
containers, err := cli.ContainerList(ctx, container.ListOptions{
All: true,
Filters: filters.NewArgs(filters.Arg("label", consts.SwarmServiceIdLabel+"="+serviceID)),
})

View File

@@ -6,6 +6,7 @@ import (
"time"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
_container "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/volume"
"github.com/docker/docker/client"
@@ -147,7 +148,7 @@ func snapshotSwarmServices(snapshot *portainer.DockerSnapshot, cli *client.Clien
}
func snapshotContainers(snapshot *portainer.DockerSnapshot, cli *client.Client) error {
containers, err := cli.ContainerList(context.Background(), types.ContainerListOptions{All: true})
containers, err := cli.ContainerList(context.Background(), container.ListOptions{All: true})
if err != nil {
return err
}

View File

@@ -934,7 +934,7 @@ func FileExists(filePath string) (bool, error) {
func (service *Service) SafeMoveDirectory(originalPath, newPath string) error {
// 1. Backup the source directory to a different folder
backupDir := fmt.Sprintf("%s-%s", filepath.Dir(originalPath), "backup")
err := MoveDirectory(originalPath, backupDir)
err := MoveDirectory(originalPath, backupDir, false)
if err != nil {
return fmt.Errorf("failed to backup source directory: %w", err)
}
@@ -973,14 +973,14 @@ func restoreBackup(src, backupDir string) error {
return fmt.Errorf("failed to delete destination directory: %w", err)
}
err = MoveDirectory(backupDir, src)
err = MoveDirectory(backupDir, src, false)
if err != nil {
return fmt.Errorf("failed to restore backup directory: %w", err)
}
return nil
}
func MoveDirectory(originalPath, newPath string) error {
func MoveDirectory(originalPath, newPath string, overwriteTargetPath bool) error {
if _, err := os.Stat(originalPath); err != nil {
return err
}
@@ -991,7 +991,13 @@ func MoveDirectory(originalPath, newPath string) error {
}
if alreadyExists {
return errors.New("Target path already exists")
if !overwriteTargetPath {
return fmt.Errorf("Target path already exists")
}
if err = os.RemoveAll(newPath); err != nil {
return fmt.Errorf("failed to overwrite path %s: %s", newPath, err.Error())
}
}
return os.Rename(originalPath, newPath)

View File

@@ -16,7 +16,7 @@ func Test_movePath_shouldFailIfSourceDirDoesNotExist(t *testing.T) {
file1 := addFile(destinationDir, "dir", "file")
file2 := addFile(destinationDir, "file")
err := MoveDirectory(sourceDir, destinationDir)
err := MoveDirectory(sourceDir, destinationDir, false)
assert.Error(t, err, "move directory should fail when source path is missing")
assert.FileExists(t, file1, "destination dir contents should remain")
assert.FileExists(t, file2, "destination dir contents should remain")
@@ -30,7 +30,7 @@ func Test_movePath_shouldFailIfDestinationDirExists(t *testing.T) {
file3 := addFile(destinationDir, "dir", "file")
file4 := addFile(destinationDir, "file")
err := MoveDirectory(sourceDir, destinationDir)
err := MoveDirectory(sourceDir, destinationDir, false)
assert.Error(t, err, "move directory should fail when destination directory already exists")
assert.FileExists(t, file1, "source dir contents should remain")
assert.FileExists(t, file2, "source dir contents should remain")
@@ -38,6 +38,22 @@ func Test_movePath_shouldFailIfDestinationDirExists(t *testing.T) {
assert.FileExists(t, file4, "destination dir contents should remain")
}
func Test_movePath_succesIfOverwriteSetWhenDestinationDirExists(t *testing.T) {
sourceDir := t.TempDir()
file1 := addFile(sourceDir, "dir", "file")
file2 := addFile(sourceDir, "file")
destinationDir := t.TempDir()
file3 := addFile(destinationDir, "dir", "file")
file4 := addFile(destinationDir, "file")
err := MoveDirectory(sourceDir, destinationDir, true)
assert.NoError(t, err)
assert.NoFileExists(t, file1, "source dir contents should be moved")
assert.NoFileExists(t, file2, "source dir contents should be moved")
assert.FileExists(t, file3, "destination dir contents should remain")
assert.FileExists(t, file4, "destination dir contents should remain")
}
func Test_movePath_successWhenSourceExistsAndDestinationIsMissing(t *testing.T) {
tmp := t.TempDir()
sourceDir := path.Join(tmp, "source")
@@ -46,7 +62,7 @@ func Test_movePath_successWhenSourceExistsAndDestinationIsMissing(t *testing.T)
file2 := addFile(sourceDir, "file")
destinationDir := path.Join(tmp, "destination")
err := MoveDirectory(sourceDir, destinationDir)
err := MoveDirectory(sourceDir, destinationDir, false)
assert.NoError(t, err)
assert.NoFileExists(t, file1, "source dir contents should be moved")
assert.NoFileExists(t, file2, "source dir contents should be moved")

View File

@@ -38,7 +38,7 @@ func CloneWithBackup(gitService portainer.GitService, fileService portainer.File
}
}
err = filesystem.MoveDirectory(options.ProjectPath, backupProjectPath)
err = filesystem.MoveDirectory(options.ProjectPath, backupProjectPath, true)
if err != nil {
return cleanFn, errors.WithMessage(err, "Unable to move git repository directory")
}
@@ -48,7 +48,7 @@ func CloneWithBackup(gitService portainer.GitService, fileService portainer.File
err = gitService.CloneRepository(options.ProjectPath, options.URL, options.ReferenceName, options.Username, options.Password, options.TLSSkipVerify)
if err != nil {
cleanUp = false
restoreError := filesystem.MoveDirectory(backupProjectPath, options.ProjectPath)
restoreError := filesystem.MoveDirectory(backupProjectPath, options.ProjectPath, false)
if restoreError != nil {
log.Warn().Err(restoreError).Msg("failed restoring backup folder")
}

View File

@@ -75,7 +75,12 @@ func (handler *Handler) authenticate(rw http.ResponseWriter, r *http.Request) *h
if settings.AuthenticationMethod == portainer.AuthenticationInternal ||
settings.AuthenticationMethod == portainer.AuthenticationOAuth ||
(settings.AuthenticationMethod == portainer.AuthenticationLDAP && !settings.LDAPSettings.AutoCreateUsers) {
return httperror.NewError(http.StatusUnprocessableEntity, "Invalid credentials", httperrors.ErrUnauthorized)
// avoid username enumeration timing attack by creating a fake user
// https://en.wikipedia.org/wiki/Timing_attack
user = &portainer.User{
Username: "portainer-fake-username",
Password: "$2a$10$abcdefghijklmnopqrstuvwx..ABCDEFGHIJKLMNOPQRSTUVWXYZ12", // fake but valid format bcrypt hash
}
}
}
@@ -112,7 +117,11 @@ func (handler *Handler) authenticateInternal(w http.ResponseWriter, user *portai
func (handler *Handler) authenticateLDAP(w http.ResponseWriter, user *portainer.User, username, password string, ldapSettings *portainer.LDAPSettings) *httperror.HandlerError {
err := handler.LDAPService.AuthenticateUser(username, password, ldapSettings)
if err != nil {
return httperror.Forbidden("Only initial admin is allowed to login without oauth", err)
if errors.Is(err, httperrors.ErrUnauthorized) {
return httperror.NewError(http.StatusUnprocessableEntity, "Invalid credentials", httperrors.ErrUnauthorized)
}
return httperror.InternalServerError("Unable to authenticate user against LDAP", err)
}
if user == nil {

View File

@@ -12,6 +12,7 @@ import (
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
)
type ImageResponse struct {
@@ -63,7 +64,9 @@ func (handler *Handler) imagesList(w http.ResponseWriter, r *http.Request) *http
imageUsageSet := set.Set[string]{}
if withUsage {
containers, err := cli.ContainerList(r.Context(), types.ContainerListOptions{})
containers, err := cli.ContainerList(r.Context(), container.ListOptions{
All: true,
})
if err != nil {
return httperror.InternalServerError("Unable to retrieve Docker containers", err)
}

View File

@@ -8,6 +8,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/internal/tag"
pendingActionActions "github.com/portainer/portainer/api/pendingactions/actions"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -159,7 +160,9 @@ func (handler *Handler) updateEndpointGroup(tx dataservices.DataStoreTx, endpoin
err := handler.PendingActionsService.Create(portainer.PendingActions{
EndpointID: endpointID,
Action: "CleanNAPWithOverridePolicies",
ActionData: endpointGroupID,
ActionData: &pendingActionActions.CleanNAPWithOverridePoliciesPayload{
EndpointGroupID: endpointGroupID,
},
})
if err != nil {
log.Error().Err(err).Msgf("Unable to create pending action to clean NAP with override policies for endpoint (%d) and endpoint group (%d).", endpointID, endpointGroupID)

View File

@@ -179,6 +179,12 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
}
}
// delete the 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))
if err != nil {
return httperror.InternalServerError("Unable to delete the environment from the database", err)

View File

@@ -21,7 +21,8 @@ func TestEndpointDeleteEdgeGroupsConcurrently(t *testing.T) {
handler := NewHandler(testhelpers.NewTestRequestBouncer(), demo.NewService())
handler.DataStore = store
handler.ProxyManager = proxy.NewManager(nil, nil, nil, nil, nil, nil, nil)
handler.ProxyManager = proxy.NewManager(nil)
handler.ProxyManager.NewProxyFactory(nil, nil, nil, nil, nil, nil, nil, nil)
// Create all the environments and add them to the same edge group

View File

@@ -12,8 +12,8 @@ import (
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/docker/docker/api/types"
dockertypes "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
)
@@ -39,7 +39,7 @@ func (payload *forceUpdateServicePayload) Validate(r *http.Request) error {
// @produce json
// @param id path int true "endpoint identifier"
// @param body body forceUpdateServicePayload true "details"
// @success 200 {object} dockertypes.ServiceUpdateResponse "Success"
// @success 200 {object} swarm.ServiceUpdateResponse "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied"
// @failure 404 "endpoint not found"
@@ -94,7 +94,7 @@ func (handler *Handler) endpointForceUpdateService(w http.ResponseWriter, r *htt
go func() {
images.EvictImageStatus(payload.ServiceID)
images.EvictImageStatus(service.Spec.Labels[consts.SwarmStackNameLabel])
containers, _ := dockerClient.ContainerList(context.TODO(), types.ContainerListOptions{
containers, _ := dockerClient.ContainerList(context.TODO(), container.ListOptions{
All: true,
Filters: filters.NewArgs(filters.Arg("label", consts.SwarmServiceIdLabel+"="+payload.ServiceID)),
})

View File

@@ -622,6 +622,7 @@ func getEdgeStackStatusParam(r *http.Request) (*portainer.EdgeStackStatusType, e
portainer.EdgeStackStatusRunning,
portainer.EdgeStackStatusDeploying,
portainer.EdgeStackStatusRemoving,
portainer.EdgeStackStatusCompleted,
}, edgeStackStatus) {
return nil, errors.New("invalid edgeStackStatus parameter")
}

View File

@@ -85,7 +85,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.20.0
// @version 2.20.3
// @description.markdown api-description.md
// @termsOfService

View File

@@ -61,8 +61,7 @@ func (handler *Handler) helmInstall(w http.ResponseWriter, r *http.Request) *htt
return httperror.InternalServerError("Unable to install a chart", err)
}
w.WriteHeader(http.StatusCreated)
return response.JSON(w, release)
return response.JSONWithStatus(w, release, http.StatusCreated)
}
func (p *installChartPayload) Validate(_ *http.Request) error {

View File

@@ -155,7 +155,7 @@ func pullImage(ctx context.Context, docker *client.Client, imageName string) err
// runContainer should be used to run a short command that returns information to stdout
// TODO: add k8s support
func runContainer(ctx context.Context, docker *client.Client, imageName, containerName string, cmdLine []string) (output string, err error) {
opts := types.ContainerListOptions{All: true}
opts := container.ListOptions{All: true}
opts.Filters = filters.NewArgs()
opts.Filters.Add("name", containerName)
existingContainers, err := docker.ContainerList(ctx, opts)
@@ -170,7 +170,7 @@ func runContainer(ctx context.Context, docker *client.Client, imageName, contain
}
if len(existingContainers) > 0 {
err = docker.ContainerRemove(ctx, existingContainers[0].ID, types.ContainerRemoveOptions{Force: true})
err = docker.ContainerRemove(ctx, existingContainers[0].ID, container.RemoveOptions{Force: true})
if err != nil {
log.Error().
Str("image_name", imageName).
@@ -211,7 +211,7 @@ func runContainer(ctx context.Context, docker *client.Client, imageName, contain
return "", err
}
err = docker.ContainerStart(ctx, created.ID, types.ContainerStartOptions{})
err = docker.ContainerStart(ctx, created.ID, container.StartOptions{})
if err != nil {
log.Error().
Str("image_name", imageName).
@@ -243,14 +243,14 @@ func runContainer(ctx context.Context, docker *client.Client, imageName, contain
log.Debug().Int64("status", statusCode).Msg("container wait status")
out, err := docker.ContainerLogs(ctx, created.ID, types.ContainerLogsOptions{ShowStdout: true})
out, err := docker.ContainerLogs(ctx, created.ID, container.LogsOptions{ShowStdout: true})
if err != nil {
log.Error().Err(err).Str("image_name", imageName).Str("container_name", containerName).Msg("getting container log")
return "", err
}
err = docker.ContainerRemove(ctx, created.ID, types.ContainerRemoveOptions{})
err = docker.ContainerRemove(ctx, created.ID, container.RemoveOptions{})
if err != nil {
log.Error().
Str("image_name", imageName).

View File

@@ -8,6 +8,7 @@ import (
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/pendingactions"
"github.com/portainer/portainer/api/pendingactions/actions"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -91,7 +92,7 @@ func (handler *Handler) deleteKubernetesSecrets(registry *portainer.Registry) er
if len(failedNamespaces) > 0 {
handler.PendingActionsService.Create(portainer.PendingActions{
EndpointID: endpointId,
Action: pendingactions.DeletePortainerK8sRegistrySecrets,
Action: actions.DeletePortainerK8sRegistrySecrets,
// When extracting the data, this is the type we need to pull out
// i.e. pendingactions.DeletePortainerK8sRegistrySecretsData

View File

@@ -13,6 +13,7 @@ import (
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"golang.org/x/oauth2"
"github.com/asaskevich/govalidator"
"github.com/pkg/errors"
@@ -95,6 +96,11 @@ func (payload *settingsUpdatePayload) Validate(r *http.Request) error {
}
}
if payload.OAuthSettings != nil {
if payload.OAuthSettings.AuthStyle < oauth2.AuthStyleAutoDetect || payload.OAuthSettings.AuthStyle > oauth2.AuthStyleInHeader {
return errors.New("Invalid OAuth AuthStyle")
}
}
return nil
}
@@ -225,6 +231,7 @@ func (handler *Handler) updateSettings(tx dataservices.DataStoreTx, payload sett
settings.OAuthSettings = *payload.OAuthSettings
settings.OAuthSettings.ClientSecret = clientSecret
settings.OAuthSettings.KubeSecretKey = kubeSecret
settings.OAuthSettings.AuthStyle = payload.OAuthSettings.AuthStyle
}
if payload.EnableEdgeComputeFeatures != nil {

View File

@@ -21,6 +21,7 @@ import (
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/gorilla/mux"
"github.com/pkg/errors"
)
@@ -190,7 +191,7 @@ func (handler *Handler) checkUniqueStackNameInDocker(endpoint *portainer.Endpoin
}
}
containers, err := dockerClient.ContainerList(context.Background(), types.ContainerListOptions{All: true})
containers, err := dockerClient.ContainerList(context.Background(), container.ListOptions{All: true})
if err != nil {
return false, err
}

View File

@@ -92,25 +92,55 @@ func (handler *Handler) stackList(w http.ResponseWriter, r *http.Request) *httpe
return response.JSON(w, stacks)
}
// filterStacks refines a collection of Stack instances using specified criteria.
// This function examines the provided filters: EndpointID, SwarmID, and IncludeOrphanedStacks.
// - If both EndpointID is zero and SwarmID is an empty string, the function directly returns the original stack list without any modifications.
// - If either filter is specified, it proceeds to selectively include stacks that match the criteria.
// Key Points on Business Logic:
// 1. Determining Inclusion of Orphaned Stacks:
// - The decision to include orphaned stacks is influenced by the user's role and usually set by the client (UI).
// - Administrators or environment administrators can include orphaned stacks by setting IncludeOrphanedStacks to true, reflecting their broader access rights.
// - For non-administrative users, this is typically set to false, limiting their visibility to only stacks within their purview.
// 2. Inclusion Criteria for Orphaned Stacks:
// - When IncludeOrphanedStacks is true and an EndpointID is specified (not zero), the function selects:
// a) Stacks linked to the specified EndpointID.
// b) Orphaned stacks that don't have a naming conflict with any stack associated with the EndpointID.
// - This approach is designed to avoid name conflicts within Docker Compose, which restricts the creation of multiple stacks with the same name.
// 3. Type Matching for Orphaned Stacks:
// - The function ensures that orphaned stacks are compatible with the environment's stack type (compose or swarm).
// - It filters out orphaned swarm stacks in Docker standalone environments
// - It filters out orphaned standalone stack in Docker swarm environments
// - This ensures that re-association respects the constraints of the environment and stack type.
// The outcome is a new list of stacks that align with these filtering and business logic criteria.
func filterStacks(stacks []portainer.Stack, filters *stackListOperationFilters, endpoints []portainer.Endpoint) []portainer.Stack {
if filters.EndpointID == 0 && filters.SwarmID == "" {
return stacks
}
filteredStacks := make([]portainer.Stack, 0, len(stacks))
uniqueStackNames := make(map[string]struct{})
for _, stack := range stacks {
if filters.IncludeOrphanedStacks && isOrphanedStack(stack, endpoints) {
if (stack.Type == portainer.DockerComposeStack && filters.SwarmID == "") || (stack.Type == portainer.DockerSwarmStack && filters.SwarmID != "") {
filteredStacks = append(filteredStacks, stack)
}
continue
}
if stack.Type == portainer.DockerComposeStack && stack.EndpointID == portainer.EndpointID(filters.EndpointID) {
filteredStacks = append(filteredStacks, stack)
uniqueStackNames[stack.Name] = struct{}{}
}
if stack.Type == portainer.DockerSwarmStack && stack.SwarmID == filters.SwarmID {
filteredStacks = append(filteredStacks, stack)
uniqueStackNames[stack.Name] = struct{}{}
}
}
for _, stack := range stacks {
if filters.IncludeOrphanedStacks && isOrphanedStack(stack, endpoints) {
if (stack.Type == portainer.DockerComposeStack && filters.SwarmID == "") || (stack.Type == portainer.DockerSwarmStack && filters.SwarmID != "") {
if _, exists := uniqueStackNames[stack.Name]; !exists {
filteredStacks = append(filteredStacks, stack)
}
}
}
}

View File

@@ -0,0 +1,74 @@
package stacks
import (
"sort"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/assert"
)
func TestFilterStacks(t *testing.T) {
t.Run("filter stacks against particular endpoint and all orphaned stacks", func(t *testing.T) {
stacks := []portainer.Stack{
{ID: 1, EndpointID: 3, Name: "normal_stack", Type: portainer.DockerComposeStack},
{ID: 2, EndpointID: 4, Name: "orphaned_stack", Type: portainer.DockerComposeStack},
{ID: 3, EndpointID: 5, Name: "other_stack", Type: portainer.DockerComposeStack},
}
filters := &stackListOperationFilters{EndpointID: 3, IncludeOrphanedStacks: true}
endpoints := []portainer.Endpoint{{ID: 3}, {ID: 5}}
expectStacks := []portainer.Stack{{ID: 1}, {ID: 2}}
actualStacks := filterStacks(stacks, filters, endpoints)
isEqualStacks(t, expectStacks, actualStacks)
})
t.Run("filter unique stacks against particular endpoint and all orphaned stacks and an orphaned stack has the same name with normal stack", func(t *testing.T) {
stacks := []portainer.Stack{
{ID: 1, EndpointID: 3, Name: "normal_stack", Type: portainer.DockerComposeStack},
{ID: 2, EndpointID: 4, Name: "orphaned_stack", Type: portainer.DockerComposeStack},
{ID: 3, EndpointID: 5, Name: "other_stack", Type: portainer.DockerComposeStack},
{ID: 4, EndpointID: 4, Name: "normal_stack", Type: portainer.DockerComposeStack},
}
filters := &stackListOperationFilters{EndpointID: 3, IncludeOrphanedStacks: true}
endpoints := []portainer.Endpoint{{ID: 3}, {ID: 5}}
expectStacks := []portainer.Stack{{ID: 1}, {ID: 2}}
actualStacks := filterStacks(stacks, filters, endpoints)
isEqualStacks(t, expectStacks, actualStacks)
})
t.Run("only filter stacks against particular endpoint and no orphaned stacks", func(t *testing.T) {
stacks := []portainer.Stack{
{ID: 1, EndpointID: 3, Name: "normal_stack", Type: portainer.DockerComposeStack},
{ID: 2, EndpointID: 4, Name: "orphaned_stack", Type: portainer.DockerComposeStack},
{ID: 3, EndpointID: 5, Name: "other_stack", Type: portainer.DockerComposeStack},
{ID: 4, EndpointID: 4, Name: "normal_stack", Type: portainer.DockerComposeStack},
}
filters := &stackListOperationFilters{EndpointID: 3, IncludeOrphanedStacks: false}
endpoints := []portainer.Endpoint{{ID: 3}, {ID: 5}}
expectStacks := []portainer.Stack{{ID: 1}}
actualStacks := filterStacks(stacks, filters, endpoints)
isEqualStacks(t, expectStacks, actualStacks)
})
}
func isEqualStacks(t *testing.T, expectStacks, actualStacks []portainer.Stack) {
expectStackIDs := make([]int, len(expectStacks))
for i, stack := range expectStacks {
expectStackIDs[i] = int(stack.ID)
}
sort.Ints(expectStackIDs)
actualStackIDs := make([]int, len(actualStacks))
for i, stack := range actualStacks {
actualStackIDs[i] = int(stack.ID)
}
sort.Ints(actualStackIDs)
assert.Equal(t, expectStackIDs, actualStackIDs)
}

View File

@@ -2,6 +2,7 @@ package users
import (
"errors"
"fmt"
"net/http"
portainer "github.com/portainer/portainer/api"
@@ -20,9 +21,6 @@ type userAccessTokenCreatePayload struct {
}
func (payload *userAccessTokenCreatePayload) Validate(r *http.Request) error {
if govalidator.IsNull(payload.Password) {
return errors.New("invalid password: cannot be empty")
}
if govalidator.IsNull(payload.Description) {
return errors.New("invalid description: cannot be empty")
}
@@ -44,6 +42,7 @@ type accessTokenResponse struct {
// @summary Generate an API key for a user
// @description Generates an API key for a user.
// @description Only the calling user can generate a token for themselves.
// @description Password is required only for internal authentication.
// @description **Access policy**: restricted
// @tags users
// @security jwt
@@ -51,7 +50,7 @@ type accessTokenResponse struct {
// @produce json
// @param id path int true "User identifier"
// @param body body userAccessTokenCreatePayload true "details"
// @success 201 {object} accessTokenResponse "Created"
// @success 200 {object} accessTokenResponse "Created"
// @failure 400 "Invalid request"
// @failure 401 "Unauthorized"
// @failure 403 "Permission denied"
@@ -94,9 +93,21 @@ func (handler *Handler) userCreateAccessToken(w http.ResponseWriter, r *http.Req
return httperror.InternalServerError("Unable to find a user with the specified identifier inside the database", err)
}
err = handler.CryptoService.CompareHashAndData(user.Password, payload.Password)
internalAuth, err := handler.usesInternalAuthentication(portainer.UserID(userID))
if err != nil {
return httperror.Forbidden("Current password doesn't match", errors.New("Current password does not match the password provided. Please try again"))
return httperror.InternalServerError("Unable to determine the authentication method", err)
}
if internalAuth {
// Internal auth requires the password field and must not be empty
if govalidator.IsNull(payload.Password) {
return httperror.BadRequest("Invalid request payload", errors.New("invalid password: cannot be empty"))
}
err = handler.CryptoService.CompareHashAndData(user.Password, payload.Password)
if err != nil {
return httperror.Forbidden("Current password doesn't match", errors.New("Current password does not match the password provided. Please try again"))
}
}
rawAPIKey, apiKey, err := handler.apiKeyService.GenerateApiKey(*user, payload.Description)
@@ -104,6 +115,20 @@ func (handler *Handler) userCreateAccessToken(w http.ResponseWriter, r *http.Req
return httperror.InternalServerError("Internal Server Error", err)
}
w.WriteHeader(http.StatusCreated)
return response.JSON(w, accessTokenResponse{rawAPIKey, *apiKey})
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 {
return true, nil
}
// otherwise determine the auth method from the settings
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return false, fmt.Errorf("unable to retrieve the settings from the database: %w", err)
}
return settings.AuthenticationMethod == portainer.AuthenticationInternal, nil
}

View File

@@ -65,7 +65,7 @@ func (factory *ProxyFactory) newDockerHTTPProxy(endpoint *portainer.Endpoint) (h
DockerClientFactory: factory.dockerClientFactory,
}
dockerTransport, err := docker.NewTransport(transportParameters, httpTransport, factory.gitService)
dockerTransport, err := docker.NewTransport(transportParameters, httpTransport, factory.gitService, factory.snapshotService)
if err != nil {
return nil, err
}

View File

@@ -36,6 +36,7 @@ type (
reverseTunnelService portainer.ReverseTunnelService
dockerClientFactory *dockerclient.ClientFactory
gitService portainer.GitService
snapshotService portainer.SnapshotService
}
// TransportParameters is used to create a new Transport
@@ -63,7 +64,7 @@ type (
)
// NewTransport returns a pointer to a new Transport instance.
func NewTransport(parameters *TransportParameters, httpTransport *http.Transport, gitService portainer.GitService) (*Transport, error) {
func NewTransport(parameters *TransportParameters, httpTransport *http.Transport, gitService portainer.GitService, snapshotService portainer.SnapshotService) (*Transport, error) {
transport := &Transport{
endpoint: parameters.Endpoint,
dataStore: parameters.DataStore,
@@ -72,6 +73,7 @@ func NewTransport(parameters *TransportParameters, httpTransport *http.Transport
dockerClientFactory: parameters.DockerClientFactory,
HTTPTransport: httpTransport,
gitService: gitService,
snapshotService: snapshotService,
}
return transport, nil

View File

@@ -8,6 +8,7 @@ import (
"path"
"github.com/docker/docker/client"
"github.com/rs/zerolog/log"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/utils"
@@ -48,6 +49,14 @@ func (transport *Transport) volumeListOperation(response *http.Response, executo
if responseObject["Volumes"] != nil {
volumeData := responseObject["Volumes"].([]interface{})
if transport.snapshotService != nil {
// Filling snapshot data can improve the performance of getVolumeResourceID
if err = transport.snapshotService.FillSnapshotData(transport.endpoint); err != nil {
log.Info().Err(err).
Int("endpoint id", int(transport.endpoint.ID)).
Msg("snapshot is not filled into the endpoint.")
}
}
for _, volumeObject := range volumeData {
volume := volumeObject.(map[string]interface{})

View File

@@ -22,7 +22,7 @@ func (factory ProxyFactory) newOSBasedLocalProxy(path string, endpoint *portaine
proxy := &dockerLocalProxy{}
dockerTransport, err := docker.NewTransport(transportParameters, newSocketTransport(path), factory.gitService)
dockerTransport, err := docker.NewTransport(transportParameters, newSocketTransport(path), factory.gitService, factory.snapshotService)
if err != nil {
return nil, err
}

View File

@@ -23,7 +23,7 @@ func (factory ProxyFactory) newOSBasedLocalProxy(path string, endpoint *portaine
proxy := &dockerLocalProxy{}
dockerTransport, err := docker.NewTransport(transportParameters, newNamedPipeTransport(path), factory.gitService)
dockerTransport, err := docker.NewTransport(transportParameters, newNamedPipeTransport(path), factory.gitService, factory.snapshotService)
if err != nil {
return nil, err
}

View File

@@ -23,11 +23,12 @@ type (
kubernetesClientFactory *cli.ClientFactory
kubernetesTokenCacheManager *kubernetes.TokenCacheManager
gitService portainer.GitService
snapshotService portainer.SnapshotService
}
)
// NewProxyFactory returns a pointer to a new instance of a ProxyFactory
func NewProxyFactory(dataStore dataservices.DataStore, signatureService portainer.DigitalSignatureService, tunnelService portainer.ReverseTunnelService, clientFactory *dockerclient.ClientFactory, kubernetesClientFactory *cli.ClientFactory, kubernetesTokenCacheManager *kubernetes.TokenCacheManager, gitService portainer.GitService) *ProxyFactory {
func NewProxyFactory(dataStore dataservices.DataStore, signatureService portainer.DigitalSignatureService, tunnelService portainer.ReverseTunnelService, clientFactory *dockerclient.ClientFactory, kubernetesClientFactory *cli.ClientFactory, kubernetesTokenCacheManager *kubernetes.TokenCacheManager, gitService portainer.GitService, snapshotService portainer.SnapshotService) *ProxyFactory {
return &ProxyFactory{
dataStore: dataStore,
signatureService: signatureService,
@@ -36,6 +37,7 @@ func NewProxyFactory(dataStore dataservices.DataStore, signatureService portaine
kubernetesClientFactory: kubernetesClientFactory,
kubernetesTokenCacheManager: kubernetesTokenCacheManager,
gitService: gitService,
snapshotService: snapshotService,
}
}

View File

@@ -25,17 +25,24 @@ type (
)
// NewManager initializes a new proxy Service
func NewManager(dataStore dataservices.DataStore, signatureService portainer.DigitalSignatureService, tunnelService portainer.ReverseTunnelService, clientFactory *dockerclient.ClientFactory, kubernetesClientFactory *cli.ClientFactory, kubernetesTokenCacheManager *kubernetes.TokenCacheManager, gitService portainer.GitService) *Manager {
func NewManager(kubernetesClientFactory *cli.ClientFactory) *Manager {
return &Manager{
endpointProxies: cmap.New(),
k8sClientFactory: kubernetesClientFactory,
proxyFactory: factory.NewProxyFactory(dataStore, signatureService, tunnelService, clientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService),
}
}
func (manager *Manager) NewProxyFactory(dataStore dataservices.DataStore, signatureService portainer.DigitalSignatureService, tunnelService portainer.ReverseTunnelService, clientFactory *dockerclient.ClientFactory, kubernetesClientFactory *cli.ClientFactory, kubernetesTokenCacheManager *kubernetes.TokenCacheManager, gitService portainer.GitService, snapshotService portainer.SnapshotService) {
manager.proxyFactory = factory.NewProxyFactory(dataStore, signatureService, tunnelService, clientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService, snapshotService)
}
// CreateAndRegisterEndpointProxy creates a new HTTP reverse proxy based on environment(endpoint) properties and and adds it to the registered proxies.
// It can also be used to create a new HTTP reverse proxy and replace an already registered proxy.
func (manager *Manager) CreateAndRegisterEndpointProxy(endpoint *portainer.Endpoint) (http.Handler, error) {
if manager.proxyFactory == nil {
return nil, fmt.Errorf("proxy factory not init")
}
proxy, err := manager.proxyFactory.NewEndpointProxy(endpoint)
if err != nil {
return nil, err
@@ -48,6 +55,9 @@ func (manager *Manager) CreateAndRegisterEndpointProxy(endpoint *portainer.Endpo
// CreateAgentProxyServer creates a new HTTP reverse proxy based on environment(endpoint) properties and and adds it to the registered proxies.
// It can also be used to create a new HTTP reverse proxy and replace an already registered proxy.
func (manager *Manager) CreateAgentProxyServer(endpoint *portainer.Endpoint) (*factory.ProxyServer, error) {
if manager.proxyFactory == nil {
return nil, fmt.Errorf("proxy factory not init")
}
return manager.proxyFactory.NewAgentProxy(endpoint)
}
@@ -74,5 +84,8 @@ func (manager *Manager) DeleteEndpointProxy(endpointID portainer.EndpointID) {
// CreateGitlabProxy creates a new HTTP reverse proxy that can be used to send requests to the Gitlab API
func (manager *Manager) CreateGitlabProxy(url string) (http.Handler, error) {
if manager.proxyFactory == nil {
return nil, fmt.Errorf("proxy factory not init")
}
return manager.proxyFactory.NewGitlabProxy(url)
}

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"
@@ -382,7 +381,8 @@ 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("", "")
}

View File

@@ -10,6 +10,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/rs/zerolog/log"
"github.com/patrickmn/go-cache"
"github.com/pkg/errors"
@@ -80,22 +81,31 @@ func (factory *ClientFactory) RemoveKubeClient(endpointID portainer.EndpointID)
// GetKubeClient checks if an existing client is already registered for the environment(endpoint) and returns it if one is found.
// If no client is registered, it will create a new client, register it, and returns it.
func (factory *ClientFactory) GetKubeClient(endpoint *portainer.Endpoint) (*KubeClient, error) {
factory.mu.Lock()
key := strconv.Itoa(int(endpoint.ID))
if client, ok := factory.endpointClients[key]; ok {
factory.mu.Unlock()
return client, nil
}
factory.mu.Unlock()
// EE-6901: Do not lock
client, err := factory.createCachedAdminKubeClient(endpoint)
if err != nil {
return nil, err
}
factory.mu.Lock()
defer factory.mu.Unlock()
key := strconv.Itoa(int(endpoint.ID))
client, ok := factory.endpointClients[key]
if !ok {
var err error
client, err = factory.createCachedAdminKubeClient(endpoint)
if err != nil {
return nil, err
}
factory.endpointClients[key] = client
// The lock was released before the client was created,
// so we need to check again
if c, ok := factory.endpointClients[key]; ok {
return c, nil
}
factory.endpointClients[key] = client
return client, nil
}
@@ -277,106 +287,111 @@ func buildLocalConfig() (*rest.Config, error) {
return config, nil
}
func (factory *ClientFactory) MigrateEndpointIngresses(e *portainer.Endpoint) error {
// classes is a list of controllers which have been manually added to the
// cluster setup view. These need to all be allowed globally, but then
// blocked in specific namespaces which they were not previously allowed in.
classes := e.Kubernetes.Configuration.IngressClasses
// We need a kube client to gather namespace level permissions. In pre-2.16
// versions of portainer, the namespace level permissions were stored by
// creating an actual ingress rule in the cluster with a particular
// annotation indicating that it's name (the class name) should be allowed.
cli, err := factory.GetKubeClient(e)
if err != nil {
return err
}
detected, err := cli.GetIngressControllers()
if err != nil {
return err
}
// newControllers is a set of all currently detected controllers.
newControllers := make(map[string]struct{})
for _, controller := range detected {
newControllers[controller.ClassName] = struct{}{}
}
namespaces, err := cli.GetNamespaces()
if err != nil {
return err
}
// Set of namespaces, if any, in which "allow none" should be true.
allow := make(map[string]map[string]struct{})
for _, c := range classes {
allow[c.Name] = make(map[string]struct{})
}
allow["none"] = make(map[string]struct{})
for namespace := range namespaces {
// Compare old annotations with currently detected controllers.
ingresses, err := cli.GetIngresses(namespace)
func (factory *ClientFactory) MigrateEndpointIngresses(e *portainer.Endpoint, datastore dataservices.DataStore, cli *KubeClient) error {
return datastore.UpdateTx(func(tx dataservices.DataStoreTx) error {
environment, err := tx.Endpoint().Endpoint(e.ID)
if err != nil {
return fmt.Errorf("failure getting ingresses during migration")
log.Error().Err(err).Msgf("Error retrieving environment %d", e.ID)
return err
}
for _, ingress := range ingresses {
oldController, ok := ingress.Annotations["ingress.portainer.io/ingress-type"]
if !ok {
// Skip rules without our old annotation.
continue
}
if _, ok := newControllers[oldController]; ok {
// Skip rules which match a detected controller.
// TODO: Allow this particular controller.
allow[oldController][ingress.Namespace] = struct{}{}
continue
}
// classes is a list of controllers which have been manually added to the
// cluster setup view. These need to all be allowed globally, but then
// blocked in specific namespaces which they were not previously allowed in.
classes := environment.Kubernetes.Configuration.IngressClasses
allow["none"][ingress.Namespace] = struct{}{}
// In pre-2.16 versions of portainer, the namespace level permissions were stored by
// creating an actual ingress rule in the cluster with a particular
// annotation indicating that it's name (the class name) should be allowed.
detected, err := cli.GetIngressControllers()
if err != nil {
log.Error().Err(err).Msgf("Error getting ingress controllers in environment %d", environment.ID)
return err
}
}
// Locally, disable "allow none" for namespaces not inside shouldAllowNone.
var newClasses []portainer.KubernetesIngressClassConfig
for _, c := range classes {
var blocked []string
// newControllers is a set of all currently detected controllers.
newControllers := make(map[string]struct{})
for _, controller := range detected {
newControllers[controller.ClassName] = struct{}{}
}
namespaces, err := cli.GetNamespaces()
if err != nil {
log.Error().Err(err).Msgf("Error getting namespaces in environment %d", environment.ID)
return err
}
// Set of namespaces, if any, in which "allow none" should be true.
allow := make(map[string]map[string]struct{})
for _, c := range classes {
allow[c.Name] = make(map[string]struct{})
}
allow["none"] = make(map[string]struct{})
for namespace := range namespaces {
if _, ok := allow[c.Name][namespace]; ok {
continue
// Compare old annotations with currently detected controllers.
ingresses, err := cli.GetIngresses(namespace)
if err != nil {
log.Error().Err(err).Msgf("Error getting ingresses in environment %d", environment.ID)
return err
}
for _, ingress := range ingresses {
oldController, ok := ingress.Annotations["ingress.portainer.io/ingress-type"]
if !ok {
// Skip rules without our old annotation.
continue
}
if _, ok := newControllers[oldController]; ok {
// Skip rules which match a detected controller.
// TODO: Allow this particular controller.
allow[oldController][ingress.Namespace] = struct{}{}
continue
}
allow["none"][ingress.Namespace] = struct{}{}
}
blocked = append(blocked, namespace)
}
newClasses = append(newClasses, portainer.KubernetesIngressClassConfig{
Name: c.Name,
Type: c.Type,
GloballyBlocked: false,
BlockedNamespaces: blocked,
})
}
// Handle "none".
if len(allow["none"]) != 0 {
e.Kubernetes.Configuration.AllowNoneIngressClass = true
var disallowNone []string
for namespace := range namespaces {
if _, ok := allow["none"][namespace]; ok {
continue
// Locally, disable "allow none" for namespaces not inside shouldAllowNone.
var newClasses []portainer.KubernetesIngressClassConfig
for _, c := range classes {
var blocked []string
for namespace := range namespaces {
if _, ok := allow[c.Name][namespace]; ok {
continue
}
blocked = append(blocked, namespace)
}
disallowNone = append(disallowNone, namespace)
}
newClasses = append(newClasses, portainer.KubernetesIngressClassConfig{
Name: "none",
Type: "custom",
GloballyBlocked: false,
BlockedNamespaces: disallowNone,
})
}
e.Kubernetes.Configuration.IngressClasses = newClasses
e.PostInitMigrations.MigrateIngresses = false
return factory.dataStore.Endpoint().UpdateEndpoint(e.ID, e)
newClasses = append(newClasses, portainer.KubernetesIngressClassConfig{
Name: c.Name,
Type: c.Type,
GloballyBlocked: false,
BlockedNamespaces: blocked,
})
}
// Handle "none".
if len(allow["none"]) != 0 {
environment.Kubernetes.Configuration.AllowNoneIngressClass = true
var disallowNone []string
for namespace := range namespaces {
if _, ok := allow["none"][namespace]; ok {
continue
}
disallowNone = append(disallowNone, namespace)
}
newClasses = append(newClasses, portainer.KubernetesIngressClassConfig{
Name: "none",
Type: "custom",
GloballyBlocked: false,
BlockedNamespaces: disallowNone,
})
}
environment.Kubernetes.Configuration.IngressClasses = newClasses
environment.PostInitMigrations.MigrateIngresses = false
return tx.Endpoint().UpdateEndpoint(environment.ID, environment)
})
}

View File

@@ -125,12 +125,27 @@ func GetNamespace(manifestYaml []byte) (string, error) {
return "", errors.Wrap(err, "failed to unmarshal yaml manifest when obtaining namespace")
}
if _, ok := m["metadata"]; ok {
if namespace, ok := m["metadata"].(map[string]interface{})["namespace"]; ok {
return namespace.(string), nil
}
kind, ok := m["kind"].(string)
if !ok {
return "", errors.New("invalid kubernetes manifest, missing 'kind' field")
}
if _, ok := m["metadata"]; ok {
var namespace interface{}
var ok bool
if strings.EqualFold(kind, "namespace") {
namespace, ok = m["metadata"].(map[string]interface{})["name"]
} else {
namespace, ok = m["metadata"].(map[string]interface{})["namespace"]
}
if ok {
if v, ok := namespace.(string); ok {
return v, nil
}
return "", errors.New("invalid kubernetes manifest, 'namespace' field is not a string")
}
}
return "", nil
}

View File

@@ -648,7 +648,7 @@ func Test_GetNamespace(t *testing.T) {
input: `apiVersion: v1
kind: Namespace
metadata:
namespace: test-namespace
name: test-namespace
`,
want: "test-namespace",
},

View File

@@ -75,7 +75,14 @@ func (*Service) AuthenticateUser(username, password string, settings *portainer.
userDN, err := searchUser(username, connection, settings.SearchSettings)
if err != nil {
return err
if errors.Is(err, errUserNotFound) {
// prevent user enumeration timing attack by attempting the bind with a fake user
// and whatever password was provided should definately fail
// https://en.wikipedia.org/wiki/Timing_attack
userDN = "portainer-fake-ldap-username"
} else {
return err
}
}
err = connection.Bind(userDN, password)

View File

@@ -172,8 +172,9 @@ func getResource(token string, configuration *portainer.OAuthSettings) (map[stri
func buildConfig(configuration *portainer.OAuthSettings) *oauth2.Config {
endpoint := oauth2.Endpoint{
AuthURL: configuration.AuthorizationURI,
TokenURL: configuration.AccessTokenURI,
AuthURL: configuration.AuthorizationURI,
TokenURL: configuration.AccessTokenURI,
AuthStyle: configuration.AuthStyle,
}
return &oauth2.Config{

View File

@@ -0,0 +1,7 @@
package actions
const (
CleanNAPWithOverridePolicies = "CleanNAPWithOverridePolicies"
DeletePortainerK8sRegistrySecrets = "DeletePortainerK8sRegistrySecrets"
PostInitMigrateEnvironment = "PostInitMigrateEnvironment"
)

View File

@@ -0,0 +1,44 @@
package actions
import (
"fmt"
portainer "github.com/portainer/portainer/api"
)
type (
CleanNAPWithOverridePoliciesPayload struct {
EndpointGroupID portainer.EndpointGroupID
}
)
func ConvertCleanNAPWithOverridePoliciesPayload(actionData interface{}) (*CleanNAPWithOverridePoliciesPayload, error) {
var payload CleanNAPWithOverridePoliciesPayload
if actionData == nil {
return nil, nil
}
// backward compatible with old data format
if endpointGroupId, ok := actionData.(float64); ok {
payload.EndpointGroupID = portainer.EndpointGroupID(endpointGroupId)
return &payload, nil
}
data, ok := actionData.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("failed to convert actionData to map[string]interface{}")
}
for key, value := range data {
switch key {
case "EndpointGroupID":
if endpointGroupID, ok := value.(float64); ok {
payload.EndpointGroupID = portainer.EndpointGroupID(endpointGroupID)
}
}
}
return &payload, nil
}

View File

@@ -17,7 +17,7 @@ func (service *PendingActionsService) DeleteKubernetesRegistrySecrets(endpoint *
return nil
}
kubeClient, err := service.clientFactory.GetKubeClient(endpoint)
kubeClient, err := service.kubeFactory.GetKubeClient(endpoint)
if err != nil {
return err
}

View File

@@ -7,22 +7,24 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore/postinit"
dockerClient "github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/endpointutils"
kubecli "github.com/portainer/portainer/api/kubernetes/cli"
"github.com/portainer/portainer/api/pendingactions/actions"
"github.com/rs/zerolog/log"
)
const (
CleanNAPWithOverridePolicies = "CleanNAPWithOverridePolicies"
DeletePortainerK8sRegistrySecrets = "DeletePortainerK8sRegistrySecrets"
)
type (
PendingActionsService struct {
authorizationService *authorization.Service
clientFactory *kubecli.ClientFactory
kubeFactory *kubecli.ClientFactory
dockerFactory *dockerClient.ClientFactory
dataStore dataservices.DataStore
shutdownCtx context.Context
assetsPath string
kubernetesDeployer portainer.KubernetesDeployer
mu sync.Mutex
}
@@ -30,15 +32,21 @@ type (
func NewService(
dataStore dataservices.DataStore,
clientFactory *kubecli.ClientFactory,
kubeFactory *kubecli.ClientFactory,
dockerFactory *dockerClient.ClientFactory,
authorizationService *authorization.Service,
shutdownCtx context.Context,
assetsPath string,
kubernetesDeployer portainer.KubernetesDeployer,
) *PendingActionsService {
return &PendingActionsService{
dataStore: dataStore,
shutdownCtx: shutdownCtx,
authorizationService: authorizationService,
clientFactory: clientFactory,
kubeFactory: kubeFactory,
dockerFactory: dockerFactory,
assetsPath: assetsPath,
kubernetesDeployer: kubernetesDeployer,
mu: sync.Mutex{},
}
}
@@ -57,9 +65,22 @@ func (service *PendingActionsService) Execute(id portainer.EndpointID) error {
return fmt.Errorf("failed to retrieve environment %d: %w", id, err)
}
if endpoint.Status != portainer.EndpointStatusUp {
isKubernetesEndpoint := endpointutils.IsKubernetesEndpoint(endpoint) && !endpointutils.IsEdgeEndpoint(endpoint)
// EndpointStatusUp is only relevant for non-Kubernetes endpoints
// Sometimes the endpoint is UP but the status is not updated in the database
if !isKubernetesEndpoint && endpoint.Status != portainer.EndpointStatusUp {
log.Debug().Msgf("Environment %q (id: %d) is not up", endpoint.Name, id)
return fmt.Errorf("environment %q (id: %d) is not up: %w", endpoint.Name, id, err)
return fmt.Errorf("environment %q (id: %d) is not up", endpoint.Name, id)
}
// For Kubernetes endpoints, we need to check if the endpoint is up by creating a kube client
if isKubernetesEndpoint {
_, err := service.kubeFactory.GetKubeClient(endpoint)
if err != nil {
log.Debug().Err(err).Msgf("Environment %q (id: %d) is not up", endpoint.Name, id)
return fmt.Errorf("environment %q (id: %d) is not up", endpoint.Name, id)
}
}
pendingActions, err := service.dataStore.PendingActions().ReadAll()
@@ -95,13 +116,19 @@ func (service *PendingActionsService) executePendingAction(pendingAction portain
}()
switch pendingAction.Action {
case CleanNAPWithOverridePolicies:
if (pendingAction.ActionData == nil) || (pendingAction.ActionData.(portainer.EndpointGroupID) == 0) {
case actions.CleanNAPWithOverridePolicies:
pendingActionData, err := actions.ConvertCleanNAPWithOverridePoliciesPayload(pendingAction.ActionData)
if err != nil {
return fmt.Errorf("failed to parse pendingActionData for CleanNAPWithOverridePoliciesPayload")
}
if pendingActionData == nil || pendingActionData.EndpointGroupID == 0 {
service.authorizationService.CleanNAPWithOverridePolicies(service.dataStore, endpoint, nil)
return nil
}
endpointGroupID := pendingAction.ActionData.(portainer.EndpointGroupID)
endpointGroupID := pendingActionData.EndpointGroupID
endpointGroup, err := service.dataStore.EndpointGroup().Read(portainer.EndpointGroupID(endpointGroupID))
if err != nil {
log.Error().Err(err).Msgf("Error reading environment group to clean NAP with override policies for environment %d and environment group %d", endpoint.ID, endpointGroup.ID)
@@ -114,7 +141,7 @@ func (service *PendingActionsService) executePendingAction(pendingAction portain
}
return nil
case DeletePortainerK8sRegistrySecrets:
case actions.DeletePortainerK8sRegistrySecrets:
if pendingAction.ActionData == nil {
return nil
}
@@ -130,6 +157,22 @@ func (service *PendingActionsService) executePendingAction(pendingAction portain
return fmt.Errorf("failed to delete kubernetes registry secrets for environment %d: %w", endpoint.ID, err)
}
return nil
case actions.PostInitMigrateEnvironment:
postInitMigrator := postinit.NewPostInitMigrator(
service.kubeFactory,
service.dockerFactory,
service.dataStore,
service.assetsPath,
service.kubernetesDeployer,
)
err := postInitMigrator.MigrateEnvironment(endpoint)
if err != nil {
log.Error().Err(err).Msgf("Error running post-init migrations for edge environment %d", endpoint.ID)
return fmt.Errorf("failed running post-init migrations for edge environment %d: %w", endpoint.ID, err)
}
return nil
}

View File

@@ -6,10 +6,13 @@ import (
"time"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/system"
"github.com/docker/docker/api/types/volume"
gittypes "github.com/portainer/portainer/api/git/types"
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/portainer/portainer/pkg/featureflags"
"golang.org/x/oauth2"
v1 "k8s.io/api/core/v1"
)
@@ -242,8 +245,8 @@ type (
Containers []DockerContainerSnapshot `json:"Containers" swaggerignore:"true"`
Volumes volume.ListResponse `json:"Volumes" swaggerignore:"true"`
Networks []types.NetworkResource `json:"Networks" swaggerignore:"true"`
Images []types.ImageSummary `json:"Images" swaggerignore:"true"`
Info types.Info `json:"Info" swaggerignore:"true"`
Images []image.Summary `json:"Images" swaggerignore:"true"`
Info system.Info `json:"Info" swaggerignore:"true"`
Version types.Version `json:"Version" swaggerignore:"true"`
}
@@ -756,19 +759,20 @@ type (
// OAuthSettings represents the settings used to authorize with an authorization server
OAuthSettings struct {
ClientID string `json:"ClientID"`
ClientSecret string `json:"ClientSecret,omitempty"`
AccessTokenURI string `json:"AccessTokenURI"`
AuthorizationURI string `json:"AuthorizationURI"`
ResourceURI string `json:"ResourceURI"`
RedirectURI string `json:"RedirectURI"`
UserIdentifier string `json:"UserIdentifier"`
Scopes string `json:"Scopes"`
OAuthAutoCreateUsers bool `json:"OAuthAutoCreateUsers"`
DefaultTeamID TeamID `json:"DefaultTeamID"`
SSO bool `json:"SSO"`
LogoutURI string `json:"LogoutURI"`
KubeSecretKey []byte `json:"KubeSecretKey"`
ClientID string `json:"ClientID"`
ClientSecret string `json:"ClientSecret,omitempty"`
AccessTokenURI string `json:"AccessTokenURI"`
AuthorizationURI string `json:"AuthorizationURI"`
ResourceURI string `json:"ResourceURI"`
RedirectURI string `json:"RedirectURI"`
UserIdentifier string `json:"UserIdentifier"`
Scopes string `json:"Scopes"`
OAuthAutoCreateUsers bool `json:"OAuthAutoCreateUsers"`
DefaultTeamID TeamID `json:"DefaultTeamID"`
SSO bool `json:"SSO"`
LogoutURI string `json:"LogoutURI"`
KubeSecretKey []byte `json:"KubeSecretKey"`
AuthStyle oauth2.AuthStyle `json:"AuthStyle"`
}
// Pair defines a key/value string pair
@@ -1595,7 +1599,7 @@ type (
const (
// APIVersion is the version number of the Portainer API
APIVersion = "2.20.0"
APIVersion = "2.20.3"
// Edition is what this edition of Portainer is called
Edition = PortainerCE
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
@@ -1724,6 +1728,8 @@ const (
EdgeStackStatusRollingBack
// EdgeStackStatusRolledBack represents an Edge stack which has rolled back
EdgeStackStatusRolledBack
// EdgeStackStatusCompleted represents a completed Edge stack
EdgeStackStatusCompleted
)
const (

View File

@@ -16,6 +16,7 @@ import (
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/api/types/system"
dockerclient "github.com/docker/docker/client"
"github.com/docker/docker/pkg/stdcopy"
"github.com/pkg/errors"
@@ -24,7 +25,7 @@ import (
)
const (
defaultUnpackerImage = "portainer/compose-unpacker:latest"
defaultUnpackerImage = "portainer/compose-unpacker:" + portainer.APIVersion
composeUnpackerImageEnvVar = "COMPOSE_UNPACKER_IMAGE"
composePathPrefix = "portainer-compose-unpacker"
)
@@ -211,9 +212,9 @@ func (d *stackDeployer) remoteStack(stack *portainer.Stack, endpoint *portainer.
if err != nil {
return errors.Wrap(err, "unable to create unpacker container")
}
defer cli.ContainerRemove(ctx, unpackerContainer.ID, types.ContainerRemoveOptions{})
defer cli.ContainerRemove(ctx, unpackerContainer.ID, container.RemoveOptions{})
if err := cli.ContainerStart(ctx, unpackerContainer.ID, types.ContainerStartOptions{}); err != nil {
if err := cli.ContainerStart(ctx, unpackerContainer.ID, container.StartOptions{}); err != nil {
return errors.Wrap(err, "start unpacker container error")
}
@@ -228,7 +229,7 @@ func (d *stackDeployer) remoteStack(stack *portainer.Stack, endpoint *portainer.
stdErr := &bytes.Buffer{}
out, err := cli.ContainerLogs(ctx, unpackerContainer.ID, types.ContainerLogsOptions{ShowStdout: true, ShowStderr: true})
out, err := cli.ContainerLogs(ctx, unpackerContainer.ID, container.LogsOptions{ShowStdout: true, ShowStderr: true})
if err != nil {
log.Error().Err(err).Msg("unable to get logs from unpacker container")
} else {
@@ -335,6 +336,6 @@ func getTargetSocketBind(osType string) string {
// Per https://stackoverflow.com/a/50590287 and Docker's LocalNodeState possible values
// `LocalNodeStateInactive` means the node is not in a swarm cluster
func isNotInASwarm(info *types.Info) bool {
func isNotInASwarm(info *system.Info) bool {
return info.Swarm.LocalNodeState == swarm.LocalNodeStateInactive
}

View File

@@ -14,7 +14,7 @@ export function ImageViewModel(data) {
}
}
this.VirtualSize = data.VirtualSize;
this.Size = data.Size;
this.Used = data.Used;
if (data.Portainer && data.Portainer.Agent && data.Portainer.Agent.NodeName) {

View File

@@ -6,15 +6,22 @@ export function ImageDetailsViewModel(data) {
this.Created = data.Created;
this.Checked = false;
this.RepoTags = data.RepoTags;
this.VirtualSize = data.VirtualSize;
this.Size = data.Size;
this.DockerVersion = data.DockerVersion;
this.Os = data.Os;
this.Architecture = data.Architecture;
this.Author = data.Author;
this.Command = data.Config.Cmd;
this.Entrypoint = data.ContainerConfig.Entrypoint ? data.ContainerConfig.Entrypoint : '';
this.ExposedPorts = data.ContainerConfig.ExposedPorts ? Object.keys(data.ContainerConfig.ExposedPorts) : [];
this.Volumes = data.ContainerConfig.Volumes ? Object.keys(data.ContainerConfig.Volumes) : [];
this.Env = data.ContainerConfig.Env ? data.ContainerConfig.Env : [];
this.Labels = data.ContainerConfig.Labels;
let config = {};
if (data.Config) {
config = data.Config; // this is part of OCI images-spec
} else if (data.ContainerConfig != null) {
config = data.ContainerConfig; // not OCI ; has been removed in Docker 26 (API v1.45) along with .Container
}
this.Entrypoint = config.Entrypoint ? config.Entrypoint : '';
this.ExposedPorts = config.ExposedPorts ? Object.keys(config.ExposedPorts) : [];
this.Volumes = config.Volumes ? Object.keys(config.Volumes) : [];
this.Env = config.Env ? config.Env : [];
this.Labels = config.Labels;
}

View File

@@ -137,5 +137,5 @@ angular.module('portainer.docker').controller('DashboardController', [
]);
function imagesTotalSize(images) {
return images.reduce((acc, image) => acc + image.VirtualSize, 0);
return images.reduce((acc, image) => acc + image.Size, 0);
}

View File

@@ -130,7 +130,7 @@
</tr>
<tr>
<td>Size</td>
<td>{{ image.VirtualSize | humansize }}</td>
<td>{{ image.Size | humansize }}</td>
</tr>
<tr>
<td>Created</td>

View File

@@ -345,7 +345,7 @@ export default class CreateEdgeStackViewController {
RepositoryUsername: this.formValues.RepositoryUsername,
RepositoryPassword: this.formValues.RepositoryPassword,
TLSSkipVerify: this.formValues.TLSSkipVerify,
CreatedFromCustomTemplateID: this.state.templateValues.template.Id,
CreatedFromCustomTemplateID: this.state.templateValues.template && this.state.templateValues.template.Id,
};
return this.EdgeStackService.createStackFromGitRepository(
{

View File

@@ -95,7 +95,7 @@ class KubernetesApplicationsController {
} else {
await this.KubernetesApplicationService.delete(application);
if (application.Metadata.labels[KubernetesPortainerApplicationStackNameLabel]) {
if (application.Metadata.labels && application.Metadata.labels[KubernetesPortainerApplicationStackNameLabel]) {
// Update applications in stack
const stack = this.state.stacks.find((x) => x.Name === application.StackName);
const index = stack.Applications.indexOf(application);

View File

@@ -65,13 +65,12 @@
<relative-path-fieldset value="$ctrl.stack" git-model="$ctrl.stack" is-editing="true" hide-edge-configs="true"></relative-path-fieldset>
</div>
<environment-variables-panel
<stack-environment-variables-panel
values="$ctrl.formValues.Env"
explanation="'These values will be used as substitutions in the stack file. To reference the .env file in your compose file, use stack.env.'"
on-change="($ctrl.onChangeEnvVar)"
show-help-message="true"
is-foldable="true"
></environment-variables-panel>
></stack-environment-variables-panel>
<option-panel ng-if="$ctrl.stack.Type === 1 && $ctrl.endpoint.apiVersion >= 1.27" ng-model="$ctrl.formValues.Option" on-change="($ctrl.onChangeOption)"></option-panel>

View File

@@ -78,6 +78,7 @@ export function OAuthSettingsViewModel(data) {
this.DefaultTeamID = data.DefaultTeamID;
this.SSO = data.SSO;
this.LogoutURI = data.LogoutURI;
this.AuthStyle = data.AuthStyle;
}
export function EdgeSettingsViewModel(data = {}) {

View File

@@ -4,7 +4,6 @@ import { isLimitedToBE } from '@/react/portainer/feature-flags/feature-flags.ser
import { ModalType } from '@@/modals';
import { confirm } from '@@/modals/confirm';
import { buildConfirmButton } from '@@/modals/utils';
import providers, { getProviderByUrl } from './providers';
const MS_TENANT_ID_PLACEHOLDER = 'TENANT_ID';
@@ -31,6 +30,7 @@ export default class OAuthSettingsController {
this.addTeamMembershipMapping = this.addTeamMembershipMapping.bind(this);
this.removeTeamMembership = this.removeTeamMembership.bind(this);
this.onToggleAutoTeamMembership = this.onToggleAutoTeamMembership.bind(this);
this.onChangeAuthStyle = this.onChangeAuthStyle.bind(this);
}
onMicrosoftTenantIDChange() {
@@ -54,6 +54,7 @@ export default class OAuthSettingsController {
this.settings.LogoutURI = provider.logoutUrl;
this.settings.UserIdentifier = provider.userIdentifier;
this.settings.Scopes = provider.scopes;
this.settings.AuthStyle = provider.authStyle;
if (providerId === 'microsoft' && this.state.microsoftTenantID !== '') {
this.onMicrosoftTenantIDChange();
@@ -77,6 +78,12 @@ export default class OAuthSettingsController {
});
}
onChangeAuthStyle(val) {
this.$scope.$evalAsync(() => {
this.settings.AuthStyle = val;
});
}
async onChangeHideInternalAuth(checked) {
this.$async(async () => {
if (this.isLimitedToBE) {

View File

@@ -341,6 +341,8 @@
/>
</div>
</div>
<oauth-auth-style value="$ctrl.settings.AuthStyle" on-change="($ctrl.onChangeAuthStyle)"></oauth-auth-style>
<save-auth-settings-button
on-save-settings="($ctrl.onSaveSettings)"
save-button-state="($ctrl.saveButtonState)"

View File

@@ -1,4 +1,5 @@
import { baseHref } from '@/portainer/helpers/pathHelper';
import { OAuthStyle } from '@/react/portainer/settings/types';
export default {
microsoft: {
@@ -8,6 +9,7 @@ export default {
logoutUrl: `https://login.microsoftonline.com/TENANT_ID/oauth2/v2.0/logout`,
userIdentifier: 'userPrincipalName',
scopes: 'profile openid',
authStyle: OAuthStyle.InParams,
},
google: {
authUrl: 'https://accounts.google.com/o/oauth2/auth',
@@ -16,6 +18,7 @@ export default {
logoutUrl: `https://www.google.com/accounts/Logout?continue=https://appengine.google.com/_ah/logout?continue=${window.location.origin}${baseHref()}#!/auth`,
userIdentifier: 'email',
scopes: 'profile email',
authStyle: OAuthStyle.InParams,
},
github: {
authUrl: 'https://github.com/login/oauth/authorize',
@@ -24,8 +27,9 @@ export default {
logoutUrl: `https://github.com/logout`,
userIdentifier: 'login',
scopes: 'id email name',
authStyle: OAuthStyle.AutoDetect,
},
custom: { authUrl: '', accessTokenUrl: '', resourceUrl: '', logoutUrl: '', userIdentifier: '', scopes: '' },
custom: { authUrl: '', accessTokenUrl: '', resourceUrl: '', logoutUrl: '', userIdentifier: '', scopes: '', authStyle: OAuthStyle.AutoDetect },
};
export function getProviderByUrl(providerAuthURL = '') {

View File

@@ -14,6 +14,7 @@ import { withControlledInput } from '@/react-tools/withControlledInput';
import {
EnvironmentVariablesFieldset,
EnvironmentVariablesPanel,
StackEnvironmentVariablesPanel,
envVarValidation,
} from '@@/form-components/EnvironmentVariablesFieldset';
import { Icon } from '@@/Icon';
@@ -263,3 +264,13 @@ withFormValidation(
['explanation', 'showHelpMessage', 'isFoldable'],
envVarValidation
);
withFormValidation(
ngModule,
withUIRouter(
withControlledInput(StackEnvironmentVariablesPanel, { values: 'onChange' })
),
'stackEnvironmentVariablesPanel',
['showHelpMessage', 'isFoldable'],
envVarValidation
);

View File

@@ -11,6 +11,7 @@ import { KubeSettingsPanel } from '@/react/portainer/settings/SettingsView/KubeS
import { HelmCertPanel } from '@/react/portainer/settings/SettingsView/HelmCertPanel';
import { HiddenContainersPanel } from '@/react/portainer/settings/SettingsView/HiddenContainersPanel/HiddenContainersPanel';
import { SSLSettingsPanelWrapper } from '@/react/portainer/settings/SettingsView/SSLSettingsPanel/SSLSettingsPanel';
import { AuthStyleField } from '@/react/portainer/settings/AuthenticationView/OAuth';
export const settingsModule = angular
.module('portainer.app.react.components.settings', [])
@@ -39,4 +40,15 @@ export const settingsModule = angular
.component(
'kubeSettingsPanel',
r2a(withUIRouter(withReactQuery(KubeSettingsPanel)), ['settings'])
)
.component(
'oauthAuthStyle',
r2a(AuthStyleField, [
'value',
'onChange',
'label',
'tooltip',
'readonly',
'size',
])
).name;

View File

@@ -6,7 +6,7 @@
<div class="widget-icon space-right">
<pr-icon icon="'history'"></pr-icon>
</div>
Activity Logs
Activity logs
</div>
<div class="vertical-center">
<datatable-searchbar on-change="($ctrl.onChangeKeyword)" value="$ctrl.keyword"></datatable-searchbar>

View File

@@ -1,46 +1,52 @@
<page-header title="'User Activity'" breadcrumbs="['Activity Logs']" reload="true"> </page-header>
<page-header title="'User activity logs'" breadcrumbs="['User activity logs']" reload="true"> </page-header>
<div class="be-indicator-container limited-be mx-4">
<div>
<div class="limited-be-link vertical-center"><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator></div>
<div class="limited-be-content">
<rd-widget>
<rd-widget-body>
<div class="form-horizontal">
<div class="form-group">
<label for="dateRangeInput" class="col-sm-2 control-label text-left">Date Range</label>
<div class="col-sm-6">
<input type="text" class="form-control" disabled />
<div class="mx-4">
<div class="be-indicator-container limited-be">
<div class="limited-be-link vertical-center m-4"><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator></div>
<div class="limited-be-content !p-0 !pt-[15px]">
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-body>
<div class="form-horizontal">
<div class="form-group">
<label for="dateRangeInput" class="col-sm-2 control-label text-left">Date range</label>
<div class="col-sm-6">
<input type="text" class="form-control" disabled />
</div>
</div>
</div>
</div>
</div>
<p class="text-muted small vertical-center">
<pr-icon icon="'info'" class-name="'icon icon-sm icon-primary'"></pr-icon>
Portainer user activity logs have a maximum retention of 7 days.
</p>
<div>
<button type="button" class="btn btn-sm btn-primary" limited-feature-dir="{{::$ctrl.limitedFeature}}" limited-feature-class="limited-be" limited-feature-disabled>
<pr-icon icon="'download'" class-name="'icon icon-sm'"></pr-icon>
Export as CSV
</button>
</div>
</rd-widget-body>
</rd-widget>
<div class="row mt-5">
<activity-logs-datatable
logs="$ctrl.state.logs"
keyword="$ctrl.state.keyword"
sort="$ctrl.state.sort"
limit="$ctrl.state.limit"
context-filter="$ctrl.state.contextFilter"
total-items="$ctrl.state.totalItems"
current-page="$ctrl.state.currentPage"
feature="{{:: $ctrl.limitedFeature}}"
on-change-keyword="($ctrl.onChangeKeyword)"
on-change-sort="($ctrl.onChangeSort)"
on-change-limit="($ctrl.onChangeLimit)"
on-change-page="($ctrl.onChangePage)"
></activity-logs-datatable>
<p class="text-muted small vertical-center">
<pr-icon icon="'info'" class-name="'icon icon-sm icon-primary'"></pr-icon>
Portainer user activity logs have a maximum retention of 7 days.
</p>
<div>
<button type="button" class="btn btn-sm btn-primary" limited-feature-dir="{{::$ctrl.limitedFeature}}" limited-feature-class="limited-be" limited-feature-disabled>
<pr-icon icon="'download'" class-name="'icon icon-sm'"></pr-icon>
Export as CSV
</button>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row">
<div class="col-sm-12">
<activity-logs-datatable
logs="$ctrl.state.logs"
keyword="$ctrl.state.keyword"
sort="$ctrl.state.sort"
limit="$ctrl.state.limit"
context-filter="$ctrl.state.contextFilter"
total-items="$ctrl.state.totalItems"
current-page="$ctrl.state.currentPage"
feature="{{:: $ctrl.limitedFeature}}"
on-change-keyword="($ctrl.onChangeKeyword)"
on-change-sort="($ctrl.onChangeSort)"
on-change-limit="($ctrl.onChangeLimit)"
on-change-page="($ctrl.onChangePage)"
></activity-logs-datatable>
</div>
</div>
</div>
</div>

View File

@@ -6,7 +6,7 @@
<div class="widget-icon space-right">
<pr-icon icon="'history'"></pr-icon>
</div>
Authentication Events
Authentication events
</div>
<div class="vertical-center">
<datatable-searchbar on-change="($ctrl.onChangeKeyword)"></datatable-searchbar>

View File

@@ -10,7 +10,7 @@ export default class AuthLogsViewController {
this.limitedFeature = FeatureId.ACTIVITY_AUDIT;
this.state = {
keyword: 'f',
keyword: '',
date: {
from: 0,
to: 0,

View File

@@ -1,48 +1,55 @@
<page-header title="'User Activity'" breadcrumbs="['User authentication activity']" reload="true"> </page-header>
<page-header title="'User authentication logs'" breadcrumbs="['User authentication logs']" reload="true"> </page-header>
<div class="be-indicator-container limited-be mx-4">
<div>
<div class="limited-be-link vertical-center"><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator></div>
<div class="limited-be-content">
<rd-widget>
<rd-widget-body>
<div class="form-horizontal">
<div class="form-group">
<label for="dateRangeInput" class="col-sm-2 control-label text-left">Date Range</label>
<div class="col-sm-6">
<input type="text" class="form-control" disabled />
<div class="mx-4">
<div class="be-indicator-container limited-be">
<div class="limited-be-link vertical-center m-4"><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator></div>
<!-- 15px matches the padding for col-sm-12 for the widget and table -->
<div class="limited-be-content !p-0 !pt-[15px]">
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-body>
<div class="form-horizontal">
<div class="form-group">
<label for="dateRangeInput" class="col-sm-2 control-label text-left">Date range</label>
<div class="col-sm-6">
<input type="text" class="form-control" disabled />
</div>
</div>
</div>
</div>
</div>
<p class="text-muted small vertical-center">
<pr-icon icon="'info'" class-name="'icon icon-sm icon-primary'"></pr-icon>
Portainer user authentication activity logs have a maximum retention of 7 days.
</p>
<div>
<button type="button" class="btn btn-sm btn-primary" limited-feature-dir="{{::$ctrl.limitedFeature}}" limited-feature-class="limited-be" limited-feature-disabled
><pr-icon icon="'download'" class-name="'icon icon-sm'"></pr-icon>Export as CSV
</button>
</div>
</rd-widget-body>
</rd-widget>
<div class="row mt-5">
<auth-logs-datatable
logs="$ctrl.state.logs"
keyword="$ctrl.state.keyword"
sort="$ctrl.state.sort"
limit="$ctrl.state.limit"
context-filter="$ctrl.state.contextFilter"
type-filter="$ctrl.state.typeFilter"
total-items="$ctrl.state.totalItems"
current-page="$ctrl.state.currentPage"
feature="{{:: $ctrl.limitedFeature}}"
on-change-context-filter="($ctrl.onChangeContextFilter)"
on-change-type-filter="($ctrl.onChangeTypeFilter)"
on-change-keyword="($ctrl.onChangeKeyword)"
on-change-sort="($ctrl.onChangeSort)"
on-change-limit="($ctrl.onChangeLimit)"
on-change-page="($ctrl.onChangePage)"
></auth-logs-datatable>
<p class="text-muted small vertical-center">
<pr-icon icon="'info'" class-name="'icon icon-sm icon-primary'"></pr-icon>
Portainer user authentication logs have a maximum retention of 7 days.
</p>
<div>
<button type="button" class="btn btn-sm btn-primary" limited-feature-dir="{{::$ctrl.limitedFeature}}" limited-feature-class="limited-be" limited-feature-disabled
><pr-icon icon="'download'" class-name="'icon icon-sm'"></pr-icon>Export as CSV
</button>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row">
<div class="col-sm-12">
<auth-logs-datatable
logs="$ctrl.state.logs"
keyword="$ctrl.state.keyword"
sort="$ctrl.state.sort"
limit="$ctrl.state.limit"
context-filter="$ctrl.state.contextFilter"
type-filter="$ctrl.state.typeFilter"
total-items="$ctrl.state.totalItems"
current-page="$ctrl.state.currentPage"
feature="{{:: $ctrl.limitedFeature}}"
on-change-context-filter="($ctrl.onChangeContextFilter)"
on-change-type-filter="($ctrl.onChangeTypeFilter)"
on-change-keyword="($ctrl.onChangeKeyword)"
on-change-sort="($ctrl.onChangeSort)"
on-change-limit="($ctrl.onChangeLimit)"
on-change-page="($ctrl.onChangePage)"
></auth-logs-datatable>
</div>
</div>
</div>
</div>

View File

@@ -152,12 +152,7 @@
</div>
<!-- environment-variables -->
<environment-variables-panel
values="formValues.Env"
explanation="'These values will be used as substitutions in the stack file. To reference the .env file in your compose file, use stack.env'"
on-change="(handleEnvVarChange)"
>
</environment-variables-panel>
<stack-environment-variables-panel values="formValues.Env" on-change="(handleEnvVarChange)" show-alert="true"> </stack-environment-variables-panel>
<!-- !environment-variables -->
<por-access-control-form form-data="formValues.AccessControlData"></por-access-control-form>
<!-- actions -->

View File

@@ -169,13 +169,12 @@
<!-- environment-variables -->
<div ng-if="stack">
<environment-variables-panel
<stack-environment-variables-panel
values="formValues.Env"
explanation="'These values will be used as substitutions in the stack file. To reference the .env file in your compose file, use stack.env.'"
on-change="(handleEnvVarChange)"
show-help-message="true"
is-foldable="true"
></environment-variables-panel>
></stack-environment-variables-panel>
</div>
<!-- !environment-variables -->

View File

@@ -5,14 +5,38 @@ import { isLimitedToBE } from '@/react/portainer/feature-flags/feature-flags.ser
import { BEFeatureIndicator } from './BEFeatureIndicator';
type Variants = 'form-section' | 'widget' | 'multi-widget';
type OverlayClasses = {
beLinkContainerClassName: string;
contentClassName: string;
};
const variantClassNames: Record<Variants, OverlayClasses> = {
'form-section': {
beLinkContainerClassName: '',
contentClassName: '',
},
widget: {
beLinkContainerClassName: '',
// no padding so that the border overlaps the widget border
contentClassName: '!p-0',
},
'multi-widget': {
beLinkContainerClassName: 'm-4',
// widgets have a mx of 15px and mb of 15px - match this at the top with padding
contentClassName: '!p-0 !pt-[15px]',
},
};
export function BEOverlay({
featureId,
children,
className,
variant = 'form-section',
}: {
featureId: FeatureId;
children: React.ReactNode;
className?: string;
variant?: 'form-section' | 'widget' | 'multi-widget';
}) {
const isLimited = isLimitedToBE(featureId);
if (!isLimited) {
@@ -21,10 +45,22 @@ export function BEOverlay({
return (
<div className="be-indicator-container limited-be">
<div className="limited-be-link vertical-center">
<div
className={clsx(
'limited-be-link vertical-center',
variantClassNames[variant].beLinkContainerClassName
)}
>
<BEFeatureIndicator featureId={featureId} />
</div>
<div className={clsx('limited-be-content', className)}>{children}</div>
<div
className={clsx(
'limited-be-content',
variantClassNames[variant].contentClassName
)}
>
{children}
</div>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { ComponentProps } from 'react';
import React, { ComponentProps } from 'react';
import { FormSection } from '@@/form-components/FormSection';
import { TextTip } from '@@/Tip/TextTip';
@@ -14,16 +14,19 @@ export function EnvironmentVariablesPanel({
showHelpMessage,
errors,
isFoldable = false,
alertMessage,
}: {
explanation?: string;
explanation?: React.ReactNode;
showHelpMessage?: boolean;
isFoldable?: boolean;
alertMessage?: React.ReactNode;
} & FieldsetProps) {
return (
<FormSection
title="Environment variables"
isFoldable={isFoldable}
defaultFolded={isFoldable}
className="flex flex-col w-full"
>
<div className="form-group">
{!!explanation && (
@@ -32,6 +35,8 @@ export function EnvironmentVariablesPanel({
</div>
)}
{alertMessage}
<div className="col-sm-12">
<EnvironmentVariablesFieldset
values={values}

View File

@@ -0,0 +1,67 @@
import { ComponentProps } from 'react';
import { Alert } from '@@/Alert';
import { EnvironmentVariablesFieldset } from './EnvironmentVariablesFieldset';
import { EnvironmentVariablesPanel } from './EnvironmentVariablesPanel';
type FieldsetProps = ComponentProps<typeof EnvironmentVariablesFieldset>;
export function StackEnvironmentVariablesPanel({
onChange,
values,
errors,
isFoldable = false,
showHelpMessage,
}: {
isFoldable?: boolean;
showHelpMessage?: boolean;
} & FieldsetProps) {
return (
<EnvironmentVariablesPanel
explanation={
<div>
You may use{' '}
<a
href="https://docs.portainer.io/v/2.20/user/docker/stacks/add#environment-variables"
target="_blank"
data-cy="stack-env-vars-help-link"
rel="noreferrer noopener"
>
environment variables in your compose file
</a>
. The environment variable values set below will be used as
substitutions in the compose file. Note that you may also reference a
stack.env file in your compose file. A stack.env file contains the
environment variables and their values (e.g. TAG=v1.5).
</div>
}
onChange={onChange}
values={values}
errors={errors}
isFoldable={isFoldable}
showHelpMessage={showHelpMessage}
alertMessage={
<div className="flex p-4">
<Alert color="info" className="col-sm-12">
<div>
<p>
<strong>stack.env file operation</strong>
</p>
<div>
When deploying via <strong>Repository</strong>, the stack.env
file must already reside in the Git repo.
</div>
<div>
When deploying via <strong>Web editor</strong>,{' '}
<strong>Upload</strong> or{' '}
<strong>Custom template deployment</strong>, the stack.env file
is auto created from what you set below.
</div>
</div>
</Alert>
</div>
}
/>
);
}

View File

@@ -4,5 +4,6 @@ export {
} from './EnvironmentVariablesFieldset';
export { EnvironmentVariablesPanel } from './EnvironmentVariablesPanel';
export { StackEnvironmentVariablesPanel } from './StackEnvironmentVariablesPanel';
export { type Values as EnvVarValues } from './types';

View File

@@ -11,6 +11,7 @@ interface Props {
isFoldable?: boolean;
defaultFolded?: boolean;
titleClassName?: string;
className?: string;
}
export function FormSection({
@@ -20,11 +21,12 @@ export function FormSection({
isFoldable = false,
defaultFolded = isFoldable,
titleClassName,
className,
}: PropsWithChildren<Props>) {
const [isExpanded, setIsExpanded] = useState(!defaultFolded);
return (
<>
<div className={className}>
<FormSectionTitle
htmlFor={isFoldable ? `foldingButton${title}` : ''}
titleSize={titleSize}
@@ -52,6 +54,6 @@ export function FormSection({
</FormSectionTitle>
{isExpanded && children}
</>
</div>
);
}

View File

@@ -20,7 +20,7 @@ import { TextTip } from '@@/Tip/TextTip';
import { HelpLink } from '@@/HelpLink';
import { useContainers } from '../queries/containers';
import { useSystemLimits } from '../../proxy/queries/useInfo';
import { useSystemLimits, useIsWindows } from '../../proxy/queries/useInfo';
import { useCreateOrReplaceMutation } from './useCreateMutation';
import { useValidation } from './validation';
@@ -48,6 +48,7 @@ export function CreateView() {
function CreateForm() {
const environmentId = useEnvironmentId();
const router = useRouter();
const isWindows = useIsWindows(environmentId);
const { trackEvent } = useAnalytics();
const isAdminQuery = useIsEdgeAdmin();
const { authorized: isEnvironmentAdmin } = useIsEnvironmentAdmin({
@@ -57,7 +58,8 @@ function CreateForm() {
const mutation = useCreateOrReplaceMutation();
const initialValuesQuery = useInitialValues(
mutation.isLoading || mutation.isSuccess
mutation.isLoading || mutation.isSuccess,
isWindows
);
const registriesQuery = useEnvironmentRegistries(environmentId);
@@ -84,9 +86,11 @@ function CreateForm() {
const environment = envQuery.data;
// if windows, hide capabilities. this is because capadd and capdel are not supported on windows
const hideCapabilities =
!environment.SecuritySettings.allowContainerCapabilitiesForRegularUsers &&
!isEnvironmentAdmin;
(!environment.SecuritySettings.allowContainerCapabilitiesForRegularUsers &&
!isEnvironmentAdmin) ||
isWindows;
const {
isDuplicating = false,

View File

@@ -5,9 +5,10 @@ import { DockerContainer } from '../../types';
import { CONTAINER_MODE, Values } from './types';
export function getDefaultViewModel() {
export function getDefaultViewModel(isWindows: boolean) {
const networkMode = isWindows ? 'nat' : 'bridge';
return {
networkMode: 'bridge',
networkMode,
hostname: '',
domain: '',
macAddress: '',

View File

@@ -57,7 +57,7 @@ export interface Values extends BaseFormValues {
env: EnvVarValues;
}
export function useInitialValues(submitting: boolean) {
export function useInitialValues(submitting: boolean, isWindows: boolean) {
const {
params: { nodeName, from },
} = useCurrentStateAndParams();
@@ -66,9 +66,10 @@ export function useInitialValues(submitting: boolean) {
const networksQuery = useNetworksForSelector();
const fromContainerQuery = useContainer(environmentId, from, {
const fromContainerQuery = useContainer(environmentId, from, nodeName, {
enabled: !submitting,
});
const runningContainersQuery = useContainers(environmentId, {
enabled: !!from,
});
@@ -86,7 +87,7 @@ export function useInitialValues(submitting: boolean) {
if (!from) {
return {
initialValues: defaultValues(isPureAdmin, user.Id, nodeName),
initialValues: defaultValues(isPureAdmin, user.Id, nodeName, isWindows),
};
}
@@ -151,12 +152,13 @@ export function useInitialValues(submitting: boolean) {
function defaultValues(
isPureAdmin: boolean,
currentUserId: UserId,
nodeName: string
nodeName: string,
isWindows: boolean
): Values {
return {
commands: commandsTabUtils.getDefaultViewModel(),
volumes: volumesTabUtils.getDefaultViewModel(),
network: networkTabUtils.getDefaultViewModel(),
network: networkTabUtils.getDefaultViewModel(isWindows), // windows containers should default to the nat network, not the bridge
labels: labelsTabUtils.getDefaultViewModel(),
restartPolicy: restartPolicyTabUtils.getDefaultViewModel(),
resources: resourcesTabUtils.getDefaultViewModel(),

View File

@@ -8,10 +8,10 @@ import { Link } from '@@/Link';
export function LogView() {
const {
params: { endpointId: environmentId, id: containerId },
params: { endpointId: environmentId, id: containerId, nodeName },
} = useCurrentStateAndParams();
const containerQuery = useContainer(environmentId, containerId);
const containerQuery = useContainer(environmentId, containerId, nodeName);
if (!containerQuery.data || containerQuery.isLoading) {
return null;
}

View File

@@ -7,6 +7,7 @@ import {
MountPoint,
NetworkSettings,
} from 'docker-types/generated/1.41';
import { RawAxiosRequestHeaders } from 'axios';
import { PortainerResponse } from '@/react/docker/types';
import axios, { parseAxiosError } from '@/portainer/services/axios';
@@ -75,11 +76,15 @@ export interface ContainerJSON {
export function useContainer(
environmentId: EnvironmentId,
containerId?: ContainerId,
nodeName?: string,
{ enabled }: { enabled?: boolean } = {}
) {
return useQuery(
containerId ? queryKeys.container(environmentId, containerId) : [],
() => (containerId ? getContainer(environmentId, containerId) : undefined),
() =>
containerId
? getContainer(environmentId, containerId, nodeName)
: undefined,
{
meta: {
title: 'Failure',
@@ -103,11 +108,19 @@ export type ContainerResponse = PortainerResponse<ContainerJSON>;
async function getContainer(
environmentId: EnvironmentId,
containerId: ContainerId
containerId: ContainerId,
nodeName?: string
) {
try {
const headers: RawAxiosRequestHeaders = {};
if (nodeName) {
headers['X-PortainerAgent-Target'] = nodeName;
}
const { data } = await axios.get<ContainerResponse>(
urlBuilder(environmentId, containerId, 'json')
urlBuilder(environmentId, containerId, 'json'),
{ headers }
);
return data;
} catch (error) {

View File

@@ -10,6 +10,5 @@ export type DockerImageResponse = {
RepoTags: string[];
SharedSize: number;
Size: number;
VirtualSize: number;
Portainer?: PortainerMetadata;
};

View File

@@ -81,7 +81,7 @@ function buildImageFullURIWithRegistry(image: string, registry: Registry) {
}
function buildImageURIForGithub(image: string, registry: Registry) {
const imageName = image.split('/').pop();
const imageName = image.startsWith('/') ? image.slice(1) : image;
const namespace = registry.Github.UseOrganisation
? registry.Github.OrganisationName

View File

@@ -30,6 +30,12 @@ export function useInfo<TSelect = SystemInfo>(
);
}
export function useIsWindows(environmentId: EnvironmentId) {
const query = useInfo(environmentId, (info) => info.OSType === 'windows');
return !!query.data;
}
export function useIsStandAlone(environmentId: EnvironmentId) {
const query = useInfo(environmentId, (info) => !info.Swarm?.NodeID);

View File

@@ -66,6 +66,8 @@ function getTooltip(count: number, total: number, type?: StatusType) {
switch (type) {
case StatusType.Running:
return 'deployments running';
case StatusType.Completed:
return 'deployments completed';
case StatusType.DeploymentReceived:
return 'deployments received';
case StatusType.Error:

View File

@@ -84,6 +84,16 @@ function getStatus(
};
}
const allCompleted = envStatus.every((s) => s.Type === StatusType.Completed);
if (allCompleted) {
return {
label: 'Completed',
icon: CheckCircle,
mode: 'success',
};
}
const allRunning = envStatus.every(
(s) =>
s.Type === StatusType.Running ||

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