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

feat(intel): Enable OpenAMT and FDO capabilities (#6212)

* feat(openamt): add AMT Devices information in Environments view [INT-8] (#6169)

* feat(openamt): add AMT Devices Ouf of Band Managamenet actions  [INT-9] (#6171)

* feat(openamt): add AMT Devices KVM Connection [INT-10] (#6179)

* feat(openamt): Enhance the Environments MX to activate OpenAMT on compatible environments [INT-7] (#6196)

* feat(openamt): Enable KVM by default [INT-25] (#6228)

* feat(fdo): implement the FDO configuration settings INT-19 (#6238)

feat(fdo): implement the FDO configuration settings INT-19

* feat(fdo): implement Owner client INT-17 (#6231)

feat(fdo): implement Owner client INT-17

* feat(openamt): hide wireless config in OpenAMT form (#6250)

* feat(openamt): Increase OpenAMT timeouts [INT-30] (#6253)

* feat(openamt): Disable the ability to use KVM and OOB actions on a MPS disconnected device [INT-36] (#6254)

* feat(fdo): add import device UI [INT-20] (#6240)

feat(fdo): add import device UI INT-20

* refactor(fdo): fix develop merge issues

* feat(openamt): Do not fetch OpenAMT details for an unassociated Edge endpoint (#6273)

* fix(intel): Fix switches params (#6282)

* feat(openamt): preload existing AMT settings (#6283)

* feat(openamt): Better UI/UX for AMT activation loading [INT-39] (#6290)

* feat(openamt): Remove wireless config related code [INT-41] (#6291)

* yarn install

* feat(openamt): change kvm redirection for pop up, always enable features [INT-37] (#6292)

* feat(openamt): change kvm redirection for pop up, always enable features [INT-37] (#6293)

* feat(openmt): use .ts services with axios for OpenAMT (#6312)

* Minor code cleanup.

* fix(fdo): move the FDO client code to the hostmanagement folder INT-44 (#6345)

* refactor(intel): Add Edge Compute Settings view (#6351)

* feat(fdo): add FDO profiles INT-22 (#6363)

feat(fdo): add FDO profiles INT-22

* fix(fdo): fix incorrect profile URL INT-45 (#6377)

* fixed husky version

* fix go.mod with go mod tidy

* feat(edge): migrate OpenAMT devices views to Edge Devices [EE-2322] (#6373)

* feat(intel): OpenAMT UI/UX adjustments (#6394)

* only allow edge agent as edge device

* show all edge agent environments on Edge Devices view

* feat(fdo): add the ability to import multiple ownership vouchers at once EE-2324 (#6395)

* fix(edge): settings edge compute alert (#6402)

* remove pagination, add useMemo for devices result array (#6409)

* feat(edge): minor Edge Devices (AMT) UI fixes (#6410)

* chore(eslint): fix versions

* chore(app): reformat codebase

* change add edge agent modal behaviour, fix yarn.lock

* fix use pagination

* remove extractedTranslations folder

* feat(edge): add FDO Profiles Datatable [EE-2406] (#6415)

* feat(edge): add KVM workaround tooltip (#6441)

* feat(edge): Add default FDO profile (#6450)

* feat(edge): add settings to disable trust on first connect and enforce Edge ID INT-1 EE-2410 (#6429)

Co-authored-by: andres-portainer <91705312+andres-portainer@users.noreply.github.com>
Co-authored-by: Anthony Lapenna <anthony.lapenna@portainer.io>
Co-authored-by: andres-portainer <andres-portainer@users.noreply.github.com>
Co-authored-by: Chaim Lev-Ari <chiptus@gmail.com>
This commit is contained in:
Marcelo Rydel 2022-01-23 16:48:04 -03:00 committed by GitHub
parent 3ed92e5fee
commit 2c4c638f46
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
170 changed files with 6834 additions and 819 deletions

View file

@ -554,7 +554,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
oauthService := initOAuthService() oauthService := initOAuthService()
gitService := initGitService() gitService := initGitService()
openAMTService := openamt.NewService(dataStore) openAMTService := openamt.NewService()
cryptoService := initCryptoService() cryptoService := initCryptoService()
digitalSignatureService := initDigitalSignatureService() digitalSignatureService := initDigitalSignatureService()

View file

@ -29,11 +29,6 @@ func Test_enableFeaturesFromFlags(t *testing.T) {
isSupported bool isSupported bool
}{ }{
{"test", false}, {"test", false},
{"openamt", false},
{"open-amt", true},
{"oPeN-amT", true},
{"fdo", true},
{"FDO", true},
} }
for _, test := range tests { for _, test := range tests {
t.Run(fmt.Sprintf("%s succeeds:%v", test.featureFlag, test.isSupported), func(t *testing.T) { t.Run(fmt.Sprintf("%s succeeds:%v", test.featureFlag, test.isSupported), func(t *testing.T) {

View file

@ -10,7 +10,7 @@ import (
) )
const ( 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":"","Credentials":{"MPSUser":"","MPSPassword":"","MPSToken":""},"DomainConfiguration":{"CertFileText":"","CertPassword":"","DomainName":""},"WirelessConfiguration":null},"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,"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" passphrase = "my secret key"
) )

View file

@ -0,0 +1,92 @@
package fdoprofile
import (
"fmt"
portainer "github.com/portainer/portainer/api"
"github.com/sirupsen/logrus"
)
const (
// BucketName represents the name of the bucket where this service stores data.
BucketName = "fdo_profiles"
)
// Service represents a service for managingFDO Profiles data.
type Service struct {
connection portainer.Connection
}
func (service *Service) BucketName() string {
return BucketName
}
// NewService creates a new instance of a service.
func NewService(connection portainer.Connection) (*Service, error) {
err := connection.SetServiceName(BucketName)
if err != nil {
return nil, err
}
return &Service{
connection: connection,
}, nil
}
// FDOProfiles return an array containing all the FDO Profiles.
func (service *Service) FDOProfiles() ([]portainer.FDOProfile, error) {
var fdoProfiles = make([]portainer.FDOProfile, 0)
err := service.connection.GetAll(
BucketName,
&portainer.FDOProfile{},
func(obj interface{}) (interface{}, error) {
fdoProfile, ok := obj.(*portainer.FDOProfile)
if !ok {
logrus.WithField("obj", obj).Errorf("Failed to convert to FDOProfile object")
return nil, fmt.Errorf("failed to convert to FDOProfile object: %s", obj)
}
fdoProfiles = append(fdoProfiles, *fdoProfile)
return &portainer.FDOProfile{}, nil
})
return fdoProfiles, err
}
// FDOProfile returns an FDO Profile by ID.
func (service *Service) FDOProfile(ID portainer.FDOProfileID) (*portainer.FDOProfile, error) {
var FDOProfile portainer.FDOProfile
identifier := service.connection.ConvertToKey(int(ID))
err := service.connection.GetObject(BucketName, identifier, &FDOProfile)
if err != nil {
return nil, err
}
return &FDOProfile, nil
}
// Create assign an ID to a new FDO Profile and saves it.
func (service *Service) Create(FDOProfile *portainer.FDOProfile) error {
return service.connection.CreateObjectWithId(
BucketName,
int(FDOProfile.ID),
FDOProfile,
)
}
// Update updates an FDO Profile.
func (service *Service) Update(ID portainer.FDOProfileID, FDOProfile *portainer.FDOProfile) error {
identifier := service.connection.ConvertToKey(int(ID))
return service.connection.UpdateObject(BucketName, identifier, FDOProfile)
}
// Delete deletes an FDO Profile.
func (service *Service) Delete(ID portainer.FDOProfileID) error {
identifier := service.connection.ConvertToKey(int(ID))
return service.connection.DeleteObject(BucketName, identifier)
}
// GetNextIdentifier returns the next identifier for a FDO Profile.
func (service *Service) GetNextIdentifier() int {
return service.connection.GetNextIdentifier(BucketName)
}

View file

@ -31,6 +31,7 @@ type (
Endpoint() EndpointService Endpoint() EndpointService
EndpointGroup() EndpointGroupService EndpointGroup() EndpointGroupService
EndpointRelation() EndpointRelationService EndpointRelation() EndpointRelationService
FDOProfile() FDOProfileService
HelmUserRepository() HelmUserRepositoryService HelmUserRepository() HelmUserRepositoryService
Registry() RegistryService Registry() RegistryService
ResourceControl() ResourceControlService ResourceControl() ResourceControlService
@ -122,6 +123,17 @@ type (
BucketName() string BucketName() string
} }
// FDOProfileService represents a service to manage FDO Profiles
FDOProfileService interface {
FDOProfiles() ([]portainer.FDOProfile, error)
FDOProfile(ID portainer.FDOProfileID) (*portainer.FDOProfile, error)
Create(FDOProfile *portainer.FDOProfile) error
Update(ID portainer.FDOProfileID, FDOProfile *portainer.FDOProfile) error
Delete(ID portainer.FDOProfileID) error
GetNextIdentifier() int
BucketName() string
}
// HelmUserRepositoryService represents a service to manage HelmUserRepositories // HelmUserRepositoryService represents a service to manage HelmUserRepositories
HelmUserRepositoryService interface { HelmUserRepositoryService interface {
HelmUserRepositories() ([]portainer.HelmUserRepository, error) HelmUserRepositories() ([]portainer.HelmUserRepository, error)

View file

@ -30,6 +30,7 @@ func (store *Store) MigrateData() error {
EndpointService: store.EndpointService, EndpointService: store.EndpointService,
EndpointRelationService: store.EndpointRelationService, EndpointRelationService: store.EndpointRelationService,
ExtensionService: store.ExtensionService, ExtensionService: store.ExtensionService,
FDOProfilesService: store.FDOProfilesService,
RegistryService: store.RegistryService, RegistryService: store.RegistryService,
ResourceControlService: store.ResourceControlService, ResourceControlService: store.ResourceControlService,
RoleService: store.RoleService, RoleService: store.RoleService,

View file

@ -7,6 +7,7 @@ import (
"github.com/portainer/portainer/api/dataservices/endpointgroup" "github.com/portainer/portainer/api/dataservices/endpointgroup"
"github.com/portainer/portainer/api/dataservices/endpointrelation" "github.com/portainer/portainer/api/dataservices/endpointrelation"
"github.com/portainer/portainer/api/dataservices/extension" "github.com/portainer/portainer/api/dataservices/extension"
"github.com/portainer/portainer/api/dataservices/fdoprofile"
"github.com/portainer/portainer/api/dataservices/registry" "github.com/portainer/portainer/api/dataservices/registry"
"github.com/portainer/portainer/api/dataservices/resourcecontrol" "github.com/portainer/portainer/api/dataservices/resourcecontrol"
"github.com/portainer/portainer/api/dataservices/role" "github.com/portainer/portainer/api/dataservices/role"
@ -26,12 +27,12 @@ var migrateLog = plog.NewScopedLog("database, migrate")
type ( type (
// Migrator defines a service to migrate data after a Portainer version update. // Migrator defines a service to migrate data after a Portainer version update.
Migrator struct { Migrator struct {
currentDBVersion int currentDBVersion int
endpointGroupService *endpointgroup.Service endpointGroupService *endpointgroup.Service
endpointService *endpoint.Service endpointService *endpoint.Service
endpointRelationService *endpointrelation.Service endpointRelationService *endpointrelation.Service
extensionService *extension.Service extensionService *extension.Service
fdoProfilesService *fdoprofile.Service
registryService *registry.Service registryService *registry.Service
resourceControlService *resourcecontrol.Service resourceControlService *resourcecontrol.Service
roleService *role.Service roleService *role.Service
@ -54,6 +55,7 @@ type (
EndpointService *endpoint.Service EndpointService *endpoint.Service
EndpointRelationService *endpointrelation.Service EndpointRelationService *endpointrelation.Service
ExtensionService *extension.Service ExtensionService *extension.Service
FDOProfilesService *fdoprofile.Service
RegistryService *registry.Service RegistryService *registry.Service
ResourceControlService *resourcecontrol.Service ResourceControlService *resourcecontrol.Service
RoleService *role.Service RoleService *role.Service
@ -78,6 +80,7 @@ func NewMigrator(parameters *MigratorParameters) *Migrator {
endpointService: parameters.EndpointService, endpointService: parameters.EndpointService,
endpointRelationService: parameters.EndpointRelationService, endpointRelationService: parameters.EndpointRelationService,
extensionService: parameters.ExtensionService, extensionService: parameters.ExtensionService,
fdoProfilesService: parameters.FDOProfilesService,
registryService: parameters.RegistryService, registryService: parameters.RegistryService,
resourceControlService: parameters.ResourceControlService, resourceControlService: parameters.ResourceControlService,
roleService: parameters.RoleService, roleService: parameters.RoleService,

View file

@ -17,6 +17,7 @@ import (
"github.com/portainer/portainer/api/dataservices/endpointgroup" "github.com/portainer/portainer/api/dataservices/endpointgroup"
"github.com/portainer/portainer/api/dataservices/endpointrelation" "github.com/portainer/portainer/api/dataservices/endpointrelation"
"github.com/portainer/portainer/api/dataservices/extension" "github.com/portainer/portainer/api/dataservices/extension"
"github.com/portainer/portainer/api/dataservices/fdoprofile"
"github.com/portainer/portainer/api/dataservices/helmuserrepository" "github.com/portainer/portainer/api/dataservices/helmuserrepository"
"github.com/portainer/portainer/api/dataservices/registry" "github.com/portainer/portainer/api/dataservices/registry"
"github.com/portainer/portainer/api/dataservices/resourcecontrol" "github.com/portainer/portainer/api/dataservices/resourcecontrol"
@ -50,6 +51,7 @@ type Store struct {
EndpointService *endpoint.Service EndpointService *endpoint.Service
EndpointRelationService *endpointrelation.Service EndpointRelationService *endpointrelation.Service
ExtensionService *extension.Service ExtensionService *extension.Service
FDOProfilesService *fdoprofile.Service
HelmUserRepositoryService *helmuserrepository.Service HelmUserRepositoryService *helmuserrepository.Service
RegistryService *registry.Service RegistryService *registry.Service
ResourceControlService *resourcecontrol.Service ResourceControlService *resourcecontrol.Service
@ -129,6 +131,12 @@ func (store *Store) initServices() error {
} }
store.ExtensionService = extensionService store.ExtensionService = extensionService
fdoProfilesService, err := fdoprofile.NewService(store.connection)
if err != nil {
return err
}
store.FDOProfilesService = fdoProfilesService
helmUserRepositoryService, err := helmuserrepository.NewService(store.connection) helmUserRepositoryService, err := helmuserrepository.NewService(store.connection)
if err != nil { if err != nil {
return err return err
@ -257,6 +265,11 @@ func (store *Store) EndpointRelation() dataservices.EndpointRelationService {
return store.EndpointRelationService return store.EndpointRelationService
} }
// FDOProfile gives access to the FDOProfile data management layer
func (store *Store) FDOProfile() dataservices.FDOProfileService {
return store.FDOProfilesService
}
// HelmUserRepository access the helm user repository settings // HelmUserRepository access the helm user repository settings
func (store *Store) HelmUserRepository() dataservices.HelmUserRepositoryService { func (store *Store) HelmUserRepository() dataservices.HelmUserRepositoryService {
return store.HelmUserRepositoryService return store.HelmUserRepositoryService

View file

@ -15,7 +15,7 @@ import (
var errUnsupportedEnvironmentType = errors.New("Environment not supported") var errUnsupportedEnvironmentType = errors.New("Environment not supported")
const ( const (
defaultDockerRequestTimeout = 60 defaultDockerRequestTimeout = 60 * time.Second
dockerClientVersion = "1.37" dockerClientVersion = "1.37"
) )
@ -33,22 +33,23 @@ func NewClientFactory(signatureService portainer.DigitalSignatureService, revers
} }
} }
// createClient is a generic function to create a Docker client based on // CreateClient is a generic function to create a Docker client based on
// a specific environment(endpoint) configuration. The nodeName parameter can be used // a specific environment(endpoint) configuration. The nodeName parameter can be used
// with an agent enabled environment(endpoint) to target a specific node in an agent cluster. // with an agent enabled environment(endpoint) to target a specific node in an agent cluster.
func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint, nodeName string) (*client.Client, error) { // The underlying http client timeout may be specified, a default value is used otherwise.
func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint, nodeName string, timeout *time.Duration) (*client.Client, error) {
if endpoint.Type == portainer.AzureEnvironment { if endpoint.Type == portainer.AzureEnvironment {
return nil, errUnsupportedEnvironmentType return nil, errUnsupportedEnvironmentType
} else if endpoint.Type == portainer.AgentOnDockerEnvironment { } else if endpoint.Type == portainer.AgentOnDockerEnvironment {
return createAgentClient(endpoint, factory.signatureService, nodeName) return createAgentClient(endpoint, factory.signatureService, nodeName, timeout)
} else if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment { } else if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment {
return createEdgeClient(endpoint, factory.signatureService, factory.reverseTunnelService, nodeName) return createEdgeClient(endpoint, factory.signatureService, factory.reverseTunnelService, nodeName, timeout)
} }
if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") { if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") {
return createLocalClient(endpoint) return createLocalClient(endpoint)
} }
return createTCPClient(endpoint) return createTCPClient(endpoint, timeout)
} }
func createLocalClient(endpoint *portainer.Endpoint) (*client.Client, error) { func createLocalClient(endpoint *portainer.Endpoint) (*client.Client, error) {
@ -58,8 +59,8 @@ func createLocalClient(endpoint *portainer.Endpoint) (*client.Client, error) {
) )
} }
func createTCPClient(endpoint *portainer.Endpoint) (*client.Client, error) { func createTCPClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*client.Client, error) {
httpCli, err := httpClient(endpoint) httpCli, err := httpClient(endpoint, timeout)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -71,8 +72,8 @@ func createTCPClient(endpoint *portainer.Endpoint) (*client.Client, error) {
) )
} }
func createEdgeClient(endpoint *portainer.Endpoint, signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, nodeName string) (*client.Client, error) { func createEdgeClient(endpoint *portainer.Endpoint, signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, nodeName string, timeout *time.Duration) (*client.Client, error) {
httpCli, err := httpClient(endpoint) httpCli, err := httpClient(endpoint, timeout)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -106,8 +107,8 @@ func createEdgeClient(endpoint *portainer.Endpoint, signatureService portainer.D
) )
} }
func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer.DigitalSignatureService, nodeName string) (*client.Client, error) { func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer.DigitalSignatureService, nodeName string, timeout *time.Duration) (*client.Client, error) {
httpCli, err := httpClient(endpoint) httpCli, err := httpClient(endpoint, timeout)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -134,7 +135,7 @@ func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer.
) )
} }
func httpClient(endpoint *portainer.Endpoint) (*http.Client, error) { func httpClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*http.Client, error) {
transport := &http.Transport{} transport := &http.Transport{}
if endpoint.TLSConfig.TLS { if endpoint.TLSConfig.TLS {
@ -145,8 +146,13 @@ func httpClient(endpoint *portainer.Endpoint) (*http.Client, error) {
transport.TLSClientConfig = tlsConfig transport.TLSClientConfig = tlsConfig
} }
clientTimeout := defaultDockerRequestTimeout
if timeout != nil {
clientTimeout = *timeout
}
return &http.Client{ return &http.Client{
Transport: transport, Transport: transport,
Timeout: defaultDockerRequestTimeout * time.Second, Timeout: clientTimeout,
}, nil }, nil
} }

View file

@ -26,7 +26,7 @@ func NewSnapshotter(clientFactory *ClientFactory) *Snapshotter {
// CreateSnapshot creates a snapshot of a specific Docker environment(endpoint) // CreateSnapshot creates a snapshot of a specific Docker environment(endpoint)
func (snapshotter *Snapshotter) CreateSnapshot(endpoint *portainer.Endpoint) (*portainer.DockerSnapshot, error) { func (snapshotter *Snapshotter) CreateSnapshot(endpoint *portainer.Endpoint) (*portainer.DockerSnapshot, error) {
cli, err := snapshotter.clientFactory.CreateClient(endpoint, "") cli, err := snapshotter.clientFactory.CreateClient(endpoint, "", nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -35,6 +35,8 @@ const (
ManifestFileDefaultName = "k8s-deployment.yml" ManifestFileDefaultName = "k8s-deployment.yml"
// EdgeStackStorePath represents the subfolder where edge stack files are stored in the file store folder. // EdgeStackStorePath represents the subfolder where edge stack files are stored in the file store folder.
EdgeStackStorePath = "edge_stacks" EdgeStackStorePath = "edge_stacks"
// FDOProfileStorePath represents the subfolder where FDO profiles files are stored in the file store folder.
FDOProfileStorePath = "fdo_profiles"
// PrivateKeyFile represents the name on disk of the file containing the private key. // PrivateKeyFile represents the name on disk of the file containing the private key.
PrivateKeyFile = "portainer.key" PrivateKeyFile = "portainer.key"
// PublicKeyFile represents the name on disk of the file containing the public key. // PublicKeyFile represents the name on disk of the file containing the public key.
@ -652,3 +654,20 @@ func MoveDirectory(originalPath, newPath string) error {
return os.Rename(originalPath, newPath) return os.Rename(originalPath, newPath)
} }
// StoreFDOProfileFileFromBytes creates a subfolder in the FDOProfileStorePath and stores a new file from bytes.
// It returns the path to the folder where the file is stored.
func (service *Service) StoreFDOProfileFileFromBytes(fdoProfileIdentifier string, data []byte) (string, error) {
err := service.createDirectoryInStore(FDOProfileStorePath)
if err != nil {
return "", err
}
filePath := JoinPaths(FDOProfileStorePath, fdoProfileIdentifier)
err = service.createFileInStore(filePath, bytes.NewReader(data))
if err != nil {
return "", err
}
return service.wrapFileStore(filePath), nil
}

View file

@ -14,6 +14,7 @@ require (
github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/docker/cli v20.10.9+incompatible github.com/docker/cli v20.10.9+incompatible
github.com/docker/docker v20.10.9+incompatible github.com/docker/docker v20.10.9+incompatible
github.com/fxamacker/cbor/v2 v2.3.0
github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814 github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814
github.com/go-git/go-git/v5 v5.3.0 github.com/go-git/go-git/v5 v5.3.0
github.com/go-ldap/ldap/v3 v3.1.8 github.com/go-ldap/ldap/v3 v3.1.8
@ -33,6 +34,7 @@ require (
github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a
github.com/portainer/libhelm v0.0.0-20210929000907-825e93d62108 github.com/portainer/libhelm v0.0.0-20210929000907-825e93d62108
github.com/portainer/libhttp v0.0.0-20211021135806-13e6c55c5fbc github.com/portainer/libhttp v0.0.0-20211021135806-13e6c55c5fbc
github.com/rkl-/digest v0.0.0-20180419075440-8316caa4a777
github.com/robfig/cron/v3 v3.0.1 github.com/robfig/cron/v3 v3.0.1
github.com/sirupsen/logrus v1.8.1 github.com/sirupsen/logrus v1.8.1
github.com/stretchr/testify v1.7.0 github.com/stretchr/testify v1.7.0
@ -94,6 +96,7 @@ require (
github.com/sergi/go-diff v1.1.0 // indirect github.com/sergi/go-diff v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.5 // indirect
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce // indirect github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xanzy/ssh-agent v0.3.0 // indirect github.com/xanzy/ssh-agent v0.3.0 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect

View file

@ -300,6 +300,8 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa/go.mod h1:KnogPXtdwXqoenmZCw6S+25EAm2MkxbG0deNDu4cbSA= github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa/go.mod h1:KnogPXtdwXqoenmZCw6S+25EAm2MkxbG0deNDu4cbSA=
github.com/fxamacker/cbor/v2 v2.3.0 h1:aM45YGMctNakddNNAezPxDUpv38j44Abh+hifNuqXik=
github.com/fxamacker/cbor/v2 v2.3.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo=
github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814 h1:gWvniJ4GbFfkf700kykAImbLiEMU0Q3QN9hQ26Js1pU= github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814 h1:gWvniJ4GbFfkf700kykAImbLiEMU0Q3QN9hQ26Js1pU=
github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814/go.mod h1:secRm32Ro77eD23BmPVbgLbWN+JWDw7pJszenjxI4bI= github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814/go.mod h1:secRm32Ro77eD23BmPVbgLbWN+JWDw7pJszenjxI4bI=
github.com/garyburd/redigo v0.0.0-20150301180006-535138d7bcd7/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY= github.com/garyburd/redigo v0.0.0-20150301180006-535138d7bcd7/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY=
@ -649,6 +651,8 @@ github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4O
github.com/prometheus/procfs v0.2.0/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= github.com/prometheus/procfs v0.2.0/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
github.com/rkl-/digest v0.0.0-20180419075440-8316caa4a777 h1:rDj3WeO+TiWyxfcydUnKegWAZoR5kQsnW0wzhggdOrw=
github.com/rkl-/digest v0.0.0-20180419075440-8316caa4a777/go.mod h1:xRVvTK+cS/dJSvrOufGUQFWfgvE7yXExeng96n8377o=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
@ -722,6 +726,8 @@ github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17
github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
github.com/willf/bitset v1.1.11-0.20200630133818-d5bec3311243/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= github.com/willf/bitset v1.1.11-0.20200630133818-d5bec3311243/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
github.com/willf/bitset v1.1.11/go.mod h1:83CECat5yLh5zVOf4P1ErAgKA5UDvKtgyUABdr3+MjI= github.com/willf/bitset v1.1.11/go.mod h1:83CECat5yLh5zVOf4P1ErAgKA5UDvKtgyUABdr3+MjI=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xanzy/ssh-agent v0.3.0 h1:wUMzuKtKilRgBAD1sUb8gOwwRr2FGoBVumcjoOACClI= github.com/xanzy/ssh-agent v0.3.0 h1:wUMzuKtKilRgBAD1sUb8gOwwRr2FGoBVumcjoOACClI=
github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0= github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=

View file

@ -0,0 +1,235 @@
package fdo
import (
"bytes"
"errors"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/rkl-/digest"
)
type FDOOwnerClient struct {
OwnerURL string
Username string
Password string
Timeout time.Duration
}
type ServiceInfo struct {
Module string
Var string
Filename string
Bytes []byte
GUID string
Device string
Priority int
OS string
Version string
Arch string
CRID int
Hash string
}
func (c FDOOwnerClient) doDigestAuthReq(method, endpoint, contentType string, body io.Reader) (*http.Response, error) {
transport := digest.NewTransport(c.Username, c.Password)
client, err := transport.Client()
if err != nil {
return nil, err
}
client.Timeout = c.Timeout
e, err := url.Parse(endpoint)
if err != nil {
return nil, err
}
u, err := url.Parse(c.OwnerURL)
if err != nil {
return nil, err
}
req, err := http.NewRequest(method, u.ResolveReference(e).String(), body)
if err != nil {
return nil, err
}
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
return client.Do(req)
}
func (c FDOOwnerClient) PostVoucher(ov []byte) (string, error) {
resp, err := c.doDigestAuthReq(
http.MethodPost,
"api/v1/owner/vouchers",
"application/cbor",
bytes.NewReader(ov),
)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
return "", errors.New(http.StatusText(resp.StatusCode))
}
return string(body), nil
}
func (c FDOOwnerClient) PutDeviceSVI(info ServiceInfo) error {
values := url.Values{}
values.Set("module", info.Module)
values.Set("var", info.Var)
values.Set("filename", info.Filename)
values.Set("guid", info.GUID)
values.Set("device", info.Device)
values.Set("priority", strconv.Itoa(info.Priority))
values.Set("os", info.OS)
values.Set("version", info.Version)
values.Set("arch", info.Arch)
values.Set("crid", strconv.Itoa(info.CRID))
values.Set("hash", info.Hash)
resp, err := c.doDigestAuthReq(
http.MethodPut,
"api/v1/device/svi?"+values.Encode(),
"application/octet-stream",
strings.NewReader(string(info.Bytes)),
)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return errors.New(http.StatusText(resp.StatusCode))
}
return nil
}
func (c FDOOwnerClient) PutDeviceSVIRaw(info url.Values, body []byte) error {
resp, err := c.doDigestAuthReq(
http.MethodPut,
"api/v1/device/svi?"+info.Encode(),
"application/octet-stream",
strings.NewReader(string(body)),
)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return errors.New(http.StatusText(resp.StatusCode))
}
return nil
}
func (c FDOOwnerClient) GetVouchers() ([]string, error) {
resp, err := c.doDigestAuthReq(
http.MethodGet,
"api/v1/owner/vouchers",
"",
nil,
)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, errors.New(http.StatusText(resp.StatusCode))
}
contents, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
guids := strings.FieldsFunc(
strings.TrimSpace(string(contents)),
func(c rune) bool {
return c == ','
},
)
return guids, nil
}
func (c FDOOwnerClient) DeleteVoucher(guid string) error {
resp, err := c.doDigestAuthReq(
http.MethodDelete,
"api/v1/owner/vouchers?id="+guid,
"",
nil,
)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return errors.New(http.StatusText(resp.StatusCode))
}
return nil
}
func (c FDOOwnerClient) GetDeviceSVI(guid string) (string, error) {
resp, err := c.doDigestAuthReq(
http.MethodGet,
"api/v1/device/svi?guid="+guid,
"",
nil,
)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
return "", errors.New(http.StatusText(resp.StatusCode))
}
return string(body), nil
}
func (c FDOOwnerClient) DeleteDeviceSVI(id string) error {
resp, err := c.doDigestAuthReq(
http.MethodDelete,
"api/v1/device/svi?id="+id,
"",
nil,
)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return errors.New(http.StatusText(resp.StatusCode))
}
return nil
}

View file

@ -14,39 +14,39 @@ type authenticationResponse struct {
Token string `json:"token"` Token string `json:"token"`
} }
func (service *Service) executeAuthenticationRequest(configuration portainer.OpenAMTConfiguration) (*authenticationResponse, error) { func (service *Service) Authorization(configuration portainer.OpenAMTConfiguration) (string, error) {
loginURL := fmt.Sprintf("https://%s/mps/login/api/v1/authorize", configuration.MPSServer) loginURL := fmt.Sprintf("https://%s/mps/login/api/v1/authorize", configuration.MPSServer)
payload := map[string]string{ payload := map[string]string{
"username": configuration.Credentials.MPSUser, "username": configuration.MPSUser,
"password": configuration.Credentials.MPSPassword, "password": configuration.MPSPassword,
} }
jsonValue, _ := json.Marshal(payload) jsonValue, _ := json.Marshal(payload)
req, err := http.NewRequest(http.MethodPost, loginURL, bytes.NewBuffer(jsonValue)) req, err := http.NewRequest(http.MethodPost, loginURL, bytes.NewBuffer(jsonValue))
if err != nil { if err != nil {
return nil, err return "", err
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
response, err := service.httpsClient.Do(req) response, err := service.httpsClient.Do(req)
if err != nil { if err != nil {
return nil, err return "", err
} }
responseBody, readErr := ioutil.ReadAll(response.Body) responseBody, readErr := ioutil.ReadAll(response.Body)
if readErr != nil { if readErr != nil {
return nil, readErr return "", readErr
} }
errorResponse := parseError(responseBody) errorResponse := parseError(responseBody)
if errorResponse != nil { if errorResponse != nil {
return nil, errorResponse return "", errorResponse
} }
var token authenticationResponse var token authenticationResponse
err = json.Unmarshal(responseBody, &token) err = json.Unmarshal(responseBody, &token)
if err != nil { if err != nil {
return nil, err return "", err
} }
return &token, nil return token.Token, nil
} }

View file

@ -4,7 +4,6 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"encoding/pem" "encoding/pem"
"errors"
"fmt" "fmt"
"io" "io"
"net" "net"
@ -47,7 +46,7 @@ func (service *Service) createOrUpdateCIRAConfig(configuration portainer.OpenAMT
func (service *Service) getCIRAConfig(configuration portainer.OpenAMTConfiguration, configName string) (*CIRAConfig, error) { func (service *Service) getCIRAConfig(configuration portainer.OpenAMTConfiguration, configName string) (*CIRAConfig, error) {
url := fmt.Sprintf("https://%s/rps/api/v1/admin/ciraconfigs/%s", configuration.MPSServer, configName) url := fmt.Sprintf("https://%s/rps/api/v1/admin/ciraconfigs/%s", configuration.MPSServer, configName)
responseBody, err := service.executeGetRequest(url, configuration.Credentials.MPSToken) responseBody, err := service.executeGetRequest(url, configuration.MPSToken)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -89,7 +88,7 @@ func (service *Service) saveCIRAConfig(method string, configuration portainer.Op
} }
payload, _ := json.Marshal(config) payload, _ := json.Marshal(config)
responseBody, err := service.executeSaveRequest(method, url, configuration.Credentials.MPSToken, payload) responseBody, err := service.executeSaveRequest(method, url, configuration.MPSToken, payload)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -123,7 +122,7 @@ func (service *Service) getCIRACertificate(configuration portainer.OpenAMTConfig
if err != nil { if err != nil {
return "", err return "", err
} }
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", configuration.Credentials.MPSToken)) req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", configuration.MPSToken))
response, err := service.httpsClient.Do(req) response, err := service.httpsClient.Do(req)
if err != nil { if err != nil {
@ -131,7 +130,7 @@ func (service *Service) getCIRACertificate(configuration portainer.OpenAMTConfig
} }
if response.StatusCode != http.StatusOK { if response.StatusCode != http.StatusOK {
return "", errors.New(fmt.Sprintf("unexpected status code %s", response.Status)) return "", fmt.Errorf("unexpected status code %s", response.Status)
} }
certificate, err := io.ReadAll(response.Body) certificate, err := io.ReadAll(response.Body)

View file

@ -0,0 +1,87 @@
package openamt
import (
"encoding/json"
"fmt"
"strings"
portainer "github.com/portainer/portainer/api"
)
type Device struct {
GUID string `json:"guid"`
HostName string `json:"hostname"`
ConnectionStatus bool `json:"connectionStatus"`
}
type DevicePowerState struct {
State portainer.PowerState `json:"powerstate"`
}
type DeviceEnabledFeatures struct {
Redirection bool `json:"redirection"`
KVM bool `json:"KVM"`
SOL bool `json:"SOL"`
IDER bool `json:"IDER"`
UserConsent string `json:"userConsent"`
}
func (service *Service) getDevice(configuration portainer.OpenAMTConfiguration, deviceGUID string) (*Device, error) {
url := fmt.Sprintf("https://%s/mps/api/v1/devices/%s", configuration.MPSServer, deviceGUID)
responseBody, err := service.executeGetRequest(url, configuration.MPSToken)
if err != nil {
if strings.EqualFold(err.Error(), "invalid value") {
return nil, nil
}
return nil, err
}
if responseBody == nil {
return nil, nil
}
var result Device
err = json.Unmarshal(responseBody, &result)
if err != nil {
return nil, err
}
return &result, nil
}
func (service *Service) getDevicePowerState(configuration portainer.OpenAMTConfiguration, deviceGUID string) (*DevicePowerState, error) {
url := fmt.Sprintf("https://%s/mps/api/v1/amt/power/state/%s", configuration.MPSServer, deviceGUID)
responseBody, err := service.executeGetRequest(url, configuration.MPSToken)
if err != nil {
return nil, err
}
if responseBody == nil {
return nil, nil
}
var result DevicePowerState
err = json.Unmarshal(responseBody, &result)
if err != nil {
return nil, err
}
return &result, nil
}
func (service *Service) getDeviceEnabledFeatures(configuration portainer.OpenAMTConfiguration, deviceGUID string) (*DeviceEnabledFeatures, error) {
url := fmt.Sprintf("https://%s/mps/api/v1/amt/features/%s", configuration.MPSServer, deviceGUID)
responseBody, err := service.executeGetRequest(url, configuration.MPSToken)
if err != nil {
return nil, err
}
if responseBody == nil {
return nil, nil
}
var result DeviceEnabledFeatures
err = json.Unmarshal(responseBody, &result)
if err != nil {
return nil, err
}
return &result, nil
}

View file

@ -37,9 +37,9 @@ func (service *Service) createOrUpdateDomain(configuration portainer.OpenAMTConf
} }
func (service *Service) getDomain(configuration portainer.OpenAMTConfiguration) (*Domain, error) { func (service *Service) getDomain(configuration portainer.OpenAMTConfiguration) (*Domain, error) {
url := fmt.Sprintf("https://%s/rps/api/v1/admin/domains/%s", configuration.MPSServer, configuration.DomainConfiguration.DomainName) url := fmt.Sprintf("https://%s/rps/api/v1/admin/domains/%s", configuration.MPSServer, configuration.DomainName)
responseBody, err := service.executeGetRequest(url, configuration.Credentials.MPSToken) responseBody, err := service.executeGetRequest(url, configuration.MPSToken)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -59,15 +59,15 @@ func (service *Service) saveDomain(method string, configuration portainer.OpenAM
url := fmt.Sprintf("https://%s/rps/api/v1/admin/domains", configuration.MPSServer) url := fmt.Sprintf("https://%s/rps/api/v1/admin/domains", configuration.MPSServer)
profile := Domain{ profile := Domain{
DomainName: configuration.DomainConfiguration.DomainName, DomainName: configuration.DomainName,
DomainSuffix: configuration.DomainConfiguration.DomainName, DomainSuffix: configuration.DomainName,
ProvisioningCert: configuration.DomainConfiguration.CertFileText, ProvisioningCert: configuration.CertFileContent,
ProvisioningCertPassword: configuration.DomainConfiguration.CertPassword, ProvisioningCertPassword: configuration.CertFilePassword,
ProvisioningCertStorageFormat: "string", ProvisioningCertStorageFormat: "string",
} }
payload, _ := json.Marshal(profile) payload, _ := json.Marshal(profile)
responseBody, err := service.executeSaveRequest(method, url, configuration.Credentials.MPSToken, payload) responseBody, err := service.executeSaveRequest(method, url, configuration.MPSToken, payload)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -29,7 +29,7 @@ type (
} }
) )
func (service *Service) createOrUpdateAMTProfile(configuration portainer.OpenAMTConfiguration, profileName string, ciraConfigName string, wirelessConfig string) (*Profile, error) { func (service *Service) createOrUpdateAMTProfile(configuration portainer.OpenAMTConfiguration, profileName string, ciraConfigName string) (*Profile, error) {
profile, err := service.getAMTProfile(configuration, profileName) profile, err := service.getAMTProfile(configuration, profileName)
if err != nil { if err != nil {
return nil, err return nil, err
@ -40,7 +40,7 @@ func (service *Service) createOrUpdateAMTProfile(configuration portainer.OpenAMT
method = http.MethodPatch method = http.MethodPatch
} }
profile, err = service.saveAMTProfile(method, configuration, profileName, ciraConfigName, wirelessConfig) profile, err = service.saveAMTProfile(method, configuration, profileName, ciraConfigName)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -50,7 +50,7 @@ func (service *Service) createOrUpdateAMTProfile(configuration portainer.OpenAMT
func (service *Service) getAMTProfile(configuration portainer.OpenAMTConfiguration, profileName string) (*Profile, error) { func (service *Service) getAMTProfile(configuration portainer.OpenAMTConfiguration, profileName string) (*Profile, error) {
url := fmt.Sprintf("https://%s/rps/api/v1/admin/profiles/%s", configuration.MPSServer, profileName) url := fmt.Sprintf("https://%s/rps/api/v1/admin/profiles/%s", configuration.MPSServer, profileName)
responseBody, err := service.executeGetRequest(url, configuration.Credentials.MPSToken) responseBody, err := service.executeGetRequest(url, configuration.MPSToken)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -66,7 +66,7 @@ func (service *Service) getAMTProfile(configuration portainer.OpenAMTConfigurati
return &result, nil return &result, nil
} }
func (service *Service) saveAMTProfile(method string, configuration portainer.OpenAMTConfiguration, profileName string, ciraConfigName string, wirelessConfig string) (*Profile, error) { func (service *Service) saveAMTProfile(method string, configuration portainer.OpenAMTConfiguration, profileName string, ciraConfigName string) (*Profile, error) {
url := fmt.Sprintf("https://%s/rps/api/v1/admin/profiles", configuration.MPSServer) url := fmt.Sprintf("https://%s/rps/api/v1/admin/profiles", configuration.MPSServer)
profile := Profile{ profile := Profile{
@ -74,23 +74,15 @@ func (service *Service) saveAMTProfile(method string, configuration portainer.Op
Activation: "acmactivate", Activation: "acmactivate",
GenerateRandomAMTPassword: false, GenerateRandomAMTPassword: false,
GenerateRandomMEBxPassword: false, GenerateRandomMEBxPassword: false,
AMTPassword: configuration.Credentials.MPSPassword, AMTPassword: configuration.MPSPassword,
MEBXPassword: configuration.Credentials.MPSPassword, MEBXPassword: configuration.MPSPassword,
CIRAConfigName: &ciraConfigName, CIRAConfigName: &ciraConfigName,
Tags: []string{}, Tags: []string{},
DHCPEnabled: true, DHCPEnabled: true,
} }
if wirelessConfig != "" {
profile.WIFIConfigs = []ProfileWifiConfig{
{
Priority: 1,
ProfileName: DefaultWirelessConfigName,
},
}
}
payload, _ := json.Marshal(profile) payload, _ := json.Marshal(profile)
responseBody, err := service.executeSaveRequest(method, url, configuration.Credentials.MPSToken, payload) responseBody, err := service.executeSaveRequest(method, url, configuration.MPSToken, payload)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -1,91 +0,0 @@
package openamt
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
portainer "github.com/portainer/portainer/api"
)
type (
WirelessProfile struct {
ProfileName string `json:"profileName"`
AuthenticationMethod int `json:"authenticationMethod"`
EncryptionMethod int `json:"encryptionMethod"`
SSID string `json:"ssid"`
PSKPassphrase string `json:"pskPassphrase"`
}
)
func (service *Service) createOrUpdateWirelessConfig(configuration portainer.OpenAMTConfiguration, wirelessConfigName string) (*WirelessProfile, error) {
wirelessConfig, err := service.getWirelessConfig(configuration, wirelessConfigName)
if err != nil {
return nil, err
}
method := http.MethodPost
if wirelessConfig != nil {
method = http.MethodPatch
}
wirelessConfig, err = service.saveWirelessConfig(method, configuration, wirelessConfigName)
if err != nil {
return nil, err
}
return wirelessConfig, nil
}
func (service *Service) getWirelessConfig(configuration portainer.OpenAMTConfiguration, configName string) (*WirelessProfile, error) {
url := fmt.Sprintf("https://%s/rps/api/v1/admin/wirelessconfigs/%s", configuration.MPSServer, configName)
responseBody, err := service.executeGetRequest(url, configuration.Credentials.MPSToken)
if err != nil {
return nil, err
}
if responseBody == nil {
return nil, nil
}
var result WirelessProfile
err = json.Unmarshal(responseBody, &result)
if err != nil {
return nil, err
}
return &result, nil
}
func (service *Service) saveWirelessConfig(method string, configuration portainer.OpenAMTConfiguration, configName string) (*WirelessProfile, error) {
parsedAuthenticationMethod, err := strconv.Atoi(configuration.WirelessConfiguration.AuthenticationMethod)
if err != nil {
return nil, fmt.Errorf("error parsing wireless authentication method: %s", err.Error())
}
parsedEncryptionMethod, err := strconv.Atoi(configuration.WirelessConfiguration.EncryptionMethod)
if err != nil {
return nil, fmt.Errorf("error parsing wireless encryption method: %s", err.Error())
}
url := fmt.Sprintf("https://%s/rps/api/v1/admin/wirelessconfigs", configuration.MPSServer)
config := WirelessProfile{
ProfileName: configName,
AuthenticationMethod: parsedAuthenticationMethod,
EncryptionMethod: parsedEncryptionMethod,
SSID: configuration.WirelessConfiguration.SSID,
PSKPassphrase: configuration.WirelessConfiguration.PskPass,
}
payload, _ := json.Marshal(config)
responseBody, err := service.executeSaveRequest(method, url, configuration.Credentials.MPSToken, payload)
if err != nil {
return nil, err
}
var result WirelessProfile
err = json.Unmarshal(responseBody, &result)
if err != nil {
return nil, err
}
return &result, nil
}

View file

@ -0,0 +1,55 @@
package openamt
import (
"encoding/json"
"fmt"
"net/http"
"strings"
portainer "github.com/portainer/portainer/api"
)
type ActionResponse struct {
Body struct {
ReturnValue int `json:"ReturnValue"`
ReturnValueStr string `json:"ReturnValueStr"`
} `json:"Body"`
}
func (service *Service) executeDeviceAction(configuration portainer.OpenAMTConfiguration, deviceGUID string, action int) error {
url := fmt.Sprintf("https://%s/mps/api/v1/amt/power/action/%s", configuration.MPSServer, deviceGUID)
payload := map[string]int{
"action": action,
}
jsonValue, _ := json.Marshal(payload)
responseBody, err := service.executeSaveRequest(http.MethodPost, url, configuration.MPSToken, jsonValue)
if err != nil {
return err
}
var response ActionResponse
err = json.Unmarshal(responseBody, &response)
if err != nil {
return err
}
if response.Body.ReturnValue != 0 {
return fmt.Errorf("failed to execute action, error status %v: %s", response.Body.ReturnValue, response.Body.ReturnValueStr)
}
return nil
}
func parseAction(actionRaw string) (portainer.PowerState, error) {
switch strings.ToLower(actionRaw) {
case "power on":
return powerOnState, nil
case "power off":
return powerOffState, nil
case "restart":
return restartState, nil
}
return 0, fmt.Errorf("unsupported device action %s", actionRaw)
}

View file

@ -0,0 +1,29 @@
package openamt
import (
"encoding/json"
"fmt"
"net/http"
portainer "github.com/portainer/portainer/api"
)
func (service *Service) enableDeviceFeatures(configuration portainer.OpenAMTConfiguration, deviceGUID string, features portainer.OpenAMTDeviceEnabledFeatures) error {
url := fmt.Sprintf("https://%s/mps/api/v1/amt/features/%s", configuration.MPSServer, deviceGUID)
payload := map[string]interface{}{
"enableSOL": features.SOL,
"enableIDER": features.IDER,
"enableKVM": features.KVM,
"redirection": features.Redirection,
"userConsent": features.UserConsent,
}
jsonValue, _ := json.Marshal(payload)
_, err := service.executeSaveRequest(http.MethodPost, url, configuration.MPSToken, jsonValue)
if err != nil {
return err
}
return nil
}

View file

@ -11,13 +11,18 @@ import (
"time" "time"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "golang.org/x/sync/errgroup"
) )
const ( const (
DefaultCIRAConfigName = "ciraConfigDefault" DefaultCIRAConfigName = "ciraConfigDefault"
DefaultWirelessConfigName = "wirelessProfileDefault" DefaultProfileName = "profileAMTDefault"
DefaultProfileName = "profileAMTDefault"
httpClientTimeout = 5 * time.Minute
powerOnState portainer.PowerState = 2
powerOffState portainer.PowerState = 8
restartState portainer.PowerState = 5
) )
// Service represents a service for managing an OpenAMT server. // Service represents a service for managing an OpenAMT server.
@ -26,13 +31,10 @@ type Service struct {
} }
// NewService initializes a new service. // NewService initializes a new service.
func NewService(dataStore dataservices.DataStore) *Service { func NewService() *Service {
if !dataStore.Settings().IsFeatureFlagEnabled(portainer.FeatOpenAMT) {
return nil
}
return &Service{ return &Service{
httpsClient: &http.Client{ httpsClient: &http.Client{
Timeout: time.Second * time.Duration(5), Timeout: httpClientTimeout,
Transport: &http.Transport{ Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}, },
@ -63,28 +65,19 @@ func parseError(responseBody []byte) error {
return nil return nil
} }
func (service *Service) ConfigureDefault(configuration portainer.OpenAMTConfiguration) error { func (service *Service) Configure(configuration portainer.OpenAMTConfiguration) error {
token, err := service.executeAuthenticationRequest(configuration) token, err := service.Authorization(configuration)
if err != nil { if err != nil {
return err return err
} }
configuration.Credentials.MPSToken = token.Token configuration.MPSToken = token
ciraConfig, err := service.createOrUpdateCIRAConfig(configuration, DefaultCIRAConfigName) ciraConfig, err := service.createOrUpdateCIRAConfig(configuration, DefaultCIRAConfigName)
if err != nil { if err != nil {
return err return err
} }
wirelessConfigName := "" _, err = service.createOrUpdateAMTProfile(configuration, DefaultProfileName, ciraConfig.ConfigName)
if configuration.WirelessConfiguration != nil {
wirelessConfig, err := service.createOrUpdateWirelessConfig(configuration, DefaultWirelessConfigName)
if err != nil {
return err
}
wirelessConfigName = wirelessConfig.ProfileName
}
_, err = service.createOrUpdateAMTProfile(configuration, DefaultProfileName, ciraConfig.ConfigName, wirelessConfigName)
if err != nil { if err != nil {
return err return err
} }
@ -119,7 +112,7 @@ func (service *Service) executeSaveRequest(method string, url string, token stri
if errorResponse != nil { if errorResponse != nil {
return nil, errorResponse return nil, errorResponse
} }
return nil, errors.New(fmt.Sprintf("unexpected status code %s", response.Status)) return nil, fmt.Errorf("unexpected status code %s", response.Status)
} }
return responseBody, nil return responseBody, nil
@ -151,8 +144,110 @@ func (service *Service) executeGetRequest(url string, token string) ([]byte, err
if errorResponse != nil { if errorResponse != nil {
return nil, errorResponse return nil, errorResponse
} }
return nil, errors.New(fmt.Sprintf("unexpected status code %s", response.Status)) return nil, fmt.Errorf("unexpected status code %s", response.Status)
} }
return responseBody, nil return responseBody, nil
} }
func (service *Service) DeviceInformation(configuration portainer.OpenAMTConfiguration, deviceGUID string) (*portainer.OpenAMTDeviceInformation, error) {
token, err := service.Authorization(configuration)
if err != nil {
return nil, err
}
configuration.MPSToken = token
var g errgroup.Group
var resultDevice *Device
var resultPowerState *DevicePowerState
var resultEnabledFeatures *DeviceEnabledFeatures
g.Go(func() error {
device, err := service.getDevice(configuration, deviceGUID)
if err != nil {
return err
}
if device == nil {
return fmt.Errorf("device %s not found", deviceGUID)
}
resultDevice = device
return nil
})
g.Go(func() error {
powerState, err := service.getDevicePowerState(configuration, deviceGUID)
if err != nil {
return err
}
resultPowerState = powerState
return nil
})
g.Go(func() error {
enabledFeatures, err := service.getDeviceEnabledFeatures(configuration, deviceGUID)
if err != nil {
return err
}
resultEnabledFeatures = enabledFeatures
return nil
})
if err := g.Wait(); err != nil {
return nil, err
}
deviceInformation := &portainer.OpenAMTDeviceInformation{
GUID: resultDevice.GUID,
HostName: resultDevice.HostName,
ConnectionStatus: resultDevice.ConnectionStatus,
}
if resultPowerState != nil {
deviceInformation.PowerState = resultPowerState.State
}
if resultEnabledFeatures != nil {
deviceInformation.EnabledFeatures = &portainer.OpenAMTDeviceEnabledFeatures{
Redirection: resultEnabledFeatures.Redirection,
KVM: resultEnabledFeatures.KVM,
SOL: resultEnabledFeatures.SOL,
IDER: resultEnabledFeatures.IDER,
UserConsent: resultEnabledFeatures.UserConsent,
}
}
return deviceInformation, nil
}
func (service *Service) ExecuteDeviceAction(configuration portainer.OpenAMTConfiguration, deviceGUID string, action string) error {
parsedAction, err := parseAction(action)
if err != nil {
return err
}
token, err := service.Authorization(configuration)
if err != nil {
return err
}
configuration.MPSToken = token
err = service.executeDeviceAction(configuration, deviceGUID, int(parsedAction))
if err != nil {
return err
}
return nil
}
func (service *Service) EnableDeviceFeatures(configuration portainer.OpenAMTConfiguration, deviceGUID string, features portainer.OpenAMTDeviceEnabledFeatures) (string, error) {
token, err := service.Authorization(configuration)
if err != nil {
return "", err
}
configuration.MPSToken = token
err = service.enableDeviceFeatures(configuration, deviceGUID, features)
if err != nil {
return "", err
}
return token, nil
}

View file

@ -11,6 +11,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/gofrs/uuid"
httperror "github.com/portainer/libhttp/error" httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request" "github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response" "github.com/portainer/libhttp/response"
@ -37,6 +38,7 @@ type endpointCreatePayload struct {
AzureAuthenticationKey string AzureAuthenticationKey string
TagIDs []portainer.TagID TagIDs []portainer.TagID
EdgeCheckinInterval int EdgeCheckinInterval int
IsEdgeDevice bool
} }
type endpointCreationEnum int type endpointCreationEnum int
@ -144,6 +146,9 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
checkinInterval, _ := request.RetrieveNumericMultiPartFormValue(r, "CheckinInterval", true) checkinInterval, _ := request.RetrieveNumericMultiPartFormValue(r, "CheckinInterval", true)
payload.EdgeCheckinInterval = checkinInterval payload.EdgeCheckinInterval = checkinInterval
isEdgeDevice, _ := request.RetrieveBooleanMultiPartFormValue(r, "IsEdgeDevice", true)
payload.IsEdgeDevice = isEdgeDevice
return nil return nil
} }
@ -332,6 +337,21 @@ func (handler *Handler) createEdgeAgentEndpoint(payload *endpointCreatePayload)
EdgeKey: edgeKey, EdgeKey: edgeKey,
EdgeCheckinInterval: payload.EdgeCheckinInterval, EdgeCheckinInterval: payload.EdgeCheckinInterval,
Kubernetes: portainer.KubernetesDefault(), Kubernetes: portainer.KubernetesDefault(),
IsEdgeDevice: payload.IsEdgeDevice,
}
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve the settings from the database", err}
}
if settings.EnforceEdgeID {
edgeID, err := uuid.NewV4()
if err != nil {
return nil, &httperror.HandlerError{http.StatusInternalServerError, "Cannot generate the Edge ID", err}
}
endpoint.EdgeID = edgeID.String()
} }
err = handler.saveEndpointAndUpdateAuthorizations(endpoint) err = handler.saveEndpointAndUpdateAuthorizations(endpoint)
@ -370,6 +390,7 @@ func (handler *Handler) createUnsecuredEndpoint(payload *endpointCreatePayload)
Status: portainer.EndpointStatusUp, Status: portainer.EndpointStatusUp,
Snapshots: []portainer.DockerSnapshot{}, Snapshots: []portainer.DockerSnapshot{},
Kubernetes: portainer.KubernetesDefault(), Kubernetes: portainer.KubernetesDefault(),
IsEdgeDevice: payload.IsEdgeDevice,
} }
err := handler.snapshotAndPersistEndpoint(endpoint) err := handler.snapshotAndPersistEndpoint(endpoint)
@ -435,6 +456,7 @@ func (handler *Handler) createTLSSecuredEndpoint(payload *endpointCreatePayload,
Status: portainer.EndpointStatusUp, Status: portainer.EndpointStatusUp,
Snapshots: []portainer.DockerSnapshot{}, Snapshots: []portainer.DockerSnapshot{},
Kubernetes: portainer.KubernetesDefault(), Kubernetes: portainer.KubernetesDefault(),
IsEdgeDevice: payload.IsEdgeDevice,
} }
err := handler.storeTLSFiles(endpoint, payload) err := handler.storeTLSFiles(endpoint, payload)

View file

@ -89,6 +89,11 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
filteredEndpoints = filterEndpointsByGroupID(filteredEndpoints, portainer.EndpointGroupID(groupID)) filteredEndpoints = filterEndpointsByGroupID(filteredEndpoints, portainer.EndpointGroupID(groupID))
} }
edgeDeviceFilter, edgeDeviceFilterErr := request.RetrieveBooleanQueryParameter(r, "edgeDeviceFilter", false)
if edgeDeviceFilterErr == nil {
filteredEndpoints = filterEndpointsByEdgeDevice(filteredEndpoints, edgeDeviceFilter)
}
if search != "" { if search != "" {
tags, err := handler.DataStore.Tag().Tags() tags, err := handler.DataStore.Tag().Tags()
if err != nil { if err != nil {
@ -231,6 +236,17 @@ func filterEndpointsByTypes(endpoints []portainer.Endpoint, endpointTypes []int)
return filteredEndpoints return filteredEndpoints
} }
func filterEndpointsByEdgeDevice(endpoints []portainer.Endpoint, edgeDeviceFilter bool) []portainer.Endpoint {
filteredEndpoints := make([]portainer.Endpoint, 0)
for _, endpoint := range endpoints {
if edgeDeviceFilter == endpoint.IsEdgeDevice {
filteredEndpoints = append(filteredEndpoints, endpoint)
}
}
return filteredEndpoints
}
func convertTagIDsToTags(tagsMap map[portainer.TagID]string, tagIDs []portainer.TagID) []string { func convertTagIDsToTags(tagsMap map[portainer.TagID]string, tagIDs []portainer.TagID) []string {
tags := make([]string, 0) tags := make([]string, 0)
for _, tagID := range tagIDs { for _, tagID := range tagIDs {

View file

@ -1,6 +1,7 @@
package endpoints package endpoints
import ( import (
"errors"
"net/http" "net/http"
httperror "github.com/portainer/libhttp/error" httperror "github.com/portainer/libhttp/error"
@ -37,7 +38,7 @@ func (handler *Handler) endpointSnapshot(w http.ResponseWriter, r *http.Request)
} }
if !snapshot.SupportDirectSnapshot(endpoint) { if !snapshot.SupportDirectSnapshot(endpoint) {
return &httperror.HandlerError{http.StatusBadRequest, "Snapshots not supported for this environment", err} return &httperror.HandlerError{http.StatusBadRequest, "Snapshots not supported for this environment", errors.New("Snapshots not supported for this environment")}
} }
snapshotError := handler.SnapshotService.SnapshotEndpoint(endpoint) snapshotError := handler.SnapshotService.SnapshotEndpoint(endpoint)

View file

@ -46,6 +46,8 @@ type endpointUpdatePayload struct {
EdgeCheckinInterval *int `example:"5"` EdgeCheckinInterval *int `example:"5"`
// Associated Kubernetes data // Associated Kubernetes data
Kubernetes *portainer.KubernetesData Kubernetes *portainer.KubernetesData
// Whether the device has been trusted or not by the user
UserTrusted *bool
} }
func (payload *endpointUpdatePayload) Validate(r *http.Request) error { func (payload *endpointUpdatePayload) Validate(r *http.Request) error {
@ -270,6 +272,10 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *
} }
} }
if payload.UserTrusted != nil {
endpoint.UserTrusted = *payload.UserTrusted
}
err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint) err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist environment changes inside the database", err} return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist environment changes inside the database", err}

View file

@ -17,6 +17,7 @@ import (
"github.com/portainer/portainer/api/http/handler/endpoints" "github.com/portainer/portainer/api/http/handler/endpoints"
"github.com/portainer/portainer/api/http/handler/file" "github.com/portainer/portainer/api/http/handler/file"
"github.com/portainer/portainer/api/http/handler/helm" "github.com/portainer/portainer/api/http/handler/helm"
"github.com/portainer/portainer/api/http/handler/hostmanagement/fdo"
"github.com/portainer/portainer/api/http/handler/hostmanagement/openamt" "github.com/portainer/portainer/api/http/handler/hostmanagement/openamt"
"github.com/portainer/portainer/api/http/handler/kubernetes" "github.com/portainer/portainer/api/http/handler/kubernetes"
"github.com/portainer/portainer/api/http/handler/ldap" "github.com/portainer/portainer/api/http/handler/ldap"
@ -64,6 +65,7 @@ type Handler struct {
SettingsHandler *settings.Handler SettingsHandler *settings.Handler
SSLHandler *ssl.Handler SSLHandler *ssl.Handler
OpenAMTHandler *openamt.Handler OpenAMTHandler *openamt.Handler
FDOHandler *fdo.Handler
StackHandler *stacks.Handler StackHandler *stacks.Handler
StatusHandler *status.Handler StatusHandler *status.Handler
StorybookHandler *storybook.Handler StorybookHandler *storybook.Handler
@ -228,9 +230,9 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
case strings.HasPrefix(r.URL.Path, "/api/ssl"): case strings.HasPrefix(r.URL.Path, "/api/ssl"):
http.StripPrefix("/api", h.SSLHandler).ServeHTTP(w, r) http.StripPrefix("/api", h.SSLHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/open_amt"): case strings.HasPrefix(r.URL.Path, "/api/open_amt"):
if h.OpenAMTHandler != nil { http.StripPrefix("/api", h.OpenAMTHandler).ServeHTTP(w, r)
http.StripPrefix("/api", h.OpenAMTHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/fdo"):
} http.StripPrefix("/api", h.FDOHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/teams"): case strings.HasPrefix(r.URL.Path, "/api/teams"):
http.StripPrefix("/api", h.TeamHandler).ServeHTTP(w, r) http.StripPrefix("/api", h.TeamHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/team_memberships"): case strings.HasPrefix(r.URL.Path, "/api/team_memberships"):

View file

@ -0,0 +1,188 @@
package fdo
import (
"encoding/hex"
"errors"
"net/http"
"net/url"
"strings"
"github.com/fxamacker/cbor/v2"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
"github.com/sirupsen/logrus"
)
const (
deploymentScriptName = "fdo.sh"
)
type deviceConfigurePayload struct {
EdgeID string `json:"edgeID"`
EdgeKey string `json:"edgeKey"`
Name string `json:"name"`
ProfileID int `json:"profile"`
}
func (payload *deviceConfigurePayload) Validate(r *http.Request) error {
if payload.EdgeID == "" {
return errors.New("invalid edge ID provided")
}
if payload.EdgeKey == "" {
return errors.New("invalid edge key provided")
}
if payload.Name == "" {
return errors.New("the device name cannot be empty")
}
if payload.ProfileID < 1 {
return errors.New("invalid profile id provided")
}
return nil
}
// @id fdoConfigureDevice
// @summary configures an FDO device
// @description configures an FDO device
// @description **Access policy**: administrator
// @tags intel
// @security jwt
// @produce json
// @param body body deviceConfigurePayload true "Device Configuration"
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied to access settings"
// @failure 500 "Server error"
// @router /fdo/configure/{guid} [post]
func (handler *Handler) fdoConfigureDevice(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
guid, err := request.RetrieveRouteVariableValue(r, "guid")
if err != nil {
logrus.WithError(err).Info("fdoConfigureDevice: request.RetrieveRouteVariableValue()")
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoConfigureDevice: guid not found", Err: err}
}
var payload deviceConfigurePayload
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
logrus.WithError(err).Error("Invalid request payload")
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
}
profile, err := handler.DataStore.FDOProfile().FDOProfile(portainer.FDOProfileID(payload.ProfileID))
if handler.DataStore.IsErrObjectNotFound(err) {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a FDO Profile with the specified identifier inside the database", err}
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a FDO Profile with the specified identifier inside the database", err}
}
fileContent, err := handler.FileService.GetFileContent(profile.FilePath, "")
if err != nil {
logrus.WithError(err).Info("fdoConfigureDevice: GetFileContent")
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoConfigureDevice: GetFileContent", Err: err}
}
fdoClient, err := handler.newFDOClient()
if err != nil {
logrus.WithError(err).Info("fdoConfigureDevice: newFDOClient()")
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoConfigureDevice: newFDOClient()", Err: err}
}
// enable fdo_sys
if err = fdoClient.PutDeviceSVIRaw(url.Values{
"guid": []string{guid},
"priority": []string{"0"},
"module": []string{"fdo_sys"},
"var": []string{"active"},
"bytes": []string{"F5"}, // this is "true" in CBOR
}, []byte("")); err != nil {
logrus.WithError(err).Info("fdoConfigureDevice: PutDeviceSVIRaw()")
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoConfigureDevice: PutDeviceSVIRaw()", Err: err}
}
if err = fdoClient.PutDeviceSVIRaw(url.Values{
"guid": []string{guid},
"priority": []string{"1"},
"module": []string{"fdo_sys"},
"var": []string{"filedesc"},
"filename": []string{"DEVICE_edgeid.txt"},
}, []byte(payload.EdgeID)); err != nil {
logrus.WithError(err).Info("fdoConfigureDevice: PutDeviceSVIRaw(edgeid)")
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoConfigureDevice: PutDeviceSVIRaw(edgeid)", Err: err}
}
// write down the edgekey
if err = fdoClient.PutDeviceSVIRaw(url.Values{
"guid": []string{guid},
"priority": []string{"1"},
"module": []string{"fdo_sys"},
"var": []string{"filedesc"},
"filename": []string{"DEVICE_edgekey.txt"},
}, []byte(payload.EdgeKey)); err != nil {
logrus.WithError(err).Info("fdoConfigureDevice: PutDeviceSVIRaw(edgekey)")
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoConfigureDevice: PutDeviceSVIRaw(edgekey)", Err: err}
}
// write down the device name
if err = fdoClient.PutDeviceSVIRaw(url.Values{
"guid": []string{guid},
"priority": []string{"1"},
"module": []string{"fdo_sys"},
"var": []string{"filedesc"},
"filename": []string{"DEVICE_name.txt"},
}, []byte(payload.Name)); err != nil {
logrus.WithError(err).Info("fdoConfigureDevice: PutDeviceSVIRaw(name)")
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoConfigureDevice: PutDeviceSVIRaw(name)", Err: err}
}
// write down the device GUID - used as the EDGE_DEVICE_GUID too
if err = fdoClient.PutDeviceSVIRaw(url.Values{
"guid": []string{guid},
"priority": []string{"1"},
"module": []string{"fdo_sys"},
"var": []string{"filedesc"},
"filename": []string{"DEVICE_GUID.txt"},
}, []byte(guid)); err != nil {
logrus.WithError(err).Info("fdoConfigureDevice: PutDeviceSVIRaw()")
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoConfigureDevice: PutDeviceSVIRaw()", Err: err}
}
if err = fdoClient.PutDeviceSVIRaw(url.Values{
"guid": []string{guid},
"priority": []string{"1"},
"module": []string{"fdo_sys"},
"var": []string{"filedesc"},
"filename": []string{deploymentScriptName},
}, fileContent); err != nil {
logrus.WithError(err).Info("fdoConfigureDevice: PutDeviceSVIRaw()")
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoConfigureDevice: PutDeviceSVIRaw()", Err: err}
}
b, err := cbor.Marshal([]string{"/bin/sh", deploymentScriptName})
if err != nil {
logrus.WithError(err).Error("failed to marshal string to CBOR")
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoConfigureDevice: PutDeviceSVIRaw() failed to encode", Err: err}
}
cborBytes := strings.ToUpper(hex.EncodeToString(b))
logrus.WithField("cbor", cborBytes).WithField("string", deploymentScriptName).Info("converted to CBOR")
if err = fdoClient.PutDeviceSVIRaw(url.Values{
"guid": []string{guid},
"priority": []string{"2"},
"module": []string{"fdo_sys"},
"var": []string{"exec"},
"bytes": []string{cborBytes},
}, []byte("")); err != nil {
logrus.WithError(err).Info("fdoConfigureDevice: PutDeviceSVIRaw()")
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoConfigureDevice: PutDeviceSVIRaw()", Err: err}
}
return response.Empty(w)
}

View file

@ -0,0 +1,164 @@
package fdo
import (
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"time"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/hostmanagement/fdo"
"github.com/sirupsen/logrus"
)
type fdoConfigurePayload portainer.FDOConfiguration
func validateURL(u string) error {
p, err := url.Parse(u)
if err != nil {
return err
}
if p.Scheme != "http" && p.Scheme != "https" {
return errors.New("invalid scheme provided, must be 'http' or 'https'")
}
if p.Host == "" {
return errors.New("invalid host provided")
}
return nil
}
func (payload *fdoConfigurePayload) Validate(r *http.Request) error {
if payload.Enabled {
if err := validateURL(payload.OwnerURL); err != nil {
return fmt.Errorf("owner server URL: %w", err)
}
}
return nil
}
func (handler *Handler) saveSettings(config portainer.FDOConfiguration) error {
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return err
}
settings.FDOConfiguration = config
return handler.DataStore.Settings().UpdateSettings(settings)
}
func (handler *Handler) newFDOClient() (fdo.FDOOwnerClient, error) {
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return fdo.FDOOwnerClient{}, err
}
return fdo.FDOOwnerClient{
OwnerURL: settings.FDOConfiguration.OwnerURL,
Username: settings.FDOConfiguration.OwnerUsername,
Password: settings.FDOConfiguration.OwnerPassword,
Timeout: 5 * time.Second,
}, nil
}
// @id fdoConfigure
// @summary Enable Portainer's FDO capabilities
// @description Enable Portainer's FDO capabilities
// @description **Access policy**: administrator
// @tags intel
// @security jwt
// @accept json
// @produce json
// @param body body fdoConfigurePayload true "FDO Settings"
// @success 204 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied to access settings"
// @failure 500 "Server error"
// @router /fdo [post]
func (handler *Handler) fdoConfigure(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload fdoConfigurePayload
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
logrus.WithError(err).Error("Invalid request payload")
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
}
settings := portainer.FDOConfiguration(payload)
if err = handler.saveSettings(settings); err != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Error saving FDO settings", Err: err}
}
profiles, err := handler.DataStore.FDOProfile().FDOProfiles()
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Error saving FDO settings", Err: err}
}
if len(profiles) == 0 {
err = handler.addDefaultProfile()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err}
}
}
return response.Empty(w)
}
func (handler *Handler) addDefaultProfile() error {
profileID := handler.DataStore.FDOProfile().GetNextIdentifier()
profile := &portainer.FDOProfile{
ID: portainer.FDOProfileID(profileID),
Name: "Docker Standalone + Edge",
}
filePath, err := handler.FileService.StoreFDOProfileFileFromBytes(strconv.Itoa(int(profile.ID)), []byte(defaultProfileFileContent))
if err != nil {
return err
}
profile.FilePath = filePath
profile.DateCreated = time.Now().Unix()
err = handler.DataStore.FDOProfile().Create(profile)
if err != nil {
return err
}
return nil
}
const defaultProfileFileContent = `
#!/bin/bash -ex
env > env.log
export AGENT_IMAGE=portainer/agent:2.11.0
export GUID=$(cat DEVICE_GUID.txt)
export DEVICE_NAME=$(cat DEVICE_name.txt)
export EDGE_ID=$(cat DEVICE_edgeid.txt)
export EDGE_KEY=$(cat DEVICE_edgekey.txt)
export AGENTVOLUME=$(pwd)/data/portainer_agent_data/
mkdir -p ${AGENTVOLUME}
docker pull ${AGENT_IMAGE}
docker run -d \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /var/lib/docker/volumes:/var/lib/docker/volumes \
-v /:/host \
-v ${AGENTVOLUME}:/data \
--restart always \
-e EDGE=1 \
-e EDGE_ID=${EDGE_ID} \
-e EDGE_KEY=${EDGE_KEY} \
-e CAP_HOST_MANAGEMENT=1 \
-e EDGE_INSECURE_POLL=1 \
--name portainer_edge_agent \
${AGENT_IMAGE}
`

View file

@ -0,0 +1,40 @@
package fdo
import (
"net/http"
"github.com/gorilla/mux"
httperror "github.com/portainer/libhttp/error"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
)
type Handler struct {
*mux.Router
DataStore dataservices.DataStore
FileService portainer.FileService
}
func NewHandler(bouncer *security.RequestBouncer, dataStore dataservices.DataStore, fileService portainer.FileService) *Handler {
h := &Handler{
Router: mux.NewRouter(),
DataStore: dataStore,
FileService: fileService,
}
h.Handle("/fdo/configure", bouncer.AdminAccess(httperror.LoggerHandler(h.fdoConfigure))).Methods(http.MethodPost)
h.Handle("/fdo/list", bouncer.AdminAccess(httperror.LoggerHandler(h.fdoListAll))).Methods(http.MethodGet)
h.Handle("/fdo/register", bouncer.AdminAccess(httperror.LoggerHandler(h.fdoRegisterDevice))).Methods(http.MethodPost)
h.Handle("/fdo/configure/{guid}", bouncer.AdminAccess(httperror.LoggerHandler(h.fdoConfigureDevice))).Methods(http.MethodPost)
h.Handle("/fdo/profiles", bouncer.AdminAccess(httperror.LoggerHandler(h.fdoProfileList))).Methods(http.MethodGet)
h.Handle("/fdo/profiles", bouncer.AdminAccess(httperror.LoggerHandler(h.createProfile))).Methods(http.MethodPost)
h.Handle("/fdo/profiles/{id}", bouncer.AdminAccess(httperror.LoggerHandler(h.fdoProfileInspect))).Methods(http.MethodGet)
h.Handle("/fdo/profiles/{id}", bouncer.AdminAccess(httperror.LoggerHandler(h.updateProfile))).Methods(http.MethodPut)
h.Handle("/fdo/profiles/{id}", bouncer.AdminAccess(httperror.LoggerHandler(h.deleteProfile))).Methods(http.MethodDelete)
h.Handle("/fdo/profiles/{id}/duplicate", bouncer.AdminAccess(httperror.LoggerHandler(h.duplicateProfile))).Methods(http.MethodPost)
return h
}

View file

@ -0,0 +1,39 @@
package fdo
import (
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/sirupsen/logrus"
"github.com/portainer/libhttp/response"
)
// @id fdoListAll
// @summary List all known FDO vouchers
// @description List all known FDO vouchers
// @description **Access policy**: administrator
// @tags intel
// @security jwt
// @produce json
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied to access settings"
// @failure 500 "Server error"
// @router /fdo/list [get]
func (handler *Handler) fdoListAll(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
fdoClient, err := handler.newFDOClient()
if err != nil {
logrus.WithError(err).Info("fdoListAll: newFDOClient()")
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoRegisterDevice: newFDOClient()", Err: err}
}
// Get all vouchers
guids, err := fdoClient.GetVouchers()
if err != nil {
logrus.WithError(err).Info("fdoListAll: GetVouchers()")
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoListAll: GetVouchers()", Err: err}
}
return response.JSON(w, guids)
}

View file

@ -0,0 +1,92 @@
package fdo
import (
"errors"
"fmt"
"net/http"
"strconv"
"time"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
)
type createProfileFromFileContentPayload struct {
Name string
ProfileFileContent string
}
func (payload *createProfileFromFileContentPayload) Validate(r *http.Request) error {
if payload.Name == "" {
return errors.New("profile name must be provided")
}
if payload.ProfileFileContent == "" {
return errors.New("profile file content must be provided")
}
return nil
}
// @id createProfile
// @summary creates a new FDO Profile
// @description creates a new FDO Profile
// @description **Access policy**: administrator
// @tags intel
// @security jwt
// @produce json
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 409 "Profile name already exists"
// @failure 500 "Server error"
// @router /fdo/profiles [post]
func (handler *Handler) createProfile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
method, err := request.RetrieveQueryParameter(r, "method", false)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: method", err}
}
switch method {
case "editor":
return handler.createFDOProfileFromFileContent(w, r)
}
return &httperror.HandlerError{http.StatusBadRequest, "Invalid method. Value must be one of: editor", errors.New("invalid method")}
}
func (handler *Handler) createFDOProfileFromFileContent(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload createProfileFromFileContentPayload
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
isUnique, err := handler.checkUniqueProfileName(payload.Name, -1)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err}
}
if !isUnique {
return &httperror.HandlerError{http.StatusConflict, fmt.Sprintf("A profile with the name '%s' already exists", payload.Name), errors.New("a profile already exists with this name")}
}
profileID := handler.DataStore.FDOProfile().GetNextIdentifier()
profile := &portainer.FDOProfile{
ID: portainer.FDOProfileID(profileID),
Name: payload.Name,
}
filePath, err := handler.FileService.StoreFDOProfileFileFromBytes(strconv.Itoa(int(profile.ID)), []byte(payload.ProfileFileContent))
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist profile file on disk", err}
}
profile.FilePath = filePath
profile.DateCreated = time.Now().Unix()
err = handler.DataStore.FDOProfile().Create(profile)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the profile inside the database", err}
}
return response.JSON(w, profile)
}

View file

@ -0,0 +1,36 @@
package fdo
import (
"errors"
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
)
// @id deleteProfile
// @summary deletes a FDO Profile
// @description deletes a FDO Profile
// @description **Access policy**: administrator
// @tags intel
// @security jwt
// @produce json
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 500 "Server error"
// @router /fdo/profiles/{id} [delete]
func (handler *Handler) deleteProfile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
id, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Bad request", errors.New("missing 'id' query parameter")}
}
err = handler.DataStore.FDOProfile().Delete(portainer.FDOProfileID(id))
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to delete Profile", err}
}
return response.Empty(w)
}

View file

@ -0,0 +1,67 @@
package fdo
import (
"errors"
"fmt"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
"net/http"
"strconv"
"time"
)
// @id duplicate
// @summary duplicated an existing FDO Profile
// @description duplicated an existing FDO Profile
// @description **Access policy**: administrator
// @tags intel
// @security jwt
// @produce json
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 500 "Server error"
// @router /fdo/profiles/{id}/duplicate [post]
func (handler *Handler) duplicateProfile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
id, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Bad request", errors.New("missing 'id' query parameter")}
}
originalProfile, err := handler.DataStore.FDOProfile().FDOProfile(portainer.FDOProfileID(id))
if handler.DataStore.IsErrObjectNotFound(err) {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a FDO Profile with the specified identifier inside the database", err}
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a FDO Profile with the specified identifier inside the database", err}
}
fileContent, err := handler.FileService.GetFileContent(originalProfile.FilePath, "")
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Profile file content", err}
}
profileID := handler.DataStore.FDOProfile().GetNextIdentifier()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to duplicate Profile", err}
}
newProfile := &portainer.FDOProfile{
ID: portainer.FDOProfileID(profileID),
Name: fmt.Sprintf("%s - copy", originalProfile.Name),
}
filePath, err := handler.FileService.StoreFDOProfileFileFromBytes(strconv.Itoa(int(newProfile.ID)), fileContent)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist profile file on disk", err}
}
newProfile.FilePath = filePath
newProfile.DateCreated = time.Now().Unix()
err = handler.DataStore.FDOProfile().Create(newProfile)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the profile inside the database", err}
}
return response.JSON(w, newProfile)
}

View file

@ -0,0 +1,49 @@
package fdo
import (
"errors"
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
)
type fdoProfileResponse struct {
Name string `json:"name"`
FileContent string `json:"fileContent"`
}
// @id fdoProfileInspect
// @summary retrieves a given FDO profile information and content
// @description retrieves a given FDO profile information and content
// @description **Access policy**: administrator
// @tags intel
// @security jwt
// @produce json
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 500 "Server error"
// @router /fdo/profiles/{id} [get]
func (handler *Handler) fdoProfileInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
id, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Bad request", errors.New("missing 'id' query parameter")}
}
profile, err := handler.DataStore.FDOProfile().FDOProfile(portainer.FDOProfileID(id))
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Profile", err}
}
fileContent, err := handler.FileService.GetFileContent(profile.FilePath, "")
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Profile file content", err}
}
return response.JSON(w, fdoProfileResponse{
Name: profile.Name,
FileContent: string(fileContent),
})
}

View file

@ -0,0 +1,31 @@
package fdo
import (
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/response"
)
// @id fdoProfileList
// @summary retrieves all FDO profiles
// @description retrieves all FDO profiles
// @description **Access policy**: administrator
// @tags intel
// @security jwt
// @produce json
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied to access settings"
// @failure 500 "Server error"
// @failure 500 "Bad gateway"
// @router /fdo/profiles [get]
func (handler *Handler) fdoProfileList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
profiles, err := handler.DataStore.FDOProfile().FDOProfiles()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err}
}
return response.JSON(w, profiles)
}

View file

@ -0,0 +1,67 @@
package fdo
import (
"errors"
"fmt"
"net/http"
"strconv"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
)
// @id updateProfile
// @summary updates an existing FDO Profile
// @description updates an existing FDO Profile
// @description **Access policy**: administrator
// @tags intel
// @security jwt
// @produce json
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 409 "Profile name already exists"
// @failure 500 "Server error"
// @router /fdo/profiles/{id} [put]
func (handler *Handler) updateProfile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
id, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Bad request", errors.New("missing 'id' query parameter")}
}
var payload createProfileFromFileContentPayload
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
profile, err := handler.DataStore.FDOProfile().FDOProfile(portainer.FDOProfileID(id))
if handler.DataStore.IsErrObjectNotFound(err) {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a FDO Profile with the specified identifier inside the database", err}
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a FDO Profile with the specified identifier inside the database", err}
}
isUnique, err := handler.checkUniqueProfileName(payload.Name, id)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err}
}
if !isUnique {
return &httperror.HandlerError{http.StatusConflict, fmt.Sprintf("A profile with the name '%s' already exists", payload.Name), errors.New("a profile already exists with this name")}
}
filePath, err := handler.FileService.StoreFDOProfileFileFromBytes(strconv.Itoa(int(profile.ID)), []byte(payload.ProfileFileContent))
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update profile", err}
}
profile.FilePath = filePath
profile.Name = payload.Name
err = handler.DataStore.FDOProfile().Update(profile.ID, profile)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update profile", err}
}
return response.JSON(w, profile)
}

View file

@ -0,0 +1,49 @@
package fdo
import (
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
"github.com/sirupsen/logrus"
)
type registerDeviceResponse struct {
Guid string `json:"guid" example:"c6ea3343-229a-4c07-9096-beef7134e1d3"`
}
// @id fdoRegisterDevice
// @summary register an FDO device
// @description register an FDO device
// @description **Access policy**: administrator
// @tags intel
// @security jwt
// @produce json
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied to access settings"
// @failure 500 "Server error"
// @router /fdo/register [post]
func (handler *Handler) fdoRegisterDevice(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
// Post a voucher
ov, filename, err := request.RetrieveMultiPartFormFile(r, "voucher")
if err != nil {
logrus.WithField("filename", filename).WithError(err).Info("fdoRegisterDevice: readVoucher()")
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoRegisterDevice: read Voucher()", Err: err}
}
fdoClient, err := handler.newFDOClient()
if err != nil {
logrus.WithError(err).Info("fdoRegisterDevice: newFDOClient()")
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoRegisterDevice: newFDOClient()", Err: err}
}
guid, err := fdoClient.PostVoucher(ov)
if err != nil {
logrus.WithError(err).Info("fdoRegisterDevice: PostVoucher()")
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoRegisterDevice: PostVoucher()", Err: err}
}
return response.JSON(w, registerDeviceResponse{guid})
}

View file

@ -0,0 +1,16 @@
package fdo
func (handler *Handler) checkUniqueProfileName(name string, id int) (bool, error) {
profiles, err := handler.DataStore.FDOProfile().FDOProfiles()
if err != nil {
return false, err
}
for _, profile := range profiles {
if profile.Name == name && (id == -1 || id != int(profile.ID)) {
return false, nil
}
}
return true, nil
}

View file

@ -0,0 +1,73 @@
package openamt
import (
"errors"
"fmt"
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
bolterrors "github.com/portainer/portainer/api/dataservices/errors"
"github.com/portainer/portainer/api/internal/endpointutils"
)
// @id openAMTActivate
// @summary Activate OpenAMT device and associate to agent endpoint
// @description Activate OpenAMT device and associate to agent endpoint
// @description **Access policy**: administrator
// @tags intel
// @security jwt
// @produce json
// @param id path int true "Environment(Endpoint) identifier"
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied to access settings"
// @failure 500 "Server error"
// @router /open_amt/{id}/activate [post]
func (handler *Handler) openAMTActivate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment identifier route variable", err}
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err}
} else if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err}
} else if !endpointutils.IsAgentEndpoint(endpoint) {
errMsg := fmt.Sprintf("%s is not an agent environment", endpoint.Name)
return &httperror.HandlerError{http.StatusBadRequest, errMsg, errors.New(errMsg)}
}
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err}
}
err = handler.activateDevice(endpoint, *settings)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to activate device", err}
}
hostInfo, _, err := handler.getEndpointAMTInfo(endpoint)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve AMT information", Err: err}
}
if hostInfo.ControlModeRaw < 1 {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Failed to activate device", Err: errors.New("failed to activate device")}
}
if hostInfo.UUID == "" {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve device UUID", Err: errors.New("unable to retrieve device UUID")}
}
endpoint.AMTDeviceGUID = hostInfo.UUID
err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist environment changes inside the database", err}
}
return response.Empty(w)
}

View file

@ -16,23 +16,19 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
) )
type openAMTConfigureDefaultPayload struct { type openAMTConfigurePayload struct {
EnableOpenAMT bool Enabled bool
MPSServer string MPSServer string
MPSUser string MPSUser string
MPSPassword string MPSPassword string
CertFileText string DomainName string
CertPassword string CertFileName string
DomainName string CertFileContent string
UseWirelessConfig bool CertFilePassword string
WifiAuthenticationMethod string
WifiEncryptionMethod string
WifiSSID string
WifiPskPass string
} }
func (payload *openAMTConfigureDefaultPayload) Validate(r *http.Request) error { func (payload *openAMTConfigurePayload) Validate(r *http.Request) error {
if payload.EnableOpenAMT { if payload.Enabled {
if payload.MPSServer == "" { if payload.MPSServer == "" {
return errors.New("MPS Server must be provided") return errors.New("MPS Server must be provided")
} }
@ -45,32 +41,21 @@ func (payload *openAMTConfigureDefaultPayload) Validate(r *http.Request) error {
if payload.DomainName == "" { if payload.DomainName == "" {
return errors.New("domain name must be provided") return errors.New("domain name must be provided")
} }
if payload.CertFileText == "" { if payload.CertFileContent == "" {
return errors.New("certificate file must be provided") return errors.New("certificate file must be provided")
} }
if payload.CertPassword == "" { if payload.CertFileName == "" {
return errors.New("certificate password must be provided") return errors.New("certificate file name must be provided")
} }
if payload.UseWirelessConfig { if payload.CertFilePassword == "" {
if payload.WifiAuthenticationMethod == "" { return errors.New("certificate password must be provided")
return errors.New("wireless authentication method must be provided")
}
if payload.WifiEncryptionMethod == "" {
return errors.New("wireless encryption method must be provided")
}
if payload.WifiSSID == "" {
return errors.New("wireless config SSID must be provided")
}
if payload.WifiPskPass == "" {
return errors.New("wireless config PSK passphrase must be provided")
}
} }
} }
return nil return nil
} }
// @id OpenAMTConfigureDefault // @id OpenAMTConfigure
// @summary Enable Portainer's OpenAMT capabilities // @summary Enable Portainer's OpenAMT capabilities
// @description Enable Portainer's OpenAMT capabilities // @description Enable Portainer's OpenAMT capabilities
// @description **Access policy**: administrator // @description **Access policy**: administrator
@ -78,22 +63,22 @@ func (payload *openAMTConfigureDefaultPayload) Validate(r *http.Request) error {
// @security jwt // @security jwt
// @accept json // @accept json
// @produce json // @produce json
// @param body body openAMTConfigureDefaultPayload true "OpenAMT Settings" // @param body body openAMTConfigurePayload true "OpenAMT Settings"
// @success 204 "Success" // @success 204 "Success"
// @failure 400 "Invalid request" // @failure 400 "Invalid request"
// @failure 403 "Permission denied to access settings" // @failure 403 "Permission denied to access settings"
// @failure 500 "Server error" // @failure 500 "Server error"
// @router /open_amt [post] // @router /open_amt [post]
func (handler *Handler) openAMTConfigureDefault(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { func (handler *Handler) openAMTConfigure(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload openAMTConfigureDefaultPayload var payload openAMTConfigurePayload
err := request.DecodeAndValidateJSONPayload(r, &payload) err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil { if err != nil {
logrus.WithError(err).Error("Invalid request payload") logrus.WithError(err).Error("Invalid request payload")
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err} return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
} }
if payload.EnableOpenAMT { if payload.Enabled {
certificateErr := validateCertificate(payload.CertFileText, payload.CertPassword) certificateErr := validateCertificate(payload.CertFileContent, payload.CertFilePassword)
if certificateErr != nil { if certificateErr != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Error validating certificate", Err: certificateErr} return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Error validating certificate", Err: certificateErr}
} }
@ -143,31 +128,19 @@ func isValidIssuer(issuer string) bool {
strings.Contains(formattedIssuer, "godaddy") strings.Contains(formattedIssuer, "godaddy")
} }
func (handler *Handler) enableOpenAMT(configurationPayload openAMTConfigureDefaultPayload) error { func (handler *Handler) enableOpenAMT(configurationPayload openAMTConfigurePayload) error {
configuration := portainer.OpenAMTConfiguration{ configuration := portainer.OpenAMTConfiguration{
Enabled: true, Enabled: true,
MPSServer: configurationPayload.MPSServer, MPSServer: configurationPayload.MPSServer,
Credentials: portainer.MPSCredentials{ MPSUser: configurationPayload.MPSUser,
MPSUser: configurationPayload.MPSUser, MPSPassword: configurationPayload.MPSPassword,
MPSPassword: configurationPayload.MPSPassword, CertFileContent: configurationPayload.CertFileContent,
}, CertFileName: configurationPayload.CertFileName,
DomainConfiguration: portainer.DomainConfiguration{ CertFilePassword: configurationPayload.CertFilePassword,
CertFileText: configurationPayload.CertFileText, DomainName: configurationPayload.DomainName,
CertPassword: configurationPayload.CertPassword,
DomainName: configurationPayload.DomainName,
},
} }
if configurationPayload.UseWirelessConfig { err := handler.OpenAMTService.Configure(configuration)
configuration.WirelessConfiguration = &portainer.WirelessConfiguration{
AuthenticationMethod: configurationPayload.WifiAuthenticationMethod,
EncryptionMethod: configurationPayload.WifiEncryptionMethod,
SSID: configurationPayload.WifiSSID,
PskPass: configurationPayload.WifiPskPass,
}
}
err := handler.OpenAMTService.ConfigureDefault(configuration)
if err != nil { if err != nil {
logrus.WithError(err).Error("error configuring OpenAMT server") logrus.WithError(err).Error("error configuring OpenAMT server")
return err return err
@ -189,7 +162,7 @@ func (handler *Handler) saveConfiguration(configuration portainer.OpenAMTConfigu
return err return err
} }
configuration.Credentials.MPSToken = "" configuration.MPSToken = ""
settings.OpenAMTConfiguration = configuration settings.OpenAMTConfiguration = configuration
err = handler.DataStore.Settings().UpdateSettings(settings) err = handler.DataStore.Settings().UpdateSettings(settings)

View file

@ -0,0 +1,178 @@
package openamt
import (
"errors"
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
bolterrors "github.com/portainer/portainer/api/dataservices/errors"
"github.com/sirupsen/logrus"
)
// @id OpenAMTDevices
// @summary Fetch OpenAMT managed devices information for endpoint
// @description Fetch OpenAMT managed devices information for endpoint
// @description **Access policy**: administrator
// @tags intel
// @security jwt
// @produce json
// @param id path int true "Environment(Endpoint) identifier"
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied to access settings"
// @failure 500 "Server error"
// @router /open_amt/{id}/devices [get]
func (handler *Handler) openAMTDevices(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment identifier route variable", err}
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err}
} else if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err}
}
if endpoint.AMTDeviceGUID == "" {
return response.JSON(w, []portainer.OpenAMTDeviceInformation{})
}
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err}
}
device, err := handler.OpenAMTService.DeviceInformation(settings.OpenAMTConfiguration, endpoint.AMTDeviceGUID)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve device information", err}
}
devices := []portainer.OpenAMTDeviceInformation{
*device,
}
return response.JSON(w, devices)
}
type deviceActionPayload struct {
Action string
}
func (payload *deviceActionPayload) Validate(r *http.Request) error {
if payload.Action == "" {
return errors.New("device action must be provided")
}
return nil
}
// @id DeviceAction
// @summary Execute out of band action on an AMT managed device
// @description Execute out of band action on an AMT managed device
// @description **Access policy**: administrator
// @tags intel
// @security jwt
// @accept json
// @produce json
// @param body body deviceActionPayload true "Device Action"
// @success 204 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied to access settings"
// @failure 500 "Server error"
// @router /open_amt/{id}/devices/{deviceId}/action [post]
func (handler *Handler) deviceAction(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
deviceID, err := request.RetrieveRouteVariableValue(r, "deviceId")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid device identifier route variable", err}
}
var payload deviceActionPayload
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
logrus.WithError(err).Error("Invalid request payload")
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
}
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err}
}
err = handler.OpenAMTService.ExecuteDeviceAction(settings.OpenAMTConfiguration, deviceID, payload.Action)
if err != nil {
logrus.WithError(err).Error("Error executing device action")
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Error executing device action", Err: err}
}
return response.Empty(w)
}
type deviceFeaturesPayload struct {
Features portainer.OpenAMTDeviceEnabledFeatures
}
func (payload *deviceFeaturesPayload) Validate(r *http.Request) error {
if payload.Features.UserConsent == "" {
return errors.New("device user consent status must be provided")
}
return nil
}
type AuthorizationResponse struct {
Server string
Token string
}
// @id DeviceFeatures
// @summary Enable features on an AMT managed device
// @description Enable features on an AMT managed device
// @description **Access policy**: administrator
// @tags intel
// @security jwt
// @accept json
// @produce json
// @param body body deviceFeaturesPayload true "Device Features"
// @success 204 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied to access settings"
// @failure 500 "Server error"
// @router /open_amt/{id}/devices_features/{deviceId} [post]
func (handler *Handler) deviceFeatures(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
deviceID, err := request.RetrieveRouteVariableValue(r, "deviceId")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid device identifier route variable", err}
}
var payload deviceFeaturesPayload
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
logrus.WithError(err).Error("Invalid request payload")
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
}
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err}
}
_, err = handler.OpenAMTService.DeviceInformation(settings.OpenAMTConfiguration, deviceID)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve device information", err}
}
token, err := handler.OpenAMTService.EnableDeviceFeatures(settings.OpenAMTConfiguration, deviceID, payload.Features)
if err != nil {
logrus.WithError(err).Error("Error executing device action")
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Error executing device action", Err: err}
}
authorizationResponse := AuthorizationResponse{
Server: settings.OpenAMTConfiguration.MPSServer,
Token: token,
}
return response.JSON(w, authorizationResponse)
}

View file

@ -0,0 +1,278 @@
package openamt
import (
"context"
"encoding/json"
"fmt"
"github.com/portainer/portainer/api/hostmanagement/openamt"
"io/ioutil"
"log"
"net/http"
"time"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/client"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
bolterrors "github.com/portainer/portainer/api/dataservices/errors"
"github.com/sirupsen/logrus"
)
type HostInfo struct {
EndpointID portainer.EndpointID `json:"EndpointID"`
RawOutput string `json:"RawOutput"`
AMT string `json:"AMT"`
UUID string `json:"UUID"`
DNSSuffix string `json:"DNS Suffix"`
BuildNumber string `json:"Build Number"`
ControlMode string `json:"Control Mode"`
ControlModeRaw int `json:"Control Mode (Raw)"`
}
const (
// TODO: this should get extracted to some configurable - don't assume Docker Hub is everyone's global namespace, or that they're allowed to pull images from the internet
rpcGoImageName = "ptrrd/openamt:rpc-go-json"
rpcGoContainerName = "openamt-rpc-go"
dockerClientTimeout = 5 * time.Minute
)
// @id OpenAMTHostInfo
// @summary Request OpenAMT info from a node
// @description Request OpenAMT info from a node
// @description **Access policy**: administrator
// @tags intel
// @security jwt
// @produce json
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied to access settings"
// @failure 500 "Server error"
// @router /open_amt/{id}/info [get]
func (handler *Handler) openAMTHostInfo(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment identifier route variable", err}
}
logrus.WithField("endpointID", endpointID).Info("OpenAMTHostInfo")
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err}
} else if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err}
}
amtInfo, output, err := handler.getEndpointAMTInfo(endpoint)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: output, Err: err}
}
return response.JSON(w, amtInfo)
}
func (handler *Handler) getEndpointAMTInfo(endpoint *portainer.Endpoint) (*HostInfo, string, error) {
ctx := context.TODO()
// pull the image so we can check if there's a new one
// TODO: these should be able to be over-ridden (don't hardcode the assumption that secure users can access Docker Hub, or that its even the orchestrator's "global namespace")
cmdLine := []string{"amtinfo", "--json"}
output, err := handler.PullAndRunContainer(ctx, endpoint, rpcGoImageName, rpcGoContainerName, cmdLine)
if err != nil {
return nil, output, err
}
amtInfo := HostInfo{}
_ = json.Unmarshal([]byte(output), &amtInfo)
amtInfo.EndpointID = endpoint.ID
amtInfo.RawOutput = output
return &amtInfo, "", nil
}
func (handler *Handler) PullAndRunContainer(ctx context.Context, endpoint *portainer.Endpoint, imageName, containerName string, cmdLine []string) (output string, err error) {
// TODO: this should not be Docker specific
// TODO: extract from this Handler into something global.
// TODO: start
// docker run --rm -it --privileged ptrrd/openamt:rpc-go amtinfo
// on the Docker standalone node (one per env :)
// and later, on the specified node in the swarm, or kube.
nodeName := ""
timeout := dockerClientTimeout
docker, err := handler.DockerClientFactory.CreateClient(endpoint, nodeName, &timeout)
if err != nil {
return "Unable to create Docker Client connection", err
}
defer docker.Close()
if err := pullImage(ctx, docker, imageName); err != nil {
return "Could not pull image from registry", err
}
output, err = runContainer(ctx, docker, imageName, containerName, cmdLine)
if err != nil {
return "Could not run container", err
}
return output, nil
}
// TODO: ideally, pullImage and runContainer will become a simple version of the use compose abstraction that can be called from withing Portainer.
// TODO: the idea being that if we have an internal struct of a parsed compose file, we can also populate that struct programmatically, and run it to get the result I'm getting here.
// TODO: likely an upgrade and abstraction of DeployComposeStack/DeploySwarmStack/DeployKubernetesStack
// pullImage will pull the image to the specified environment
// TODO: add k8s implementation
// TODO: work out registry auth
func pullImage(ctx context.Context, docker *client.Client, imageName string) error {
out, err := docker.ImagePull(ctx, imageName, types.ImagePullOptions{})
if err != nil {
logrus.WithError(err).WithField("imageName", imageName).Error("Could not pull image from registry")
return err
}
defer out.Close()
outputBytes, err := ioutil.ReadAll(out)
if err != nil {
logrus.WithError(err).WithField("imageName", imageName).Error("Could not read image pull output")
return err
}
log.Printf("%s imaged pulled with output:\n%s", imageName, string(outputBytes))
return nil
}
// TODO: ideally, pullImage and runContainer will become a simple version of the use compose abstraction that can be called from withing Portainer.
// runContainer should be used to run a short command that returns information to stdout
// TODO: add k8s support
func runContainer(ctx context.Context, docker *client.Client, imageName, containerName string, cmdLine []string) (output string, err error) {
opts := types.ContainerListOptions{All: true}
opts.Filters = filters.NewArgs()
opts.Filters.Add("name", containerName)
existingContainers, err := docker.ContainerList(ctx, opts)
if err != nil {
logrus.WithError(err).WithField("imagename", imageName).WithField("containername", containerName).Error("listing existing container")
return "", err
}
if len(existingContainers) > 0 {
err = docker.ContainerRemove(ctx, existingContainers[0].ID, types.ContainerRemoveOptions{Force: true})
if err != nil {
logrus.WithError(err).WithField("imagename", imageName).WithField("containername", containerName).Error("removing existing container")
return "", err
}
}
created, err := docker.ContainerCreate(
ctx,
&container.Config{
Image: imageName,
Cmd: cmdLine,
Env: []string{},
Tty: true,
OpenStdin: true,
AttachStdout: true,
AttachStderr: true,
},
&container.HostConfig{
Privileged: true,
},
&network.NetworkingConfig{},
nil,
containerName,
)
if err != nil {
logrus.WithError(err).WithField("imagename", imageName).WithField("containername", containerName).Error("creating container")
return "", err
}
err = docker.ContainerStart(ctx, created.ID, types.ContainerStartOptions{})
if err != nil {
logrus.WithError(err).WithField("imagename", imageName).WithField("containername", containerName).Error("starting container")
return "", err
}
log.Printf("%s container created and started\n", containerName)
statusCh, errCh := docker.ContainerWait(ctx, created.ID, container.WaitConditionNotRunning)
var statusCode int64
select {
case err := <-errCh:
if err != nil {
logrus.WithError(err).WithField("imagename", imageName).WithField("containername", containerName).Error("starting container")
return "", err
}
case status := <-statusCh:
statusCode = status.StatusCode
}
logrus.WithField("status", statusCode).Debug("container wait status")
out, err := docker.ContainerLogs(ctx, created.ID, types.ContainerLogsOptions{ShowStdout: true})
if err != nil {
logrus.WithError(err).WithField("imagename", imageName).WithField("containername", containerName).Error("getting container log")
return "", err
}
err = docker.ContainerRemove(ctx, created.ID, types.ContainerRemoveOptions{})
if err != nil {
logrus.WithError(err).WithField("imagename", imageName).WithField("containername", containerName).Error("removing container")
return "", err
}
outputBytes, err := ioutil.ReadAll(out)
if err != nil {
logrus.WithError(err).WithField("imagename", imageName).WithField("containername", containerName).Error("read container output")
return "", err
}
log.Printf("%s container finished with output:\n%s", containerName, string(outputBytes))
return string(outputBytes), nil
}
func (handler *Handler) activateDevice(endpoint *portainer.Endpoint, settings portainer.Settings) error {
ctx := context.TODO()
config := settings.OpenAMTConfiguration
cmdLine := []string{
"activate",
"-n",
"-v",
"-u", fmt.Sprintf("wss://%s/activate", config.MPSServer),
"-profile", openamt.DefaultProfileName,
"-d", config.DomainName,
"-password", config.MPSPassword,
}
_, err := handler.PullAndRunContainer(ctx, endpoint, rpcGoImageName, rpcGoContainerName, cmdLine)
if err != nil {
return err
}
return nil
}
func (handler *Handler) deactivateDevice(endpoint *portainer.Endpoint, settings portainer.Settings) error {
ctx := context.TODO()
config := settings.OpenAMTConfiguration
cmdLine := []string{
"deactivate",
"-n",
"-v",
"-u", fmt.Sprintf("wss://%s/activate", config.MPSServer),
"-password", config.MPSPassword,
}
_, err := handler.PullAndRunContainer(ctx, endpoint, rpcGoImageName, rpcGoContainerName, cmdLine)
if err != nil {
return err
}
return nil
}

View file

@ -8,27 +8,30 @@ import (
httperror "github.com/portainer/libhttp/error" httperror "github.com/portainer/libhttp/error"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/docker"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
) )
// Handler is the HTTP handler used to handle OpenAMT operations. // Handler is the HTTP handler used to handle OpenAMT operations.
type Handler struct { type Handler struct {
*mux.Router *mux.Router
OpenAMTService portainer.OpenAMTService OpenAMTService portainer.OpenAMTService
DataStore dataservices.DataStore DataStore dataservices.DataStore
DockerClientFactory *docker.ClientFactory
} }
// NewHandler returns a new Handler // NewHandler returns a new Handler
func NewHandler(bouncer *security.RequestBouncer, dataStore dataservices.DataStore) (*Handler, error) { func NewHandler(bouncer *security.RequestBouncer) *Handler {
if !dataStore.Settings().IsFeatureFlagEnabled(portainer.FeatOpenAMT) {
return nil, nil
}
h := &Handler{ h := &Handler{
Router: mux.NewRouter(), Router: mux.NewRouter(),
} }
h.Handle("/open_amt", bouncer.AdminAccess(httperror.LoggerHandler(h.openAMTConfigureDefault))).Methods(http.MethodPost) h.Handle("/open_amt/configure", bouncer.AdminAccess(httperror.LoggerHandler(h.openAMTConfigure))).Methods(http.MethodPost)
h.Handle("/open_amt/{id}/info", bouncer.AdminAccess(httperror.LoggerHandler(h.openAMTHostInfo))).Methods(http.MethodGet)
h.Handle("/open_amt/{id}/activate", bouncer.AdminAccess(httperror.LoggerHandler(h.openAMTActivate))).Methods(http.MethodPost)
h.Handle("/open_amt/{id}/devices", bouncer.AdminAccess(httperror.LoggerHandler(h.openAMTDevices))).Methods(http.MethodGet)
h.Handle("/open_amt/{id}/devices/{deviceId}/action", bouncer.AdminAccess(httperror.LoggerHandler(h.deviceAction))).Methods(http.MethodPost)
h.Handle("/open_amt/{id}/devices/{deviceId}/features", bouncer.AdminAccess(httperror.LoggerHandler(h.deviceFeatures))).Methods(http.MethodPost)
return h, nil return h
} }

View file

@ -42,6 +42,10 @@ type settingsUpdatePayload struct {
HelmRepositoryURL *string `example:"https://charts.bitnami.com/bitnami"` HelmRepositoryURL *string `example:"https://charts.bitnami.com/bitnami"`
// Kubectl Shell Image // Kubectl Shell Image
KubectlShellImage *string `example:"portainer/kubectl-shell:latest"` KubectlShellImage *string `example:"portainer/kubectl-shell:latest"`
// DisableTrustOnFirstConnect makes Portainer require explicit user trust of the edge agent before accepting the connection
DisableTrustOnFirstConnect *bool `example:"false"`
// EnforceEdgeID makes Portainer store the Edge ID instead of accepting anyone
EnforceEdgeID *bool `example:"false"`
} }
func (payload *settingsUpdatePayload) Validate(r *http.Request) error { func (payload *settingsUpdatePayload) Validate(r *http.Request) error {
@ -162,6 +166,14 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) *
settings.EnableEdgeComputeFeatures = *payload.EnableEdgeComputeFeatures settings.EnableEdgeComputeFeatures = *payload.EnableEdgeComputeFeatures
} }
if payload.DisableTrustOnFirstConnect != nil {
settings.DisableTrustOnFirstConnect = *payload.DisableTrustOnFirstConnect
}
if payload.EnforceEdgeID != nil {
settings.EnforceEdgeID = *payload.EnforceEdgeID
}
if payload.SnapshotInterval != nil && *payload.SnapshotInterval != settings.SnapshotInterval { if payload.SnapshotInterval != nil && *payload.SnapshotInterval != settings.SnapshotInterval {
err := handler.updateSnapshotInterval(settings, *payload.SnapshotInterval) err := handler.updateSnapshotInterval(settings, *payload.SnapshotInterval)
if err != nil { if err != nil {

View file

@ -154,7 +154,7 @@ func (handler *Handler) checkUniqueStackNameInDocker(endpoint *portainer.Endpoin
return false, err return false, err
} }
dockerClient, err := handler.DockerClientFactory.CreateClient(endpoint, "") dockerClient, err := handler.DockerClientFactory.CreateClient(endpoint, "", nil)
if err != nil { if err != nil {
return false, err return false, err
} }

View file

@ -68,7 +68,7 @@ func (handler *Handler) executeServiceWebhook(
registryID portainer.RegistryID, registryID portainer.RegistryID,
imageTag string, imageTag string,
) *httperror.HandlerError { ) *httperror.HandlerError {
dockerClient, err := handler.DockerClientFactory.CreateClient(endpoint, "") dockerClient, err := handler.DockerClientFactory.CreateClient(endpoint, "", nil)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Error creating docker client", err} return &httperror.HandlerError{http.StatusInternalServerError, "Error creating docker client", err}
} }

View file

@ -122,7 +122,7 @@ func (transport *Transport) createPrivateResourceControl(resourceIdentifier stri
} }
func (transport *Transport) getInheritedResourceControlFromServiceOrStack(resourceIdentifier, nodeName string, resourceType portainer.ResourceControlType, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) { func (transport *Transport) getInheritedResourceControlFromServiceOrStack(resourceIdentifier, nodeName string, resourceType portainer.ResourceControlType, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) {
client, err := transport.dockerClientFactory.CreateClient(transport.endpoint, nodeName) client, err := transport.dockerClientFactory.CreateClient(transport.endpoint, nodeName, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -133,7 +133,7 @@ func (transport *Transport) decorateVolumeResourceCreationOperation(request *htt
if volumeID != "" { if volumeID != "" {
agentTargetHeader := request.Header.Get(portainer.PortainerAgentTargetHeader) agentTargetHeader := request.Header.Get(portainer.PortainerAgentTargetHeader)
cli, err := transport.dockerClientFactory.CreateClient(transport.endpoint, agentTargetHeader) cli, err := transport.dockerClientFactory.CreateClient(transport.endpoint, agentTargetHeader, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -219,7 +219,7 @@ func (transport *Transport) getDockerID() (string, error) {
} }
} }
client, err := transport.dockerClientFactory.CreateClient(transport.endpoint, "") client, err := transport.dockerClientFactory.CreateClient(transport.endpoint, "", nil)
if err != nil { if err != nil {
return "", err return "", err
} }

View file

@ -2,6 +2,7 @@ package security
import ( import (
"errors" "errors"
"fmt"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@ -134,6 +135,19 @@ func (bouncer *RequestBouncer) AuthorizedEdgeEndpointOperation(r *http.Request,
return errors.New("invalid Edge identifier") return errors.New("invalid Edge identifier")
} }
if endpoint.LastCheckInDate > 0 || endpoint.UserTrusted {
return nil
}
settings, err := bouncer.dataStore.Settings().Settings()
if err != nil {
return fmt.Errorf("could not retrieve the settings: %w", err)
}
if settings.DisableTrustOnFirstConnect {
return errors.New("the device has not been trusted yet")
}
return nil return nil
} }

View file

@ -30,6 +30,7 @@ import (
"github.com/portainer/portainer/api/http/handler/endpoints" "github.com/portainer/portainer/api/http/handler/endpoints"
"github.com/portainer/portainer/api/http/handler/file" "github.com/portainer/portainer/api/http/handler/file"
"github.com/portainer/portainer/api/http/handler/helm" "github.com/portainer/portainer/api/http/handler/helm"
"github.com/portainer/portainer/api/http/handler/hostmanagement/fdo"
"github.com/portainer/portainer/api/http/handler/hostmanagement/openamt" "github.com/portainer/portainer/api/http/handler/hostmanagement/openamt"
kubehandler "github.com/portainer/portainer/api/http/handler/kubernetes" kubehandler "github.com/portainer/portainer/api/http/handler/kubernetes"
"github.com/portainer/portainer/api/http/handler/ldap" "github.com/portainer/portainer/api/http/handler/ldap"
@ -209,14 +210,12 @@ func (server *Server) Start() error {
var sslHandler = sslhandler.NewHandler(requestBouncer) var sslHandler = sslhandler.NewHandler(requestBouncer)
sslHandler.SSLService = server.SSLService sslHandler.SSLService = server.SSLService
openAMTHandler, err := openamt.NewHandler(requestBouncer, server.DataStore) openAMTHandler := openamt.NewHandler(requestBouncer)
if err != nil { openAMTHandler.OpenAMTService = server.OpenAMTService
return err openAMTHandler.DataStore = server.DataStore
} openAMTHandler.DockerClientFactory = server.DockerClientFactory
if openAMTHandler != nil {
openAMTHandler.OpenAMTService = server.OpenAMTService fdoHandler := fdo.NewHandler(requestBouncer, server.DataStore, server.FileService)
openAMTHandler.DataStore = server.DataStore
}
var stackHandler = stacks.NewHandler(requestBouncer) var stackHandler = stacks.NewHandler(requestBouncer)
stackHandler.DataStore = server.DataStore stackHandler.DataStore = server.DataStore
@ -284,6 +283,7 @@ func (server *Server) Start() error {
KubernetesHandler: kubernetesHandler, KubernetesHandler: kubernetesHandler,
MOTDHandler: motdHandler, MOTDHandler: motdHandler,
OpenAMTHandler: openAMTHandler, OpenAMTHandler: openAMTHandler,
FDOHandler: fdoHandler,
RegistryHandler: registryHandler, RegistryHandler: registryHandler,
ResourceControlHandler: resourceControlHandler, ResourceControlHandler: resourceControlHandler,
SettingsHandler: settingsHandler, SettingsHandler: settingsHandler,

View file

@ -46,6 +46,23 @@ func Test_IsKubernetesEndpoint(t *testing.T) {
} }
} }
func Test_IsAgentEndpoint(t *testing.T) {
tests := []isEndpointTypeTest{
{endpointType: portainer.DockerEnvironment, expected: false},
{endpointType: portainer.AgentOnDockerEnvironment, expected: true},
{endpointType: portainer.AzureEnvironment, expected: false},
{endpointType: portainer.EdgeAgentOnDockerEnvironment, expected: true},
{endpointType: portainer.KubernetesLocalEnvironment, expected: false},
{endpointType: portainer.AgentOnKubernetesEnvironment, expected: true},
{endpointType: portainer.EdgeAgentOnKubernetesEnvironment, expected: true},
}
for _, test := range tests {
ans := IsAgentEndpoint(&portainer.Endpoint{Type: test.endpointType})
assert.Equal(t, test.expected, ans)
}
}
func Test_FilterByExcludeIDs(t *testing.T) { func Test_FilterByExcludeIDs(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

View file

@ -30,6 +30,14 @@ func IsEdgeEndpoint(endpoint *portainer.Endpoint) bool {
return endpoint.Type == portainer.EdgeAgentOnDockerEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment return endpoint.Type == portainer.EdgeAgentOnDockerEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment
} }
// IsAgentEndpoint returns true if this is an Agent endpoint
func IsAgentEndpoint(endpoint *portainer.Endpoint) bool {
return endpoint.Type == portainer.AgentOnDockerEnvironment ||
endpoint.Type == portainer.EdgeAgentOnDockerEnvironment ||
endpoint.Type == portainer.AgentOnKubernetesEnvironment ||
endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment
}
// FilterByExcludeIDs receives an environment(endpoint) array and returns a filtered array using an excludeIds param // FilterByExcludeIDs receives an environment(endpoint) array and returns a filtered array using an excludeIds param
func FilterByExcludeIDs(endpoints []portainer.Endpoint, excludeIds []portainer.EndpointID) []portainer.Endpoint { func FilterByExcludeIDs(endpoints []portainer.Endpoint, excludeIds []portainer.EndpointID) []portainer.Endpoint {
if len(excludeIds) == 0 { if len(excludeIds) == 0 {

View file

@ -16,6 +16,7 @@ type testDatastore struct {
endpoint dataservices.EndpointService endpoint dataservices.EndpointService
endpointGroup dataservices.EndpointGroupService endpointGroup dataservices.EndpointGroupService
endpointRelation dataservices.EndpointRelationService endpointRelation dataservices.EndpointRelationService
fdoProfile dataservices.FDOProfileService
helmUserRepository dataservices.HelmUserRepositoryService helmUserRepository dataservices.HelmUserRepositoryService
registry dataservices.RegistryService registry dataservices.RegistryService
resourceControl dataservices.ResourceControlService resourceControl dataservices.ResourceControlService
@ -46,6 +47,9 @@ func (d *testDatastore) EdgeJob() dataservices.EdgeJobService { re
func (d *testDatastore) EdgeStack() dataservices.EdgeStackService { return d.edgeStack } func (d *testDatastore) EdgeStack() dataservices.EdgeStackService { return d.edgeStack }
func (d *testDatastore) Endpoint() dataservices.EndpointService { return d.endpoint } func (d *testDatastore) Endpoint() dataservices.EndpointService { return d.endpoint }
func (d *testDatastore) EndpointGroup() dataservices.EndpointGroupService { return d.endpointGroup } func (d *testDatastore) EndpointGroup() dataservices.EndpointGroupService { return d.endpointGroup }
func (d *testDatastore) FDOProfile() dataservices.FDOProfileService {
return d.fdoProfile
}
func (d *testDatastore) EndpointRelation() dataservices.EndpointRelationService { func (d *testDatastore) EndpointRelation() dataservices.EndpointRelationService {
return d.endpointRelation return d.endpointRelation
} }

View file

@ -41,30 +41,54 @@ type (
// OpenAMTConfiguration represents the credentials and configurations used to connect to an OpenAMT MPS server // OpenAMTConfiguration represents the credentials and configurations used to connect to an OpenAMT MPS server
OpenAMTConfiguration struct { OpenAMTConfiguration struct {
Enabled bool `json:"Enabled"` Enabled bool `json:"enabled"`
MPSServer string `json:"MPSServer"` MPSServer string `json:"mpsServer"`
Credentials MPSCredentials `json:"Credentials"` MPSUser string `json:"mpsUser"`
DomainConfiguration DomainConfiguration `json:"DomainConfiguration"` MPSPassword string `json:"mpsPassword"`
WirelessConfiguration *WirelessConfiguration `json:"WirelessConfiguration"` MPSToken string `json:"mpsToken"` // retrieved from API
CertFileName string `json:"certFileName"`
CertFileContent string `json:"certFileContent"`
CertFilePassword string `json:"certFilePassword"`
DomainName string `json:"domainName"`
} }
MPSCredentials struct { // OpenAMTDeviceInformation represents an AMT managed device information
MPSUser string `json:"MPSUser"` OpenAMTDeviceInformation struct {
MPSPassword string `json:"MPSPassword"` GUID string `json:"guid"`
MPSToken string `json:"MPSToken"` // retrieved from API HostName string `json:"hostname"`
ConnectionStatus bool `json:"connectionStatus"`
PowerState PowerState `json:"powerState"`
EnabledFeatures *OpenAMTDeviceEnabledFeatures `json:"features"`
} }
DomainConfiguration struct { // OpenAMTDeviceEnabledFeatures represents an AMT managed device features information
CertFileText string `json:"CertFileText"` OpenAMTDeviceEnabledFeatures struct {
CertPassword string `json:"CertPassword"` Redirection bool `json:"redirection"`
DomainName string `json:"DomainName"` KVM bool `json:"KVM"`
SOL bool `json:"SOL"`
IDER bool `json:"IDER"`
UserConsent string `json:"userConsent"`
} }
WirelessConfiguration struct { // PowerState represents an AMT managed device power state
AuthenticationMethod string `json:"AuthenticationMethod"` PowerState int
EncryptionMethod string `json:"EncryptionMethod"`
SSID string `json:"SSID"` FDOConfiguration struct {
PskPass string `json:"PskPass"` Enabled bool `json:"enabled"`
OwnerURL string `json:"ownerURL"`
OwnerUsername string `json:"ownerUsername"`
OwnerPassword string `json:"ownerPassword"`
}
// FDOProfileID represents a fdo profile id
FDOProfileID int
FDOProfile struct {
ID FDOProfileID `json:"id"`
Name string `json:"name"`
FilePath string `json:"filePath"`
NumberDevices int `json:"numberDevices"`
DateCreated int64 `json:"dateCreated"`
} }
// CLIFlags represents the available flags on the CLI // CLIFlags represents the available flags on the CLI
@ -298,8 +322,14 @@ type (
ComposeSyntaxMaxVersion string `json:"ComposeSyntaxMaxVersion" example:"3.8"` ComposeSyntaxMaxVersion string `json:"ComposeSyntaxMaxVersion" example:"3.8"`
// Environment(Endpoint) specific security settings // Environment(Endpoint) specific security settings
SecuritySettings EndpointSecuritySettings SecuritySettings EndpointSecuritySettings
// The identifier of the AMT Device associated with this environment(endpoint)
AMTDeviceGUID string `json:"AMTDeviceGUID,omitempty" example:"4c4c4544-004b-3910-8037-b6c04f504633"`
// LastCheckInDate mark last check-in date on checkin // LastCheckInDate mark last check-in date on checkin
LastCheckInDate int64 LastCheckInDate int64
// IsEdgeDevice marks if the environment was created as an EdgeDevice
IsEdgeDevice bool
// Whether the device has been trusted or not by the user
UserTrusted bool
// Deprecated fields // Deprecated fields
// Deprecated in DBVersion == 4 // Deprecated in DBVersion == 4
@ -768,7 +798,8 @@ type (
AuthenticationMethod AuthenticationMethod `json:"AuthenticationMethod" example:"1"` AuthenticationMethod AuthenticationMethod `json:"AuthenticationMethod" example:"1"`
LDAPSettings LDAPSettings `json:"LDAPSettings" example:""` LDAPSettings LDAPSettings `json:"LDAPSettings" example:""`
OAuthSettings OAuthSettings `json:"OAuthSettings" example:""` OAuthSettings OAuthSettings `json:"OAuthSettings" example:""`
OpenAMTConfiguration OpenAMTConfiguration `json:"OpenAMTConfiguration" example:""` OpenAMTConfiguration OpenAMTConfiguration `json:"openAMTConfiguration" example:""`
FDOConfiguration FDOConfiguration `json:"fdoConfiguration" example:""`
FeatureFlagSettings map[Feature]bool `json:"FeatureFlagSettings" example:""` FeatureFlagSettings map[Feature]bool `json:"FeatureFlagSettings" example:""`
// The interval in which environment(endpoint) snapshots are created // The interval in which environment(endpoint) snapshots are created
SnapshotInterval string `json:"SnapshotInterval" example:"5m"` SnapshotInterval string `json:"SnapshotInterval" example:"5m"`
@ -788,6 +819,10 @@ type (
HelmRepositoryURL string `json:"HelmRepositoryURL" example:"https://charts.bitnami.com/bitnami"` HelmRepositoryURL string `json:"HelmRepositoryURL" example:"https://charts.bitnami.com/bitnami"`
// KubectlImage, defaults to portainer/kubectl-shell // KubectlImage, defaults to portainer/kubectl-shell
KubectlShellImage string `json:"KubectlShellImage" example:"portainer/kubectl-shell"` KubectlShellImage string `json:"KubectlShellImage" example:"portainer/kubectl-shell"`
// DisableTrustOnFirstConnect makes Portainer require explicit user trust of the edge agent before accepting the connection
DisableTrustOnFirstConnect bool `json:"DisableTrustOnFirstConnect" example:"false"`
// EnforceEdgeID makes Portainer store the Edge ID instead of accepting anyone
EnforceEdgeID bool `json:"EnforceEdgeID" example:"false"`
// Deprecated fields // Deprecated fields
DisplayDonationHeader bool DisplayDonationHeader bool
@ -1213,6 +1248,7 @@ type (
GetDefaultSSLCertsPath() (string, string) GetDefaultSSLCertsPath() (string, string)
StoreSSLCertPair(cert, key []byte) (string, string, error) StoreSSLCertPair(cert, key []byte) (string, string, error)
CopySSLCertPair(certPath, keyPath string) (string, string, error) CopySSLCertPair(certPath, keyPath string) (string, string, error)
StoreFDOProfileFileFromBytes(fdoProfileIdentifier string, data []byte) (string, error)
} }
// GitService represents a service for managing Git // GitService represents a service for managing Git
@ -1223,7 +1259,10 @@ type (
// OpenAMTService represents a service for managing OpenAMT // OpenAMTService represents a service for managing OpenAMT
OpenAMTService interface { OpenAMTService interface {
ConfigureDefault(configuration OpenAMTConfiguration) error Configure(configuration OpenAMTConfiguration) error
DeviceInformation(configuration OpenAMTConfiguration, deviceGUID string) (*OpenAMTDeviceInformation, error)
EnableDeviceFeatures(configuration OpenAMTConfiguration, deviceGUID string, features OpenAMTDeviceEnabledFeatures) (string, error)
ExecuteDeviceAction(configuration OpenAMTConfiguration, deviceGUID string, action string) error
} }
// KubeClient represents a service used to query a Kubernetes environment(endpoint) // KubeClient represents a service used to query a Kubernetes environment(endpoint)
@ -1353,17 +1392,8 @@ const (
WebSocketKeepAlive = 1 * time.Hour WebSocketKeepAlive = 1 * time.Hour
) )
// Supported feature flags
const (
FeatOpenAMT Feature = "open-amt"
FeatFDO Feature = "fdo"
)
// List of supported features // List of supported features
var SupportedFeatureFlags = []Feature{ var SupportedFeatureFlags = []Feature{}
FeatOpenAMT,
FeatFDO,
}
const ( const (
_ AuthenticationMethod = iota _ AuthenticationMethod = iota

View file

@ -680,6 +680,7 @@ a[ng-click] {
.image-zoom-modal .modal-dialog { .image-zoom-modal .modal-dialog {
width: 80%; width: 80%;
} }
/*!bootbox override*/ /*!bootbox override*/
/*angular-multi-select override*/ /*angular-multi-select override*/
@ -893,6 +894,16 @@ json-tree .branch-preview {
white-space: normal; white-space: normal;
} }
.icon-disabled {
opacity: 0.5;
cursor: not-allowed;
}
.space-x-1 { .space-x-1 {
margin-left: 0.25rem; margin-left: 0.25rem;
} }
/* used for bootbox prompt with inputType radio */
.form-check.radio {
margin-left: 15px;
}

View file

@ -264,6 +264,7 @@ html {
--bg-multiselect-helpercontainer: var(--white-color); --bg-multiselect-helpercontainer: var(--white-color);
--text-input-textarea: var(--white-color); --text-input-textarea: var(--white-color);
--bg-service-datatable-thead: var(--grey-23); --bg-service-datatable-thead: var(--grey-23);
--bg-inner-datatable-thead: var(--grey-23);
--bg-service-datatable-tbody: var(--grey-24); --bg-service-datatable-tbody: var(--grey-24);
} }
@ -435,6 +436,7 @@ html {
--bg-multiselect-helpercontainer: var(--grey-1); --bg-multiselect-helpercontainer: var(--grey-1);
--text-input-textarea: var(--grey-1); --text-input-textarea: var(--grey-1);
--bg-service-datatable-thead: var(--grey-1); --bg-service-datatable-thead: var(--grey-1);
--bg-inner-datatable-thead: var(--grey-1);
--bg-service-datatable-tbody: var(--grey-1); --bg-service-datatable-tbody: var(--grey-1);
} }
@ -472,6 +474,7 @@ html {
--bg-input-sm-color: var(--black-color); --bg-input-sm-color: var(--black-color);
--bg-item-highlighted-color: var(--black-color); --bg-item-highlighted-color: var(--black-color);
--bg-service-datatable-thead: var(--black-color); --bg-service-datatable-thead: var(--black-color);
--bg-inner-datatable-thead: var(--black-color);
--bg-service-datatable-tbody: var(--black-color); --bg-service-datatable-tbody: var(--black-color);
--bg-pagination-color: var(--grey-3); --bg-pagination-color: var(--grey-3);
--bg-pagination-span-color: var(--grey-3); --bg-pagination-span-color: var(--grey-3);

View file

@ -140,7 +140,7 @@ export function ContainersDatatable({
<TableContainer> <TableContainer>
<TableTitle icon="fa-cubes" label="Containers"> <TableTitle icon="fa-cubes" label="Containers">
<TableTitleActions> <TableTitleActions>
<ColumnVisibilityMenu <ColumnVisibilityMenu<DockerContainer>
columns={columnsToHide} columns={columnsToHide}
onChange={handleChangeColumnsVisibility} onChange={handleChangeColumnsVisibility}
value={settings.hiddenColumns} value={settings.hiddenColumns}

View file

@ -1,8 +1,9 @@
import angular from 'angular'; import angular from 'angular';
import edgeStackModule from './views/edge-stacks'; import edgeStackModule from './views/edge-stacks';
import edgeDevicesModule from './devices';
angular.module('portainer.edge', [edgeStackModule]).config(function config($stateRegistryProvider) { angular.module('portainer.edge', [edgeStackModule, edgeDevicesModule]).config(function config($stateRegistryProvider) {
const edge = { const edge = {
name: 'edge', name: 'edge',
url: '/edge', url: '/edge',
@ -106,6 +107,16 @@ angular.module('portainer.edge', [edgeStackModule]).config(function config($stat
}, },
}; };
const edgeDevices = {
name: 'edge.devices',
url: '/devices',
views: {
'content@': {
component: 'edgeDevicesView',
},
},
};
$stateRegistryProvider.register(edge); $stateRegistryProvider.register(edge);
$stateRegistryProvider.register(groups); $stateRegistryProvider.register(groups);
@ -119,4 +130,6 @@ angular.module('portainer.edge', [edgeStackModule]).config(function config($stat
$stateRegistryProvider.register(edgeJobs); $stateRegistryProvider.register(edgeJobs);
$stateRegistryProvider.register(edgeJob); $stateRegistryProvider.register(edgeJob);
$stateRegistryProvider.register(edgeJobCreation); $stateRegistryProvider.register(edgeJobCreation);
$stateRegistryProvider.register(edgeDevices);
}); });

View file

@ -0,0 +1,108 @@
import { usePagination, useTable } from 'react-table';
import {
Table,
TableContainer,
TableHeaderRow,
TableRow,
} from '@/portainer/components/datatables/components';
import { InnerDatatable } from '@/portainer/components/datatables/components/InnerDatatable';
import { Device } from '@/portainer/hostmanagement/open-amt/model';
import { useAMTDevices } from '@/edge/devices/components/AMTDevicesDatatable/useAMTDevices';
import { RowProvider } from '@/edge/devices/components/AMTDevicesDatatable/columns/RowContext';
import { EnvironmentId } from '@/portainer/environments/types';
import PortainerError from '@/portainer/error';
import { useColumns } from './columns';
export interface AMTDevicesTableProps {
environmentId: EnvironmentId;
}
export function AMTDevicesDatatable({ environmentId }: AMTDevicesTableProps) {
const columns = useColumns();
const { isLoading, devices, error } = useAMTDevices(environmentId);
const { getTableProps, getTableBodyProps, headerGroups, page, prepareRow } =
useTable<Device>(
{
columns,
data: devices,
},
usePagination
);
const tableProps = getTableProps();
const tbodyProps = getTableBodyProps();
return (
<InnerDatatable>
<TableContainer>
<Table
className={tableProps.className}
role={tableProps.role}
style={tableProps.style}
>
<thead>
{headerGroups.map((headerGroup) => {
const { key, className, role, style } =
headerGroup.getHeaderGroupProps();
return (
<TableHeaderRow<Device>
key={key}
className={className}
role={role}
style={style}
headers={headerGroup.headers}
/>
);
})}
</thead>
<tbody
className={tbodyProps.className}
role={tbodyProps.role}
style={tbodyProps.style}
>
{!isLoading && devices && devices.length > 0 ? (
page.map((row) => {
prepareRow(row);
const { key, className, role, style } = row.getRowProps();
return (
<RowProvider key={key} environmentId={environmentId}>
<TableRow<Device>
cells={row.cells}
key={key}
className={className}
role={role}
style={style}
/>
</RowProvider>
);
})
) : (
<tr>
<td colSpan={5} className="text-center text-muted">
{userMessage(isLoading, error)}
</td>
</tr>
)}
</tbody>
</Table>
</TableContainer>
</InnerDatatable>
);
}
function userMessage(isLoading: boolean, error?: PortainerError) {
if (isLoading) {
return 'Loading...';
}
if (error) {
return error.message;
}
return 'No devices found';
}

View file

@ -0,0 +1,43 @@
import {
createContext,
useContext,
useMemo,
useReducer,
PropsWithChildren,
} from 'react';
import { EnvironmentId } from 'Portainer/environments/types';
interface RowContextState {
environmentId: EnvironmentId;
isLoading: boolean;
toggleIsLoading(): void;
}
const RowContext = createContext<RowContextState | null>(null);
export interface RowProviderProps {
environmentId: EnvironmentId;
}
export function RowProvider({
environmentId,
children,
}: PropsWithChildren<RowProviderProps>) {
const [isLoading, toggleIsLoading] = useReducer((state) => !state, false);
const state = useMemo(
() => ({ isLoading, toggleIsLoading, environmentId }),
[isLoading, toggleIsLoading, environmentId]
);
return <RowContext.Provider value={state}>{children}</RowContext.Provider>;
}
export function useRowContext() {
const context = useContext(RowContext);
if (!context) {
throw new Error('should be nested under RowProvider');
}
return context;
}

View file

@ -0,0 +1,108 @@
import { CellProps, Column } from 'react-table';
import { useSref } from '@uirouter/react';
import { MenuItem, MenuLink } from '@reach/menu-button';
import { useQueryClient } from 'react-query';
import { Device } from '@/portainer/hostmanagement/open-amt/model';
import { ActionsMenu } from '@/portainer/components/datatables/components/ActionsMenu';
import { confirmAsync } from '@/portainer/services/modal.service/confirm';
import { executeDeviceAction } from '@/portainer/hostmanagement/open-amt/open-amt.service';
import * as notifications from '@/portainer/services/notifications';
import { ActionsMenuTitle } from '@/portainer/components/datatables/components/ActionsMenuTitle';
import { useRowContext } from '@/edge/devices/components/AMTDevicesDatatable/columns/RowContext';
import { DeviceAction } from '@/edge/devices/types';
export const actions: Column<Device> = {
Header: 'Actions',
accessor: () => 'actions',
id: 'actions',
disableFilters: true,
canHide: true,
disableResizing: true,
width: '5px',
sortType: 'string',
Filter: () => null,
Cell: ActionsCell,
};
export function ActionsCell({ row: { original: device } }: CellProps<Device>) {
const queryClient = useQueryClient();
const { isLoading, toggleIsLoading, environmentId } = useRowContext();
const kvmLinkProps = useSref('portainer.endpoints.endpoint.kvm', {
id: environmentId,
deviceId: device.guid,
deviceName: device.hostname,
});
return (
<ActionsMenu>
<ActionsMenuTitle>AMT Functions</ActionsMenuTitle>
<MenuItem
disabled={isLoading}
onSelect={() => handleDeviceActionClick(DeviceAction.PowerOn)}
>
Power ON
</MenuItem>
<MenuItem
disabled={isLoading}
onSelect={() => handleDeviceActionClick(DeviceAction.PowerOff)}
>
Power OFF
</MenuItem>
<MenuItem
disabled={isLoading}
onSelect={() => handleDeviceActionClick(DeviceAction.Restart)}
>
Restart
</MenuItem>
<MenuLink
href={kvmLinkProps.href}
onClick={kvmLinkProps.onClick}
disabled={isLoading}
>
KVM
</MenuLink>
</ActionsMenu>
);
async function handleDeviceActionClick(action: string) {
const confirmed = await confirmAsync({
title: 'Confirm action',
message: `Are you sure you want to ${action} the device?`,
buttons: {
cancel: {
label: 'Cancel',
className: 'btn-default',
},
confirm: {
label: 'Confirm',
className: 'btn-primary',
},
},
});
if (!confirmed) {
return;
}
try {
toggleIsLoading();
await executeDeviceAction(environmentId, device.guid, action);
notifications.success(
`${action} action sent successfully`,
device.hostname
);
await queryClient.invalidateQueries(['amt_devices', environmentId]);
} catch (err) {
notifications.error(
'Failure',
err as Error,
`Failed to ${action} the device`
);
} finally {
toggleIsLoading();
}
}
}

View file

@ -0,0 +1,13 @@
import { Column } from 'react-table';
import { Device } from '@/portainer/hostmanagement/open-amt/model';
export const hostname: Column<Device> = {
Header: 'Hostname',
accessor: (row) => row.hostname || '-',
id: 'hostname',
disableFilters: true,
canHide: true,
sortType: 'string',
Filter: () => null,
};

View file

@ -0,0 +1,10 @@
import { useMemo } from 'react';
import { hostname } from './hostname';
import { status } from './status';
import { powerState } from './power-state';
import { actions } from './actions';
export function useColumns() {
return useMemo(() => [hostname, status, powerState, actions], []);
}

View file

@ -0,0 +1,56 @@
import { CellProps, Column } from 'react-table';
import clsx from 'clsx';
import { Device } from '@/portainer/hostmanagement/open-amt/model';
import { useRowContext } from '@/edge/devices/components/AMTDevicesDatatable/columns/RowContext';
import { PowerState, PowerStateCode } from '@/edge/devices/types';
export const powerState: Column<Device> = {
Header: 'Power State',
accessor: (row) => parsePowerState(row.powerState),
id: 'powerState',
disableFilters: true,
canHide: true,
sortType: 'string',
Cell: PowerStateCell,
Filter: () => null,
};
export function PowerStateCell({
row: { original: device },
}: CellProps<Device>) {
const { isLoading } = useRowContext();
return (
<>
<span
className={clsx({
'text-success': device.powerState === PowerStateCode.On,
})}
>
{parsePowerState(device.powerState)}
</span>
<span>{isLoading && <i className="fa fa-cog fa-spin space-left" />}</span>
</>
);
}
function parsePowerState(value: PowerStateCode) {
// https://app.swaggerhub.com/apis-docs/rbheopenamt/mps/1.4.0#/AMT/get_api_v1_amt_power_state__guid_
switch (value) {
case PowerStateCode.On:
return PowerState.Running;
case PowerStateCode.SleepLight:
case PowerStateCode.SleepDeep:
return PowerState.Sleep;
case PowerStateCode.OffHard:
case PowerStateCode.OffSoft:
case PowerStateCode.OffHardGraceful:
return PowerState.Off;
case PowerStateCode.Hibernate:
return PowerState.Hibernate;
case PowerStateCode.PowerCycle:
return PowerState.PowerCycle;
default:
return '-';
}
}

View file

@ -0,0 +1,22 @@
import { CellProps, Column } from 'react-table';
import clsx from 'clsx';
import { Device } from '@/portainer/hostmanagement/open-amt/model';
export const status: Column<Device> = {
Header: 'MPS Status',
id: 'status',
disableFilters: true,
canHide: true,
sortType: 'string',
Cell: StatusCell,
Filter: () => null,
};
export function StatusCell({ row: { original: device } }: CellProps<Device>) {
return (
<span className={clsx({ 'text-success': device.connectionStatus })}>
{device.connectionStatus ? 'Connected' : 'Disconnected'}
</span>
);
}

View file

@ -0,0 +1,32 @@
import { useEffect, useMemo } from 'react';
import { useQuery } from 'react-query';
import { getDevices } from '@/portainer/hostmanagement/open-amt/open-amt.service';
import { EnvironmentId } from '@/portainer/environments/types';
import PortainerError from '@/portainer/error';
import * as notifications from '@/portainer/services/notifications';
export function useAMTDevices(environmentId: EnvironmentId) {
const { isLoading, data, isError, error } = useQuery(
['amt_devices', environmentId],
() => getDevices(environmentId)
);
useEffect(() => {
if (isError) {
notifications.error(
'Failure',
error as Error,
'Failed retrieving AMT devices'
);
}
}, [isError, error]);
const devices = useMemo(() => data || [], [data]);
return {
isLoading,
devices,
error: isError ? (error as PortainerError) : undefined,
};
}

View file

@ -0,0 +1,3 @@
.kvm-tip {
padding: 8px;
}

View file

@ -0,0 +1,265 @@
import { useEffect } from 'react';
import {
useTable,
useExpanded,
useSortBy,
useFilters,
useGlobalFilter,
usePagination,
} from 'react-table';
import { useRowSelectColumn } from '@lineup-lite/hooks';
import { Environment } from '@/portainer/environments/types';
import { PaginationControls } from '@/portainer/components/pagination-controls';
import {
Table,
TableActions,
TableContainer,
TableHeaderRow,
TableRow,
TableSettingsMenu,
TableTitle,
TableTitleActions,
} from '@/portainer/components/datatables/components';
import { multiple } from '@/portainer/components/datatables/components/filter-types';
import { useTableSettings } from '@/portainer/components/datatables/components/useTableSettings';
import { ColumnVisibilityMenu } from '@/portainer/components/datatables/components/ColumnVisibilityMenu';
import { useRepeater } from '@/portainer/components/datatables/components/useRepeater';
import { useDebounce } from '@/portainer/hooks/useDebounce';
import {
useSearchBarContext,
SearchBar,
} from '@/portainer/components/datatables/components/SearchBar';
import { useRowSelect } from '@/portainer/components/datatables/components/useRowSelect';
import { TableFooter } from '@/portainer/components/datatables/components/TableFooter';
import { SelectedRowsCount } from '@/portainer/components/datatables/components/SelectedRowsCount';
import { EdgeDeviceTableSettings } from '@/edge/devices/types';
import { EdgeDevicesDatatableSettings } from '@/edge/devices/components/EdgeDevicesDatatable/EdgeDevicesDatatableSettings';
import { EdgeDevicesDatatableActions } from '@/edge/devices/components/EdgeDevicesDatatable/EdgeDevicesDatatableActions';
import { AMTDevicesDatatable } from '@/edge/devices/components/AMTDevicesDatatable/AMTDevicesDatatable';
import { TextTip } from '@/portainer/components/Tip/TextTip';
import { RowProvider } from './columns/RowContext';
import { useColumns } from './columns';
import styles from './EdgeDevicesDatatable.module.css';
export interface EdgeDevicesTableProps {
isEnabled: boolean;
isFdoEnabled: boolean;
isOpenAmtEnabled: boolean;
disableTrustOnFirstConnect: boolean;
mpsServer: string;
dataset: Environment[];
onRefresh(): Promise<void>;
setLoadingMessage(message: string): void;
}
export function EdgeDevicesDatatable({
isFdoEnabled,
isOpenAmtEnabled,
disableTrustOnFirstConnect,
mpsServer,
dataset,
onRefresh,
setLoadingMessage,
}: EdgeDevicesTableProps) {
const { settings, setTableSettings } =
useTableSettings<EdgeDeviceTableSettings>();
const [searchBarValue, setSearchBarValue] = useSearchBarContext();
const columns = useColumns();
useRepeater(settings.autoRefreshRate, onRefresh);
const {
getTableProps,
getTableBodyProps,
headerGroups,
page,
prepareRow,
selectedFlatRows,
allColumns,
gotoPage,
setPageSize,
setHiddenColumns,
setGlobalFilter,
state: { pageIndex, pageSize },
} = useTable<Environment>(
{
defaultCanFilter: false,
columns,
data: dataset,
filterTypes: { multiple },
initialState: {
pageSize: settings.pageSize || 10,
hiddenColumns: settings.hiddenColumns,
sortBy: [settings.sortBy],
globalFilter: searchBarValue,
},
isRowSelectable() {
return true;
},
autoResetExpanded: false,
autoResetSelectedRows: false,
selectColumnWidth: 5,
},
useFilters,
useGlobalFilter,
useSortBy,
useExpanded,
usePagination,
useRowSelect,
useRowSelectColumn
);
const debouncedSearchValue = useDebounce(searchBarValue);
useEffect(() => {
setGlobalFilter(debouncedSearchValue);
}, [debouncedSearchValue, setGlobalFilter]);
const columnsToHide = allColumns.filter((colInstance) => {
const columnDef = columns.find((c) => c.id === colInstance.id);
return columnDef?.canHide;
});
const tableProps = getTableProps();
const tbodyProps = getTableBodyProps();
const someDeviceHasAMTActivated = dataset.some(
(environment) =>
environment.AMTDeviceGUID && environment.AMTDeviceGUID !== ''
);
return (
<TableContainer>
<TableTitle icon="fa-plug" label="Edge Devices">
<TableTitleActions>
<ColumnVisibilityMenu<Environment>
columns={columnsToHide}
onChange={handleChangeColumnsVisibility}
value={settings.hiddenColumns}
/>
<TableSettingsMenu>
<EdgeDevicesDatatableSettings />
</TableSettingsMenu>
</TableTitleActions>
</TableTitle>
<TableActions>
<EdgeDevicesDatatableActions
selectedItems={selectedFlatRows.map((row) => row.original)}
isFDOEnabled={isFdoEnabled}
isOpenAMTEnabled={isOpenAmtEnabled}
setLoadingMessage={setLoadingMessage}
/>
</TableActions>
{someDeviceHasAMTActivated && (
<div className={styles.kvmTip}>
<TextTip color="blue">
For the KVM function to work you need to have the MPS server added
to your trusted site list, browse to this{' '}
<a href={`https://${mpsServer}`} target="_blank" rel="noreferrer">
site
</a>{' '}
and add to your trusted site list
</TextTip>
</div>
)}
<SearchBar value={searchBarValue} onChange={handleSearchBarChange} />
<Table
className={tableProps.className}
role={tableProps.role}
style={tableProps.style}
>
<thead>
{headerGroups.map((headerGroup) => {
const { key, className, role, style } =
headerGroup.getHeaderGroupProps();
return (
<TableHeaderRow<Environment>
key={key}
className={className}
role={role}
style={style}
headers={headerGroup.headers}
onSortChange={handleSortChange}
/>
);
})}
</thead>
<tbody
className={tbodyProps.className}
role={tbodyProps.role}
style={tbodyProps.style}
>
{page.map((row) => {
prepareRow(row);
const { key, className, role, style } = row.getRowProps();
return (
<RowProvider
key={key}
disableTrustOnFirstConnect={disableTrustOnFirstConnect}
>
<TableRow<Environment>
cells={row.cells}
key={key}
className={className}
role={role}
style={style}
/>
{row.isExpanded && (
<tr>
<td />
<td colSpan={row.cells.length - 1}>
<AMTDevicesDatatable environmentId={row.original.Id} />
</td>
</tr>
)}
</RowProvider>
);
})}
</tbody>
</Table>
<TableFooter>
<SelectedRowsCount value={selectedFlatRows.length} />
<PaginationControls
showAll
pageLimit={pageSize}
page={pageIndex + 1}
onPageChange={(p) => gotoPage(p - 1)}
totalCount={dataset.length}
onPageLimitChange={handlePageSizeChange}
/>
</TableFooter>
</TableContainer>
);
function handlePageSizeChange(pageSize: number) {
setPageSize(pageSize);
setTableSettings((settings) => ({ ...settings, pageSize }));
}
function handleChangeColumnsVisibility(hiddenColumns: string[]) {
setHiddenColumns(hiddenColumns);
setTableSettings((settings) => ({ ...settings, hiddenColumns }));
}
function handleSearchBarChange(value: string) {
setSearchBarValue(value);
}
function handleSortChange(id: string, desc: boolean) {
setTableSettings((settings) => ({
...settings,
sortBy: { id, desc },
}));
}
}

View file

@ -0,0 +1,173 @@
import { useRouter } from '@uirouter/react';
import type { Environment } from '@/portainer/environments/types';
import { Button } from '@/portainer/components/Button';
import { confirmAsync } from '@/portainer/services/modal.service/confirm';
import { promptAsync } from '@/portainer/services/modal.service/prompt';
import * as notifications from '@/portainer/services/notifications';
import { activateDevice } from '@/portainer/hostmanagement/open-amt/open-amt.service';
import { deleteEndpoint } from '@/portainer/environments/environment.service';
interface Props {
selectedItems: Environment[];
isFDOEnabled: boolean;
isOpenAMTEnabled: boolean;
setLoadingMessage(message: string): void;
}
export function EdgeDevicesDatatableActions({
selectedItems,
isOpenAMTEnabled,
isFDOEnabled,
setLoadingMessage,
}: Props) {
const router = useRouter();
return (
<div className="actionBar">
<Button
disabled={selectedItems.length < 1}
color="danger"
onClick={() => onDeleteEdgeDeviceClick()}
>
<i className="fa fa-trash-alt space-right" aria-hidden="true" />
Remove
</Button>
<Button onClick={() => onAddNewDeviceClick()}>
<i className="fa fa-plus-circle space-right" aria-hidden="true" />
Add Device
</Button>
{isOpenAMTEnabled && (
<Button
disabled={selectedItems.length !== 1}
onClick={() => onAssociateOpenAMTClick(selectedItems)}
>
<i className="fa fa-link space-right" aria-hidden="true" />
Associate with OpenAMT
</Button>
)}
</div>
);
async function onDeleteEdgeDeviceClick() {
const confirmed = await confirmAsync({
title: 'Are you sure ?',
message:
'This action will remove all configurations associated to your environment(s). Continue?',
buttons: {
confirm: {
label: 'Remove',
className: 'btn-danger',
},
},
});
if (!confirmed) {
return;
}
await Promise.all(
selectedItems.map(async (environment) => {
try {
await deleteEndpoint(environment.Id);
notifications.success(
'Environment successfully removed',
environment.Name
);
} catch (err) {
notifications.error(
'Failure',
err as Error,
'Unable to remove environment'
);
}
})
);
await router.stateService.reload();
}
async function onAddNewDeviceClick() {
if (!isFDOEnabled) {
router.stateService.go('portainer.endpoints.newEdgeDevice');
return;
}
const result = await promptAsync({
title: 'How would you like to add an Edge Device?',
inputType: 'radio',
inputOptions: [
{
text: 'Provision bare-metal using Intel FDO',
value: 'FDO',
},
{
text: 'Deploy agent manually',
value: 'MANUAL',
},
],
buttons: {
confirm: {
label: 'Confirm',
className: 'btn-primary',
},
},
});
switch (result) {
case 'FDO':
router.stateService.go('portainer.endpoints.importDevice');
break;
case 'MANUAL':
router.stateService.go('portainer.endpoints.newEdgeDevice');
break;
default:
break;
}
}
async function onAssociateOpenAMTClick(selectedItems: Environment[]) {
const selectedEnvironment = selectedItems[0];
const confirmed = await confirmAsync({
title: '',
message: `Associate ${selectedEnvironment.Name} with OpenAMT`,
buttons: {
cancel: {
label: 'Cancel',
className: 'btn-default',
},
confirm: {
label: 'Confirm',
className: 'btn-primary',
},
},
});
if (!confirmed) {
return;
}
try {
setLoadingMessage(
'Activating Active Management Technology on selected device...'
);
await activateDevice(selectedEnvironment.Id);
notifications.success(
'Successfully associated with OpenAMT',
selectedEnvironment.Name
);
} catch (err) {
notifications.error(
'Failure',
err as Error,
'Unable to associate with OpenAMT'
);
} finally {
setLoadingMessage('');
}
}
}

View file

@ -0,0 +1,42 @@
import { react2angular } from '@/react-tools/react2angular';
import { TableSettingsProvider } from '@/portainer/components/datatables/components/useTableSettings';
import { SearchBarProvider } from '@/portainer/components/datatables/components/SearchBar';
import {
EdgeDevicesDatatable,
EdgeDevicesTableProps,
} from './EdgeDevicesDatatable';
export function EdgeDevicesDatatableContainer({
...props
}: EdgeDevicesTableProps) {
const defaultSettings = {
autoRefreshRate: 0,
hiddenQuickActions: [],
hiddenColumns: [],
pageSize: 10,
sortBy: { id: 'state', desc: false },
};
return (
<TableSettingsProvider defaults={defaultSettings} storageKey="edgeDevices">
<SearchBarProvider storageKey="edgeDevices">
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<EdgeDevicesDatatable {...props} />
</SearchBarProvider>
</TableSettingsProvider>
);
}
export const EdgeDevicesDatatableAngular = react2angular(
EdgeDevicesDatatableContainer,
[
'dataset',
'onRefresh',
'setLoadingMessage',
'isFdoEnabled',
'disableTrustOnFirstConnect',
'isOpenAmtEnabled',
'mpsServer',
]
);

View file

@ -0,0 +1,19 @@
import { TableSettingsMenuAutoRefresh } from '@/portainer/components/datatables/components/TableSettingsMenuAutoRefresh';
import { useTableSettings } from '@/portainer/components/datatables/components/useTableSettings';
import { EdgeDeviceTableSettings } from '@/edge/devices/types';
export function EdgeDevicesDatatableSettings() {
const { settings, setTableSettings } =
useTableSettings<EdgeDeviceTableSettings>();
return (
<TableSettingsMenuAutoRefresh
value={settings.autoRefreshRate}
onChange={handleRefreshRateChange}
/>
);
function handleRefreshRateChange(autoRefreshRate: number) {
setTableSettings({ autoRefreshRate });
}
}

View file

@ -0,0 +1,32 @@
import { createContext, useContext, useMemo, PropsWithChildren } from 'react';
interface RowContextState {
disableTrustOnFirstConnect: boolean;
}
const RowContext = createContext<RowContextState | null>(null);
export interface RowProviderProps {
disableTrustOnFirstConnect: boolean;
}
export function RowProvider({
disableTrustOnFirstConnect,
children,
}: PropsWithChildren<RowProviderProps>) {
const state = useMemo(
() => ({ disableTrustOnFirstConnect }),
[disableTrustOnFirstConnect]
);
return <RowContext.Provider value={state}>{children}</RowContext.Provider>;
}
export function useRowContext() {
const context = useContext(RowContext);
if (!context) {
throw new Error('should be nested under RowProvider');
}
return context;
}

View file

@ -0,0 +1,107 @@
import { CellProps, Column } from 'react-table';
import { MenuItem, MenuLink } from '@reach/menu-button';
import { useRouter, useSref } from '@uirouter/react';
import { Environment } from '@/portainer/environments/types';
import { ActionsMenu } from '@/portainer/components/datatables/components/ActionsMenu';
import {
snapshotEndpoint,
trustEndpoint,
} from '@/portainer/environments/environment.service';
import * as notifications from '@/portainer/services/notifications';
import { getRoute } from '@/portainer/environments/utils';
import { confirmAsync } from '@/portainer/services/modal.service/confirm';
import { useRowContext } from '@/edge/devices/components/EdgeDevicesDatatable/columns/RowContext';
export const actions: Column<Environment> = {
Header: 'Actions',
accessor: () => 'actions',
id: 'actions',
disableFilters: true,
canHide: true,
disableResizing: true,
width: '5px',
sortType: 'string',
Filter: () => null,
Cell: ActionsCell,
};
export function ActionsCell({
row: { original: environment },
}: CellProps<Environment>) {
const router = useRouter();
const environmentRoute = getRoute(environment);
const browseLinkProps = useSref(environmentRoute, {
id: environment.Id,
endpointId: environment.Id,
});
const showRefreshSnapshot = false; // remove and show MenuItem when feature is available
const { disableTrustOnFirstConnect } = useRowContext();
return (
<ActionsMenu>
<MenuLink href={browseLinkProps.href} onClick={browseLinkProps.onClick}>
Browse
</MenuLink>
{showRefreshSnapshot && (
<MenuItem hidden onSelect={() => handleRefreshSnapshotClick()}>
Refresh Snapshot
</MenuItem>
)}
{disableTrustOnFirstConnect && !environment.UserTrusted && (
<MenuLink onClick={trustDevice}>Trust</MenuLink>
)}
</ActionsMenu>
);
async function handleRefreshSnapshotClick() {
try {
await snapshotEndpoint(environment.Id);
notifications.success('Success', 'Environment updated');
} catch (err) {
notifications.error(
'Failure',
err as Error,
'An error occurred during environment snapshot'
);
} finally {
await router.stateService.reload();
}
}
async function trustDevice() {
const confirmed = await confirmAsync({
title: '',
message: `Mark ${environment.Name} as trusted?`,
buttons: {
cancel: {
label: 'Cancel',
className: 'btn-default',
},
confirm: {
label: 'Trust',
className: 'btn-primary',
},
},
});
if (!confirmed) {
return;
}
try {
await trustEndpoint(environment.Id);
} catch (err) {
notifications.error(
'Failure',
err as Error,
'An error occurred while trusting the environment'
);
} finally {
await router.stateService.reload();
}
}
}

View file

@ -0,0 +1,12 @@
import { Column } from 'react-table';
import { Environment } from '@/portainer/environments/types';
import { DefaultFilter } from '@/portainer/components/datatables/components/Filter';
export const group: Column<Environment> = {
Header: 'Group',
accessor: (row) => row.GroupName || '-',
id: 'groupName',
Filter: DefaultFilter,
canHide: true,
};

View file

@ -0,0 +1,50 @@
import { CellProps, Column } from 'react-table';
import clsx from 'clsx';
import { Environment, EnvironmentStatus } from '@/portainer/environments/types';
import { useRowContext } from '@/edge/devices/components/EdgeDevicesDatatable/columns/RowContext';
export const heartbeat: Column<Environment> = {
Header: 'Heartbeat',
accessor: 'Status',
id: 'status',
Cell: StatusCell,
disableFilters: true,
canHide: true,
};
export function StatusCell({
row: { original: environment },
}: CellProps<Environment>) {
const { disableTrustOnFirstConnect } = useRowContext();
if (disableTrustOnFirstConnect && !environment.UserTrusted) {
return <span className="label label-default">untrusted</span>;
}
if (!environment.LastCheckInDate) {
return (
<span className="label label-default">
<s>associated</s>
</span>
);
}
return (
<i
className={clsx(
'fa',
'fa-heartbeat',
environmentStatusLabel(environment.Status)
)}
aria-hidden="true"
/>
);
function environmentStatusLabel(status: EnvironmentStatus) {
if (status === EnvironmentStatus.Up) {
return 'green-icon';
}
return 'orange-icon';
}
}

View file

@ -0,0 +1,10 @@
import { useMemo } from 'react';
import { name } from './name';
import { heartbeat } from './heartbeat';
import { group } from './group';
import { actions } from './actions';
export function useColumns() {
return useMemo(() => [name, heartbeat, group, actions], []);
}

View file

@ -0,0 +1,30 @@
import { CellProps, Column, TableInstance } from 'react-table';
import { Environment } from '@/portainer/environments/types';
import { Link } from '@/portainer/components/Link';
import { ExpandingCell } from '@/portainer/components/datatables/components/ExpandingCell';
export const name: Column<Environment> = {
Header: 'Name',
accessor: (row) => row.Name,
id: 'name',
Cell: NameCell,
disableFilters: true,
Filter: () => null,
canHide: false,
sortType: 'string',
};
export function NameCell({ value: name, row }: CellProps<TableInstance>) {
return (
<ExpandingCell row={row}>
<Link
to="portainer.endpoints.endpoint"
params={{ id: row.original.Id }}
title={name}
>
{name}
</Link>
</ExpandingCell>
);
}

View file

@ -0,0 +1,7 @@
import angular from 'angular';
import { EdgeDevicesDatatableAngular } from '@/edge/devices/components/EdgeDevicesDatatable/EdgeDevicesDatatableContainer';
export default angular
.module('portainer.edge.devices', [])
.component('edgeDevicesDatatable', EdgeDevicesDatatableAngular).name;

36
app/edge/devices/types.ts Normal file
View file

@ -0,0 +1,36 @@
export interface EdgeDeviceTableSettings {
hiddenColumns: string[];
autoRefreshRate: number;
pageSize: number;
sortBy: { id: string; desc: boolean };
}
export interface FDOProfilesTableSettings {
pageSize: number;
sortBy: { id: string; desc: boolean };
}
export enum DeviceAction {
PowerOn = 'power on',
PowerOff = 'power off',
Restart = 'restart',
}
export enum PowerState {
Running = 'Running',
Sleep = 'Sleep',
Off = 'Off',
Hibernate = 'Hibernate',
PowerCycle = 'Power Cycle',
}
export enum PowerStateCode {
On = 2,
SleepLight = 3,
SleepDeep = 4,
OffHard = 6,
Hibernate = 7,
OffSoft = 8,
PowerCycle = 9,
OffHardGraceful = 13,
}

View file

@ -1,6 +1,6 @@
import angular from 'angular'; import angular from 'angular';
import { ScheduleCreateRequest, ScheduleUpdateRequest } from 'Portainer/models/schedule'; import { ScheduleCreateRequest, ScheduleUpdateRequest } from '@/portainer/models/schedule';
function EdgeJobService(EdgeJobs, EdgeJobResults, FileUploadService) { function EdgeJobService(EdgeJobs, EdgeJobResults, FileUploadService) {
var service = {}; var service = {};

View file

@ -0,0 +1,39 @@
<rd-header>
<rd-header-title title-text="Edge Devices">
<a data-toggle="tooltip" title="Refresh" ui-sref="edge.devices" ui-sref-opts="{reload: true}">
<i class="fa fa-sync" aria-hidden="true"></i>
</a>
</rd-header-title>
<rd-header-content>Edge Devices</rd-header-content>
</rd-header>
<div
class="row"
style="width: 100%; height: 100%; text-align: center; display: flex; flex-direction: column; align-items: center; justify-content: center"
ng-if="$ctrl.loadingMessage"
>
<div class="sk-fold">
<div class="sk-fold-cube"></div>
<div class="sk-fold-cube"></div>
<div class="sk-fold-cube"></div>
<div class="sk-fold-cube"></div>
</div>
<span style="margin-top: 25px">
{{ $ctrl.loadingMessage }}
<i class="fa fa-cog fa-spin"></i>
</span>
</div>
<div class="row" ng-if="!$ctrl.loadingMessage">
<div class="col-sm-12">
<edge-devices-datatable
dataset="($ctrl.edgeDevices)"
is-fdo-enabled="($ctrl.isFDOEnabled)"
is-open-amt-enabled="($ctrl.isOpenAMTEnabled)"
disable-trust-on-first-connect="($ctrl.disableTrustOnFirstConnect)"
mps-server="($ctrl.mpsServer)"
on-refresh="($ctrl.getEnvironments)"
set-loading-message="($ctrl.setLoadingMessage)"
></edge-devices-datatable>
</div>
</div>

View file

@ -0,0 +1,52 @@
import EndpointHelper from 'Portainer/helpers/endpointHelper';
import { getEndpoints } from 'Portainer/environments/environment.service';
import { EnvironmentType } from 'Portainer/environments/types';
angular.module('portainer.edge').controller('EdgeDevicesViewController', EdgeDevicesViewController);
/* @ngInject */
export function EdgeDevicesViewController($q, $async, EndpointService, GroupService, SettingsService, ModalService, Notifications) {
var ctrl = this;
ctrl.edgeDevices = [];
this.getEnvironments = function () {
return $async(async () => {
try {
const [endpointsResponse, groups] = await Promise.all([getEndpoints(0, 100, { types: [EnvironmentType.EdgeAgentOnDocker] }), GroupService.groups()]);
EndpointHelper.mapGroupNameToEndpoint(endpointsResponse.value, groups);
ctrl.edgeDevices = endpointsResponse.value;
} catch (err) {
Notifications.error('Failure', err, 'Unable to retrieve edge devices');
ctrl.edgeDevices = [];
}
});
};
this.getSettings = function () {
return $async(async () => {
try {
const settings = await SettingsService.settings();
ctrl.isFDOEnabled = settings && settings.EnableEdgeComputeFeatures && settings.fdoConfiguration && settings.fdoConfiguration.enabled;
ctrl.disableTrustOnFirstConnect = settings && settings.EnableEdgeComputeFeatures && settings.DisableTrustOnFirstConnect;
ctrl.isOpenAMTEnabled = settings && settings.EnableEdgeComputeFeatures && settings.openAMTConfiguration && settings.openAMTConfiguration.enabled;
ctrl.mpsServer = ctrl.isOpenAMTEnabled ? settings.openAMTConfiguration.mpsServer : '';
} catch (err) {
Notifications.error('Failure', err, 'Unable to retrieve settings');
}
});
};
this.setLoadingMessage = function (message) {
return $async(async () => {
ctrl.loadingMessage = message;
});
};
function initView() {
ctrl.getEnvironments();
ctrl.getSettings();
}
initView();
}

View file

@ -0,0 +1,8 @@
import angular from 'angular';
import { EdgeDevicesViewController } from './edgeDevicesViewController';
angular.module('portainer.edge').component('edgeDevicesView', {
templateUrl: './edgeDevicesView.html',
controller: EdgeDevicesViewController,
});

2
app/global.d.ts vendored
View file

@ -6,3 +6,5 @@ declare module '*.png' {
} }
declare module '*.css'; declare module '*.css';
declare module '@open-amt-cloud-toolkit/ui-toolkit-react/reactjs/src/kvm.bundle';

View file

@ -205,6 +205,51 @@ angular
}, },
}; };
var edgeDeviceCreation = {
name: 'portainer.endpoints.newEdgeDevice',
url: '/newEdgeDevice',
params: {
isEdgeDevice: true,
},
views: {
'content@': {
templateUrl: './views/endpoints/create/createendpoint.html',
controller: 'CreateEndpointController',
},
},
};
var deviceImport = {
name: 'portainer.endpoints.importDevice',
url: '/device',
views: {
'content@': {
templateUrl: './views/devices/import/importDevice.html',
controller: 'ImportDeviceController',
},
},
};
var addFDOProfile = {
name: 'portainer.endpoints.profile',
url: '/profile',
views: {
'content@': {
component: 'addProfileView',
},
},
};
var editFDOProfile = {
name: 'portainer.endpoints.profile.edit',
url: '/:id',
views: {
'content@': {
component: 'editProfileView',
},
},
};
var endpointAccess = { var endpointAccess = {
name: 'portainer.endpoints.endpoint.access', name: 'portainer.endpoints.endpoint.access',
url: '/access', url: '/access',
@ -217,6 +262,17 @@ angular
}, },
}; };
var endpointKVM = {
name: 'portainer.endpoints.endpoint.kvm',
url: '/kvm?deviceId&deviceName',
views: {
'content@': {
templateUrl: './views/endpoints/kvm/endpointKVM.html',
controller: 'EndpointKVMController',
},
},
};
var groups = { var groups = {
name: 'portainer.groups', name: 'portainer.groups',
url: '/groups', url: '/groups',
@ -378,6 +434,16 @@ angular
}, },
}; };
var settingsEdgeCompute = {
name: 'portainer.settings.edgeCompute',
url: '/edge',
views: {
'content@': {
component: 'settingsEdgeComputeView',
},
},
};
var tags = { var tags = {
name: 'portainer.tags', name: 'portainer.tags',
url: '/tags', url: '/tags',
@ -444,7 +510,12 @@ angular
$stateRegistryProvider.register(endpoint); $stateRegistryProvider.register(endpoint);
$stateRegistryProvider.register(k8sendpoint); $stateRegistryProvider.register(k8sendpoint);
$stateRegistryProvider.register(endpointAccess); $stateRegistryProvider.register(endpointAccess);
$stateRegistryProvider.register(endpointKVM);
$stateRegistryProvider.register(edgeDeviceCreation);
$stateRegistryProvider.register(endpointCreation); $stateRegistryProvider.register(endpointCreation);
$stateRegistryProvider.register(deviceImport);
$stateRegistryProvider.register(addFDOProfile);
$stateRegistryProvider.register(editFDOProfile);
$stateRegistryProvider.register(endpointKubernetesConfiguration); $stateRegistryProvider.register(endpointKubernetesConfiguration);
$stateRegistryProvider.register(groups); $stateRegistryProvider.register(groups);
$stateRegistryProvider.register(group); $stateRegistryProvider.register(group);
@ -461,6 +532,7 @@ angular
$stateRegistryProvider.register(registryCreation); $stateRegistryProvider.register(registryCreation);
$stateRegistryProvider.register(settings); $stateRegistryProvider.register(settings);
$stateRegistryProvider.register(settingsAuthentication); $stateRegistryProvider.register(settingsAuthentication);
$stateRegistryProvider.register(settingsEdgeCompute);
$stateRegistryProvider.register(tags); $stateRegistryProvider.register(tags);
$stateRegistryProvider.register(users); $stateRegistryProvider.register(users);
$stateRegistryProvider.register(user); $stateRegistryProvider.register(user);

View file

@ -3,14 +3,23 @@ import clsx from 'clsx';
import styles from './TextTip.module.css'; import styles from './TextTip.module.css';
export function TextTip({ children }: PropsWithChildren<unknown>) { type Color = 'orange' | 'blue';
export interface Props {
color?: Color;
}
export function TextTip({
color = 'orange',
children,
}: PropsWithChildren<Props>) {
return ( return (
<p className="text-muted small"> <p className="text-muted small">
<i <i
aria-hidden="true" aria-hidden="true"
className={clsx( className={clsx(
'fa fa-exclamation-circle', 'fa fa-exclamation-circle',
'orange-icon', `${color}-icon`,
styles.textMargin styles.textMargin
)} )}
/> />

View file

@ -0,0 +1,25 @@
.actions {
float: right;
margin: 5px 10px 0 0;
}
.actions-active {
color: var(--blue-2);
}
.table-actions-menu-list {
padding: 0 10px 0 10px;
}
.table-actions-menu-list [data-reach-menu-item] {
padding: 5px 5px !important;
}
.table-actions-menu-btn {
border: none;
background: none;
}
[data-reach-menu-link] {
text-decoration: none !important;
}

View file

@ -0,0 +1,31 @@
import { ReactNode } from 'react';
import clsx from 'clsx';
import { Menu, MenuList, MenuButton } from '@reach/menu-button';
import styles from './ActionsMenu.module.css';
interface Props {
children: ReactNode;
}
export function ActionsMenu({ children }: Props) {
return (
<Menu className={styles.actions}>
{({ isExpanded }) => (
<>
<MenuButton
className={clsx(
styles.tableActionsMenuBtn,
isExpanded && styles.actionsActive
)}
>
<i className="fa fa-ellipsis-v" aria-hidden="true" />
</MenuButton>
<MenuList>
<div className={styles.tableActionsMenuList}>{children}</div>
</MenuList>
</>
)}
</Menu>
);
}

View file

@ -0,0 +1,3 @@
.table-actions-title {
color: var(--blue-2);
}

View file

@ -0,0 +1,11 @@
import { ReactNode } from 'react';
import styles from './ActionsMenuTitle.module.css';
interface Props {
children: ReactNode;
}
export function ActionsMenuTitle({ children }: Props) {
return <div className={styles.tableActionsTitle}>{children}</div>;
}

View file

@ -3,17 +3,20 @@ import { Menu, MenuButton, MenuList } from '@reach/menu-button';
import { ColumnInstance } from 'react-table'; import { ColumnInstance } from 'react-table';
import { Checkbox } from '@/portainer/components/form-components/Checkbox'; import { Checkbox } from '@/portainer/components/form-components/Checkbox';
import type { DockerContainer } from '@/docker/containers/types';
import { useTableContext } from './TableContainer'; import { useTableContext } from './TableContainer';
interface Props { interface Props<D extends object> {
columns: ColumnInstance<DockerContainer>[]; columns: ColumnInstance<D>[];
onChange: (value: string[]) => void; onChange: (value: string[]) => void;
value: string[]; value: string[];
} }
export function ColumnVisibilityMenu({ columns, onChange, value }: Props) { export function ColumnVisibilityMenu<D extends object>({
columns,
onChange,
value,
}: Props<D>) {
useTableContext(); useTableContext();
return ( return (

View file

@ -0,0 +1,26 @@
import { PropsWithChildren } from 'react';
import { Row, TableInstance } from 'react-table';
interface Props {
row: Row<TableInstance>;
}
export function ExpandingCell({ children, row }: PropsWithChildren<Props>) {
return (
// eslint-disable-next-line react/jsx-props-no-spreading
<div {...row.getToggleRowExpandedProps()}>
<i
className={`fas ${arrowClass(row.isExpanded)} space-right`}
aria-hidden="true"
/>
{children}
</div>
);
function arrowClass(isExpanded: boolean) {
if (isExpanded) {
return 'fa-angle-down';
}
return 'fa-angle-right';
}
}

View file

@ -0,0 +1,3 @@
.inner-datatable thead {
background-color: var(--bg-inner-datatable-thead) !important;
}

View file

@ -0,0 +1,7 @@
import { PropsWithChildren } from 'react';
import styles from './InnerDatatable.module.css';
export function InnerDatatable({ children }: PropsWithChildren<unknown>) {
return <div className={styles.innerDatatable}>{children}</div>;
}

View file

@ -5,7 +5,7 @@ import { TableHeaderCell } from './TableHeaderCell';
interface Props<D extends Record<string, unknown> = Record<string, unknown>> { interface Props<D extends Record<string, unknown> = Record<string, unknown>> {
headers: HeaderGroup<D>[]; headers: HeaderGroup<D>[];
onSortChange(colId: string, desc: boolean): void; onSortChange?(colId: string, desc: boolean): void;
} }
export function TableHeaderRow< export function TableHeaderRow<
@ -26,13 +26,18 @@ export function TableHeaderRow<
headerProps={{ headerProps={{
...column.getHeaderProps({ ...column.getHeaderProps({
className: column.className, className: column.className,
style: {
width: column.disableResizing ? column.width : '',
},
}), }),
}} }}
key={column.id} key={column.id}
canSort={column.canSort} canSort={column.canSort}
onSortClick={(desc) => { onSortClick={(desc) => {
column.toggleSortBy(desc); column.toggleSortBy(desc);
onSortChange(column.id, desc); if (onSortChange) {
onSortChange(column.id, desc);
}
}} }}
isSorted={column.isSorted} isSorted={column.isSorted}
isSortedDesc={column.isSortedDesc} isSortedDesc={column.isSortedDesc}

View file

@ -5,7 +5,7 @@ import { PropsWithChildren, ReactNode } from 'react';
import { useTableContext } from './TableContainer'; import { useTableContext } from './TableContainer';
interface Props { interface Props {
quickActions: ReactNode; quickActions?: ReactNode;
} }
export function TableSettingsMenu({ export function TableSettingsMenu({

View file

@ -15,7 +15,7 @@
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove <i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove
</button> </button>
<button type="button" class="btn btn-sm btn-primary" ui-sref="portainer.endpoints.new" data-cy="endpoint-addEndpointButton"> <button type="button" class="btn btn-sm btn-primary" ui-sref="portainer.endpoints.new" data-cy="endpoint-addEndpointButton">
<i class="fa fa-plus space-right" aria-hidden="true"></i>Add environment <i class="fa fa-plus-circle space-right" aria-hidden="true"></i>Add environment
</button> </button>
</div> </div>
<div class="searchBar"> <div class="searchBar">

View file

@ -3,6 +3,7 @@ angular.module('portainer.app').controller('EndpointsDatatableController', [
'$controller', '$controller',
'DatatableService', 'DatatableService',
'PaginationService', 'PaginationService',
'Notifications',
function ($scope, $controller, DatatableService, PaginationService) { function ($scope, $controller, DatatableService, PaginationService) {
angular.extend(this, $controller('GenericDatatableController', { $scope: $scope })); angular.extend(this, $controller('GenericDatatableController', { $scope: $scope }));

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