Compare commits

...

55 Commits

Author SHA1 Message Date
Chaim Lev-Ari
92eaa02156 fix(docker/networks): show correct resource control data [EE-3401] (#7060)
Some checks are pending
Test / test-client (push) Waiting to run
2022-06-17 19:21:41 +03:00
Chaim Lev-Ari
18252ab854 refactor(app): move react components to react codebase [EE-3179] (#6971) 2022-06-17 19:18:42 +03:00
itsconquest
212400c283 fix(auth): clear skips when using new instance [EE-3331] (#7027) 2022-06-17 14:45:47 +12:00
LP B
8ed41de815 fix(app/account): create access token button (#7014)
* fix(api): remove unused leftover imports

* fix(app/account): create access token button

* fix(app/formcontrol): error message overlapping input on smaller screens
2022-06-16 21:25:37 +02:00
Chaim Lev-Ari
97a880e6c1 feat(custom-templates): hide variables [EE-2602] (#7068) 2022-06-16 08:32:41 +03:00
itsconquest
f39775752d feat(auth): allow single char passwords [EE-3385] (#7050)
* feat(auth): allow single character passwords

* match weak password modal logic to slider
2022-06-16 12:31:36 +12:00
Matt Hook
6d6c70a98b fix(swarm): don't stomp on the x-registry-auth header EE-3308 (#7080)
* don't stomp on the x-registry-auth header

* del header if empty json provided for registry auth
2022-06-16 09:53:58 +12:00
Dmitry Salakhov
461fc91446 fix: clarify password change error (#7082) 2022-06-15 16:56:59 +12:00
itsconquest
8059cae8e7 fix(auth): notify user password requirements [EE-3344] (#7042)
* fix(auth): notify user password requirements [EE-3344]

* fix angular code
2022-06-15 16:01:19 +12:00
congs
41107191c3 fix(teamleader): EE-3411 normal users get an unauthorized error (#7052) 2022-06-14 14:12:25 +12:00
sunportainer
cb6a5fa41d fix(typo):UI and logs EE-3282 (#7063)
* fix logs and UI typos
2022-06-13 14:53:51 +08:00
Ali
66799a53f4 fix(wizard): return back to envs page EE-3419 (#7065) 2022-06-13 14:59:41 +12:00
congs
892fdbf60d fix(teamleader): EE-3383 allow teamleader promote member to teamleader (#7040) 2022-06-10 17:13:33 +12:00
Chao Geng
b6309682ef feat(kubeconfig): pagination for downloading kubeconfigs EE-2141 (#6895)
* EE-2141 Add pagination to kubeconfig download dialog
2022-06-10 11:42:27 +08:00
Ali
be11dfc231 fix(wizard): show teasers for kaas and kubeconfig features [EE-3316] (#7008)
* fix(wizard): add kubeconfig, nomad and kaas teasers
2022-06-10 09:17:13 +12:00
congs
12527aa820 fix(teamleader): EE-3332 hide name and leaders (#7031) 2022-06-09 14:22:35 +12:00
Matt Hook
0d0f9499eb chore(version): fix readme version (#7028) 2022-06-08 23:20:58 +12:00
Ali
60eab3e263 fix(wizard): use 'New Environments' title EE-3329 (#7034) 2022-06-08 16:35:58 +12:00
Chao Geng
eb547162e9 fix(image) add validation of image name in build image page [EE-3010] (#6988)
* EE-3010 add validation of image name
2022-06-07 16:42:09 +08:00
Matt Hook
0864c371e8 chore(version): bump develop branch version to 2.15 (#7019)
* bump version to 2.15
2022-06-07 11:00:36 +12:00
Chaim Lev-Ari
b90b1701e9 fix(users): remove unused imports [EE-3340] (#7016)
fixes [EE-3340]
2022-06-06 10:04:33 +03:00
Ali
eb4ff12744 feat(wizard): replace-the-add-envs-button-with-env-wizard-button EE-3001 (#7013)
* feat(envs): on env click, direct user to wizard
2022-06-03 22:33:17 +12:00
congs
0522032515 feat(teamleader) EE-294 redesign team leader (#6973)
feat(teamleader) EE-294 redesign team leader (#6973)
2022-06-03 16:44:42 +12:00
itsconquest
bca1c6b9cf feat(internal-auth): ability to set minimum password length [EE-3175] (#6942)
* feat(internal-auth): ability to set minimum password length [EE-3175]

* pass props to react component

* fixes + WIP slider

* fix slider updating + add styles

* remove nested ternary

* fix slider updating + add remind me later button

* add length to settings + value & onchange method

* finish my account view

* fix slider updating

* slider styles

* update style

* move slider in

* update size of slider

* allow admin to browse to authentication view

* use feather icons instead of font awesome

* feat(settings): add colors to password rules

* clean up tooltip styles

* more style changes

* styles

* fixes + use requiredLength in password field for icon logic

* simplify logic

* simplify slider logic and remove debug code

* use required length for logic to display pwd length warning

* fix slider styles

* use requiredPasswordLength to determine if password is valid

* style tooltip based on theme

* reset skips when password is changed

* misc cleanup

* reset skips when required length is changed

* fix formatting

* fix issues

* implement some suggestions

* simplify logic

* update broken test

* pick min password length from DB

* fix suggestions

* set up min password length in the DB

* fix test after migration

* fix formatting issue

* fix bug with icon

* refactored migration

* fix typo

* fixes

* fix logic

* set skips per user

* reset skips for all users on length change

Co-authored-by: Chaim Lev-Ari <chiptus@gmail.com>
Co-authored-by: Dmitry Salakhov <to@dimasalakhov.com>
2022-06-03 16:00:13 +12:00
Matt Hook
4195d93a16 fix typo 2022-06-03 14:21:55 +12:00
Matt Hook
e8a8b71daa feat(compose): upgrade to docker compose v2 EE-2096 (#6994)
Upgrade to compose v2 + new helm + new kubectl
2022-06-03 13:50:37 +12:00
Ali
aea62723c0 fix(forms): increase-click-area-for-expandable-form-section EE-3314 (#7007)
* fix(forms): increase click area for form section
2022-06-03 13:29:23 +12:00
Prabhat Khera
9b58c2e466 rename output_35 to output_24_to_latest (#7006) 2022-06-02 11:30:42 +12:00
Prabhat Khera
c41f7f8270 chore(version): version bump to 2.14.0 (#6958) 2022-06-02 10:53:48 +12:00
Chaim Lev-Ari
ac096dda46 feat(wizard): add edge form [EE-3000] (#6979) 2022-06-01 07:28:31 +03:00
Chaim Lev-Ari
e686d64011 refactor(docker): strongly type snapshot [EE-3256] (#6990)
* refactor(docker): strongly type snapshot [EE-3256]

fixes [EE-3256]

* fix(endpoints): return empty from association api

* refactor(docker): ignore raw snapshot for swagger
2022-05-31 13:03:10 +03:00
Chaim Lev-Ari
1ccdb64938 refactor(custom-templates): render template variables [EE-2602] (#6937) 2022-05-31 13:00:47 +03:00
Prabhat Khera
71c0e8e661 fix(kubernetes): fix redeploying kubernetes app EE-2875 (#6984) 2022-05-31 10:12:37 +12:00
andres-portainer
c162e180e0 fix(endpoints): remove global map to avoid panic writes EE-3160 (#6918) 2022-05-30 11:22:37 -03:00
Ali
e806f74652 refactor(tailwind): add-consistent-theme-colors-to-tailwind EE-3255 (#6989)
* refactor(tailwind): add custom colors EE-3255
2022-05-30 14:01:05 +12:00
Chaim Lev-Ari
d52417c14f refactor(app): convert tag-selector to react [EE-2983] (#6783) 2022-05-29 09:14:14 +03:00
Chaim Lev-Ari
75d854e6ad Revert "refactor(docker): strongly type snapshot [EE-3256]"
This reverts commit 0b2217a916.
2022-05-26 15:39:55 +03:00
Chaim Lev-Ari
0b2217a916 refactor(docker): strongly type snapshot [EE-3256]
fixes [EE-3256]
2022-05-26 15:34:34 +03:00
Chao Geng
ca30efeca7 EE-1892 Centralize prompt dialog (#6903) 2022-05-24 20:14:38 +08:00
Chaim Lev-Ari
dc98850489 feat(app): enforce using of props in r2a [EE-3215] (#6943) 2022-05-24 08:35:20 +03:00
Chaim Lev-Ari
01dc9066b7 refactor(wizard): migrate to react [EE-2305] (#6957) 2022-05-23 17:32:51 +03:00
Chao Geng
3aacaa7caf feat(dashboard) remove environment url from dashboard EE-2849 (#6955)
* EE-2849 remove environment url from dashboard

* EE-2849 only remove edge env's url

* EE-2849 remove logging
2022-05-23 17:05:37 +08:00
Chaim Lev-Ari
b031a30f62 feat(edge-devices): set specific page to view [EE-2082] (#6869) 2022-05-23 10:57:22 +03:00
Chaim Lev-Ari
12cddbd896 feat(demo): disable features on demo env [EE-1874] (#6040) 2022-05-22 08:34:09 +03:00
Chao Geng
3791b7a16f fix(kube): misspelling kube namespace (#6951) 2022-05-20 07:34:30 +08:00
matias-portainer
d754532ab1 chore(edgestacks): add unit tests for edge stacks (#6931)
chore(edgestacks): add unit tests for edge stacks EE-3172
2022-05-19 17:13:51 -03:00
Chao Geng
9a48ceaec1 fix(docker): Restrict registry edit options for different registry type EE-2705 (#6708)
* EE-2705 restrict registry edit options for different registry type

* EE-2705 quay and azure registry should not disable authentication

* EE-2705 Resolve conflict
2022-05-18 18:46:24 +08:00
Chaim Lev-Ari
1132c9ce87 refactor(app): create empty react structure [EE-3178] (#6926) 2022-05-17 07:22:44 +03:00
itsconquest
668d526604 fix(networks): handle windows specific system networks [EE-2594] (#6922) 2022-05-17 14:45:30 +12:00
Chaim Lev-Ari
0e257c200f chore(app): use base font-size of 16px [EE-3186] (#6938) 2022-05-16 10:24:13 +03:00
congs
df05914fac fix(git) EE-2026 git default branch (#6876)
fix(git) EE-2026 git default branch
2022-05-16 09:35:11 +12:00
Chaim Lev-Ari
0ffb84aaa6 refactor(app): add rq mutation helpers [EE-3176] (#6923) 2022-05-15 10:01:08 +03:00
Chaim Lev-Ari
b01180bb29 chore(deps): remove lodash-es dependency [EE-2560] (#6576) 2022-05-12 08:44:53 +03:00
cong meng
16f8b737f1 fix(pwd) EE-3161 ease the minimum password restrictions to 12 characters (#6921)
* fix(pwd): EE-3161 ease the minimum password restrictions to 12 characters
2022-05-12 13:17:01 +12:00
itsconquest
d9d1d6bfaa feat(extension): add a readme [EE-3085] (#6888)
* feat(extension): add a readme [EE-3085]

* add prerequisites
2022-05-11 11:58:11 +12:00
900 changed files with 11953 additions and 6459 deletions

View File

@@ -31,7 +31,12 @@ rules:
[
'error',
{
pathGroups: [{ pattern: '@/**', group: 'internal' }, { pattern: '{Kubernetes,Portainer,Agent,Azure,Docker}/**', group: 'internal' }],
pathGroups:
[
{ pattern: '@@/**', group: 'internal', position: 'after' },
{ pattern: '@/**', group: 'internal' },
{ pattern: '{Kubernetes,Portainer,Agent,Azure,Docker}/**', group: 'internal' },
],
groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
pathGroupsExcludedImportTypes: ['internal'],
},
@@ -41,6 +46,7 @@ settings:
'import/resolver':
alias:
map:
- ['@@', './app/react/components']
- ['@', './app']
extensions: ['.js', '.ts', '.tsx']
@@ -68,7 +74,14 @@ overrides:
version: 'detect'
rules:
import/order:
['error', { pathGroups: [{ pattern: '@/**', group: 'internal' }], groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'], 'newlines-between': 'always' }]
[
'error',
{
pathGroups: [{ pattern: '@@/**', group: 'internal', position: 'after' }, { pattern: '@/**', group: 'internal' }],
groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
'newlines-between': 'always',
},
]
func-style: [error, 'declaration']
import/prefer-default-export: off
no-use-before-define: ['error', { functions: false }]

View File

@@ -3,6 +3,7 @@ import '../app/assets/css';
import { pushStateLocationPlugin, UIRouter } from '@uirouter/react';
import { initialize as initMSW, mswDecorator } from 'msw-storybook-addon';
import { handlers } from '@/setup-tests/server-handlers';
import { QueryClient, QueryClientProvider } from 'react-query';
// Initialize MSW
initMSW({
@@ -31,11 +32,17 @@ export const parameters = {
},
};
const testQueryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
export const decorators = [
(Story) => (
<UIRouter plugins={[pushStateLocationPlugin]}>
<Story />
</UIRouter>
<QueryClientProvider client={testQueryClient}>
<UIRouter plugins={[pushStateLocationPlugin]}>
<Story />
</UIRouter>
</QueryClientProvider>
),
mswDecorator,
];

View File

@@ -22,7 +22,7 @@ Please note that the public demo cluster is **reset every 15min**.
Portainer CE is updated regularly. We aim to do an update release every couple of months.
**The latest version of Portainer is 2.9.x**. Portainer is on version 2, the second number denotes the month of release.
**The latest version of Portainer is 2.13.x**.
## Getting started

View File

@@ -35,6 +35,7 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
TunnelPort: kingpin.Flag("tunnel-port", "Port to serve the tunnel server").Default(defaultTunnelServerPort).String(),
Assets: kingpin.Flag("assets", "Path to the assets").Default(defaultAssetsDirectory).Short('a').String(),
Data: kingpin.Flag("data", "Path to the folder where the data is stored").Default(defaultDataDirectory).Short('d').String(),
DemoEnvironment: kingpin.Flag("demo", "Demo environment").Bool(),
EndpointURL: kingpin.Flag("host", "Environment URL").Short('H').String(),
FeatureFlags: BoolPairs(kingpin.Flag("feat", "List of feature flags").Hidden()),
EnableEdgeComputeFeatures: kingpin.Flag("edge-compute", "Enable Edge Compute features").Bool(),

View File

@@ -23,6 +23,7 @@ import (
"github.com/portainer/portainer/api/database/boltdb"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/demo"
"github.com/portainer/portainer/api/docker"
"github.com/portainer/portainer/api/exec"
"github.com/portainer/portainer/api/filesystem"
@@ -572,6 +573,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
openAMTService := openamt.NewService()
cryptoService := initCryptoService()
digitalSignatureService := initDigitalSignatureService()
sslService, err := initSSLService(*flags.AddrHTTPS, *flags.SSLCert, *flags.SSLKey, fileService, dataStore, shutdownTrigger)
@@ -607,7 +609,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
kubeClusterAccessService := kubernetes.NewKubeClusterAccessService(*flags.BaseURL, *flags.AddrHTTPS, sslSettings.CertPath)
proxyManager := proxy.NewManager(dataStore, digitalSignatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager)
proxyManager := proxy.NewManager(dataStore, digitalSignatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService)
reverseTunnelService.ProxyManager = proxyManager
@@ -634,6 +636,14 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
applicationStatus := initStatus(instanceID)
demoService := demo.NewService()
if *flags.DemoEnvironment {
err := demoService.Init(dataStore, cryptoService)
if err != nil {
log.Fatalf("failed initializing demo environment: %v", err)
}
}
err = initEndpoint(flags, dataStore, snapshotService)
if err != nil {
logrus.Fatalf("Failed initializing environment: %v", err)
@@ -722,6 +732,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
ShutdownCtx: shutdownCtx,
ShutdownTrigger: shutdownTrigger,
StackDeployer: stackDeployer,
DemoService: demoService,
}
}

View File

@@ -10,7 +10,7 @@ import (
)
const (
jsonobject = `{"LogoURL":"","BlackListedLabels":[],"AuthenticationMethod":1,"LDAPSettings":{"AnonymousMode":true,"ReaderDN":"","URL":"","TLSConfig":{"TLS":false,"TLSSkipVerify":false},"StartTLS":false,"SearchSettings":[{"BaseDN":"","Filter":"","UserNameAttribute":""}],"GroupSearchSettings":[{"GroupBaseDN":"","GroupFilter":"","GroupAttribute":""}],"AutoCreateUsers":true},"OAuthSettings":{"ClientID":"","AccessTokenURI":"","AuthorizationURI":"","ResourceURI":"","RedirectURI":"","UserIdentifier":"","Scopes":"","OAuthAutoCreateUsers":false,"DefaultTeamID":0,"SSO":true,"LogoutURI":"","KubeSecretKey":"j0zLVtY/lAWBk62ByyF0uP80SOXaitsABP0TTJX8MhI="},"OpenAMTConfiguration":{"Enabled":false,"MPSServer":"","MPSUser":"","MPSPassword":"","MPSToken":"","CertFileContent":"","CertFileName":"","CertFilePassword":"","DomainName":""},"FeatureFlagSettings":{},"SnapshotInterval":"5m","TemplatesURL":"https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json","EdgeAgentCheckinInterval":5,"EnableEdgeComputeFeatures":false,"UserSessionTimeout":"8h","KubeconfigExpiry":"0","EnableTelemetry":true,"HelmRepositoryURL":"https://charts.bitnami.com/bitnami","KubectlShellImage":"portainer/kubectl-shell","DisplayDonationHeader":false,"DisplayExternalContributors":false,"EnableHostManagementFeatures":false,"AllowVolumeBrowserForRegularUsers":false,"AllowBindMountsForRegularUsers":false,"AllowPrivilegedModeForRegularUsers":false,"AllowHostNamespaceForRegularUsers":false,"AllowStackManagementForRegularUsers":false,"AllowDeviceMappingForRegularUsers":false,"AllowContainerCapabilitiesForRegularUsers":false}`
jsonobject = `{"LogoURL":"","BlackListedLabels":[],"AuthenticationMethod":1,"InternalAuthSettings": {"RequiredPasswordLength": 12}"LDAPSettings":{"AnonymousMode":true,"ReaderDN":"","URL":"","TLSConfig":{"TLS":false,"TLSSkipVerify":false},"StartTLS":false,"SearchSettings":[{"BaseDN":"","Filter":"","UserNameAttribute":""}],"GroupSearchSettings":[{"GroupBaseDN":"","GroupFilter":"","GroupAttribute":""}],"AutoCreateUsers":true},"OAuthSettings":{"ClientID":"","AccessTokenURI":"","AuthorizationURI":"","ResourceURI":"","RedirectURI":"","UserIdentifier":"","Scopes":"","OAuthAutoCreateUsers":false,"DefaultTeamID":0,"SSO":true,"LogoutURI":"","KubeSecretKey":"j0zLVtY/lAWBk62ByyF0uP80SOXaitsABP0TTJX8MhI="},"OpenAMTConfiguration":{"Enabled":false,"MPSServer":"","MPSUser":"","MPSPassword":"","MPSToken":"","CertFileContent":"","CertFileName":"","CertFilePassword":"","DomainName":""},"FeatureFlagSettings":{},"SnapshotInterval":"5m","TemplatesURL":"https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json","EdgeAgentCheckinInterval":5,"EnableEdgeComputeFeatures":false,"UserSessionTimeout":"8h","KubeconfigExpiry":"0","EnableTelemetry":true,"HelmRepositoryURL":"https://charts.bitnami.com/bitnami","KubectlShellImage":"portainer/kubectl-shell","DisplayDonationHeader":false,"DisplayExternalContributors":false,"EnableHostManagementFeatures":false,"AllowVolumeBrowserForRegularUsers":false,"AllowBindMountsForRegularUsers":false,"AllowPrivilegedModeForRegularUsers":false,"AllowHostNamespaceForRegularUsers":false,"AllowStackManagementForRegularUsers":false,"AllowDeviceMappingForRegularUsers":false,"AllowContainerCapabilitiesForRegularUsers":false}`
passphrase = "my secret key"
)

View File

@@ -47,6 +47,9 @@ func (store *Store) checkOrCreateDefaultSettings() error {
EnableTelemetry: true,
AuthenticationMethod: portainer.AuthenticationInternal,
BlackListedLabels: make([]portainer.Pair, 0),
InternalAuthSettings: portainer.InternalAuthSettings{
RequiredPasswordLength: 12,
},
LDAPSettings: portainer.LDAPSettings{
AnonymousMode: true,
AutoCreateUsers: true,

View File

@@ -34,9 +34,9 @@ func TestMigrateData(t *testing.T) {
wantPath string
}{
{
testName: "migrate version 24 to 35",
testName: "migrate version 24 to latest",
srcPath: "test_data/input_24.json",
wantPath: "test_data/output_35.json",
wantPath: "test_data/output_24_to_latest.json",
},
}
for _, test := range snapshotTests {

View File

@@ -100,6 +100,9 @@ func (m *Migrator) Migrate() error {
// Portainer 2.13
newMigration(40, m.migrateDBVersionToDB40),
// Portainer 2.14
newMigration(50, m.migrateDBVersionToDB50),
}
var lastDbVersion int

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"log"
"github.com/docker/docker/api/types/volume"
"github.com/portainer/portainer/api/dataservices/errors"
portainer "github.com/portainer/portainer/api"
@@ -210,14 +211,14 @@ func (m *Migrator) updateVolumeResourceControlToDB32() error {
continue
}
if volumesData, done := snapshot.SnapshotRaw.Volumes.(map[string]interface{}); done {
if volumesData["Volumes"] == nil {
log.Println("[DEBUG] [volume migration] [message: no volume data found]")
continue
}
findResourcesToUpdateForDB32(endpointDockerID, volumesData, toUpdate, volumeResourceControls)
volumesData := snapshot.SnapshotRaw.Volumes
if volumesData.Volumes == nil {
log.Println("[DEBUG] [volume migration] [message: no volume data found]")
continue
}
findResourcesToUpdateForDB32(endpointDockerID, volumesData, toUpdate, volumeResourceControls)
}
for _, resourceControl := range volumeResourceControls {
@@ -240,18 +241,11 @@ func (m *Migrator) updateVolumeResourceControlToDB32() error {
return nil
}
func findResourcesToUpdateForDB32(dockerID string, volumesData map[string]interface{}, toUpdate map[portainer.ResourceControlID]string, volumeResourceControls map[string]*portainer.ResourceControl) {
volumes := volumesData["Volumes"].([]interface{})
for _, volumeMeta := range volumes {
volume := volumeMeta.(map[string]interface{})
volumeName, nameExist := volume["Name"].(string)
if !nameExist {
continue
}
createTime, createTimeExist := volume["CreatedAt"].(string)
if !createTimeExist {
continue
}
func findResourcesToUpdateForDB32(dockerID string, volumesData volume.VolumeListOKBody, toUpdate map[portainer.ResourceControlID]string, volumeResourceControls map[string]*portainer.ResourceControl) {
volumes := volumesData.Volumes
for _, volume := range volumes {
volumeName := volume.Name
createTime := volume.CreatedAt
oldResourceID := fmt.Sprintf("%s%s", volumeName, createTime)
resourceControl, ok := volumeResourceControls[oldResourceID]

View File

@@ -0,0 +1,20 @@
package migrator
import (
"github.com/pkg/errors"
)
func (m *Migrator) migrateDBVersionToDB50() error {
return m.migratePasswordLengthSettings()
}
func (m *Migrator) migratePasswordLengthSettings() error {
migrateLog.Info("Updating required password length")
s, err := m.settingsService.Settings()
if err != nil {
return errors.Wrap(err, "unable to retrieve settings")
}
s.InternalAuthSettings.RequiredPasswordLength = 12
return m.settingsService.UpdateSettings(s)
}

View File

@@ -70,10 +70,103 @@
"DockerSnapshotRaw": {
"Containers": null,
"Images": null,
"Info": null,
"Info": {
"Architecture": "",
"BridgeNfIp6tables": false,
"BridgeNfIptables": false,
"CPUSet": false,
"CPUShares": false,
"CgroupDriver": "",
"ContainerdCommit": {
"Expected": "",
"ID": ""
},
"Containers": 0,
"ContainersPaused": 0,
"ContainersRunning": 0,
"ContainersStopped": 0,
"CpuCfsPeriod": false,
"CpuCfsQuota": false,
"Debug": false,
"DefaultRuntime": "",
"DockerRootDir": "",
"Driver": "",
"DriverStatus": null,
"ExperimentalBuild": false,
"GenericResources": null,
"HttpProxy": "",
"HttpsProxy": "",
"ID": "",
"IPv4Forwarding": false,
"Images": 0,
"IndexServerAddress": "",
"InitBinary": "",
"InitCommit": {
"Expected": "",
"ID": ""
},
"Isolation": "",
"KernelMemory": false,
"KernelMemoryTCP": false,
"KernelVersion": "",
"Labels": null,
"LiveRestoreEnabled": false,
"LoggingDriver": "",
"MemTotal": 0,
"MemoryLimit": false,
"NCPU": 0,
"NEventsListener": 0,
"NFd": 0,
"NGoroutines": 0,
"Name": "",
"NoProxy": "",
"OSType": "",
"OSVersion": "",
"OomKillDisable": false,
"OperatingSystem": "",
"PidsLimit": false,
"Plugins": {
"Authorization": null,
"Log": null,
"Network": null,
"Volume": null
},
"RegistryConfig": null,
"RuncCommit": {
"Expected": "",
"ID": ""
},
"Runtimes": null,
"SecurityOptions": null,
"ServerVersion": "",
"SwapLimit": false,
"Swarm": {
"ControlAvailable": false,
"Error": "",
"LocalNodeState": "",
"NodeAddr": "",
"NodeID": "",
"RemoteManagers": null
},
"SystemTime": "",
"Warnings": null
},
"Networks": null,
"Version": null,
"Volumes": null
"Version": {
"ApiVersion": "",
"Arch": "",
"GitCommit": "",
"GoVersion": "",
"Os": "",
"Platform": {
"Name": ""
},
"Version": ""
},
"Volumes": {
"Volumes": null,
"Warnings": null
}
},
"DockerVersion": "20.10.13",
"HealthyContainerCount": 0,
@@ -597,6 +690,9 @@
"EnforceEdgeID": false,
"FeatureFlagSettings": null,
"HelmRepositoryURL": "https://charts.bitnami.com/bitnami",
"InternalAuthSettings": {
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
"KubectlShellImage": "portainer/kubectl-shell",
"LDAPSettings": {
@@ -802,7 +898,7 @@
],
"version": {
"DB_UPDATING": "false",
"DB_VERSION": "35",
"DB_VERSION": "60",
"INSTANCE_ID": "null"
}
}

118
api/demo/demo.go Normal file
View File

@@ -0,0 +1,118 @@
package demo
import (
"log"
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
)
type EnvironmentDetails struct {
Enabled bool `json:"enabled"`
Users []portainer.UserID `json:"users"`
Environments []portainer.EndpointID `json:"environments"`
}
type Service struct {
details EnvironmentDetails
}
func NewService() *Service {
return &Service{}
}
func (service *Service) Details() EnvironmentDetails {
return service.details
}
func (service *Service) Init(store dataservices.DataStore, cryptoService portainer.CryptoService) error {
log.Print("[INFO] [main] Starting demo environment")
isClean, err := isCleanStore(store)
if err != nil {
return errors.WithMessage(err, "failed checking if store is clean")
}
if !isClean {
return errors.New(" Demo environment can only be initialized on a clean database")
}
id, err := initDemoUser(store, cryptoService)
if err != nil {
return errors.WithMessage(err, "failed creating demo user")
}
endpointIds, err := initDemoEndpoints(store)
if err != nil {
return errors.WithMessage(err, "failed creating demo endpoint")
}
err = initDemoSettings(store)
if err != nil {
return errors.WithMessage(err, "failed updating demo settings")
}
service.details = EnvironmentDetails{
Enabled: true,
Users: []portainer.UserID{id},
// endpoints 2,3 are created after deployment of portainer
Environments: endpointIds,
}
return nil
}
func isCleanStore(store dataservices.DataStore) (bool, error) {
endpoints, err := store.Endpoint().Endpoints()
if err != nil {
return false, err
}
if len(endpoints) > 0 {
return false, nil
}
users, err := store.User().Users()
if err != nil {
return false, err
}
if len(users) > 0 {
return false, nil
}
return true, nil
}
func (service *Service) IsDemo() bool {
return service.details.Enabled
}
func (service *Service) IsDemoEnvironment(environmentID portainer.EndpointID) bool {
if !service.IsDemo() {
return false
}
for _, demoEndpointID := range service.details.Environments {
if environmentID == demoEndpointID {
return true
}
}
return false
}
func (service *Service) IsDemoUser(userID portainer.UserID) bool {
if !service.IsDemo() {
return false
}
for _, demoUserID := range service.details.Users {
if userID == demoUserID {
return true
}
}
return false
}

79
api/demo/init.go Normal file
View File

@@ -0,0 +1,79 @@
package demo
import (
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
)
func initDemoUser(
store dataservices.DataStore,
cryptoService portainer.CryptoService,
) (portainer.UserID, error) {
password, err := cryptoService.Hash("tryportainer")
if err != nil {
return 0, errors.WithMessage(err, "failed creating password hash")
}
admin := &portainer.User{
Username: "admin",
Password: password,
Role: portainer.AdministratorRole,
}
err = store.User().Create(admin)
return admin.ID, errors.WithMessage(err, "failed creating user")
}
func initDemoEndpoints(store dataservices.DataStore) ([]portainer.EndpointID, error) {
localEndpointId, err := initDemoLocalEndpoint(store)
if err != nil {
return nil, err
}
// second and third endpoints are going to be created with docker-compose as a part of the demo environment set up.
// ref: https://github.com/portainer/portainer-demo/blob/master/docker-compose.yml
return []portainer.EndpointID{localEndpointId, localEndpointId + 1, localEndpointId + 2}, nil
}
func initDemoLocalEndpoint(store dataservices.DataStore) (portainer.EndpointID, error) {
id := portainer.EndpointID(store.Endpoint().GetNextIdentifier())
localEndpoint := &portainer.Endpoint{
ID: id,
Name: "local",
URL: "unix:///var/run/docker.sock",
PublicURL: "demo.portainer.io",
Type: portainer.DockerEnvironment,
GroupID: portainer.EndpointGroupID(1),
TLSConfig: portainer.TLSConfiguration{
TLS: false,
},
AuthorizedUsers: []portainer.UserID{},
AuthorizedTeams: []portainer.TeamID{},
UserAccessPolicies: portainer.UserAccessPolicies{},
TeamAccessPolicies: portainer.TeamAccessPolicies{},
TagIDs: []portainer.TagID{},
Status: portainer.EndpointStatusUp,
Snapshots: []portainer.DockerSnapshot{},
Kubernetes: portainer.KubernetesDefault(),
}
err := store.Endpoint().Create(localEndpoint)
return id, errors.WithMessage(err, "failed creating local endpoint")
}
func initDemoSettings(
store dataservices.DataStore,
) error {
settings, err := store.Settings().Settings()
if err != nil {
return errors.WithMessage(err, "failed fetching settings")
}
settings.EnableTelemetry = false
settings.LogoURL = ""
err = store.Settings().UpdateSettings(settings)
return errors.WithMessage(err, "failed updating settings")
}

View File

@@ -11,6 +11,7 @@ import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/internal/testhelpers"
)
const composeFile = `version: "3.9"
@@ -41,6 +42,8 @@ func setup(t *testing.T) (*portainer.Stack, *portainer.Endpoint) {
func Test_UpAndDown(t *testing.T) {
testhelpers.IntegrationTest(t)
stack, endpoint := setup(t)
w, err := NewComposeStackManager("", "", nil)

View File

@@ -108,12 +108,12 @@ func (a *azureDownloader) latestCommitID(ctx context.Context, options fetchOptio
return "", errors.WithMessage(err, "failed to parse url")
}
refsUrl, err := a.buildRefsUrl(config, options.referenceName)
rootItemUrl, err := a.buildRootItemUrl(config, options.referenceName)
if err != nil {
return "", errors.WithMessage(err, "failed to build azure refs url")
return "", errors.WithMessage(err, "failed to build azure root item url")
}
req, err := http.NewRequestWithContext(ctx, "GET", refsUrl, nil)
req, err := http.NewRequestWithContext(ctx, "GET", rootItemUrl, nil)
if options.username != "" || options.password != "" {
req.SetBasicAuth(options.username, options.password)
} else if config.username != "" || config.password != "" {
@@ -131,26 +131,24 @@ func (a *azureDownloader) latestCommitID(ctx context.Context, options fetchOptio
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("failed to get repository refs with a status \"%v\"", resp.Status)
return "", fmt.Errorf("failed to get repository root item with a status \"%v\"", resp.Status)
}
var refs struct {
var items struct {
Value []struct {
Name string `json:"name"`
ObjectId string `json:"objectId"`
}
}
if err := json.NewDecoder(resp.Body).Decode(&refs); err != nil {
return "", errors.Wrap(err, "could not parse Azure Refs response")
}
for _, ref := range refs.Value {
if strings.EqualFold(ref.Name, options.referenceName) {
return ref.ObjectId, nil
CommitId string `json:"commitId"`
}
}
return "", errors.Errorf("could not find ref %q in the repository", options.referenceName)
if err := json.NewDecoder(resp.Body).Decode(&items); err != nil {
return "", errors.Wrap(err, "could not parse Azure items response")
}
if len(items.Value) == 0 || items.Value[0].CommitId == "" {
return "", errors.Errorf("failed to get latest commitID in the repository")
}
return items.Value[0].CommitId, nil
}
func parseUrl(rawUrl string) (*azureOptions, error) {
@@ -236,8 +234,10 @@ func (a *azureDownloader) buildDownloadUrl(config *azureOptions, referenceName s
// scopePath=/&download=true&versionDescriptor.version=main&$format=zip&recursionLevel=full&api-version=6.0
q.Set("scopePath", "/")
q.Set("download", "true")
q.Set("versionDescriptor.versionType", getVersionType(referenceName))
q.Set("versionDescriptor.version", formatReferenceName(referenceName))
if referenceName != "" {
q.Set("versionDescriptor.versionType", getVersionType(referenceName))
q.Set("versionDescriptor.version", formatReferenceName(referenceName))
}
q.Set("$format", "zip")
q.Set("recursionLevel", "full")
q.Set("api-version", "6.0")
@@ -246,8 +246,8 @@ func (a *azureDownloader) buildDownloadUrl(config *azureOptions, referenceName s
return u.String(), nil
}
func (a *azureDownloader) buildRefsUrl(config *azureOptions, referenceName string) (string, error) {
rawUrl := fmt.Sprintf("%s/%s/%s/_apis/git/repositories/%s/refs",
func (a *azureDownloader) buildRootItemUrl(config *azureOptions, referenceName string) (string, error) {
rawUrl := fmt.Sprintf("%s/%s/%s/_apis/git/repositories/%s/items",
a.baseUrl,
url.PathEscape(config.organisation),
url.PathEscape(config.project),
@@ -255,12 +255,15 @@ func (a *azureDownloader) buildRefsUrl(config *azureOptions, referenceName strin
u, err := url.Parse(rawUrl)
if err != nil {
return "", errors.Wrapf(err, "failed to parse refs url path %s", rawUrl)
return "", errors.Wrapf(err, "failed to parse root item url path %s", rawUrl)
}
// filterContains=main&api-version=6.0
q := u.Query()
q.Set("filterContains", formatReferenceName(referenceName))
q.Set("scopePath", "/")
if referenceName != "" {
q.Set("versionDescriptor.versionType", getVersionType(referenceName))
q.Set("versionDescriptor.version", formatReferenceName(referenceName))
}
q.Set("api-version", "6.0")
u.RawQuery = q.Encode()

View File

@@ -28,15 +28,15 @@ func Test_buildDownloadUrl(t *testing.T) {
}
}
func Test_buildRefsUrl(t *testing.T) {
func Test_buildRootItemUrl(t *testing.T) {
a := NewAzureDownloader(nil)
u, err := a.buildRefsUrl(&azureOptions{
u, err := a.buildRootItemUrl(&azureOptions{
organisation: "organisation",
project: "project",
repository: "repository",
}, "refs/heads/main")
expectedUrl, _ := url.Parse("https://dev.azure.com/organisation/project/_apis/git/repositories/repository/refs?filterContains=main&api-version=6.0")
expectedUrl, _ := url.Parse("https://dev.azure.com/organisation/project/_apis/git/repositories/repository/items?scopePath=/&api-version=6.0&versionDescriptor.version=main&versionDescriptor.versionType=branch")
actualUrl, _ := url.Parse(u)
assert.NoError(t, err)
assert.Equal(t, expectedUrl.Host, actualUrl.Host)
@@ -270,63 +270,17 @@ func Test_azureDownloader_downloadZipFromAzureDevOps(t *testing.T) {
func Test_azureDownloader_latestCommitID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
response := `{
"value": [
{
"name": "refs/heads/feature/calcApp",
"objectId": "ffe9cba521f00d7f60e322845072238635edb451",
"creator": {
"displayName": "Normal Paulk",
"url": "https://vssps.dev.azure.com/fabrikam/_apis/Identities/ac5aaba6-a66a-4e1d-b508-b060ec624fa9",
"_links": {
"avatar": {
"href": "https://dev.azure.com/fabrikam/_apis/GraphProfile/MemberAvatars/aad.YmFjMGYyZDctNDA3ZC03OGRhLTlhMjUtNmJhZjUwMWFjY2U5"
}
},
"id": "ac5aaba6-a66a-4e1d-b508-b060ec624fa9",
"uniqueName": "dev@mailserver.com",
"imageUrl": "https://dev.azure.com/fabrikam/_api/_common/identityImage?id=ac5aaba6-a66a-4e1d-b508-b060ec624fa9",
"descriptor": "aad.YmFjMGYyZDctNDA3ZC03OGRhLTlhMjUtNmJhZjUwMWFjY2U5"
},
"url": "https://dev.azure.com/fabrikam/7484f783-66a3-4f27-b7cd-6b08b0b077ed/_apis/git/repositories/d3d1760b-311c-4175-a726-20dfc6a7f885/refs?filter=heads%2Ffeature%2FcalcApp"
},
{
"name": "refs/heads/feature/replacer",
"objectId": "917131a709996c5cfe188c3b57e9a6ad90e8b85c",
"creator": {
"displayName": "Normal Paulk",
"url": "https://vssps.dev.azure.com/fabrikam/_apis/Identities/ac5aaba6-a66a-4e1d-b508-b060ec624fa9",
"_links": {
"avatar": {
"href": "https://dev.azure.com/fabrikam/_apis/GraphProfile/MemberAvatars/aad.YmFjMGYyZDctNDA3ZC03OGRhLTlhMjUtNmJhZjUwMWFjY2U5"
}
},
"id": "ac5aaba6-a66a-4e1d-b508-b060ec624fa9",
"uniqueName": "dev@mailserver.com",
"imageUrl": "https://dev.azure.com/fabrikam/_api/_common/identityImage?id=ac5aaba6-a66a-4e1d-b508-b060ec624fa9",
"descriptor": "aad.YmFjMGYyZDctNDA3ZC03OGRhLTlhMjUtNmJhZjUwMWFjY2U5"
},
"url": "https://dev.azure.com/fabrikam/7484f783-66a3-4f27-b7cd-6b08b0b077ed/_apis/git/repositories/d3d1760b-311c-4175-a726-20dfc6a7f885/refs?filter=heads%2Ffeature%2Freplacer"
},
{
"name": "refs/heads/master",
"objectId": "ffe9cba521f00d7f60e322845072238635edb451",
"creator": {
"displayName": "Normal Paulk",
"url": "https://vssps.dev.azure.com/fabrikam/_apis/Identities/ac5aaba6-a66a-4e1d-b508-b060ec624fa9",
"_links": {
"avatar": {
"href": "https://dev.azure.com/fabrikam/_apis/GraphProfile/MemberAvatars/aad.YmFjMGYyZDctNDA3ZC03OGRhLTlhMjUtNmJhZjUwMWFjY2U5"
}
},
"id": "ac5aaba6-a66a-4e1d-b508-b060ec624fa9",
"uniqueName": "dev@mailserver.com",
"imageUrl": "https://dev.azure.com/fabrikam/_api/_common/identityImage?id=ac5aaba6-a66a-4e1d-b508-b060ec624fa9",
"descriptor": "aad.YmFjMGYyZDctNDA3ZC03OGRhLTlhMjUtNmJhZjUwMWFjY2U5"
},
"url": "https://dev.azure.com/fabrikam/7484f783-66a3-4f27-b7cd-6b08b0b077ed/_apis/git/repositories/d3d1760b-311c-4175-a726-20dfc6a7f885/refs?filter=heads%2Fmaster"
}
],
"count": 3
"count": 1,
"value": [
{
"objectId": "1a5630f017127db7de24d8771da0f536ff98fc9b",
"gitObjectType": "tree",
"commitId": "27104ad7549d9e66685e115a497533f18024be9c",
"path": "/",
"isFolder": true,
"url": "https://dev.azure.com/simonmeng0474/4b546a97-c481-4506-bdd5-976e9592f91a/_apis/git/repositories/a22247ad-053f-43bc-88a7-62ff4846bb97/items?path=%2F&versionType=Branch&versionOptions=None"
}
]
}`
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(response))
@@ -347,19 +301,11 @@ func Test_azureDownloader_latestCommitID(t *testing.T) {
{
name: "should be able to parse response",
args: fetchOptions{
referenceName: "refs/heads/master",
referenceName: "",
repositoryUrl: "https://dev.azure.com/Organisation/Project/_git/Repository"},
want: "ffe9cba521f00d7f60e322845072238635edb451",
want: "27104ad7549d9e66685e115a497533f18024be9c",
wantErr: false,
},
{
name: "should be able to parse response",
args: fetchOptions{
referenceName: "refs/heads/unknown",
repositoryUrl: "https://dev.azure.com/Organisation/Project/_git/Repository"},
want: "",
wantErr: true,
},
}
for _, tt := range tests {

View File

@@ -82,8 +82,17 @@ func (c gitClient) latestCommitID(ctx context.Context, opt fetchOptions) (string
return "", errors.Wrap(err, "failed to list repository refs")
}
referenceName := opt.referenceName
if referenceName == "" {
for _, ref := range refs {
if strings.EqualFold(ref.Name().String(), "HEAD") {
referenceName = ref.Target().String()
}
}
}
for _, ref := range refs {
if strings.EqualFold(ref.Name().String(), opt.referenceName) {
if strings.EqualFold(ref.Name().String(), referenceName) {
return ref.Hash().String(), nil
}
}

View File

@@ -32,7 +32,7 @@ require (
github.com/koding/websocketproxy v0.0.0-20181220232114-7ed82d81a28c
github.com/orcaman/concurrent-map v0.0.0-20190826125027-8c72a8bb44f6
github.com/pkg/errors v0.9.1
github.com/portainer/docker-compose-wrapper v0.0.0-20220407011010-3c7408969ad3
github.com/portainer/docker-compose-wrapper v0.0.0-20220531190153-c597b853e410
github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a
github.com/portainer/libhelm v0.0.0-20210929000907-825e93d62108
github.com/portainer/libhttp v0.0.0-20211208103139-07a5f798eb3f

View File

@@ -807,8 +807,10 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/portainer/docker-compose-wrapper v0.0.0-20220407011010-3c7408969ad3 h1:dg/uvltrR++AVDjjVkXKrinZ/T8YlaKeUAOAmQ1i+dk=
github.com/portainer/docker-compose-wrapper v0.0.0-20220407011010-3c7408969ad3/go.mod h1:WxDlJWZxCnicdLCPnLNEv7/gRhjeIVuCGmsv+iOPH3c=
github.com/portainer/docker-compose-wrapper v0.0.0-20220526210722-e1574867298e h1:gW1Ooaj7RZ9YkwHxesnNEyOB5nUD71FlZ7cdb5h63vw=
github.com/portainer/docker-compose-wrapper v0.0.0-20220526210722-e1574867298e/go.mod h1:WxDlJWZxCnicdLCPnLNEv7/gRhjeIVuCGmsv+iOPH3c=
github.com/portainer/docker-compose-wrapper v0.0.0-20220531190153-c597b853e410 h1:LjxLd8UGR8ae73ov/vLrt/0jedj/nh98XnONkr8DJj8=
github.com/portainer/docker-compose-wrapper v0.0.0-20220531190153-c597b853e410/go.mod h1:WxDlJWZxCnicdLCPnLNEv7/gRhjeIVuCGmsv+iOPH3c=
github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a h1:qY8TbocN75n5PDl16o0uVr5MevtM5IhdwSelXEd4nFM=
github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a/go.mod h1:n54EEIq+MM0NNtqLeCby8ljL+l275VpolXO0ibHegLE=
github.com/portainer/libhelm v0.0.0-20210929000907-825e93d62108 h1:5e8KAnDa2G3cEHK7aV/ue8lOaoQwBZUzoALslwWkR04=

View File

@@ -9,4 +9,6 @@ var (
ErrUnauthorized = errors.New("Unauthorized")
// ErrResourceAccessDenied Access denied to resource error
ErrResourceAccessDenied = errors.New("Access denied to resource")
// ErrNotAvailableInDemo feature is not allowed in demo
ErrNotAvailableInDemo = errors.New("This feature is not available in the demo version of Portainer")
)

View File

@@ -13,7 +13,6 @@ import (
portainer "github.com/portainer/portainer/api"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/passwordutils"
)
type authenticatePayload struct {
@@ -101,7 +100,7 @@ func (handler *Handler) authenticateInternal(w http.ResponseWriter, user *portai
return &httperror.HandlerError{http.StatusUnprocessableEntity, "Invalid credentials", httperrors.ErrUnauthorized}
}
forceChangePassword := !passwordutils.StrengthCheck(password)
forceChangePassword := !handler.passwordStrengthChecker.Check(password)
return handler.writeToken(w, user, forceChangePassword)
}

View File

@@ -22,12 +22,14 @@ type Handler struct {
OAuthService portainer.OAuthService
ProxyManager *proxy.Manager
KubernetesTokenCacheManager *kubernetes.TokenCacheManager
passwordStrengthChecker security.PasswordStrengthChecker
}
// NewHandler creates a handler to manage authentication operations.
func NewHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimiter) *Handler {
func NewHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimiter, passwordStrengthChecker security.PasswordStrengthChecker) *Handler {
h := &Handler{
Router: mux.NewRouter(),
Router: mux.NewRouter(),
passwordStrengthChecker: passwordStrengthChecker,
}
h.Handle("/auth/oauth/validate",

View File

@@ -18,6 +18,7 @@ import (
"github.com/docker/docker/pkg/ioutils"
"github.com/portainer/portainer/api/adminmonitor"
"github.com/portainer/portainer/api/crypto"
"github.com/portainer/portainer/api/demo"
"github.com/portainer/portainer/api/http/offlinegate"
i "github.com/portainer/portainer/api/internal/testhelpers"
"github.com/stretchr/testify/assert"
@@ -49,7 +50,7 @@ func Test_backupHandlerWithoutPassword_shouldCreateATarballArchive(t *testing.T)
gate := offlinegate.NewOfflineGate()
adminMonitor := adminmonitor.New(time.Hour, nil, context.Background())
handlerErr := NewHandler(nil, i.NewDatastore(), gate, "./test_assets/handler_test", func() {}, adminMonitor).backup(w, r)
handlerErr := NewHandler(nil, i.NewDatastore(), gate, "./test_assets/handler_test", func() {}, adminMonitor, &demo.Service{}).backup(w, r)
assert.Nil(t, handlerErr, "Handler should not fail")
response := w.Result()
@@ -86,7 +87,7 @@ func Test_backupHandlerWithPassword_shouldCreateEncryptedATarballArchive(t *test
gate := offlinegate.NewOfflineGate()
adminMonitor := adminmonitor.New(time.Hour, nil, nil)
handlerErr := NewHandler(nil, i.NewDatastore(), gate, "./test_assets/handler_test", func() {}, adminMonitor).backup(w, r)
handlerErr := NewHandler(nil, i.NewDatastore(), gate, "./test_assets/handler_test", func() {}, adminMonitor, &demo.Service{}).backup(w, r)
assert.Nil(t, handlerErr, "Handler should not fail")
response := w.Result()

View File

@@ -9,6 +9,8 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/adminmonitor"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/demo"
"github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/offlinegate"
"github.com/portainer/portainer/api/http/security"
)
@@ -25,7 +27,17 @@ type Handler struct {
}
// NewHandler creates an new instance of backup handler
func NewHandler(bouncer *security.RequestBouncer, dataStore dataservices.DataStore, gate *offlinegate.OfflineGate, filestorePath string, shutdownTrigger context.CancelFunc, adminMonitor *adminmonitor.Monitor) *Handler {
func NewHandler(
bouncer *security.RequestBouncer,
dataStore dataservices.DataStore,
gate *offlinegate.OfflineGate,
filestorePath string,
shutdownTrigger context.CancelFunc,
adminMonitor *adminmonitor.Monitor,
demoService *demo.Service,
) *Handler {
h := &Handler{
Router: mux.NewRouter(),
bouncer: bouncer,
@@ -36,8 +48,11 @@ func NewHandler(bouncer *security.RequestBouncer, dataStore dataservices.DataSto
adminMonitor: adminMonitor,
}
h.Handle("/backup", bouncer.RestrictedAccess(adminAccess(httperror.LoggerHandler(h.backup)))).Methods(http.MethodPost)
h.Handle("/restore", bouncer.PublicAccess(httperror.LoggerHandler(h.restore))).Methods(http.MethodPost)
demoRestrictedRouter := h.NewRoute().Subrouter()
demoRestrictedRouter.Use(middlewares.RestrictDemoEnv(demoService.IsDemo))
demoRestrictedRouter.Handle("/backup", bouncer.RestrictedAccess(adminAccess(httperror.LoggerHandler(h.backup)))).Methods(http.MethodPost)
demoRestrictedRouter.Handle("/restore", bouncer.PublicAccess(httperror.LoggerHandler(h.restore))).Methods(http.MethodPost)
return h
}
@@ -50,7 +65,7 @@ func adminAccess(next http.Handler) http.Handler {
}
if !securityContext.IsAdmin {
httperror.WriteError(w, http.StatusUnauthorized, "User is not authorized to perfom the action", nil)
httperror.WriteError(w, http.StatusUnauthorized, "User is not authorized to perform the action", nil)
}
next.ServeHTTP(w, r)

View File

@@ -14,6 +14,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/adminmonitor"
"github.com/portainer/portainer/api/demo"
"github.com/portainer/portainer/api/http/offlinegate"
i "github.com/portainer/portainer/api/internal/testhelpers"
"github.com/stretchr/testify/assert"
@@ -51,7 +52,7 @@ func Test_restoreArchive_usingCombinationOfPasswords(t *testing.T) {
datastore := i.NewDatastore(i.WithUsers([]portainer.User{}), i.WithEdgeJobs([]portainer.EdgeJob{}))
adminMonitor := adminmonitor.New(time.Hour, datastore, context.Background())
h := NewHandler(nil, datastore, offlinegate.NewOfflineGate(), "./test_assets/handler_test", func() {}, adminMonitor)
h := NewHandler(nil, datastore, offlinegate.NewOfflineGate(), "./test_assets/handler_test", func() {}, adminMonitor, &demo.Service{})
//backup
archive := backup(t, h, test.backupPassword)
@@ -74,7 +75,7 @@ func Test_restoreArchive_shouldFailIfSystemWasAlreadyInitialized(t *testing.T) {
datastore := i.NewDatastore(i.WithUsers([]portainer.User{admin}), i.WithEdgeJobs([]portainer.EdgeJob{}))
adminMonitor := adminmonitor.New(time.Hour, datastore, context.Background())
h := NewHandler(nil, datastore, offlinegate.NewOfflineGate(), "./test_assets/handler_test", func() {}, adminMonitor)
h := NewHandler(nil, datastore, offlinegate.NewOfflineGate(), "./test_assets/handler_test", func() {}, adminMonitor, &demo.Service{})
//backup
archive := backup(t, h, "password")

View File

@@ -1,6 +1,7 @@
package customtemplates
import (
"encoding/json"
"errors"
"log"
"net/http"
@@ -115,6 +116,8 @@ type customTemplateFromFileContentPayload struct {
Type portainer.StackType `example:"1" enums:"1,2,3" validate:"required"`
// Content of stack file
FileContent string `validate:"required"`
// Definitions of variables in the stack file
Variables []portainer.CustomTemplateVariableDefinition
}
func (payload *customTemplateFromFileContentPayload) Validate(r *http.Request) error {
@@ -136,6 +139,12 @@ func (payload *customTemplateFromFileContentPayload) Validate(r *http.Request) e
if !isValidNote(payload.Note) {
return errors.New("Invalid note. <img> tag is not supported")
}
err := validateVariablesDefinitions(payload.Variables)
if err != nil {
return err
}
return nil
}
@@ -164,6 +173,7 @@ func (handler *Handler) createCustomTemplateFromFileContent(r *http.Request) (*p
Platform: (payload.Platform),
Type: (payload.Type),
Logo: payload.Logo,
Variables: payload.Variables,
}
templateFolder := strconv.Itoa(customTemplateID)
@@ -204,6 +214,8 @@ type customTemplateFromGitRepositoryPayload struct {
RepositoryPassword string `example:"myGitPassword"`
// Path to the Stack file inside the Git repository
ComposeFilePathInRepository string `example:"docker-compose.yml" default:"docker-compose.yml"`
// Definitions of variables in the stack file
Variables []portainer.CustomTemplateVariableDefinition
}
func (payload *customTemplateFromGitRepositoryPayload) Validate(r *http.Request) error {
@@ -236,6 +248,12 @@ func (payload *customTemplateFromGitRepositoryPayload) Validate(r *http.Request)
if !isValidNote(payload.Note) {
return errors.New("Invalid note. <img> tag is not supported")
}
err := validateVariablesDefinitions(payload.Variables)
if err != nil {
return err
}
return nil
}
@@ -256,6 +274,7 @@ func (handler *Handler) createCustomTemplateFromGitRepository(r *http.Request) (
Platform: payload.Platform,
Type: payload.Type,
Logo: payload.Logo,
Variables: payload.Variables,
}
projectPath := handler.FileService.GetCustomTemplateProjectPath(strconv.Itoa(customTemplateID))
@@ -316,6 +335,8 @@ type customTemplateFromFileUploadPayload struct {
Platform portainer.CustomTemplatePlatform
Type portainer.StackType
FileContent []byte
// Definitions of variables in the stack file
Variables []portainer.CustomTemplateVariableDefinition
}
func (payload *customTemplateFromFileUploadPayload) Validate(r *http.Request) error {
@@ -361,6 +382,17 @@ func (payload *customTemplateFromFileUploadPayload) Validate(r *http.Request) er
}
payload.FileContent = composeFileContent
varsString, _ := request.RetrieveMultiPartFormValue(r, "Variables", true)
err = json.Unmarshal([]byte(varsString), &payload.Variables)
if err != nil {
return errors.New("Invalid variables. Ensure that the variables are valid JSON")
}
err = validateVariablesDefinitions(payload.Variables)
if err != nil {
return err
}
return nil
}
@@ -381,6 +413,7 @@ func (handler *Handler) createCustomTemplateFromFileUpload(r *http.Request) (*po
Type: payload.Type,
Logo: payload.Logo,
EntryPoint: filesystem.ComposeFileDefaultName,
Variables: payload.Variables,
}
templateFolder := strconv.Itoa(customTemplateID)

View File

@@ -31,6 +31,8 @@ type customTemplateUpdatePayload struct {
Type portainer.StackType `example:"1" enums:"1,2,3" validate:"required"`
// Content of stack file
FileContent string `validate:"required"`
// Definitions of variables in the stack file
Variables []portainer.CustomTemplateVariableDefinition
}
func (payload *customTemplateUpdatePayload) Validate(r *http.Request) error {
@@ -52,6 +54,12 @@ func (payload *customTemplateUpdatePayload) Validate(r *http.Request) error {
if !isValidNote(payload.Note) {
return errors.New("Invalid note. <img> tag is not supported")
}
err := validateVariablesDefinitions(payload.Variables)
if err != nil {
return err
}
return nil
}
@@ -124,6 +132,7 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ
customTemplate.Note = payload.Note
customTemplate.Platform = payload.Platform
customTemplate.Type = payload.Type
customTemplate.Variables = payload.Variables
err = handler.DataStore.CustomTemplate().UpdateCustomTemplate(customTemplate.ID, customTemplate)
if err != nil {

View File

@@ -0,0 +1,19 @@
package customtemplates
import (
"errors"
portainer "github.com/portainer/portainer/api"
)
func validateVariablesDefinitions(variables []portainer.CustomTemplateVariableDefinition) error {
for _, variable := range variables {
if variable.Name == "" {
return errors.New("variable name is required")
}
if variable.Label == "" {
return errors.New("variable label is required")
}
}
return nil
}

View File

@@ -0,0 +1,924 @@
package edgestacks
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"reflect"
"strconv"
"testing"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/apikey"
"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/jwt"
)
type gitService struct {
cloneErr error
id string
}
func (g *gitService) CloneRepository(destination, repositoryURL, referenceName, username, password string) error {
return g.cloneErr
}
func (g *gitService) LatestCommitID(repositoryURL, referenceName, username, password string) (string, error) {
return g.id, nil
}
// Helpers
func setupHandler(t *testing.T) (*Handler, string, func()) {
t.Helper()
_, store, storeTeardown := datastore.MustNewTestStore(true, true)
jwtService, err := jwt.NewService("1h", store)
if err != nil {
storeTeardown()
t.Fatal(err)
}
user := &portainer.User{ID: 2, Username: "admin", Role: portainer.AdministratorRole}
err = store.User().Create(user)
if err != nil {
storeTeardown()
t.Fatal(err)
}
apiKeyService := apikey.NewAPIKeyService(store.APIKeyRepository(), store.User())
rawAPIKey, _, err := apiKeyService.GenerateApiKey(*user, "test")
if err != nil {
storeTeardown()
t.Fatal(err)
}
handler := NewHandler(
security.NewRequestBouncer(store, jwtService, apiKeyService),
store,
)
tmpDir, err := os.MkdirTemp(os.TempDir(), "portainer-test")
if err != nil {
storeTeardown()
t.Fatal(err)
}
fs, err := filesystem.NewService(tmpDir, "")
if err != nil {
storeTeardown()
t.Fatal(err)
}
handler.FileService = fs
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
t.Fatal(err)
}
settings.EnableEdgeComputeFeatures = true
err = handler.DataStore.Settings().UpdateSettings(settings)
if err != nil {
t.Fatal(err)
}
handler.GitService = &gitService{errors.New("Clone error"), "git-service-id"}
return handler, rawAPIKey, storeTeardown
}
func createEndpoint(t *testing.T, store dataservices.DataStore) portainer.Endpoint {
t.Helper()
endpointID := portainer.EndpointID(5)
endpoint := portainer.Endpoint{
ID: endpointID,
Name: "test-endpoint-" + strconv.Itoa(int(endpointID)),
Type: portainer.EdgeAgentOnDockerEnvironment,
URL: "https://portainer.io:9443",
EdgeID: "edge-id",
LastCheckInDate: time.Now().Unix(),
}
err := store.Endpoint().Create(&endpoint)
if err != nil {
t.Fatal(err)
}
return endpoint
}
func createEdgeStack(t *testing.T, store dataservices.DataStore, endpointID portainer.EndpointID) portainer.EdgeStack {
t.Helper()
edgeGroup := portainer.EdgeGroup{
ID: 1,
Name: "EdgeGroup 1",
Dynamic: false,
TagIDs: nil,
Endpoints: []portainer.EndpointID{endpointID},
PartialMatch: false,
}
err := store.EdgeGroup().Create(&edgeGroup)
if err != nil {
t.Fatal(err)
}
edgeStackID := portainer.EdgeStackID(14)
edgeStack := portainer.EdgeStack{
ID: edgeStackID,
Name: "test-edge-stack-" + strconv.Itoa(int(edgeStackID)),
Status: map[portainer.EndpointID]portainer.EdgeStackStatus{
endpointID: {Type: portainer.StatusOk, Error: "", EndpointID: endpointID},
},
CreationDate: time.Now().Unix(),
EdgeGroups: []portainer.EdgeGroupID{edgeGroup.ID},
ProjectPath: "/project/path",
EntryPoint: "entrypoint",
Version: 237,
ManifestPath: "/manifest/path",
DeploymentType: portainer.EdgeStackDeploymentKubernetes,
}
endpointRelation := portainer.EndpointRelation{
EndpointID: endpointID,
EdgeStacks: map[portainer.EdgeStackID]bool{
edgeStack.ID: true,
},
}
err = store.EdgeStack().Create(edgeStack.ID, &edgeStack)
if err != nil {
t.Fatal(err)
}
err = store.EndpointRelation().Create(&endpointRelation)
if err != nil {
t.Fatal(err)
}
return edgeStack
}
// Inspect
func TestInspectInvalidEdgeID(t *testing.T) {
handler, rawAPIKey, teardown := setupHandler(t)
defer teardown()
cases := []struct {
Name string
EdgeStackID string
ExpectedStatusCode int
}{
{"Invalid EdgeStackID", "x", 400},
{"Non-existing EdgeStackID", "5", 404},
}
for _, tc := range cases {
t.Run(tc.Name, func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "/edge_stacks/"+tc.EdgeStackID, nil)
if err != nil {
t.Fatal("request error:", err)
}
req.Header.Add("x-api-key", rawAPIKey)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != tc.ExpectedStatusCode {
t.Fatalf(fmt.Sprintf("expected a %d response, found: %d", tc.ExpectedStatusCode, rec.Code))
}
})
}
}
// Create
func TestCreateAndInspect(t *testing.T) {
handler, rawAPIKey, teardown := setupHandler(t)
defer teardown()
// Create Endpoint, EdgeGroup and EndpointRelation
endpoint := createEndpoint(t, handler.DataStore)
edgeGroup := portainer.EdgeGroup{
ID: 1,
Name: "EdgeGroup 1",
Dynamic: false,
TagIDs: nil,
Endpoints: []portainer.EndpointID{endpoint.ID},
PartialMatch: false,
}
err := handler.DataStore.EdgeGroup().Create(&edgeGroup)
if err != nil {
t.Fatal(err)
}
endpointRelation := portainer.EndpointRelation{
EndpointID: endpoint.ID,
EdgeStacks: map[portainer.EdgeStackID]bool{},
}
err = handler.DataStore.EndpointRelation().Create(&endpointRelation)
if err != nil {
t.Fatal(err)
}
payload := swarmStackFromFileContentPayload{
Name: "Test Stack",
StackFileContent: "stack content",
EdgeGroups: []portainer.EdgeGroupID{1},
DeploymentType: portainer.EdgeStackDeploymentCompose,
}
jsonPayload, err := json.Marshal(payload)
if err != nil {
t.Fatal("JSON marshal error:", err)
}
r := bytes.NewBuffer(jsonPayload)
// Create EdgeStack
req, err := http.NewRequest(http.MethodPost, "/edge_stacks?method=string", r)
if err != nil {
t.Fatal("request error:", err)
}
req.Header.Add("x-api-key", rawAPIKey)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf(fmt.Sprintf("expected a %d response, found: %d", http.StatusOK, rec.Code))
}
data := portainer.EdgeStack{}
err = json.NewDecoder(rec.Body).Decode(&data)
if err != nil {
t.Fatal("error decoding response:", err)
}
// Inspect
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", data.ID), nil)
if err != nil {
t.Fatal("request error:", err)
}
req.Header.Add("x-api-key", rawAPIKey)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf(fmt.Sprintf("expected a %d response, found: %d", http.StatusOK, rec.Code))
}
data = portainer.EdgeStack{}
err = json.NewDecoder(rec.Body).Decode(&data)
if err != nil {
t.Fatal("error decoding response:", err)
}
if payload.Name != data.Name {
t.Fatalf(fmt.Sprintf("expected EdgeStack Name %s, found %s", payload.Name, data.Name))
}
}
func TestCreateWithInvalidPayload(t *testing.T) {
handler, rawAPIKey, teardown := setupHandler(t)
defer teardown()
endpoint := createEndpoint(t, handler.DataStore)
edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID)
cases := []struct {
Name string
Payload interface{}
QueryString string
ExpectedStatusCode int
}{
{
Name: "Invalid query string parameter",
Payload: swarmStackFromFileContentPayload{},
QueryString: "invalid=query-string",
ExpectedStatusCode: 400,
},
{
Name: "Invalid creation method",
Payload: swarmStackFromFileContentPayload{},
QueryString: "method=invalid-creation-method",
ExpectedStatusCode: 500,
},
{
Name: "Empty swarmStackFromFileContentPayload with string method",
Payload: swarmStackFromFileContentPayload{},
QueryString: "method=string",
ExpectedStatusCode: 500,
},
{
Name: "Empty swarmStackFromFileContentPayload with repository method",
Payload: swarmStackFromFileContentPayload{},
QueryString: "method=repository",
ExpectedStatusCode: 500,
},
{
Name: "Empty swarmStackFromFileContentPayload with file method",
Payload: swarmStackFromFileContentPayload{},
QueryString: "method=file",
ExpectedStatusCode: 500,
},
{
Name: "Duplicated EdgeStack Name",
Payload: swarmStackFromFileContentPayload{
Name: edgeStack.Name,
StackFileContent: "content",
EdgeGroups: edgeStack.EdgeGroups,
DeploymentType: edgeStack.DeploymentType,
},
QueryString: "method=string",
ExpectedStatusCode: 500,
},
{
Name: "Empty EdgeStack Groups",
Payload: swarmStackFromFileContentPayload{
Name: edgeStack.Name,
StackFileContent: "content",
EdgeGroups: []portainer.EdgeGroupID{},
DeploymentType: edgeStack.DeploymentType,
},
QueryString: "method=string",
ExpectedStatusCode: 500,
},
{
Name: "EdgeStackDeploymentKubernetes with Docker endpoint",
Payload: swarmStackFromFileContentPayload{
Name: "Stack name",
StackFileContent: "content",
EdgeGroups: []portainer.EdgeGroupID{1},
DeploymentType: portainer.EdgeStackDeploymentKubernetes,
},
QueryString: "method=string",
ExpectedStatusCode: 500,
},
{
Name: "Empty Stack File Content",
Payload: swarmStackFromFileContentPayload{
Name: "Stack name",
StackFileContent: "",
EdgeGroups: []portainer.EdgeGroupID{1},
DeploymentType: portainer.EdgeStackDeploymentCompose,
},
QueryString: "method=string",
ExpectedStatusCode: 500,
},
{
Name: "Clone Git respository error",
Payload: swarmStackFromGitRepositoryPayload{
Name: "Stack name",
RepositoryURL: "github.com/portainer/portainer",
RepositoryReferenceName: "ref name",
RepositoryAuthentication: false,
RepositoryUsername: "",
RepositoryPassword: "",
FilePathInRepository: "/file/path",
EdgeGroups: []portainer.EdgeGroupID{1},
DeploymentType: portainer.EdgeStackDeploymentCompose,
},
QueryString: "method=repository",
ExpectedStatusCode: 500,
},
}
for _, tc := range cases {
t.Run(tc.Name, func(t *testing.T) {
jsonPayload, err := json.Marshal(tc.Payload)
if err != nil {
t.Fatal("JSON marshal error:", err)
}
r := bytes.NewBuffer(jsonPayload)
// Create EdgeStack
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("/edge_stacks?%s", tc.QueryString), r)
if err != nil {
t.Fatal("request error:", err)
}
req.Header.Add("x-api-key", rawAPIKey)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != tc.ExpectedStatusCode {
t.Fatalf(fmt.Sprintf("expected a %d response, found: %d", tc.ExpectedStatusCode, rec.Code))
}
})
}
}
// Delete
func TestDeleteAndInspect(t *testing.T) {
handler, rawAPIKey, teardown := setupHandler(t)
defer teardown()
// Create
endpoint := createEndpoint(t, handler.DataStore)
edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID)
// Inspect
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil)
if err != nil {
t.Fatal("request error:", err)
}
req.Header.Add("x-api-key", rawAPIKey)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf(fmt.Sprintf("expected a %d response, found: %d", http.StatusOK, rec.Code))
}
data := portainer.EdgeStack{}
err = json.NewDecoder(rec.Body).Decode(&data)
if err != nil {
t.Fatal("error decoding response:", err)
}
if data.ID != edgeStack.ID {
t.Fatalf(fmt.Sprintf("expected EdgeStackID %d, found %d", int(edgeStack.ID), data.ID))
}
// Delete
req, err = http.NewRequest(http.MethodDelete, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil)
if err != nil {
t.Fatal("request error:", err)
}
req.Header.Add("x-api-key", rawAPIKey)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf(fmt.Sprintf("expected a %d response, found: %d", http.StatusNoContent, rec.Code))
}
// Inspect
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil)
if err != nil {
t.Fatal("request error:", err)
}
req.Header.Add("x-api-key", rawAPIKey)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf(fmt.Sprintf("expected a %d response, found: %d", http.StatusNotFound, rec.Code))
}
}
func TestDeleteInvalidEdgeStack(t *testing.T) {
handler, rawAPIKey, teardown := setupHandler(t)
defer teardown()
cases := []struct {
Name string
URL string
ExpectedStatusCode int
}{
{Name: "Non-existing EdgeStackID", URL: "/edge_stacks/-1", ExpectedStatusCode: http.StatusNotFound},
{Name: "Invalid EdgeStackID", URL: "/edge_stacks/aaaaaaa", ExpectedStatusCode: http.StatusBadRequest},
}
for _, tc := range cases {
t.Run(tc.Name, func(t *testing.T) {
req, err := http.NewRequest(http.MethodDelete, tc.URL, nil)
if err != nil {
t.Fatal("request error:", err)
}
req.Header.Add("x-api-key", rawAPIKey)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != tc.ExpectedStatusCode {
t.Fatalf(fmt.Sprintf("expected a %d response, found: %d", tc.ExpectedStatusCode, rec.Code))
}
})
}
}
// Update
func TestUpdateAndInspect(t *testing.T) {
handler, rawAPIKey, teardown := setupHandler(t)
defer teardown()
endpoint := createEndpoint(t, handler.DataStore)
edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID)
// Update edge stack: create new Endpoint, EndpointRelation and EdgeGroup
endpointID := portainer.EndpointID(6)
newEndpoint := portainer.Endpoint{
ID: endpointID,
Name: "test-endpoint-" + strconv.Itoa(int(endpointID)),
Type: portainer.EdgeAgentOnDockerEnvironment,
URL: "https://portainer.io:9443",
EdgeID: "edge-id",
LastCheckInDate: time.Now().Unix(),
}
err := handler.DataStore.Endpoint().Create(&newEndpoint)
if err != nil {
t.Fatal(err)
}
endpointRelation := portainer.EndpointRelation{
EndpointID: endpointID,
EdgeStacks: map[portainer.EdgeStackID]bool{
edgeStack.ID: true,
},
}
err = handler.DataStore.EndpointRelation().Create(&endpointRelation)
if err != nil {
t.Fatal(err)
}
newEdgeGroup := portainer.EdgeGroup{
ID: 2,
Name: "EdgeGroup 2",
Dynamic: false,
TagIDs: nil,
Endpoints: []portainer.EndpointID{newEndpoint.ID},
PartialMatch: false,
}
err = handler.DataStore.EdgeGroup().Create(&newEdgeGroup)
if err != nil {
t.Fatal(err)
}
newVersion := 238
payload := updateEdgeStackPayload{
StackFileContent: "update-test",
Version: &newVersion,
EdgeGroups: append(edgeStack.EdgeGroups, newEdgeGroup.ID),
DeploymentType: portainer.EdgeStackDeploymentCompose,
}
jsonPayload, err := json.Marshal(payload)
if err != nil {
t.Fatal("request error:", err)
}
r := bytes.NewBuffer(jsonPayload)
req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), r)
if err != nil {
t.Fatal("request error:", err)
}
req.Header.Add("x-api-key", rawAPIKey)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf(fmt.Sprintf("expected a %d response, found: %d", http.StatusOK, rec.Code))
}
// Get updated edge stack
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil)
if err != nil {
t.Fatal("request error:", err)
}
req.Header.Add("x-api-key", rawAPIKey)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf(fmt.Sprintf("expected a %d response, found: %d", http.StatusOK, rec.Code))
}
data := portainer.EdgeStack{}
err = json.NewDecoder(rec.Body).Decode(&data)
if err != nil {
t.Fatal("error decoding response:", err)
}
if data.Version != *payload.Version {
t.Fatalf(fmt.Sprintf("expected EdgeStackID %d, found %d", edgeStack.Version, data.Version))
}
if data.DeploymentType != payload.DeploymentType {
t.Fatalf(fmt.Sprintf("expected DeploymentType %d, found %d", edgeStack.DeploymentType, data.DeploymentType))
}
if !reflect.DeepEqual(data.EdgeGroups, payload.EdgeGroups) {
t.Fatalf("expected EdgeGroups to be equal")
}
}
func TestUpdateWithInvalidEdgeGroups(t *testing.T) {
handler, rawAPIKey, teardown := setupHandler(t)
defer teardown()
endpoint := createEndpoint(t, handler.DataStore)
edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID)
//newEndpoint := createEndpoint(t, handler.DataStore)
newEdgeGroup := portainer.EdgeGroup{
ID: 2,
Name: "EdgeGroup 2",
Dynamic: false,
TagIDs: nil,
Endpoints: []portainer.EndpointID{8889},
PartialMatch: false,
}
handler.DataStore.EdgeGroup().Create(&newEdgeGroup)
newVersion := 238
cases := []struct {
Name string
Payload updateEdgeStackPayload
ExpectedStatusCode int
}{
{
"Update with non-existing EdgeGroupID",
updateEdgeStackPayload{
StackFileContent: "error-test",
Version: &newVersion,
EdgeGroups: []portainer.EdgeGroupID{9999},
DeploymentType: edgeStack.DeploymentType,
},
http.StatusInternalServerError,
},
{
"Update with invalid EdgeGroup (non-existing Endpoint)",
updateEdgeStackPayload{
StackFileContent: "error-test",
Version: &newVersion,
EdgeGroups: []portainer.EdgeGroupID{2},
DeploymentType: edgeStack.DeploymentType,
},
http.StatusInternalServerError,
},
{
"Update DeploymentType from Docker to Kubernetes",
updateEdgeStackPayload{
StackFileContent: "error-test",
Version: &newVersion,
EdgeGroups: []portainer.EdgeGroupID{1},
DeploymentType: portainer.EdgeStackDeploymentKubernetes,
},
http.StatusBadRequest,
},
}
for _, tc := range cases {
t.Run(tc.Name, func(t *testing.T) {
jsonPayload, err := json.Marshal(tc.Payload)
if err != nil {
t.Fatal("JSON marshal error:", err)
}
r := bytes.NewBuffer(jsonPayload)
req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), r)
if err != nil {
t.Fatal("request error:", err)
}
req.Header.Add("x-api-key", rawAPIKey)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != tc.ExpectedStatusCode {
t.Fatalf(fmt.Sprintf("expected a %d response, found: %d", tc.ExpectedStatusCode, rec.Code))
}
})
}
}
func TestUpdateWithInvalidPayload(t *testing.T) {
handler, rawAPIKey, teardown := setupHandler(t)
defer teardown()
endpoint := createEndpoint(t, handler.DataStore)
edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID)
newVersion := 238
cases := []struct {
Name string
Payload updateEdgeStackPayload
ExpectedStatusCode int
}{
{
"Update with empty StackFileContent",
updateEdgeStackPayload{
StackFileContent: "",
Version: &newVersion,
EdgeGroups: edgeStack.EdgeGroups,
DeploymentType: edgeStack.DeploymentType,
},
http.StatusBadRequest,
},
{
"Update with empty EdgeGroups",
updateEdgeStackPayload{
StackFileContent: "error-test",
Version: &newVersion,
EdgeGroups: []portainer.EdgeGroupID{},
DeploymentType: edgeStack.DeploymentType,
},
http.StatusBadRequest,
},
}
for _, tc := range cases {
t.Run(tc.Name, func(t *testing.T) {
jsonPayload, err := json.Marshal(tc.Payload)
if err != nil {
t.Fatal("request error:", err)
}
r := bytes.NewBuffer(jsonPayload)
req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), r)
if err != nil {
t.Fatal("request error:", err)
}
req.Header.Add("x-api-key", rawAPIKey)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != tc.ExpectedStatusCode {
t.Fatalf(fmt.Sprintf("expected a %d response, found: %d", tc.ExpectedStatusCode, rec.Code))
}
})
}
}
// Update Status
func TestUpdateStatusAndInspect(t *testing.T) {
handler, rawAPIKey, teardown := setupHandler(t)
defer teardown()
endpoint := createEndpoint(t, handler.DataStore)
edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID)
// Update edge stack status
newStatus := portainer.StatusError
payload := updateStatusPayload{
Error: "test-error",
Status: &newStatus,
EndpointID: &endpoint.ID,
}
jsonPayload, err := json.Marshal(payload)
if err != nil {
t.Fatal("request error:", err)
}
r := bytes.NewBuffer(jsonPayload)
req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d/status", edgeStack.ID), r)
if err != nil {
t.Fatal("request error:", err)
}
req.Header.Set(portainer.PortainerAgentEdgeIDHeader, endpoint.EdgeID)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf(fmt.Sprintf("expected a %d response, found: %d", http.StatusOK, rec.Code))
}
// Get updated edge stack
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil)
if err != nil {
t.Fatal("request error:", err)
}
req.Header.Add("x-api-key", rawAPIKey)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf(fmt.Sprintf("expected a %d response, found: %d", http.StatusOK, rec.Code))
}
data := portainer.EdgeStack{}
err = json.NewDecoder(rec.Body).Decode(&data)
if err != nil {
t.Fatal("error decoding response:", err)
}
if data.Status[endpoint.ID].Type != *payload.Status {
t.Fatalf(fmt.Sprintf("expected EdgeStackStatusType %d, found %d", payload.Status, data.Status[endpoint.ID].Type))
}
if data.Status[endpoint.ID].Error != payload.Error {
t.Fatalf(fmt.Sprintf("expected EdgeStackStatusError %s, found %s", payload.Error, data.Status[endpoint.ID].Error))
}
if data.Status[endpoint.ID].EndpointID != *payload.EndpointID {
t.Fatalf(fmt.Sprintf("expected EndpointID %d, found %d", payload.EndpointID, data.Status[endpoint.ID].EndpointID))
}
}
func TestUpdateStatusWithInvalidPayload(t *testing.T) {
handler, _, teardown := setupHandler(t)
defer teardown()
endpoint := createEndpoint(t, handler.DataStore)
edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID)
// Update edge stack status
statusError := portainer.StatusError
statusOk := portainer.StatusOk
cases := []struct {
Name string
Payload updateStatusPayload
ExpectedErrorMessage string
ExpectedStatusCode int
}{
{
"Update with nil Status",
updateStatusPayload{
Error: "test-error",
Status: nil,
EndpointID: &endpoint.ID,
},
"Invalid status",
400,
},
{
"Update with error status and empty error message",
updateStatusPayload{
Error: "",
Status: &statusError,
EndpointID: &endpoint.ID,
},
"Error message is mandatory when status is error",
400,
},
{
"Update with nil EndpointID",
updateStatusPayload{
Error: "",
Status: &statusOk,
EndpointID: nil,
},
"Invalid EnvironmentID",
400,
},
}
for _, tc := range cases {
t.Run(tc.Name, func(t *testing.T) {
jsonPayload, err := json.Marshal(tc.Payload)
if err != nil {
t.Fatal("request error:", err)
}
r := bytes.NewBuffer(jsonPayload)
req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d/status", edgeStack.ID), r)
if err != nil {
t.Fatal("request error:", err)
}
req.Header.Set(portainer.PortainerAgentEdgeIDHeader, endpoint.EdgeID)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != tc.ExpectedStatusCode {
t.Fatalf(fmt.Sprintf("expected a %d response, found: %d", tc.ExpectedStatusCode, rec.Code))
}
})
}
}
// Delete Status
func TestDeleteStatus(t *testing.T) {
handler, _, teardown := setupHandler(t)
defer teardown()
endpoint := createEndpoint(t, handler.DataStore)
edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID)
req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("/edge_stacks/%d/status/%d", edgeStack.ID, endpoint.ID), nil)
if err != nil {
t.Fatal("request error:", err)
}
req.Header.Set(portainer.PortainerAgentEdgeIDHeader, endpoint.EdgeID)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf(fmt.Sprintf("expected a %d response, found: %d", http.StatusOK, rec.Code))
}
}

View File

@@ -23,7 +23,7 @@ import (
// @tags endpoints
// @produce json
// @param id path int true "Environment(Endpoint) identifier"
// @success 200 {object} portainer.Endpoint "Success"
// @success 204 "Success"
// @failure 400 "Invalid request"
// @failure 404 "Environment(Endpoint) not found"
// @failure 500 "Server error"
@@ -61,7 +61,7 @@ func (handler *Handler) endpointAssociationDelete(w http.ResponseWriter, r *http
handler.ReverseTunnelService.SetTunnelStatusToIdle(endpoint.ID)
return response.JSON(w, endpoint)
return response.Empty(w)
}
func (handler *Handler) updateEdgeKey(edgeKey string) (string, error) {

View File

@@ -187,6 +187,15 @@ func (handler *Handler) endpointCreate(w http.ResponseWriter, r *http.Request) *
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
isUnique, err := handler.isNameUnique(payload.Name, 0)
if err != nil {
return httperror.InternalServerError("Unable to check if name is unique", err)
}
if !isUnique {
return httperror.NewError(http.StatusConflict, "Name is not unique", nil)
}
endpoint, endpointCreationError := handler.createEndpoint(payload)
if endpointCreationError != nil {
return endpointCreationError

View File

@@ -12,6 +12,7 @@ import (
func TestEmptyGlobalKey(t *testing.T) {
handler := NewHandler(
helper.NewTestRequestBouncer(),
nil,
)
req, err := http.NewRequest(http.MethodPost, "https://portainer.io:9443/endpoints/global-key", nil)

View File

@@ -8,6 +8,7 @@ import (
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
httperrors "github.com/portainer/portainer/api/http/errors"
)
// @id EndpointDelete
@@ -29,6 +30,10 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) *
return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment identifier route variable", err}
}
if handler.demoService.IsDemoEnvironment(portainer.EndpointID(endpointID)) {
return &httperror.HandlerError{http.StatusForbidden, httperrors.ErrNotAvailableInDemo.Error(), httperrors.ErrNotAvailableInDemo}
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if handler.DataStore.IsErrObjectNotFound(err) {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment with the specified identifier inside the database", err}

View File

@@ -34,7 +34,7 @@ var endpointGroupNames map[portainer.EndpointGroupID]string
// @id EndpointList
// @summary List environments(endpoints)
// @description List all environments(endpoints) based on the current user authorizations. Will
// @description return all environments(endpoints) if using an administrator account otherwise it will
// @description return all environments(endpoints) if using an administrator or team leader account otherwise it will
// @description only return authorized environments(endpoints).
// @description **Access policy**: restricted
// @tags endpoints
@@ -50,6 +50,7 @@ var endpointGroupNames map[portainer.EndpointGroupID]string
// @param tagsPartialMatch query bool false "If true, will return environment(endpoint) which has one of tagIds, if false (or missing) will return only environments(endpoints) that has all the tags"
// @param endpointIds query []int false "will return only these environments(endpoints)"
// @param edgeDeviceFilter query string false "will return only these edge environments, none will return only regular edge environments" Enum("all", "trusted", "untrusted", "none")
// @param name query string false "will return only environments(endpoints) with this name"
// @success 200 {array} portainer.Endpoint "Endpoints"
// @failure 500 "Server error"
// @router /endpoints [get]
@@ -91,12 +92,6 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environment groups from the database", err}
}
// create endpoint groups as a map for more convenient access
endpointGroupNames = make(map[portainer.EndpointGroupID]string, 0)
for _, group := range endpointGroups {
endpointGroupNames[group.ID] = group.Name
}
endpoints, err := handler.DataStore.Endpoint().Endpoints()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environments from the database", err}
@@ -127,6 +122,11 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
filteredEndpoints = filterEndpointsByGroupIDs(filteredEndpoints, groupIDs)
}
name, _ := request.RetrieveQueryParameter(r, "name", true)
if name != "" {
filteredEndpoints = filterEndpointsByName(filteredEndpoints, name)
}
edgeDeviceFilter, _ := request.RetrieveQueryParameter(r, "edgeDeviceFilter", false)
if edgeDeviceFilter != "" {
filteredEndpoints = filterEndpointsByEdgeDevice(filteredEndpoints, edgeDeviceFilter)
@@ -157,7 +157,7 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
}
// Sort endpoints by field
sortEndpointsByField(filteredEndpoints, sortField, sortOrder == "desc")
sortEndpointsByField(filteredEndpoints, endpointGroups, sortField, sortOrder == "desc")
filteredEndpointCount := len(filteredEndpoints)
@@ -254,7 +254,7 @@ func filterEndpointsByStatuses(endpoints []portainer.Endpoint, statuses []int, s
return filteredEndpoints
}
func sortEndpointsByField(endpoints []portainer.Endpoint, sortField string, isSortDesc bool) {
func sortEndpointsByField(endpoints []portainer.Endpoint, endpointGroups []portainer.EndpointGroup, sortField string, isSortDesc bool) {
switch sortField {
case "Name":
@@ -265,10 +265,20 @@ func sortEndpointsByField(endpoints []portainer.Endpoint, sortField string, isSo
}
case "Group":
endpointGroupNames = make(map[portainer.EndpointGroupID]string, 0)
for _, group := range endpointGroups {
endpointGroupNames[group.ID] = group.Name
}
endpointsByGroup := EndpointsByGroup{
endpointGroupNames: endpointGroupNames,
endpoints: endpoints,
}
if isSortDesc {
sort.Stable(sort.Reverse(EndpointsByGroup(endpoints)))
sort.Stable(sort.Reverse(endpointsByGroup))
} else {
sort.Stable(EndpointsByGroup(endpoints))
sort.Stable(endpointsByGroup)
}
case "Status":
@@ -465,3 +475,18 @@ func filteredEndpointsByIds(endpoints []portainer.Endpoint, ids []portainer.Endp
return filteredEndpoints
}
func filterEndpointsByName(endpoints []portainer.Endpoint, name string) []portainer.Endpoint {
if name == "" {
return endpoints
}
filteredEndpoints := make([]portainer.Endpoint, 0)
for _, endpoint := range endpoints {
if endpoint.Name == name {
filteredEndpoints = append(filteredEndpoints, endpoint)
}
}
return filteredEndpoints
}

View File

@@ -52,7 +52,7 @@ func Test_endpointList(t *testing.T) {
is.NoError(err, "error creating a user")
bouncer := helper.NewTestRequestBouncer()
h := NewHandler(bouncer)
h := NewHandler(bouncer, nil)
h.DataStore = store
h.ComposeStackManager = testhelpers.NewComposeStackManager()

View File

@@ -88,7 +88,18 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *
}
if payload.Name != nil {
endpoint.Name = *payload.Name
name := *payload.Name
isUnique, err := handler.isNameUnique(name, endpoint.ID)
if err != nil {
return httperror.InternalServerError("Unable to check if name is unique", err)
}
if !isUnique {
return httperror.NewError(http.StatusConflict, "Name is not unique", nil)
}
endpoint.Name = name
}
if payload.URL != nil {

View File

@@ -4,6 +4,7 @@ import (
httperror "github.com/portainer/libhttp/error"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/demo"
"github.com/portainer/portainer/api/http/proxy"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/kubernetes/cli"
@@ -35,6 +36,7 @@ type requestBouncer interface {
type Handler struct {
*mux.Router
requestBouncer requestBouncer
demoService *demo.Service
DataStore dataservices.DataStore
FileService portainer.FileService
ProxyManager *proxy.Manager
@@ -48,10 +50,11 @@ type Handler struct {
}
// NewHandler creates a handler to manage environment(endpoint) operations.
func NewHandler(bouncer requestBouncer) *Handler {
func NewHandler(bouncer requestBouncer, demoService *demo.Service) *Handler {
h := &Handler{
Router: mux.NewRouter(),
requestBouncer: bouncer,
demoService: demoService,
}
h.Handle("/endpoints",

View File

@@ -21,23 +21,26 @@ func (e EndpointsByName) Less(i, j int) bool {
return sortorder.NaturalLess(strings.ToLower(e[i].Name), strings.ToLower(e[j].Name))
}
type EndpointsByGroup []portainer.Endpoint
type EndpointsByGroup struct {
endpointGroupNames map[portainer.EndpointGroupID]string
endpoints []portainer.Endpoint
}
func (e EndpointsByGroup) Len() int {
return len(e)
return len(e.endpoints)
}
func (e EndpointsByGroup) Swap(i, j int) {
e[i], e[j] = e[j], e[i]
e.endpoints[i], e.endpoints[j] = e.endpoints[j], e.endpoints[i]
}
func (e EndpointsByGroup) Less(i, j int) bool {
if e[i].GroupID == e[j].GroupID {
if e.endpoints[i].GroupID == e.endpoints[j].GroupID {
return false
}
groupA := endpointGroupNames[e[i].GroupID]
groupB := endpointGroupNames[e[j].GroupID]
groupA := endpointGroupNames[e.endpoints[i].GroupID]
groupB := endpointGroupNames[e.endpoints[j].GroupID]
return sortorder.NaturalLess(strings.ToLower(groupA), strings.ToLower(groupB))
}

View File

@@ -0,0 +1,18 @@
package endpoints
import portainer "github.com/portainer/portainer/api"
func (handler *Handler) isNameUnique(name string, endpointID portainer.EndpointID) (bool, error) {
endpoints, err := handler.DataStore.Endpoint().Endpoints()
if err != nil {
return false, err
}
for _, endpoint := range endpoints {
if endpoint.Name == name && (endpointID == 0 || endpoint.ID != endpointID) {
return false, nil
}
}
return true, nil
}

View File

@@ -80,7 +80,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.13.0
// @version 2.15.0
// @description.markdown api-description.md
// @termsOfService

View File

@@ -7,6 +7,7 @@ import (
httperror "github.com/portainer/libhttp/error"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/demo"
"github.com/portainer/portainer/api/http/security"
)
@@ -24,12 +25,14 @@ type Handler struct {
JWTService dataservices.JWTService
LDAPService portainer.LDAPService
SnapshotService portainer.SnapshotService
demoService *demo.Service
}
// NewHandler creates a handler to manage settings operations.
func NewHandler(bouncer *security.RequestBouncer) *Handler {
func NewHandler(bouncer *security.RequestBouncer, demoService *demo.Service) *Handler {
h := &Handler{
Router: mux.NewRouter(),
Router: mux.NewRouter(),
demoService: demoService,
}
h.Handle("/settings",
bouncer.AdminAccess(httperror.LoggerHandler(h.settingsInspect))).Methods(http.MethodGet)

View File

@@ -14,6 +14,8 @@ type publicSettingsResponse struct {
LogoURL string `json:"LogoURL" example:"https://mycompany.mydomain.tld/logo.png"`
// Active authentication method for the Portainer instance. Valid values are: 1 for internal, 2 for LDAP, or 3 for oauth
AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod" example:"1"`
// The minimum required length for a password of any user when using internal auth mode
RequiredPasswordLength int `json:"RequiredPasswordLength" example:"1"`
// Whether edge compute features are enabled
EnableEdgeComputeFeatures bool `json:"EnableEdgeComputeFeatures" example:"true"`
// Supported feature flags
@@ -26,6 +28,8 @@ type publicSettingsResponse struct {
EnableTelemetry bool `json:"EnableTelemetry" example:"true"`
// The expiry of a Kubeconfig
KubeconfigExpiry string `example:"24h" default:"0"`
// Whether team sync is enabled
TeamSync bool `json:"TeamSync" example:"true"`
}
// @id SettingsPublic
@@ -51,6 +55,7 @@ func generatePublicSettings(appSettings *portainer.Settings) *publicSettingsResp
publicSettings := &publicSettingsResponse{
LogoURL: appSettings.LogoURL,
AuthenticationMethod: appSettings.AuthenticationMethod,
RequiredPasswordLength: appSettings.InternalAuthSettings.RequiredPasswordLength,
EnableEdgeComputeFeatures: appSettings.EnableEdgeComputeFeatures,
EnableTelemetry: appSettings.EnableTelemetry,
KubeconfigExpiry: appSettings.KubeconfigExpiry,
@@ -69,5 +74,9 @@ func generatePublicSettings(appSettings *portainer.Settings) *publicSettingsResp
publicSettings.OAuthLoginURI += "&prompt=login"
}
}
//if LDAP authentication is on, compose the related fields from application settings
if publicSettings.AuthenticationMethod == portainer.AuthenticationLDAP {
publicSettings.TeamSync = len(appSettings.LDAPSettings.GroupSearchSettings) > 0
}
return publicSettings
}

View File

@@ -22,9 +22,10 @@ type settingsUpdatePayload struct {
// A list of label name & value that will be used to hide containers when querying containers
BlackListedLabels []portainer.Pair
// Active authentication method for the Portainer instance. Valid values are: 1 for internal, 2 for LDAP, or 3 for oauth
AuthenticationMethod *int `example:"1"`
LDAPSettings *portainer.LDAPSettings `example:""`
OAuthSettings *portainer.OAuthSettings `example:""`
AuthenticationMethod *int `example:"1"`
InternalAuthSettings *portainer.InternalAuthSettings `example:""`
LDAPSettings *portainer.LDAPSettings `example:""`
OAuthSettings *portainer.OAuthSettings `example:""`
// The interval in which environment(endpoint) snapshots are created
SnapshotInterval *string `example:"5m"`
// URL to the templates that will be displayed in the UI when navigating to App Templates
@@ -113,6 +114,11 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) *
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve the settings from the database", err}
}
if handler.demoService.IsDemo() {
payload.EnableTelemetry = nil
payload.LogoURL = nil
}
if payload.AuthenticationMethod != nil {
settings.AuthenticationMethod = portainer.AuthenticationMethod(*payload.AuthenticationMethod)
}
@@ -148,6 +154,10 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) *
settings.BlackListedLabels = payload.BlackListedLabels
}
if payload.InternalAuthSettings != nil {
settings.InternalAuthSettings.RequiredPasswordLength = payload.InternalAuthSettings.RequiredPasswordLength
}
if payload.LDAPSettings != nil {
ldapReaderDN := settings.LDAPSettings.ReaderDN
ldapPassword := settings.LDAPSettings.Password

View File

@@ -177,9 +177,6 @@ func (payload *composeStackFromGitRepositoryPayload) Validate(r *http.Request) e
if govalidator.IsNull(payload.RepositoryURL) || !govalidator.IsURL(payload.RepositoryURL) {
return errors.New("Invalid repository URL. Must correspond to a valid URL format")
}
if govalidator.IsNull(payload.RepositoryReferenceName) {
payload.RepositoryReferenceName = defaultGitReferenceName
}
if payload.RepositoryAuthentication && govalidator.IsNull(payload.RepositoryPassword) {
return errors.New("Invalid repository credentials. Password must be specified when authentication is enabled")
}

View File

@@ -70,9 +70,6 @@ func (payload *kubernetesGitDeploymentPayload) Validate(r *http.Request) error {
if govalidator.IsNull(payload.ManifestFile) {
return errors.New("Invalid manifest file in repository")
}
if govalidator.IsNull(payload.RepositoryReferenceName) {
payload.RepositoryReferenceName = defaultGitReferenceName
}
if err := validateStackAutoUpdate(payload.AutoUpdate); err != nil {
return err
}

View File

@@ -144,9 +144,6 @@ func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) err
if govalidator.IsNull(payload.RepositoryURL) || !govalidator.IsURL(payload.RepositoryURL) {
return errors.New("Invalid repository URL. Must correspond to a valid URL format")
}
if govalidator.IsNull(payload.RepositoryReferenceName) {
payload.RepositoryReferenceName = defaultGitReferenceName
}
if payload.RepositoryAuthentication && govalidator.IsNull(payload.RepositoryPassword) {
return errors.New("Invalid repository credentials. Password must be specified when authentication is enabled")
}

View File

@@ -21,8 +21,6 @@ import (
"github.com/portainer/portainer/api/stacks"
)
const defaultGitReferenceName = "refs/heads/master"
var (
errStackAlreadyExists = errors.New("A stack already exists with this name")
errWebhookIDAlreadyExists = errors.New("A webhook ID already exists")

View File

@@ -4,7 +4,6 @@ import (
"net/http"
"time"
"github.com/asaskevich/govalidator"
"github.com/pkg/errors"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
@@ -26,10 +25,6 @@ type stackGitUpdatePayload struct {
}
func (payload *stackGitUpdatePayload) Validate(r *http.Request) error {
if govalidator.IsNull(payload.RepositoryReferenceName) {
payload.RepositoryReferenceName = defaultGitReferenceName
}
if err := validateStackAutoUpdate(payload.AutoUpdate); err != nil {
return err
}

View File

@@ -6,7 +6,6 @@ import (
"net/http"
"time"
"github.com/asaskevich/govalidator"
"github.com/pkg/errors"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
@@ -28,9 +27,6 @@ type stackGitRedployPayload struct {
}
func (payload *stackGitRedployPayload) Validate(r *http.Request) error {
if govalidator.IsNull(payload.RepositoryReferenceName) {
payload.RepositoryReferenceName = defaultGitReferenceName
}
return nil
}

View File

@@ -38,9 +38,6 @@ func (payload *kubernetesFileStackUpdatePayload) Validate(r *http.Request) error
}
func (payload *kubernetesGitStackUpdatePayload) Validate(r *http.Request) error {
if govalidator.IsNull(payload.RepositoryReferenceName) {
payload.RepositoryReferenceName = defaultGitReferenceName
}
if err := validateStackAutoUpdate(payload.AutoUpdate); err != nil {
return err
}

View File

@@ -5,21 +5,24 @@ import (
"github.com/gorilla/mux"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/portainer/api"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/demo"
"github.com/portainer/portainer/api/http/security"
)
// Handler is the HTTP handler used to handle status operations.
type Handler struct {
*mux.Router
Status *portainer.Status
Status *portainer.Status
demoService *demo.Service
}
// NewHandler creates a handler to manage status operations.
func NewHandler(bouncer *security.RequestBouncer, status *portainer.Status) *Handler {
func NewHandler(bouncer *security.RequestBouncer, status *portainer.Status, demoService *demo.Service) *Handler {
h := &Handler{
Router: mux.NewRouter(),
Status: status,
Router: mux.NewRouter(),
Status: status,
demoService: demoService,
}
h.Handle("/status",
bouncer.PublicAccess(httperror.LoggerHandler(h.statusInspect))).Methods(http.MethodGet)

View File

@@ -5,16 +5,26 @@ import (
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/demo"
)
type status struct {
*portainer.Status
DemoEnvironment demo.EnvironmentDetails
}
// @id StatusInspect
// @summary Check Portainer status
// @description Retrieve Portainer status
// @description **Access policy**: public
// @tags status
// @produce json
// @success 200 {object} portainer.Status "Success"
// @success 200 {object} status "Success"
// @router /status [get]
func (handler *Handler) statusInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
return response.JSON(w, handler.Status)
return response.JSON(w, &status{
Status: handler.Status,
DemoEnvironment: handler.demoService.Details(),
})
}

View File

@@ -21,14 +21,13 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
h := &Handler{
Router: mux.NewRouter(),
}
h.Handle("/team_memberships",
bouncer.AdminAccess(httperror.LoggerHandler(h.teamMembershipCreate))).Methods(http.MethodPost)
h.Handle("/team_memberships",
bouncer.AdminAccess(httperror.LoggerHandler(h.teamMembershipList))).Methods(http.MethodGet)
h.Handle("/team_memberships/{id}",
bouncer.AdminAccess(httperror.LoggerHandler(h.teamMembershipUpdate))).Methods(http.MethodPut)
h.Handle("/team_memberships/{id}",
bouncer.AdminAccess(httperror.LoggerHandler(h.teamMembershipDelete))).Methods(http.MethodDelete)
h.Use(bouncer.TeamLeaderAccess)
h.Handle("/team_memberships", httperror.LoggerHandler(h.teamMembershipCreate)).Methods(http.MethodPost)
h.Handle("/team_memberships", httperror.LoggerHandler(h.teamMembershipList)).Methods(http.MethodGet)
h.Handle("/team_memberships/{id}", httperror.LoggerHandler(h.teamMembershipUpdate)).Methods(http.MethodPut)
h.Handle("/team_memberships/{id}", httperror.LoggerHandler(h.teamMembershipDelete)).Methods(http.MethodDelete)
return h
}

View File

@@ -5,8 +5,6 @@ import (
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/response"
"github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
)
// @id TeamMembershipList
@@ -23,15 +21,6 @@ import (
// @failure 500 "Server error"
// @router /team_memberships [get]
func (handler *Handler) teamMembershipList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
}
if !securityContext.IsAdmin && !securityContext.IsTeamLeader {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to list team memberships", errors.ErrResourceAccessDenied}
}
memberships, err := handler.DataStore.TeamMembership().TeamMemberships()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve team memberships from the database", err}

View File

@@ -36,8 +36,8 @@ func (payload *teamMembershipUpdatePayload) Validate(r *http.Request) error {
// @id TeamMembershipUpdate
// @summary Update a team membership
// @description Update a team membership. Access is only available to administrators leaders of the associated team.
// @description **Access policy**: administrator
// @description Update a team membership. Access is only available to administrators or leaders of the associated team.
// @description **Access policy**: administrator or leaders of the associated team
// @tags team_memberships
// @security ApiKeyAuth
// @security jwt
@@ -63,15 +63,6 @@ func (handler *Handler) teamMembershipUpdate(w http.ResponseWriter, r *http.Requ
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
}
if !security.AuthorizedTeamManagement(portainer.TeamID(payload.TeamID), securityContext) {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update the membership", httperrors.ErrResourceAccessDenied}
}
membership, err := handler.DataStore.TeamMembership().TeamMembership(portainer.TeamMembershipID(membershipID))
if handler.DataStore.IsErrObjectNotFound(err) {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a team membership with the specified identifier inside the database", err}
@@ -79,8 +70,15 @@ func (handler *Handler) teamMembershipUpdate(w http.ResponseWriter, r *http.Requ
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a team membership with the specified identifier inside the database", err}
}
if securityContext.IsTeamLeader && membership.Role != portainer.MembershipRole(payload.Role) {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update the role of membership", httperrors.ErrResourceAccessDenied}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
}
isLeadingBothTeam := security.AuthorizedTeamManagement(portainer.TeamID(payload.TeamID), securityContext) &&
security.AuthorizedTeamManagement(membership.TeamID, securityContext)
if !(securityContext.IsAdmin || isLeadingBothTeam) {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update the membership", httperrors.ErrResourceAccessDenied}
}
membership.UserID = portainer.UserID(payload.UserID)

View File

@@ -20,18 +20,22 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
h := &Handler{
Router: mux.NewRouter(),
}
h.Handle("/teams",
bouncer.AdminAccess(httperror.LoggerHandler(h.teamCreate))).Methods(http.MethodPost)
h.Handle("/teams",
bouncer.RestrictedAccess(httperror.LoggerHandler(h.teamList))).Methods(http.MethodGet)
h.Handle("/teams/{id}",
bouncer.AdminAccess(httperror.LoggerHandler(h.teamInspect))).Methods(http.MethodGet)
h.Handle("/teams/{id}",
bouncer.AdminAccess(httperror.LoggerHandler(h.teamUpdate))).Methods(http.MethodPut)
h.Handle("/teams/{id}",
bouncer.AdminAccess(httperror.LoggerHandler(h.teamDelete))).Methods(http.MethodDelete)
h.Handle("/teams/{id}/memberships",
bouncer.AdminAccess(httperror.LoggerHandler(h.teamMemberships))).Methods(http.MethodGet)
adminRouter := h.NewRoute().Subrouter()
adminRouter.Use(bouncer.AdminAccess)
restrictedRouter := h.NewRoute().Subrouter()
restrictedRouter.Use(bouncer.RestrictedAccess)
teamLeaderRouter := h.NewRoute().Subrouter()
teamLeaderRouter.Use(bouncer.TeamLeaderAccess)
adminRouter.Handle("/teams", httperror.LoggerHandler(h.teamCreate)).Methods(http.MethodPost)
restrictedRouter.Handle("/teams", httperror.LoggerHandler(h.teamList)).Methods(http.MethodGet)
teamLeaderRouter.Handle("/teams/{id}", httperror.LoggerHandler(h.teamInspect)).Methods(http.MethodGet)
adminRouter.Handle("/teams/{id}", httperror.LoggerHandler(h.teamUpdate)).Methods(http.MethodPut)
adminRouter.Handle("/teams/{id}", httperror.LoggerHandler(h.teamDelete)).Methods(http.MethodDelete)
teamLeaderRouter.Handle("/teams/{id}/memberships", httperror.LoggerHandler(h.teamMemberships)).Methods(http.MethodGet)
return h
}

View File

@@ -14,6 +14,8 @@ import (
type teamCreatePayload struct {
// Name
Name string `example:"developers" validate:"required"`
// TeamLeaders
TeamLeaders []portainer.UserID `example:"3,5"`
}
func (payload *teamCreatePayload) Validate(r *http.Request) error {
@@ -62,5 +64,18 @@ func (handler *Handler) teamCreate(w http.ResponseWriter, r *http.Request) *http
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the team inside the database", err}
}
for _, teamLeader := range payload.TeamLeaders {
membership := &portainer.TeamMembership{
UserID: teamLeader,
TeamID: team.ID,
Role: portainer.TeamLeader,
}
err = handler.DataStore.TeamMembership().Create(membership)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist team leadership inside the database", err}
}
}
return response.JSON(w, team)
}

View File

@@ -9,7 +9,6 @@ import (
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/internal/passwordutils"
)
type adminInitPayload struct {
@@ -58,7 +57,7 @@ func (handler *Handler) adminInit(w http.ResponseWriter, r *http.Request) *httpe
return &httperror.HandlerError{http.StatusConflict, "Unable to create administrator user", errAdminAlreadyInitialized}
}
if !passwordutils.StrengthCheck(payload.Password) {
if !handler.passwordStrengthChecker.Check(payload.Password) {
return &httperror.HandlerError{http.StatusBadRequest, "Password does not meet the requirements", nil}
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/portainer/portainer/api/apikey"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/demo"
"github.com/portainer/portainer/api/http/security"
"net/http"
@@ -30,43 +31,51 @@ func hideFields(user *portainer.User) {
// Handler is the HTTP handler used to handle user operations.
type Handler struct {
*mux.Router
bouncer *security.RequestBouncer
apiKeyService apikey.APIKeyService
DataStore dataservices.DataStore
CryptoService portainer.CryptoService
bouncer *security.RequestBouncer
apiKeyService apikey.APIKeyService
demoService *demo.Service
DataStore dataservices.DataStore
CryptoService portainer.CryptoService
passwordStrengthChecker security.PasswordStrengthChecker
}
// NewHandler creates a handler to manage user operations.
func NewHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimiter, apiKeyService apikey.APIKeyService) *Handler {
func NewHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimiter, apiKeyService apikey.APIKeyService, demoService *demo.Service, passwordStrengthChecker security.PasswordStrengthChecker) *Handler {
h := &Handler{
Router: mux.NewRouter(),
bouncer: bouncer,
apiKeyService: apiKeyService,
Router: mux.NewRouter(),
bouncer: bouncer,
apiKeyService: apiKeyService,
demoService: demoService,
passwordStrengthChecker: passwordStrengthChecker,
}
h.Handle("/users",
bouncer.AdminAccess(httperror.LoggerHandler(h.userCreate))).Methods(http.MethodPost)
h.Handle("/users",
bouncer.RestrictedAccess(httperror.LoggerHandler(h.userList))).Methods(http.MethodGet)
h.Handle("/users/{id}",
bouncer.RestrictedAccess(httperror.LoggerHandler(h.userInspect))).Methods(http.MethodGet)
h.Handle("/users/{id}",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.userUpdate))).Methods(http.MethodPut)
h.Handle("/users/{id}",
bouncer.AdminAccess(httperror.LoggerHandler(h.userDelete))).Methods(http.MethodDelete)
h.Handle("/users/{id}/tokens",
bouncer.RestrictedAccess(httperror.LoggerHandler(h.userGetAccessTokens))).Methods(http.MethodGet)
h.Handle("/users/{id}/tokens",
rateLimiter.LimitAccess(bouncer.RestrictedAccess(httperror.LoggerHandler(h.userCreateAccessToken)))).Methods(http.MethodPost)
h.Handle("/users/{id}/tokens/{keyID}",
bouncer.RestrictedAccess(httperror.LoggerHandler(h.userRemoveAccessToken))).Methods(http.MethodDelete)
h.Handle("/users/{id}/memberships",
bouncer.RestrictedAccess(httperror.LoggerHandler(h.userMemberships))).Methods(http.MethodGet)
h.Handle("/users/{id}/passwd",
rateLimiter.LimitAccess(bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.userUpdatePassword)))).Methods(http.MethodPut)
h.Handle("/users/admin/check",
bouncer.PublicAccess(httperror.LoggerHandler(h.adminCheck))).Methods(http.MethodGet)
h.Handle("/users/admin/init",
bouncer.PublicAccess(httperror.LoggerHandler(h.adminInit))).Methods(http.MethodPost)
adminRouter := h.NewRoute().Subrouter()
adminRouter.Use(bouncer.AdminAccess)
teamLeaderRouter := h.NewRoute().Subrouter()
teamLeaderRouter.Use(bouncer.TeamLeaderAccess)
restrictedRouter := h.NewRoute().Subrouter()
restrictedRouter.Use(bouncer.RestrictedAccess)
authenticatedRouter := h.NewRoute().Subrouter()
authenticatedRouter.Use(bouncer.AuthenticatedAccess)
publicRouter := h.NewRoute().Subrouter()
publicRouter.Use(bouncer.PublicAccess)
adminRouter.Handle("/users", httperror.LoggerHandler(h.userCreate)).Methods(http.MethodPost)
restrictedRouter.Handle("/users", httperror.LoggerHandler(h.userList)).Methods(http.MethodGet)
restrictedRouter.Handle("/users/{id}", httperror.LoggerHandler(h.userInspect)).Methods(http.MethodGet)
authenticatedRouter.Handle("/users/{id}", httperror.LoggerHandler(h.userUpdate)).Methods(http.MethodPut)
adminRouter.Handle("/users/{id}", httperror.LoggerHandler(h.userDelete)).Methods(http.MethodDelete)
restrictedRouter.Handle("/users/{id}/tokens", httperror.LoggerHandler(h.userGetAccessTokens)).Methods(http.MethodGet)
restrictedRouter.Handle("/users/{id}/tokens", rateLimiter.LimitAccess(httperror.LoggerHandler(h.userCreateAccessToken))).Methods(http.MethodPost)
restrictedRouter.Handle("/users/{id}/tokens/{keyID}", httperror.LoggerHandler(h.userRemoveAccessToken)).Methods(http.MethodDelete)
restrictedRouter.Handle("/users/{id}/memberships", httperror.LoggerHandler(h.userMemberships)).Methods(http.MethodGet)
authenticatedRouter.Handle("/users/{id}/passwd", rateLimiter.LimitAccess(httperror.LoggerHandler(h.userUpdatePassword))).Methods(http.MethodPut)
publicRouter.Handle("/users/admin/check", httperror.LoggerHandler(h.adminCheck)).Methods(http.MethodGet)
publicRouter.Handle("/users/admin/init", httperror.LoggerHandler(h.adminInit)).Methods(http.MethodPost)
return h
}

View File

@@ -9,9 +9,6 @@ import (
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/passwordutils"
)
type userCreatePayload struct {
@@ -35,8 +32,7 @@ func (payload *userCreatePayload) Validate(r *http.Request) error {
// @id UserCreate
// @summary Create a new user
// @description Create a new Portainer user.
// @description Only team leaders and administrators can create users.
// @description Only administrators can create an administrator user account.
// @description Only administrators can create users.
// @description **Access policy**: restricted
// @tags users
// @security ApiKeyAuth
@@ -57,19 +53,6 @@ func (handler *Handler) userCreate(w http.ResponseWriter, r *http.Request) *http
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
}
if !securityContext.IsAdmin && !securityContext.IsTeamLeader {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to create user", httperrors.ErrResourceAccessDenied}
}
if securityContext.IsTeamLeader && payload.Role == 1 {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to create administrator user", httperrors.ErrResourceAccessDenied}
}
user, err := handler.DataStore.User().UserByUsername(payload.Username)
if err != nil && !handler.DataStore.IsErrObjectNotFound(err) {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve users from the database", err}
@@ -95,7 +78,7 @@ func (handler *Handler) userCreate(w http.ResponseWriter, r *http.Request) *http
}
if settings.AuthenticationMethod == portainer.AuthenticationInternal {
if !passwordutils.StrengthCheck(payload.Password) {
if !handler.passwordStrengthChecker.Check(payload.Password) {
return &httperror.HandlerError{http.StatusBadRequest, "Password does not meet the requirements", nil}
}

View File

@@ -39,8 +39,9 @@ func Test_userCreateAccessToken(t *testing.T) {
apiKeyService := apikey.NewAPIKeyService(store.APIKeyRepository(), store.User())
requestBouncer := security.NewRequestBouncer(store, jwtService, apiKeyService)
rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour)
passwordChecker := security.NewPasswordStrengthChecker(store.SettingsService)
h := NewHandler(requestBouncer, rateLimiter, apiKeyService)
h := NewHandler(requestBouncer, rateLimiter, apiKeyService, nil, passwordChecker)
h.DataStore = store
// generate standard and admin user tokens

View File

@@ -31,8 +31,9 @@ func Test_deleteUserRemovesAccessTokens(t *testing.T) {
apiKeyService := apikey.NewAPIKeyService(store.APIKeyRepository(), store.User())
requestBouncer := security.NewRequestBouncer(store, jwtService, apiKeyService)
rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour)
passwordChecker := security.NewPasswordStrengthChecker(store.SettingsService)
h := NewHandler(requestBouncer, rateLimiter, apiKeyService)
h := NewHandler(requestBouncer, rateLimiter, apiKeyService, nil, passwordChecker)
h.DataStore = store
t.Run("standard user deletion removes all associated access tokens", func(t *testing.T) {

View File

@@ -38,8 +38,9 @@ func Test_userGetAccessTokens(t *testing.T) {
apiKeyService := apikey.NewAPIKeyService(store.APIKeyRepository(), store.User())
requestBouncer := security.NewRequestBouncer(store, jwtService, apiKeyService)
rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour)
passwordChecker := security.NewPasswordStrengthChecker(store.SettingsService)
h := NewHandler(requestBouncer, rateLimiter, apiKeyService)
h := NewHandler(requestBouncer, rateLimiter, apiKeyService, nil, passwordChecker)
h.DataStore = store
// generate standard and admin user tokens

View File

@@ -36,8 +36,9 @@ func Test_userRemoveAccessToken(t *testing.T) {
apiKeyService := apikey.NewAPIKeyService(store.APIKeyRepository(), store.User())
requestBouncer := security.NewRequestBouncer(store, jwtService, apiKeyService)
rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour)
passwordChecker := security.NewPasswordStrengthChecker(store.SettingsService)
h := NewHandler(requestBouncer, rateLimiter, apiKeyService)
h := NewHandler(requestBouncer, rateLimiter, apiKeyService, nil, passwordChecker)
h.DataStore = store
// generate standard and admin user tokens

View File

@@ -57,6 +57,10 @@ func (handler *Handler) userUpdate(w http.ResponseWriter, r *http.Request) *http
return &httperror.HandlerError{http.StatusBadRequest, "Invalid user identifier route variable", err}
}
if handler.demoService.IsDemoUser(portainer.UserID(userID)) {
return &httperror.HandlerError{http.StatusForbidden, httperrors.ErrNotAvailableInDemo.Error(), httperrors.ErrNotAvailableInDemo}
}
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err}

View File

@@ -12,7 +12,6 @@ import (
portainer "github.com/portainer/portainer/api"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/passwordutils"
)
type userUpdatePasswordPayload struct {
@@ -55,6 +54,10 @@ func (handler *Handler) userUpdatePassword(w http.ResponseWriter, r *http.Reques
return &httperror.HandlerError{http.StatusBadRequest, "Invalid user identifier route variable", err}
}
if handler.demoService.IsDemoUser(portainer.UserID(userID)) {
return &httperror.HandlerError{http.StatusForbidden, httperrors.ErrNotAvailableInDemo.Error(), httperrors.ErrNotAvailableInDemo}
}
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err}
@@ -79,10 +82,10 @@ func (handler *Handler) userUpdatePassword(w http.ResponseWriter, r *http.Reques
err = handler.CryptoService.CompareHashAndData(user.Password, payload.Password)
if err != nil {
return &httperror.HandlerError{http.StatusForbidden, "Specified password do not match actual password", httperrors.ErrUnauthorized}
return &httperror.HandlerError{http.StatusForbidden, "Current password doesn't match", errors.New("Current password does not match the password provided. Please try again")}
}
if !passwordutils.StrengthCheck(payload.NewPassword) {
if !handler.passwordStrengthChecker.Check(payload.NewPassword) {
return &httperror.HandlerError{http.StatusBadRequest, "Password does not meet the requirements", nil}
}

View File

@@ -31,8 +31,9 @@ func Test_updateUserRemovesAccessTokens(t *testing.T) {
apiKeyService := apikey.NewAPIKeyService(store.APIKeyRepository(), store.User())
requestBouncer := security.NewRequestBouncer(store, jwtService, apiKeyService)
rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour)
passwordChecker := security.NewPasswordStrengthChecker(store.SettingsService)
h := NewHandler(requestBouncer, rateLimiter, apiKeyService)
h := NewHandler(requestBouncer, rateLimiter, apiKeyService, nil, passwordChecker)
h.DataStore = store
t.Run("standard user deletion removes all associated access tokens", func(t *testing.T) {

View File

@@ -0,0 +1,23 @@
package middlewares
import (
"net/http"
"github.com/gorilla/mux"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/portainer/api/http/errors"
)
// restrict functionality on demo environments
func RestrictDemoEnv(isDemo func() bool) mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !isDemo() {
next.ServeHTTP(w, r)
return
}
httperror.WriteError(w, http.StatusBadRequest, errors.ErrNotAvailableInDemo.Error(), errors.ErrNotAvailableInDemo)
})
}
}

View File

@@ -0,0 +1,41 @@
package middlewares
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func Test_demoEnvironment_shouldFail(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{}`))
w := httptest.NewRecorder()
h := http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {})
RestrictDemoEnv(func() bool { return true }).Middleware(h).ServeHTTP(w, r)
response := w.Result()
defer response.Body.Close()
assert.Equal(t, http.StatusBadRequest, response.StatusCode)
body, _ := io.ReadAll(response.Body)
assert.Contains(t, string(body), "This feature is not available in the demo version of Portainer")
}
func Test_notDemoEnvironment_shouldSucceed(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{}`))
w := httptest.NewRecorder()
h := http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {})
RestrictDemoEnv(func() bool { return false }).Middleware(h).ServeHTTP(w, r)
response := w.Result()
assert.Equal(t, http.StatusOK, response.StatusCode)
}

View File

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

View File

@@ -86,7 +86,7 @@ func findSystemNetworkResourceControl(networkObject map[string]interface{}) *por
networkID := networkObject[networkObjectIdentifier].(string)
networkName := networkObject[networkObjectName].(string)
if networkName == "bridge" || networkName == "host" || networkName == "none" {
if networkName == "bridge" || networkName == "host" || networkName == "ingress" || networkName == "nat" || networkName == "none" {
return authorization.NewSystemResourceControl(networkID, portainer.NetworkResourceControl)
}

View File

@@ -23,7 +23,7 @@ type (
}
portainerRegistryAuthenticationHeader struct {
RegistryId portainer.RegistryID `json:"registryId"`
RegistryId *portainer.RegistryID `json:"registryId"`
}
)

View File

@@ -35,6 +35,7 @@ type (
signatureService portainer.DigitalSignatureService
reverseTunnelService portainer.ReverseTunnelService
dockerClientFactory *docker.ClientFactory
gitService portainer.GitService
}
// TransportParameters is used to create a new Transport
@@ -62,7 +63,7 @@ type (
)
// NewTransport returns a pointer to a new Transport instance.
func NewTransport(parameters *TransportParameters, httpTransport *http.Transport) (*Transport, error) {
func NewTransport(parameters *TransportParameters, httpTransport *http.Transport, gitService portainer.GitService) (*Transport, error) {
transport := &Transport{
endpoint: parameters.Endpoint,
dataStore: parameters.DataStore,
@@ -70,6 +71,7 @@ func NewTransport(parameters *TransportParameters, httpTransport *http.Transport
reverseTunnelService: parameters.ReverseTunnelService,
dockerClientFactory: parameters.DockerClientFactory,
HTTPTransport: httpTransport,
gitService: gitService,
}
return transport, nil
@@ -381,9 +383,31 @@ func (transport *Transport) proxyTaskRequest(request *http.Request) (*http.Respo
}
func (transport *Transport) proxyBuildRequest(request *http.Request) (*http.Response, error) {
err := transport.updateDefaultGitBranch(request)
if err != nil {
return nil, err
}
return transport.interceptAndRewriteRequest(request, buildOperation)
}
func (transport *Transport) updateDefaultGitBranch(request *http.Request) error {
remote := request.URL.Query().Get("remote")
if strings.HasSuffix(remote, ".git") {
repositoryURL := remote[:len(remote)-4]
latestCommitID, err := transport.gitService.LatestCommitID(repositoryURL, "", "", "")
if err != nil {
return err
}
newRemote := fmt.Sprintf("%s#%s", remote, latestCommitID)
q := request.URL.Query()
q.Set("remote", newRemote)
request.URL.RawQuery = q.Encode()
}
return nil
}
func (transport *Transport) proxyImageRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath {
case "/images/create":
@@ -422,7 +446,20 @@ func (transport *Transport) decorateRegistryAuthenticationHeader(request *http.R
return err
}
authenticationHeader, err := createRegistryAuthenticationHeader(transport.dataStore, originalHeaderData.RegistryId, accessContext)
// delete header and exist function without error if Front End
// passes empty json. This is to restore original behavior which
// never originally passed this header
if string(decodedHeaderData) == "{}" {
request.Header.Del("X-Registry-Auth")
return nil
}
// only set X-Registry-Auth if registryId is defined
if originalHeaderData.RegistryId == nil {
return nil
}
authenticationHeader, err := createRegistryAuthenticationHeader(transport.dataStore, *originalHeaderData.RegistryId, accessContext)
if err != nil {
return err
}

View File

@@ -0,0 +1,73 @@
package docker
import (
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/assert"
"net/http"
"net/http/httptest"
"testing"
)
type noopGitService struct{}
func (s *noopGitService) CloneRepository(destination string, repositoryURL, referenceName, username, password string) error {
return nil
}
func (s *noopGitService) LatestCommitID(repositoryURL, referenceName, username, password string) (string, error) {
return "my-latest-commit-id", nil
}
func TestTransport_updateDefaultGitBranch(t *testing.T) {
type fields struct {
gitService portainer.GitService
}
type args struct {
request *http.Request
}
defaultFields := fields{
gitService: &noopGitService{},
}
tests := []struct {
name string
fields fields
args args
wantErr bool
expectedQuery string
}{
{
name: "append commit ID",
fields: defaultFields,
args: args{
request: httptest.NewRequest(http.MethodPost, "http://unixsocket/build?dockerfile=Dockerfile&remote=https://my-host.com/my-user/my-repo.git&t=my-image", nil),
},
wantErr: false,
expectedQuery: "dockerfile=Dockerfile&remote=https%3A%2F%2Fmy-host.com%2Fmy-user%2Fmy-repo.git%23my-latest-commit-id&t=my-image",
},
{
name: "not append commit ID",
fields: defaultFields,
args: args{
request: httptest.NewRequest(http.MethodPost, "http://unixsocket/build?dockerfile=Dockerfile&remote=https://my-host.com/my-user/my-repo/my-file&t=my-image", nil),
},
wantErr: false,
expectedQuery: "dockerfile=Dockerfile&remote=https://my-host.com/my-user/my-repo/my-file&t=my-image",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
transport := &Transport{
gitService: tt.fields.gitService,
}
err := transport.updateDefaultGitBranch(tt.args.request)
if (err != nil) != tt.wantErr {
t.Errorf("updateDefaultGitBranch() error = %v, wantErr %v", err, tt.wantErr)
return
}
assert.Equal(t, tt.expectedQuery, tt.args.request.URL.RawQuery)
})
}
}

View File

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

View File

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

View File

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

View File

@@ -25,11 +25,11 @@ type (
)
// NewManager initializes a new proxy Service
func NewManager(dataStore dataservices.DataStore, signatureService portainer.DigitalSignatureService, tunnelService portainer.ReverseTunnelService, clientFactory *docker.ClientFactory, kubernetesClientFactory *cli.ClientFactory, kubernetesTokenCacheManager *kubernetes.TokenCacheManager) *Manager {
func NewManager(dataStore dataservices.DataStore, signatureService portainer.DigitalSignatureService, tunnelService portainer.ReverseTunnelService, clientFactory *docker.ClientFactory, kubernetesClientFactory *cli.ClientFactory, kubernetesTokenCacheManager *kubernetes.TokenCacheManager, gitService portainer.GitService) *Manager {
return &Manager{
endpointProxies: cmap.New(),
k8sClientFactory: kubernetesClientFactory,
proxyFactory: factory.NewProxyFactory(dataStore, signatureService, tunnelService, clientFactory, kubernetesClientFactory, kubernetesTokenCacheManager),
proxyFactory: factory.NewProxyFactory(dataStore, signatureService, tunnelService, clientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService),
}
}

View File

@@ -103,6 +103,16 @@ func AuthorizedTeamManagement(teamID portainer.TeamID, context *RestrictedReques
return false
}
// AuthorizedIsTeamLeader ensure that the user is an admin or a team leader
func AuthorizedIsTeamLeader(context *RestrictedRequestContext) bool {
return context.IsAdmin || context.IsTeamLeader
}
// AuthorizedIsAdmin ensure that the user is an admin
func AuthorizedIsAdmin(context *RestrictedRequestContext) bool {
return context.IsAdmin
}
// authorizedEndpointAccess ensure that the user can access the specified environment(endpoint).
// It will check if the user is part of the authorized users or part of a team that is
// listed in the authorized teams of the environment(endpoint) and the associated group.

View File

@@ -78,6 +78,19 @@ func (bouncer *RequestBouncer) RestrictedAccess(h http.Handler) http.Handler {
return h
}
// TeamLeaderAccess defines a security check for APIs require team leader privilege
//
// Bouncer operations are applied backwards:
// - Parse the JWT from the request and stored in context, user has to be authenticated
// - Upgrade to the restricted request
// - User is admin or team leader
func (bouncer *RequestBouncer) TeamLeaderAccess(h http.Handler) http.Handler {
h = bouncer.mwIsTeamLeader(h)
h = bouncer.mwUpgradeToRestrictedRequest(h)
h = bouncer.mwAuthenticatedUser(h)
return h
}
// AuthenticatedAccess defines a security check for restricted API environments(endpoints).
// Authentication is required to access these environments(endpoints).
// The request context will be enhanced with a RestrictedRequestContext object
@@ -219,6 +232,24 @@ func (bouncer *RequestBouncer) mwUpgradeToRestrictedRequest(next http.Handler) h
})
}
// mwIsTeamLeader will verify that the user is an admin or a team leader
func (bouncer *RequestBouncer) mwIsTeamLeader(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
securityContext, err := RetrieveRestrictedRequestContext(r)
if err != nil {
httperror.WriteError(w, http.StatusInternalServerError, "Unable to retrieve restricted request context ", err)
return
}
if !securityContext.IsAdmin && !securityContext.IsTeamLeader {
httperror.WriteError(w, http.StatusForbidden, "Access denied", httperrors.ErrUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
// mwAuthenticateFirst authenticates a request an auth token.
// A result of a first succeded token lookup would be used for the authentication.
func (bouncer *RequestBouncer) mwAuthenticateFirst(tokenLookups []tokenLookup, next http.Handler) http.Handler {

View File

@@ -81,11 +81,11 @@ func FilterRegistries(registries []portainer.Registry, user *portainer.User, tea
}
// FilterEndpoints filters environments(endpoints) based on user role and team memberships.
// Non administrator users only have access to authorized environments(endpoints) (can be inherited via endpoint groups).
// Non administrator and non-team-leader only have access to authorized environments(endpoints) (can be inherited via endpoint groups).
func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.EndpointGroup, context *RestrictedRequestContext) []portainer.Endpoint {
filteredEndpoints := endpoints
if !context.IsAdmin {
if !context.IsAdmin && !context.IsTeamLeader {
filteredEndpoints = make([]portainer.Endpoint, 0)
for _, endpoint := range endpoints {
@@ -101,11 +101,11 @@ func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.Endpoint
}
// FilterEndpointGroups filters environment(endpoint) groups based on user role and team memberships.
// Non administrator users only have access to authorized environment(endpoint) groups.
// Non administrator users and Non-team-leaders only have access to authorized environment(endpoint) groups.
func FilterEndpointGroups(endpointGroups []portainer.EndpointGroup, context *RestrictedRequestContext) []portainer.EndpointGroup {
filteredEndpointGroups := endpointGroups
if !context.IsAdmin {
if !context.IsAdmin && !context.IsTeamLeader {
filteredEndpointGroups = make([]portainer.EndpointGroup, 0)
for _, group := range endpointGroups {

View File

@@ -0,0 +1,35 @@
package security
import (
portainer "github.com/portainer/portainer/api"
"github.com/sirupsen/logrus"
)
type PasswordStrengthChecker interface {
Check(password string) bool
}
type passwordStrengthChecker struct {
settings settingsService
}
func NewPasswordStrengthChecker(settings settingsService) *passwordStrengthChecker {
return &passwordStrengthChecker{
settings: settings,
}
}
// Check returns true if the password is strong enough
func (c *passwordStrengthChecker) Check(password string) bool {
s, err := c.settings.Settings()
if err != nil {
logrus.WithError(err).Warn("failed to fetch Portainer settings to validate user password")
return true
}
return len(password) >= s.InternalAuthSettings.RequiredPasswordLength
}
type settingsService interface {
Settings() (*portainer.Settings, error)
}

View File

@@ -1,8 +1,14 @@
package passwordutils
package security
import "testing"
import (
"testing"
portainer "github.com/portainer/portainer/api"
)
func TestStrengthCheck(t *testing.T) {
checker := NewPasswordStrengthChecker(settingsStub{minLength: 12})
type args struct {
password string
}
@@ -13,9 +19,9 @@ func TestStrengthCheck(t *testing.T) {
}{
{"Empty password", args{""}, false},
{"Short password", args{"portainer"}, false},
{"Short password", args{"portaienr!@#"}, false},
{"Short password", args{"portaienr!@#"}, true},
{"Week password", args{"12345678!@#"}, false},
{"Week password", args{"portaienr123"}, false},
{"Week password", args{"portaienr123"}, true},
{"Good password", args{"Portainer123"}, true},
{"Good password", args{"Portainer___"}, true},
{"Good password", args{"^portainer12"}, true},
@@ -23,9 +29,21 @@ func TestStrengthCheck(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if gotStrong := StrengthCheck(tt.args.password); gotStrong != tt.wantStrong {
if gotStrong := checker.Check(tt.args.password); gotStrong != tt.wantStrong {
t.Errorf("StrengthCheck() = %v, want %v", gotStrong, tt.wantStrong)
}
})
}
}
type settingsStub struct {
minLength int
}
func (s settingsStub) Settings() (*portainer.Settings, error) {
return &portainer.Settings{
InternalAuthSettings: portainer.InternalAuthSettings{
RequiredPasswordLength: s.minLength,
},
}, nil
}

View File

@@ -15,6 +15,7 @@ import (
"github.com/portainer/portainer/api/apikey"
"github.com/portainer/portainer/api/crypto"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/demo"
"github.com/portainer/portainer/api/docker"
"github.com/portainer/portainer/api/http/handler"
"github.com/portainer/portainer/api/http/handler/auth"
@@ -98,6 +99,7 @@ type Server struct {
ShutdownCtx context.Context
ShutdownTrigger context.CancelFunc
StackDeployer stackdeployer.StackDeployer
DemoService *demo.Service
}
// Start starts the HTTP server
@@ -109,7 +111,9 @@ func (server *Server) Start() error {
rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour)
offlineGate := offlinegate.NewOfflineGate()
var authHandler = auth.NewHandler(requestBouncer, rateLimiter)
passwordStrengthChecker := security.NewPasswordStrengthChecker(server.DataStore.Settings())
var authHandler = auth.NewHandler(requestBouncer, rateLimiter, passwordStrengthChecker)
authHandler.DataStore = server.DataStore
authHandler.CryptoService = server.CryptoService
authHandler.JWTService = server.JWTService
@@ -121,7 +125,15 @@ func (server *Server) Start() error {
adminMonitor := adminmonitor.New(5*time.Minute, server.DataStore, server.ShutdownCtx)
adminMonitor.Start()
var backupHandler = backup.NewHandler(requestBouncer, server.DataStore, offlineGate, server.FileService.GetDatastorePath(), server.ShutdownTrigger, adminMonitor)
var backupHandler = backup.NewHandler(
requestBouncer,
server.DataStore,
offlineGate,
server.FileService.GetDatastorePath(),
server.ShutdownTrigger,
adminMonitor,
server.DemoService,
)
var roleHandler = roles.NewHandler(requestBouncer)
roleHandler.DataStore = server.DataStore
@@ -147,7 +159,7 @@ func (server *Server) Start() error {
var edgeTemplatesHandler = edgetemplates.NewHandler(requestBouncer)
edgeTemplatesHandler.DataStore = server.DataStore
var endpointHandler = endpoints.NewHandler(requestBouncer)
var endpointHandler = endpoints.NewHandler(requestBouncer, server.DemoService)
endpointHandler.DataStore = server.DataStore
endpointHandler.FileService = server.FileService
endpointHandler.ProxyManager = server.ProxyManager
@@ -194,7 +206,7 @@ func (server *Server) Start() error {
var resourceControlHandler = resourcecontrols.NewHandler(requestBouncer)
resourceControlHandler.DataStore = server.DataStore
var settingsHandler = settings.NewHandler(requestBouncer)
var settingsHandler = settings.NewHandler(requestBouncer, server.DemoService)
settingsHandler.DataStore = server.DataStore
settingsHandler.FileService = server.FileService
settingsHandler.JWTService = server.JWTService
@@ -234,7 +246,7 @@ func (server *Server) Start() error {
var teamMembershipHandler = teammemberships.NewHandler(requestBouncer)
teamMembershipHandler.DataStore = server.DataStore
var statusHandler = status.NewHandler(requestBouncer, server.Status)
var statusHandler = status.NewHandler(requestBouncer, server.Status, server.DemoService)
var templatesHandler = templates.NewHandler(requestBouncer)
templatesHandler.DataStore = server.DataStore
@@ -244,7 +256,7 @@ func (server *Server) Start() error {
var uploadHandler = upload.NewHandler(requestBouncer)
uploadHandler.FileService = server.FileService
var userHandler = users.NewHandler(requestBouncer, rateLimiter, server.APIKeyService)
var userHandler = users.NewHandler(requestBouncer, rateLimiter, server.APIKeyService, server.DemoService, passwordStrengthChecker)
userHandler.DataStore = server.DataStore
userHandler.CryptoService = server.CryptoService

View File

@@ -1,33 +0,0 @@
package passwordutils
import (
"regexp"
)
const MinPasswordLen = 12
func lengthCheck(password string) bool {
return len(password) >= MinPasswordLen
}
func comboCheck(password string) bool {
count := 0
regexps := [4]*regexp.Regexp{
regexp.MustCompile(`[a-z]`),
regexp.MustCompile(`[A-Z]`),
regexp.MustCompile(`[0-9]`),
regexp.MustCompile(`[\W_]`),
}
for _, re := range regexps {
if re.FindString(password) != "" {
count += 1
}
}
return count >= 3
}
func StrengthCheck(password string) bool {
return lengthCheck(password) && comboCheck(password)
}

View File

@@ -37,7 +37,7 @@ func parseRegToken(registry *portainer.Registry) (username, password string, err
func EnsureRegTokenValid(dataStore dataservices.DataStore, registry *portainer.Registry) (err error) {
if registry.Type == portainer.EcrRegistry {
if isRegTokenValid(registry) {
log.Println("[DEBUG] [registry, GetEcrAccessToken] [message: curretn ECR token is still valid]")
log.Println("[DEBUG] [registry, GetEcrAccessToken] [message: current ECR token is still valid]")
} else {
err = doGetRegToken(dataStore, registry)
if err != nil {

View File

@@ -188,24 +188,17 @@ func (service *Service) snapshotEndpoints() error {
// FetchDockerID fetches info.Swarm.Cluster.ID if environment(endpoint) is swarm and info.ID otherwise
func FetchDockerID(snapshot portainer.DockerSnapshot) (string, error) {
info, done := snapshot.SnapshotRaw.Info.(map[string]interface{})
if !done {
return "", errors.New("failed getting snapshot info")
}
info := snapshot.SnapshotRaw.Info
if !snapshot.Swarm {
return info["ID"].(string), nil
return info.ID, nil
}
if info["Swarm"] == nil {
return "", errors.New("swarm environment is missing swarm info snapshot")
}
swarmInfo := info["Swarm"].(map[string]interface{})
if swarmInfo["Cluster"] == nil {
swarmInfo := info.Swarm
if swarmInfo.Cluster == nil {
return "", errors.New("swarm environment is missing cluster info snapshot")
}
clusterInfo := swarmInfo["Cluster"].(map[string]interface{})
return clusterInfo["ID"].(string), nil
clusterInfo := swarmInfo.Cluster
return clusterInfo.ID, nil
}

View File

@@ -5,6 +5,8 @@ import (
"io"
"time"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/volume"
gittypes "github.com/portainer/portainer/api/git/types"
v1 "k8s.io/api/core/v1"
)
@@ -102,6 +104,7 @@ type (
Assets *string
Data *string
FeatureFlags *[]Pair
DemoEnvironment *bool
EnableEdgeComputeFeatures *bool
EndpointURL *string
Labels *[]Pair
@@ -127,6 +130,14 @@ type (
SecretKeyName *string
}
// CustomTemplateVariableDefinition
CustomTemplateVariableDefinition struct {
Name string `json:"name" example:"MY_VAR"`
Label string `json:"label" example:"My Variable"`
DefaultValue string `json:"defaultValue" example:"default value"`
Description string `json:"description" example:"Description"`
}
// CustomTemplate represents a custom template
CustomTemplate struct {
// CustomTemplate Identifier
@@ -151,6 +162,7 @@ type (
// Type of created stack (1 - swarm, 2 - compose)
Type StackType `json:"Type" example:"1"`
ResourceControl *ResourceControl `json:"ResourceControl"`
Variables []CustomTemplateVariableDefinition
}
// CustomTemplateID represents a custom template identifier
@@ -190,13 +202,14 @@ type (
}
// DockerSnapshotRaw represents all the information related to a snapshot as returned by the Docker API
DockerSnapshotRaw struct {
Containers interface{} `json:"Containers"`
Volumes interface{} `json:"Volumes"`
Networks interface{} `json:"Networks"`
Images interface{} `json:"Images"`
Info interface{} `json:"Info"`
Version interface{} `json:"Version"`
Containers []types.Container `json:"Containers" swaggerignore:"true"`
Volumes volume.VolumeListOKBody `json:"Volumes" swaggerignore:"true"`
Networks []types.NetworkResource `json:"Networks" swaggerignore:"true"`
Images []types.ImageSummary `json:"Images" swaggerignore:"true"`
Info types.Info `json:"Info" swaggerignore:"true"`
Version types.Version `json:"Version" swaggerignore:"true"`
}
// EdgeGroup represents an Edge group
@@ -536,6 +549,11 @@ type (
ShellExecCommand string
}
// InternalAuthSettings represents settings used for the default 'internal' authentication
InternalAuthSettings struct {
RequiredPasswordLength int
}
// LDAPGroupSearchSettings represents settings used to search for groups in a LDAP server
LDAPGroupSearchSettings struct {
// The distinguished name of the element from which the LDAP server will search for groups
@@ -786,6 +804,7 @@ type (
BlackListedLabels []Pair `json:"BlackListedLabels"`
// Active authentication method for the Portainer instance. Valid values are: 1 for internal, 2 for LDAP, or 3 for oauth
AuthenticationMethod AuthenticationMethod `json:"AuthenticationMethod" example:"1"`
InternalAuthSettings InternalAuthSettings `json:"InternalAuthSettings" example:""`
LDAPSettings LDAPSettings `json:"LDAPSettings" example:""`
OAuthSettings OAuthSettings `json:"OAuthSettings" example:""`
OpenAMTConfiguration OpenAMTConfiguration `json:"openAMTConfiguration" example:""`
@@ -1344,9 +1363,9 @@ type (
const (
// APIVersion is the version number of the Portainer API
APIVersion = "2.13.0"
APIVersion = "2.15.0"
// DBVersion is the version number of the Portainer database
DBVersion = 35
DBVersion = 60
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
ComposeSyntaxMaxVersion = "3.9"
// AssetsServerURL represents the URL of the Portainer asset server

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,6 @@
import _ from 'lodash-es';
import _ from 'lodash';
import { useSettings } from '@/portainer/settings/queries';
const categories = [
'docker',
@@ -61,6 +63,18 @@ export function push(
}
}
export function useAnalytics() {
const telemetryQuery = useSettings((settings) => settings.EnableTelemetry);
return { trackEvent: handleTrackEvent };
function handleTrackEvent(...args: Parameters<typeof trackEvent>) {
if (telemetryQuery.data) {
trackEvent(...args);
}
}
}
export function trackEvent(action: string, properties: TrackEventProps) {
/**
* @description Logs an event with an event category (Videos, Music, Games...), an event

View File

@@ -2,6 +2,10 @@
@tailwind components;
@tailwind utilities;
html {
font-size: 16px;
}
html,
body,
#page-wrapper,
@@ -217,8 +221,8 @@ a[ng-click] {
}
.blocklist-item {
padding: 0.7rem;
margin-bottom: 0.7rem;
padding: 7px;
margin-bottom: 7px;
cursor: pointer;
border: 1px solid var(--border-blocklist-color);
border-radius: 2px;
@@ -803,13 +807,6 @@ json-tree .branch-preview {
}
/* !spinkit override */
/* uib-typeahead override */
#scrollable-dropdown-menu .dropdown-menu {
max-height: 300px;
overflow-y: auto;
}
/* !uib-typeahead override */
.kubectl-shell {
display: block;
text-align: center;

View File

@@ -20,3 +20,4 @@ import './app.css';
import './theme.css';
import './vendor-override.css';
import '../fonts/nomad-icon.css';

View File

@@ -0,0 +1,32 @@
/* created using https://icomoon.io/app */
/* https://stackoverflow.com/a/35092005/681629 */
/* for additional icons, we should create a new set that includes the existing icons */
@font-face {
font-family: 'nomad-icon';
src: url('nomad-icon/nomad-icon.eot?6tre2n');
src: url('nomad-icon/nomad-icon.eot?6tre2n#iefix') format('embedded-opentype'), url('nomad-icon/nomad-icon.ttf?6tre2n') format('truetype'),
url('nomad-icon/nomad-icon.woff?6tre2n') format('woff'), url('nomad-icon/nomad-icon.svg?6tre2n#nomad-icon') format('svg');
font-weight: normal;
font-style: normal;
font-display: block;
}
.nomad-icon {
/* use !important to prevent issues with browser extensions that change fonts */
font-family: 'nomad-icon' !important;
speak: never;
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
/* Better Font Rendering =========== */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.nomad-icon:before {
content: '\e900';
}

Binary file not shown.

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" >
<svg xmlns="http://www.w3.org/2000/svg">
<metadata>Generated by IcoMoon</metadata>
<defs>
<font id="icomoon" horiz-adv-x="1024">
<font-face units-per-em="1024" ascent="960" descent="-64" />
<missing-glyph horiz-adv-x="1024" />
<glyph unicode="&#x20;" horiz-adv-x="512" d="" />
<glyph unicode="&#xe900;" glyph-name="nomad_black" d="M507.999 959.562l-443.079-255.649v-511.675l443.079-255.8 443.079 255.8v511.675l-443.079 255.649zM705.402 396.893l-118.079-67.992-142.631 77.435v-163.256l-134.095-84.839v340.865l106.369 65.121 147.617-77.813v166.202l140.894 84.612-0.076-340.336z" />
</font></defs></svg>

After

Width:  |  Height:  |  Size: 738 B

Binary file not shown.

Binary file not shown.

View File

@@ -1,16 +1,17 @@
import { Field, Form, Formik } from 'formik';
import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
import { FormControl } from '@/portainer/components/form-components/FormControl';
import { Input, Select } from '@/portainer/components/form-components/Input';
import { FormSectionTitle } from '@/portainer/components/form-components/FormSectionTitle';
import { LoadingButton } from '@/portainer/components/Button/LoadingButton';
import { InputListError } from '@/portainer/components/form-components/InputList/InputList';
import { ContainerInstanceFormValues } from '@/azure/types';
import * as notifications from '@/portainer/services/notifications';
import { useUser } from '@/portainer/hooks/useUser';
import { AccessControlForm } from '@/portainer/access-control/AccessControlForm';
import { FormControl } from '@@/form-components/FormControl';
import { Input, Select } from '@@/form-components/Input';
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
import { LoadingButton } from '@@/buttons/LoadingButton';
import { InputListError } from '@@/form-components/InputList/InputList';
import { validationSchema } from './CreateContainerInstanceForm.validation';
import { PortMapping, PortsMappingField } from './PortsMappingField';
import { useLoadFormState } from './useLoadFormState';

View File

@@ -1,11 +1,11 @@
import { ButtonSelector } from '@/portainer/components/form-components/ButtonSelector/ButtonSelector';
import { FormError } from '@/portainer/components/form-components/FormError';
import { InputGroup } from '@/portainer/components/form-components/InputGroup';
import { InputList } from '@/portainer/components/form-components/InputList';
import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector';
import { FormError } from '@@/form-components/FormError';
import { InputGroup } from '@@/form-components/InputGroup';
import { InputList } from '@@/form-components/InputList';
import {
InputListError,
ItemProps,
} from '@/portainer/components/form-components/InputList/InputList';
} from '@@/form-components/InputList/InputList';
import styles from './PortsMappingField.module.css';
@@ -47,7 +47,7 @@ export function PortsMappingField({ value, onChange, errors }: Props) {
function Item({ onChange, item, error }: ItemProps<PortMapping>) {
return (
<div className={styles.item}>
<div className={styles.inputs}>
<div className="flex items-center gap-2">
<InputGroup size="small">
<InputGroup.Addon>host</InputGroup.Addon>
<InputGroup.Input
@@ -57,7 +57,7 @@ function Item({ onChange, item, error }: ItemProps<PortMapping>) {
/>
</InputGroup>
<span style={{ margin: '0 10px 0 10px' }}>
<span className="mx-3">
<i className="fa fa-long-arrow-alt-right" aria-hidden="true" />
</span>

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