diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 76afcf4f3..85c1f586b 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -554,7 +554,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { oauthService := initOAuthService() gitService := initGitService() - openAMTService := openamt.NewService(dataStore) + openAMTService := openamt.NewService() cryptoService := initCryptoService() digitalSignatureService := initDigitalSignatureService() diff --git a/api/cmd/portainer/main_test.go b/api/cmd/portainer/main_test.go index 503b0df3a..e80c05ce2 100644 --- a/api/cmd/portainer/main_test.go +++ b/api/cmd/portainer/main_test.go @@ -29,11 +29,6 @@ func Test_enableFeaturesFromFlags(t *testing.T) { isSupported bool }{ {"test", false}, - {"openamt", false}, - {"open-amt", true}, - {"oPeN-amT", true}, - {"fdo", true}, - {"FDO", true}, } for _, test := range tests { t.Run(fmt.Sprintf("%s succeeds:%v", test.featureFlag, test.isSupported), func(t *testing.T) { diff --git a/api/database/boltdb/json_test.go b/api/database/boltdb/json_test.go index 712621f10..4f44110f9 100644 --- a/api/database/boltdb/json_test.go +++ b/api/database/boltdb/json_test.go @@ -10,7 +10,7 @@ import ( ) const ( - jsonobject = `{"LogoURL":"","BlackListedLabels":[],"AuthenticationMethod":1,"LDAPSettings":{"AnonymousMode":true,"ReaderDN":"","URL":"","TLSConfig":{"TLS":false,"TLSSkipVerify":false},"StartTLS":false,"SearchSettings":[{"BaseDN":"","Filter":"","UserNameAttribute":""}],"GroupSearchSettings":[{"GroupBaseDN":"","GroupFilter":"","GroupAttribute":""}],"AutoCreateUsers":true},"OAuthSettings":{"ClientID":"","AccessTokenURI":"","AuthorizationURI":"","ResourceURI":"","RedirectURI":"","UserIdentifier":"","Scopes":"","OAuthAutoCreateUsers":false,"DefaultTeamID":0,"SSO":true,"LogoutURI":"","KubeSecretKey":"j0zLVtY/lAWBk62ByyF0uP80SOXaitsABP0TTJX8MhI="},"OpenAMTConfiguration":{"Enabled":false,"MPSServer":"","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" ) diff --git a/api/dataservices/fdoprofile/fdoprofile.go b/api/dataservices/fdoprofile/fdoprofile.go new file mode 100644 index 000000000..d8f57aea9 --- /dev/null +++ b/api/dataservices/fdoprofile/fdoprofile.go @@ -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) +} diff --git a/api/dataservices/interface.go b/api/dataservices/interface.go index fecfe6666..24dcaf71b 100644 --- a/api/dataservices/interface.go +++ b/api/dataservices/interface.go @@ -31,6 +31,7 @@ type ( Endpoint() EndpointService EndpointGroup() EndpointGroupService EndpointRelation() EndpointRelationService + FDOProfile() FDOProfileService HelmUserRepository() HelmUserRepositoryService Registry() RegistryService ResourceControl() ResourceControlService @@ -122,6 +123,17 @@ type ( 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 interface { HelmUserRepositories() ([]portainer.HelmUserRepository, error) diff --git a/api/datastore/migrate_data.go b/api/datastore/migrate_data.go index 74f1794a4..a453d96d5 100644 --- a/api/datastore/migrate_data.go +++ b/api/datastore/migrate_data.go @@ -30,6 +30,7 @@ func (store *Store) MigrateData() error { EndpointService: store.EndpointService, EndpointRelationService: store.EndpointRelationService, ExtensionService: store.ExtensionService, + FDOProfilesService: store.FDOProfilesService, RegistryService: store.RegistryService, ResourceControlService: store.ResourceControlService, RoleService: store.RoleService, diff --git a/api/datastore/migrator/migrator.go b/api/datastore/migrator/migrator.go index 729940fdb..97836cacb 100644 --- a/api/datastore/migrator/migrator.go +++ b/api/datastore/migrator/migrator.go @@ -7,6 +7,7 @@ import ( "github.com/portainer/portainer/api/dataservices/endpointgroup" "github.com/portainer/portainer/api/dataservices/endpointrelation" "github.com/portainer/portainer/api/dataservices/extension" + "github.com/portainer/portainer/api/dataservices/fdoprofile" "github.com/portainer/portainer/api/dataservices/registry" "github.com/portainer/portainer/api/dataservices/resourcecontrol" "github.com/portainer/portainer/api/dataservices/role" @@ -26,12 +27,12 @@ var migrateLog = plog.NewScopedLog("database, migrate") type ( // Migrator defines a service to migrate data after a Portainer version update. Migrator struct { - currentDBVersion int - + currentDBVersion int endpointGroupService *endpointgroup.Service endpointService *endpoint.Service endpointRelationService *endpointrelation.Service extensionService *extension.Service + fdoProfilesService *fdoprofile.Service registryService *registry.Service resourceControlService *resourcecontrol.Service roleService *role.Service @@ -54,6 +55,7 @@ type ( EndpointService *endpoint.Service EndpointRelationService *endpointrelation.Service ExtensionService *extension.Service + FDOProfilesService *fdoprofile.Service RegistryService *registry.Service ResourceControlService *resourcecontrol.Service RoleService *role.Service @@ -78,6 +80,7 @@ func NewMigrator(parameters *MigratorParameters) *Migrator { endpointService: parameters.EndpointService, endpointRelationService: parameters.EndpointRelationService, extensionService: parameters.ExtensionService, + fdoProfilesService: parameters.FDOProfilesService, registryService: parameters.RegistryService, resourceControlService: parameters.ResourceControlService, roleService: parameters.RoleService, diff --git a/api/datastore/services.go b/api/datastore/services.go index 0d23fa612..5c0914dab 100644 --- a/api/datastore/services.go +++ b/api/datastore/services.go @@ -17,6 +17,7 @@ import ( "github.com/portainer/portainer/api/dataservices/endpointgroup" "github.com/portainer/portainer/api/dataservices/endpointrelation" "github.com/portainer/portainer/api/dataservices/extension" + "github.com/portainer/portainer/api/dataservices/fdoprofile" "github.com/portainer/portainer/api/dataservices/helmuserrepository" "github.com/portainer/portainer/api/dataservices/registry" "github.com/portainer/portainer/api/dataservices/resourcecontrol" @@ -50,6 +51,7 @@ type Store struct { EndpointService *endpoint.Service EndpointRelationService *endpointrelation.Service ExtensionService *extension.Service + FDOProfilesService *fdoprofile.Service HelmUserRepositoryService *helmuserrepository.Service RegistryService *registry.Service ResourceControlService *resourcecontrol.Service @@ -129,6 +131,12 @@ func (store *Store) initServices() error { } store.ExtensionService = extensionService + fdoProfilesService, err := fdoprofile.NewService(store.connection) + if err != nil { + return err + } + store.FDOProfilesService = fdoProfilesService + helmUserRepositoryService, err := helmuserrepository.NewService(store.connection) if err != nil { return err @@ -257,6 +265,11 @@ func (store *Store) EndpointRelation() dataservices.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 func (store *Store) HelmUserRepository() dataservices.HelmUserRepositoryService { return store.HelmUserRepositoryService diff --git a/api/docker/client.go b/api/docker/client.go index eac3b238c..5b55e44dc 100644 --- a/api/docker/client.go +++ b/api/docker/client.go @@ -15,7 +15,7 @@ import ( var errUnsupportedEnvironmentType = errors.New("Environment not supported") const ( - defaultDockerRequestTimeout = 60 + defaultDockerRequestTimeout = 60 * time.Second 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 // 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 { return nil, errUnsupportedEnvironmentType } 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 { - 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://") { return createLocalClient(endpoint) } - return createTCPClient(endpoint) + return createTCPClient(endpoint, timeout) } 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) { - httpCli, err := httpClient(endpoint) +func createTCPClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*client.Client, error) { + httpCli, err := httpClient(endpoint, timeout) if err != nil { 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) { - httpCli, err := httpClient(endpoint) +func createEdgeClient(endpoint *portainer.Endpoint, signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, nodeName string, timeout *time.Duration) (*client.Client, error) { + httpCli, err := httpClient(endpoint, timeout) if err != nil { 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) { - httpCli, err := httpClient(endpoint) +func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer.DigitalSignatureService, nodeName string, timeout *time.Duration) (*client.Client, error) { + httpCli, err := httpClient(endpoint, timeout) if err != nil { 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{} if endpoint.TLSConfig.TLS { @@ -145,8 +146,13 @@ func httpClient(endpoint *portainer.Endpoint) (*http.Client, error) { transport.TLSClientConfig = tlsConfig } + clientTimeout := defaultDockerRequestTimeout + if timeout != nil { + clientTimeout = *timeout + } + return &http.Client{ Transport: transport, - Timeout: defaultDockerRequestTimeout * time.Second, + Timeout: clientTimeout, }, nil } diff --git a/api/docker/snapshot.go b/api/docker/snapshot.go index 553a94847..2d638fb55 100644 --- a/api/docker/snapshot.go +++ b/api/docker/snapshot.go @@ -26,7 +26,7 @@ func NewSnapshotter(clientFactory *ClientFactory) *Snapshotter { // CreateSnapshot creates a snapshot of a specific Docker environment(endpoint) 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 { return nil, err } diff --git a/api/filesystem/filesystem.go b/api/filesystem/filesystem.go index 1fb58aa8c..8e9226937 100644 --- a/api/filesystem/filesystem.go +++ b/api/filesystem/filesystem.go @@ -35,6 +35,8 @@ const ( ManifestFileDefaultName = "k8s-deployment.yml" // EdgeStackStorePath represents the subfolder where edge stack files are stored in the file store folder. 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 = "portainer.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) } + +// 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 +} diff --git a/api/go.mod b/api/go.mod index 0ee7f6e4d..861950757 100644 --- a/api/go.mod +++ b/api/go.mod @@ -14,6 +14,7 @@ require ( github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/docker/cli 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/go-git/go-git/v5 v5.3.0 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/libhelm v0.0.0-20210929000907-825e93d62108 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/sirupsen/logrus v1.8.1 github.com/stretchr/testify v1.7.0 @@ -94,6 +96,7 @@ require ( github.com/sergi/go-diff v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // 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/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect diff --git a/api/go.sum b/api/go.sum index 4a476664f..b04a82a4c 100644 --- a/api/go.sum +++ b/api/go.sum @@ -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/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 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/go.mod h1:secRm32Ro77eD23BmPVbgLbWN+JWDw7pJszenjxI4bI= 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.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= 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/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= 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/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/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/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= diff --git a/api/hostmanagement/fdo/owner_client.go b/api/hostmanagement/fdo/owner_client.go new file mode 100644 index 000000000..1bca6dc7b --- /dev/null +++ b/api/hostmanagement/fdo/owner_client.go @@ -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 +} diff --git a/api/hostmanagement/openamt/authorization.go b/api/hostmanagement/openamt/authorization.go index 92be17b49..5e9d0773f 100644 --- a/api/hostmanagement/openamt/authorization.go +++ b/api/hostmanagement/openamt/authorization.go @@ -14,39 +14,39 @@ type authenticationResponse struct { 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) payload := map[string]string{ - "username": configuration.Credentials.MPSUser, - "password": configuration.Credentials.MPSPassword, + "username": configuration.MPSUser, + "password": configuration.MPSPassword, } jsonValue, _ := json.Marshal(payload) req, err := http.NewRequest(http.MethodPost, loginURL, bytes.NewBuffer(jsonValue)) if err != nil { - return nil, err + return "", err } req.Header.Set("Content-Type", "application/json") response, err := service.httpsClient.Do(req) if err != nil { - return nil, err + return "", err } responseBody, readErr := ioutil.ReadAll(response.Body) if readErr != nil { - return nil, readErr + return "", readErr } errorResponse := parseError(responseBody) if errorResponse != nil { - return nil, errorResponse + return "", errorResponse } var token authenticationResponse err = json.Unmarshal(responseBody, &token) if err != nil { - return nil, err + return "", err } - return &token, nil + return token.Token, nil } diff --git a/api/hostmanagement/openamt/configCIRA.go b/api/hostmanagement/openamt/configCIRA.go index fdbc23202..def1f423c 100644 --- a/api/hostmanagement/openamt/configCIRA.go +++ b/api/hostmanagement/openamt/configCIRA.go @@ -4,7 +4,6 @@ import ( "encoding/base64" "encoding/json" "encoding/pem" - "errors" "fmt" "io" "net" @@ -47,7 +46,7 @@ func (service *Service) createOrUpdateCIRAConfig(configuration portainer.OpenAMT 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) - responseBody, err := service.executeGetRequest(url, configuration.Credentials.MPSToken) + responseBody, err := service.executeGetRequest(url, configuration.MPSToken) if err != nil { return nil, err } @@ -89,7 +88,7 @@ func (service *Service) saveCIRAConfig(method string, configuration portainer.Op } 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 { return nil, err } @@ -123,7 +122,7 @@ func (service *Service) getCIRACertificate(configuration portainer.OpenAMTConfig if err != nil { 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) if err != nil { @@ -131,7 +130,7 @@ func (service *Service) getCIRACertificate(configuration portainer.OpenAMTConfig } 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) diff --git a/api/hostmanagement/openamt/configDevice.go b/api/hostmanagement/openamt/configDevice.go new file mode 100644 index 000000000..202d5be3e --- /dev/null +++ b/api/hostmanagement/openamt/configDevice.go @@ -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 +} diff --git a/api/hostmanagement/openamt/configDomain.go b/api/hostmanagement/openamt/configDomain.go index 4936ad8ca..5117defdf 100644 --- a/api/hostmanagement/openamt/configDomain.go +++ b/api/hostmanagement/openamt/configDomain.go @@ -37,9 +37,9 @@ func (service *Service) createOrUpdateDomain(configuration portainer.OpenAMTConf } 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 { 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) profile := Domain{ - DomainName: configuration.DomainConfiguration.DomainName, - DomainSuffix: configuration.DomainConfiguration.DomainName, - ProvisioningCert: configuration.DomainConfiguration.CertFileText, - ProvisioningCertPassword: configuration.DomainConfiguration.CertPassword, + DomainName: configuration.DomainName, + DomainSuffix: configuration.DomainName, + ProvisioningCert: configuration.CertFileContent, + ProvisioningCertPassword: configuration.CertFilePassword, ProvisioningCertStorageFormat: "string", } 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 { return nil, err } diff --git a/api/hostmanagement/openamt/configProfile.go b/api/hostmanagement/openamt/configProfile.go index 55cd6f1a8..5e80205b4 100644 --- a/api/hostmanagement/openamt/configProfile.go +++ b/api/hostmanagement/openamt/configProfile.go @@ -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) if err != nil { return nil, err @@ -40,7 +40,7 @@ func (service *Service) createOrUpdateAMTProfile(configuration portainer.OpenAMT method = http.MethodPatch } - profile, err = service.saveAMTProfile(method, configuration, profileName, ciraConfigName, wirelessConfig) + profile, err = service.saveAMTProfile(method, configuration, profileName, ciraConfigName) if err != nil { 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) { 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 { return nil, err } @@ -66,7 +66,7 @@ func (service *Service) getAMTProfile(configuration portainer.OpenAMTConfigurati 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) profile := Profile{ @@ -74,23 +74,15 @@ func (service *Service) saveAMTProfile(method string, configuration portainer.Op Activation: "acmactivate", GenerateRandomAMTPassword: false, GenerateRandomMEBxPassword: false, - AMTPassword: configuration.Credentials.MPSPassword, - MEBXPassword: configuration.Credentials.MPSPassword, + AMTPassword: configuration.MPSPassword, + MEBXPassword: configuration.MPSPassword, CIRAConfigName: &ciraConfigName, Tags: []string{}, DHCPEnabled: true, } - if wirelessConfig != "" { - profile.WIFIConfigs = []ProfileWifiConfig{ - { - Priority: 1, - ProfileName: DefaultWirelessConfigName, - }, - } - } 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 { return nil, err } diff --git a/api/hostmanagement/openamt/configWireless.go b/api/hostmanagement/openamt/configWireless.go deleted file mode 100644 index 958626755..000000000 --- a/api/hostmanagement/openamt/configWireless.go +++ /dev/null @@ -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 -} diff --git a/api/hostmanagement/openamt/deviceActions.go b/api/hostmanagement/openamt/deviceActions.go new file mode 100644 index 000000000..39960add1 --- /dev/null +++ b/api/hostmanagement/openamt/deviceActions.go @@ -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) +} diff --git a/api/hostmanagement/openamt/deviceFeatures.go b/api/hostmanagement/openamt/deviceFeatures.go new file mode 100644 index 000000000..e71d96684 --- /dev/null +++ b/api/hostmanagement/openamt/deviceFeatures.go @@ -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 +} diff --git a/api/hostmanagement/openamt/openamt.go b/api/hostmanagement/openamt/openamt.go index 2f5a92292..ca03c999e 100644 --- a/api/hostmanagement/openamt/openamt.go +++ b/api/hostmanagement/openamt/openamt.go @@ -11,13 +11,18 @@ import ( "time" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/dataservices" + "golang.org/x/sync/errgroup" ) const ( - DefaultCIRAConfigName = "ciraConfigDefault" - DefaultWirelessConfigName = "wirelessProfileDefault" - DefaultProfileName = "profileAMTDefault" + DefaultCIRAConfigName = "ciraConfigDefault" + 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. @@ -26,13 +31,10 @@ type Service struct { } // NewService initializes a new service. -func NewService(dataStore dataservices.DataStore) *Service { - if !dataStore.Settings().IsFeatureFlagEnabled(portainer.FeatOpenAMT) { - return nil - } +func NewService() *Service { return &Service{ httpsClient: &http.Client{ - Timeout: time.Second * time.Duration(5), + Timeout: httpClientTimeout, Transport: &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, }, @@ -63,28 +65,19 @@ func parseError(responseBody []byte) error { return nil } -func (service *Service) ConfigureDefault(configuration portainer.OpenAMTConfiguration) error { - token, err := service.executeAuthenticationRequest(configuration) +func (service *Service) Configure(configuration portainer.OpenAMTConfiguration) error { + token, err := service.Authorization(configuration) if err != nil { return err } - configuration.Credentials.MPSToken = token.Token + configuration.MPSToken = token ciraConfig, err := service.createOrUpdateCIRAConfig(configuration, DefaultCIRAConfigName) if err != nil { return err } - wirelessConfigName := "" - 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) + _, err = service.createOrUpdateAMTProfile(configuration, DefaultProfileName, ciraConfig.ConfigName) if err != nil { return err } @@ -119,7 +112,7 @@ func (service *Service) executeSaveRequest(method string, url string, token stri if errorResponse != nil { 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 @@ -151,8 +144,110 @@ func (service *Service) executeGetRequest(url string, token string) ([]byte, err if errorResponse != nil { 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 } + +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 +} diff --git a/api/http/handler/endpoints/endpoint_create.go b/api/http/handler/endpoints/endpoint_create.go index 6cde1a1c3..b06887c6e 100644 --- a/api/http/handler/endpoints/endpoint_create.go +++ b/api/http/handler/endpoints/endpoint_create.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/gofrs/uuid" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" @@ -37,6 +38,7 @@ type endpointCreatePayload struct { AzureAuthenticationKey string TagIDs []portainer.TagID EdgeCheckinInterval int + IsEdgeDevice bool } type endpointCreationEnum int @@ -144,6 +146,9 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error { checkinInterval, _ := request.RetrieveNumericMultiPartFormValue(r, "CheckinInterval", true) payload.EdgeCheckinInterval = checkinInterval + isEdgeDevice, _ := request.RetrieveBooleanMultiPartFormValue(r, "IsEdgeDevice", true) + payload.IsEdgeDevice = isEdgeDevice + return nil } @@ -332,6 +337,21 @@ func (handler *Handler) createEdgeAgentEndpoint(payload *endpointCreatePayload) EdgeKey: edgeKey, EdgeCheckinInterval: payload.EdgeCheckinInterval, 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) @@ -370,6 +390,7 @@ func (handler *Handler) createUnsecuredEndpoint(payload *endpointCreatePayload) Status: portainer.EndpointStatusUp, Snapshots: []portainer.DockerSnapshot{}, Kubernetes: portainer.KubernetesDefault(), + IsEdgeDevice: payload.IsEdgeDevice, } err := handler.snapshotAndPersistEndpoint(endpoint) @@ -435,6 +456,7 @@ func (handler *Handler) createTLSSecuredEndpoint(payload *endpointCreatePayload, Status: portainer.EndpointStatusUp, Snapshots: []portainer.DockerSnapshot{}, Kubernetes: portainer.KubernetesDefault(), + IsEdgeDevice: payload.IsEdgeDevice, } err := handler.storeTLSFiles(endpoint, payload) diff --git a/api/http/handler/endpoints/endpoint_list.go b/api/http/handler/endpoints/endpoint_list.go index 0eec62678..00107278c 100644 --- a/api/http/handler/endpoints/endpoint_list.go +++ b/api/http/handler/endpoints/endpoint_list.go @@ -89,6 +89,11 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht filteredEndpoints = filterEndpointsByGroupID(filteredEndpoints, portainer.EndpointGroupID(groupID)) } + edgeDeviceFilter, edgeDeviceFilterErr := request.RetrieveBooleanQueryParameter(r, "edgeDeviceFilter", false) + if edgeDeviceFilterErr == nil { + filteredEndpoints = filterEndpointsByEdgeDevice(filteredEndpoints, edgeDeviceFilter) + } + if search != "" { tags, err := handler.DataStore.Tag().Tags() if err != nil { @@ -231,6 +236,17 @@ func filterEndpointsByTypes(endpoints []portainer.Endpoint, endpointTypes []int) 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 { tags := make([]string, 0) for _, tagID := range tagIDs { diff --git a/api/http/handler/endpoints/endpoint_snapshot.go b/api/http/handler/endpoints/endpoint_snapshot.go index cd00f1a8a..e4db58616 100644 --- a/api/http/handler/endpoints/endpoint_snapshot.go +++ b/api/http/handler/endpoints/endpoint_snapshot.go @@ -1,6 +1,7 @@ package endpoints import ( + "errors" "net/http" httperror "github.com/portainer/libhttp/error" @@ -37,7 +38,7 @@ func (handler *Handler) endpointSnapshot(w http.ResponseWriter, r *http.Request) } 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) diff --git a/api/http/handler/endpoints/endpoint_update.go b/api/http/handler/endpoints/endpoint_update.go index 4b6633bce..b8364b4a2 100644 --- a/api/http/handler/endpoints/endpoint_update.go +++ b/api/http/handler/endpoints/endpoint_update.go @@ -46,6 +46,8 @@ type endpointUpdatePayload struct { EdgeCheckinInterval *int `example:"5"` // Associated Kubernetes data Kubernetes *portainer.KubernetesData + // Whether the device has been trusted or not by the user + UserTrusted *bool } 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) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist environment changes inside the database", err} diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index 4b8ce2585..d84c6cc83 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -17,6 +17,7 @@ import ( "github.com/portainer/portainer/api/http/handler/endpoints" "github.com/portainer/portainer/api/http/handler/file" "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/kubernetes" "github.com/portainer/portainer/api/http/handler/ldap" @@ -64,6 +65,7 @@ type Handler struct { SettingsHandler *settings.Handler SSLHandler *ssl.Handler OpenAMTHandler *openamt.Handler + FDOHandler *fdo.Handler StackHandler *stacks.Handler StatusHandler *status.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"): http.StripPrefix("/api", h.SSLHandler).ServeHTTP(w, r) 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"): http.StripPrefix("/api", h.TeamHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/team_memberships"): diff --git a/api/http/handler/hostmanagement/fdo/configure.go b/api/http/handler/hostmanagement/fdo/configure.go new file mode 100644 index 000000000..e207675bd --- /dev/null +++ b/api/http/handler/hostmanagement/fdo/configure.go @@ -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) +} diff --git a/api/http/handler/hostmanagement/fdo/fdo.go b/api/http/handler/hostmanagement/fdo/fdo.go new file mode 100644 index 000000000..b5b4c3fb6 --- /dev/null +++ b/api/http/handler/hostmanagement/fdo/fdo.go @@ -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} +` diff --git a/api/http/handler/hostmanagement/fdo/handler.go b/api/http/handler/hostmanagement/fdo/handler.go new file mode 100644 index 000000000..3c0afb378 --- /dev/null +++ b/api/http/handler/hostmanagement/fdo/handler.go @@ -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 +} diff --git a/api/http/handler/hostmanagement/fdo/list.go b/api/http/handler/hostmanagement/fdo/list.go new file mode 100644 index 000000000..69b36692a --- /dev/null +++ b/api/http/handler/hostmanagement/fdo/list.go @@ -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) +} diff --git a/api/http/handler/hostmanagement/fdo/profile_create.go b/api/http/handler/hostmanagement/fdo/profile_create.go new file mode 100644 index 000000000..8752c6d05 --- /dev/null +++ b/api/http/handler/hostmanagement/fdo/profile_create.go @@ -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) +} diff --git a/api/http/handler/hostmanagement/fdo/profile_delete.go b/api/http/handler/hostmanagement/fdo/profile_delete.go new file mode 100644 index 000000000..b3210602d --- /dev/null +++ b/api/http/handler/hostmanagement/fdo/profile_delete.go @@ -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) +} diff --git a/api/http/handler/hostmanagement/fdo/profile_duplicate.go b/api/http/handler/hostmanagement/fdo/profile_duplicate.go new file mode 100644 index 000000000..085468e92 --- /dev/null +++ b/api/http/handler/hostmanagement/fdo/profile_duplicate.go @@ -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) +} diff --git a/api/http/handler/hostmanagement/fdo/profile_inspect.go b/api/http/handler/hostmanagement/fdo/profile_inspect.go new file mode 100644 index 000000000..c7882213f --- /dev/null +++ b/api/http/handler/hostmanagement/fdo/profile_inspect.go @@ -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), + }) +} diff --git a/api/http/handler/hostmanagement/fdo/profile_list.go b/api/http/handler/hostmanagement/fdo/profile_list.go new file mode 100644 index 000000000..e3b0c23de --- /dev/null +++ b/api/http/handler/hostmanagement/fdo/profile_list.go @@ -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) +} diff --git a/api/http/handler/hostmanagement/fdo/profile_update.go b/api/http/handler/hostmanagement/fdo/profile_update.go new file mode 100644 index 000000000..cf60f43bf --- /dev/null +++ b/api/http/handler/hostmanagement/fdo/profile_update.go @@ -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) +} diff --git a/api/http/handler/hostmanagement/fdo/register.go b/api/http/handler/hostmanagement/fdo/register.go new file mode 100644 index 000000000..ebfce96af --- /dev/null +++ b/api/http/handler/hostmanagement/fdo/register.go @@ -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}) +} diff --git a/api/http/handler/hostmanagement/fdo/utils.go b/api/http/handler/hostmanagement/fdo/utils.go new file mode 100644 index 000000000..410ca009f --- /dev/null +++ b/api/http/handler/hostmanagement/fdo/utils.go @@ -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 +} diff --git a/api/http/handler/hostmanagement/openamt/amtactivation.go b/api/http/handler/hostmanagement/openamt/amtactivation.go new file mode 100644 index 000000000..c4d86f779 --- /dev/null +++ b/api/http/handler/hostmanagement/openamt/amtactivation.go @@ -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) +} diff --git a/api/http/handler/hostmanagement/openamt/openamt.go b/api/http/handler/hostmanagement/openamt/amtconfiguration.go similarity index 62% rename from api/http/handler/hostmanagement/openamt/openamt.go rename to api/http/handler/hostmanagement/openamt/amtconfiguration.go index 69eb0918d..677343352 100644 --- a/api/http/handler/hostmanagement/openamt/openamt.go +++ b/api/http/handler/hostmanagement/openamt/amtconfiguration.go @@ -16,23 +16,19 @@ import ( portainer "github.com/portainer/portainer/api" ) -type openAMTConfigureDefaultPayload struct { - EnableOpenAMT bool - MPSServer string - MPSUser string - MPSPassword string - CertFileText string - CertPassword string - DomainName string - UseWirelessConfig bool - WifiAuthenticationMethod string - WifiEncryptionMethod string - WifiSSID string - WifiPskPass string +type openAMTConfigurePayload struct { + Enabled bool + MPSServer string + MPSUser string + MPSPassword string + DomainName string + CertFileName string + CertFileContent string + CertFilePassword string } -func (payload *openAMTConfigureDefaultPayload) Validate(r *http.Request) error { - if payload.EnableOpenAMT { +func (payload *openAMTConfigurePayload) Validate(r *http.Request) error { + if payload.Enabled { if payload.MPSServer == "" { return errors.New("MPS Server must be provided") } @@ -45,32 +41,21 @@ func (payload *openAMTConfigureDefaultPayload) Validate(r *http.Request) error { if payload.DomainName == "" { return errors.New("domain name must be provided") } - if payload.CertFileText == "" { + if payload.CertFileContent == "" { return errors.New("certificate file must be provided") } - if payload.CertPassword == "" { - return errors.New("certificate password must be provided") + if payload.CertFileName == "" { + return errors.New("certificate file name must be provided") } - if payload.UseWirelessConfig { - if payload.WifiAuthenticationMethod == "" { - 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") - } + if payload.CertFilePassword == "" { + return errors.New("certificate password must be provided") } } return nil } -// @id OpenAMTConfigureDefault +// @id OpenAMTConfigure // @summary Enable Portainer's OpenAMT capabilities // @description Enable Portainer's OpenAMT capabilities // @description **Access policy**: administrator @@ -78,22 +63,22 @@ func (payload *openAMTConfigureDefaultPayload) Validate(r *http.Request) error { // @security jwt // @accept json // @produce json -// @param body body openAMTConfigureDefaultPayload true "OpenAMT Settings" +// @param body body openAMTConfigurePayload true "OpenAMT Settings" // @success 204 "Success" // @failure 400 "Invalid request" // @failure 403 "Permission denied to access settings" // @failure 500 "Server error" // @router /open_amt [post] -func (handler *Handler) openAMTConfigureDefault(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - var payload openAMTConfigureDefaultPayload +func (handler *Handler) openAMTConfigure(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload openAMTConfigurePayload 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} } - if payload.EnableOpenAMT { - certificateErr := validateCertificate(payload.CertFileText, payload.CertPassword) + if payload.Enabled { + certificateErr := validateCertificate(payload.CertFileContent, payload.CertFilePassword) if certificateErr != nil { 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") } -func (handler *Handler) enableOpenAMT(configurationPayload openAMTConfigureDefaultPayload) error { +func (handler *Handler) enableOpenAMT(configurationPayload openAMTConfigurePayload) error { configuration := portainer.OpenAMTConfiguration{ - Enabled: true, - MPSServer: configurationPayload.MPSServer, - Credentials: portainer.MPSCredentials{ - MPSUser: configurationPayload.MPSUser, - MPSPassword: configurationPayload.MPSPassword, - }, - DomainConfiguration: portainer.DomainConfiguration{ - CertFileText: configurationPayload.CertFileText, - CertPassword: configurationPayload.CertPassword, - DomainName: configurationPayload.DomainName, - }, + Enabled: true, + MPSServer: configurationPayload.MPSServer, + MPSUser: configurationPayload.MPSUser, + MPSPassword: configurationPayload.MPSPassword, + CertFileContent: configurationPayload.CertFileContent, + CertFileName: configurationPayload.CertFileName, + CertFilePassword: configurationPayload.CertFilePassword, + DomainName: configurationPayload.DomainName, } - if configurationPayload.UseWirelessConfig { - configuration.WirelessConfiguration = &portainer.WirelessConfiguration{ - AuthenticationMethod: configurationPayload.WifiAuthenticationMethod, - EncryptionMethod: configurationPayload.WifiEncryptionMethod, - SSID: configurationPayload.WifiSSID, - PskPass: configurationPayload.WifiPskPass, - } - } - - err := handler.OpenAMTService.ConfigureDefault(configuration) + err := handler.OpenAMTService.Configure(configuration) if err != nil { logrus.WithError(err).Error("error configuring OpenAMT server") return err @@ -189,7 +162,7 @@ func (handler *Handler) saveConfiguration(configuration portainer.OpenAMTConfigu return err } - configuration.Credentials.MPSToken = "" + configuration.MPSToken = "" settings.OpenAMTConfiguration = configuration err = handler.DataStore.Settings().UpdateSettings(settings) diff --git a/api/http/handler/hostmanagement/openamt/amtdevices.go b/api/http/handler/hostmanagement/openamt/amtdevices.go new file mode 100644 index 000000000..5459e2ac1 --- /dev/null +++ b/api/http/handler/hostmanagement/openamt/amtdevices.go @@ -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) +} diff --git a/api/http/handler/hostmanagement/openamt/amtrpc.go b/api/http/handler/hostmanagement/openamt/amtrpc.go new file mode 100644 index 000000000..8f9595f5f --- /dev/null +++ b/api/http/handler/hostmanagement/openamt/amtrpc.go @@ -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 +} diff --git a/api/http/handler/hostmanagement/openamt/handler.go b/api/http/handler/hostmanagement/openamt/handler.go index 7f750bd2d..708c0f13d 100644 --- a/api/http/handler/hostmanagement/openamt/handler.go +++ b/api/http/handler/hostmanagement/openamt/handler.go @@ -8,27 +8,30 @@ import ( httperror "github.com/portainer/libhttp/error" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/docker" "github.com/portainer/portainer/api/http/security" ) // Handler is the HTTP handler used to handle OpenAMT operations. type Handler struct { *mux.Router - OpenAMTService portainer.OpenAMTService - DataStore dataservices.DataStore + OpenAMTService portainer.OpenAMTService + DataStore dataservices.DataStore + DockerClientFactory *docker.ClientFactory } // NewHandler returns a new Handler -func NewHandler(bouncer *security.RequestBouncer, dataStore dataservices.DataStore) (*Handler, error) { - if !dataStore.Settings().IsFeatureFlagEnabled(portainer.FeatOpenAMT) { - return nil, nil - } - +func NewHandler(bouncer *security.RequestBouncer) *Handler { h := &Handler{ 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 } diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go index 2171c47ef..75f1870ce 100644 --- a/api/http/handler/settings/settings_update.go +++ b/api/http/handler/settings/settings_update.go @@ -42,6 +42,10 @@ type settingsUpdatePayload struct { HelmRepositoryURL *string `example:"https://charts.bitnami.com/bitnami"` // Kubectl Shell Image 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 { @@ -162,6 +166,14 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) * 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 { err := handler.updateSnapshotInterval(settings, *payload.SnapshotInterval) if err != nil { diff --git a/api/http/handler/stacks/handler.go b/api/http/handler/stacks/handler.go index 81e642001..d0043d49b 100644 --- a/api/http/handler/stacks/handler.go +++ b/api/http/handler/stacks/handler.go @@ -154,7 +154,7 @@ func (handler *Handler) checkUniqueStackNameInDocker(endpoint *portainer.Endpoin return false, err } - dockerClient, err := handler.DockerClientFactory.CreateClient(endpoint, "") + dockerClient, err := handler.DockerClientFactory.CreateClient(endpoint, "", nil) if err != nil { return false, err } diff --git a/api/http/handler/webhooks/webhook_execute.go b/api/http/handler/webhooks/webhook_execute.go index e312c3b64..cff0b3305 100644 --- a/api/http/handler/webhooks/webhook_execute.go +++ b/api/http/handler/webhooks/webhook_execute.go @@ -68,7 +68,7 @@ func (handler *Handler) executeServiceWebhook( registryID portainer.RegistryID, imageTag string, ) *httperror.HandlerError { - dockerClient, err := handler.DockerClientFactory.CreateClient(endpoint, "") + dockerClient, err := handler.DockerClientFactory.CreateClient(endpoint, "", nil) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Error creating docker client", err} } diff --git a/api/http/proxy/factory/docker/access_control.go b/api/http/proxy/factory/docker/access_control.go index 2ee8ec892..1d097ecc1 100644 --- a/api/http/proxy/factory/docker/access_control.go +++ b/api/http/proxy/factory/docker/access_control.go @@ -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) { - client, err := transport.dockerClientFactory.CreateClient(transport.endpoint, nodeName) + client, err := transport.dockerClientFactory.CreateClient(transport.endpoint, nodeName, nil) if err != nil { return nil, err } diff --git a/api/http/proxy/factory/docker/volumes.go b/api/http/proxy/factory/docker/volumes.go index fcc272c22..c4b44c985 100644 --- a/api/http/proxy/factory/docker/volumes.go +++ b/api/http/proxy/factory/docker/volumes.go @@ -133,7 +133,7 @@ func (transport *Transport) decorateVolumeResourceCreationOperation(request *htt if volumeID != "" { 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 { 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 { return "", err } diff --git a/api/http/security/bouncer.go b/api/http/security/bouncer.go index 28b051cc5..c827f17be 100644 --- a/api/http/security/bouncer.go +++ b/api/http/security/bouncer.go @@ -2,6 +2,7 @@ package security import ( "errors" + "fmt" "net/http" "strings" "time" @@ -134,6 +135,19 @@ func (bouncer *RequestBouncer) AuthorizedEdgeEndpointOperation(r *http.Request, 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 } diff --git a/api/http/server.go b/api/http/server.go index 133a78e0a..0ed3667f6 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -30,6 +30,7 @@ import ( "github.com/portainer/portainer/api/http/handler/endpoints" "github.com/portainer/portainer/api/http/handler/file" "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" kubehandler "github.com/portainer/portainer/api/http/handler/kubernetes" "github.com/portainer/portainer/api/http/handler/ldap" @@ -209,14 +210,12 @@ func (server *Server) Start() error { var sslHandler = sslhandler.NewHandler(requestBouncer) sslHandler.SSLService = server.SSLService - openAMTHandler, err := openamt.NewHandler(requestBouncer, server.DataStore) - if err != nil { - return err - } - if openAMTHandler != nil { - openAMTHandler.OpenAMTService = server.OpenAMTService - openAMTHandler.DataStore = server.DataStore - } + openAMTHandler := openamt.NewHandler(requestBouncer) + openAMTHandler.OpenAMTService = server.OpenAMTService + openAMTHandler.DataStore = server.DataStore + openAMTHandler.DockerClientFactory = server.DockerClientFactory + + fdoHandler := fdo.NewHandler(requestBouncer, server.DataStore, server.FileService) var stackHandler = stacks.NewHandler(requestBouncer) stackHandler.DataStore = server.DataStore @@ -284,6 +283,7 @@ func (server *Server) Start() error { KubernetesHandler: kubernetesHandler, MOTDHandler: motdHandler, OpenAMTHandler: openAMTHandler, + FDOHandler: fdoHandler, RegistryHandler: registryHandler, ResourceControlHandler: resourceControlHandler, SettingsHandler: settingsHandler, diff --git a/api/internal/endpointutils/endpoint_test.go b/api/internal/endpointutils/endpoint_test.go index ad6b3c76f..208d79dfd 100644 --- a/api/internal/endpointutils/endpoint_test.go +++ b/api/internal/endpointutils/endpoint_test.go @@ -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) { tests := []struct { name string diff --git a/api/internal/endpointutils/endpointutils.go b/api/internal/endpointutils/endpointutils.go index 537f52315..128e61a86 100644 --- a/api/internal/endpointutils/endpointutils.go +++ b/api/internal/endpointutils/endpointutils.go @@ -30,6 +30,14 @@ func IsEdgeEndpoint(endpoint *portainer.Endpoint) bool { 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 func FilterByExcludeIDs(endpoints []portainer.Endpoint, excludeIds []portainer.EndpointID) []portainer.Endpoint { if len(excludeIds) == 0 { diff --git a/api/internal/testhelpers/datastore.go b/api/internal/testhelpers/datastore.go index 3c49e1da5..ff9f9e68a 100644 --- a/api/internal/testhelpers/datastore.go +++ b/api/internal/testhelpers/datastore.go @@ -16,6 +16,7 @@ type testDatastore struct { endpoint dataservices.EndpointService endpointGroup dataservices.EndpointGroupService endpointRelation dataservices.EndpointRelationService + fdoProfile dataservices.FDOProfileService helmUserRepository dataservices.HelmUserRepositoryService registry dataservices.RegistryService 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) Endpoint() dataservices.EndpointService { return d.endpoint } func (d *testDatastore) EndpointGroup() dataservices.EndpointGroupService { return d.endpointGroup } +func (d *testDatastore) FDOProfile() dataservices.FDOProfileService { + return d.fdoProfile +} func (d *testDatastore) EndpointRelation() dataservices.EndpointRelationService { return d.endpointRelation } diff --git a/api/portainer.go b/api/portainer.go index 66f1125b2..6d2c62449 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -41,30 +41,54 @@ type ( // OpenAMTConfiguration represents the credentials and configurations used to connect to an OpenAMT MPS server OpenAMTConfiguration struct { - Enabled bool `json:"Enabled"` - MPSServer string `json:"MPSServer"` - Credentials MPSCredentials `json:"Credentials"` - DomainConfiguration DomainConfiguration `json:"DomainConfiguration"` - WirelessConfiguration *WirelessConfiguration `json:"WirelessConfiguration"` + Enabled bool `json:"enabled"` + MPSServer string `json:"mpsServer"` + MPSUser string `json:"mpsUser"` + MPSPassword string `json:"mpsPassword"` + 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 { - MPSUser string `json:"MPSUser"` - MPSPassword string `json:"MPSPassword"` - MPSToken string `json:"MPSToken"` // retrieved from API + // OpenAMTDeviceInformation represents an AMT managed device information + OpenAMTDeviceInformation struct { + GUID string `json:"guid"` + HostName string `json:"hostname"` + ConnectionStatus bool `json:"connectionStatus"` + PowerState PowerState `json:"powerState"` + EnabledFeatures *OpenAMTDeviceEnabledFeatures `json:"features"` } - DomainConfiguration struct { - CertFileText string `json:"CertFileText"` - CertPassword string `json:"CertPassword"` - DomainName string `json:"DomainName"` + // OpenAMTDeviceEnabledFeatures represents an AMT managed device features information + OpenAMTDeviceEnabledFeatures struct { + Redirection bool `json:"redirection"` + KVM bool `json:"KVM"` + SOL bool `json:"SOL"` + IDER bool `json:"IDER"` + UserConsent string `json:"userConsent"` } - WirelessConfiguration struct { - AuthenticationMethod string `json:"AuthenticationMethod"` - EncryptionMethod string `json:"EncryptionMethod"` - SSID string `json:"SSID"` - PskPass string `json:"PskPass"` + // PowerState represents an AMT managed device power state + PowerState int + + FDOConfiguration struct { + 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 @@ -298,8 +322,14 @@ type ( ComposeSyntaxMaxVersion string `json:"ComposeSyntaxMaxVersion" example:"3.8"` // Environment(Endpoint) specific security settings 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 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 in DBVersion == 4 @@ -768,7 +798,8 @@ type ( AuthenticationMethod AuthenticationMethod `json:"AuthenticationMethod" example:"1"` LDAPSettings LDAPSettings `json:"LDAPSettings" 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:""` // The interval in which environment(endpoint) snapshots are created SnapshotInterval string `json:"SnapshotInterval" example:"5m"` @@ -788,6 +819,10 @@ type ( HelmRepositoryURL string `json:"HelmRepositoryURL" example:"https://charts.bitnami.com/bitnami"` // KubectlImage, defaults to 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 DisplayDonationHeader bool @@ -1213,6 +1248,7 @@ type ( GetDefaultSSLCertsPath() (string, string) StoreSSLCertPair(cert, key []byte) (string, string, error) CopySSLCertPair(certPath, keyPath string) (string, string, error) + StoreFDOProfileFileFromBytes(fdoProfileIdentifier string, data []byte) (string, error) } // GitService represents a service for managing Git @@ -1223,7 +1259,10 @@ type ( // OpenAMTService represents a service for managing OpenAMT 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) @@ -1353,17 +1392,8 @@ const ( WebSocketKeepAlive = 1 * time.Hour ) -// Supported feature flags -const ( - FeatOpenAMT Feature = "open-amt" - FeatFDO Feature = "fdo" -) - // List of supported features -var SupportedFeatureFlags = []Feature{ - FeatOpenAMT, - FeatFDO, -} +var SupportedFeatureFlags = []Feature{} const ( _ AuthenticationMethod = iota diff --git a/app/assets/css/app.css b/app/assets/css/app.css index 7a8122f14..158289a19 100644 --- a/app/assets/css/app.css +++ b/app/assets/css/app.css @@ -680,6 +680,7 @@ a[ng-click] { .image-zoom-modal .modal-dialog { width: 80%; } + /*!bootbox override*/ /*angular-multi-select override*/ @@ -893,6 +894,16 @@ json-tree .branch-preview { white-space: normal; } +.icon-disabled { + opacity: 0.5; + cursor: not-allowed; +} + .space-x-1 { margin-left: 0.25rem; } + +/* used for bootbox prompt with inputType radio */ +.form-check.radio { + margin-left: 15px; +} diff --git a/app/assets/css/theme.css b/app/assets/css/theme.css index de8d16ee1..25897fd42 100644 --- a/app/assets/css/theme.css +++ b/app/assets/css/theme.css @@ -264,6 +264,7 @@ html { --bg-multiselect-helpercontainer: var(--white-color); --text-input-textarea: var(--white-color); --bg-service-datatable-thead: var(--grey-23); + --bg-inner-datatable-thead: var(--grey-23); --bg-service-datatable-tbody: var(--grey-24); } @@ -435,6 +436,7 @@ html { --bg-multiselect-helpercontainer: var(--grey-1); --text-input-textarea: var(--grey-1); --bg-service-datatable-thead: var(--grey-1); + --bg-inner-datatable-thead: var(--grey-1); --bg-service-datatable-tbody: var(--grey-1); } @@ -472,6 +474,7 @@ html { --bg-input-sm-color: var(--black-color); --bg-item-highlighted-color: 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-pagination-color: var(--grey-3); --bg-pagination-span-color: var(--grey-3); diff --git a/app/docker/containers/components/ContainersDatatable/ContainersDatatable.tsx b/app/docker/containers/components/ContainersDatatable/ContainersDatatable.tsx index 691e986c4..742c65860 100644 --- a/app/docker/containers/components/ContainersDatatable/ContainersDatatable.tsx +++ b/app/docker/containers/components/ContainersDatatable/ContainersDatatable.tsx @@ -140,7 +140,7 @@ export function ContainersDatatable({ - columns={columnsToHide} onChange={handleChangeColumnsVisibility} value={settings.hiddenColumns} diff --git a/app/edge/__module.js b/app/edge/__module.js index 72af140a5..94affb39f 100644 --- a/app/edge/__module.js +++ b/app/edge/__module.js @@ -1,8 +1,9 @@ import angular from 'angular'; 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 = { name: '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(groups); @@ -119,4 +130,6 @@ angular.module('portainer.edge', [edgeStackModule]).config(function config($stat $stateRegistryProvider.register(edgeJobs); $stateRegistryProvider.register(edgeJob); $stateRegistryProvider.register(edgeJobCreation); + + $stateRegistryProvider.register(edgeDevices); }); diff --git a/app/edge/devices/components/AMTDevicesDatatable/AMTDevicesDatatable.tsx b/app/edge/devices/components/AMTDevicesDatatable/AMTDevicesDatatable.tsx new file mode 100644 index 000000000..f04856407 --- /dev/null +++ b/app/edge/devices/components/AMTDevicesDatatable/AMTDevicesDatatable.tsx @@ -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( + { + columns, + data: devices, + }, + usePagination + ); + + const tableProps = getTableProps(); + const tbodyProps = getTableBodyProps(); + + return ( + + + + + {headerGroups.map((headerGroup) => { + const { key, className, role, style } = + headerGroup.getHeaderGroupProps(); + return ( + + key={key} + className={className} + role={role} + style={style} + headers={headerGroup.headers} + /> + ); + })} + + + {!isLoading && devices && devices.length > 0 ? ( + page.map((row) => { + prepareRow(row); + const { key, className, role, style } = row.getRowProps(); + + return ( + + + cells={row.cells} + key={key} + className={className} + role={role} + style={style} + /> + + ); + }) + ) : ( + + + + )} + +
+ {userMessage(isLoading, error)} +
+
+
+ ); +} + +function userMessage(isLoading: boolean, error?: PortainerError) { + if (isLoading) { + return 'Loading...'; + } + + if (error) { + return error.message; + } + + return 'No devices found'; +} diff --git a/app/edge/devices/components/AMTDevicesDatatable/columns/RowContext.tsx b/app/edge/devices/components/AMTDevicesDatatable/columns/RowContext.tsx new file mode 100644 index 000000000..c50daa89a --- /dev/null +++ b/app/edge/devices/components/AMTDevicesDatatable/columns/RowContext.tsx @@ -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(null); + +export interface RowProviderProps { + environmentId: EnvironmentId; +} + +export function RowProvider({ + environmentId, + children, +}: PropsWithChildren) { + const [isLoading, toggleIsLoading] = useReducer((state) => !state, false); + + const state = useMemo( + () => ({ isLoading, toggleIsLoading, environmentId }), + [isLoading, toggleIsLoading, environmentId] + ); + + return {children}; +} + +export function useRowContext() { + const context = useContext(RowContext); + if (!context) { + throw new Error('should be nested under RowProvider'); + } + + return context; +} diff --git a/app/edge/devices/components/AMTDevicesDatatable/columns/actions.tsx b/app/edge/devices/components/AMTDevicesDatatable/columns/actions.tsx new file mode 100644 index 000000000..5801a9801 --- /dev/null +++ b/app/edge/devices/components/AMTDevicesDatatable/columns/actions.tsx @@ -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 = { + 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) { + const queryClient = useQueryClient(); + + const { isLoading, toggleIsLoading, environmentId } = useRowContext(); + + const kvmLinkProps = useSref('portainer.endpoints.endpoint.kvm', { + id: environmentId, + deviceId: device.guid, + deviceName: device.hostname, + }); + + return ( + + AMT Functions + handleDeviceActionClick(DeviceAction.PowerOn)} + > + Power ON + + handleDeviceActionClick(DeviceAction.PowerOff)} + > + Power OFF + + handleDeviceActionClick(DeviceAction.Restart)} + > + Restart + + + KVM + + + ); + + 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(); + } + } +} diff --git a/app/edge/devices/components/AMTDevicesDatatable/columns/hostname.tsx b/app/edge/devices/components/AMTDevicesDatatable/columns/hostname.tsx new file mode 100644 index 000000000..020cbb1ac --- /dev/null +++ b/app/edge/devices/components/AMTDevicesDatatable/columns/hostname.tsx @@ -0,0 +1,13 @@ +import { Column } from 'react-table'; + +import { Device } from '@/portainer/hostmanagement/open-amt/model'; + +export const hostname: Column = { + Header: 'Hostname', + accessor: (row) => row.hostname || '-', + id: 'hostname', + disableFilters: true, + canHide: true, + sortType: 'string', + Filter: () => null, +}; diff --git a/app/edge/devices/components/AMTDevicesDatatable/columns/index.tsx b/app/edge/devices/components/AMTDevicesDatatable/columns/index.tsx new file mode 100644 index 000000000..b4caedf4b --- /dev/null +++ b/app/edge/devices/components/AMTDevicesDatatable/columns/index.tsx @@ -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], []); +} diff --git a/app/edge/devices/components/AMTDevicesDatatable/columns/power-state.tsx b/app/edge/devices/components/AMTDevicesDatatable/columns/power-state.tsx new file mode 100644 index 000000000..cc5301e8a --- /dev/null +++ b/app/edge/devices/components/AMTDevicesDatatable/columns/power-state.tsx @@ -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 = { + 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) { + const { isLoading } = useRowContext(); + return ( + <> + + {parsePowerState(device.powerState)} + + {isLoading && } + + ); +} + +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 '-'; + } +} diff --git a/app/edge/devices/components/AMTDevicesDatatable/columns/status.tsx b/app/edge/devices/components/AMTDevicesDatatable/columns/status.tsx new file mode 100644 index 000000000..4ba4c877f --- /dev/null +++ b/app/edge/devices/components/AMTDevicesDatatable/columns/status.tsx @@ -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 = { + Header: 'MPS Status', + id: 'status', + disableFilters: true, + canHide: true, + sortType: 'string', + Cell: StatusCell, + Filter: () => null, +}; + +export function StatusCell({ row: { original: device } }: CellProps) { + return ( + + {device.connectionStatus ? 'Connected' : 'Disconnected'} + + ); +} diff --git a/app/edge/devices/components/AMTDevicesDatatable/useAMTDevices.tsx b/app/edge/devices/components/AMTDevicesDatatable/useAMTDevices.tsx new file mode 100644 index 000000000..a38ad5816 --- /dev/null +++ b/app/edge/devices/components/AMTDevicesDatatable/useAMTDevices.tsx @@ -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, + }; +} diff --git a/app/edge/devices/components/EdgeDevicesDatatable/EdgeDevicesDatatable.module.css b/app/edge/devices/components/EdgeDevicesDatatable/EdgeDevicesDatatable.module.css new file mode 100644 index 000000000..45b67533c --- /dev/null +++ b/app/edge/devices/components/EdgeDevicesDatatable/EdgeDevicesDatatable.module.css @@ -0,0 +1,3 @@ +.kvm-tip { + padding: 8px; +} diff --git a/app/edge/devices/components/EdgeDevicesDatatable/EdgeDevicesDatatable.tsx b/app/edge/devices/components/EdgeDevicesDatatable/EdgeDevicesDatatable.tsx new file mode 100644 index 000000000..4fbf196ac --- /dev/null +++ b/app/edge/devices/components/EdgeDevicesDatatable/EdgeDevicesDatatable.tsx @@ -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; + setLoadingMessage(message: string): void; +} + +export function EdgeDevicesDatatable({ + isFdoEnabled, + isOpenAmtEnabled, + disableTrustOnFirstConnect, + mpsServer, + dataset, + onRefresh, + setLoadingMessage, +}: EdgeDevicesTableProps) { + const { settings, setTableSettings } = + useTableSettings(); + 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( + { + 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 ( + + + + + columns={columnsToHide} + onChange={handleChangeColumnsVisibility} + value={settings.hiddenColumns} + /> + + + + + + + + + row.original)} + isFDOEnabled={isFdoEnabled} + isOpenAMTEnabled={isOpenAmtEnabled} + setLoadingMessage={setLoadingMessage} + /> + + + {someDeviceHasAMTActivated && ( +
+ + For the KVM function to work you need to have the MPS server added + to your trusted site list, browse to this{' '} + + site + {' '} + and add to your trusted site list + +
+ )} + + + + + + {headerGroups.map((headerGroup) => { + const { key, className, role, style } = + headerGroup.getHeaderGroupProps(); + + return ( + + key={key} + className={className} + role={role} + style={style} + headers={headerGroup.headers} + onSortChange={handleSortChange} + /> + ); + })} + + + {page.map((row) => { + prepareRow(row); + const { key, className, role, style } = row.getRowProps(); + + return ( + + + cells={row.cells} + key={key} + className={className} + role={role} + style={style} + /> + {row.isExpanded && ( + + + + )} + + ); + })} + +
+ + +
+ + + + gotoPage(p - 1)} + totalCount={dataset.length} + onPageLimitChange={handlePageSizeChange} + /> + +
+ ); + + 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 }, + })); + } +} diff --git a/app/edge/devices/components/EdgeDevicesDatatable/EdgeDevicesDatatableActions.tsx b/app/edge/devices/components/EdgeDevicesDatatable/EdgeDevicesDatatableActions.tsx new file mode 100644 index 000000000..c62081d83 --- /dev/null +++ b/app/edge/devices/components/EdgeDevicesDatatable/EdgeDevicesDatatableActions.tsx @@ -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 ( +
+