Compare commits

..

1 Commits

Author SHA1 Message Date
Steven Kang 0d8802c6d1 chore: version bump 2.33.0 (#1065) 2025-08-20 11:44:52 +12:00
267 changed files with 20000 additions and 26614 deletions
+2 -8
View File
@@ -17,7 +17,7 @@ plugins:
- import
parserOptions:
ecmaVersion: latest
ecmaVersion: 2018
sourceType: module
project: './tsconfig.json'
ecmaFeatures:
@@ -114,13 +114,7 @@ overrides:
'@typescript-eslint/explicit-module-boundary-types': off
'@typescript-eslint/no-unused-vars': 'error'
'@typescript-eslint/no-explicit-any': 'error'
'jsx-a11y/label-has-associated-control':
- error
- assert: either
controlComponents:
- Input
- Checkbox
'jsx-a11y/control-has-associated-label': off
'jsx-a11y/label-has-associated-control': ['error', { 'assert': 'either', controlComponents: ['Input', 'Checkbox'] }]
'react/function-component-definition': ['error', { 'namedComponents': 'function-declaration' }]
'react/jsx-no-bind': off
'no-await-in-loop': 'off'
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
cd $(dirname -- "$0") && pnpm lint-staged
cd $(dirname -- "$0") && yarn lint-staged
+1 -1
View File
@@ -77,7 +77,7 @@ The feature request process is similar to the bug report process but has an extr
## Build and run Portainer locally
Ensure you have Docker, Node.js, pnpm, and Golang installed in the correct versions.
Ensure you have Docker, Node.js, yarn, and Golang installed in the correct versions.
Install dependencies:
+10 -11
View File
@@ -26,7 +26,7 @@ all: tidy deps build-server build-client ## Build the client, server and downloa
build-all: all ## Alias for the 'all' target (used by CI)
build-client: init-dist ## Build the client
export NODE_ENV=$(ENV) && pnpm run build --config $(WEBPACK_CONFIG)
export NODE_ENV=$(ENV) && yarn build --config $(WEBPACK_CONFIG)
build-server: init-dist ## Build the server binary
./build/build_binary.sh "$(PLATFORM)" "$(ARCH)"
@@ -35,7 +35,7 @@ build-image: build-all ## Build the Portainer image locally
docker buildx build --load -t portainerci/portainer-ce:$(TAG) -f build/linux/Dockerfile .
build-storybook: ## Build and serve the storybook files
pnpm run storybook:build
yarn storybook:build
devops: clean deps build-client ## Build the everything target specifically for CI
echo "Building the devops binary..."
@@ -49,7 +49,7 @@ server-deps: init-dist ## Download dependant server binaries
@./build/download_binaries.sh $(PLATFORM) $(ARCH)
client-deps: ## Install client dependencies
pnpm install
yarn
tidy: ## Tidy up the go.mod file
@go mod tidy
@@ -67,7 +67,7 @@ clean: ## Remove all build and download artifacts
test: test-server test-client ## Run all tests
test-client: ## Run client tests
pnpm run test $(ARGS) --coverage
yarn test $(ARGS) --coverage
test-server: ## Run server tests
$(GOTESTSUM) --format pkgname-and-test-fails --format-hide-empty-pkg --hide-summary skipped -- -cover -covermode=atomic -coverprofile=coverage.out ./...
@@ -79,7 +79,7 @@ dev: ## Run both the client and server in development mode
make dev-client
dev-client: ## Run the client in development mode
pnpm run dev
yarn dev
dev-server: build-server ## Run the server in development mode
@./dev/run_container.sh
@@ -93,7 +93,7 @@ dev-server-podman: build-server ## Run the server in development mode
format: format-client format-server ## Format all code
format-client: ## Format client code
pnpm run format
yarn format
format-server: ## Format server code
go fmt ./...
@@ -103,9 +103,9 @@ format-server: ## Format server code
lint: lint-client lint-server ## Lint all code
lint-client: ## Lint client code
pnpm run lint
yarn lint
lint-server: tidy ## Lint server code
lint-server: ## Lint server code
golangci-lint run --timeout=10m -c .golangci.yaml
@@ -118,12 +118,11 @@ dev-extension: build-server build-client ## Run the extension in development mod
##@ Docs
.PHONY: docs-build docs-validate docs-clean docs-validate-clean
docs-build: init-dist ## Build docs
go mod download -x
cd api && $(SWAG) init -o "../dist/docs" -ot "yaml" -g ./http/handler/handler.go --parseDependency --parseInternal --parseDepth 2 -p pascalcase --markdownFiles ./
docs-validate: docs-build ## Validate docs
pnpm swagger2openapi --warnOnly dist/docs/swagger.yaml -o dist/docs/openapi.yaml
pnpm swagger-cli validate dist/docs/openapi.yaml
yarn swagger2openapi --warnOnly dist/docs/swagger.yaml -o dist/docs/openapi.yaml
yarn swagger-cli validate dist/docs/openapi.yaml
##@ Helpers
.PHONY: help
+1 -3
View File
@@ -9,8 +9,6 @@ import (
"os"
"path/filepath"
"strings"
"github.com/portainer/portainer/api/filesystem"
)
// TarGzDir creates a tar.gz archive and returns it's path.
@@ -107,7 +105,7 @@ func ExtractTarGz(r io.Reader, outputDirPath string) error {
case tar.TypeDir:
// skip, dir will be created with a file
case tar.TypeReg:
p := filesystem.JoinPaths(outputDirPath, header.Name)
p := filepath.Clean(filepath.Join(outputDirPath, header.Name))
if err := os.MkdirAll(filepath.Dir(p), 0o744); err != nil {
return fmt.Errorf("Failed to extract dir %s", filepath.Dir(p))
}
-57
View File
@@ -1,17 +1,13 @@
package archive
import (
"archive/tar"
"compress/gzip"
"os"
"os/exec"
"path"
"path/filepath"
"testing"
"github.com/portainer/portainer/api/filesystem"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func listFiles(dir string) []string {
@@ -88,56 +84,3 @@ func Test_shouldCreateArchive2(t *testing.T) {
wasExtracted("dir/inner")
wasExtracted("dir/.dotfile")
}
func TestExtractTarGzPathTraversal(t *testing.T) {
testDir := t.TempDir()
// Create an evil file with a path traversal attempt
tarPath := filesystem.JoinPaths(testDir, "evil.tar.gz")
evilFile, err := os.Create(tarPath)
require.NoError(t, err)
gzWriter := gzip.NewWriter(evilFile)
tarWriter := tar.NewWriter(gzWriter)
content := []byte("evil content")
header := &tar.Header{
Name: "../evil.txt",
Mode: 0600,
Size: int64(len(content)),
Typeflag: tar.TypeReg,
}
err = tarWriter.WriteHeader(header)
require.NoError(t, err)
_, err = tarWriter.Write(content)
require.NoError(t, err)
err = tarWriter.Close()
require.NoError(t, err)
err = gzWriter.Close()
require.NoError(t, err)
err = evilFile.Close()
require.NoError(t, err)
// Attempt to extract the evil file
extractionDir := filesystem.JoinPaths(testDir, "extraction")
err = os.Mkdir(extractionDir, 0700)
require.NoError(t, err)
tarFile, err := os.Open(tarPath)
require.NoError(t, err)
// Check that the file didn't escape
err = ExtractTarGz(tarFile, extractionDir)
require.NoError(t, err)
require.NoFileExists(t, filesystem.JoinPaths(testDir, "evil.txt"))
err = tarFile.Close()
require.NoError(t, err)
}
+12 -66
View File
@@ -9,8 +9,8 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/alecthomas/kingpin/v2"
"github.com/rs/zerolog/log"
"gopkg.in/alecthomas/kingpin.v2"
)
// Service implements the CLIService interface
@@ -35,9 +35,16 @@ func CLIFlags() *portainer.CLIFlags {
FeatureFlags: kingpin.Flag("feat", "List of feature flags").Strings(),
EnableEdgeComputeFeatures: kingpin.Flag("edge-compute", "Enable Edge Compute features").Bool(),
NoAnalytics: kingpin.Flag("no-analytics", "Disable Analytics in app (deprecated)").Bool(),
TLS: kingpin.Flag("tlsverify", "TLS support").Default(defaultTLS).Bool(),
TLSSkipVerify: kingpin.Flag("tlsskipverify", "Disable TLS server verification").Default(defaultTLSSkipVerify).Bool(),
TLSCacert: kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).String(),
TLSCert: kingpin.Flag("tlscert", "Path to the TLS certificate file").Default(defaultTLSCertPath).String(),
TLSKey: kingpin.Flag("tlskey", "Path to the TLS key").Default(defaultTLSKeyPath).String(),
HTTPDisabled: kingpin.Flag("http-disabled", "Serve portainer only on https").Default(defaultHTTPDisabled).Bool(),
HTTPEnabled: kingpin.Flag("http-enabled", "Serve portainer on http").Default(defaultHTTPEnabled).Bool(),
SSL: kingpin.Flag("ssl", "Secure Portainer instance using SSL (deprecated)").Default(defaultSSL).Bool(),
SSLCert: kingpin.Flag("sslcert", "Path to the SSL certificate used to secure the Portainer instance").String(),
SSLKey: kingpin.Flag("sslkey", "Path to the SSL key used to secure the Portainer instance").String(),
Rollback: kingpin.Flag("rollback", "Rollback the database to the previous backup").Bool(),
SnapshotInterval: kingpin.Flag("snapshot-interval", "Duration between each environment snapshot job").String(),
AdminPassword: kingpin.Flag("admin-password", "Set admin password with provided hash").String(),
@@ -56,7 +63,6 @@ func CLIFlags() *portainer.CLIFlags {
PullLimitCheckDisabled: kingpin.Flag("pull-limit-check-disabled", "Pull limit check").Envar(portainer.PullLimitCheckDisabledEnvVar).Default(defaultPullLimitCheckDisabled).Bool(),
TrustedOrigins: kingpin.Flag("trusted-origins", "List of trusted origins for CSRF protection. Separate multiple origins with a comma.").Envar(portainer.TrustedOriginsEnvVar).String(),
CSP: kingpin.Flag("csp", "Content Security Policy (CSP) header").Envar(portainer.CSPEnvVar).Default("true").Bool(),
CompactDB: kingpin.Flag("compact-db", "Enable database compaction on startup").Envar(portainer.CompactDBEnvVar).Default("false").Bool(),
}
}
@@ -64,37 +70,8 @@ func CLIFlags() *portainer.CLIFlags {
func (Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
kingpin.Version(version)
var hasSSLFlag, hasSSLCertFlag, hasSSLKeyFlag bool
sslFlag := kingpin.Flag(
"ssl",
"Secure Portainer instance using SSL (deprecated)",
).Default(defaultSSL).IsSetByUser(&hasSSLFlag)
ssl := sslFlag.Bool()
sslCertFlag := kingpin.Flag(
"sslcert",
"Path to the SSL certificate used to secure the Portainer instance",
).IsSetByUser(&hasSSLCertFlag)
sslCert := sslCertFlag.String()
sslKeyFlag := kingpin.Flag(
"sslkey",
"Path to the SSL key used to secure the Portainer instance",
).IsSetByUser(&hasSSLKeyFlag)
sslKey := sslKeyFlag.String()
flags := CLIFlags()
var hasTLSFlag, hasTLSCertFlag, hasTLSKeyFlag bool
tlsFlag := kingpin.Flag("tlsverify", "TLS support").Default(defaultTLS).IsSetByUser(&hasTLSFlag)
flags.TLS = tlsFlag.Bool()
tlsCertFlag := kingpin.Flag(
"tlscert",
"Path to the TLS certificate file",
).Default(defaultTLSCertPath).IsSetByUser(&hasTLSCertFlag)
flags.TLSCert = tlsCertFlag.String()
tlsKeyFlag := kingpin.Flag("tlskey", "Path to the TLS key").Default(defaultTLSKeyPath).IsSetByUser(&hasTLSKeyFlag)
flags.TLSKey = tlsKeyFlag.String()
flags.TLSCacert = kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).String()
kingpin.Parse()
if !filepath.IsAbs(*flags.Assets) {
@@ -106,41 +83,6 @@ func (Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
*flags.Assets = filepath.Join(filepath.Dir(ex), *flags.Assets)
}
// If the user didn't provide a tls flag remove the defaults to match previous behaviour
if !hasTLSFlag {
if !hasTLSCertFlag {
*flags.TLSCert = ""
}
if !hasTLSKeyFlag {
*flags.TLSKey = ""
}
}
if hasSSLFlag {
log.Warn().Msgf("the %q flag is deprecated. use %q instead.", sslFlag.Model().Name, tlsFlag.Model().Name)
if !hasTLSFlag {
flags.TLS = ssl
}
}
if hasSSLCertFlag {
log.Warn().Msgf("the %q flag is deprecated. use %q instead.", sslCertFlag.Model().Name, tlsCertFlag.Model().Name)
if !hasTLSCertFlag {
flags.TLSCert = sslCert
}
}
if hasSSLKeyFlag {
log.Warn().Msgf("the %q flag is deprecated. use %q instead.", sslKeyFlag.Model().Name, tlsKeyFlag.Model().Name)
if !hasTLSKeyFlag {
flags.TLSKey = sslKey
}
}
return flags, nil
}
@@ -167,6 +109,10 @@ func displayDeprecationWarnings(flags *portainer.CLIFlags) {
if *flags.NoAnalytics {
log.Warn().Msg("the --no-analytics flag has been kept to allow migration of instances running a previous version of Portainer with this flag enabled, to version 2.0 where enabling this flag will have no effect")
}
if *flags.SSL {
log.Warn().Msg("SSL is enabled by default and there is no need for the --ssl flag, it has been kept to allow migration of instances running a previous version of Portainer with this flag enabled")
}
}
func validateEndpointURL(endpointURL string) error {
-185
View File
@@ -1,12 +1,9 @@
package cli
import (
"io"
"os"
"strings"
"testing"
zerolog "github.com/rs/zerolog/log"
"github.com/stretchr/testify/require"
)
@@ -25,185 +22,3 @@ func TestOptionParser(t *testing.T) {
require.False(t, *opts.HTTPDisabled)
require.True(t, *opts.EnableEdgeComputeFeatures)
}
func TestParseTLSFlags(t *testing.T) {
testCases := []struct {
name string
args []string
expectedTLSFlag bool
expectedTLSCertFlag string
expectedTLSKeyFlag string
expectedLogMessages []string
}{
{
name: "no flags",
expectedTLSFlag: false,
expectedTLSCertFlag: "",
expectedTLSKeyFlag: "",
},
{
name: "only ssl flag",
args: []string{
"portainer",
"--ssl",
},
expectedTLSFlag: true,
expectedTLSCertFlag: "",
expectedTLSKeyFlag: "",
},
{
name: "only tls flag",
args: []string{
"portainer",
"--tlsverify",
},
expectedTLSFlag: true,
expectedTLSCertFlag: defaultTLSCertPath,
expectedTLSKeyFlag: defaultTLSKeyPath,
},
{
name: "partial ssl flags",
args: []string{
"portainer",
"--ssl",
"--sslcert=ssl-cert-flag-value",
},
expectedTLSFlag: true,
expectedTLSCertFlag: "ssl-cert-flag-value",
expectedTLSKeyFlag: "",
},
{
name: "partial tls flags",
args: []string{
"portainer",
"--tlsverify",
"--tlscert=tls-cert-flag-value",
},
expectedTLSFlag: true,
expectedTLSCertFlag: "tls-cert-flag-value",
expectedTLSKeyFlag: defaultTLSKeyPath,
},
{
name: "partial tls and ssl flags",
args: []string{
"portainer",
"--tlsverify",
"--tlscert=tls-cert-flag-value",
"--sslkey=ssl-key-flag-value",
},
expectedTLSFlag: true,
expectedTLSCertFlag: "tls-cert-flag-value",
expectedTLSKeyFlag: "ssl-key-flag-value",
},
{
name: "partial tls and ssl flags 2",
args: []string{
"portainer",
"--ssl",
"--tlscert=tls-cert-flag-value",
"--sslkey=ssl-key-flag-value",
},
expectedTLSFlag: true,
expectedTLSCertFlag: "tls-cert-flag-value",
expectedTLSKeyFlag: "ssl-key-flag-value",
},
{
name: "ssl flags",
args: []string{
"portainer",
"--ssl",
"--sslcert=ssl-cert-flag-value",
"--sslkey=ssl-key-flag-value",
},
expectedTLSFlag: true,
expectedTLSCertFlag: "ssl-cert-flag-value",
expectedTLSKeyFlag: "ssl-key-flag-value",
expectedLogMessages: []string{
"the \\\"ssl\\\" flag is deprecated. use \\\"tlsverify\\\" instead.",
"the \\\"sslcert\\\" flag is deprecated. use \\\"tlscert\\\" instead.",
"the \\\"sslkey\\\" flag is deprecated. use \\\"tlskey\\\" instead.",
},
},
{
name: "tls flags",
args: []string{
"portainer",
"--tlsverify",
"--tlscert=tls-cert-flag-value",
"--tlskey=tls-key-flag-value",
},
expectedTLSFlag: true,
expectedTLSCertFlag: "tls-cert-flag-value",
expectedTLSKeyFlag: "tls-key-flag-value",
},
{
name: "tls and ssl flags",
args: []string{
"portainer",
"--tlsverify",
"--tlscert=tls-cert-flag-value",
"--tlskey=tls-key-flag-value",
"--ssl",
"--sslcert=ssl-cert-flag-value",
"--sslkey=ssl-key-flag-value",
},
expectedTLSFlag: true,
expectedTLSCertFlag: "tls-cert-flag-value",
expectedTLSKeyFlag: "tls-key-flag-value",
expectedLogMessages: []string{
"the \\\"ssl\\\" flag is deprecated. use \\\"tlsverify\\\" instead.",
"the \\\"sslcert\\\" flag is deprecated. use \\\"tlscert\\\" instead.",
"the \\\"sslkey\\\" flag is deprecated. use \\\"tlskey\\\" instead.",
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var logOutput strings.Builder
setupLogOutput(t, &logOutput)
if tc.args == nil {
tc.args = []string{"portainer"}
}
setOsArgs(t, tc.args)
s := Service{}
flags, err := s.ParseFlags("test-version")
if err != nil {
t.Fatalf("error parsing flags: %v", err)
}
if flags.TLS == nil {
t.Fatal("TLS flag was nil")
}
require.Equal(t, tc.expectedTLSFlag, *flags.TLS, "tlsverify flag didn't match")
require.Equal(t, tc.expectedTLSCertFlag, *flags.TLSCert, "tlscert flag didn't match")
require.Equal(t, tc.expectedTLSKeyFlag, *flags.TLSKey, "tlskey flag didn't match")
for _, expectedLogMessage := range tc.expectedLogMessages {
require.Contains(t, logOutput.String(), expectedLogMessage, "Log didn't contain expected message")
}
})
}
}
func setOsArgs(t *testing.T, args []string) {
t.Helper()
previousArgs := os.Args
os.Args = args
t.Cleanup(func() {
os.Args = previousArgs
})
}
func setupLogOutput(t *testing.T, w io.Writer) {
t.Helper()
oldLogger := zerolog.Logger
zerolog.Logger = zerolog.Output(w)
t.Cleanup(func() {
zerolog.Logger = oldLogger
})
}
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"fmt"
"strings"
"github.com/alecthomas/kingpin/v2"
"gopkg.in/alecthomas/kingpin.v2"
)
type pairList []portainer.Pair
+5 -5
View File
@@ -84,7 +84,7 @@ func initFileService(dataStorePath string) portainer.FileService {
}
func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService portainer.FileService, shutdownCtx context.Context) dataservices.DataStore {
connection, err := database.NewDatabase("boltdb", *flags.Data, secretKey, *flags.CompactDB)
connection, err := database.NewDatabase("boltdb", *flags.Data, secretKey)
if err != nil {
log.Fatal().Err(err).Msg("failed creating database connection")
}
@@ -309,13 +309,13 @@ func initKeyPair(fileService portainer.FileService, signatureService portainer.D
// dbSecretPath build the path to the file that contains the db encryption
// secret. Normally in Docker this is built from the static path inside
// /run/secrets for example: /run/secrets/<keyFilenameFlag> but for ease of
// /run/portainer for example: /run/portainer/<keyFilenameFlag> but for ease of
// use outside Docker it also accepts an absolute path
func dbSecretPath(keyFilenameFlag string) string {
if path.IsAbs(keyFilenameFlag) {
return keyFilenameFlag
}
return path.Join("/run/secrets", keyFilenameFlag)
return path.Join("/run/portainer", keyFilenameFlag)
}
func loadEncryptionSecretKey(keyfilename string) []byte {
@@ -408,7 +408,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
edgeStacksService := edgestacks.NewService(dataStore)
sslService, err := initSSLService(*flags.AddrHTTPS, *flags.TLSCert, *flags.TLSKey, fileService, dataStore, shutdownTrigger)
sslService, err := initSSLService(*flags.AddrHTTPS, *flags.SSLCert, *flags.SSLKey, fileService, dataStore, shutdownTrigger)
if err != nil {
log.Fatal().Err(err).Msg("")
}
@@ -630,7 +630,7 @@ func main() {
Str("build_number", build.BuildNumber).
Str("image_tag", build.ImageTag).
Str("nodejs_version", build.NodejsVersion).
Str("pnpm_version", build.PnpmVersion).
Str("yarn_version", build.YarnVersion).
Str("webpack_version", build.WebpackVersion).
Str("go_version", build.GoVersion).
Msg("starting Portainer")
+4 -4
View File
@@ -43,12 +43,12 @@ func TestDBSecretPath(t *testing.T) {
keyFilenameFlag string
expected string
}{
{keyFilenameFlag: "secret.txt", expected: "/run/secrets/secret.txt"},
{keyFilenameFlag: "secret.txt", expected: "/run/portainer/secret.txt"},
{keyFilenameFlag: "/tmp/secret.txt", expected: "/tmp/secret.txt"},
{keyFilenameFlag: "/run/secrets/secret.txt", expected: "/run/secrets/secret.txt"},
{keyFilenameFlag: "./secret.txt", expected: "/run/secrets/secret.txt"},
{keyFilenameFlag: "/run/portainer/secret.txt", expected: "/run/portainer/secret.txt"},
{keyFilenameFlag: "./secret.txt", expected: "/run/portainer/secret.txt"},
{keyFilenameFlag: "../secret.txt", expected: "/run/secret.txt"},
{keyFilenameFlag: "foo/bar/secret.txt", expected: "/run/secrets/foo/bar/secret.txt"},
{keyFilenameFlag: "foo/bar/secret.txt", expected: "/run/portainer/foo/bar/secret.txt"},
}
for _, test := range tests {
-15
View File
@@ -388,18 +388,3 @@ func aesDecryptOFB(input io.Reader, passphrase []byte) (io.Reader, error) {
return reader, nil
}
// HasEncryptedHeader checks if the data has an encrypted header, note that fips
// mode changes this behavior and so will only recognize data encrypted by the
// same mode (fips enabled or disabled)
func HasEncryptedHeader(data []byte) bool {
return hasEncryptedHeader(data, fips.FIPSMode())
}
func hasEncryptedHeader(data []byte, fipsMode bool) bool {
if fipsMode {
return bytes.HasPrefix(data, []byte(aesGcmFIPSHeader))
}
return bytes.HasPrefix(data, []byte(aesGcmHeader))
}
-59
View File
@@ -350,62 +350,3 @@ func legacyAesEncrypt(input io.Reader, output io.Writer, passphrase []byte) erro
return nil
}
func Test_hasEncryptedHeader(t *testing.T) {
tests := []struct {
name string
data []byte
fipsMode bool
want bool
}{
{
name: "non-FIPS mode with valid header",
data: []byte("AES256-GCM" + "some encrypted data"),
fipsMode: false,
want: true,
},
{
name: "non-FIPS mode with FIPS header",
data: []byte("FIPS-AES256-GCM" + "some encrypted data"),
fipsMode: false,
want: false,
},
{
name: "FIPS mode with valid header",
data: []byte("FIPS-AES256-GCM" + "some encrypted data"),
fipsMode: true,
want: true,
},
{
name: "FIPS mode with non-FIPS header",
data: []byte("AES256-GCM" + "some encrypted data"),
fipsMode: true,
want: false,
},
{
name: "invalid header",
data: []byte("INVALID-HEADER" + "some data"),
fipsMode: false,
want: false,
},
{
name: "empty data",
data: []byte{},
fipsMode: false,
want: false,
},
{
name: "nil data",
data: nil,
fipsMode: false,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := hasEncryptedHeader(tt.data, tt.fipsMode)
assert.Equal(t, tt.want, got)
})
}
}
+8 -68
View File
@@ -21,9 +21,6 @@ import (
const (
DatabaseFileName = "portainer.db"
EncryptedDatabaseFileName = "portainer.edb"
txMaxSize = 65536
compactedSuffix = ".compacted"
)
var (
@@ -38,7 +35,6 @@ type DbConnection struct {
InitialMmapSize int
EncryptionKey []byte
isEncrypted bool
Compact bool
*bolt.DB
}
@@ -136,8 +132,15 @@ func (connection *DbConnection) NeedsEncryptionMigration() (bool, error) {
func (connection *DbConnection) Open() error {
log.Info().Str("filename", connection.GetDatabaseFileName()).Msg("loading PortainerDB")
// Now we open the db
databasePath := connection.GetDatabaseFilePath()
db, err := bolt.Open(databasePath, 0600, connection.boltOptions(connection.Compact))
db, err := bolt.Open(databasePath, 0600, &bolt.Options{
Timeout: 1 * time.Second,
InitialMmapSize: connection.InitialMmapSize,
FreelistType: bolt.FreelistMapType,
NoFreelistSync: true,
})
if err != nil {
return err
}
@@ -146,24 +149,6 @@ func (connection *DbConnection) Open() error {
db.MaxBatchDelay = connection.MaxBatchDelay
connection.DB = db
if connection.Compact {
log.Info().Msg("compacting database")
if err := connection.compact(); err != nil {
log.Error().Err(err).Msg("failed to compact database")
// Close the read-only database and re-open in read-write mode
if err := connection.Close(); err != nil {
log.Warn().Err(err).Msg("failure to close the database after failed compaction")
}
connection.Compact = false
return connection.Open()
} else {
log.Info().Msg("database compaction completed")
}
}
return nil
}
@@ -429,48 +414,3 @@ func (connection *DbConnection) RestoreMetadata(s map[string]any) error {
return err
}
// compact attempts to compact the database and replace it iff it succeeds
func (connection *DbConnection) compact() (err error) {
compactedPath := connection.GetDatabaseFilePath() + compactedSuffix
if err := os.Remove(compactedPath); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("failure to remove an existing compacted database: %w", err)
}
compactedDB, err := bolt.Open(compactedPath, 0o600, connection.boltOptions(false))
if err != nil {
return fmt.Errorf("failure to create the compacted database: %w", err)
}
compactedDB.MaxBatchSize = connection.MaxBatchSize
compactedDB.MaxBatchDelay = connection.MaxBatchDelay
if err := bolt.Compact(compactedDB, connection.DB, txMaxSize); err != nil {
return fmt.Errorf("failure to compact the database: %w",
errors.Join(err, compactedDB.Close(), os.Remove(compactedPath)))
}
if err := os.Rename(compactedPath, connection.GetDatabaseFilePath()); err != nil {
return fmt.Errorf("failure to move the compacted database: %w",
errors.Join(err, compactedDB.Close(), os.Remove(compactedPath)))
}
if err := connection.Close(); err != nil {
log.Warn().Err(err).Msg("failure to close the database after compaction")
}
connection.DB = compactedDB
return nil
}
func (connection *DbConnection) boltOptions(readOnly bool) *bolt.Options {
return &bolt.Options{
Timeout: 1 * time.Second,
InitialMmapSize: connection.InitialMmapSize,
FreelistType: bolt.FreelistMapType,
NoFreelistSync: true,
ReadOnly: readOnly,
}
}
-60
View File
@@ -5,11 +5,7 @@ import (
"path"
"testing"
"github.com/portainer/portainer/api/filesystem"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.etcd.io/bbolt"
)
func Test_NeedsEncryptionMigration(t *testing.T) {
@@ -123,59 +119,3 @@ func Test_NeedsEncryptionMigration(t *testing.T) {
})
}
}
func TestDBCompaction(t *testing.T) {
db := &DbConnection{Path: t.TempDir()}
err := db.Open()
require.NoError(t, err)
err = db.Update(func(tx *bbolt.Tx) error {
b, err := tx.CreateBucketIfNotExists([]byte("testbucket"))
if err != nil {
return err
}
b.Put([]byte("key"), []byte("value"))
return nil
})
require.NoError(t, err)
err = db.Close()
require.NoError(t, err)
// Reopen the DB to trigger compaction
db.Compact = true
err = db.Open()
require.NoError(t, err)
// Check that the data is still there
err = db.View(func(tx *bbolt.Tx) error {
b := tx.Bucket([]byte("testbucket"))
if b == nil {
return nil
}
val := b.Get([]byte("key"))
require.Equal(t, []byte("value"), val)
return nil
})
require.NoError(t, err)
err = db.Close()
require.NoError(t, err)
// Failures
compactedPath := db.GetDatabaseFilePath() + compactedSuffix
err = os.Mkdir(compactedPath, 0o755)
require.NoError(t, err)
f, err := os.Create(filesystem.JoinPaths(compactedPath, "somefile"))
require.NoError(t, err)
require.NoError(t, f.Close())
err = db.Open()
require.NoError(t, err)
}
+2 -2
View File
@@ -45,12 +45,12 @@ func (connection *DbConnection) UnmarshalObject(data []byte, object any) error {
}
}
if err := json.Unmarshal(data, object); err != nil {
if e := json.Unmarshal(data, object); e != nil {
// Special case for the VERSION bucket. Here we're not using json
// So we need to return it as a string
s, ok := object.(*string)
if !ok {
return errors.Wrap(err, "Failed unmarshalling object")
return errors.Wrap(err, e.Error())
}
*s = string(data)
+1 -2
View File
@@ -8,12 +8,11 @@ import (
)
// NewDatabase should use config options to return a connection to the requested database
func NewDatabase(storeType, storePath string, encryptionKey []byte, compact bool) (connection portainer.Connection, err error) {
func NewDatabase(storeType, storePath string, encryptionKey []byte) (connection portainer.Connection, err error) {
if storeType == "boltdb" {
return &boltdb.DbConnection{
Path: storePath,
EncryptionKey: encryptionKey,
Compact: compact,
}, nil
}
@@ -28,12 +28,13 @@ func NewService(connection portainer.Connection) (*Service, error) {
}, nil
}
// CreateCustomTemplate uses the existing id and saves it.
// TODO: where does the ID come from, and is it safe?
func (service *Service) Create(customTemplate *portainer.CustomTemplate) error {
return service.Connection.CreateObjectWithId(BucketName, int(customTemplate.ID), customTemplate)
}
// GetNextIdentifier returns the next identifier for a custom template.
func (service *Service) GetNextIdentifier() int {
return service.Connection.GetNextIdentifier(BucketName)
}
func (service *Service) Create(customTemplate *portainer.CustomTemplate) error {
return service.Connection.UpdateTx(func(tx portainer.Transaction) error {
return service.Tx(tx).Create(customTemplate)
})
}
@@ -1,19 +0,0 @@
package customtemplate_test
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/stretchr/testify/require"
)
func TestCustomTemplateCreate(t *testing.T) {
_, ds := datastore.MustNewTestStore(t, true, false)
require.NotNil(t, ds)
require.NoError(t, ds.CustomTemplate().Create(&portainer.CustomTemplate{ID: 1}))
e, err := ds.CustomTemplate().Read(1)
require.NoError(t, err)
require.Equal(t, portainer.CustomTemplateID(1), e.ID)
}
-31
View File
@@ -1,31 +0,0 @@
package customtemplate
import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
)
// Service represents a service for managing custom template data.
type ServiceTx struct {
dataservices.BaseDataServiceTx[portainer.CustomTemplate, portainer.CustomTemplateID]
}
func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
return ServiceTx{
BaseDataServiceTx: dataservices.BaseDataServiceTx[portainer.CustomTemplate, portainer.CustomTemplateID]{
Bucket: BucketName,
Connection: service.Connection,
Tx: tx,
},
}
}
func (service ServiceTx) GetNextIdentifier() int {
return service.Tx.GetNextIdentifier(BucketName)
}
// CreateCustomTemplate uses the existing id and saves it.
// TODO: where does the ID come from, and is it safe?
func (service ServiceTx) Create(customTemplate *portainer.CustomTemplate) error {
return service.Tx.CreateObjectWithId(BucketName, int(customTemplate.ID), customTemplate)
}
@@ -1,28 +0,0 @@
package customtemplate_test
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/stretchr/testify/require"
)
func TestCustomTemplateCreateTx(t *testing.T) {
_, ds := datastore.MustNewTestStore(t, true, false)
require.NotNil(t, ds)
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.CustomTemplate().Create(&portainer.CustomTemplate{ID: 1})
}))
var template *portainer.CustomTemplate
require.NoError(t, ds.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
template, err = tx.CustomTemplate().Read(1)
return err
}))
require.Equal(t, portainer.CustomTemplateID(1), template.ID)
}
@@ -1,8 +1,13 @@
package pendingactions
import (
"fmt"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/rs/zerolog/log"
)
const BucketName = "pending_actions"
@@ -11,6 +16,10 @@ type Service struct {
dataservices.BaseDataService[portainer.PendingAction, portainer.PendingActionID]
}
type ServiceTx struct {
dataservices.BaseDataServiceTx[portainer.PendingAction, portainer.PendingActionID]
}
func NewService(connection portainer.Connection) (*Service, error) {
err := connection.SetServiceName(BucketName)
if err != nil {
@@ -25,11 +34,6 @@ func NewService(connection portainer.Connection) (*Service, error) {
}, nil
}
// GetNextIdentifier returns the next identifier for a custom template.
func (service *Service) GetNextIdentifier() int {
return service.Connection.GetNextIdentifier(BucketName)
}
func (s Service) Create(config *portainer.PendingAction) error {
return s.Connection.UpdateTx(func(tx portainer.Transaction) error {
return s.Tx(tx).Create(config)
@@ -57,3 +61,43 @@ func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
},
}
}
func (s ServiceTx) Create(config *portainer.PendingAction) error {
return s.Tx.CreateObject(BucketName, func(id uint64) (int, any) {
config.ID = portainer.PendingActionID(id)
config.CreatedAt = time.Now().Unix()
return int(config.ID), config
})
}
func (s ServiceTx) Update(ID portainer.PendingActionID, config *portainer.PendingAction) error {
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.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 {
if err := s.Delete(pendingAction.ID); 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)
}
-49
View File
@@ -1,49 +0,0 @@
package pendingactions
import (
"fmt"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/rs/zerolog/log"
)
type ServiceTx struct {
dataservices.BaseDataServiceTx[portainer.PendingAction, portainer.PendingActionID]
}
func (s ServiceTx) Create(config *portainer.PendingAction) error {
return s.Tx.CreateObject(BucketName, func(id uint64) (int, any) {
config.ID = portainer.PendingActionID(id)
config.CreatedAt = time.Now().Unix()
return int(config.ID), config
})
}
func (s ServiceTx) Update(ID portainer.PendingActionID, config *portainer.PendingAction) error {
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.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 {
if err := s.Delete(pendingAction.ID); 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)
}
-1
View File
@@ -60,7 +60,6 @@ func (store *Store) checkOrCreateDefaultSettings() error {
KubectlShellImage: *store.flags.KubectlShellImage,
IsDockerDesktopExtension: isDDExtention,
EnforceEdgeID: true,
}
return store.SettingsService.UpdateSettings(defaultSettings)
+1 -1
View File
@@ -256,7 +256,7 @@ func (m *Migrator) initMigrations() {
m.addMigrations("2.32.0", m.addEndpointRelationForEdgeAgents_2_32_0)
m.addMigrations("2.33.1", m.migrateEdgeGroupEndpointsToRoars_2_33_0)
m.addMigrations("2.33.0", m.migrateEdgeGroupEndpointsToRoars_2_33_0)
// Add new migrations above...
// One function per migration, each versions migration funcs in the same file.
+1 -3
View File
@@ -14,9 +14,7 @@ func (tx *StoreTx) IsErrObjectNotFound(err error) bool {
return tx.store.IsErrObjectNotFound(err)
}
func (tx *StoreTx) CustomTemplate() dataservices.CustomTemplateService {
return tx.store.CustomTemplateService.Tx(tx.tx)
}
func (tx *StoreTx) CustomTemplate() dataservices.CustomTemplateService { return nil }
func (tx *StoreTx) PendingActions() dataservices.PendingActionsService {
return tx.store.PendingActionsService.Tx(tx.tx)
@@ -615,7 +615,7 @@
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
"KubectlShellImage": "portainer/kubectl-shell:2.33.8",
"KubectlShellImage": "portainer/kubectl-shell:2.33.0",
"LDAPSettings": {
"AnonymousMode": true,
"AutoCreateUsers": true,
@@ -944,7 +944,7 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.33.8\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.33.0\",\"MigratorCount\":1,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null
}
+1 -1
View File
@@ -44,7 +44,7 @@ func NewTestStore(t testing.TB, init, secure bool) (bool, *Store, func(), error)
secretKey = nil
}
connection, err := database.NewDatabase("boltdb", storePath, secretKey, false)
connection, err := database.NewDatabase("boltdb", storePath, secretKey)
if err != nil {
panic(err)
}
-5
View File
@@ -49,11 +49,6 @@ type (
// Is relative path supported
SupportRelativePath bool
// AlwaysCloneGitRepoForRelativePath is a flag indicating if the agent must always clone the git repository for relative path.
// This field is only valid when SupportRelativePath is true.
// Used only for EE
AlwaysCloneGitRepoForRelativePath bool
// Mount point for relative path
FilesystemPath string
// Used only for EE
+1 -1
View File
@@ -183,7 +183,7 @@ func (manager *SwarmStackManager) prepareDockerCommandAndArgs(binaryPath, config
if !endpoint.TLSConfig.TLSSkipVerify {
args = append(args, "--tlsverify", "--tlscacert", endpoint.TLSConfig.TLSCACertPath)
} else {
args = append(args, "--tlscacert", "")
args = append(args, "--tlscacert", "''")
}
if endpoint.TLSConfig.TLSCertPath != "" && endpoint.TLSConfig.TLSKeyPath != "" {
-28
View File
@@ -3,9 +3,7 @@ package exec
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestConfigFilePaths(t *testing.T) {
@@ -15,29 +13,3 @@ func TestConfigFilePaths(t *testing.T) {
output := configureFilePaths(args, filePaths)
assert.ElementsMatch(t, expected, output, "wrong output file paths")
}
func TestPrepareDockerCommandAndArgs(t *testing.T) {
binaryPath := "/test/dist"
configPath := "/test/config"
manager := &SwarmStackManager{
binaryPath: binaryPath,
configPath: configPath,
}
endpoint := &portainer.Endpoint{
URL: "tcp://test:9000",
TLSConfig: portainer.TLSConfiguration{
TLS: true,
TLSSkipVerify: true,
},
}
command, args, err := manager.prepareDockerCommandAndArgs(binaryPath, configPath, endpoint)
require.NoError(t, err)
expectedCommand := "/test/dist/docker"
expectedArgs := []string{"--config", "/test/config", "-H", "tcp://test:9000", "--tls", "--tlscacert", ""}
require.Equal(t, expectedCommand, command)
require.Equal(t, expectedArgs, args)
}
+1 -1
View File
@@ -848,7 +848,7 @@ func defaultMTLSCertPathUnderFileStore() (string, string, string) {
return caCertPath, certPath, keyPath
}
// GetDefaultChiselPrivateKeyPath returns the chisel private key path
// GetDefaultChiselPrivateKeyPath returns the chisle private key path
func (service *Service) GetDefaultChiselPrivateKeyPath() string {
privateKeyPath := defaultChiselPrivateKeyPathUnderFileStore()
return service.wrapFileStore(privateKeyPath)
-18
View File
@@ -167,21 +167,3 @@ func DecodeDirEntries(dirEntries []DirEntry) error {
return nil
}
// GetDirEntriesByFilenames returns the dir entries that are files and match the provided filenames
func GetDirEntriesByFilenames(dirEntries []DirEntry, names []string) []DirEntry {
var filteredDirEntries []DirEntry
for _, dirEntry := range dirEntries {
if !dirEntry.IsFile {
continue
}
for _, name := range names {
if dirEntry.Name == name {
filteredDirEntries = append(filteredDirEntries, dirEntry)
}
}
}
return filteredDirEntries
}
@@ -30,20 +30,6 @@ func MultiFilterDirForPerDevConfigs(dirEntries []DirEntry, configPath string, mu
return deduplicate(filteredDirEntries), envFiles
}
// MultiFilterDirForPerDevConfigsWithDefaults filers the given dirEntries with multiple filter args, returns the merged entries for the given device
// and always includes the defaultFilenames
func MultiFilterDirForPerDevConfigsWithDefaults(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs, defaultFilenames []string) ([]DirEntry, []string) {
filteredDirEntries, envFiles := MultiFilterDirForPerDevConfigs(dirEntries, configPath, multiFilterArgs)
// Add files that should always be included
// e.g. entrypoint files
defaultDirEntries := GetDirEntriesByFilenames(dirEntries, defaultFilenames)
filteredDirEntries = append(filteredDirEntries, defaultDirEntries...)
return deduplicate(filteredDirEntries), envFiles
}
func deduplicate(dirEntries []DirEntry) []DirEntry {
var deduplicatedDirEntries []DirEntry
@@ -49,11 +49,8 @@ func TestMultiFilterDirForPerDevConfigs(t *testing.T) {
f(
baseDirEntries,
"configs",
MultiFilterArgs{
{"file1", portainer.PerDevConfigsTypeFile},
{"folder1", portainer.PerDevConfigsTypeDir},
},
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3], baseDirEntries[5], baseDirEntries[6]},
MultiFilterArgs{{"folder1", portainer.PerDevConfigsTypeDir}},
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[5], baseDirEntries[6]},
)
// Filter file1 and file2
@@ -79,106 +76,6 @@ func TestMultiFilterDirForPerDevConfigs(t *testing.T) {
)
}
func TestMultiFilterDirForPerDevConfigsWithDefaults(t *testing.T) {
f := func(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs, defaultFilenames []string, wantDirEntries []DirEntry) {
t.Helper()
dirEntries, _ = MultiFilterDirForPerDevConfigsWithDefaults(dirEntries, configPath, multiFilterArgs, defaultFilenames)
require.Equal(t, wantDirEntries, dirEntries)
}
baseDirEntries := []DirEntry{
{".env", "", true, 420},
{"docker-compose.yaml", "", true, 420},
{"configs", "", false, 420},
{"configs/file1.conf", "", true, 420},
{"configs/file2.conf", "", true, 420},
{"configs/folder1", "", false, 420},
{"configs/folder1/config1", "", true, 420},
{"configs/folder2", "", false, 420},
{"configs/folder2/config2", "", true, 420},
{"configs/docker-compose-2.yaml", "", true, 420},
{"configs/folder2/docker-compose-3.yaml", "", true, 420},
}
// Filter file1
f(
baseDirEntries,
"configs",
MultiFilterArgs{{"file1", portainer.PerDevConfigsTypeFile}},
nil,
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3]},
)
// Filter folder1
f(
baseDirEntries,
"configs",
MultiFilterArgs{{"folder1", portainer.PerDevConfigsTypeDir}},
nil,
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[5], baseDirEntries[6]},
)
// Filter file1 and folder1
f(
baseDirEntries,
"configs",
MultiFilterArgs{
{"file1", portainer.PerDevConfigsTypeFile},
{"folder1", portainer.PerDevConfigsTypeDir},
},
nil,
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3], baseDirEntries[5], baseDirEntries[6]},
)
// Filter file1 and file2
f(
baseDirEntries,
"configs",
MultiFilterArgs{
{"file1", portainer.PerDevConfigsTypeFile},
{"file2", portainer.PerDevConfigsTypeFile},
},
nil,
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3], baseDirEntries[4]},
)
// Filter folder1 and folder2
f(
baseDirEntries,
"configs",
MultiFilterArgs{
{"folder1", portainer.PerDevConfigsTypeDir},
{"folder2", portainer.PerDevConfigsTypeDir},
},
nil,
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[5], baseDirEntries[6], baseDirEntries[7], baseDirEntries[8], baseDirEntries[10]},
)
// Filter file1 and folder1 and docker-compose-2.yaml
f(
baseDirEntries,
"configs",
MultiFilterArgs{
{"file1", portainer.PerDevConfigsTypeFile},
{"folder1", portainer.PerDevConfigsTypeDir},
},
[]string{"configs/docker-compose-2.yaml"},
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3], baseDirEntries[5], baseDirEntries[6], baseDirEntries[9]},
)
// Filter file1 and docker-compose-3.yaml
f(
baseDirEntries,
"configs",
MultiFilterArgs{
{"file1", portainer.PerDevConfigsTypeFile},
},
[]string{"configs/folder2/docker-compose-3.yaml"},
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3], baseDirEntries[10]},
)
}
func TestMultiFilterDirForPerDevConfigsEnvFiles(t *testing.T) {
f := func(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs, wantEnvFiles []string) {
t.Helper()
+20 -51
View File
@@ -3,42 +3,23 @@ package git
import (
"context"
"os"
"path/filepath"
"strings"
"github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/rs/zerolog/log"
"github.com/go-git/go-billy/v5"
"github.com/go-git/go-billy/v5/osfs"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/cache"
"github.com/go-git/go-git/v5/plumbing/filemode"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/plumbing/transport"
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
gogitfs "github.com/go-git/go-git/v5/storage/filesystem"
"github.com/go-git/go-git/v5/storage/memory"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)
// noSymlinkFS wraps a billy.Filesystem and rejects symlink creation to prevent
// symlink traversal attacks from untrusted git repositories
type noSymlinkFS struct {
billy.Filesystem
}
func (fs noSymlinkFS) Symlink(_, _ string) error {
return gittypes.ErrSymlinkDetected
}
// NewNoSymlinkFS wraps fs and rejects any symlink creation
func NewNoSymlinkFS(fs billy.Filesystem) billy.Filesystem {
return noSymlinkFS{fs}
}
type gitClient struct {
preserveGitDirectory bool
}
@@ -49,33 +30,8 @@ func NewGitClient(preserveGitDir bool) *gitClient {
}
}
func (c *gitClient) Download(ctx context.Context, dst string, opt *git.CloneOptions) error {
wt := NewNoSymlinkFS(osfs.New(dst))
dot := osfs.New(filesystem.JoinPaths(dst, ".git"))
storer := gogitfs.NewStorage(dot, cache.NewObjectLRU(0))
_, err := git.CloneContext(ctx, storer, wt, opt)
if err != nil {
if err.Error() == "authentication required" {
return gittypes.ErrAuthenticationFailure
}
return errors.Wrap(err, "failed to clone git repository")
}
if c.preserveGitDirectory {
return nil
}
if err := os.RemoveAll(filesystem.JoinPaths(dst, ".git")); err != nil {
log.Error().Err(err).Msg("failed to remove .git directory")
}
return nil
}
func (c *gitClient) download(ctx context.Context, dst string, opt cloneOption) error {
gitOptions := &git.CloneOptions{
gitOptions := git.CloneOptions{
URL: opt.repositoryUrl,
Depth: opt.depth,
InsecureSkipTLS: opt.tlsSkipVerify,
@@ -87,7 +43,23 @@ func (c *gitClient) download(ctx context.Context, dst string, opt cloneOption) e
gitOptions.ReferenceName = plumbing.ReferenceName(opt.referenceName)
}
return c.Download(ctx, dst, gitOptions)
_, err := git.PlainCloneContext(ctx, dst, false, &gitOptions)
if err != nil {
if err.Error() == "authentication required" {
return gittypes.ErrAuthenticationFailure
}
return errors.Wrap(err, "failed to clone git repository")
}
if !c.preserveGitDirectory {
err := os.RemoveAll(filepath.Join(dst, ".git"))
if err != nil {
log.Error().Err(err).Msg("failed to remove .git directory")
}
}
return nil
}
func (c *gitClient) latestCommitID(ctx context.Context, opt fetchOption) (string, error) {
@@ -106,7 +78,6 @@ func (c *gitClient) latestCommitID(ctx context.Context, opt fetchOption) (string
if err.Error() == "authentication required" {
return "", gittypes.ErrAuthenticationFailure
}
return "", errors.Wrap(err, "failed to list repository refs")
}
@@ -188,7 +159,6 @@ func (c *gitClient) listRefs(ctx context.Context, opt baseOption) ([]string, err
if ref.Name().String() == "HEAD" {
continue
}
ret = append(ret, ref.Name().String())
}
@@ -255,6 +225,5 @@ func checkGitError(err error) error {
} else if errMsg == "authentication required" {
return gittypes.ErrAuthenticationFailure
}
return err
}
+3 -114
View File
@@ -3,26 +3,21 @@ package git
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/portainer/portainer/api/archive"
"github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/go-git/go-billy/v5/osfs"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/filemode"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setup(t *testing.T) string {
dir := t.TempDir()
bareRepoDir := filesystem.JoinPaths(dir, "test-clone.git")
bareRepoDir := filepath.Join(dir, "test-clone.git")
file, err := os.OpenFile("./testdata/test-clone-git-repo.tar.gz", os.O_RDONLY, 0755)
if err != nil {
@@ -57,7 +52,7 @@ func Test_ClonePublicRepository_NoGitDirectory(t *testing.T) {
t.Logf("Cloning into %s", dir)
err := service.CloneRepository(dir, repositoryURL, referenceName, "", "", gittypes.GitCredentialAuthType_Basic, false)
assert.NoError(t, err)
assert.NoDirExists(t, filesystem.JoinPaths(dir, ".git"))
assert.NoDirExists(t, filepath.Join(dir, ".git"))
}
func Test_cloneRepository(t *testing.T) {
@@ -150,112 +145,6 @@ func getCommitHistoryLength(t *testing.T, dir string) int {
return count
}
func Test_noSymlinkFS_Symlink(t *testing.T) {
fs := NewNoSymlinkFS(osfs.New(t.TempDir()))
err := fs.Symlink("../../../etc/passwd", "evil-link")
require.ErrorIs(t, err, gittypes.ErrSymlinkDetected)
}
func Test_noSymlinkFS_OtherOperations(t *testing.T) {
dir := t.TempDir()
fs := NewNoSymlinkFS(osfs.New(dir))
f, err := fs.Create("test.txt")
require.NoError(t, err)
_, err = f.Write([]byte("hello"))
require.NoError(t, err)
err = f.Close()
require.NoError(t, err)
info, err := fs.Stat("test.txt")
require.NoError(t, err)
require.Equal(t, "test.txt", info.Name())
}
func createBareRepoWithSymlink(t *testing.T) string {
t.Helper()
bareDir := filesystem.JoinPaths(t.TempDir(), "symlink-repo.git")
repo, err := git.PlainInit(bareDir, true)
require.NoError(t, err)
storer := repo.Storer
fileBlob := &plumbing.MemoryObject{}
fileBlob.SetType(plumbing.BlobObject)
_, err = fileBlob.Write([]byte("hello world\n"))
require.NoError(t, err)
fileHash, err := storer.SetEncodedObject(fileBlob)
require.NoError(t, err)
symlinkBlob := &plumbing.MemoryObject{}
symlinkBlob.SetType(plumbing.BlobObject)
_, err = symlinkBlob.Write([]byte("../../../etc/passwd"))
require.NoError(t, err)
symlinkHash, err := storer.SetEncodedObject(symlinkBlob)
require.NoError(t, err)
tree := &object.Tree{
Entries: []object.TreeEntry{
{Name: "evil-link", Mode: filemode.Symlink, Hash: symlinkHash},
{Name: "file.txt", Mode: filemode.Regular, Hash: fileHash},
},
}
treeObj := &plumbing.MemoryObject{}
err = tree.Encode(treeObj)
require.NoError(t, err)
treeHash, err := storer.SetEncodedObject(treeObj)
require.NoError(t, err)
sig := object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()}
commit := &object.Commit{
Message: "add symlink",
Author: sig,
Committer: sig,
TreeHash: treeHash,
}
commitObj := &plumbing.MemoryObject{}
err = commit.Encode(commitObj)
require.NoError(t, err)
commitHash, err := storer.SetEncodedObject(commitObj)
require.NoError(t, err)
err = storer.SetReference(plumbing.NewHashReference("refs/heads/main", commitHash))
require.NoError(t, err)
err = storer.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, "refs/heads/main"))
require.NoError(t, err)
return bareDir
}
func Test_Download_RejectsSymlink(t *testing.T) {
client := NewGitClient(false)
repoURL := createBareRepoWithSymlink(t)
err := client.Download(t.Context(), t.TempDir(), &git.CloneOptions{
URL: repoURL,
Depth: 1,
SingleBranch: true,
Tags: git.NoTags,
})
require.Error(t, err)
require.ErrorIs(t, err, gittypes.ErrSymlinkDetected)
}
func Test_listRefsPrivateRepository(t *testing.T) {
ensureIntegrationTest(t)
-1
View File
@@ -7,7 +7,6 @@ import (
var (
ErrIncorrectRepositoryURL = errors.New("git repository could not be found, please ensure that the URL is correct")
ErrAuthenticationFailure = errors.New("authentication failed, please ensure that the git credentials are correct")
ErrSymlinkDetected = errors.New("repository contains a symlink, which is not allowed for security reasons")
)
type GitCredentialAuthType int
+4 -8
View File
@@ -21,14 +21,10 @@ func ValidateAutoUpdateSettings(autoUpdate *portainer.AutoUpdateSettings) error
return httperrors.NewInvalidPayloadError("invalid Webhook format")
}
if autoUpdate.Interval == "" {
return nil
}
if d, err := time.ParseDuration(autoUpdate.Interval); err != nil {
return httperrors.NewInvalidPayloadError("invalid Interval format")
} else if d < time.Minute {
return httperrors.NewInvalidPayloadError("interval must be at least 1 minute")
if autoUpdate.Interval != "" {
if _, err := time.ParseDuration(autoUpdate.Interval); err != nil {
return httperrors.NewInvalidPayloadError("invalid Interval format")
}
}
return nil
-10
View File
@@ -23,16 +23,6 @@ func Test_ValidateAutoUpdate(t *testing.T) {
value: &portainer.AutoUpdateSettings{Interval: "1dd2hh3mm"},
wantErr: true,
},
{
name: "short interval value",
value: &portainer.AutoUpdateSettings{Interval: "1s"},
wantErr: true,
},
{
name: "valid webhook without interval",
value: &portainer.AutoUpdateSettings{Webhook: "8dce8c2f-9ca1-482b-ad20-271e86536ada"},
wantErr: false,
},
{
name: "valid auto update",
value: &portainer.AutoUpdateSettings{
+2 -1
View File
@@ -26,10 +26,11 @@ func (handler *Handler) logout(w http.ResponseWriter, r *http.Request) *httperro
handler.KubernetesTokenCacheManager.RemoveUserFromCache(tokenData.ID)
handler.KubernetesClientFactory.ClearUserClientCache(strconv.Itoa(int(tokenData.ID)))
logoutcontext.Cancel(tokenData.Token)
handler.bouncer.RevokeJWT(tokenData.Token)
}
security.RemoveAuthCookie(w)
handler.bouncer.RevokeJWT(tokenData.Token)
return response.Empty(w)
}
-55
View File
@@ -1,55 +0,0 @@
package auth
import (
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/kubernetes/cli"
"github.com/stretchr/testify/require"
)
type mockBouncer struct {
security.BouncerService
}
func NewMockBouncer() *mockBouncer {
return &mockBouncer{BouncerService: testhelpers.NewTestRequestBouncer()}
}
func (*mockBouncer) CookieAuthLookup(r *http.Request) (*portainer.TokenData, error) {
return &portainer.TokenData{
ID: 1,
Username: "testuser",
Token: "valid-token",
}, nil
}
func TestLogout(t *testing.T) {
h := NewHandler(NewMockBouncer(), nil, nil, nil)
h.KubernetesTokenCacheManager = kubernetes.NewTokenCacheManager()
k, err := cli.NewClientFactory(nil, nil, nil, "", "", "")
require.NoError(t, err)
h.KubernetesClientFactory = k
rr := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/auth/logout", nil)
h.ServeHTTP(rr, req)
require.Equal(t, http.StatusNoContent, rr.Code)
}
func TestLogoutNoPanic(t *testing.T) {
h := NewHandler(testhelpers.NewTestRequestBouncer(), nil, nil, nil)
rr := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/auth/logout", nil)
h.ServeHTTP(rr, req)
require.Equal(t, http.StatusNoContent, rr.Code)
}
@@ -2,14 +2,8 @@ package customtemplates
import (
"net/http"
"strconv"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/slicesx"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -39,46 +33,11 @@ func (handler *Handler) customTemplateFile(w http.ResponseWriter, r *http.Reques
return httperror.BadRequest("Invalid custom template identifier route variable", err)
}
var customTemplate *portainer.CustomTemplate
if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
customTemplate, err = tx.CustomTemplate().Read(portainer.CustomTemplateID(customTemplateID))
if tx.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find a custom template with the specified identifier inside the database", err)
} else if err != nil {
return httperror.InternalServerError("Unable to find a custom template with the specified identifier inside the database", err)
}
resourceControl, err := tx.ResourceControl().ResourceControlByResourceIDAndType(strconv.Itoa(customTemplateID), portainer.CustomTemplateResourceControl)
if err != nil {
return httperror.InternalServerError("Unable to retrieve a resource control associated to the custom template", err)
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve user info from request context", err)
}
canEdit := userCanEditTemplate(customTemplate, securityContext)
hasAccess := false
if resourceControl != nil {
customTemplate.ResourceControl = resourceControl
teamIDs := slicesx.Map(securityContext.UserMemberships, func(m portainer.TeamMembership) portainer.TeamID {
return m.TeamID
})
hasAccess = authorization.UserCanAccessResource(securityContext.UserID, teamIDs, resourceControl)
}
if canEdit || hasAccess {
return nil
}
return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied)
}); err != nil {
return response.TxErrorResponse(err)
customTemplate, err := handler.DataStore.CustomTemplate().Read(portainer.CustomTemplateID(customTemplateID))
if handler.DataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find a custom template with the specified identifier inside the database", err)
} else if err != nil {
return httperror.InternalServerError("Unable to find a custom template with the specified identifier inside the database", err)
}
entryPath := customTemplate.EntryPoint
@@ -1,115 +0,0 @@
package customtemplates
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gorilla/mux"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/require"
)
func TestCustomTemplateFile(t *testing.T) {
_, ds := datastore.MustNewTestStore(t, true, false)
require.NotNil(t, ds)
fs, err := filesystem.NewService(t.TempDir(), t.TempDir())
require.NoError(t, err)
templateContent := "some template content"
templateEntrypoint := "entrypoint"
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}))
require.NoError(t, tx.User().Create(&portainer.User{ID: 2, Username: "std2", Role: portainer.StandardUserRole}))
require.NoError(t, tx.User().Create(&portainer.User{ID: 3, Username: "std3", Role: portainer.StandardUserRole}))
require.NoError(t, tx.User().Create(&portainer.User{ID: 4, Username: "std4", Role: portainer.StandardUserRole}))
require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{ID: 1,
UserAccessPolicies: portainer.UserAccessPolicies{
2: portainer.AccessPolicy{RoleID: 0},
3: portainer.AccessPolicy{RoleID: 0},
}}))
require.NoError(t, tx.Team().Create(&portainer.Team{ID: 1}))
require.NoError(t, tx.TeamMembership().Create(&portainer.TeamMembership{ID: 1, UserID: 3, TeamID: 1, Role: portainer.TeamMember}))
// template 1
path, err := fs.StoreCustomTemplateFileFromBytes("1", templateEntrypoint, []byte(templateContent))
require.NoError(t, err)
require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{ID: 1, EntryPoint: templateEntrypoint, ProjectPath: path}))
// template 2
path, err = fs.StoreCustomTemplateFileFromBytes("2", templateEntrypoint, []byte(templateContent))
require.NoError(t, err)
require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{ID: 2, EntryPoint: templateEntrypoint, ProjectPath: path}))
require.NoError(t, tx.ResourceControl().Create(&portainer.ResourceControl{ID: 1, ResourceID: "2", Type: portainer.CustomTemplateResourceControl,
UserAccesses: []portainer.UserResourceAccess{{UserID: 2}},
TeamAccesses: []portainer.TeamResourceAccess{{TeamID: 1}},
}))
return nil
}))
handler := NewHandler(testhelpers.NewTestRequestBouncer(), ds, fs, nil)
test := func(templateID string, restrictedContext *security.RestrictedRequestContext) (*httptest.ResponseRecorder, *httperror.HandlerError) {
r := httptest.NewRequest(http.MethodGet, "/custom_templates/"+templateID+"/file", nil)
r = mux.SetURLVars(r, map[string]string{"id": templateID})
ctx := security.StoreRestrictedRequestContext(r, restrictedContext)
r = r.WithContext(ctx)
rr := httptest.NewRecorder()
return rr, handler.customTemplateFile(rr, r)
}
t.Run("unknown id should get not found error", func(t *testing.T) {
_, r := test("0", &security.RestrictedRequestContext{UserID: 1})
require.NotNil(t, r)
require.Equal(t, http.StatusNotFound, r.StatusCode)
})
t.Run("admin should access adminonly template", func(t *testing.T) {
rr, r := test("1", &security.RestrictedRequestContext{UserID: 1, IsAdmin: true})
require.Nil(t, r)
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
var res struct{ FileContent string }
require.NoError(t, json.NewDecoder(rr.Body).Decode(&res))
require.Equal(t, templateContent, res.FileContent)
})
t.Run("std should not access adminonly template", func(t *testing.T) {
_, r := test("1", &security.RestrictedRequestContext{UserID: 2})
require.NotNil(t, r)
require.Equal(t, http.StatusForbidden, r.StatusCode)
})
t.Run("std should access template via direct user access", func(t *testing.T) {
rr, r := test("2", &security.RestrictedRequestContext{UserID: 2})
require.Nil(t, r)
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
var res struct{ FileContent string }
require.NoError(t, json.NewDecoder(rr.Body).Decode(&res))
require.Equal(t, templateContent, res.FileContent)
})
t.Run("std should access template via team access", func(t *testing.T) {
rr, r := test("2", &security.RestrictedRequestContext{UserID: 3, UserMemberships: []portainer.TeamMembership{{ID: 1, UserID: 3, TeamID: 1}}})
require.Nil(t, r)
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
var res struct{ FileContent string }
require.NoError(t, json.NewDecoder(rr.Body).Decode(&res))
require.Equal(t, templateContent, res.FileContent)
})
t.Run("std should not access template without access", func(t *testing.T) {
_, r := test("2", &security.RestrictedRequestContext{UserID: 4})
require.NotNil(t, r)
require.Equal(t, http.StatusForbidden, r.StatusCode)
})
}
@@ -5,11 +5,8 @@ import (
"strconv"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/slicesx"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -35,45 +32,31 @@ func (handler *Handler) customTemplateInspect(w http.ResponseWriter, r *http.Req
return httperror.BadRequest("Invalid Custom template identifier route variable", err)
}
var customTemplate *portainer.CustomTemplate
err = handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
customTemplate, err = tx.CustomTemplate().Read(portainer.CustomTemplateID(customTemplateID))
if tx.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find a custom template with the specified identifier inside the database", err)
} else if err != nil {
return httperror.InternalServerError("Unable to find a custom template with the specified identifier inside the database", err)
}
customTemplate, err := handler.DataStore.CustomTemplate().Read(portainer.CustomTemplateID(customTemplateID))
if handler.DataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find a custom template with the specified identifier inside the database", err)
} else if err != nil {
return httperror.InternalServerError("Unable to find a custom template with the specified identifier inside the database", err)
}
resourceControl, err := tx.ResourceControl().ResourceControlByResourceIDAndType(strconv.Itoa(customTemplateID), portainer.CustomTemplateResourceControl)
if err != nil {
return httperror.InternalServerError("Unable to retrieve a resource control associated to the custom template", err)
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve user info from request context", err)
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve user info from request context", err)
}
canEdit := userCanEditTemplate(customTemplate, securityContext)
hasAccess := false
if resourceControl != nil {
customTemplate.ResourceControl = resourceControl
teamIDs := slicesx.Map(securityContext.UserMemberships, func(m portainer.TeamMembership) portainer.TeamID {
return m.TeamID
})
hasAccess = authorization.UserCanAccessResource(securityContext.UserID, teamIDs, resourceControl)
}
if canEdit || hasAccess {
return nil
}
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(strconv.Itoa(customTemplateID), portainer.CustomTemplateResourceControl)
if err != nil {
return httperror.InternalServerError("Unable to retrieve a resource control associated to the custom template", err)
}
access := userCanEditTemplate(customTemplate, securityContext)
if !access {
return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied)
})
}
return response.TxResponse(w, customTemplate, err)
if resourceControl != nil {
customTemplate.ResourceControl = resourceControl
}
return response.JSON(w, customTemplate)
}
@@ -1,104 +0,0 @@
package customtemplates
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gorilla/mux"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/require"
)
func TestInspectHandler(t *testing.T) {
_, ds := datastore.MustNewTestStore(t, true, false)
require.NotNil(t, ds)
fs, err := filesystem.NewService(t.TempDir(), t.TempDir())
require.NoError(t, err)
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}))
require.NoError(t, tx.User().Create(&portainer.User{ID: 2, Username: "std2", Role: portainer.StandardUserRole}))
require.NoError(t, tx.User().Create(&portainer.User{ID: 3, Username: "std3", Role: portainer.StandardUserRole}))
require.NoError(t, tx.User().Create(&portainer.User{ID: 4, Username: "std4", Role: portainer.StandardUserRole}))
require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{ID: 1,
UserAccessPolicies: portainer.UserAccessPolicies{
2: portainer.AccessPolicy{RoleID: 0},
3: portainer.AccessPolicy{RoleID: 0},
}}))
require.NoError(t, tx.Team().Create(&portainer.Team{ID: 1}))
require.NoError(t, tx.TeamMembership().Create(&portainer.TeamMembership{ID: 1, UserID: 3, TeamID: 1, Role: portainer.TeamMember}))
require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{ID: 1}))
require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{ID: 2}))
require.NoError(t, tx.ResourceControl().Create(&portainer.ResourceControl{ID: 1, ResourceID: "2", Type: portainer.CustomTemplateResourceControl,
UserAccesses: []portainer.UserResourceAccess{{UserID: 2}},
TeamAccesses: []portainer.TeamResourceAccess{{TeamID: 1}},
}))
return nil
}))
handler := NewHandler(testhelpers.NewTestRequestBouncer(), ds, fs, nil)
test := func(templateID string, restrictedContext *security.RestrictedRequestContext) (*httptest.ResponseRecorder, *httperror.HandlerError) {
r := httptest.NewRequest(http.MethodGet, "/custom_templates/"+templateID, nil)
r = mux.SetURLVars(r, map[string]string{"id": templateID})
ctx := security.StoreRestrictedRequestContext(r, restrictedContext)
r = r.WithContext(ctx)
rr := httptest.NewRecorder()
return rr, handler.customTemplateInspect(rr, r)
}
t.Run("unknown id should get not found error", func(t *testing.T) {
_, r := test("0", &security.RestrictedRequestContext{UserID: 1})
require.NotNil(t, r)
require.Equal(t, http.StatusNotFound, r.StatusCode)
})
t.Run("admin should access adminonly template", func(t *testing.T) {
rr, r := test("1", &security.RestrictedRequestContext{UserID: 1, IsAdmin: true})
require.Nil(t, r)
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
var template portainer.CustomTemplate
require.NoError(t, json.NewDecoder(rr.Body).Decode(&template))
require.Equal(t, portainer.CustomTemplateID(1), template.ID)
})
t.Run("std should not access adminonly template", func(t *testing.T) {
_, r := test("1", &security.RestrictedRequestContext{UserID: 2})
require.NotNil(t, r)
require.Equal(t, http.StatusForbidden, r.StatusCode)
})
t.Run("std should access template via direct user access", func(t *testing.T) {
rr, r := test("2", &security.RestrictedRequestContext{UserID: 2})
require.Nil(t, r)
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
var template portainer.CustomTemplate
require.NoError(t, json.NewDecoder(rr.Body).Decode(&template))
require.Equal(t, portainer.CustomTemplateID(2), template.ID)
})
t.Run("std should access template via team access", func(t *testing.T) {
rr, r := test("2", &security.RestrictedRequestContext{UserID: 3, UserMemberships: []portainer.TeamMembership{{ID: 1, UserID: 3, TeamID: 1}}})
require.Nil(t, r)
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
var template portainer.CustomTemplate
require.NoError(t, json.NewDecoder(rr.Body).Decode(&template))
require.Equal(t, portainer.CustomTemplateID(2), template.ID)
})
t.Run("std should not access template without access", func(t *testing.T) {
_, r := test("2", &security.RestrictedRequestContext{UserID: 4})
require.NotNil(t, r)
require.Equal(t, http.StatusForbidden, r.StatusCode)
})
}
@@ -77,7 +77,8 @@ func (handler *Handler) edgeGroupCreate(w http.ResponseWriter, r *http.Request)
return httperror.BadRequest("Invalid request payload", err)
}
var shadowEdgeGroup shadowedEdgeGroup
var edgeGroup *portainer.EdgeGroup
err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
edgeGroups, err := tx.EdgeGroup().ReadAll()
if err != nil {
@@ -90,7 +91,7 @@ func (handler *Handler) edgeGroupCreate(w http.ResponseWriter, r *http.Request)
}
}
edgeGroup := &portainer.EdgeGroup{
edgeGroup = &portainer.EdgeGroup{
Name: payload.Name,
Dynamic: payload.Dynamic,
TagIDs: []portainer.TagID{},
@@ -107,10 +108,8 @@ func (handler *Handler) edgeGroupCreate(w http.ResponseWriter, r *http.Request)
return httperror.InternalServerError("Unable to persist the Edge group inside the database", err)
}
shadowEdgeGroup = shadowedEdgeGroup{EdgeGroup: *edgeGroup}
return nil
})
return txResponse(w, shadowEdgeGroup, err)
return txResponse(w, shadowedEdgeGroup{EdgeGroup: *edgeGroup}, err)
}
@@ -60,22 +60,3 @@ func TestEdgeGroupCreateHandler(t *testing.T) {
require.ElementsMatch(t, []portainer.EndpointID{1, 2, 3}, responseGroup.Endpoints)
}
func TestEdgeGroupCreatePanic(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, true)
handler := NewHandler(testhelpers.NewTestRequestBouncer())
handler.DataStore = store
err := store.EdgeGroup().Create(&portainer.EdgeGroup{ID: 1, Name: "New Edge Group"})
require.NoError(t, err)
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost,
"/edge_groups",
strings.NewReader(`{"Name": "New Edge Group", "Endpoints": [1, 2, 3]}`),
)
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusBadRequest, rr.Result().StatusCode)
}
@@ -28,21 +28,15 @@ func (handler *Handler) edgeGroupInspect(w http.ResponseWriter, r *http.Request)
return httperror.BadRequest("Invalid Edge group identifier route variable", err)
}
var shadowEdgeGroup shadowedEdgeGroup
var edgeGroup *portainer.EdgeGroup
err = handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
edgeGroup, err := getEdgeGroup(tx, portainer.EdgeGroupID(edgeGroupID))
if err != nil {
return err
}
edgeGroup.Endpoints = edgeGroup.EndpointIDs.ToSlice()
shadowEdgeGroup = shadowedEdgeGroup{EdgeGroup: *edgeGroup}
return nil
edgeGroup, err = getEdgeGroup(tx, portainer.EdgeGroupID(edgeGroupID))
return err
})
return txResponse(w, shadowEdgeGroup, err)
edgeGroup.Endpoints = edgeGroup.EndpointIDs.ToSlice()
return txResponse(w, shadowedEdgeGroup{EdgeGroup: *edgeGroup}, err)
}
func getEdgeGroup(tx dataservices.DataStoreTx, ID portainer.EdgeGroupID) (*portainer.EdgeGroup, error) {
@@ -174,16 +174,3 @@ func TestDynamicEdgeGroupInspectHandler(t *testing.T) {
require.ElementsMatch(t, []portainer.EndpointID{1, 2, 3}, responseGroup.Endpoints)
}
func TestEdgeGroupInspectPanic(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, true)
handler := NewHandler(testhelpers.NewTestRequestBouncer())
handler.DataStore = store
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/edge_groups/1", nil)
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusNotFound, rr.Result().StatusCode)
}
@@ -56,9 +56,9 @@ func (handler *Handler) edgeGroupUpdate(w http.ResponseWriter, r *http.Request)
return httperror.BadRequest("Invalid request payload", err)
}
var shadowEdgeGroup shadowedEdgeGroup
var edgeGroup *portainer.EdgeGroup
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
edgeGroup, err := tx.EdgeGroup().Read(portainer.EdgeGroupID(edgeGroupID))
edgeGroup, err = tx.EdgeGroup().Read(portainer.EdgeGroupID(edgeGroupID))
if handler.DataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find an Edge group with the specified identifier inside the database", err)
} else if err != nil {
@@ -155,12 +155,10 @@ func (handler *Handler) edgeGroupUpdate(w http.ResponseWriter, r *http.Request)
}
}
shadowEdgeGroup = shadowedEdgeGroup{EdgeGroup: *edgeGroup}
return nil
})
return txResponse(w, shadowEdgeGroup, err)
return txResponse(w, shadowedEdgeGroup{EdgeGroup: *edgeGroup}, err)
}
func (handler *Handler) updateEndpointStacks(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack) error {
@@ -68,16 +68,3 @@ func TestEdgeGroupUpdateHandler(t *testing.T) {
require.ElementsMatch(t, []portainer.EndpointID{1, 2, 3}, responseGroup.Endpoints)
}
func TestEdgeGroupUpdatePanic(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, true)
handler := NewHandler(testhelpers.NewTestRequestBouncer())
handler.DataStore = store
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/edge_groups/1", strings.NewReader("{}"))
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusNotFound, rr.Result().StatusCode)
}
@@ -74,7 +74,7 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
}
if err := handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint); err != nil {
return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment ID: %d", err, payload.EndpointID))
return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment name: %s", err, endpoint.Name))
}
var stack *portainer.EdgeStack
@@ -42,17 +42,17 @@ func (handler *Handler) endpointEdgeJobsLogs(w http.ResponseWriter, r *http.Requ
}
if err := handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint); err != nil {
return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment ID: %d", err, endpoint.ID))
return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment name: %s", err, endpoint.Name))
}
edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "jobID")
if err != nil {
return httperror.BadRequest("Invalid edge job identifier route variable", fmt.Errorf("invalid Edge job route variable: %w. Environment ID: %d", err, endpoint.ID))
return httperror.BadRequest("Invalid edge job identifier route variable", fmt.Errorf("invalid Edge job route variable: %w. Environment name: %s", err, endpoint.Name))
}
var payload logsPayload
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
return httperror.BadRequest("Invalid request payload", fmt.Errorf("invalid Edge job request payload: %w. Environment ID: %d", err, endpoint.ID))
return httperror.BadRequest("Invalid request payload", fmt.Errorf("invalid Edge job request payload: %w. Environment name: %s", err, endpoint.Name))
}
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
@@ -60,11 +60,11 @@ func (handler *Handler) endpointEdgeJobsLogs(w http.ResponseWriter, r *http.Requ
}); err != nil {
var httpErr *httperror.HandlerError
if errors.As(err, &httpErr) {
httpErr.Err = fmt.Errorf("edge polling error: %w. Environment ID: %d", httpErr.Err, endpoint.ID)
httpErr.Err = fmt.Errorf("edge polling error: %w. Environment name: %s", httpErr.Err, endpoint.Name)
return httpErr
}
return httperror.InternalServerError("Unexpected error", fmt.Errorf("edge polling error: %w. Environment ID: %d", err, endpoint.ID))
return httperror.InternalServerError("Unexpected error", fmt.Errorf("edge polling error: %w. Environment name: %s", err, endpoint.Name))
}
return response.JSON(w, nil)
@@ -40,18 +40,18 @@ func (handler *Handler) endpointEdgeStackInspect(w http.ResponseWriter, r *http.
}
if err := handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint); err != nil {
return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment ID: %d", err, endpoint.ID))
return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment name: %s", err, endpoint.Name))
}
edgeStackID, err := request.RetrieveNumericRouteVariableValue(r, "stackId")
if err != nil {
return httperror.BadRequest("Invalid edge stack identifier route variable", fmt.Errorf("invalid Edge stack route variable: %w. Environment ID: %d", err, endpoint.ID))
return httperror.BadRequest("Invalid edge stack identifier route variable", fmt.Errorf("invalid Edge stack route variable: %w. Environment name: %s", err, endpoint.Name))
}
s, err, _ := edgeStackSingleFlightGroup.Do(strconv.Itoa(edgeStackID), func() (any, error) {
edgeStack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(edgeStackID))
if handler.DataStore.IsErrObjectNotFound(err) {
return nil, httperror.NotFound("Unable to find an edge stack with the specified identifier inside the database", fmt.Errorf("unable to find the Edge stack from database: %w. Environment ID: %d", err, endpoint.ID))
return nil, httperror.NotFound("Unable to find an edge stack with the specified identifier inside the database", fmt.Errorf("unable to find the Edge stack from database: %w. Environment name: %s", err, endpoint.Name))
}
return edgeStack, err
@@ -62,7 +62,7 @@ func (handler *Handler) endpointEdgeStackInspect(w http.ResponseWriter, r *http.
return httpErr
}
return httperror.InternalServerError("Unable to find an edge stack with the specified identifier inside the database", fmt.Errorf("failed to find Edge stack from the database: %w. Environment ID: %d", err, endpoint.ID))
return httperror.InternalServerError("Unable to find an edge stack with the specified identifier inside the database", fmt.Errorf("failed to find Edge stack from the database: %w. Environment name: %s", err, endpoint.Name))
}
// WARNING: this variable must not be mutated
@@ -71,7 +71,7 @@ func (handler *Handler) endpointEdgeStackInspect(w http.ResponseWriter, r *http.
fileName := edgeStack.EntryPoint
if endpointutils.IsDockerEndpoint(endpoint) {
if fileName == "" {
return httperror.BadRequest("Docker is not supported by this stack", fmt.Errorf("no filename is provided for the Docker endpoint. Environment ID: %d", endpoint.ID))
return httperror.BadRequest("Docker is not supported by this stack", fmt.Errorf("no filename is provided for the Docker endpoint. Environment name: %s", endpoint.Name))
}
}
@@ -84,18 +84,18 @@ func (handler *Handler) endpointEdgeStackInspect(w http.ResponseWriter, r *http.
fileName = edgeStack.ManifestPath
if fileName == "" {
return httperror.BadRequest("Kubernetes is not supported by this stack", fmt.Errorf("no filename is provided for the Kubernetes endpoint. Environment ID: %d", endpoint.ID))
return httperror.BadRequest("Kubernetes is not supported by this stack", fmt.Errorf("no filename is provided for the Kubernetes endpoint. Environment name: %s", endpoint.Name))
}
}
dirEntries, err := filesystem.LoadDir(edgeStack.ProjectPath)
if err != nil {
return httperror.InternalServerError("Unable to load repository", fmt.Errorf("failed to load project directory: %w. Environment ID: %d", err, endpoint.ID))
return httperror.InternalServerError("Unable to load repository", fmt.Errorf("failed to load project directory: %w. Environment name: %s", err, endpoint.Name))
}
fileContent, err := filesystem.FilterDirForCompatibility(dirEntries, fileName, endpoint.Agent.Version)
if err != nil {
return httperror.InternalServerError("File not found", fmt.Errorf("unable to find file: %w. Environment ID: %d", err, endpoint.ID))
return httperror.InternalServerError("File not found", fmt.Errorf("unable to find file: %w. Environment name: %s", err, endpoint.Name))
}
dirEntries = filesystem.FilterDirForEntryFile(dirEntries, fileName)
@@ -97,13 +97,13 @@ func (handler *Handler) endpointEdgeStatusInspect(w http.ResponseWriter, r *http
firstConn := endpoint.LastCheckInDate == 0
if err := handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint); err != nil {
return httperror.Forbidden("Permission denied to access environment. The device has not been trusted yet", fmt.Errorf("unauthorized Edge endpoint operation: %w. Environment ID: %d", err, endpoint.ID))
return httperror.Forbidden("Permission denied to access environment. The device has not been trusted yet", fmt.Errorf("unauthorized Edge endpoint operation: %w. Environment name: %s", err, endpoint.Name))
}
handler.DataStore.Endpoint().UpdateHeartbeat(endpoint.ID)
if err := handler.requestBouncer.TrustedEdgeEnvironmentAccess(handler.DataStore, endpoint); err != nil {
return httperror.Forbidden("Permission denied to access environment. The device has not been trusted yet", fmt.Errorf("untrusted Edge environment access: %w. Environment ID: %d", err, endpoint.ID))
return httperror.Forbidden("Permission denied to access environment. The device has not been trusted yet", fmt.Errorf("untrusted Edge environment access: %w. Environment name: %s", err, endpoint.Name))
}
var statusResponse *endpointEdgeStatusInspectResponse
@@ -113,11 +113,11 @@ func (handler *Handler) endpointEdgeStatusInspect(w http.ResponseWriter, r *http
}); err != nil {
var httpErr *httperror.HandlerError
if errors.As(err, &httpErr) {
httpErr.Err = fmt.Errorf("edge polling error: %w. Environment ID: %d", httpErr.Err, endpoint.ID)
httpErr.Err = fmt.Errorf("edge polling error: %w. Environment name: %s", httpErr.Err, endpoint.Name)
return httpErr
}
return httperror.InternalServerError("Unexpected error", fmt.Errorf("edge polling error: %w. Environment ID: %d", err, endpoint.ID))
return httperror.InternalServerError("Unexpected error", fmt.Errorf("edge polling error: %w. Environment name: %s", err, endpoint.Name))
}
return cacheResponse(w, endpoint.ID, *statusResponse)
+4 -10
View File
@@ -372,16 +372,10 @@ func (handler *Handler) createEdgeAgentEndpoint(tx dataservices.DataStoreTx, pay
edgeKey := handler.ReverseTunnelService.GenerateEdgeKey(payload.URL, portainerHost, endpointID)
endpoint := &portainer.Endpoint{
ID: portainer.EndpointID(endpointID),
Name: payload.Name,
URL: portainerHost,
Type: func() portainer.EndpointType {
// an empty container engine means that the endpoint is a Kubernetes endpoint
if payload.ContainerEngine == "" {
return portainer.EdgeAgentOnKubernetesEnvironment
}
return portainer.EdgeAgentOnDockerEnvironment
}(),
ID: portainer.EndpointID(endpointID),
Name: payload.Name,
URL: portainerHost,
Type: portainer.EdgeAgentOnDockerEnvironment,
ContainerEngine: payload.ContainerEngine,
GroupID: portainer.EndpointGroupID(payload.GroupID),
Gpus: payload.Gpus,
@@ -1,172 +0,0 @@
package endpoints
import (
"net/http"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/chisel"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/pkg/fips"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// EE-only kubeconfig validation tests removed for CE
func TestSaveEndpointAndUpdateAuthorizations(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, false)
endpointGroup := &portainer.EndpointGroup{
ID: 1,
Name: "test-endpoint-group",
}
err := store.EndpointGroup().Create(endpointGroup)
require.NoError(t, err)
h := &Handler{
DataStore: store,
}
testCases := []struct {
name string
endpointType portainer.EndpointType
expectRelation bool
}{
{
name: "create azure environment, expect no relation to be created",
endpointType: portainer.AzureEnvironment,
expectRelation: false,
},
{
name: "create edge agent environment, expect relation to be created",
endpointType: portainer.EdgeAgentOnDockerEnvironment,
expectRelation: true,
},
{
name: "create kubernetes environment, expect no relation to be created",
endpointType: portainer.KubernetesLocalEnvironment,
expectRelation: false,
},
{
name: "create kubeconfig environment, expect no relation to be created",
endpointType: portainer.AgentOnKubernetesEnvironment,
expectRelation: false,
},
{
name: "create agent docker environment, expect no relation to be created",
endpointType: portainer.AgentOnDockerEnvironment,
expectRelation: false,
},
{
name: "create unsecured environment, expect no relation to be created",
endpointType: portainer.DockerEnvironment,
expectRelation: false,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
endpoint := &portainer.Endpoint{
ID: portainer.EndpointID(store.Endpoint().GetNextIdentifier()),
Type: testCase.endpointType,
GroupID: portainer.EndpointGroupID(endpointGroup.ID),
}
err := h.saveEndpointAndUpdateAuthorizations(store, endpoint)
require.NoError(t, err)
relation, relationErr := store.EndpointRelation().EndpointRelation(endpoint.ID)
if testCase.expectRelation {
require.NoError(t, relationErr)
require.NotNil(t, relation)
} else {
require.Error(t, relationErr)
require.True(t, store.IsErrObjectNotFound(relationErr))
require.Nil(t, relation)
}
})
}
}
func TestCreateEndpointFailure(t *testing.T) {
fips.InitFIPS(false)
_, store := datastore.MustNewTestStore(t, true, false)
h := NewHandler(testhelpers.NewTestRequestBouncer())
h.DataStore = store
payload := &endpointCreatePayload{
Name: "Test Endpoint",
EndpointCreationType: agentEnvironment,
TLS: true,
TLSCertFile: []byte("invalid data"),
TLSKeyFile: []byte("invalid data"),
}
endpoint, httpErr := h.createEndpoint(store, payload)
require.NotNil(t, httpErr)
require.Equal(t, http.StatusInternalServerError, httpErr.StatusCode)
require.Nil(t, endpoint)
}
func TestCreateEdgeAgentEndpoint_ContainerEngineMapping(t *testing.T) {
fips.InitFIPS(false)
_, store := datastore.MustNewTestStore(t, true, false)
// required group for save flow
endpointGroup := &portainer.EndpointGroup{ID: 1, Name: "test-group"}
err := store.EndpointGroup().Create(endpointGroup)
require.NoError(t, err)
h := &Handler{
DataStore: store,
ReverseTunnelService: chisel.NewService(store, nil, nil),
}
tests := []struct {
name string
engine string
wantType portainer.EndpointType
}{
{
name: "empty engine -> EdgeAgentOnKubernetesEnvironment",
engine: "",
wantType: portainer.EdgeAgentOnKubernetesEnvironment,
},
{
name: "docker engine -> EdgeAgentOnDockerEnvironment",
engine: portainer.ContainerEngineDocker,
wantType: portainer.EdgeAgentOnDockerEnvironment,
},
{
name: "podman engine -> EdgeAgentOnDockerEnvironment",
engine: portainer.ContainerEnginePodman,
wantType: portainer.EdgeAgentOnDockerEnvironment,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
payload := &endpointCreatePayload{
Name: "edge-endpoint",
EndpointCreationType: edgeAgentEnvironment,
ContainerEngine: tc.engine,
GroupID: 1,
URL: "https://portainer.example:9443",
}
ep, httpErr := h.createEdgeAgentEndpoint(store, payload)
require.Nil(t, httpErr)
require.NotNil(t, ep)
assert.Equal(t, tc.wantType, ep.Type)
assert.Equal(t, tc.engine, ep.ContainerEngine)
})
}
}
@@ -20,6 +20,7 @@ import (
// @produce json
// @param id path int true "Environment(Endpoint) identifier"
// @param excludeSnapshot query bool false "if true, the snapshot data won't be retrieved"
// @param excludeSnapshotRaw query bool false "if true, the SnapshotRaw field won't be retrieved"
// @success 200 {object} portainer.Endpoint "Success"
// @failure 400 "Invalid request"
// @failure 404 "Environment(Endpoint) not found"
@@ -52,9 +53,10 @@ func (handler *Handler) endpointInspect(w http.ResponseWriter, r *http.Request)
endpoint.ComposeSyntaxMaxVersion = handler.ComposeStackManager.ComposeSyntaxMaxVersion()
excludeSnapshot, _ := request.RetrieveBooleanQueryParameter(r, "excludeSnapshot", true)
excludeRaw, _ := request.RetrieveBooleanQueryParameter(r, "excludeSnapshotRaw", true)
if !excludeSnapshot {
if err := handler.SnapshotService.FillSnapshotData(endpoint, false); err != nil {
if err := handler.SnapshotService.FillSnapshotData(endpoint, !excludeRaw); err != nil {
return httperror.InternalServerError("Unable to add snapshot data", err)
}
}
+3 -1
View File
@@ -45,6 +45,7 @@ const (
// @param edgeDeviceUntrusted query bool false "if true, show only untrusted edge agents, if false show only trusted edge agents (relevant only for edge agents)"
// @param edgeCheckInPassedSeconds query number false "if bigger then zero, show only edge agents that checked-in in the last provided seconds (relevant only for edge agents)"
// @param excludeSnapshots query bool false "if true, the snapshot data won't be retrieved"
// @param excludeSnapshotRaw query bool false "if true, the SnapshotRaw field won't be retrieved"
// @param name query string false "will return only environments(endpoints) with this name"
// @param edgeStackId query portainer.EdgeStackID false "will return the environements of the specified edge stack"
// @param edgeStackStatus query string false "only applied when edgeStackId exists. Filter the returned environments based on their deployment status in the stack (not the environment status!)" Enum("Pending", "Ok", "Error", "Acknowledged", "Remove", "RemoteUpdateSuccess", "ImagesPulled")
@@ -62,6 +63,7 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
limit, _ := request.RetrieveNumericQueryParameter(r, "limit", true)
sortField, _ := request.RetrieveQueryParameter(r, "sort", true)
sortOrder, _ := request.RetrieveQueryParameter(r, "order", true)
excludeRaw, _ := request.RetrieveBooleanQueryParameter(r, "excludeSnapshotRaw", true)
endpointGroups, err := handler.DataStore.EndpointGroup().ReadAll()
if err != nil {
@@ -116,7 +118,7 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
endpointutils.UpdateEdgeEndpointHeartbeat(&paginatedEndpoints[idx], settings)
if !query.excludeSnapshots {
if err := handler.SnapshotService.FillSnapshotData(&paginatedEndpoints[idx], false); err != nil {
if err := handler.SnapshotService.FillSnapshotData(&paginatedEndpoints[idx], !excludeRaw); err != nil {
return httperror.InternalServerError("Unable to add snapshot data", err)
}
}
@@ -5,7 +5,6 @@ import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
portainer "github.com/portainer/portainer/api"
@@ -79,14 +78,12 @@ func Test_EndpointList_AgentVersion(t *testing.T) {
for _, test := range tests {
t.Run(test.title, func(t *testing.T) {
is := assert.New(t)
var sb strings.Builder
query := ""
for _, filter := range test.filter {
sb.WriteString("agentVersions[]=")
sb.WriteString(filter)
sb.WriteString("&")
query += fmt.Sprintf("agentVersions[]=%s&", filter)
}
req := buildEndpointListRequest(sb.String())
req := buildEndpointListRequest(query)
resp, err := doEndpointListRequest(req, handler, is)
is.NoError(err)
@@ -49,7 +49,7 @@ func (handler *Handler) endpointSnapshots(w http.ResponseWriter, r *http.Request
continue
}
latestEndpointReference.Status = portainer.EndpointStatusUp
endpoint.Status = portainer.EndpointStatusUp
if snapshotError != nil {
log.Debug().
Str("endpoint", endpoint.Name).
@@ -57,7 +57,7 @@ func (handler *Handler) endpointSnapshots(w http.ResponseWriter, r *http.Request
Err(snapshotError).
Msg("background schedule error (environment snapshot), unable to create snapshot")
latestEndpointReference.Status = portainer.EndpointStatusDown
endpoint.Status = portainer.EndpointStatusDown
}
latestEndpointReference.Agent.Version = endpoint.Agent.Version
@@ -1,107 +0,0 @@
package endpoints
import (
"errors"
"io"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_endpointSnapshots(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, true)
endpointID := portainer.EndpointID(123)
endpoint := &portainer.Endpoint{
ID: endpointID,
Name: "mock",
URL: "http://mock.example/",
Status: portainer.EndpointStatusDown, // starts in down state
}
err := store.Endpoint().Create(endpoint)
require.NoError(t, err, "error creating environment")
err = store.User().Create(
&portainer.User{
Username: "admin",
Role: portainer.AdministratorRole,
},
)
require.NoError(t, err, "error creating a user")
bouncer := testhelpers.NewTestRequestBouncer()
snapshotService := &mockSnapshotService{
snapshotEndpointShouldSucceed: atomic.Bool{},
}
snapshotService.snapshotEndpointShouldSucceed.Store(true)
h := NewHandler(bouncer)
h.DataStore = store
h.SnapshotService = snapshotService
doPostRequest := func() {
req := httptest.NewRequest(http.MethodPost, "/endpoints/snapshot", nil)
ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1})
req = req.WithContext(ctx)
testhelpers.AddTestSecurityCookie(req, "Bearer dummytoken")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
require.Equal(t, http.StatusNoContent, rr.Code, "Status should be 204")
_, err := io.ReadAll(rr.Body)
require.NoError(t, err, "ReadAll should not return error")
}
doPostRequest()
// check that the endpoint has been immediately set to up
endpoint, err = store.Endpoint().Endpoint(endpointID)
require.NoError(t, err, "error getting endpoint")
assert.Equal(t, portainer.EndpointStatusUp, endpoint.Status, "endpoint should be up (1) since mock snapshot returned ok")
// set the mock to return an error
snapshotService.snapshotEndpointShouldSucceed.Store(false)
doPostRequest()
// check that the endpoint has been immediately set to down
endpoint, err = store.Endpoint().Endpoint(endpointID)
require.NoError(t, err, "error getting endpoint")
assert.Equal(t, portainer.EndpointStatusDown, endpoint.Status, "endpoint should be down (2) since mock snapshot returned error")
}
var _ portainer.SnapshotService = &mockSnapshotService{}
type mockSnapshotService struct {
snapshotEndpointShouldSucceed atomic.Bool
}
func (s *mockSnapshotService) Start() {
}
func (s *mockSnapshotService) SetSnapshotInterval(snapshotInterval string) error {
return nil
}
func (s *mockSnapshotService) SnapshotEndpoint(endpoint *portainer.Endpoint) error {
if s.snapshotEndpointShouldSucceed.Load() {
return nil
}
return errors.New("snapshot failed")
}
func (s *mockSnapshotService) FillSnapshotData(endpoint *portainer.Endpoint, includeRaw bool) error {
return nil
}
+49 -56
View File
@@ -256,7 +256,7 @@ func (handler *Handler) filterEndpointsByQuery(
return filteredEndpoints, totalAvailableEndpoints, nil
}
func endpointStatusInStackMatchesFilter(stackStatus *portainer.EdgeStackStatusForEnv, statusFilter portainer.EdgeStackStatusType) bool {
func endpointStatusInStackMatchesFilter(stackStatus *portainer.EdgeStackStatusForEnv, envId portainer.EndpointID, statusFilter portainer.EdgeStackStatusType) bool {
// consider that if the env has no status in the stack it is in Pending state
if statusFilter == portainer.EdgeStackStatusPending {
return stackStatus == nil || len(stackStatus.Status) == 0
@@ -272,62 +272,55 @@ func endpointStatusInStackMatchesFilter(stackStatus *portainer.EdgeStackStatusFo
}
func filterEndpointsByEdgeStack(endpoints []portainer.Endpoint, edgeStackId portainer.EdgeStackID, statusFilter *portainer.EdgeStackStatusType, datastore dataservices.DataStore) ([]portainer.Endpoint, error) {
var filteredEndpoints []portainer.Endpoint
if err := datastore.ViewTx(func(tx dataservices.DataStoreTx) error {
stack, err := tx.EdgeStack().EdgeStack(edgeStackId)
if err != nil {
return errors.WithMessage(err, "Unable to retrieve edge stack from the database")
}
envIds := roar.Roar[portainer.EndpointID]{}
for _, edgeGroupId := range stack.EdgeGroups {
edgeGroup, err := tx.EdgeGroup().Read(edgeGroupId)
if err != nil {
return errors.WithMessage(err, "Unable to retrieve edge group from the database")
}
if edgeGroup.Dynamic {
endpointIDs, err := edgegroups.GetEndpointsByTags(tx, edgeGroup.TagIDs, edgeGroup.PartialMatch)
if err != nil {
return errors.WithMessage(err, "Unable to retrieve environments and environment groups for Edge group")
}
edgeGroup.EndpointIDs = roar.FromSlice(endpointIDs)
}
envIds.Union(edgeGroup.EndpointIDs)
}
filteredEnvIds := roar.Roar[portainer.EndpointID]{}
filteredEnvIds.Union(envIds)
if statusFilter != nil {
var innerErr error
envIds.Iterate(func(envId portainer.EndpointID) bool {
edgeStackStatus, err := tx.EdgeStackStatus().Read(edgeStackId, envId)
if err != nil && !dataservices.IsErrObjectNotFound(err) {
innerErr = errors.WithMessagef(err, "Unable to retrieve edge stack status for environment %d", envId)
return false
}
if !endpointStatusInStackMatchesFilter(edgeStackStatus, *statusFilter) {
filteredEnvIds.Remove(envId)
}
return true
})
if innerErr != nil {
return innerErr
}
}
filteredEndpoints = filteredEndpointsByIds(endpoints, filteredEnvIds)
return nil
}); err != nil {
return nil, err
stack, err := datastore.EdgeStack().EdgeStack(edgeStackId)
if err != nil {
return nil, errors.WithMessage(err, "Unable to retrieve edge stack from the database")
}
envIds := roar.Roar[portainer.EndpointID]{}
for _, edgeGroupdId := range stack.EdgeGroups {
edgeGroup, err := datastore.EdgeGroup().Read(edgeGroupdId)
if err != nil {
return nil, errors.WithMessage(err, "Unable to retrieve edge group from the database")
}
if edgeGroup.Dynamic {
endpointIDs, err := edgegroups.GetEndpointsByTags(datastore, edgeGroup.TagIDs, edgeGroup.PartialMatch)
if err != nil {
return nil, errors.WithMessage(err, "Unable to retrieve environments and environment groups for Edge group")
}
edgeGroup.EndpointIDs = roar.FromSlice(endpointIDs)
}
envIds.Union(edgeGroup.EndpointIDs)
}
if statusFilter != nil {
var innerErr error
envIds.Iterate(func(envId portainer.EndpointID) bool {
edgeStackStatus, err := datastore.EdgeStackStatus().Read(edgeStackId, envId)
if dataservices.IsErrObjectNotFound(err) {
return true
} else if err != nil {
innerErr = errors.WithMessagef(err, "Unable to retrieve edge stack status for environment %d", envId)
return false
}
if !endpointStatusInStackMatchesFilter(edgeStackStatus, portainer.EndpointID(envId), *statusFilter) {
envIds.Remove(envId)
}
return true
})
if innerErr != nil {
return nil, innerErr
}
}
filteredEndpoints := filteredEndpointsByIds(endpoints, envIds)
return filteredEndpoints, nil
}
+26 -88
View File
@@ -5,7 +5,6 @@ import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
@@ -305,103 +304,42 @@ func TestFilterEndpointsByEdgeStack(t *testing.T) {
_, store := datastore.MustNewTestStore(t, false, false)
endpoints := []portainer.Endpoint{
{ID: 1, Name: "Endpoint 1", Type: portainer.EdgeAgentOnDockerEnvironment, UserTrusted: true},
{ID: 2, Name: "Endpoint 2", TagIDs: []portainer.TagID{1}, Type: portainer.EdgeAgentOnDockerEnvironment, UserTrusted: true},
{ID: 3, Name: "Endpoint 3", TagIDs: []portainer.TagID{1}, Type: portainer.EdgeAgentOnDockerEnvironment, UserTrusted: true},
{ID: 1, Name: "Endpoint 1"},
{ID: 2, Name: "Endpoint 2"},
{ID: 3, Name: "Endpoint 3"},
{ID: 4, Name: "Endpoint 4"},
}
edgeStackId := portainer.EdgeStackID(1)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.Tag().Create(&portainer.Tag{ID: 1, Name: "tag", Endpoints: map[portainer.EndpointID]bool{2: true, 3: true}}))
for i := range endpoints {
require.NoError(t, tx.Endpoint().Create(&endpoints[i]))
}
require.NoError(t, tx.EdgeStack().Create(edgeStackId, &portainer.EdgeStack{
ID: edgeStackId,
Name: "Test Edge Stack",
EdgeGroups: []portainer.EdgeGroupID{1, 2},
}))
require.NoError(t, tx.EdgeGroup().Create(&portainer.EdgeGroup{
ID: 1,
Name: "Edge Group 1",
EndpointIDs: roar.FromSlice([]portainer.EndpointID{1}),
}))
require.NoError(t, tx.EdgeGroup().Create(&portainer.EdgeGroup{
ID: 2,
Name: "Edge Group 2",
Dynamic: true,
TagIDs: []portainer.TagID{1},
}))
require.NoError(t, tx.EdgeStackStatus().Create(edgeStackId, endpoints[0].ID, &portainer.EdgeStackStatusForEnv{
Status: []portainer.EdgeStackDeploymentStatus{{Type: portainer.EdgeStackStatusAcknowledged}}}))
return nil
}))
test := func(status *portainer.EdgeStackStatusType, expected []portainer.Endpoint) {
tmp := make([]portainer.Endpoint, len(endpoints))
require.Equal(t, 4, copy(tmp, endpoints))
es, err := filterEndpointsByEdgeStack(tmp, edgeStackId, status, store)
require.NoError(t, err)
// validate that the len is the same
require.Len(t, es, len(expected))
// and that all items are the expected ones
for i := range expected {
require.Contains(t, es, expected[i])
}
}
test(nil, []portainer.Endpoint{endpoints[0], endpoints[1], endpoints[2]})
status := portainer.EdgeStackStatusPending
test(&status, []portainer.Endpoint{endpoints[1], endpoints[2]})
status = portainer.EdgeStackStatusCompleted
test(&status, []portainer.Endpoint{})
status = portainer.EdgeStackStatusAcknowledged
test(&status, []portainer.Endpoint{endpoints[0]}) // that's the only one with an edge stack status in DB
}
func TestErrorsFilterEndpointsByEdgeStack(t *testing.T) {
t.Run("must error by edge stack not found", func(t *testing.T) {
_, store := datastore.MustNewTestStore(t, false, false)
require.NotNil(t, store)
_, err := filterEndpointsByEdgeStack([]portainer.Endpoint{}, 1, nil, store)
require.Error(t, err)
err := store.EdgeStack().Create(edgeStackId, &portainer.EdgeStack{
ID: edgeStackId,
Name: "Test Edge Stack",
EdgeGroups: []portainer.EdgeGroupID{1, 2},
})
require.NoError(t, err)
t.Run("must error by edge group not found", func(t *testing.T) {
_, store := datastore.MustNewTestStore(t, false, false)
require.NotNil(t, store)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.EdgeStack().Create(1, &portainer.EdgeStack{ID: 1, Name: "1", EdgeGroups: []portainer.EdgeGroupID{1}}))
return nil
}))
_, err := filterEndpointsByEdgeStack([]portainer.Endpoint{}, 1, nil, store)
require.Error(t, err)
err = store.EdgeGroup().Create(&portainer.EdgeGroup{
ID: 1,
Name: "Edge Group 1",
EndpointIDs: roar.FromSlice([]portainer.EndpointID{1}),
})
require.NoError(t, err)
t.Run("must error by env tag not found", func(t *testing.T) {
_, store := datastore.MustNewTestStore(t, false, false)
require.NotNil(t, store)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.EdgeStack().Create(1, &portainer.EdgeStack{ID: 1, Name: "1", EdgeGroups: []portainer.EdgeGroupID{1}}))
require.NoError(t, tx.EdgeGroup().Create(&portainer.EdgeGroup{ID: 1, Name: "edge group", Dynamic: true, TagIDs: []portainer.TagID{1}}))
return nil
}))
_, err := filterEndpointsByEdgeStack([]portainer.Endpoint{}, 1, nil, store)
require.Error(t, err)
err = store.EdgeGroup().Create(&portainer.EdgeGroup{
ID: 2,
Name: "Edge Group 2",
EndpointIDs: roar.FromSlice([]portainer.EndpointID{2, 3}),
})
require.NoError(t, err)
es, err := filterEndpointsByEdgeStack(endpoints, edgeStackId, nil, store)
require.NoError(t, err)
require.Len(t, es, 3)
require.Contains(t, es, endpoints[0]) // Endpoint 1
require.Contains(t, es, endpoints[1]) // Endpoint 2
require.Contains(t, es, endpoints[2]) // Endpoint 3
require.NotContains(t, es, endpoints[3]) // Endpoint 4
}
func TestFilterEndpointsByEdgeGroup(t *testing.T) {
+1 -1
View File
@@ -81,7 +81,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.33.8
// @version 2.33.0
// @description.markdown api-description.md
// @termsOfService
-1
View File
@@ -176,7 +176,6 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
httperror.WriteError(w, http.StatusForbidden, "an error occurred during the KubeClientMiddleware operation, permission denied to access the environment. Error: ", err)
return
}
// Check if we have a kubeclient against this auth token already, otherwise generate a new one
@@ -1,19 +1,10 @@
package registries
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_registryCreatePayload_Validate(t *testing.T) {
@@ -52,46 +43,3 @@ func Test_registryCreatePayload_Validate(t *testing.T) {
assert.NoError(t, err)
})
}
func TestHandler_registryCreate(t *testing.T) {
_, store := datastore.MustNewTestStore(t, false, false)
payload := registryCreatePayload{
Name: "Test registry",
Type: portainer.ProGetRegistry,
URL: "http://example.com",
BaseURL: "http://example.com",
Authentication: false,
Username: "username",
Password: "password",
Gitlab: portainer.GitlabRegistryData{},
}
payloadBytes, err := json.Marshal(payload)
require.NoError(t, err)
r := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(payloadBytes))
w := httptest.NewRecorder()
restrictedContext := &security.RestrictedRequestContext{IsAdmin: true, UserID: 1}
ctx := security.StoreRestrictedRequestContext(r, restrictedContext)
r = r.WithContext(ctx)
handler := NewHandler(testhelpers.NewTestRequestBouncer())
handler.DataStore = store
handlerError := handler.registryCreate(w, r)
require.Nil(t, handlerError)
registry := portainer.Registry{}
err = json.NewDecoder(w.Body).Decode(&registry)
require.NoError(t, err)
assert.Equal(t, payload.Name, registry.Name)
assert.Equal(t, payload.Type, registry.Type)
assert.Equal(t, payload.URL, registry.URL)
assert.Equal(t, payload.BaseURL, registry.BaseURL)
assert.Equal(t, payload.Authentication, registry.Authentication)
assert.Equal(t, payload.Username, registry.Username)
assert.Empty(t, registry.Password)
}
@@ -177,8 +177,6 @@ func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) *
return httperror.InternalServerError("Unable to persist registry changes inside the database", err)
}
hideFields(registry, true)
return response.JSON(w, registry)
}
@@ -1,68 +0,0 @@
package registries
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func ptr[T any](i T) *T { return &i }
func TestHandler_registryUpdate(t *testing.T) {
_, store := datastore.MustNewTestStore(t, false, false)
registry := &portainer.Registry{Type: portainer.ProGetRegistry}
err := store.Registry().Create(registry)
require.NoError(t, err)
payload := registryUpdatePayload{
Name: ptr("Updated test registry"),
URL: ptr("http://example.org/feed"),
BaseURL: ptr("http://example.org"),
Authentication: ptr(true),
Username: ptr("username"),
Password: ptr("password"),
}
payloadBytes, err := json.Marshal(payload)
require.NoError(t, err)
r := httptest.NewRequest(http.MethodPut, "/registries/1", bytes.NewReader(payloadBytes))
w := httptest.NewRecorder()
restrictedContext := &security.RestrictedRequestContext{IsAdmin: true, UserID: 1}
ctx := security.StoreRestrictedRequestContext(r, restrictedContext)
r = r.WithContext(ctx)
handler := NewHandler(testhelpers.NewTestRequestBouncer())
handler.DataStore = store
handler.ServeHTTP(w, r)
require.Equal(t, http.StatusOK, w.Code)
updatedRegistry := portainer.Registry{}
err = json.NewDecoder(w.Body).Decode(&updatedRegistry)
require.NoError(t, err)
// Registry type should remain intact
assert.Equal(t, registry.Type, updatedRegistry.Type)
assert.Equal(t, *payload.Name, updatedRegistry.Name)
assert.Equal(t, *payload.URL, updatedRegistry.URL)
assert.Equal(t, *payload.BaseURL, updatedRegistry.BaseURL)
assert.Equal(t, *payload.Authentication, updatedRegistry.Authentication)
assert.Equal(t, *payload.Username, updatedRegistry.Username)
assert.Empty(t, updatedRegistry.Password)
}
+1 -7
View File
@@ -161,13 +161,7 @@ func (handler *Handler) startStack(
return handler.StackDeployer.StartRemoteComposeStack(stack, endpoint, filteredRegistries)
}
options := portainer.ComposeUpOptions{
ComposeOptions: portainer.ComposeOptions{
Registries: filteredRegistries,
},
}
return handler.ComposeStackManager.Up(context.TODO(), stack, endpoint, options)
return handler.ComposeStackManager.Up(context.TODO(), stack, endpoint, portainer.ComposeUpOptions{})
case portainer.DockerSwarmStack:
stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name)
@@ -73,14 +73,6 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
return httperror.InternalServerError(msg, errors.New(msg))
}
if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" &&
(stack.AutoUpdate == nil ||
(stack.AutoUpdate != nil && stack.AutoUpdate.Webhook != payload.AutoUpdate.Webhook)) {
if isUnique, err := handler.checkUniqueWebhookID(payload.AutoUpdate.Webhook); !isUnique || err != nil {
return httperror.Conflict("Webhook ID already exists", errors.New("webhook ID already exists"))
}
}
// TODO: this is a work-around for stacks created with Portainer version >= 1.17.1
// The EndpointID property is not available for these stacks, this API environment(endpoint)
// can use the optional EndpointID query parameter to associate a valid environment(endpoint) identifier to the stack.
@@ -1,78 +0,0 @@
package stacks
import (
"bytes"
"net/http"
"net/http/httptest"
"strconv"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/gofrs/uuid"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/require"
)
func TestStackUpdateGitWebhookUniqueness(t *testing.T) {
webhook, err := uuid.NewV4()
require.NoError(t, err)
_, store := datastore.MustNewTestStore(t, false, false)
endpoint := &portainer.Endpoint{
ID: 123,
Name: "endpoint1",
Type: portainer.DockerEnvironment,
}
err = store.Endpoint().Create(endpoint)
require.NoError(t, err)
stack1 := portainer.Stack{
ID: 456,
EndpointID: endpoint.ID,
AutoUpdate: &portainer.AutoUpdateSettings{
Webhook: webhook.String(),
},
GitConfig: &gittypes.RepoConfig{
URL: "https://github.com/portainer/portainer.git",
},
}
err = store.Stack().Create(&stack1)
require.NoError(t, err)
stack2 := stack1
stack2.ID++
stack2.AutoUpdate = nil
err = store.Stack().Create(&stack2)
require.NoError(t, err)
handler := NewHandler(testhelpers.NewTestRequestBouncer())
handler.DataStore = store
payload := &stackGitUpdatePayload{
AutoUpdate: &portainer.AutoUpdateSettings{
Webhook: webhook.String(),
},
}
jsonPayload, err := json.Marshal(payload)
require.NoError(t, err)
url := "/stacks/" + strconv.Itoa(int(stack2.ID)) + "/git?endpointId=" + strconv.Itoa(int(endpoint.ID))
req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(jsonPayload))
rrc := &security.RestrictedRequestContext{}
req = req.WithContext(security.StoreRestrictedRequestContext(req, rrc))
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusConflict, rr.Code)
}
+1
View File
@@ -26,6 +26,7 @@ import (
// @produce json
// @param endpointId query int true "environment(endpoint) ID of the environment(endpoint) where the resource is located"
// @param nodeName query string false "node name"
// @param token query string true "JWT token used for authentication against this environment(endpoint)"
// @success 200
// @failure 400
// @failure 403
+1
View File
@@ -31,6 +31,7 @@ type execStartOperationPayload struct {
// @produce json
// @param endpointId query int true "environment(endpoint) ID of the environment(endpoint) where the resource is located"
// @param nodeName query string false "node name"
// @param token query string true "JWT token used for authentication against this environment(endpoint)"
// @success 200
// @failure 400
// @failure 409
+1
View File
@@ -30,6 +30,7 @@ import (
// @param podName query string true "name of the pod containing the container"
// @param containerName query string true "name of the container"
// @param command query string true "command to execute in the container"
// @param token query string true "JWT token used for authentication against this environment(endpoint)"
// @success 200
// @failure 400
// @failure 403
+1
View File
@@ -18,6 +18,7 @@ import (
// @accept json
// @produce json
// @param endpointId query int true "environment(endpoint) ID of the environment(endpoint) where the resource is located"
// @param token query string true "JWT token used for authentication against this environment(endpoint)"
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied"
+1 -3
View File
@@ -36,15 +36,13 @@ type K8sApplication struct {
Kind string `json:"Kind,omitempty"`
MatchLabels map[string]string `json:"MatchLabels,omitempty"`
Labels map[string]string `json:"Labels,omitempty"`
Annotations map[string]string `json:"Annotations,omitempty"`
Resource K8sApplicationResource `json:"Resource,omitempty"`
HorizontalPodAutoscaler *autoscalingv2.HorizontalPodAutoscaler `json:"HorizontalPodAutoscaler,omitempty"`
CustomResourceMetadata CustomResourceMetadata `json:"CustomResourceMetadata,omitempty"`
}
type Metadata struct {
Labels map[string]string `json:"labels"`
Annotations map[string]string `json:"annotations"`
Labels map[string]string `json:"labels"`
}
type CustomResourceMetadata struct {
+20 -13
View File
@@ -5,13 +5,11 @@ import (
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/docker/consts"
"github.com/portainer/portainer/api/http/proxy/factory/utils"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/slicesx"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/docker/docker/client"
"github.com/rs/zerolog/log"
)
@@ -19,6 +17,9 @@ const (
resourceLabelForPortainerTeamResourceControl = "io.portainer.accesscontrol.teams"
resourceLabelForPortainerUserResourceControl = "io.portainer.accesscontrol.users"
resourceLabelForPortainerPublicResourceControl = "io.portainer.accesscontrol.public"
resourceLabelForDockerSwarmStackName = "com.docker.stack.namespace"
resourceLabelForDockerServiceID = "com.docker.swarm.service.id"
resourceLabelForDockerComposeStackName = "com.docker.compose.project"
)
type (
@@ -122,7 +123,13 @@ func (transport *Transport) createPrivateResourceControl(resourceIdentifier stri
return resourceControl, nil
}
func (transport *Transport) getInheritedResourceControlFromServiceOrStack(client *client.Client, resourceIdentifier string, resourceType portainer.ResourceControlType, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) {
func (transport *Transport) getInheritedResourceControlFromServiceOrStack(resourceIdentifier, nodeName string, resourceType portainer.ResourceControlType, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) {
client, err := transport.dockerClientFactory.CreateClient(transport.endpoint, nodeName, nil)
if err != nil {
return nil, err
}
defer client.Close()
switch resourceType {
case portainer.ContainerResourceControl:
return getInheritedResourceControlFromContainerLabels(client, transport.endpoint.ID, resourceIdentifier, resourceControls)
@@ -288,8 +295,8 @@ func (transport *Transport) findResourceControl(resourceIdentifier string, resou
return nil, nil
}
if resourceLabelsObject[consts.SwarmServiceIDLabel] != nil {
inheritedServiceIdentifier := resourceLabelsObject[consts.SwarmServiceIDLabel].(string)
if resourceLabelsObject[resourceLabelForDockerServiceID] != nil {
inheritedServiceIdentifier := resourceLabelsObject[resourceLabelForDockerServiceID].(string)
resourceControl = authorization.GetResourceControlByResourceIDAndType(inheritedServiceIdentifier, portainer.ServiceResourceControl, resourceControls)
if resourceControl != nil {
@@ -297,8 +304,8 @@ func (transport *Transport) findResourceControl(resourceIdentifier string, resou
}
}
if resourceLabelsObject[consts.SwarmStackNameLabel] != nil {
stackName := resourceLabelsObject[consts.SwarmStackNameLabel].(string)
if resourceLabelsObject[resourceLabelForDockerSwarmStackName] != nil {
stackName := resourceLabelsObject[resourceLabelForDockerSwarmStackName].(string)
stackResourceID := stackutils.ResourceControlID(transport.endpoint.ID, stackName)
resourceControl = authorization.GetResourceControlByResourceIDAndType(stackResourceID, portainer.StackResourceControl, resourceControls)
@@ -307,8 +314,8 @@ func (transport *Transport) findResourceControl(resourceIdentifier string, resou
}
}
if resourceLabelsObject[consts.ComposeStackNameLabel] != nil {
stackName := resourceLabelsObject[consts.ComposeStackNameLabel].(string)
if resourceLabelsObject[resourceLabelForDockerComposeStackName] != nil {
stackName := resourceLabelsObject[resourceLabelForDockerComposeStackName].(string)
stackResourceID := stackutils.ResourceControlID(transport.endpoint.ID, stackName)
resourceControl = authorization.GetResourceControlByResourceIDAndType(stackResourceID, portainer.StackResourceControl, resourceControls)
@@ -321,14 +328,14 @@ func (transport *Transport) findResourceControl(resourceIdentifier string, resou
}
func getStackResourceIDFromLabels(resourceLabelsObject map[string]string, endpointID portainer.EndpointID) string {
if resourceLabelsObject[consts.SwarmStackNameLabel] != "" {
stackName := resourceLabelsObject[consts.SwarmStackNameLabel]
if resourceLabelsObject[resourceLabelForDockerSwarmStackName] != "" {
stackName := resourceLabelsObject[resourceLabelForDockerSwarmStackName]
return stackutils.ResourceControlID(endpointID, stackName)
}
if resourceLabelsObject[consts.ComposeStackNameLabel] != "" {
stackName := resourceLabelsObject[consts.ComposeStackNameLabel]
if resourceLabelsObject[resourceLabelForDockerComposeStackName] != "" {
stackName := resourceLabelsObject[resourceLabelForDockerComposeStackName]
return stackutils.ResourceControlID(endpointID, stackName)
}
+9 -65
View File
@@ -9,7 +9,6 @@ import (
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/docker/consts"
"github.com/portainer/portainer/api/http/proxy/factory/utils"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
@@ -35,7 +34,7 @@ func getInheritedResourceControlFromContainerLabels(dockerClient *client.Client,
return nil, err
}
serviceName := container.Config.Labels[consts.SwarmServiceIDLabel]
serviceName := container.Config.Labels[resourceLabelForDockerServiceID]
if serviceName != "" {
serviceResourceControl := authorization.GetResourceControlByResourceIDAndType(serviceName, portainer.ServiceResourceControl, resourceControls)
if serviceResourceControl != nil {
@@ -170,23 +169,18 @@ func containerHasBlackListedLabel(containerLabels map[string]any, labelBlackList
func (transport *Transport) decorateContainerCreationOperation(request *http.Request, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType) (*http.Response, error) {
type PartialContainer struct {
HostConfig struct {
Privileged bool `json:"Privileged"`
PidMode string `json:"PidMode"`
Devices []any `json:"Devices"`
Sysctls map[string]any `json:"Sysctls"`
SecurityOpt []string `json:"SecurityOpt"`
CapAdd []string `json:"CapAdd"`
CapDrop []string `json:"CapDrop"`
Binds []string `json:"Binds"`
Mounts []struct {
Type string `json:"Type"`
} `json:"Mounts"`
Privileged bool `json:"Privileged"`
PidMode string `json:"PidMode"`
Devices []any `json:"Devices"`
Sysctls map[string]any `json:"Sysctls"`
CapAdd []string `json:"CapAdd"`
CapDrop []string `json:"CapDrop"`
Binds []string `json:"Binds"`
} `json:"HostConfig"`
}
forbiddenResponse := &http.Response{
StatusCode: http.StatusForbidden,
Body: http.NoBody,
}
tokenData, err := security.RetrieveTokenData(request)
@@ -235,7 +229,7 @@ func (transport *Transport) decorateContainerCreationOperation(request *http.Req
return nil, ErrContainerCapabilitiesForbidden
}
if !securitySettings.AllowBindMountsForRegularUsers && len(partialContainer.HostConfig.Binds) > 0 {
if !securitySettings.AllowBindMountsForRegularUsers && (len(partialContainer.HostConfig.Binds) > 0) {
for _, bind := range partialContainer.HostConfig.Binds {
if strings.HasPrefix(bind, "/") {
return forbiddenResponse, ErrBindMountsForbidden
@@ -243,14 +237,6 @@ func (transport *Transport) decorateContainerCreationOperation(request *http.Req
}
}
if !securitySettings.AllowBindMountsForRegularUsers && len(partialContainer.HostConfig.Mounts) > 0 {
for _, mount := range partialContainer.HostConfig.Mounts {
if mount.Type == "bind" {
return forbiddenResponse, ErrBindMountsForbidden
}
}
}
request.Body = io.NopCloser(bytes.NewBuffer(body))
}
@@ -265,45 +251,3 @@ func (transport *Transport) decorateContainerCreationOperation(request *http.Req
return response, err
}
func (transport *Transport) decorateContainerUpdateOperation(request *http.Request, containerID string) (*http.Response, error) {
type PartialContainerUpdate struct {
Devices []any `json:"Devices"`
}
forbiddenResponse := &http.Response{
StatusCode: http.StatusForbidden,
}
isAdminOrEndpointAdmin, err := transport.isAdminOrEndpointAdmin(request)
if err != nil {
return nil, err
}
if isAdminOrEndpointAdmin {
return transport.restrictedResourceOperation(request, containerID, containerID, portainer.ContainerResourceControl, false)
}
securitySettings, err := transport.fetchEndpointSecuritySettings()
if err != nil {
return nil, err
}
body, err := io.ReadAll(request.Body)
if err != nil {
return nil, err
}
partialUpdate := &PartialContainerUpdate{}
if err := json.Unmarshal(body, partialUpdate); err != nil {
return nil, err
}
if !securitySettings.AllowDeviceMappingForRegularUsers && len(partialUpdate.Devices) > 0 {
return forbiddenResponse, ErrDeviceMappingForbidden
}
request.Body = io.NopCloser(bytes.NewBuffer(body))
return transport.restrictedResourceOperation(request, containerID, containerID, portainer.ContainerResourceControl, false)
}
@@ -1,123 +0,0 @@
package docker
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/security"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/require"
)
func TestDecorateContainerCreationOperation_BindMounts(t *testing.T) {
t.Parallel()
admin := portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}
regularUser := portainer.User{ID: 2, Username: "user", Role: portainer.StandardUserRole}
_, ds := datastore.MustNewTestStore(t, true, false)
err := ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
err := tx.User().Create(&admin)
require.NoError(t, err)
err = tx.User().Create(&regularUser)
require.NoError(t, err)
err = tx.Endpoint().Create(&portainer.Endpoint{
ID: 1,
Name: "test",
SecuritySettings: portainer.EndpointSecuritySettings{
AllowBindMountsForRegularUsers: false,
},
})
require.NoError(t, err)
return nil
})
require.NoError(t, err)
srv, version := mockDockerAPIServer(t, RoutesDefinition{
{http.MethodPost, "/containers/create"}: map[string]any{"Id": "abc123", "Warnings": []any{}},
})
defer srv.Close()
transport := &Transport{
endpoint: &portainer.Endpoint{ID: 1, URL: srv.URL},
dataStore: ds,
HTTPTransport: &http.Transport{},
}
adminToken := portainer.TokenData{ID: admin.ID, Username: admin.Username, Role: admin.Role}
userToken := portainer.TokenData{ID: regularUser.ID, Username: regularUser.Username, Role: regularUser.Role}
makeRequest := func(token portainer.TokenData, body any) *http.Request {
bodyBytes, err := json.Marshal(body)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, srv.URL+"/v"+version+"/containers/create", bytes.NewReader(bodyBytes))
req = req.WithContext(security.StoreTokenData(req, &token))
return req
}
// Admin bypasses security checks
req := makeRequest(adminToken, map[string]any{
"HostConfig": map[string]any{
"Mounts": []map[string]any{{"Type": "bind", "Source": "/", "Target": "/host"}},
},
})
resp, err := transport.decorateContainerCreationOperation(req, containerObjectIdentifier, portainer.ContainerResourceControl)
require.NoError(t, err)
require.NotNil(t, resp)
err = resp.Body.Close()
require.NoError(t, err)
// HostConfig.Binds with an absolute path is blocked for regular users
req = makeRequest(userToken, map[string]any{
"HostConfig": map[string]any{
"Binds": []string{"/:/host:ro"},
},
})
resp, err = transport.decorateContainerCreationOperation(req, containerObjectIdentifier, portainer.ContainerResourceControl)
require.ErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
// HostConfig.Mounts with type bind is blocked for regular users
req = makeRequest(userToken, map[string]any{
"HostConfig": map[string]any{
"Mounts": []map[string]any{{"Type": "bind", "Source": "/", "Target": "/host"}},
},
})
resp, err = transport.decorateContainerCreationOperation(req, containerObjectIdentifier, portainer.ContainerResourceControl)
require.ErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
// HostConfig.Mounts with a non-bind type is allowed for regular users
req = makeRequest(userToken, map[string]any{
"HostConfig": map[string]any{
"Mounts": []map[string]any{{"Type": "volume", "Source": "myvolume", "Target": "/data"}},
},
})
resp, err = transport.decorateContainerCreationOperation(req, containerObjectIdentifier, portainer.ContainerResourceControl)
require.NoError(t, err)
require.NotNil(t, resp)
err = resp.Body.Close()
require.NoError(t, err)
}
+35 -104
View File
@@ -3,6 +3,7 @@ package docker
import (
"bytes"
"context"
"errors"
"io"
"net/http"
@@ -10,79 +11,15 @@ import (
"github.com/portainer/portainer/api/http/proxy/factory/utils"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/api/types"
"github.com/docker/docker/client"
"github.com/segmentio/encoding/json"
)
const serviceObjectIdentifier = "ID"
type partialServiceSpec struct {
TaskTemplate struct {
ContainerSpec struct {
CapabilityAdd []string `json:"CapabilityAdd"`
CapabilityDrop []string `json:"CapabilityDrop"`
Sysctls map[string]any `json:"Sysctls"`
Privileges *struct {
Seccomp *struct{ Mode string } `json:"Seccomp"`
AppArmor *struct{ Mode string } `json:"AppArmor"`
} `json:"Privileges"`
Mounts []struct {
Type string `json:"Type"`
VolumeOptions *struct {
DriverConfig *struct {
Options map[string]string `json:"Options"`
} `json:"DriverConfig"`
} `json:"VolumeOptions"`
} `json:"Mounts"`
} `json:"ContainerSpec"`
} `json:"TaskTemplate"`
}
func CheckServiceBodyRestrictions(request *http.Request, securitySettings *portainer.EndpointSecuritySettings) error {
defer request.Body.Close()
body, err := io.ReadAll(request.Body)
if err != nil {
return err
}
spec := &partialServiceSpec{}
if err := json.Unmarshal(body, spec); err != nil {
return err
}
containerSpec := spec.TaskTemplate.ContainerSpec
if !securitySettings.AllowContainerCapabilitiesForRegularUsers && (len(containerSpec.CapabilityAdd) > 0 || len(containerSpec.CapabilityDrop) > 0) {
return ErrContainerCapabilitiesForbidden
}
if !securitySettings.AllowSysctlSettingForRegularUsers && len(containerSpec.Sysctls) > 0 {
return ErrSysCtlSettingsForbidden
}
if !securitySettings.AllowBindMountsForRegularUsers {
for _, mount := range containerSpec.Mounts {
if mount.Type == "bind" {
return ErrBindMountsForbidden
}
if mount.VolumeOptions != nil && mount.VolumeOptions.DriverConfig != nil {
if mount.VolumeOptions.DriverConfig.Options["type"] == "bind" {
return ErrBindMountsForbidden
}
}
}
}
request.Body = io.NopCloser(bytes.NewBuffer(body))
return nil
}
func getInheritedResourceControlFromServiceLabels(dockerClient *client.Client, endpointID portainer.EndpointID, serviceID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) {
service, _, err := dockerClient.ServiceInspectWithRaw(context.Background(), serviceID, swarm.ServiceInspectOptions{})
service, _, err := dockerClient.ServiceInspectWithRaw(context.Background(), serviceID, types.ServiceInspectOptions{})
if err != nil {
return nil, err
}
@@ -153,6 +90,20 @@ func selectorServiceLabels(responseObject map[string]any) map[string]any {
}
func (transport *Transport) decorateServiceCreationOperation(request *http.Request) (*http.Response, error) {
type PartialService struct {
TaskTemplate struct {
ContainerSpec struct {
Mounts []struct {
Type string
}
}
}
}
forbiddenResponse := &http.Response{
StatusCode: http.StatusForbidden,
}
isAdminOrEndpointAdmin, err := transport.isAdminOrEndpointAdmin(request)
if err != nil {
return nil, err
@@ -167,45 +118,25 @@ func (transport *Transport) decorateServiceCreationOperation(request *http.Reque
return nil, err
}
if err := CheckServiceBodyRestrictions(request, securitySettings); err != nil {
return &http.Response{
StatusCode: http.StatusForbidden,
Body: io.NopCloser(bytes.NewBufferString("Access denied: insufficient permissions to create service with specified configuration")),
}, err
body, err := io.ReadAll(request.Body)
if err != nil {
return nil, err
}
partialService := &PartialService{}
if err := json.Unmarshal(body, partialService); err != nil {
return nil, err
}
if !securitySettings.AllowBindMountsForRegularUsers && (len(partialService.TaskTemplate.ContainerSpec.Mounts) > 0) {
for _, mount := range partialService.TaskTemplate.ContainerSpec.Mounts {
if mount.Type == "bind" {
return forbiddenResponse, errors.New("forbidden to use bind mounts")
}
}
}
request.Body = io.NopCloser(bytes.NewBuffer(body))
return transport.replaceRegistryAuthenticationHeader(request)
}
func (transport *Transport) decorateServiceUpdateOperation(request *http.Request, serviceID string) (*http.Response, error) {
isAdminOrEndpointAdmin, err := transport.isAdminOrEndpointAdmin(request)
if err != nil {
return nil, err
}
if isAdminOrEndpointAdmin {
if err := transport.decorateRegistryAuthenticationHeader(request); err != nil {
return nil, err
}
return transport.executeDockerRequest(request)
}
securitySettings, err := transport.fetchEndpointSecuritySettings()
if err != nil {
return nil, err
}
if err := CheckServiceBodyRestrictions(request, securitySettings); err != nil {
return &http.Response{
StatusCode: http.StatusForbidden,
Body: io.NopCloser(bytes.NewBufferString("Access denied: insufficient permissions to update service with specified configuration")),
}, err
}
if err := transport.decorateRegistryAuthenticationHeader(request); err != nil {
return nil, err
}
return transport.restrictedResourceOperation(request, serviceID, serviceID, portainer.ServiceResourceControl, false)
}
@@ -1,522 +0,0 @@
package docker
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/security"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/swarm"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/require"
)
const serviceCreationAPIVersion = "1.51"
type serviceCreationFixtures struct {
dockerSrv *httptest.Server
ds dataservices.DataStore
stdUser portainer.User
adminUser portainer.User
endpointID portainer.EndpointID
}
func newServiceCreationFixtures(t *testing.T) *serviceCreationFixtures {
t.Helper()
const serviceID = "some-service-id"
dockerSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead && r.URL.Path == "/_ping" {
w.Header().Add("Api-Version", serviceCreationAPIVersion)
_, _ = w.Write([]byte{})
return
}
if r.Method == http.MethodPost {
data, err := json.Marshal(map[string]string{"ID": serviceID})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write(data)
return
}
http.NotFound(w, r)
}))
t.Cleanup(dockerSrv.Close)
_, store := datastore.MustNewTestStore(t, true, false)
f := &serviceCreationFixtures{
dockerSrv: dockerSrv,
ds: store,
stdUser: portainer.User{ID: 1, Username: "std", Role: portainer.StandardUserRole},
adminUser: portainer.User{ID: 2, Username: "admin", Role: portainer.AdministratorRole},
endpointID: portainer.EndpointID(1),
}
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
err := tx.User().Create(&f.stdUser)
require.NoError(t, err)
err = tx.User().Create(&f.adminUser)
require.NoError(t, err)
err = tx.Endpoint().Create(&portainer.Endpoint{ID: f.endpointID, Name: "test-env"})
require.NoError(t, err)
return nil
})
require.NoError(t, err)
return f
}
func (f *serviceCreationFixtures) setSecuritySettings(t *testing.T, settings portainer.EndpointSecuritySettings) {
t.Helper()
err := f.ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.Endpoint().UpdateEndpoint(f.endpointID, &portainer.Endpoint{
ID: f.endpointID,
Name: "test-env",
SecuritySettings: settings,
})
})
require.NoError(t, err)
}
func (f *serviceCreationFixtures) newTransport() *Transport {
return &Transport{
endpoint: &portainer.Endpoint{ID: f.endpointID},
dataStore: f.ds,
HTTPTransport: &http.Transport{},
}
}
func (f *serviceCreationFixtures) newRequest(t *testing.T, spec swarm.ServiceSpec, user portainer.User) *http.Request {
t.Helper()
data, err := json.Marshal(spec)
require.NoError(t, err)
req, err := http.NewRequestWithContext(
t.Context(),
http.MethodPost,
f.dockerSrv.URL+"/v"+serviceCreationAPIVersion+"/services/create",
bytes.NewReader(data),
)
require.NoError(t, err)
return req.WithContext(security.StoreTokenData(req, &portainer.TokenData{
ID: user.ID,
Username: user.Username,
Role: user.Role,
}))
}
var (
restrictiveSettings = portainer.EndpointSecuritySettings{
AllowContainerCapabilitiesForRegularUsers: false,
AllowSysctlSettingForRegularUsers: false,
AllowBindMountsForRegularUsers: false,
}
permissiveSettings = portainer.EndpointSecuritySettings{
AllowContainerCapabilitiesForRegularUsers: true,
AllowSysctlSettingForRegularUsers: true,
AllowBindMountsForRegularUsers: true,
}
)
func TestDecorateServiceCreationOperation_CapabilityAddForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
CapabilityAdd: []string{"NET_ADMIN"},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.ErrorIs(t, err, ErrContainerCapabilitiesForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_CapabilityDropForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
CapabilityDrop: []string{"MKNOD"},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.ErrorIs(t, err, ErrContainerCapabilitiesForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_CapabilitiesAllowed(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, permissiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
CapabilityAdd: []string{"NET_ADMIN"},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NoError(t, err)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_NoCapabilitiesAllowed(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
var spec swarm.ServiceSpec
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NotErrorIs(t, err, ErrContainerCapabilitiesForbidden)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_SysctlForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Sysctls: map[string]string{"net.ipv4.ip_forward": "1"},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.ErrorIs(t, err, ErrSysCtlSettingsForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_SysctlAllowed(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, permissiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Sysctls: map[string]string{"net.ipv4.ip_forward": "1"},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NoError(t, err)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_BindMountForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Mounts: []mount.Mount{{Type: mount.TypeBind}},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.ErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_NonBindMountNotForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, portainer.EndpointSecuritySettings{
AllowContainerCapabilitiesForRegularUsers: true,
AllowSysctlSettingForRegularUsers: true,
AllowBindMountsForRegularUsers: false,
})
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Mounts: []mount.Mount{{Type: mount.TypeVolume}},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NotErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_BindMountAllowed(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, permissiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Mounts: []mount.Mount{{Type: mount.TypeBind}},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NoError(t, err)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_AdminBypassesAllSecurityChecks(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
CapabilityAdd: []string{"NET_ADMIN"},
CapabilityDrop: []string{"MKNOD"},
Sysctls: map[string]string{"net.ipv4.ip_forward": "1"},
Mounts: []mount.Mount{{Type: mount.TypeBind}},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.adminUser))
require.NotErrorIs(t, err, ErrContainerCapabilitiesForbidden)
require.NotErrorIs(t, err, ErrSysCtlSettingsForbidden)
require.NotErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_StandardUserPermissiveSettingsSucceeds(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, permissiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
CapabilityAdd: []string{"NET_ADMIN"},
Sysctls: map[string]string{"net.core.somaxconn": "128"},
Mounts: []mount.Mount{{Type: mount.TypeBind}},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NoError(t, err)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_VolumeWithBindDriverOptionForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Mounts: []mount.Mount{{
Type: mount.TypeVolume,
VolumeOptions: &mount.VolumeOptions{
DriverConfig: &mount.Driver{
Options: map[string]string{"type": "bind", "device": "/etc"},
},
},
}},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.ErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_VolumeWithBindDriverOptionAllowed(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, permissiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Mounts: []mount.Mount{{
Type: mount.TypeVolume,
VolumeOptions: &mount.VolumeOptions{
DriverConfig: &mount.Driver{
Options: map[string]string{"type": "bind", "device": "/etc"},
},
},
}},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NoError(t, err)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_VolumeWithNonBindDriverOptionNotForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Mounts: []mount.Mount{{
Type: mount.TypeVolume,
VolumeOptions: &mount.VolumeOptions{
DriverConfig: &mount.Driver{
Options: map[string]string{"type": "tmpfs"},
},
},
}},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NotErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceUpdateOperation_VolumeWithBindDriverOptionForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Mounts: []mount.Mount{{
Type: mount.TypeVolume,
VolumeOptions: &mount.VolumeOptions{
DriverConfig: &mount.Driver{
Options: map[string]string{"type": "bind", "device": "/etc"},
},
},
}},
},
},
}
resp, err := f.newTransport().decorateServiceUpdateOperation(f.newRequest(t, spec, f.stdUser), "test-service-id")
require.ErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
+38 -168
View File
@@ -2,7 +2,6 @@ package docker
import (
"bytes"
"context"
"encoding/base64"
"errors"
"fmt"
@@ -16,16 +15,12 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/docker/client"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/http/proxy/factory/utils"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/slicesx"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/swarm"
dockerclient "github.com/docker/docker/client"
dockerclient "github.com/portainer/portainer/api/docker/client"
"github.com/rs/zerolog/log"
"github.com/segmentio/encoding/json"
)
@@ -41,7 +36,7 @@ type (
dataStore dataservices.DataStore
signatureService portainer.DigitalSignatureService
reverseTunnelService portainer.ReverseTunnelService
dockerClientFactory *client.ClientFactory
dockerClientFactory *dockerclient.ClientFactory
gitService portainer.GitService
snapshotService portainer.SnapshotService
dockerID string
@@ -54,7 +49,7 @@ type (
DataStore dataservices.DataStore
SignatureService portainer.DigitalSignatureService
ReverseTunnelService portainer.ReverseTunnelService
DockerClientFactory *client.ClientFactory
DockerClientFactory *dockerclient.ClientFactory
}
restrictedDockerOperationContext struct {
@@ -109,34 +104,9 @@ var prefixProxyFuncMap = map[string]func(*Transport, *http.Request, string) (*ht
"volumes": (*Transport).proxyVolumeRequest,
}
type route struct {
method string
pattern *regexp.Regexp
}
var adminOnlyRoutes = []route{
{http.MethodPost, regexp.MustCompile(`^/plugins/.+/enable$`)},
{http.MethodPost, regexp.MustCompile(`^/plugins/.+/disable$`)},
{http.MethodPost, regexp.MustCompile(`^/plugins/pull$`)},
{http.MethodPost, regexp.MustCompile(`^/plugins/.+/push$`)},
{http.MethodPost, regexp.MustCompile(`^/plugins/.+/upgrade$`)},
{http.MethodPost, regexp.MustCompile(`^/plugins/.+/set$`)},
{http.MethodPost, regexp.MustCompile(`^/plugins/create$`)},
{http.MethodDelete, regexp.MustCompile(`^/plugins/.+$`)},
}
func isAdminOnlyRoute(method string, path string) bool {
return slicesx.Some(adminOnlyRoutes, func(r route) bool {
return method == r.method && r.pattern.MatchString(path)
})
}
// ProxyDockerRequest intercepts a Docker API request and apply logic based
// on the requested operation.
func (transport *Transport) ProxyDockerRequest(request *http.Request) (*http.Response, error) {
// from : /v1.44/containers/{id}/json
// or : /containers/{id}/json
// to : /containers/{id}/json
unversionedPath := apiVersionRe.ReplaceAllString(request.URL.Path, "")
if transport.endpoint.Type == portainer.AgentOnDockerEnvironment || transport.endpoint.Type == portainer.EdgeAgentOnDockerEnvironment {
@@ -149,20 +119,12 @@ func (transport *Transport) ProxyDockerRequest(request *http.Request) (*http.Res
request.Header.Set(portainer.PortainerAgentSignatureHeader, signature)
}
// from : /containers/{id}/json
// trim to : containers/{id}/json
// pick : [ containers, {id}, json ][0]
// prefix : containers
prefix := strings.Split(strings.TrimPrefix(unversionedPath, "/"), "/")[0]
if proxyFunc := prefixProxyFuncMap[prefix]; proxyFunc != nil {
return proxyFunc(transport, request, unversionedPath)
}
if isAdminOnlyRoute(request.Method, unversionedPath) {
return transport.administratorOperation(request)
}
return transport.executeDockerRequest(request)
}
@@ -253,10 +215,9 @@ func (transport *Transport) proxyConfigRequest(request *http.Request, unversione
// Assume /configs/{id}
configID := path.Base(requestPath)
switch request.Method {
case http.MethodGet:
if request.Method == http.MethodGet {
return transport.rewriteOperation(request, transport.configInspectOperation)
case http.MethodDelete:
} else if request.Method == http.MethodDelete {
return transport.executeGenericResourceDeletionOperation(request, configID, configID, portainer.ConfigResourceControl)
}
@@ -288,10 +249,6 @@ func (transport *Transport) proxyContainerRequest(request *http.Request, unversi
return transport.rewriteOperation(request, transport.containerInspectOperation)
}
if action == "update" {
return transport.decorateContainerUpdateOperation(request, containerID)
}
return transport.restrictedResourceOperation(request, containerID, containerID, portainer.ContainerResourceControl, false)
} else if match, _ := path.Match("/containers/*", requestPath); match {
// Handle /containers/{id} requests
@@ -323,15 +280,7 @@ func (transport *Transport) proxyServiceRequest(request *http.Request, unversion
if match, _ := path.Match("/services/*/*", requestPath); match {
// Handle /services/{id}/{action} requests
serviceID := path.Base(path.Dir(requestPath))
action := path.Base(requestPath)
if action == "update" {
return transport.decorateServiceUpdateOperation(request, serviceID)
}
if err := transport.decorateRegistryAuthenticationHeader(request); err != nil {
return nil, err
}
transport.decorateRegistryAuthenticationHeader(request)
return transport.restrictedResourceOperation(request, serviceID, serviceID, portainer.ServiceResourceControl, false)
} else if match, _ := path.Match("/services/*", requestPath); match {
@@ -371,38 +320,28 @@ func (transport *Transport) proxyVolumeRequest(request *http.Request, unversione
}
}
func match(requestPath string, pattern string) bool {
ok, err := path.Match(pattern, requestPath)
return err == nil && ok
}
func (transport *Transport) proxyNetworkRequest(request *http.Request, unversionedPath string) (*http.Response, error) {
requestPath := unversionedPath
switch {
case requestPath == "/networks/create":
switch requestPath {
case "/networks/create":
return transport.decorateGenericResourceCreationOperation(request, networkObjectIdentifier, portainer.NetworkResourceControl)
case requestPath == "/networks":
case "/networks":
return transport.rewriteOperation(request, transport.networkListOperation)
case request.Method == http.MethodPost && match(requestPath, "/networks/*/connect"),
request.Method == http.MethodPost && match(requestPath, "/networks/*/disconnect"):
networkID := path.Base(path.Dir(requestPath))
return transport.restrictedResourceOperation(request, networkID, networkID, portainer.NetworkResourceControl, false)
case request.Method == http.MethodGet && match(requestPath, "/networks/*"):
return transport.rewriteOperation(request, transport.networkInspectOperation)
case request.Method == http.MethodDelete && match(requestPath, "/networks/*"):
default:
// Assume /networks/{id}
networkID := path.Base(requestPath)
return transport.executeGenericResourceDeletionOperation(request, networkID, networkID, portainer.NetworkResourceControl)
}
// Assume /networks/{id}
networkID := path.Base(requestPath)
return transport.restrictedResourceOperation(request, networkID, networkID, portainer.NetworkResourceControl, false)
if request.Method == http.MethodGet {
return transport.rewriteOperation(request, transport.networkInspectOperation)
} else if request.Method == http.MethodDelete {
return transport.executeGenericResourceDeletionOperation(request, networkID, networkID, portainer.NetworkResourceControl)
}
return transport.restrictedResourceOperation(request, networkID, networkID, portainer.NetworkResourceControl, false)
}
}
func (transport *Transport) proxySecretRequest(request *http.Request, unversionedPath string) (*http.Response, error) {
@@ -419,10 +358,9 @@ func (transport *Transport) proxySecretRequest(request *http.Request, unversione
// Assume /secrets/{id}
secretID := path.Base(requestPath)
switch request.Method {
case http.MethodGet:
if request.Method == http.MethodGet {
return transport.rewriteOperation(request, transport.secretInspectOperation)
case http.MethodDelete:
} else if request.Method == http.MethodDelete {
return transport.executeGenericResourceDeletionOperation(request, secretID, secretID, portainer.SecretResourceControl)
}
@@ -475,6 +413,7 @@ func (transport *Transport) proxyBuildRequest(request *http.Request, _ string) (
func (transport *Transport) updateDefaultGitBranch(request *http.Request) error {
remote := request.URL.Query().Get("remote")
if !strings.HasSuffix(remote, ".git") {
return nil
}
@@ -610,101 +549,32 @@ func (transport *Transport) restrictedResourceOperation(request *http.Request, r
}
resourceControl := authorization.GetResourceControlByResourceIDAndType(resourceID, resourceType, resourceControls)
if resourceControl != nil {
if !authorization.UserCanAccessResource(tokenData.ID, userTeamIDs, resourceControl) {
if resourceControl == nil {
agentTargetHeader := request.Header.Get(portainer.PortainerAgentTargetHeader)
if dockerResourceID == "" {
dockerResourceID = resourceID
}
// This resource was created outside of portainer,
// is part of a Docker service or part of a Docker Swarm/Compose stack.
inheritedResourceControl, err := transport.getInheritedResourceControlFromServiceOrStack(dockerResourceID, agentTargetHeader, resourceType, resourceControls)
if err != nil {
return nil, err
}
if inheritedResourceControl == nil || !authorization.UserCanAccessResource(tokenData.ID, userTeamIDs, inheritedResourceControl) {
return utils.WriteAccessDeniedResponse()
}
return transport.executeDockerRequest(request)
}
client, err := transport.dockerClientFactory.CreateClient(transport.endpoint, request.Header.Get(portainer.PortainerAgentTargetHeader), nil)
if err != nil {
return nil, err
}
defer client.Close()
// the resourceID may be the resource name (as it's a valid proxy call to use the name and not the UUID)
// so get the real resource ID and retry with it
resourceID, err = getRealResourceID(client, resourceType, resourceID)
if err != nil {
return nil, err
}
resourceControl = authorization.GetResourceControlByResourceIDAndType(resourceID, resourceType, resourceControls)
if resourceControl != nil {
if !authorization.UserCanAccessResource(tokenData.ID, userTeamIDs, resourceControl) {
return utils.WriteAccessDeniedResponse()
}
return transport.executeDockerRequest(request)
}
// If we still can't find the RC by provided ID or "real" (docker-extracted) ID
// it means this resource was created outside of portainer,
// is part of a Docker service or part of a Docker Swarm/Compose stack.
if dockerResourceID == "" {
dockerResourceID = resourceID
}
inheritedResourceControl, err := transport.getInheritedResourceControlFromServiceOrStack(client, dockerResourceID, resourceType, resourceControls)
if err != nil {
return nil, err
}
if inheritedResourceControl == nil || !authorization.UserCanAccessResource(tokenData.ID, userTeamIDs, inheritedResourceControl) {
if resourceControl != nil && !authorization.UserCanAccessResource(tokenData.ID, userTeamIDs, resourceControl) {
return utils.WriteAccessDeniedResponse()
}
return transport.executeDockerRequest(request)
}
func getRealResourceID(client *dockerclient.Client, resourceType portainer.ResourceControlType, resourceId string) (string, error) {
switch resourceType {
case portainer.NetworkResourceControl:
network, err := client.NetworkInspect(context.Background(), resourceId, network.InspectOptions{})
if err != nil {
return "", err
}
return network.ID, nil
case portainer.ContainerResourceControl:
container, err := client.ContainerInspect(context.Background(), resourceId)
if err != nil {
return "", err
}
return container.ID, nil
case portainer.VolumeResourceControl:
// volumes don't have an UUID and their UACresourceID has a particular construct that makes them unique
// e.g. fmt.Sprintf("%s_%s", volumeName, dockerID)
// see transport.getVolumeResourceID() / FetchDockerID()
// FetchDockerID fetches info.Swarm.Cluster.ID if environment(endpoint) is swarm and info.ID otherwise
// So: return empty ID but without error
return "", nil
case portainer.ServiceResourceControl:
service, _, err := client.ServiceInspectWithRaw(context.Background(), resourceId, swarm.ServiceInspectOptions{})
if err != nil {
return "", err
}
return service.ID, nil
case portainer.ConfigResourceControl:
config, _, err := client.ConfigInspectWithRaw(context.Background(), resourceId)
if err != nil {
return "", err
}
return config.ID, nil
case portainer.SecretResourceControl:
secret, _, err := client.SecretInspectWithRaw(context.Background(), resourceId)
if err != nil {
return "", err
}
return secret.ID, nil
}
return "", fmt.Errorf("Unknown resource type %v", resourceType)
}
// rewriteOperationWithLabelFiltering will create a new operation context with data that will be used
// to decorate the original request's response as well as retrieve all the black listed labels
// to filter the resources.
+1 -474
View File
@@ -6,19 +6,9 @@ import (
"net/http/httptest"
"testing"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/swarm"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTransport_updateDefaultGitBranch(t *testing.T) {
@@ -31,6 +21,7 @@ func TestTransport_updateDefaultGitBranch(t *testing.T) {
}
commitId := "my-latest-commit-id"
defaultFields := fields{
gitService: testhelpers.NewGitService(nil, commitId),
}
@@ -76,467 +67,3 @@ func TestTransport_updateDefaultGitBranch(t *testing.T) {
})
}
}
type RoutesDefinition map[[2]string]any
func mockDockerAPIServer(t *testing.T, routes RoutesDefinition) (*httptest.Server, string) {
version := "1.51"
v := func(path string) string {
return "/v" + version + path
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead && r.URL.Path == "/_ping" {
w.Header().Add("Api-Version", version)
_, err := w.Write([]byte{})
require.Nil(t, err)
return
}
for defs, rValue := range routes {
method, path := defs[0], defs[1]
if r.Method == method && r.URL.Path == v(path) {
require.Nil(t, response.JSON(w, rValue))
return
}
}
http.NotFound(w, r)
}))
require.NotNil(t, srv)
return srv, version
}
func TestTransport_adminProxy(t *testing.T) {
t.Parallel()
admin := portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}
std1 := portainer.User{ID: 2, Username: "std1", Role: portainer.StandardUserRole}
std2 := portainer.User{ID: 3, Username: "std2", Role: portainer.StandardUserRole}
_, ds := datastore.MustNewTestStore(t, true, false)
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.User().Create(&admin))
require.NoError(t, tx.User().Create(&std1))
require.NoError(t, tx.User().Create(&std2))
require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{ID: 1, Name: "env",
UserAccessPolicies: portainer.UserAccessPolicies{std1.ID: portainer.AccessPolicy{RoleID: 1}},
}))
return nil
}))
srv, version := mockDockerAPIServer(t, RoutesDefinition{
// allowed routes
{http.MethodGet, "/plugins"}: nil,
{http.MethodGet, "/plugins/xxx/json"}: nil,
{http.MethodGet, "/plugins/privileges"}: nil,
// admin routes ; see `adminOnlyRoutes`
{http.MethodDelete, "/plugins/xxx"}: nil,
{http.MethodPost, "/plugins/sshfs/enable"}: nil, // simulate plugin "sshfs"
{http.MethodPost, "/plugins/vieux/sshfs/enable"}: nil, // simulate "vieux/sshfs"
{http.MethodPost, "/plugins/xxx/disable"}: nil,
{http.MethodPost, "/plugins/pull"}: nil,
{http.MethodPost, "/plugins/xxx/push"}: nil,
{http.MethodPost, "/plugins/xxx/upgrade"}: nil,
{http.MethodPost, "/plugins/xxx/set"}: nil,
{http.MethodPost, "/plugins/create"}: nil,
})
defer srv.Close()
transport := &Transport{
endpoint: &portainer.Endpoint{URL: srv.URL},
dataStore: ds,
HTTPTransport: &http.Transport{},
}
test := func(method string, url string, token portainer.TokenData) (*http.Response, error) {
req := httptest.NewRequest(method, srv.URL+"/v"+version+url, nil)
req = req.WithContext(security.StoreTokenData(req, &token))
require.NotNil(t, req)
return transport.ProxyDockerRequest(req)
}
adminToken := portainer.TokenData{ID: admin.ID, Username: admin.Username, Role: admin.Role}
std1Token := portainer.TokenData{ID: std1.ID, Username: std1.Username, Role: std1.Role}
std2Token := portainer.TokenData{ID: std2.ID, Username: std2.Username, Role: std2.Role}
{
r, err := test(http.MethodGet, "/plugins", adminToken)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodGet, "/plugins", std1Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodGet, "/plugins", std2Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/plugins/pull", adminToken)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/plugins/pull", std1Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusForbidden, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/plugins/pull", std2Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusForbidden, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/plugins/sshfs/enable", adminToken)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/plugins/sshfs/enable", std2Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusForbidden, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/plugins/vieux/sshfs/enable", adminToken)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/plugins/vieux/sshfs/enable", std2Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusForbidden, r.StatusCode)
require.NoError(t, r.Body.Close())
}
}
func TestTransport_getRealResourceID(t *testing.T) {
srv, _ := mockDockerAPIServer(t, RoutesDefinition{
{http.MethodGet, "/networks"}: []network.Summary{{ID: "16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4", Name: "mynetwork"}},
{http.MethodGet, "/networks/mynetwork"}: network.Inspect{ID: "16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4", Name: "mynetwork"},
{http.MethodGet, "/networks/16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4"}: network.Inspect{ID: "16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4", Name: "mynetwork"},
{http.MethodGet, "/containers/mycontainer/json"}: container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{ID: "545fc03ed1fd5008c3bfa2441209ff024e21e396acbeb58b2355930ad1295aa6", Name: "mycontainer"}},
{http.MethodGet, "/containers/545fc03ed1fd5008c3bfa2441209ff024e21e396acbeb58b2355930ad1295aa6/json"}: container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{ID: "545fc03ed1fd5008c3bfa2441209ff024e21e396acbeb58b2355930ad1295aa6", Name: "mycontainer"}},
{http.MethodGet, "/services/myservice"}: swarm.Service{ID: "ibt43uf5awhg06bxp8rkd7bhi", Spec: swarm.ServiceSpec{Annotations: swarm.Annotations{Name: "myservice"}}},
{http.MethodGet, "/services/ibt43uf5awhg06bxp8rkd7bhi"}: swarm.Service{ID: "ibt43uf5awhg06bxp8rkd7bhi", Spec: swarm.ServiceSpec{Annotations: swarm.Annotations{Name: "myservice"}}},
{http.MethodGet, "/configs/myconfig"}: swarm.Config{ID: "3mlqqza0k413ecebk0mfa11em", Spec: swarm.ConfigSpec{Annotations: swarm.Annotations{Name: "myconfig"}}},
{http.MethodGet, "/configs/3mlqqza0k413ecebk0mfa11em"}: swarm.Config{ID: "3mlqqza0k413ecebk0mfa11em", Spec: swarm.ConfigSpec{Annotations: swarm.Annotations{Name: "myconfig"}}},
{http.MethodGet, "/secrets/mysecret"}: swarm.Secret{ID: "v9i7o4ivg33u4z3jfyxto162d", Spec: swarm.SecretSpec{Annotations: swarm.Annotations{Name: "mysecret"}}},
{http.MethodGet, "/secrets/v9i7o4ivg33u4z3jfyxto162d"}: swarm.Secret{ID: "v9i7o4ivg33u4z3jfyxto162d", Spec: swarm.SecretSpec{Annotations: swarm.Annotations{Name: "mysecret"}}},
})
defer srv.Close()
transport := &Transport{
endpoint: &portainer.Endpoint{URL: srv.URL},
}
client, err := transport.dockerClientFactory.CreateClient(transport.endpoint, "", nil)
require.NoError(t, err)
require.NotNil(t, client)
test := func(rctype portainer.ResourceControlType, name string, id string, errOnUnknown bool) {
// by id
got, err := getRealResourceID(client, rctype, id)
require.NoError(t, err)
require.Equal(t, id, got)
// by name
got, err = getRealResourceID(client, rctype, name)
require.NoError(t, err)
require.Equal(t, id, got)
// unknown for this type
_, err = getRealResourceID(client, rctype, "unknown")
if errOnUnknown {
require.Error(t, err)
} else {
require.NoError(t, err)
}
}
test(portainer.NetworkResourceControl, "mynetwork", "16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4", true)
test(portainer.ContainerResourceControl, "mycontainer", "545fc03ed1fd5008c3bfa2441209ff024e21e396acbeb58b2355930ad1295aa6", true)
test(portainer.VolumeResourceControl, "anything", "", false)
test(portainer.ServiceResourceControl, "myservice", "ibt43uf5awhg06bxp8rkd7bhi", true)
test(portainer.ConfigResourceControl, "myconfig", "3mlqqza0k413ecebk0mfa11em", true)
test(portainer.SecretResourceControl, "mysecret", "v9i7o4ivg33u4z3jfyxto162d", true)
// validate that other types are not supported
_, err = getRealResourceID(client, portainer.ContainerGroupResourceControl, "")
require.Error(t, err)
}
func TestTransport_proxyNetworkRequest(t *testing.T) {
admin := portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}
std1 := portainer.User{ID: 2, Username: "std1", Role: portainer.StandardUserRole}
std2 := portainer.User{ID: 3, Username: "std2", Role: portainer.StandardUserRole}
_, ds := datastore.MustNewTestStore(t, true, false)
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.User().Create(&admin))
require.NoError(t, tx.User().Create(&std1))
require.NoError(t, tx.User().Create(&std2))
require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{ID: 1, Name: "env",
UserAccessPolicies: portainer.UserAccessPolicies{std1.ID: portainer.AccessPolicy{RoleID: 1}},
}))
require.NoError(t, tx.ResourceControl().Create(authorization.NewPrivateResourceControl("16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4", portainer.NetworkResourceControl, std1.ID)))
return nil
}))
srv, version := mockDockerAPIServer(t, RoutesDefinition{
{http.MethodGet, "/networks"}: []network.Summary{{ID: "16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4", Name: "mynetwork"}},
{http.MethodGet, "/networks/mynetwork"}: network.Inspect{ID: "16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4", Name: "mynetwork"},
{http.MethodGet, "/networks/16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4"}: network.Inspect{ID: "16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4", Name: "mynetwork"},
{http.MethodPost, "/networks/mynetwork/connect"}: struct{}{},
{http.MethodPost, "/networks/16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4/connect"}: struct{}{},
{http.MethodPost, "/networks/mynetwork/disconnect"}: struct{}{},
{http.MethodPost, "/networks/16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4/disconnect"}: struct{}{},
{http.MethodDelete, "/networks/mynetwork"}: struct{}{},
{http.MethodDelete, "/networks/16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4"}: struct{}{},
{http.MethodPost, "/networks/create"}: network.CreateResponse{ID: "16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4"},
{http.MethodPost, "/networks/prune"}: struct{}{},
})
defer srv.Close()
transport := &Transport{
endpoint: &portainer.Endpoint{URL: srv.URL},
dataStore: ds,
HTTPTransport: &http.Transport{},
}
test := func(method string, url string, token portainer.TokenData) (*http.Response, error) {
req := httptest.NewRequest(method, srv.URL+"/v"+version+url, nil)
req = req.WithContext(security.StoreTokenData(req, &token))
require.NotNil(t, req)
return transport.proxyNetworkRequest(req, url)
}
adminToken := portainer.TokenData{ID: admin.ID, Username: admin.Username, Role: admin.Role}
std1Token := portainer.TokenData{ID: std1.ID, Username: std1.Username, Role: std1.Role}
std2Token := portainer.TokenData{ID: std2.ID, Username: std2.Username, Role: std2.Role}
{
r, err := test(http.MethodGet, "/networks", adminToken)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
var resp []network.Summary
require.NoError(t, json.NewDecoder(r.Body).Decode(&resp))
require.Equal(t, 1, len(resp))
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodGet, "/networks", std1Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
var resp []network.Summary
require.NoError(t, json.NewDecoder(r.Body).Decode(&resp))
require.Equal(t, 1, len(resp))
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodGet, "/networks", std2Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
var resp []network.Summary
require.NoError(t, json.NewDecoder(r.Body).Decode(&resp))
require.Equal(t, 0, len(resp))
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodGet, "/networks/mynetwork", adminToken)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodGet, "/networks/mynetwork", std1Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodGet, "/networks/mynetwork", std2Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusForbidden, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodGet, "/networks/unknown", adminToken)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusNotFound, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/networks/mynetwork/connect", adminToken)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/networks/mynetwork/connect", std1Token)
require.NoError(t, err)
require.NotNil(t, r)
require.NoError(t, r.Body.Close())
require.Equal(t, http.StatusOK, r.StatusCode)
}
{
r, err := test(http.MethodPost, "/networks/mynetwork/connect", std2Token)
require.NoError(t, err)
require.NotNil(t, r)
require.NoError(t, r.Body.Close())
require.Equal(t, http.StatusForbidden, r.StatusCode)
}
{
r, err := test(http.MethodPost, "/networks/mynetwork/disconnect", adminToken)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/networks/mynetwork/disconnect", std1Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/networks/mynetwork/disconnect", std2Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusForbidden, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodDelete, "/networks/mynetwork", adminToken)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodDelete, "/networks/mynetwork", std1Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodDelete, "/networks/mynetwork", std2Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusForbidden, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/networks/create", adminToken)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/networks/create", std1Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/networks/create", std2Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/networks/prune", adminToken)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/networks/prune", std1Token)
require.Error(t, err)
require.Nil(t, r)
if r != nil {
r.Body.Close()
}
}
{
r, err := test(http.MethodPost, "/networks/prune", std2Token)
require.Error(t, err)
require.Nil(t, r)
if r != nil {
r.Body.Close()
}
}
}
-49
View File
@@ -1,11 +1,9 @@
package docker
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"path"
@@ -16,7 +14,6 @@ import (
"github.com/portainer/portainer/api/internal/snapshot"
"github.com/docker/docker/client"
"github.com/segmentio/encoding/json"
)
const volumeObjectIdentifier = "ResourceID"
@@ -124,58 +121,12 @@ func selectorVolumeLabels(responseObject map[string]any) map[string]any {
return utils.GetJSONObject(responseObject, "Labels")
}
func CheckVolumeBodyRestrictions(request *http.Request) error {
defer request.Body.Close()
body, err := io.ReadAll(request.Body)
if err != nil {
return err
}
var volumeCreateBody struct {
DriverOpts map[string]string `json:"DriverOpts"`
}
if err := json.Unmarshal(body, &volumeCreateBody); err != nil {
return err
}
if volumeCreateBody.DriverOpts["type"] == "bind" {
return ErrBindMountsForbidden
}
request.Body = io.NopCloser(bytes.NewBuffer(body))
return nil
}
func (transport *Transport) decorateVolumeResourceCreationOperation(request *http.Request, resourceType portainer.ResourceControlType) (*http.Response, error) {
tokenData, err := security.RetrieveTokenData(request)
if err != nil {
return nil, err
}
isAdminOrEndpointAdmin, err := transport.isAdminOrEndpointAdmin(request)
if err != nil {
return nil, err
}
if !isAdminOrEndpointAdmin {
securitySettings, err := transport.fetchEndpointSecuritySettings()
if err != nil {
return nil, err
}
if !securitySettings.AllowBindMountsForRegularUsers {
if err := CheckVolumeBodyRestrictions(request); err != nil {
return &http.Response{
StatusCode: http.StatusForbidden,
Body: io.NopCloser(bytes.NewBufferString("Access denied: insufficient permissions to create volume with specified configuration")),
}, err
}
}
}
volumeID := request.Header.Get("X-Portainer-VolumeName")
if volumeID != "" {
@@ -1,226 +0,0 @@
package docker
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/security"
"github.com/docker/docker/api/types/volume"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/require"
)
const volumeCreationAPIVersion = "1.51"
type volumeCreationFixtures struct {
dockerSrv *httptest.Server
ds dataservices.DataStore
stdUser portainer.User
adminUser portainer.User
endpointID portainer.EndpointID
}
func newVolumeCreationFixtures(t *testing.T) *volumeCreationFixtures {
t.Helper()
dockerSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead && r.URL.Path == "/_ping" {
w.Header().Add("Api-Version", volumeCreationAPIVersion)
_, _ = w.Write([]byte{})
return
}
if r.Method == http.MethodPost {
data, err := json.Marshal(map[string]string{"Name": "test-volume"})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write(data)
return
}
http.NotFound(w, r)
}))
t.Cleanup(dockerSrv.Close)
_, store := datastore.MustNewTestStore(t, true, false)
f := &volumeCreationFixtures{
dockerSrv: dockerSrv,
ds: store,
stdUser: portainer.User{ID: 1, Username: "std", Role: portainer.StandardUserRole},
adminUser: portainer.User{ID: 2, Username: "admin", Role: portainer.AdministratorRole},
endpointID: portainer.EndpointID(1),
}
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
err := tx.User().Create(&f.stdUser)
require.NoError(t, err)
err = tx.User().Create(&f.adminUser)
require.NoError(t, err)
err = tx.Endpoint().Create(&portainer.Endpoint{ID: f.endpointID, Name: "test-env"})
require.NoError(t, err)
return nil
})
require.NoError(t, err)
return f
}
func (f *volumeCreationFixtures) setSecuritySettings(t *testing.T, settings portainer.EndpointSecuritySettings) {
t.Helper()
err := f.ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.Endpoint().UpdateEndpoint(f.endpointID, &portainer.Endpoint{
ID: f.endpointID,
Name: "test-env",
SecuritySettings: settings,
})
})
require.NoError(t, err)
}
func (f *volumeCreationFixtures) newTransport() *Transport {
return &Transport{
endpoint: &portainer.Endpoint{ID: f.endpointID},
dataStore: f.ds,
HTTPTransport: &http.Transport{},
}
}
func (f *volumeCreationFixtures) newRequest(t *testing.T, body volume.CreateOptions, user portainer.User) *http.Request {
t.Helper()
data, err := json.Marshal(body)
require.NoError(t, err)
req, err := http.NewRequestWithContext(
t.Context(),
http.MethodPost,
f.dockerSrv.URL+"/v"+volumeCreationAPIVersion+"/volumes/create",
bytes.NewReader(data),
)
require.NoError(t, err)
return req.WithContext(security.StoreTokenData(req, &portainer.TokenData{
ID: user.ID,
Username: user.Username,
Role: user.Role,
}))
}
func TestDecorateVolumeResourceCreationOperation_BindDriverOptForbidden(t *testing.T) {
t.Parallel()
f := newVolumeCreationFixtures(t)
f.setSecuritySettings(t, portainer.EndpointSecuritySettings{
AllowBindMountsForRegularUsers: false,
})
body := volume.CreateOptions{
Name: "evil-volume",
Driver: "local",
DriverOpts: map[string]string{
"type": "bind",
"device": "/etc",
"o": "bind",
},
}
resp, err := f.newTransport().decorateVolumeResourceCreationOperation(f.newRequest(t, body, f.stdUser), portainer.VolumeResourceControl)
require.ErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateVolumeResourceCreationOperation_BindDriverOptAllowedForAdmin(t *testing.T) {
t.Parallel()
f := newVolumeCreationFixtures(t)
f.setSecuritySettings(t, portainer.EndpointSecuritySettings{
AllowBindMountsForRegularUsers: false,
})
body := volume.CreateOptions{
Name: "admin-volume",
Driver: "local",
DriverOpts: map[string]string{
"type": "bind",
"device": "/etc",
},
}
resp, err := f.newTransport().decorateVolumeResourceCreationOperation(f.newRequest(t, body, f.adminUser), portainer.VolumeResourceControl)
require.NotErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateVolumeResourceCreationOperation_BindDriverOptAllowedWhenSettingPermissive(t *testing.T) {
t.Parallel()
f := newVolumeCreationFixtures(t)
f.setSecuritySettings(t, portainer.EndpointSecuritySettings{
AllowBindMountsForRegularUsers: true,
})
body := volume.CreateOptions{
Name: "allowed-volume",
Driver: "local",
DriverOpts: map[string]string{
"type": "bind",
"device": "/data",
},
}
resp, err := f.newTransport().decorateVolumeResourceCreationOperation(f.newRequest(t, body, f.stdUser), portainer.VolumeResourceControl)
require.NotErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateVolumeResourceCreationOperation_NonBindDriverOptNotForbidden(t *testing.T) {
t.Parallel()
f := newVolumeCreationFixtures(t)
f.setSecuritySettings(t, portainer.EndpointSecuritySettings{
AllowBindMountsForRegularUsers: false,
})
body := volume.CreateOptions{
Name: "normal-volume",
Driver: "local",
DriverOpts: map[string]string{
"type": "tmpfs",
},
}
resp, err := f.newTransport().decorateVolumeResourceCreationOperation(f.newRequest(t, body, f.stdUser), portainer.VolumeResourceControl)
require.NotErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
-5
View File
@@ -23,11 +23,6 @@ var allowedHeaders = map[string]struct{}{
"X-Portainer-Volumename": {},
"X-Registry-Auth": {},
"X-Stream-Protocol-Version": {},
// WebSocket headers those are required for kubectl exec/attach/port-forward operations
"Sec-Websocket-Key": {},
"Sec-Websocket-Version": {},
"Sec-Websocket-Protocol": {},
"Sec-Websocket-Extensions": {},
}
// newSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy
+1 -4
View File
@@ -63,10 +63,7 @@ type errorResponse struct {
// WriteAccessDeniedResponse will create a new access denied response
func WriteAccessDeniedResponse() (*http.Response, error) {
header := http.Header{}
header.Add("Content-Type", "application/json")
response := &http.Response{Header: header}
response := &http.Response{}
err := RewriteResponse(response, errorResponse{Message: "access denied to resource"}, http.StatusForbidden)
return response, err
@@ -1,18 +0,0 @@
package utils
import (
"net/http"
"testing"
"github.com/stretchr/testify/require"
)
func TestWriteAccessDeniedResponse(t *testing.T) {
r, err := WriteAccessDeniedResponse()
require.NoError(t, err)
defer r.Body.Close()
require.NotNil(t, r)
require.Equal(t, "application/json", r.Header.Get("content-type"))
require.Equal(t, http.StatusForbidden, r.StatusCode)
}
+21 -7
View File
@@ -2,7 +2,6 @@ package security
import (
"net/http"
"slices"
"strings"
"sync"
"time"
@@ -447,14 +446,26 @@ func (bouncer *RequestBouncer) apiKeyLookup(r *http.Request) (*portainer.TokenDa
return tokenData, nil
}
// extractBearerToken extracts the Bearer token from the Authorization header and returns the token.
// extractBearerToken extracts the Bearer token from the request header or query parameter and returns the token.
func extractBearerToken(r *http.Request) (string, bool) {
// Token might be set via the "token" query parameter.
// For example, in websocket requests
// For these cases, hide the token from the query
query := r.URL.Query()
token := query.Get("token")
if token != "" {
query.Del("token")
r.URL.RawQuery = query.Encode()
return token, true
}
tokens, ok := r.Header[jwtTokenHeader]
if !ok || len(tokens) == 0 {
return "", false
}
token := tokens[0]
token = tokens[0]
token = strings.TrimPrefix(token, "Bearer ")
return token, true
@@ -523,7 +534,7 @@ func MWSecureHeaders(next http.Handler, hsts, csp bool) http.Handler {
}
if csp {
w.Header().Set("Content-Security-Policy", "script-src 'self' cdn.matomo.cloud js.hsforms.net https://www.google.com/recaptcha/, https://www.gstatic.com/recaptcha/; object-src 'none'; frame-ancestors 'none'; frame-src https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/")
w.Header().Set("Content-Security-Policy", "script-src 'self' cdn.matomo.cloud js.hsforms.net; frame-ancestors 'none';")
}
w.Header().Set("X-Content-Type-Options", "nosniff")
@@ -544,9 +555,12 @@ func (bouncer *RequestBouncer) newRestrictedContextRequest(userID portainer.User
return nil, err
}
isTeamLeader := slices.ContainsFunc(memberships, func(m portainer.TeamMembership) bool {
return m.Role == portainer.TeamLeader
})
isTeamLeader := false
for _, membership := range memberships {
if membership.Role == portainer.TeamLeader {
isTeamLeader = true
}
}
return &RestrictedRequestContext{
IsAdmin: false,
+4 -9
View File
@@ -1,9 +1,6 @@
package errorlist
import (
"errors"
"strings"
)
import "errors"
// Combine a slice of errors into a single error
// to use this, generate errors by appending to errorList in a loop, then return combine(errorList)
@@ -12,12 +9,10 @@ func Combine(errorList []error) error {
return nil
}
var sb strings.Builder
sb.WriteString("Multiple errors occurred:")
errorMsg := "Multiple errors occurred:"
for _, err := range errorList {
sb.WriteString("\n")
sb.WriteString(err.Error())
errorMsg += "\n" + err.Error()
}
return errors.New(sb.String())
return errors.New(errorMsg)
}
+1 -1
View File
@@ -113,7 +113,7 @@ type datastoreOption = func(d *testDatastore)
// NewDatastore creates new instance of testDatastore.
// Will apply options before returning, opts will be applied from left to right.
func NewDatastore(options ...datastoreOption) *testDatastore {
conn, _ := database.NewDatabase("boltdb", "", nil, false)
conn, _ := database.NewDatabase("boltdb", "", nil)
d := testDatastore{connection: conn}
for _, o := range options {
+8 -20
View File
@@ -145,33 +145,21 @@ func (kcl *KubeClient) GetNonAdminNamespaces(userID int, teamIDs []int, isRestri
}
// GetIsKubeAdmin retrieves true if client is admin
func (kcl *KubeClient) GetIsKubeAdmin() bool {
kcl.mu.Lock()
defer kcl.mu.Unlock()
return kcl.isKubeAdmin
func (client *KubeClient) GetIsKubeAdmin() bool {
return client.IsKubeAdmin
}
// UpdateIsKubeAdmin sets whether the kube client is admin
func (kcl *KubeClient) SetIsKubeAdmin(isKubeAdmin bool) {
kcl.mu.Lock()
defer kcl.mu.Unlock()
kcl.isKubeAdmin = isKubeAdmin
func (client *KubeClient) SetIsKubeAdmin(isKubeAdmin bool) {
client.IsKubeAdmin = isKubeAdmin
}
// GetClientNonAdminNamespaces retrieves non-admin namespaces
func (kcl *KubeClient) GetClientNonAdminNamespaces() []string {
kcl.mu.Lock()
defer kcl.mu.Unlock()
return kcl.nonAdminNamespaces
func (client *KubeClient) GetClientNonAdminNamespaces() []string {
return client.NonAdminNamespaces
}
// UpdateClientNonAdminNamespaces sets the client non admin namespace list
func (kcl *KubeClient) SetClientNonAdminNamespaces(nonAdminNamespaces []string) {
kcl.mu.Lock()
defer kcl.mu.Unlock()
kcl.nonAdminNamespaces = nonAdminNamespaces
func (client *KubeClient) SetClientNonAdminNamespaces(nonAdminNamespaces []string) {
client.NonAdminNamespaces = nonAdminNamespaces
}
-26
View File
@@ -5,9 +5,7 @@ import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
ktypes "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
kfake "k8s.io/client-go/kubernetes/fake"
@@ -67,27 +65,3 @@ func Test_NamespaceAccessPoliciesDeleteNamespace_updatesPortainerConfig_whenConf
})
}
}
func TestKubeAdmin(t *testing.T) {
kcl := &KubeClient{}
require.False(t, kcl.GetIsKubeAdmin())
kcl.SetIsKubeAdmin(true)
require.True(t, kcl.GetIsKubeAdmin())
kcl.SetIsKubeAdmin(false)
require.False(t, kcl.GetIsKubeAdmin())
}
func TestClientNonAdminNamespaces(t *testing.T) {
kcl := &KubeClient{}
require.Empty(t, kcl.GetClientNonAdminNamespaces())
nss := []string{"ns1", "ns2"}
kcl.SetClientNonAdminNamespaces(nss)
require.Equal(t, nss, kcl.GetClientNonAdminNamespaces())
kcl.SetClientNonAdminNamespaces([]string{})
require.Empty(t, kcl.GetClientNonAdminNamespaces())
}
+7 -15
View File
@@ -28,7 +28,7 @@ type PortainerApplicationResources struct {
// if the user is an admin, all namespaces in the current k8s environment(endpoint) are fetched using the fetchApplications function.
// otherwise, namespaces the non-admin user has access to will be used to filter the applications based on the allowed namespaces.
func (kcl *KubeClient) GetApplications(namespace, nodeName string) ([]models.K8sApplication, error) {
if kcl.GetIsKubeAdmin() {
if kcl.IsKubeAdmin {
return kcl.fetchApplications(namespace, nodeName)
}
@@ -64,13 +64,9 @@ func (kcl *KubeClient) fetchApplications(namespace, nodeName string) ([]models.K
// fetchApplicationsForNonAdmin fetches the applications in the namespaces the user has access to.
// This function is called when the user is not an admin.
func (kcl *KubeClient) fetchApplicationsForNonAdmin(namespace, nodeName string) ([]models.K8sApplication, error) {
nonAdminNamespaces := kcl.GetClientNonAdminNamespaces()
log.Debug().Msgf("Fetching applications for non-admin user: %v", kcl.NonAdminNamespaces)
log.Debug().
Strs("non_admin_namespaces", nonAdminNamespaces).
Msg("fetching applications for non-admin user")
if len(nonAdminNamespaces) == 0 {
if len(kcl.NonAdminNamespaces) == 0 {
return nil, nil
}
@@ -273,8 +269,7 @@ func populateApplicationFromDeployment(application *models.K8sApplication, deplo
application.RunningPodsCount = int(deployment.Status.ReadyReplicas)
application.DeploymentType = "Replicated"
application.Metadata = &models.Metadata{
Labels: deployment.Labels,
Annotations: deployment.Annotations,
Labels: deployment.Labels,
}
// If the deployment has containers, use the first container's image
@@ -302,8 +297,7 @@ func populateApplicationFromStatefulSet(application *models.K8sApplication, stat
application.RunningPodsCount = int(statefulSet.Status.ReadyReplicas)
application.DeploymentType = "Replicated"
application.Metadata = &models.Metadata{
Labels: statefulSet.Labels,
Annotations: statefulSet.Annotations,
Labels: statefulSet.Labels,
}
// If the statefulSet has containers, use the first container's image
@@ -328,8 +322,7 @@ func populateApplicationFromDaemonSet(application *models.K8sApplication, daemon
application.RunningPodsCount = int(daemonSet.Status.NumberReady)
application.DeploymentType = "Global"
application.Metadata = &models.Metadata{
Labels: daemonSet.Labels,
Annotations: daemonSet.Annotations,
Labels: daemonSet.Labels,
}
if len(daemonSet.Spec.Template.Spec.Containers) > 0 {
@@ -358,8 +351,7 @@ func populateApplicationFromPod(application *models.K8sApplication, pod corev1.P
application.RunningPodsCount = runningPodsCount
application.DeploymentType = string(pod.Status.Phase)
application.Metadata = &models.Metadata{
Labels: pod.Labels,
Annotations: pod.Annotations,
Labels: pod.Labels,
}
// If the pod has containers, use the first container's image
+4 -4
View File
@@ -310,7 +310,7 @@ func TestGetApplications(t *testing.T) {
kubeClient := &KubeClient{
cli: fakeClient,
instanceID: "test-instance",
isKubeAdmin: true,
IsKubeAdmin: true,
}
// Test cases
@@ -385,8 +385,8 @@ func TestGetApplications(t *testing.T) {
kubeClient := &KubeClient{
cli: fakeClient,
instanceID: "test-instance",
isKubeAdmin: false,
nonAdminNamespaces: []string{namespace1},
IsKubeAdmin: false,
NonAdminNamespaces: []string{namespace1},
}
// Test that only resources from allowed namespace are returned
@@ -445,7 +445,7 @@ func TestGetApplications(t *testing.T) {
kubeClient := &KubeClient{
cli: fakeClient,
instanceID: "test-instance",
isKubeAdmin: true,
IsKubeAdmin: true,
}
// Test filtering by node name
+5 -7
View File
@@ -42,8 +42,8 @@ type (
cli kubernetes.Interface
instanceID string
mu sync.Mutex
isKubeAdmin bool
nonAdminNamespaces []string
IsKubeAdmin bool
NonAdminNamespaces []string
}
)
@@ -147,7 +147,6 @@ func (factory *ClientFactory) GetProxyKubeClient(endpointID, userID string) (*Ku
if ok {
return client.(*KubeClient), true
}
return nil, false
}
@@ -180,8 +179,8 @@ func (factory *ClientFactory) CreateKubeClientFromKubeConfig(clusterID string, k
return &KubeClient{
cli: cli,
instanceID: factory.instanceID,
isKubeAdmin: IsKubeAdmin,
nonAdminNamespaces: NonAdminNamespaces,
IsKubeAdmin: IsKubeAdmin,
NonAdminNamespaces: NonAdminNamespaces,
}, nil
}
@@ -194,7 +193,7 @@ func (factory *ClientFactory) createCachedPrivilegedKubeClient(endpoint *portain
return &KubeClient{
cli: cli,
instanceID: factory.instanceID,
isKubeAdmin: true,
IsKubeAdmin: true,
}, nil
}
@@ -372,7 +371,6 @@ func (factory *ClientFactory) MigrateEndpointIngresses(e *portainer.Endpoint, da
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 {
+1 -1
View File
@@ -16,7 +16,7 @@ import (
// GetClusterRoles gets all the clusterRoles for at the cluster level in a k8s endpoint.
// It returns a list of K8sClusterRole objects.
func (kcl *KubeClient) GetClusterRoles() ([]models.K8sClusterRole, error) {
if kcl.GetIsKubeAdmin() {
if kcl.IsKubeAdmin {
return kcl.fetchClusterRoles()
}
+1 -1
View File
@@ -16,7 +16,7 @@ import (
// GetClusterRoleBindings gets all the clusterRoleBindings for at the cluster level in a k8s endpoint.
// It returns a list of K8sClusterRoleBinding objects.
func (kcl *KubeClient) GetClusterRoleBindings() ([]models.K8sClusterRoleBinding, error) {
if kcl.GetIsKubeAdmin() {
if kcl.IsKubeAdmin {
return kcl.fetchClusterRoleBindings()
}

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