mirror of
https://github.com/portainer/portainer.git
synced 2025-07-19 13:29:41 +02:00
feat(intel): Enable OpenAMT and FDO capabilities (#6212)
* feat(openamt): add AMT Devices information in Environments view [INT-8] (#6169) * feat(openamt): add AMT Devices Ouf of Band Managamenet actions [INT-9] (#6171) * feat(openamt): add AMT Devices KVM Connection [INT-10] (#6179) * feat(openamt): Enhance the Environments MX to activate OpenAMT on compatible environments [INT-7] (#6196) * feat(openamt): Enable KVM by default [INT-25] (#6228) * feat(fdo): implement the FDO configuration settings INT-19 (#6238) feat(fdo): implement the FDO configuration settings INT-19 * feat(fdo): implement Owner client INT-17 (#6231) feat(fdo): implement Owner client INT-17 * feat(openamt): hide wireless config in OpenAMT form (#6250) * feat(openamt): Increase OpenAMT timeouts [INT-30] (#6253) * feat(openamt): Disable the ability to use KVM and OOB actions on a MPS disconnected device [INT-36] (#6254) * feat(fdo): add import device UI [INT-20] (#6240) feat(fdo): add import device UI INT-20 * refactor(fdo): fix develop merge issues * feat(openamt): Do not fetch OpenAMT details for an unassociated Edge endpoint (#6273) * fix(intel): Fix switches params (#6282) * feat(openamt): preload existing AMT settings (#6283) * feat(openamt): Better UI/UX for AMT activation loading [INT-39] (#6290) * feat(openamt): Remove wireless config related code [INT-41] (#6291) * yarn install * feat(openamt): change kvm redirection for pop up, always enable features [INT-37] (#6292) * feat(openamt): change kvm redirection for pop up, always enable features [INT-37] (#6293) * feat(openmt): use .ts services with axios for OpenAMT (#6312) * Minor code cleanup. * fix(fdo): move the FDO client code to the hostmanagement folder INT-44 (#6345) * refactor(intel): Add Edge Compute Settings view (#6351) * feat(fdo): add FDO profiles INT-22 (#6363) feat(fdo): add FDO profiles INT-22 * fix(fdo): fix incorrect profile URL INT-45 (#6377) * fixed husky version * fix go.mod with go mod tidy * feat(edge): migrate OpenAMT devices views to Edge Devices [EE-2322] (#6373) * feat(intel): OpenAMT UI/UX adjustments (#6394) * only allow edge agent as edge device * show all edge agent environments on Edge Devices view * feat(fdo): add the ability to import multiple ownership vouchers at once EE-2324 (#6395) * fix(edge): settings edge compute alert (#6402) * remove pagination, add useMemo for devices result array (#6409) * feat(edge): minor Edge Devices (AMT) UI fixes (#6410) * chore(eslint): fix versions * chore(app): reformat codebase * change add edge agent modal behaviour, fix yarn.lock * fix use pagination * remove extractedTranslations folder * feat(edge): add FDO Profiles Datatable [EE-2406] (#6415) * feat(edge): add KVM workaround tooltip (#6441) * feat(edge): Add default FDO profile (#6450) * feat(edge): add settings to disable trust on first connect and enforce Edge ID INT-1 EE-2410 (#6429) Co-authored-by: andres-portainer <91705312+andres-portainer@users.noreply.github.com> Co-authored-by: Anthony Lapenna <anthony.lapenna@portainer.io> Co-authored-by: andres-portainer <andres-portainer@users.noreply.github.com> Co-authored-by: Chaim Lev-Ari <chiptus@gmail.com>
This commit is contained in:
parent
3ed92e5fee
commit
2c4c638f46
170 changed files with 6834 additions and 819 deletions
|
@ -554,7 +554,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||||
oauthService := initOAuthService()
|
oauthService := initOAuthService()
|
||||||
gitService := initGitService()
|
gitService := initGitService()
|
||||||
|
|
||||||
openAMTService := openamt.NewService(dataStore)
|
openAMTService := openamt.NewService()
|
||||||
|
|
||||||
cryptoService := initCryptoService()
|
cryptoService := initCryptoService()
|
||||||
digitalSignatureService := initDigitalSignatureService()
|
digitalSignatureService := initDigitalSignatureService()
|
||||||
|
|
|
@ -29,11 +29,6 @@ func Test_enableFeaturesFromFlags(t *testing.T) {
|
||||||
isSupported bool
|
isSupported bool
|
||||||
}{
|
}{
|
||||||
{"test", false},
|
{"test", false},
|
||||||
{"openamt", false},
|
|
||||||
{"open-amt", true},
|
|
||||||
{"oPeN-amT", true},
|
|
||||||
{"fdo", true},
|
|
||||||
{"FDO", true},
|
|
||||||
}
|
}
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(fmt.Sprintf("%s succeeds:%v", test.featureFlag, test.isSupported), func(t *testing.T) {
|
t.Run(fmt.Sprintf("%s succeeds:%v", test.featureFlag, test.isSupported), func(t *testing.T) {
|
||||||
|
|
|
@ -10,7 +10,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
jsonobject = `{"LogoURL":"","BlackListedLabels":[],"AuthenticationMethod":1,"LDAPSettings":{"AnonymousMode":true,"ReaderDN":"","URL":"","TLSConfig":{"TLS":false,"TLSSkipVerify":false},"StartTLS":false,"SearchSettings":[{"BaseDN":"","Filter":"","UserNameAttribute":""}],"GroupSearchSettings":[{"GroupBaseDN":"","GroupFilter":"","GroupAttribute":""}],"AutoCreateUsers":true},"OAuthSettings":{"ClientID":"","AccessTokenURI":"","AuthorizationURI":"","ResourceURI":"","RedirectURI":"","UserIdentifier":"","Scopes":"","OAuthAutoCreateUsers":false,"DefaultTeamID":0,"SSO":true,"LogoutURI":"","KubeSecretKey":"j0zLVtY/lAWBk62ByyF0uP80SOXaitsABP0TTJX8MhI="},"OpenAMTConfiguration":{"Enabled":false,"MPSServer":"","Credentials":{"MPSUser":"","MPSPassword":"","MPSToken":""},"DomainConfiguration":{"CertFileText":"","CertPassword":"","DomainName":""},"WirelessConfiguration":null},"FeatureFlagSettings":{},"SnapshotInterval":"5m","TemplatesURL":"https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json","EdgeAgentCheckinInterval":5,"EnableEdgeComputeFeatures":false,"UserSessionTimeout":"8h","KubeconfigExpiry":"0","EnableTelemetry":true,"HelmRepositoryURL":"https://charts.bitnami.com/bitnami","KubectlShellImage":"portainer/kubectl-shell","DisplayDonationHeader":false,"DisplayExternalContributors":false,"EnableHostManagementFeatures":false,"AllowVolumeBrowserForRegularUsers":false,"AllowBindMountsForRegularUsers":false,"AllowPrivilegedModeForRegularUsers":false,"AllowHostNamespaceForRegularUsers":false,"AllowStackManagementForRegularUsers":false,"AllowDeviceMappingForRegularUsers":false,"AllowContainerCapabilitiesForRegularUsers":false}`
|
jsonobject = `{"LogoURL":"","BlackListedLabels":[],"AuthenticationMethod":1,"LDAPSettings":{"AnonymousMode":true,"ReaderDN":"","URL":"","TLSConfig":{"TLS":false,"TLSSkipVerify":false},"StartTLS":false,"SearchSettings":[{"BaseDN":"","Filter":"","UserNameAttribute":""}],"GroupSearchSettings":[{"GroupBaseDN":"","GroupFilter":"","GroupAttribute":""}],"AutoCreateUsers":true},"OAuthSettings":{"ClientID":"","AccessTokenURI":"","AuthorizationURI":"","ResourceURI":"","RedirectURI":"","UserIdentifier":"","Scopes":"","OAuthAutoCreateUsers":false,"DefaultTeamID":0,"SSO":true,"LogoutURI":"","KubeSecretKey":"j0zLVtY/lAWBk62ByyF0uP80SOXaitsABP0TTJX8MhI="},"OpenAMTConfiguration":{"Enabled":false,"MPSServer":"","MPSUser":"","MPSPassword":"","MPSToken":"","CertFileContent":"","CertFileName":"","CertFilePassword":"","DomainName":""},"FeatureFlagSettings":{},"SnapshotInterval":"5m","TemplatesURL":"https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json","EdgeAgentCheckinInterval":5,"EnableEdgeComputeFeatures":false,"UserSessionTimeout":"8h","KubeconfigExpiry":"0","EnableTelemetry":true,"HelmRepositoryURL":"https://charts.bitnami.com/bitnami","KubectlShellImage":"portainer/kubectl-shell","DisplayDonationHeader":false,"DisplayExternalContributors":false,"EnableHostManagementFeatures":false,"AllowVolumeBrowserForRegularUsers":false,"AllowBindMountsForRegularUsers":false,"AllowPrivilegedModeForRegularUsers":false,"AllowHostNamespaceForRegularUsers":false,"AllowStackManagementForRegularUsers":false,"AllowDeviceMappingForRegularUsers":false,"AllowContainerCapabilitiesForRegularUsers":false}`
|
||||||
passphrase = "my secret key"
|
passphrase = "my secret key"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
92
api/dataservices/fdoprofile/fdoprofile.go
Normal file
92
api/dataservices/fdoprofile/fdoprofile.go
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
package fdoprofile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// BucketName represents the name of the bucket where this service stores data.
|
||||||
|
BucketName = "fdo_profiles"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service represents a service for managingFDO Profiles data.
|
||||||
|
type Service struct {
|
||||||
|
connection portainer.Connection
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) BucketName() string {
|
||||||
|
return BucketName
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService creates a new instance of a service.
|
||||||
|
func NewService(connection portainer.Connection) (*Service, error) {
|
||||||
|
err := connection.SetServiceName(BucketName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Service{
|
||||||
|
connection: connection,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FDOProfiles return an array containing all the FDO Profiles.
|
||||||
|
func (service *Service) FDOProfiles() ([]portainer.FDOProfile, error) {
|
||||||
|
var fdoProfiles = make([]portainer.FDOProfile, 0)
|
||||||
|
|
||||||
|
err := service.connection.GetAll(
|
||||||
|
BucketName,
|
||||||
|
&portainer.FDOProfile{},
|
||||||
|
func(obj interface{}) (interface{}, error) {
|
||||||
|
fdoProfile, ok := obj.(*portainer.FDOProfile)
|
||||||
|
if !ok {
|
||||||
|
logrus.WithField("obj", obj).Errorf("Failed to convert to FDOProfile object")
|
||||||
|
return nil, fmt.Errorf("failed to convert to FDOProfile object: %s", obj)
|
||||||
|
}
|
||||||
|
fdoProfiles = append(fdoProfiles, *fdoProfile)
|
||||||
|
return &portainer.FDOProfile{}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
return fdoProfiles, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FDOProfile returns an FDO Profile by ID.
|
||||||
|
func (service *Service) FDOProfile(ID portainer.FDOProfileID) (*portainer.FDOProfile, error) {
|
||||||
|
var FDOProfile portainer.FDOProfile
|
||||||
|
identifier := service.connection.ConvertToKey(int(ID))
|
||||||
|
|
||||||
|
err := service.connection.GetObject(BucketName, identifier, &FDOProfile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &FDOProfile, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create assign an ID to a new FDO Profile and saves it.
|
||||||
|
func (service *Service) Create(FDOProfile *portainer.FDOProfile) error {
|
||||||
|
return service.connection.CreateObjectWithId(
|
||||||
|
BucketName,
|
||||||
|
int(FDOProfile.ID),
|
||||||
|
FDOProfile,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update updates an FDO Profile.
|
||||||
|
func (service *Service) Update(ID portainer.FDOProfileID, FDOProfile *portainer.FDOProfile) error {
|
||||||
|
identifier := service.connection.ConvertToKey(int(ID))
|
||||||
|
return service.connection.UpdateObject(BucketName, identifier, FDOProfile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete deletes an FDO Profile.
|
||||||
|
func (service *Service) Delete(ID portainer.FDOProfileID) error {
|
||||||
|
identifier := service.connection.ConvertToKey(int(ID))
|
||||||
|
return service.connection.DeleteObject(BucketName, identifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNextIdentifier returns the next identifier for a FDO Profile.
|
||||||
|
func (service *Service) GetNextIdentifier() int {
|
||||||
|
return service.connection.GetNextIdentifier(BucketName)
|
||||||
|
}
|
|
@ -31,6 +31,7 @@ type (
|
||||||
Endpoint() EndpointService
|
Endpoint() EndpointService
|
||||||
EndpointGroup() EndpointGroupService
|
EndpointGroup() EndpointGroupService
|
||||||
EndpointRelation() EndpointRelationService
|
EndpointRelation() EndpointRelationService
|
||||||
|
FDOProfile() FDOProfileService
|
||||||
HelmUserRepository() HelmUserRepositoryService
|
HelmUserRepository() HelmUserRepositoryService
|
||||||
Registry() RegistryService
|
Registry() RegistryService
|
||||||
ResourceControl() ResourceControlService
|
ResourceControl() ResourceControlService
|
||||||
|
@ -122,6 +123,17 @@ type (
|
||||||
BucketName() string
|
BucketName() string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FDOProfileService represents a service to manage FDO Profiles
|
||||||
|
FDOProfileService interface {
|
||||||
|
FDOProfiles() ([]portainer.FDOProfile, error)
|
||||||
|
FDOProfile(ID portainer.FDOProfileID) (*portainer.FDOProfile, error)
|
||||||
|
Create(FDOProfile *portainer.FDOProfile) error
|
||||||
|
Update(ID portainer.FDOProfileID, FDOProfile *portainer.FDOProfile) error
|
||||||
|
Delete(ID portainer.FDOProfileID) error
|
||||||
|
GetNextIdentifier() int
|
||||||
|
BucketName() string
|
||||||
|
}
|
||||||
|
|
||||||
// HelmUserRepositoryService represents a service to manage HelmUserRepositories
|
// HelmUserRepositoryService represents a service to manage HelmUserRepositories
|
||||||
HelmUserRepositoryService interface {
|
HelmUserRepositoryService interface {
|
||||||
HelmUserRepositories() ([]portainer.HelmUserRepository, error)
|
HelmUserRepositories() ([]portainer.HelmUserRepository, error)
|
||||||
|
|
|
@ -30,6 +30,7 @@ func (store *Store) MigrateData() error {
|
||||||
EndpointService: store.EndpointService,
|
EndpointService: store.EndpointService,
|
||||||
EndpointRelationService: store.EndpointRelationService,
|
EndpointRelationService: store.EndpointRelationService,
|
||||||
ExtensionService: store.ExtensionService,
|
ExtensionService: store.ExtensionService,
|
||||||
|
FDOProfilesService: store.FDOProfilesService,
|
||||||
RegistryService: store.RegistryService,
|
RegistryService: store.RegistryService,
|
||||||
ResourceControlService: store.ResourceControlService,
|
ResourceControlService: store.ResourceControlService,
|
||||||
RoleService: store.RoleService,
|
RoleService: store.RoleService,
|
||||||
|
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"github.com/portainer/portainer/api/dataservices/endpointgroup"
|
"github.com/portainer/portainer/api/dataservices/endpointgroup"
|
||||||
"github.com/portainer/portainer/api/dataservices/endpointrelation"
|
"github.com/portainer/portainer/api/dataservices/endpointrelation"
|
||||||
"github.com/portainer/portainer/api/dataservices/extension"
|
"github.com/portainer/portainer/api/dataservices/extension"
|
||||||
|
"github.com/portainer/portainer/api/dataservices/fdoprofile"
|
||||||
"github.com/portainer/portainer/api/dataservices/registry"
|
"github.com/portainer/portainer/api/dataservices/registry"
|
||||||
"github.com/portainer/portainer/api/dataservices/resourcecontrol"
|
"github.com/portainer/portainer/api/dataservices/resourcecontrol"
|
||||||
"github.com/portainer/portainer/api/dataservices/role"
|
"github.com/portainer/portainer/api/dataservices/role"
|
||||||
|
@ -26,12 +27,12 @@ var migrateLog = plog.NewScopedLog("database, migrate")
|
||||||
type (
|
type (
|
||||||
// Migrator defines a service to migrate data after a Portainer version update.
|
// Migrator defines a service to migrate data after a Portainer version update.
|
||||||
Migrator struct {
|
Migrator struct {
|
||||||
currentDBVersion int
|
currentDBVersion int
|
||||||
|
|
||||||
endpointGroupService *endpointgroup.Service
|
endpointGroupService *endpointgroup.Service
|
||||||
endpointService *endpoint.Service
|
endpointService *endpoint.Service
|
||||||
endpointRelationService *endpointrelation.Service
|
endpointRelationService *endpointrelation.Service
|
||||||
extensionService *extension.Service
|
extensionService *extension.Service
|
||||||
|
fdoProfilesService *fdoprofile.Service
|
||||||
registryService *registry.Service
|
registryService *registry.Service
|
||||||
resourceControlService *resourcecontrol.Service
|
resourceControlService *resourcecontrol.Service
|
||||||
roleService *role.Service
|
roleService *role.Service
|
||||||
|
@ -54,6 +55,7 @@ type (
|
||||||
EndpointService *endpoint.Service
|
EndpointService *endpoint.Service
|
||||||
EndpointRelationService *endpointrelation.Service
|
EndpointRelationService *endpointrelation.Service
|
||||||
ExtensionService *extension.Service
|
ExtensionService *extension.Service
|
||||||
|
FDOProfilesService *fdoprofile.Service
|
||||||
RegistryService *registry.Service
|
RegistryService *registry.Service
|
||||||
ResourceControlService *resourcecontrol.Service
|
ResourceControlService *resourcecontrol.Service
|
||||||
RoleService *role.Service
|
RoleService *role.Service
|
||||||
|
@ -78,6 +80,7 @@ func NewMigrator(parameters *MigratorParameters) *Migrator {
|
||||||
endpointService: parameters.EndpointService,
|
endpointService: parameters.EndpointService,
|
||||||
endpointRelationService: parameters.EndpointRelationService,
|
endpointRelationService: parameters.EndpointRelationService,
|
||||||
extensionService: parameters.ExtensionService,
|
extensionService: parameters.ExtensionService,
|
||||||
|
fdoProfilesService: parameters.FDOProfilesService,
|
||||||
registryService: parameters.RegistryService,
|
registryService: parameters.RegistryService,
|
||||||
resourceControlService: parameters.ResourceControlService,
|
resourceControlService: parameters.ResourceControlService,
|
||||||
roleService: parameters.RoleService,
|
roleService: parameters.RoleService,
|
||||||
|
|
|
@ -17,6 +17,7 @@ import (
|
||||||
"github.com/portainer/portainer/api/dataservices/endpointgroup"
|
"github.com/portainer/portainer/api/dataservices/endpointgroup"
|
||||||
"github.com/portainer/portainer/api/dataservices/endpointrelation"
|
"github.com/portainer/portainer/api/dataservices/endpointrelation"
|
||||||
"github.com/portainer/portainer/api/dataservices/extension"
|
"github.com/portainer/portainer/api/dataservices/extension"
|
||||||
|
"github.com/portainer/portainer/api/dataservices/fdoprofile"
|
||||||
"github.com/portainer/portainer/api/dataservices/helmuserrepository"
|
"github.com/portainer/portainer/api/dataservices/helmuserrepository"
|
||||||
"github.com/portainer/portainer/api/dataservices/registry"
|
"github.com/portainer/portainer/api/dataservices/registry"
|
||||||
"github.com/portainer/portainer/api/dataservices/resourcecontrol"
|
"github.com/portainer/portainer/api/dataservices/resourcecontrol"
|
||||||
|
@ -50,6 +51,7 @@ type Store struct {
|
||||||
EndpointService *endpoint.Service
|
EndpointService *endpoint.Service
|
||||||
EndpointRelationService *endpointrelation.Service
|
EndpointRelationService *endpointrelation.Service
|
||||||
ExtensionService *extension.Service
|
ExtensionService *extension.Service
|
||||||
|
FDOProfilesService *fdoprofile.Service
|
||||||
HelmUserRepositoryService *helmuserrepository.Service
|
HelmUserRepositoryService *helmuserrepository.Service
|
||||||
RegistryService *registry.Service
|
RegistryService *registry.Service
|
||||||
ResourceControlService *resourcecontrol.Service
|
ResourceControlService *resourcecontrol.Service
|
||||||
|
@ -129,6 +131,12 @@ func (store *Store) initServices() error {
|
||||||
}
|
}
|
||||||
store.ExtensionService = extensionService
|
store.ExtensionService = extensionService
|
||||||
|
|
||||||
|
fdoProfilesService, err := fdoprofile.NewService(store.connection)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
store.FDOProfilesService = fdoProfilesService
|
||||||
|
|
||||||
helmUserRepositoryService, err := helmuserrepository.NewService(store.connection)
|
helmUserRepositoryService, err := helmuserrepository.NewService(store.connection)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -257,6 +265,11 @@ func (store *Store) EndpointRelation() dataservices.EndpointRelationService {
|
||||||
return store.EndpointRelationService
|
return store.EndpointRelationService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FDOProfile gives access to the FDOProfile data management layer
|
||||||
|
func (store *Store) FDOProfile() dataservices.FDOProfileService {
|
||||||
|
return store.FDOProfilesService
|
||||||
|
}
|
||||||
|
|
||||||
// HelmUserRepository access the helm user repository settings
|
// HelmUserRepository access the helm user repository settings
|
||||||
func (store *Store) HelmUserRepository() dataservices.HelmUserRepositoryService {
|
func (store *Store) HelmUserRepository() dataservices.HelmUserRepositoryService {
|
||||||
return store.HelmUserRepositoryService
|
return store.HelmUserRepositoryService
|
||||||
|
|
|
@ -15,7 +15,7 @@ import (
|
||||||
var errUnsupportedEnvironmentType = errors.New("Environment not supported")
|
var errUnsupportedEnvironmentType = errors.New("Environment not supported")
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultDockerRequestTimeout = 60
|
defaultDockerRequestTimeout = 60 * time.Second
|
||||||
dockerClientVersion = "1.37"
|
dockerClientVersion = "1.37"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -33,22 +33,23 @@ func NewClientFactory(signatureService portainer.DigitalSignatureService, revers
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// createClient is a generic function to create a Docker client based on
|
// CreateClient is a generic function to create a Docker client based on
|
||||||
// a specific environment(endpoint) configuration. The nodeName parameter can be used
|
// a specific environment(endpoint) configuration. The nodeName parameter can be used
|
||||||
// with an agent enabled environment(endpoint) to target a specific node in an agent cluster.
|
// with an agent enabled environment(endpoint) to target a specific node in an agent cluster.
|
||||||
func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint, nodeName string) (*client.Client, error) {
|
// The underlying http client timeout may be specified, a default value is used otherwise.
|
||||||
|
func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint, nodeName string, timeout *time.Duration) (*client.Client, error) {
|
||||||
if endpoint.Type == portainer.AzureEnvironment {
|
if endpoint.Type == portainer.AzureEnvironment {
|
||||||
return nil, errUnsupportedEnvironmentType
|
return nil, errUnsupportedEnvironmentType
|
||||||
} else if endpoint.Type == portainer.AgentOnDockerEnvironment {
|
} else if endpoint.Type == portainer.AgentOnDockerEnvironment {
|
||||||
return createAgentClient(endpoint, factory.signatureService, nodeName)
|
return createAgentClient(endpoint, factory.signatureService, nodeName, timeout)
|
||||||
} else if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment {
|
} else if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment {
|
||||||
return createEdgeClient(endpoint, factory.signatureService, factory.reverseTunnelService, nodeName)
|
return createEdgeClient(endpoint, factory.signatureService, factory.reverseTunnelService, nodeName, timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") {
|
if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") {
|
||||||
return createLocalClient(endpoint)
|
return createLocalClient(endpoint)
|
||||||
}
|
}
|
||||||
return createTCPClient(endpoint)
|
return createTCPClient(endpoint, timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
func createLocalClient(endpoint *portainer.Endpoint) (*client.Client, error) {
|
func createLocalClient(endpoint *portainer.Endpoint) (*client.Client, error) {
|
||||||
|
@ -58,8 +59,8 @@ func createLocalClient(endpoint *portainer.Endpoint) (*client.Client, error) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func createTCPClient(endpoint *portainer.Endpoint) (*client.Client, error) {
|
func createTCPClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*client.Client, error) {
|
||||||
httpCli, err := httpClient(endpoint)
|
httpCli, err := httpClient(endpoint, timeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -71,8 +72,8 @@ func createTCPClient(endpoint *portainer.Endpoint) (*client.Client, error) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func createEdgeClient(endpoint *portainer.Endpoint, signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, nodeName string) (*client.Client, error) {
|
func createEdgeClient(endpoint *portainer.Endpoint, signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, nodeName string, timeout *time.Duration) (*client.Client, error) {
|
||||||
httpCli, err := httpClient(endpoint)
|
httpCli, err := httpClient(endpoint, timeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -106,8 +107,8 @@ func createEdgeClient(endpoint *portainer.Endpoint, signatureService portainer.D
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer.DigitalSignatureService, nodeName string) (*client.Client, error) {
|
func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer.DigitalSignatureService, nodeName string, timeout *time.Duration) (*client.Client, error) {
|
||||||
httpCli, err := httpClient(endpoint)
|
httpCli, err := httpClient(endpoint, timeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -134,7 +135,7 @@ func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer.
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func httpClient(endpoint *portainer.Endpoint) (*http.Client, error) {
|
func httpClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*http.Client, error) {
|
||||||
transport := &http.Transport{}
|
transport := &http.Transport{}
|
||||||
|
|
||||||
if endpoint.TLSConfig.TLS {
|
if endpoint.TLSConfig.TLS {
|
||||||
|
@ -145,8 +146,13 @@ func httpClient(endpoint *portainer.Endpoint) (*http.Client, error) {
|
||||||
transport.TLSClientConfig = tlsConfig
|
transport.TLSClientConfig = tlsConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clientTimeout := defaultDockerRequestTimeout
|
||||||
|
if timeout != nil {
|
||||||
|
clientTimeout = *timeout
|
||||||
|
}
|
||||||
|
|
||||||
return &http.Client{
|
return &http.Client{
|
||||||
Transport: transport,
|
Transport: transport,
|
||||||
Timeout: defaultDockerRequestTimeout * time.Second,
|
Timeout: clientTimeout,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,7 +26,7 @@ func NewSnapshotter(clientFactory *ClientFactory) *Snapshotter {
|
||||||
|
|
||||||
// CreateSnapshot creates a snapshot of a specific Docker environment(endpoint)
|
// CreateSnapshot creates a snapshot of a specific Docker environment(endpoint)
|
||||||
func (snapshotter *Snapshotter) CreateSnapshot(endpoint *portainer.Endpoint) (*portainer.DockerSnapshot, error) {
|
func (snapshotter *Snapshotter) CreateSnapshot(endpoint *portainer.Endpoint) (*portainer.DockerSnapshot, error) {
|
||||||
cli, err := snapshotter.clientFactory.CreateClient(endpoint, "")
|
cli, err := snapshotter.clientFactory.CreateClient(endpoint, "", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,6 +35,8 @@ const (
|
||||||
ManifestFileDefaultName = "k8s-deployment.yml"
|
ManifestFileDefaultName = "k8s-deployment.yml"
|
||||||
// EdgeStackStorePath represents the subfolder where edge stack files are stored in the file store folder.
|
// EdgeStackStorePath represents the subfolder where edge stack files are stored in the file store folder.
|
||||||
EdgeStackStorePath = "edge_stacks"
|
EdgeStackStorePath = "edge_stacks"
|
||||||
|
// FDOProfileStorePath represents the subfolder where FDO profiles files are stored in the file store folder.
|
||||||
|
FDOProfileStorePath = "fdo_profiles"
|
||||||
// PrivateKeyFile represents the name on disk of the file containing the private key.
|
// PrivateKeyFile represents the name on disk of the file containing the private key.
|
||||||
PrivateKeyFile = "portainer.key"
|
PrivateKeyFile = "portainer.key"
|
||||||
// PublicKeyFile represents the name on disk of the file containing the public key.
|
// PublicKeyFile represents the name on disk of the file containing the public key.
|
||||||
|
@ -652,3 +654,20 @@ func MoveDirectory(originalPath, newPath string) error {
|
||||||
|
|
||||||
return os.Rename(originalPath, newPath)
|
return os.Rename(originalPath, newPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StoreFDOProfileFileFromBytes creates a subfolder in the FDOProfileStorePath and stores a new file from bytes.
|
||||||
|
// It returns the path to the folder where the file is stored.
|
||||||
|
func (service *Service) StoreFDOProfileFileFromBytes(fdoProfileIdentifier string, data []byte) (string, error) {
|
||||||
|
err := service.createDirectoryInStore(FDOProfileStorePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := JoinPaths(FDOProfileStorePath, fdoProfileIdentifier)
|
||||||
|
err = service.createFileInStore(filePath, bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return service.wrapFileStore(filePath), nil
|
||||||
|
}
|
||||||
|
|
|
@ -14,6 +14,7 @@ require (
|
||||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible
|
github.com/dgrijalva/jwt-go v3.2.0+incompatible
|
||||||
github.com/docker/cli v20.10.9+incompatible
|
github.com/docker/cli v20.10.9+incompatible
|
||||||
github.com/docker/docker v20.10.9+incompatible
|
github.com/docker/docker v20.10.9+incompatible
|
||||||
|
github.com/fxamacker/cbor/v2 v2.3.0
|
||||||
github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814
|
github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814
|
||||||
github.com/go-git/go-git/v5 v5.3.0
|
github.com/go-git/go-git/v5 v5.3.0
|
||||||
github.com/go-ldap/ldap/v3 v3.1.8
|
github.com/go-ldap/ldap/v3 v3.1.8
|
||||||
|
@ -33,6 +34,7 @@ require (
|
||||||
github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a
|
github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a
|
||||||
github.com/portainer/libhelm v0.0.0-20210929000907-825e93d62108
|
github.com/portainer/libhelm v0.0.0-20210929000907-825e93d62108
|
||||||
github.com/portainer/libhttp v0.0.0-20211021135806-13e6c55c5fbc
|
github.com/portainer/libhttp v0.0.0-20211021135806-13e6c55c5fbc
|
||||||
|
github.com/rkl-/digest v0.0.0-20180419075440-8316caa4a777
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
github.com/sirupsen/logrus v1.8.1
|
github.com/sirupsen/logrus v1.8.1
|
||||||
github.com/stretchr/testify v1.7.0
|
github.com/stretchr/testify v1.7.0
|
||||||
|
@ -94,6 +96,7 @@ require (
|
||||||
github.com/sergi/go-diff v1.1.0 // indirect
|
github.com/sergi/go-diff v1.1.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.5 // indirect
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce // indirect
|
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce // indirect
|
||||||
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
github.com/xanzy/ssh-agent v0.3.0 // indirect
|
github.com/xanzy/ssh-agent v0.3.0 // indirect
|
||||||
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
|
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
|
||||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
|
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
|
||||||
|
|
|
@ -300,6 +300,8 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo
|
||||||
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||||
github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa/go.mod h1:KnogPXtdwXqoenmZCw6S+25EAm2MkxbG0deNDu4cbSA=
|
github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa/go.mod h1:KnogPXtdwXqoenmZCw6S+25EAm2MkxbG0deNDu4cbSA=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.3.0 h1:aM45YGMctNakddNNAezPxDUpv38j44Abh+hifNuqXik=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.3.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo=
|
||||||
github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814 h1:gWvniJ4GbFfkf700kykAImbLiEMU0Q3QN9hQ26Js1pU=
|
github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814 h1:gWvniJ4GbFfkf700kykAImbLiEMU0Q3QN9hQ26Js1pU=
|
||||||
github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814/go.mod h1:secRm32Ro77eD23BmPVbgLbWN+JWDw7pJszenjxI4bI=
|
github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814/go.mod h1:secRm32Ro77eD23BmPVbgLbWN+JWDw7pJszenjxI4bI=
|
||||||
github.com/garyburd/redigo v0.0.0-20150301180006-535138d7bcd7/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY=
|
github.com/garyburd/redigo v0.0.0-20150301180006-535138d7bcd7/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY=
|
||||||
|
@ -649,6 +651,8 @@ github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4O
|
||||||
github.com/prometheus/procfs v0.2.0/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
|
github.com/prometheus/procfs v0.2.0/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
|
||||||
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
|
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
|
||||||
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
|
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
|
||||||
|
github.com/rkl-/digest v0.0.0-20180419075440-8316caa4a777 h1:rDj3WeO+TiWyxfcydUnKegWAZoR5kQsnW0wzhggdOrw=
|
||||||
|
github.com/rkl-/digest v0.0.0-20180419075440-8316caa4a777/go.mod h1:xRVvTK+cS/dJSvrOufGUQFWfgvE7yXExeng96n8377o=
|
||||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
|
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
|
||||||
|
@ -722,6 +726,8 @@ github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17
|
||||||
github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
|
github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
|
||||||
github.com/willf/bitset v1.1.11-0.20200630133818-d5bec3311243/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
|
github.com/willf/bitset v1.1.11-0.20200630133818-d5bec3311243/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
|
||||||
github.com/willf/bitset v1.1.11/go.mod h1:83CECat5yLh5zVOf4P1ErAgKA5UDvKtgyUABdr3+MjI=
|
github.com/willf/bitset v1.1.11/go.mod h1:83CECat5yLh5zVOf4P1ErAgKA5UDvKtgyUABdr3+MjI=
|
||||||
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
github.com/xanzy/ssh-agent v0.3.0 h1:wUMzuKtKilRgBAD1sUb8gOwwRr2FGoBVumcjoOACClI=
|
github.com/xanzy/ssh-agent v0.3.0 h1:wUMzuKtKilRgBAD1sUb8gOwwRr2FGoBVumcjoOACClI=
|
||||||
github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0=
|
github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0=
|
||||||
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=
|
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=
|
||||||
|
|
235
api/hostmanagement/fdo/owner_client.go
Normal file
235
api/hostmanagement/fdo/owner_client.go
Normal file
|
@ -0,0 +1,235 @@
|
||||||
|
package fdo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rkl-/digest"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FDOOwnerClient struct {
|
||||||
|
OwnerURL string
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServiceInfo struct {
|
||||||
|
Module string
|
||||||
|
Var string
|
||||||
|
Filename string
|
||||||
|
Bytes []byte
|
||||||
|
GUID string
|
||||||
|
Device string
|
||||||
|
Priority int
|
||||||
|
OS string
|
||||||
|
Version string
|
||||||
|
Arch string
|
||||||
|
CRID int
|
||||||
|
Hash string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c FDOOwnerClient) doDigestAuthReq(method, endpoint, contentType string, body io.Reader) (*http.Response, error) {
|
||||||
|
transport := digest.NewTransport(c.Username, c.Password)
|
||||||
|
|
||||||
|
client, err := transport.Client()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
client.Timeout = c.Timeout
|
||||||
|
|
||||||
|
e, err := url.Parse(endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := url.Parse(c.OwnerURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(method, u.ResolveReference(e).String(), body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if contentType != "" {
|
||||||
|
req.Header.Set("Content-Type", contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.Do(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c FDOOwnerClient) PostVoucher(ov []byte) (string, error) {
|
||||||
|
resp, err := c.doDigestAuthReq(
|
||||||
|
http.MethodPost,
|
||||||
|
"api/v1/owner/vouchers",
|
||||||
|
"application/cbor",
|
||||||
|
bytes.NewReader(ov),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", errors.New(http.StatusText(resp.StatusCode))
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(body), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c FDOOwnerClient) PutDeviceSVI(info ServiceInfo) error {
|
||||||
|
values := url.Values{}
|
||||||
|
values.Set("module", info.Module)
|
||||||
|
values.Set("var", info.Var)
|
||||||
|
values.Set("filename", info.Filename)
|
||||||
|
values.Set("guid", info.GUID)
|
||||||
|
values.Set("device", info.Device)
|
||||||
|
values.Set("priority", strconv.Itoa(info.Priority))
|
||||||
|
values.Set("os", info.OS)
|
||||||
|
values.Set("version", info.Version)
|
||||||
|
values.Set("arch", info.Arch)
|
||||||
|
values.Set("crid", strconv.Itoa(info.CRID))
|
||||||
|
values.Set("hash", info.Hash)
|
||||||
|
|
||||||
|
resp, err := c.doDigestAuthReq(
|
||||||
|
http.MethodPut,
|
||||||
|
"api/v1/device/svi?"+values.Encode(),
|
||||||
|
"application/octet-stream",
|
||||||
|
strings.NewReader(string(info.Bytes)),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return errors.New(http.StatusText(resp.StatusCode))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c FDOOwnerClient) PutDeviceSVIRaw(info url.Values, body []byte) error {
|
||||||
|
resp, err := c.doDigestAuthReq(
|
||||||
|
http.MethodPut,
|
||||||
|
"api/v1/device/svi?"+info.Encode(),
|
||||||
|
"application/octet-stream",
|
||||||
|
strings.NewReader(string(body)),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return errors.New(http.StatusText(resp.StatusCode))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c FDOOwnerClient) GetVouchers() ([]string, error) {
|
||||||
|
resp, err := c.doDigestAuthReq(
|
||||||
|
http.MethodGet,
|
||||||
|
"api/v1/owner/vouchers",
|
||||||
|
"",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, errors.New(http.StatusText(resp.StatusCode))
|
||||||
|
}
|
||||||
|
|
||||||
|
contents, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
guids := strings.FieldsFunc(
|
||||||
|
strings.TrimSpace(string(contents)),
|
||||||
|
func(c rune) bool {
|
||||||
|
return c == ','
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return guids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c FDOOwnerClient) DeleteVoucher(guid string) error {
|
||||||
|
resp, err := c.doDigestAuthReq(
|
||||||
|
http.MethodDelete,
|
||||||
|
"api/v1/owner/vouchers?id="+guid,
|
||||||
|
"",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return errors.New(http.StatusText(resp.StatusCode))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c FDOOwnerClient) GetDeviceSVI(guid string) (string, error) {
|
||||||
|
resp, err := c.doDigestAuthReq(
|
||||||
|
http.MethodGet,
|
||||||
|
"api/v1/device/svi?guid="+guid,
|
||||||
|
"",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", errors.New(http.StatusText(resp.StatusCode))
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(body), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c FDOOwnerClient) DeleteDeviceSVI(id string) error {
|
||||||
|
resp, err := c.doDigestAuthReq(
|
||||||
|
http.MethodDelete,
|
||||||
|
"api/v1/device/svi?id="+id,
|
||||||
|
"",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return errors.New(http.StatusText(resp.StatusCode))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -14,39 +14,39 @@ type authenticationResponse struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *Service) executeAuthenticationRequest(configuration portainer.OpenAMTConfiguration) (*authenticationResponse, error) {
|
func (service *Service) Authorization(configuration portainer.OpenAMTConfiguration) (string, error) {
|
||||||
loginURL := fmt.Sprintf("https://%s/mps/login/api/v1/authorize", configuration.MPSServer)
|
loginURL := fmt.Sprintf("https://%s/mps/login/api/v1/authorize", configuration.MPSServer)
|
||||||
|
|
||||||
payload := map[string]string{
|
payload := map[string]string{
|
||||||
"username": configuration.Credentials.MPSUser,
|
"username": configuration.MPSUser,
|
||||||
"password": configuration.Credentials.MPSPassword,
|
"password": configuration.MPSPassword,
|
||||||
}
|
}
|
||||||
jsonValue, _ := json.Marshal(payload)
|
jsonValue, _ := json.Marshal(payload)
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodPost, loginURL, bytes.NewBuffer(jsonValue))
|
req, err := http.NewRequest(http.MethodPost, loginURL, bytes.NewBuffer(jsonValue))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return "", err
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
response, err := service.httpsClient.Do(req)
|
response, err := service.httpsClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return "", err
|
||||||
}
|
}
|
||||||
responseBody, readErr := ioutil.ReadAll(response.Body)
|
responseBody, readErr := ioutil.ReadAll(response.Body)
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
return nil, readErr
|
return "", readErr
|
||||||
}
|
}
|
||||||
errorResponse := parseError(responseBody)
|
errorResponse := parseError(responseBody)
|
||||||
if errorResponse != nil {
|
if errorResponse != nil {
|
||||||
return nil, errorResponse
|
return "", errorResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
var token authenticationResponse
|
var token authenticationResponse
|
||||||
err = json.Unmarshal(responseBody, &token)
|
err = json.Unmarshal(responseBody, &token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &token, nil
|
return token.Token, nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,6 @@ import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
|
@ -47,7 +46,7 @@ func (service *Service) createOrUpdateCIRAConfig(configuration portainer.OpenAMT
|
||||||
func (service *Service) getCIRAConfig(configuration portainer.OpenAMTConfiguration, configName string) (*CIRAConfig, error) {
|
func (service *Service) getCIRAConfig(configuration portainer.OpenAMTConfiguration, configName string) (*CIRAConfig, error) {
|
||||||
url := fmt.Sprintf("https://%s/rps/api/v1/admin/ciraconfigs/%s", configuration.MPSServer, configName)
|
url := fmt.Sprintf("https://%s/rps/api/v1/admin/ciraconfigs/%s", configuration.MPSServer, configName)
|
||||||
|
|
||||||
responseBody, err := service.executeGetRequest(url, configuration.Credentials.MPSToken)
|
responseBody, err := service.executeGetRequest(url, configuration.MPSToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -89,7 +88,7 @@ func (service *Service) saveCIRAConfig(method string, configuration portainer.Op
|
||||||
}
|
}
|
||||||
payload, _ := json.Marshal(config)
|
payload, _ := json.Marshal(config)
|
||||||
|
|
||||||
responseBody, err := service.executeSaveRequest(method, url, configuration.Credentials.MPSToken, payload)
|
responseBody, err := service.executeSaveRequest(method, url, configuration.MPSToken, payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -123,7 +122,7 @@ func (service *Service) getCIRACertificate(configuration portainer.OpenAMTConfig
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", configuration.Credentials.MPSToken))
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", configuration.MPSToken))
|
||||||
|
|
||||||
response, err := service.httpsClient.Do(req)
|
response, err := service.httpsClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -131,7 +130,7 @@ func (service *Service) getCIRACertificate(configuration portainer.OpenAMTConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
if response.StatusCode != http.StatusOK {
|
if response.StatusCode != http.StatusOK {
|
||||||
return "", errors.New(fmt.Sprintf("unexpected status code %s", response.Status))
|
return "", fmt.Errorf("unexpected status code %s", response.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
certificate, err := io.ReadAll(response.Body)
|
certificate, err := io.ReadAll(response.Body)
|
||||||
|
|
87
api/hostmanagement/openamt/configDevice.go
Normal file
87
api/hostmanagement/openamt/configDevice.go
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
package openamt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Device struct {
|
||||||
|
GUID string `json:"guid"`
|
||||||
|
HostName string `json:"hostname"`
|
||||||
|
ConnectionStatus bool `json:"connectionStatus"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DevicePowerState struct {
|
||||||
|
State portainer.PowerState `json:"powerstate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceEnabledFeatures struct {
|
||||||
|
Redirection bool `json:"redirection"`
|
||||||
|
KVM bool `json:"KVM"`
|
||||||
|
SOL bool `json:"SOL"`
|
||||||
|
IDER bool `json:"IDER"`
|
||||||
|
UserConsent string `json:"userConsent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) getDevice(configuration portainer.OpenAMTConfiguration, deviceGUID string) (*Device, error) {
|
||||||
|
url := fmt.Sprintf("https://%s/mps/api/v1/devices/%s", configuration.MPSServer, deviceGUID)
|
||||||
|
|
||||||
|
responseBody, err := service.executeGetRequest(url, configuration.MPSToken)
|
||||||
|
if err != nil {
|
||||||
|
if strings.EqualFold(err.Error(), "invalid value") {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if responseBody == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var result Device
|
||||||
|
err = json.Unmarshal(responseBody, &result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) getDevicePowerState(configuration portainer.OpenAMTConfiguration, deviceGUID string) (*DevicePowerState, error) {
|
||||||
|
url := fmt.Sprintf("https://%s/mps/api/v1/amt/power/state/%s", configuration.MPSServer, deviceGUID)
|
||||||
|
|
||||||
|
responseBody, err := service.executeGetRequest(url, configuration.MPSToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if responseBody == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var result DevicePowerState
|
||||||
|
err = json.Unmarshal(responseBody, &result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) getDeviceEnabledFeatures(configuration portainer.OpenAMTConfiguration, deviceGUID string) (*DeviceEnabledFeatures, error) {
|
||||||
|
url := fmt.Sprintf("https://%s/mps/api/v1/amt/features/%s", configuration.MPSServer, deviceGUID)
|
||||||
|
|
||||||
|
responseBody, err := service.executeGetRequest(url, configuration.MPSToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if responseBody == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var result DeviceEnabledFeatures
|
||||||
|
err = json.Unmarshal(responseBody, &result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
|
@ -37,9 +37,9 @@ func (service *Service) createOrUpdateDomain(configuration portainer.OpenAMTConf
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *Service) getDomain(configuration portainer.OpenAMTConfiguration) (*Domain, error) {
|
func (service *Service) getDomain(configuration portainer.OpenAMTConfiguration) (*Domain, error) {
|
||||||
url := fmt.Sprintf("https://%s/rps/api/v1/admin/domains/%s", configuration.MPSServer, configuration.DomainConfiguration.DomainName)
|
url := fmt.Sprintf("https://%s/rps/api/v1/admin/domains/%s", configuration.MPSServer, configuration.DomainName)
|
||||||
|
|
||||||
responseBody, err := service.executeGetRequest(url, configuration.Credentials.MPSToken)
|
responseBody, err := service.executeGetRequest(url, configuration.MPSToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -59,15 +59,15 @@ func (service *Service) saveDomain(method string, configuration portainer.OpenAM
|
||||||
url := fmt.Sprintf("https://%s/rps/api/v1/admin/domains", configuration.MPSServer)
|
url := fmt.Sprintf("https://%s/rps/api/v1/admin/domains", configuration.MPSServer)
|
||||||
|
|
||||||
profile := Domain{
|
profile := Domain{
|
||||||
DomainName: configuration.DomainConfiguration.DomainName,
|
DomainName: configuration.DomainName,
|
||||||
DomainSuffix: configuration.DomainConfiguration.DomainName,
|
DomainSuffix: configuration.DomainName,
|
||||||
ProvisioningCert: configuration.DomainConfiguration.CertFileText,
|
ProvisioningCert: configuration.CertFileContent,
|
||||||
ProvisioningCertPassword: configuration.DomainConfiguration.CertPassword,
|
ProvisioningCertPassword: configuration.CertFilePassword,
|
||||||
ProvisioningCertStorageFormat: "string",
|
ProvisioningCertStorageFormat: "string",
|
||||||
}
|
}
|
||||||
payload, _ := json.Marshal(profile)
|
payload, _ := json.Marshal(profile)
|
||||||
|
|
||||||
responseBody, err := service.executeSaveRequest(method, url, configuration.Credentials.MPSToken, payload)
|
responseBody, err := service.executeSaveRequest(method, url, configuration.MPSToken, payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,7 +29,7 @@ type (
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func (service *Service) createOrUpdateAMTProfile(configuration portainer.OpenAMTConfiguration, profileName string, ciraConfigName string, wirelessConfig string) (*Profile, error) {
|
func (service *Service) createOrUpdateAMTProfile(configuration portainer.OpenAMTConfiguration, profileName string, ciraConfigName string) (*Profile, error) {
|
||||||
profile, err := service.getAMTProfile(configuration, profileName)
|
profile, err := service.getAMTProfile(configuration, profileName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -40,7 +40,7 @@ func (service *Service) createOrUpdateAMTProfile(configuration portainer.OpenAMT
|
||||||
method = http.MethodPatch
|
method = http.MethodPatch
|
||||||
}
|
}
|
||||||
|
|
||||||
profile, err = service.saveAMTProfile(method, configuration, profileName, ciraConfigName, wirelessConfig)
|
profile, err = service.saveAMTProfile(method, configuration, profileName, ciraConfigName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -50,7 +50,7 @@ func (service *Service) createOrUpdateAMTProfile(configuration portainer.OpenAMT
|
||||||
func (service *Service) getAMTProfile(configuration portainer.OpenAMTConfiguration, profileName string) (*Profile, error) {
|
func (service *Service) getAMTProfile(configuration portainer.OpenAMTConfiguration, profileName string) (*Profile, error) {
|
||||||
url := fmt.Sprintf("https://%s/rps/api/v1/admin/profiles/%s", configuration.MPSServer, profileName)
|
url := fmt.Sprintf("https://%s/rps/api/v1/admin/profiles/%s", configuration.MPSServer, profileName)
|
||||||
|
|
||||||
responseBody, err := service.executeGetRequest(url, configuration.Credentials.MPSToken)
|
responseBody, err := service.executeGetRequest(url, configuration.MPSToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -66,7 +66,7 @@ func (service *Service) getAMTProfile(configuration portainer.OpenAMTConfigurati
|
||||||
return &result, nil
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *Service) saveAMTProfile(method string, configuration portainer.OpenAMTConfiguration, profileName string, ciraConfigName string, wirelessConfig string) (*Profile, error) {
|
func (service *Service) saveAMTProfile(method string, configuration portainer.OpenAMTConfiguration, profileName string, ciraConfigName string) (*Profile, error) {
|
||||||
url := fmt.Sprintf("https://%s/rps/api/v1/admin/profiles", configuration.MPSServer)
|
url := fmt.Sprintf("https://%s/rps/api/v1/admin/profiles", configuration.MPSServer)
|
||||||
|
|
||||||
profile := Profile{
|
profile := Profile{
|
||||||
|
@ -74,23 +74,15 @@ func (service *Service) saveAMTProfile(method string, configuration portainer.Op
|
||||||
Activation: "acmactivate",
|
Activation: "acmactivate",
|
||||||
GenerateRandomAMTPassword: false,
|
GenerateRandomAMTPassword: false,
|
||||||
GenerateRandomMEBxPassword: false,
|
GenerateRandomMEBxPassword: false,
|
||||||
AMTPassword: configuration.Credentials.MPSPassword,
|
AMTPassword: configuration.MPSPassword,
|
||||||
MEBXPassword: configuration.Credentials.MPSPassword,
|
MEBXPassword: configuration.MPSPassword,
|
||||||
CIRAConfigName: &ciraConfigName,
|
CIRAConfigName: &ciraConfigName,
|
||||||
Tags: []string{},
|
Tags: []string{},
|
||||||
DHCPEnabled: true,
|
DHCPEnabled: true,
|
||||||
}
|
}
|
||||||
if wirelessConfig != "" {
|
|
||||||
profile.WIFIConfigs = []ProfileWifiConfig{
|
|
||||||
{
|
|
||||||
Priority: 1,
|
|
||||||
ProfileName: DefaultWirelessConfigName,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
payload, _ := json.Marshal(profile)
|
payload, _ := json.Marshal(profile)
|
||||||
|
|
||||||
responseBody, err := service.executeSaveRequest(method, url, configuration.Credentials.MPSToken, payload)
|
responseBody, err := service.executeSaveRequest(method, url, configuration.MPSToken, payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
55
api/hostmanagement/openamt/deviceActions.go
Normal file
55
api/hostmanagement/openamt/deviceActions.go
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
package openamt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ActionResponse struct {
|
||||||
|
Body struct {
|
||||||
|
ReturnValue int `json:"ReturnValue"`
|
||||||
|
ReturnValueStr string `json:"ReturnValueStr"`
|
||||||
|
} `json:"Body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) executeDeviceAction(configuration portainer.OpenAMTConfiguration, deviceGUID string, action int) error {
|
||||||
|
url := fmt.Sprintf("https://%s/mps/api/v1/amt/power/action/%s", configuration.MPSServer, deviceGUID)
|
||||||
|
|
||||||
|
payload := map[string]int{
|
||||||
|
"action": action,
|
||||||
|
}
|
||||||
|
jsonValue, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
responseBody, err := service.executeSaveRequest(http.MethodPost, url, configuration.MPSToken, jsonValue)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var response ActionResponse
|
||||||
|
err = json.Unmarshal(responseBody, &response)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.Body.ReturnValue != 0 {
|
||||||
|
return fmt.Errorf("failed to execute action, error status %v: %s", response.Body.ReturnValue, response.Body.ReturnValueStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAction(actionRaw string) (portainer.PowerState, error) {
|
||||||
|
switch strings.ToLower(actionRaw) {
|
||||||
|
case "power on":
|
||||||
|
return powerOnState, nil
|
||||||
|
case "power off":
|
||||||
|
return powerOffState, nil
|
||||||
|
case "restart":
|
||||||
|
return restartState, nil
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("unsupported device action %s", actionRaw)
|
||||||
|
}
|
29
api/hostmanagement/openamt/deviceFeatures.go
Normal file
29
api/hostmanagement/openamt/deviceFeatures.go
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
package openamt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (service *Service) enableDeviceFeatures(configuration portainer.OpenAMTConfiguration, deviceGUID string, features portainer.OpenAMTDeviceEnabledFeatures) error {
|
||||||
|
url := fmt.Sprintf("https://%s/mps/api/v1/amt/features/%s", configuration.MPSServer, deviceGUID)
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"enableSOL": features.SOL,
|
||||||
|
"enableIDER": features.IDER,
|
||||||
|
"enableKVM": features.KVM,
|
||||||
|
"redirection": features.Redirection,
|
||||||
|
"userConsent": features.UserConsent,
|
||||||
|
}
|
||||||
|
jsonValue, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
_, err := service.executeSaveRequest(http.MethodPost, url, configuration.MPSToken, jsonValue)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -11,13 +11,18 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
"golang.org/x/sync/errgroup"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DefaultCIRAConfigName = "ciraConfigDefault"
|
DefaultCIRAConfigName = "ciraConfigDefault"
|
||||||
DefaultWirelessConfigName = "wirelessProfileDefault"
|
DefaultProfileName = "profileAMTDefault"
|
||||||
DefaultProfileName = "profileAMTDefault"
|
|
||||||
|
httpClientTimeout = 5 * time.Minute
|
||||||
|
|
||||||
|
powerOnState portainer.PowerState = 2
|
||||||
|
powerOffState portainer.PowerState = 8
|
||||||
|
restartState portainer.PowerState = 5
|
||||||
)
|
)
|
||||||
|
|
||||||
// Service represents a service for managing an OpenAMT server.
|
// Service represents a service for managing an OpenAMT server.
|
||||||
|
@ -26,13 +31,10 @@ type Service struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService initializes a new service.
|
// NewService initializes a new service.
|
||||||
func NewService(dataStore dataservices.DataStore) *Service {
|
func NewService() *Service {
|
||||||
if !dataStore.Settings().IsFeatureFlagEnabled(portainer.FeatOpenAMT) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &Service{
|
return &Service{
|
||||||
httpsClient: &http.Client{
|
httpsClient: &http.Client{
|
||||||
Timeout: time.Second * time.Duration(5),
|
Timeout: httpClientTimeout,
|
||||||
Transport: &http.Transport{
|
Transport: &http.Transport{
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||||
},
|
},
|
||||||
|
@ -63,28 +65,19 @@ func parseError(responseBody []byte) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *Service) ConfigureDefault(configuration portainer.OpenAMTConfiguration) error {
|
func (service *Service) Configure(configuration portainer.OpenAMTConfiguration) error {
|
||||||
token, err := service.executeAuthenticationRequest(configuration)
|
token, err := service.Authorization(configuration)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
configuration.Credentials.MPSToken = token.Token
|
configuration.MPSToken = token
|
||||||
|
|
||||||
ciraConfig, err := service.createOrUpdateCIRAConfig(configuration, DefaultCIRAConfigName)
|
ciraConfig, err := service.createOrUpdateCIRAConfig(configuration, DefaultCIRAConfigName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
wirelessConfigName := ""
|
_, err = service.createOrUpdateAMTProfile(configuration, DefaultProfileName, ciraConfig.ConfigName)
|
||||||
if configuration.WirelessConfiguration != nil {
|
|
||||||
wirelessConfig, err := service.createOrUpdateWirelessConfig(configuration, DefaultWirelessConfigName)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
wirelessConfigName = wirelessConfig.ProfileName
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = service.createOrUpdateAMTProfile(configuration, DefaultProfileName, ciraConfig.ConfigName, wirelessConfigName)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -119,7 +112,7 @@ func (service *Service) executeSaveRequest(method string, url string, token stri
|
||||||
if errorResponse != nil {
|
if errorResponse != nil {
|
||||||
return nil, errorResponse
|
return nil, errorResponse
|
||||||
}
|
}
|
||||||
return nil, errors.New(fmt.Sprintf("unexpected status code %s", response.Status))
|
return nil, fmt.Errorf("unexpected status code %s", response.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
return responseBody, nil
|
return responseBody, nil
|
||||||
|
@ -151,8 +144,110 @@ func (service *Service) executeGetRequest(url string, token string) ([]byte, err
|
||||||
if errorResponse != nil {
|
if errorResponse != nil {
|
||||||
return nil, errorResponse
|
return nil, errorResponse
|
||||||
}
|
}
|
||||||
return nil, errors.New(fmt.Sprintf("unexpected status code %s", response.Status))
|
return nil, fmt.Errorf("unexpected status code %s", response.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
return responseBody, nil
|
return responseBody, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (service *Service) DeviceInformation(configuration portainer.OpenAMTConfiguration, deviceGUID string) (*portainer.OpenAMTDeviceInformation, error) {
|
||||||
|
token, err := service.Authorization(configuration)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
configuration.MPSToken = token
|
||||||
|
|
||||||
|
var g errgroup.Group
|
||||||
|
var resultDevice *Device
|
||||||
|
var resultPowerState *DevicePowerState
|
||||||
|
var resultEnabledFeatures *DeviceEnabledFeatures
|
||||||
|
|
||||||
|
g.Go(func() error {
|
||||||
|
device, err := service.getDevice(configuration, deviceGUID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if device == nil {
|
||||||
|
return fmt.Errorf("device %s not found", deviceGUID)
|
||||||
|
}
|
||||||
|
resultDevice = device
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
g.Go(func() error {
|
||||||
|
powerState, err := service.getDevicePowerState(configuration, deviceGUID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resultPowerState = powerState
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
g.Go(func() error {
|
||||||
|
enabledFeatures, err := service.getDeviceEnabledFeatures(configuration, deviceGUID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resultEnabledFeatures = enabledFeatures
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := g.Wait(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceInformation := &portainer.OpenAMTDeviceInformation{
|
||||||
|
GUID: resultDevice.GUID,
|
||||||
|
HostName: resultDevice.HostName,
|
||||||
|
ConnectionStatus: resultDevice.ConnectionStatus,
|
||||||
|
}
|
||||||
|
if resultPowerState != nil {
|
||||||
|
deviceInformation.PowerState = resultPowerState.State
|
||||||
|
}
|
||||||
|
if resultEnabledFeatures != nil {
|
||||||
|
deviceInformation.EnabledFeatures = &portainer.OpenAMTDeviceEnabledFeatures{
|
||||||
|
Redirection: resultEnabledFeatures.Redirection,
|
||||||
|
KVM: resultEnabledFeatures.KVM,
|
||||||
|
SOL: resultEnabledFeatures.SOL,
|
||||||
|
IDER: resultEnabledFeatures.IDER,
|
||||||
|
UserConsent: resultEnabledFeatures.UserConsent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return deviceInformation, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) ExecuteDeviceAction(configuration portainer.OpenAMTConfiguration, deviceGUID string, action string) error {
|
||||||
|
parsedAction, err := parseAction(action)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := service.Authorization(configuration)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
configuration.MPSToken = token
|
||||||
|
|
||||||
|
err = service.executeDeviceAction(configuration, deviceGUID, int(parsedAction))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) EnableDeviceFeatures(configuration portainer.OpenAMTConfiguration, deviceGUID string, features portainer.OpenAMTDeviceEnabledFeatures) (string, error) {
|
||||||
|
token, err := service.Authorization(configuration)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
configuration.MPSToken = token
|
||||||
|
|
||||||
|
err = service.enableDeviceFeatures(configuration, deviceGUID, features)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/gofrs/uuid"
|
||||||
httperror "github.com/portainer/libhttp/error"
|
httperror "github.com/portainer/libhttp/error"
|
||||||
"github.com/portainer/libhttp/request"
|
"github.com/portainer/libhttp/request"
|
||||||
"github.com/portainer/libhttp/response"
|
"github.com/portainer/libhttp/response"
|
||||||
|
@ -37,6 +38,7 @@ type endpointCreatePayload struct {
|
||||||
AzureAuthenticationKey string
|
AzureAuthenticationKey string
|
||||||
TagIDs []portainer.TagID
|
TagIDs []portainer.TagID
|
||||||
EdgeCheckinInterval int
|
EdgeCheckinInterval int
|
||||||
|
IsEdgeDevice bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type endpointCreationEnum int
|
type endpointCreationEnum int
|
||||||
|
@ -144,6 +146,9 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
|
||||||
checkinInterval, _ := request.RetrieveNumericMultiPartFormValue(r, "CheckinInterval", true)
|
checkinInterval, _ := request.RetrieveNumericMultiPartFormValue(r, "CheckinInterval", true)
|
||||||
payload.EdgeCheckinInterval = checkinInterval
|
payload.EdgeCheckinInterval = checkinInterval
|
||||||
|
|
||||||
|
isEdgeDevice, _ := request.RetrieveBooleanMultiPartFormValue(r, "IsEdgeDevice", true)
|
||||||
|
payload.IsEdgeDevice = isEdgeDevice
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -332,6 +337,21 @@ func (handler *Handler) createEdgeAgentEndpoint(payload *endpointCreatePayload)
|
||||||
EdgeKey: edgeKey,
|
EdgeKey: edgeKey,
|
||||||
EdgeCheckinInterval: payload.EdgeCheckinInterval,
|
EdgeCheckinInterval: payload.EdgeCheckinInterval,
|
||||||
Kubernetes: portainer.KubernetesDefault(),
|
Kubernetes: portainer.KubernetesDefault(),
|
||||||
|
IsEdgeDevice: payload.IsEdgeDevice,
|
||||||
|
}
|
||||||
|
|
||||||
|
settings, err := handler.DataStore.Settings().Settings()
|
||||||
|
if err != nil {
|
||||||
|
return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve the settings from the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.EnforceEdgeID {
|
||||||
|
edgeID, err := uuid.NewV4()
|
||||||
|
if err != nil {
|
||||||
|
return nil, &httperror.HandlerError{http.StatusInternalServerError, "Cannot generate the Edge ID", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint.EdgeID = edgeID.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
err = handler.saveEndpointAndUpdateAuthorizations(endpoint)
|
err = handler.saveEndpointAndUpdateAuthorizations(endpoint)
|
||||||
|
@ -370,6 +390,7 @@ func (handler *Handler) createUnsecuredEndpoint(payload *endpointCreatePayload)
|
||||||
Status: portainer.EndpointStatusUp,
|
Status: portainer.EndpointStatusUp,
|
||||||
Snapshots: []portainer.DockerSnapshot{},
|
Snapshots: []portainer.DockerSnapshot{},
|
||||||
Kubernetes: portainer.KubernetesDefault(),
|
Kubernetes: portainer.KubernetesDefault(),
|
||||||
|
IsEdgeDevice: payload.IsEdgeDevice,
|
||||||
}
|
}
|
||||||
|
|
||||||
err := handler.snapshotAndPersistEndpoint(endpoint)
|
err := handler.snapshotAndPersistEndpoint(endpoint)
|
||||||
|
@ -435,6 +456,7 @@ func (handler *Handler) createTLSSecuredEndpoint(payload *endpointCreatePayload,
|
||||||
Status: portainer.EndpointStatusUp,
|
Status: portainer.EndpointStatusUp,
|
||||||
Snapshots: []portainer.DockerSnapshot{},
|
Snapshots: []portainer.DockerSnapshot{},
|
||||||
Kubernetes: portainer.KubernetesDefault(),
|
Kubernetes: portainer.KubernetesDefault(),
|
||||||
|
IsEdgeDevice: payload.IsEdgeDevice,
|
||||||
}
|
}
|
||||||
|
|
||||||
err := handler.storeTLSFiles(endpoint, payload)
|
err := handler.storeTLSFiles(endpoint, payload)
|
||||||
|
|
|
@ -89,6 +89,11 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
|
||||||
filteredEndpoints = filterEndpointsByGroupID(filteredEndpoints, portainer.EndpointGroupID(groupID))
|
filteredEndpoints = filterEndpointsByGroupID(filteredEndpoints, portainer.EndpointGroupID(groupID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
edgeDeviceFilter, edgeDeviceFilterErr := request.RetrieveBooleanQueryParameter(r, "edgeDeviceFilter", false)
|
||||||
|
if edgeDeviceFilterErr == nil {
|
||||||
|
filteredEndpoints = filterEndpointsByEdgeDevice(filteredEndpoints, edgeDeviceFilter)
|
||||||
|
}
|
||||||
|
|
||||||
if search != "" {
|
if search != "" {
|
||||||
tags, err := handler.DataStore.Tag().Tags()
|
tags, err := handler.DataStore.Tag().Tags()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -231,6 +236,17 @@ func filterEndpointsByTypes(endpoints []portainer.Endpoint, endpointTypes []int)
|
||||||
return filteredEndpoints
|
return filteredEndpoints
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func filterEndpointsByEdgeDevice(endpoints []portainer.Endpoint, edgeDeviceFilter bool) []portainer.Endpoint {
|
||||||
|
filteredEndpoints := make([]portainer.Endpoint, 0)
|
||||||
|
|
||||||
|
for _, endpoint := range endpoints {
|
||||||
|
if edgeDeviceFilter == endpoint.IsEdgeDevice {
|
||||||
|
filteredEndpoints = append(filteredEndpoints, endpoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filteredEndpoints
|
||||||
|
}
|
||||||
|
|
||||||
func convertTagIDsToTags(tagsMap map[portainer.TagID]string, tagIDs []portainer.TagID) []string {
|
func convertTagIDsToTags(tagsMap map[portainer.TagID]string, tagIDs []portainer.TagID) []string {
|
||||||
tags := make([]string, 0)
|
tags := make([]string, 0)
|
||||||
for _, tagID := range tagIDs {
|
for _, tagID := range tagIDs {
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package endpoints
|
package endpoints
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
httperror "github.com/portainer/libhttp/error"
|
httperror "github.com/portainer/libhttp/error"
|
||||||
|
@ -37,7 +38,7 @@ func (handler *Handler) endpointSnapshot(w http.ResponseWriter, r *http.Request)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !snapshot.SupportDirectSnapshot(endpoint) {
|
if !snapshot.SupportDirectSnapshot(endpoint) {
|
||||||
return &httperror.HandlerError{http.StatusBadRequest, "Snapshots not supported for this environment", err}
|
return &httperror.HandlerError{http.StatusBadRequest, "Snapshots not supported for this environment", errors.New("Snapshots not supported for this environment")}
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshotError := handler.SnapshotService.SnapshotEndpoint(endpoint)
|
snapshotError := handler.SnapshotService.SnapshotEndpoint(endpoint)
|
||||||
|
|
|
@ -46,6 +46,8 @@ type endpointUpdatePayload struct {
|
||||||
EdgeCheckinInterval *int `example:"5"`
|
EdgeCheckinInterval *int `example:"5"`
|
||||||
// Associated Kubernetes data
|
// Associated Kubernetes data
|
||||||
Kubernetes *portainer.KubernetesData
|
Kubernetes *portainer.KubernetesData
|
||||||
|
// Whether the device has been trusted or not by the user
|
||||||
|
UserTrusted *bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (payload *endpointUpdatePayload) Validate(r *http.Request) error {
|
func (payload *endpointUpdatePayload) Validate(r *http.Request) error {
|
||||||
|
@ -270,6 +272,10 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if payload.UserTrusted != nil {
|
||||||
|
endpoint.UserTrusted = *payload.UserTrusted
|
||||||
|
}
|
||||||
|
|
||||||
err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint)
|
err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist environment changes inside the database", err}
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist environment changes inside the database", err}
|
||||||
|
|
|
@ -17,6 +17,7 @@ import (
|
||||||
"github.com/portainer/portainer/api/http/handler/endpoints"
|
"github.com/portainer/portainer/api/http/handler/endpoints"
|
||||||
"github.com/portainer/portainer/api/http/handler/file"
|
"github.com/portainer/portainer/api/http/handler/file"
|
||||||
"github.com/portainer/portainer/api/http/handler/helm"
|
"github.com/portainer/portainer/api/http/handler/helm"
|
||||||
|
"github.com/portainer/portainer/api/http/handler/hostmanagement/fdo"
|
||||||
"github.com/portainer/portainer/api/http/handler/hostmanagement/openamt"
|
"github.com/portainer/portainer/api/http/handler/hostmanagement/openamt"
|
||||||
"github.com/portainer/portainer/api/http/handler/kubernetes"
|
"github.com/portainer/portainer/api/http/handler/kubernetes"
|
||||||
"github.com/portainer/portainer/api/http/handler/ldap"
|
"github.com/portainer/portainer/api/http/handler/ldap"
|
||||||
|
@ -64,6 +65,7 @@ type Handler struct {
|
||||||
SettingsHandler *settings.Handler
|
SettingsHandler *settings.Handler
|
||||||
SSLHandler *ssl.Handler
|
SSLHandler *ssl.Handler
|
||||||
OpenAMTHandler *openamt.Handler
|
OpenAMTHandler *openamt.Handler
|
||||||
|
FDOHandler *fdo.Handler
|
||||||
StackHandler *stacks.Handler
|
StackHandler *stacks.Handler
|
||||||
StatusHandler *status.Handler
|
StatusHandler *status.Handler
|
||||||
StorybookHandler *storybook.Handler
|
StorybookHandler *storybook.Handler
|
||||||
|
@ -228,9 +230,9 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
case strings.HasPrefix(r.URL.Path, "/api/ssl"):
|
case strings.HasPrefix(r.URL.Path, "/api/ssl"):
|
||||||
http.StripPrefix("/api", h.SSLHandler).ServeHTTP(w, r)
|
http.StripPrefix("/api", h.SSLHandler).ServeHTTP(w, r)
|
||||||
case strings.HasPrefix(r.URL.Path, "/api/open_amt"):
|
case strings.HasPrefix(r.URL.Path, "/api/open_amt"):
|
||||||
if h.OpenAMTHandler != nil {
|
http.StripPrefix("/api", h.OpenAMTHandler).ServeHTTP(w, r)
|
||||||
http.StripPrefix("/api", h.OpenAMTHandler).ServeHTTP(w, r)
|
case strings.HasPrefix(r.URL.Path, "/api/fdo"):
|
||||||
}
|
http.StripPrefix("/api", h.FDOHandler).ServeHTTP(w, r)
|
||||||
case strings.HasPrefix(r.URL.Path, "/api/teams"):
|
case strings.HasPrefix(r.URL.Path, "/api/teams"):
|
||||||
http.StripPrefix("/api", h.TeamHandler).ServeHTTP(w, r)
|
http.StripPrefix("/api", h.TeamHandler).ServeHTTP(w, r)
|
||||||
case strings.HasPrefix(r.URL.Path, "/api/team_memberships"):
|
case strings.HasPrefix(r.URL.Path, "/api/team_memberships"):
|
||||||
|
|
188
api/http/handler/hostmanagement/fdo/configure.go
Normal file
188
api/http/handler/hostmanagement/fdo/configure.go
Normal file
|
@ -0,0 +1,188 @@
|
||||||
|
package fdo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
|
||||||
|
httperror "github.com/portainer/libhttp/error"
|
||||||
|
"github.com/portainer/libhttp/request"
|
||||||
|
"github.com/portainer/libhttp/response"
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
deploymentScriptName = "fdo.sh"
|
||||||
|
)
|
||||||
|
|
||||||
|
type deviceConfigurePayload struct {
|
||||||
|
EdgeID string `json:"edgeID"`
|
||||||
|
EdgeKey string `json:"edgeKey"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ProfileID int `json:"profile"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (payload *deviceConfigurePayload) Validate(r *http.Request) error {
|
||||||
|
if payload.EdgeID == "" {
|
||||||
|
return errors.New("invalid edge ID provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.EdgeKey == "" {
|
||||||
|
return errors.New("invalid edge key provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Name == "" {
|
||||||
|
return errors.New("the device name cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.ProfileID < 1 {
|
||||||
|
return errors.New("invalid profile id provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// @id fdoConfigureDevice
|
||||||
|
// @summary configures an FDO device
|
||||||
|
// @description configures an FDO device
|
||||||
|
// @description **Access policy**: administrator
|
||||||
|
// @tags intel
|
||||||
|
// @security jwt
|
||||||
|
// @produce json
|
||||||
|
// @param body body deviceConfigurePayload true "Device Configuration"
|
||||||
|
// @success 200 "Success"
|
||||||
|
// @failure 400 "Invalid request"
|
||||||
|
// @failure 403 "Permission denied to access settings"
|
||||||
|
// @failure 500 "Server error"
|
||||||
|
// @router /fdo/configure/{guid} [post]
|
||||||
|
func (handler *Handler) fdoConfigureDevice(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
guid, err := request.RetrieveRouteVariableValue(r, "guid")
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithError(err).Info("fdoConfigureDevice: request.RetrieveRouteVariableValue()")
|
||||||
|
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoConfigureDevice: guid not found", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload deviceConfigurePayload
|
||||||
|
|
||||||
|
err = request.DecodeAndValidateJSONPayload(r, &payload)
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithError(err).Error("Invalid request payload")
|
||||||
|
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
profile, err := handler.DataStore.FDOProfile().FDOProfile(portainer.FDOProfileID(payload.ProfileID))
|
||||||
|
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||||
|
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a FDO Profile with the specified identifier inside the database", err}
|
||||||
|
} else if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a FDO Profile with the specified identifier inside the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileContent, err := handler.FileService.GetFileContent(profile.FilePath, "")
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithError(err).Info("fdoConfigureDevice: GetFileContent")
|
||||||
|
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoConfigureDevice: GetFileContent", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
fdoClient, err := handler.newFDOClient()
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithError(err).Info("fdoConfigureDevice: newFDOClient()")
|
||||||
|
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoConfigureDevice: newFDOClient()", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
// enable fdo_sys
|
||||||
|
if err = fdoClient.PutDeviceSVIRaw(url.Values{
|
||||||
|
"guid": []string{guid},
|
||||||
|
"priority": []string{"0"},
|
||||||
|
"module": []string{"fdo_sys"},
|
||||||
|
"var": []string{"active"},
|
||||||
|
"bytes": []string{"F5"}, // this is "true" in CBOR
|
||||||
|
}, []byte("")); err != nil {
|
||||||
|
logrus.WithError(err).Info("fdoConfigureDevice: PutDeviceSVIRaw()")
|
||||||
|
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoConfigureDevice: PutDeviceSVIRaw()", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = fdoClient.PutDeviceSVIRaw(url.Values{
|
||||||
|
"guid": []string{guid},
|
||||||
|
"priority": []string{"1"},
|
||||||
|
"module": []string{"fdo_sys"},
|
||||||
|
"var": []string{"filedesc"},
|
||||||
|
"filename": []string{"DEVICE_edgeid.txt"},
|
||||||
|
}, []byte(payload.EdgeID)); err != nil {
|
||||||
|
logrus.WithError(err).Info("fdoConfigureDevice: PutDeviceSVIRaw(edgeid)")
|
||||||
|
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoConfigureDevice: PutDeviceSVIRaw(edgeid)", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
// write down the edgekey
|
||||||
|
if err = fdoClient.PutDeviceSVIRaw(url.Values{
|
||||||
|
"guid": []string{guid},
|
||||||
|
"priority": []string{"1"},
|
||||||
|
"module": []string{"fdo_sys"},
|
||||||
|
"var": []string{"filedesc"},
|
||||||
|
"filename": []string{"DEVICE_edgekey.txt"},
|
||||||
|
}, []byte(payload.EdgeKey)); err != nil {
|
||||||
|
logrus.WithError(err).Info("fdoConfigureDevice: PutDeviceSVIRaw(edgekey)")
|
||||||
|
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoConfigureDevice: PutDeviceSVIRaw(edgekey)", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
// write down the device name
|
||||||
|
if err = fdoClient.PutDeviceSVIRaw(url.Values{
|
||||||
|
"guid": []string{guid},
|
||||||
|
"priority": []string{"1"},
|
||||||
|
"module": []string{"fdo_sys"},
|
||||||
|
"var": []string{"filedesc"},
|
||||||
|
"filename": []string{"DEVICE_name.txt"},
|
||||||
|
}, []byte(payload.Name)); err != nil {
|
||||||
|
logrus.WithError(err).Info("fdoConfigureDevice: PutDeviceSVIRaw(name)")
|
||||||
|
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoConfigureDevice: PutDeviceSVIRaw(name)", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
// write down the device GUID - used as the EDGE_DEVICE_GUID too
|
||||||
|
if err = fdoClient.PutDeviceSVIRaw(url.Values{
|
||||||
|
"guid": []string{guid},
|
||||||
|
"priority": []string{"1"},
|
||||||
|
"module": []string{"fdo_sys"},
|
||||||
|
"var": []string{"filedesc"},
|
||||||
|
"filename": []string{"DEVICE_GUID.txt"},
|
||||||
|
}, []byte(guid)); err != nil {
|
||||||
|
logrus.WithError(err).Info("fdoConfigureDevice: PutDeviceSVIRaw()")
|
||||||
|
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoConfigureDevice: PutDeviceSVIRaw()", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = fdoClient.PutDeviceSVIRaw(url.Values{
|
||||||
|
"guid": []string{guid},
|
||||||
|
"priority": []string{"1"},
|
||||||
|
"module": []string{"fdo_sys"},
|
||||||
|
"var": []string{"filedesc"},
|
||||||
|
"filename": []string{deploymentScriptName},
|
||||||
|
}, fileContent); err != nil {
|
||||||
|
logrus.WithError(err).Info("fdoConfigureDevice: PutDeviceSVIRaw()")
|
||||||
|
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoConfigureDevice: PutDeviceSVIRaw()", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := cbor.Marshal([]string{"/bin/sh", deploymentScriptName})
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithError(err).Error("failed to marshal string to CBOR")
|
||||||
|
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoConfigureDevice: PutDeviceSVIRaw() failed to encode", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
cborBytes := strings.ToUpper(hex.EncodeToString(b))
|
||||||
|
logrus.WithField("cbor", cborBytes).WithField("string", deploymentScriptName).Info("converted to CBOR")
|
||||||
|
|
||||||
|
if err = fdoClient.PutDeviceSVIRaw(url.Values{
|
||||||
|
"guid": []string{guid},
|
||||||
|
"priority": []string{"2"},
|
||||||
|
"module": []string{"fdo_sys"},
|
||||||
|
"var": []string{"exec"},
|
||||||
|
"bytes": []string{cborBytes},
|
||||||
|
}, []byte("")); err != nil {
|
||||||
|
logrus.WithError(err).Info("fdoConfigureDevice: PutDeviceSVIRaw()")
|
||||||
|
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoConfigureDevice: PutDeviceSVIRaw()", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Empty(w)
|
||||||
|
}
|
164
api/http/handler/hostmanagement/fdo/fdo.go
Normal file
164
api/http/handler/hostmanagement/fdo/fdo.go
Normal file
|
@ -0,0 +1,164 @@
|
||||||
|
package fdo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
httperror "github.com/portainer/libhttp/error"
|
||||||
|
"github.com/portainer/libhttp/request"
|
||||||
|
"github.com/portainer/libhttp/response"
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/hostmanagement/fdo"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fdoConfigurePayload portainer.FDOConfiguration
|
||||||
|
|
||||||
|
func validateURL(u string) error {
|
||||||
|
p, err := url.Parse(u)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.Scheme != "http" && p.Scheme != "https" {
|
||||||
|
return errors.New("invalid scheme provided, must be 'http' or 'https'")
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.Host == "" {
|
||||||
|
return errors.New("invalid host provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (payload *fdoConfigurePayload) Validate(r *http.Request) error {
|
||||||
|
if payload.Enabled {
|
||||||
|
if err := validateURL(payload.OwnerURL); err != nil {
|
||||||
|
return fmt.Errorf("owner server URL: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *Handler) saveSettings(config portainer.FDOConfiguration) error {
|
||||||
|
settings, err := handler.DataStore.Settings().Settings()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
settings.FDOConfiguration = config
|
||||||
|
|
||||||
|
return handler.DataStore.Settings().UpdateSettings(settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *Handler) newFDOClient() (fdo.FDOOwnerClient, error) {
|
||||||
|
settings, err := handler.DataStore.Settings().Settings()
|
||||||
|
if err != nil {
|
||||||
|
return fdo.FDOOwnerClient{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return fdo.FDOOwnerClient{
|
||||||
|
OwnerURL: settings.FDOConfiguration.OwnerURL,
|
||||||
|
Username: settings.FDOConfiguration.OwnerUsername,
|
||||||
|
Password: settings.FDOConfiguration.OwnerPassword,
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// @id fdoConfigure
|
||||||
|
// @summary Enable Portainer's FDO capabilities
|
||||||
|
// @description Enable Portainer's FDO capabilities
|
||||||
|
// @description **Access policy**: administrator
|
||||||
|
// @tags intel
|
||||||
|
// @security jwt
|
||||||
|
// @accept json
|
||||||
|
// @produce json
|
||||||
|
// @param body body fdoConfigurePayload true "FDO Settings"
|
||||||
|
// @success 204 "Success"
|
||||||
|
// @failure 400 "Invalid request"
|
||||||
|
// @failure 403 "Permission denied to access settings"
|
||||||
|
// @failure 500 "Server error"
|
||||||
|
// @router /fdo [post]
|
||||||
|
func (handler *Handler) fdoConfigure(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
var payload fdoConfigurePayload
|
||||||
|
|
||||||
|
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithError(err).Error("Invalid request payload")
|
||||||
|
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
settings := portainer.FDOConfiguration(payload)
|
||||||
|
if err = handler.saveSettings(settings); err != nil {
|
||||||
|
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Error saving FDO settings", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
profiles, err := handler.DataStore.FDOProfile().FDOProfiles()
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Error saving FDO settings", Err: err}
|
||||||
|
}
|
||||||
|
if len(profiles) == 0 {
|
||||||
|
err = handler.addDefaultProfile()
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Empty(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *Handler) addDefaultProfile() error {
|
||||||
|
profileID := handler.DataStore.FDOProfile().GetNextIdentifier()
|
||||||
|
profile := &portainer.FDOProfile{
|
||||||
|
ID: portainer.FDOProfileID(profileID),
|
||||||
|
Name: "Docker Standalone + Edge",
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath, err := handler.FileService.StoreFDOProfileFileFromBytes(strconv.Itoa(int(profile.ID)), []byte(defaultProfileFileContent))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
profile.FilePath = filePath
|
||||||
|
profile.DateCreated = time.Now().Unix()
|
||||||
|
|
||||||
|
err = handler.DataStore.FDOProfile().Create(profile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultProfileFileContent = `
|
||||||
|
#!/bin/bash -ex
|
||||||
|
|
||||||
|
env > env.log
|
||||||
|
|
||||||
|
export AGENT_IMAGE=portainer/agent:2.11.0
|
||||||
|
export GUID=$(cat DEVICE_GUID.txt)
|
||||||
|
export DEVICE_NAME=$(cat DEVICE_name.txt)
|
||||||
|
export EDGE_ID=$(cat DEVICE_edgeid.txt)
|
||||||
|
export EDGE_KEY=$(cat DEVICE_edgekey.txt)
|
||||||
|
export AGENTVOLUME=$(pwd)/data/portainer_agent_data/
|
||||||
|
|
||||||
|
mkdir -p ${AGENTVOLUME}
|
||||||
|
|
||||||
|
docker pull ${AGENT_IMAGE}
|
||||||
|
|
||||||
|
docker run -d \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
-v /var/lib/docker/volumes:/var/lib/docker/volumes \
|
||||||
|
-v /:/host \
|
||||||
|
-v ${AGENTVOLUME}:/data \
|
||||||
|
--restart always \
|
||||||
|
-e EDGE=1 \
|
||||||
|
-e EDGE_ID=${EDGE_ID} \
|
||||||
|
-e EDGE_KEY=${EDGE_KEY} \
|
||||||
|
-e CAP_HOST_MANAGEMENT=1 \
|
||||||
|
-e EDGE_INSECURE_POLL=1 \
|
||||||
|
--name portainer_edge_agent \
|
||||||
|
${AGENT_IMAGE}
|
||||||
|
`
|
40
api/http/handler/hostmanagement/fdo/handler.go
Normal file
40
api/http/handler/hostmanagement/fdo/handler.go
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
package fdo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
|
||||||
|
httperror "github.com/portainer/libhttp/error"
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
|
"github.com/portainer/portainer/api/http/security"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
*mux.Router
|
||||||
|
DataStore dataservices.DataStore
|
||||||
|
FileService portainer.FileService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(bouncer *security.RequestBouncer, dataStore dataservices.DataStore, fileService portainer.FileService) *Handler {
|
||||||
|
h := &Handler{
|
||||||
|
Router: mux.NewRouter(),
|
||||||
|
DataStore: dataStore,
|
||||||
|
FileService: fileService,
|
||||||
|
}
|
||||||
|
|
||||||
|
h.Handle("/fdo/configure", bouncer.AdminAccess(httperror.LoggerHandler(h.fdoConfigure))).Methods(http.MethodPost)
|
||||||
|
h.Handle("/fdo/list", bouncer.AdminAccess(httperror.LoggerHandler(h.fdoListAll))).Methods(http.MethodGet)
|
||||||
|
h.Handle("/fdo/register", bouncer.AdminAccess(httperror.LoggerHandler(h.fdoRegisterDevice))).Methods(http.MethodPost)
|
||||||
|
h.Handle("/fdo/configure/{guid}", bouncer.AdminAccess(httperror.LoggerHandler(h.fdoConfigureDevice))).Methods(http.MethodPost)
|
||||||
|
|
||||||
|
h.Handle("/fdo/profiles", bouncer.AdminAccess(httperror.LoggerHandler(h.fdoProfileList))).Methods(http.MethodGet)
|
||||||
|
h.Handle("/fdo/profiles", bouncer.AdminAccess(httperror.LoggerHandler(h.createProfile))).Methods(http.MethodPost)
|
||||||
|
h.Handle("/fdo/profiles/{id}", bouncer.AdminAccess(httperror.LoggerHandler(h.fdoProfileInspect))).Methods(http.MethodGet)
|
||||||
|
h.Handle("/fdo/profiles/{id}", bouncer.AdminAccess(httperror.LoggerHandler(h.updateProfile))).Methods(http.MethodPut)
|
||||||
|
h.Handle("/fdo/profiles/{id}", bouncer.AdminAccess(httperror.LoggerHandler(h.deleteProfile))).Methods(http.MethodDelete)
|
||||||
|
h.Handle("/fdo/profiles/{id}/duplicate", bouncer.AdminAccess(httperror.LoggerHandler(h.duplicateProfile))).Methods(http.MethodPost)
|
||||||
|
|
||||||
|
return h
|
||||||
|
}
|
39
api/http/handler/hostmanagement/fdo/list.go
Normal file
39
api/http/handler/hostmanagement/fdo/list.go
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
package fdo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
httperror "github.com/portainer/libhttp/error"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"github.com/portainer/libhttp/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// @id fdoListAll
|
||||||
|
// @summary List all known FDO vouchers
|
||||||
|
// @description List all known FDO vouchers
|
||||||
|
// @description **Access policy**: administrator
|
||||||
|
// @tags intel
|
||||||
|
// @security jwt
|
||||||
|
// @produce json
|
||||||
|
// @success 200 "Success"
|
||||||
|
// @failure 400 "Invalid request"
|
||||||
|
// @failure 403 "Permission denied to access settings"
|
||||||
|
// @failure 500 "Server error"
|
||||||
|
// @router /fdo/list [get]
|
||||||
|
func (handler *Handler) fdoListAll(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
fdoClient, err := handler.newFDOClient()
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithError(err).Info("fdoListAll: newFDOClient()")
|
||||||
|
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoRegisterDevice: newFDOClient()", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all vouchers
|
||||||
|
guids, err := fdoClient.GetVouchers()
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithError(err).Info("fdoListAll: GetVouchers()")
|
||||||
|
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoListAll: GetVouchers()", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.JSON(w, guids)
|
||||||
|
}
|
92
api/http/handler/hostmanagement/fdo/profile_create.go
Normal file
92
api/http/handler/hostmanagement/fdo/profile_create.go
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
package fdo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
httperror "github.com/portainer/libhttp/error"
|
||||||
|
"github.com/portainer/libhttp/request"
|
||||||
|
"github.com/portainer/libhttp/response"
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
type createProfileFromFileContentPayload struct {
|
||||||
|
Name string
|
||||||
|
ProfileFileContent string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (payload *createProfileFromFileContentPayload) Validate(r *http.Request) error {
|
||||||
|
if payload.Name == "" {
|
||||||
|
return errors.New("profile name must be provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.ProfileFileContent == "" {
|
||||||
|
return errors.New("profile file content must be provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// @id createProfile
|
||||||
|
// @summary creates a new FDO Profile
|
||||||
|
// @description creates a new FDO Profile
|
||||||
|
// @description **Access policy**: administrator
|
||||||
|
// @tags intel
|
||||||
|
// @security jwt
|
||||||
|
// @produce json
|
||||||
|
// @success 200 "Success"
|
||||||
|
// @failure 400 "Invalid request"
|
||||||
|
// @failure 409 "Profile name already exists"
|
||||||
|
// @failure 500 "Server error"
|
||||||
|
// @router /fdo/profiles [post]
|
||||||
|
func (handler *Handler) createProfile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
method, err := request.RetrieveQueryParameter(r, "method", false)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: method", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch method {
|
||||||
|
case "editor":
|
||||||
|
return handler.createFDOProfileFromFileContent(w, r)
|
||||||
|
}
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid method. Value must be one of: editor", errors.New("invalid method")}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *Handler) createFDOProfileFromFileContent(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
var payload createProfileFromFileContentPayload
|
||||||
|
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
isUnique, err := handler.checkUniqueProfileName(payload.Name, -1)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err}
|
||||||
|
}
|
||||||
|
if !isUnique {
|
||||||
|
return &httperror.HandlerError{http.StatusConflict, fmt.Sprintf("A profile with the name '%s' already exists", payload.Name), errors.New("a profile already exists with this name")}
|
||||||
|
}
|
||||||
|
|
||||||
|
profileID := handler.DataStore.FDOProfile().GetNextIdentifier()
|
||||||
|
profile := &portainer.FDOProfile{
|
||||||
|
ID: portainer.FDOProfileID(profileID),
|
||||||
|
Name: payload.Name,
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath, err := handler.FileService.StoreFDOProfileFileFromBytes(strconv.Itoa(int(profile.ID)), []byte(payload.ProfileFileContent))
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist profile file on disk", err}
|
||||||
|
}
|
||||||
|
profile.FilePath = filePath
|
||||||
|
profile.DateCreated = time.Now().Unix()
|
||||||
|
|
||||||
|
err = handler.DataStore.FDOProfile().Create(profile)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the profile inside the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.JSON(w, profile)
|
||||||
|
}
|
36
api/http/handler/hostmanagement/fdo/profile_delete.go
Normal file
36
api/http/handler/hostmanagement/fdo/profile_delete.go
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
package fdo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
httperror "github.com/portainer/libhttp/error"
|
||||||
|
"github.com/portainer/libhttp/request"
|
||||||
|
"github.com/portainer/libhttp/response"
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// @id deleteProfile
|
||||||
|
// @summary deletes a FDO Profile
|
||||||
|
// @description deletes a FDO Profile
|
||||||
|
// @description **Access policy**: administrator
|
||||||
|
// @tags intel
|
||||||
|
// @security jwt
|
||||||
|
// @produce json
|
||||||
|
// @success 200 "Success"
|
||||||
|
// @failure 400 "Invalid request"
|
||||||
|
// @failure 500 "Server error"
|
||||||
|
// @router /fdo/profiles/{id} [delete]
|
||||||
|
func (handler *Handler) deleteProfile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
id, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Bad request", errors.New("missing 'id' query parameter")}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = handler.DataStore.FDOProfile().Delete(portainer.FDOProfileID(id))
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to delete Profile", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Empty(w)
|
||||||
|
}
|
67
api/http/handler/hostmanagement/fdo/profile_duplicate.go
Normal file
67
api/http/handler/hostmanagement/fdo/profile_duplicate.go
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
package fdo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
httperror "github.com/portainer/libhttp/error"
|
||||||
|
"github.com/portainer/libhttp/request"
|
||||||
|
"github.com/portainer/libhttp/response"
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// @id duplicate
|
||||||
|
// @summary duplicated an existing FDO Profile
|
||||||
|
// @description duplicated an existing FDO Profile
|
||||||
|
// @description **Access policy**: administrator
|
||||||
|
// @tags intel
|
||||||
|
// @security jwt
|
||||||
|
// @produce json
|
||||||
|
// @success 200 "Success"
|
||||||
|
// @failure 400 "Invalid request"
|
||||||
|
// @failure 500 "Server error"
|
||||||
|
// @router /fdo/profiles/{id}/duplicate [post]
|
||||||
|
func (handler *Handler) duplicateProfile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
id, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Bad request", errors.New("missing 'id' query parameter")}
|
||||||
|
}
|
||||||
|
|
||||||
|
originalProfile, err := handler.DataStore.FDOProfile().FDOProfile(portainer.FDOProfileID(id))
|
||||||
|
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||||
|
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a FDO Profile with the specified identifier inside the database", err}
|
||||||
|
} else if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a FDO Profile with the specified identifier inside the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileContent, err := handler.FileService.GetFileContent(originalProfile.FilePath, "")
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Profile file content", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
profileID := handler.DataStore.FDOProfile().GetNextIdentifier()
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to duplicate Profile", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
newProfile := &portainer.FDOProfile{
|
||||||
|
ID: portainer.FDOProfileID(profileID),
|
||||||
|
Name: fmt.Sprintf("%s - copy", originalProfile.Name),
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath, err := handler.FileService.StoreFDOProfileFileFromBytes(strconv.Itoa(int(newProfile.ID)), fileContent)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist profile file on disk", err}
|
||||||
|
}
|
||||||
|
newProfile.FilePath = filePath
|
||||||
|
newProfile.DateCreated = time.Now().Unix()
|
||||||
|
|
||||||
|
err = handler.DataStore.FDOProfile().Create(newProfile)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the profile inside the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.JSON(w, newProfile)
|
||||||
|
}
|
49
api/http/handler/hostmanagement/fdo/profile_inspect.go
Normal file
49
api/http/handler/hostmanagement/fdo/profile_inspect.go
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
package fdo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
httperror "github.com/portainer/libhttp/error"
|
||||||
|
"github.com/portainer/libhttp/request"
|
||||||
|
"github.com/portainer/libhttp/response"
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fdoProfileResponse struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
FileContent string `json:"fileContent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// @id fdoProfileInspect
|
||||||
|
// @summary retrieves a given FDO profile information and content
|
||||||
|
// @description retrieves a given FDO profile information and content
|
||||||
|
// @description **Access policy**: administrator
|
||||||
|
// @tags intel
|
||||||
|
// @security jwt
|
||||||
|
// @produce json
|
||||||
|
// @success 200 "Success"
|
||||||
|
// @failure 400 "Invalid request"
|
||||||
|
// @failure 500 "Server error"
|
||||||
|
// @router /fdo/profiles/{id} [get]
|
||||||
|
func (handler *Handler) fdoProfileInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
id, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Bad request", errors.New("missing 'id' query parameter")}
|
||||||
|
}
|
||||||
|
|
||||||
|
profile, err := handler.DataStore.FDOProfile().FDOProfile(portainer.FDOProfileID(id))
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Profile", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileContent, err := handler.FileService.GetFileContent(profile.FilePath, "")
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Profile file content", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.JSON(w, fdoProfileResponse{
|
||||||
|
Name: profile.Name,
|
||||||
|
FileContent: string(fileContent),
|
||||||
|
})
|
||||||
|
}
|
31
api/http/handler/hostmanagement/fdo/profile_list.go
Normal file
31
api/http/handler/hostmanagement/fdo/profile_list.go
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
package fdo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
httperror "github.com/portainer/libhttp/error"
|
||||||
|
"github.com/portainer/libhttp/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// @id fdoProfileList
|
||||||
|
// @summary retrieves all FDO profiles
|
||||||
|
// @description retrieves all FDO profiles
|
||||||
|
// @description **Access policy**: administrator
|
||||||
|
// @tags intel
|
||||||
|
// @security jwt
|
||||||
|
// @produce json
|
||||||
|
// @success 200 "Success"
|
||||||
|
// @failure 400 "Invalid request"
|
||||||
|
// @failure 403 "Permission denied to access settings"
|
||||||
|
// @failure 500 "Server error"
|
||||||
|
// @failure 500 "Bad gateway"
|
||||||
|
// @router /fdo/profiles [get]
|
||||||
|
func (handler *Handler) fdoProfileList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
|
||||||
|
profiles, err := handler.DataStore.FDOProfile().FDOProfiles()
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.JSON(w, profiles)
|
||||||
|
}
|
67
api/http/handler/hostmanagement/fdo/profile_update.go
Normal file
67
api/http/handler/hostmanagement/fdo/profile_update.go
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
package fdo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
httperror "github.com/portainer/libhttp/error"
|
||||||
|
"github.com/portainer/libhttp/request"
|
||||||
|
"github.com/portainer/libhttp/response"
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// @id updateProfile
|
||||||
|
// @summary updates an existing FDO Profile
|
||||||
|
// @description updates an existing FDO Profile
|
||||||
|
// @description **Access policy**: administrator
|
||||||
|
// @tags intel
|
||||||
|
// @security jwt
|
||||||
|
// @produce json
|
||||||
|
// @success 200 "Success"
|
||||||
|
// @failure 400 "Invalid request"
|
||||||
|
// @failure 409 "Profile name already exists"
|
||||||
|
// @failure 500 "Server error"
|
||||||
|
// @router /fdo/profiles/{id} [put]
|
||||||
|
func (handler *Handler) updateProfile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
id, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Bad request", errors.New("missing 'id' query parameter")}
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload createProfileFromFileContentPayload
|
||||||
|
err = request.DecodeAndValidateJSONPayload(r, &payload)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
profile, err := handler.DataStore.FDOProfile().FDOProfile(portainer.FDOProfileID(id))
|
||||||
|
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||||
|
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a FDO Profile with the specified identifier inside the database", err}
|
||||||
|
} else if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a FDO Profile with the specified identifier inside the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
isUnique, err := handler.checkUniqueProfileName(payload.Name, id)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err}
|
||||||
|
}
|
||||||
|
if !isUnique {
|
||||||
|
return &httperror.HandlerError{http.StatusConflict, fmt.Sprintf("A profile with the name '%s' already exists", payload.Name), errors.New("a profile already exists with this name")}
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath, err := handler.FileService.StoreFDOProfileFileFromBytes(strconv.Itoa(int(profile.ID)), []byte(payload.ProfileFileContent))
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update profile", err}
|
||||||
|
}
|
||||||
|
profile.FilePath = filePath
|
||||||
|
profile.Name = payload.Name
|
||||||
|
|
||||||
|
err = handler.DataStore.FDOProfile().Update(profile.ID, profile)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update profile", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.JSON(w, profile)
|
||||||
|
}
|
49
api/http/handler/hostmanagement/fdo/register.go
Normal file
49
api/http/handler/hostmanagement/fdo/register.go
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
package fdo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
httperror "github.com/portainer/libhttp/error"
|
||||||
|
"github.com/portainer/libhttp/request"
|
||||||
|
"github.com/portainer/libhttp/response"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
type registerDeviceResponse struct {
|
||||||
|
Guid string `json:"guid" example:"c6ea3343-229a-4c07-9096-beef7134e1d3"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// @id fdoRegisterDevice
|
||||||
|
// @summary register an FDO device
|
||||||
|
// @description register an FDO device
|
||||||
|
// @description **Access policy**: administrator
|
||||||
|
// @tags intel
|
||||||
|
// @security jwt
|
||||||
|
// @produce json
|
||||||
|
// @success 200 "Success"
|
||||||
|
// @failure 400 "Invalid request"
|
||||||
|
// @failure 403 "Permission denied to access settings"
|
||||||
|
// @failure 500 "Server error"
|
||||||
|
// @router /fdo/register [post]
|
||||||
|
func (handler *Handler) fdoRegisterDevice(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
// Post a voucher
|
||||||
|
ov, filename, err := request.RetrieveMultiPartFormFile(r, "voucher")
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithField("filename", filename).WithError(err).Info("fdoRegisterDevice: readVoucher()")
|
||||||
|
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoRegisterDevice: read Voucher()", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
fdoClient, err := handler.newFDOClient()
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithError(err).Info("fdoRegisterDevice: newFDOClient()")
|
||||||
|
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoRegisterDevice: newFDOClient()", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
guid, err := fdoClient.PostVoucher(ov)
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithError(err).Info("fdoRegisterDevice: PostVoucher()")
|
||||||
|
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoRegisterDevice: PostVoucher()", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.JSON(w, registerDeviceResponse{guid})
|
||||||
|
}
|
16
api/http/handler/hostmanagement/fdo/utils.go
Normal file
16
api/http/handler/hostmanagement/fdo/utils.go
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
package fdo
|
||||||
|
|
||||||
|
func (handler *Handler) checkUniqueProfileName(name string, id int) (bool, error) {
|
||||||
|
profiles, err := handler.DataStore.FDOProfile().FDOProfiles()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, profile := range profiles {
|
||||||
|
if profile.Name == name && (id == -1 || id != int(profile.ID)) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
73
api/http/handler/hostmanagement/openamt/amtactivation.go
Normal file
73
api/http/handler/hostmanagement/openamt/amtactivation.go
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
package openamt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
httperror "github.com/portainer/libhttp/error"
|
||||||
|
"github.com/portainer/libhttp/request"
|
||||||
|
"github.com/portainer/libhttp/response"
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
bolterrors "github.com/portainer/portainer/api/dataservices/errors"
|
||||||
|
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// @id openAMTActivate
|
||||||
|
// @summary Activate OpenAMT device and associate to agent endpoint
|
||||||
|
// @description Activate OpenAMT device and associate to agent endpoint
|
||||||
|
// @description **Access policy**: administrator
|
||||||
|
// @tags intel
|
||||||
|
// @security jwt
|
||||||
|
// @produce json
|
||||||
|
// @param id path int true "Environment(Endpoint) identifier"
|
||||||
|
// @success 200 "Success"
|
||||||
|
// @failure 400 "Invalid request"
|
||||||
|
// @failure 403 "Permission denied to access settings"
|
||||||
|
// @failure 500 "Server error"
|
||||||
|
// @router /open_amt/{id}/activate [post]
|
||||||
|
func (handler *Handler) openAMTActivate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment identifier route variable", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
||||||
|
if err == bolterrors.ErrObjectNotFound {
|
||||||
|
return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err}
|
||||||
|
} else if err != nil {
|
||||||
|
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err}
|
||||||
|
} else if !endpointutils.IsAgentEndpoint(endpoint) {
|
||||||
|
errMsg := fmt.Sprintf("%s is not an agent environment", endpoint.Name)
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, errMsg, errors.New(errMsg)}
|
||||||
|
}
|
||||||
|
|
||||||
|
settings, err := handler.DataStore.Settings().Settings()
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = handler.activateDevice(endpoint, *settings)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to activate device", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
hostInfo, _, err := handler.getEndpointAMTInfo(endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve AMT information", Err: err}
|
||||||
|
}
|
||||||
|
if hostInfo.ControlModeRaw < 1 {
|
||||||
|
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Failed to activate device", Err: errors.New("failed to activate device")}
|
||||||
|
}
|
||||||
|
if hostInfo.UUID == "" {
|
||||||
|
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve device UUID", Err: errors.New("unable to retrieve device UUID")}
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint.AMTDeviceGUID = hostInfo.UUID
|
||||||
|
err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist environment changes inside the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Empty(w)
|
||||||
|
}
|
|
@ -16,23 +16,19 @@ import (
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
type openAMTConfigureDefaultPayload struct {
|
type openAMTConfigurePayload struct {
|
||||||
EnableOpenAMT bool
|
Enabled bool
|
||||||
MPSServer string
|
MPSServer string
|
||||||
MPSUser string
|
MPSUser string
|
||||||
MPSPassword string
|
MPSPassword string
|
||||||
CertFileText string
|
DomainName string
|
||||||
CertPassword string
|
CertFileName string
|
||||||
DomainName string
|
CertFileContent string
|
||||||
UseWirelessConfig bool
|
CertFilePassword string
|
||||||
WifiAuthenticationMethod string
|
|
||||||
WifiEncryptionMethod string
|
|
||||||
WifiSSID string
|
|
||||||
WifiPskPass string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (payload *openAMTConfigureDefaultPayload) Validate(r *http.Request) error {
|
func (payload *openAMTConfigurePayload) Validate(r *http.Request) error {
|
||||||
if payload.EnableOpenAMT {
|
if payload.Enabled {
|
||||||
if payload.MPSServer == "" {
|
if payload.MPSServer == "" {
|
||||||
return errors.New("MPS Server must be provided")
|
return errors.New("MPS Server must be provided")
|
||||||
}
|
}
|
||||||
|
@ -45,32 +41,21 @@ func (payload *openAMTConfigureDefaultPayload) Validate(r *http.Request) error {
|
||||||
if payload.DomainName == "" {
|
if payload.DomainName == "" {
|
||||||
return errors.New("domain name must be provided")
|
return errors.New("domain name must be provided")
|
||||||
}
|
}
|
||||||
if payload.CertFileText == "" {
|
if payload.CertFileContent == "" {
|
||||||
return errors.New("certificate file must be provided")
|
return errors.New("certificate file must be provided")
|
||||||
}
|
}
|
||||||
if payload.CertPassword == "" {
|
if payload.CertFileName == "" {
|
||||||
return errors.New("certificate password must be provided")
|
return errors.New("certificate file name must be provided")
|
||||||
}
|
}
|
||||||
if payload.UseWirelessConfig {
|
if payload.CertFilePassword == "" {
|
||||||
if payload.WifiAuthenticationMethod == "" {
|
return errors.New("certificate password must be provided")
|
||||||
return errors.New("wireless authentication method must be provided")
|
|
||||||
}
|
|
||||||
if payload.WifiEncryptionMethod == "" {
|
|
||||||
return errors.New("wireless encryption method must be provided")
|
|
||||||
}
|
|
||||||
if payload.WifiSSID == "" {
|
|
||||||
return errors.New("wireless config SSID must be provided")
|
|
||||||
}
|
|
||||||
if payload.WifiPskPass == "" {
|
|
||||||
return errors.New("wireless config PSK passphrase must be provided")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// @id OpenAMTConfigureDefault
|
// @id OpenAMTConfigure
|
||||||
// @summary Enable Portainer's OpenAMT capabilities
|
// @summary Enable Portainer's OpenAMT capabilities
|
||||||
// @description Enable Portainer's OpenAMT capabilities
|
// @description Enable Portainer's OpenAMT capabilities
|
||||||
// @description **Access policy**: administrator
|
// @description **Access policy**: administrator
|
||||||
|
@ -78,22 +63,22 @@ func (payload *openAMTConfigureDefaultPayload) Validate(r *http.Request) error {
|
||||||
// @security jwt
|
// @security jwt
|
||||||
// @accept json
|
// @accept json
|
||||||
// @produce json
|
// @produce json
|
||||||
// @param body body openAMTConfigureDefaultPayload true "OpenAMT Settings"
|
// @param body body openAMTConfigurePayload true "OpenAMT Settings"
|
||||||
// @success 204 "Success"
|
// @success 204 "Success"
|
||||||
// @failure 400 "Invalid request"
|
// @failure 400 "Invalid request"
|
||||||
// @failure 403 "Permission denied to access settings"
|
// @failure 403 "Permission denied to access settings"
|
||||||
// @failure 500 "Server error"
|
// @failure 500 "Server error"
|
||||||
// @router /open_amt [post]
|
// @router /open_amt [post]
|
||||||
func (handler *Handler) openAMTConfigureDefault(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
func (handler *Handler) openAMTConfigure(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
var payload openAMTConfigureDefaultPayload
|
var payload openAMTConfigurePayload
|
||||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.WithError(err).Error("Invalid request payload")
|
logrus.WithError(err).Error("Invalid request payload")
|
||||||
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
|
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.EnableOpenAMT {
|
if payload.Enabled {
|
||||||
certificateErr := validateCertificate(payload.CertFileText, payload.CertPassword)
|
certificateErr := validateCertificate(payload.CertFileContent, payload.CertFilePassword)
|
||||||
if certificateErr != nil {
|
if certificateErr != nil {
|
||||||
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Error validating certificate", Err: certificateErr}
|
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Error validating certificate", Err: certificateErr}
|
||||||
}
|
}
|
||||||
|
@ -143,31 +128,19 @@ func isValidIssuer(issuer string) bool {
|
||||||
strings.Contains(formattedIssuer, "godaddy")
|
strings.Contains(formattedIssuer, "godaddy")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler *Handler) enableOpenAMT(configurationPayload openAMTConfigureDefaultPayload) error {
|
func (handler *Handler) enableOpenAMT(configurationPayload openAMTConfigurePayload) error {
|
||||||
configuration := portainer.OpenAMTConfiguration{
|
configuration := portainer.OpenAMTConfiguration{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
MPSServer: configurationPayload.MPSServer,
|
MPSServer: configurationPayload.MPSServer,
|
||||||
Credentials: portainer.MPSCredentials{
|
MPSUser: configurationPayload.MPSUser,
|
||||||
MPSUser: configurationPayload.MPSUser,
|
MPSPassword: configurationPayload.MPSPassword,
|
||||||
MPSPassword: configurationPayload.MPSPassword,
|
CertFileContent: configurationPayload.CertFileContent,
|
||||||
},
|
CertFileName: configurationPayload.CertFileName,
|
||||||
DomainConfiguration: portainer.DomainConfiguration{
|
CertFilePassword: configurationPayload.CertFilePassword,
|
||||||
CertFileText: configurationPayload.CertFileText,
|
DomainName: configurationPayload.DomainName,
|
||||||
CertPassword: configurationPayload.CertPassword,
|
|
||||||
DomainName: configurationPayload.DomainName,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if configurationPayload.UseWirelessConfig {
|
err := handler.OpenAMTService.Configure(configuration)
|
||||||
configuration.WirelessConfiguration = &portainer.WirelessConfiguration{
|
|
||||||
AuthenticationMethod: configurationPayload.WifiAuthenticationMethod,
|
|
||||||
EncryptionMethod: configurationPayload.WifiEncryptionMethod,
|
|
||||||
SSID: configurationPayload.WifiSSID,
|
|
||||||
PskPass: configurationPayload.WifiPskPass,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err := handler.OpenAMTService.ConfigureDefault(configuration)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.WithError(err).Error("error configuring OpenAMT server")
|
logrus.WithError(err).Error("error configuring OpenAMT server")
|
||||||
return err
|
return err
|
||||||
|
@ -189,7 +162,7 @@ func (handler *Handler) saveConfiguration(configuration portainer.OpenAMTConfigu
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
configuration.Credentials.MPSToken = ""
|
configuration.MPSToken = ""
|
||||||
|
|
||||||
settings.OpenAMTConfiguration = configuration
|
settings.OpenAMTConfiguration = configuration
|
||||||
err = handler.DataStore.Settings().UpdateSettings(settings)
|
err = handler.DataStore.Settings().UpdateSettings(settings)
|
178
api/http/handler/hostmanagement/openamt/amtdevices.go
Normal file
178
api/http/handler/hostmanagement/openamt/amtdevices.go
Normal file
|
@ -0,0 +1,178 @@
|
||||||
|
package openamt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
httperror "github.com/portainer/libhttp/error"
|
||||||
|
"github.com/portainer/libhttp/request"
|
||||||
|
"github.com/portainer/libhttp/response"
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
bolterrors "github.com/portainer/portainer/api/dataservices/errors"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// @id OpenAMTDevices
|
||||||
|
// @summary Fetch OpenAMT managed devices information for endpoint
|
||||||
|
// @description Fetch OpenAMT managed devices information for endpoint
|
||||||
|
// @description **Access policy**: administrator
|
||||||
|
// @tags intel
|
||||||
|
// @security jwt
|
||||||
|
// @produce json
|
||||||
|
// @param id path int true "Environment(Endpoint) identifier"
|
||||||
|
// @success 200 "Success"
|
||||||
|
// @failure 400 "Invalid request"
|
||||||
|
// @failure 403 "Permission denied to access settings"
|
||||||
|
// @failure 500 "Server error"
|
||||||
|
// @router /open_amt/{id}/devices [get]
|
||||||
|
func (handler *Handler) openAMTDevices(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment identifier route variable", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
||||||
|
if err == bolterrors.ErrObjectNotFound {
|
||||||
|
return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err}
|
||||||
|
} else if err != nil {
|
||||||
|
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
if endpoint.AMTDeviceGUID == "" {
|
||||||
|
return response.JSON(w, []portainer.OpenAMTDeviceInformation{})
|
||||||
|
}
|
||||||
|
|
||||||
|
settings, err := handler.DataStore.Settings().Settings()
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
device, err := handler.OpenAMTService.DeviceInformation(settings.OpenAMTConfiguration, endpoint.AMTDeviceGUID)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve device information", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
devices := []portainer.OpenAMTDeviceInformation{
|
||||||
|
*device,
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.JSON(w, devices)
|
||||||
|
}
|
||||||
|
|
||||||
|
type deviceActionPayload struct {
|
||||||
|
Action string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (payload *deviceActionPayload) Validate(r *http.Request) error {
|
||||||
|
if payload.Action == "" {
|
||||||
|
return errors.New("device action must be provided")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// @id DeviceAction
|
||||||
|
// @summary Execute out of band action on an AMT managed device
|
||||||
|
// @description Execute out of band action on an AMT managed device
|
||||||
|
// @description **Access policy**: administrator
|
||||||
|
// @tags intel
|
||||||
|
// @security jwt
|
||||||
|
// @accept json
|
||||||
|
// @produce json
|
||||||
|
// @param body body deviceActionPayload true "Device Action"
|
||||||
|
// @success 204 "Success"
|
||||||
|
// @failure 400 "Invalid request"
|
||||||
|
// @failure 403 "Permission denied to access settings"
|
||||||
|
// @failure 500 "Server error"
|
||||||
|
// @router /open_amt/{id}/devices/{deviceId}/action [post]
|
||||||
|
func (handler *Handler) deviceAction(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
deviceID, err := request.RetrieveRouteVariableValue(r, "deviceId")
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid device identifier route variable", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload deviceActionPayload
|
||||||
|
err = request.DecodeAndValidateJSONPayload(r, &payload)
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithError(err).Error("Invalid request payload")
|
||||||
|
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
settings, err := handler.DataStore.Settings().Settings()
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = handler.OpenAMTService.ExecuteDeviceAction(settings.OpenAMTConfiguration, deviceID, payload.Action)
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithError(err).Error("Error executing device action")
|
||||||
|
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Error executing device action", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Empty(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
type deviceFeaturesPayload struct {
|
||||||
|
Features portainer.OpenAMTDeviceEnabledFeatures
|
||||||
|
}
|
||||||
|
|
||||||
|
func (payload *deviceFeaturesPayload) Validate(r *http.Request) error {
|
||||||
|
if payload.Features.UserConsent == "" {
|
||||||
|
return errors.New("device user consent status must be provided")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthorizationResponse struct {
|
||||||
|
Server string
|
||||||
|
Token string
|
||||||
|
}
|
||||||
|
|
||||||
|
// @id DeviceFeatures
|
||||||
|
// @summary Enable features on an AMT managed device
|
||||||
|
// @description Enable features on an AMT managed device
|
||||||
|
// @description **Access policy**: administrator
|
||||||
|
// @tags intel
|
||||||
|
// @security jwt
|
||||||
|
// @accept json
|
||||||
|
// @produce json
|
||||||
|
// @param body body deviceFeaturesPayload true "Device Features"
|
||||||
|
// @success 204 "Success"
|
||||||
|
// @failure 400 "Invalid request"
|
||||||
|
// @failure 403 "Permission denied to access settings"
|
||||||
|
// @failure 500 "Server error"
|
||||||
|
// @router /open_amt/{id}/devices_features/{deviceId} [post]
|
||||||
|
func (handler *Handler) deviceFeatures(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
deviceID, err := request.RetrieveRouteVariableValue(r, "deviceId")
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid device identifier route variable", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload deviceFeaturesPayload
|
||||||
|
err = request.DecodeAndValidateJSONPayload(r, &payload)
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithError(err).Error("Invalid request payload")
|
||||||
|
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
settings, err := handler.DataStore.Settings().Settings()
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = handler.OpenAMTService.DeviceInformation(settings.OpenAMTConfiguration, deviceID)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve device information", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := handler.OpenAMTService.EnableDeviceFeatures(settings.OpenAMTConfiguration, deviceID, payload.Features)
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithError(err).Error("Error executing device action")
|
||||||
|
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Error executing device action", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
authorizationResponse := AuthorizationResponse{
|
||||||
|
Server: settings.OpenAMTConfiguration.MPSServer,
|
||||||
|
Token: token,
|
||||||
|
}
|
||||||
|
return response.JSON(w, authorizationResponse)
|
||||||
|
}
|
278
api/http/handler/hostmanagement/openamt/amtrpc.go
Normal file
278
api/http/handler/hostmanagement/openamt/amtrpc.go
Normal file
|
@ -0,0 +1,278 @@
|
||||||
|
package openamt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"github.com/portainer/portainer/api/hostmanagement/openamt"
|
||||||
|
"io/ioutil"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types"
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
|
"github.com/docker/docker/api/types/filters"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types/network"
|
||||||
|
"github.com/docker/docker/client"
|
||||||
|
httperror "github.com/portainer/libhttp/error"
|
||||||
|
"github.com/portainer/libhttp/request"
|
||||||
|
"github.com/portainer/libhttp/response"
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
bolterrors "github.com/portainer/portainer/api/dataservices/errors"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HostInfo struct {
|
||||||
|
EndpointID portainer.EndpointID `json:"EndpointID"`
|
||||||
|
RawOutput string `json:"RawOutput"`
|
||||||
|
AMT string `json:"AMT"`
|
||||||
|
UUID string `json:"UUID"`
|
||||||
|
DNSSuffix string `json:"DNS Suffix"`
|
||||||
|
BuildNumber string `json:"Build Number"`
|
||||||
|
ControlMode string `json:"Control Mode"`
|
||||||
|
ControlModeRaw int `json:"Control Mode (Raw)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// TODO: this should get extracted to some configurable - don't assume Docker Hub is everyone's global namespace, or that they're allowed to pull images from the internet
|
||||||
|
rpcGoImageName = "ptrrd/openamt:rpc-go-json"
|
||||||
|
rpcGoContainerName = "openamt-rpc-go"
|
||||||
|
dockerClientTimeout = 5 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// @id OpenAMTHostInfo
|
||||||
|
// @summary Request OpenAMT info from a node
|
||||||
|
// @description Request OpenAMT info from a node
|
||||||
|
// @description **Access policy**: administrator
|
||||||
|
// @tags intel
|
||||||
|
// @security jwt
|
||||||
|
// @produce json
|
||||||
|
// @success 200 "Success"
|
||||||
|
// @failure 400 "Invalid request"
|
||||||
|
// @failure 403 "Permission denied to access settings"
|
||||||
|
// @failure 500 "Server error"
|
||||||
|
// @router /open_amt/{id}/info [get]
|
||||||
|
func (handler *Handler) openAMTHostInfo(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment identifier route variable", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
logrus.WithField("endpointID", endpointID).Info("OpenAMTHostInfo")
|
||||||
|
|
||||||
|
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
||||||
|
if err == bolterrors.ErrObjectNotFound {
|
||||||
|
return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err}
|
||||||
|
} else if err != nil {
|
||||||
|
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
amtInfo, output, err := handler.getEndpointAMTInfo(endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: output, Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.JSON(w, amtInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *Handler) getEndpointAMTInfo(endpoint *portainer.Endpoint) (*HostInfo, string, error) {
|
||||||
|
ctx := context.TODO()
|
||||||
|
|
||||||
|
// pull the image so we can check if there's a new one
|
||||||
|
// TODO: these should be able to be over-ridden (don't hardcode the assumption that secure users can access Docker Hub, or that its even the orchestrator's "global namespace")
|
||||||
|
cmdLine := []string{"amtinfo", "--json"}
|
||||||
|
output, err := handler.PullAndRunContainer(ctx, endpoint, rpcGoImageName, rpcGoContainerName, cmdLine)
|
||||||
|
if err != nil {
|
||||||
|
return nil, output, err
|
||||||
|
}
|
||||||
|
|
||||||
|
amtInfo := HostInfo{}
|
||||||
|
_ = json.Unmarshal([]byte(output), &amtInfo)
|
||||||
|
|
||||||
|
amtInfo.EndpointID = endpoint.ID
|
||||||
|
amtInfo.RawOutput = output
|
||||||
|
|
||||||
|
return &amtInfo, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *Handler) PullAndRunContainer(ctx context.Context, endpoint *portainer.Endpoint, imageName, containerName string, cmdLine []string) (output string, err error) {
|
||||||
|
// TODO: this should not be Docker specific
|
||||||
|
// TODO: extract from this Handler into something global.
|
||||||
|
|
||||||
|
// TODO: start
|
||||||
|
// docker run --rm -it --privileged ptrrd/openamt:rpc-go amtinfo
|
||||||
|
// on the Docker standalone node (one per env :)
|
||||||
|
// and later, on the specified node in the swarm, or kube.
|
||||||
|
nodeName := ""
|
||||||
|
timeout := dockerClientTimeout
|
||||||
|
docker, err := handler.DockerClientFactory.CreateClient(endpoint, nodeName, &timeout)
|
||||||
|
if err != nil {
|
||||||
|
return "Unable to create Docker Client connection", err
|
||||||
|
}
|
||||||
|
defer docker.Close()
|
||||||
|
|
||||||
|
if err := pullImage(ctx, docker, imageName); err != nil {
|
||||||
|
return "Could not pull image from registry", err
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err = runContainer(ctx, docker, imageName, containerName, cmdLine)
|
||||||
|
if err != nil {
|
||||||
|
return "Could not run container", err
|
||||||
|
}
|
||||||
|
return output, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: ideally, pullImage and runContainer will become a simple version of the use compose abstraction that can be called from withing Portainer.
|
||||||
|
// TODO: the idea being that if we have an internal struct of a parsed compose file, we can also populate that struct programmatically, and run it to get the result I'm getting here.
|
||||||
|
// TODO: likely an upgrade and abstraction of DeployComposeStack/DeploySwarmStack/DeployKubernetesStack
|
||||||
|
// pullImage will pull the image to the specified environment
|
||||||
|
// TODO: add k8s implementation
|
||||||
|
// TODO: work out registry auth
|
||||||
|
func pullImage(ctx context.Context, docker *client.Client, imageName string) error {
|
||||||
|
out, err := docker.ImagePull(ctx, imageName, types.ImagePullOptions{})
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithError(err).WithField("imageName", imageName).Error("Could not pull image from registry")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer out.Close()
|
||||||
|
outputBytes, err := ioutil.ReadAll(out)
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithError(err).WithField("imageName", imageName).Error("Could not read image pull output")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("%s imaged pulled with output:\n%s", imageName, string(outputBytes))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: ideally, pullImage and runContainer will become a simple version of the use compose abstraction that can be called from withing Portainer.
|
||||||
|
// runContainer should be used to run a short command that returns information to stdout
|
||||||
|
// TODO: add k8s support
|
||||||
|
func runContainer(ctx context.Context, docker *client.Client, imageName, containerName string, cmdLine []string) (output string, err error) {
|
||||||
|
opts := types.ContainerListOptions{All: true}
|
||||||
|
opts.Filters = filters.NewArgs()
|
||||||
|
opts.Filters.Add("name", containerName)
|
||||||
|
existingContainers, err := docker.ContainerList(ctx, opts)
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithError(err).WithField("imagename", imageName).WithField("containername", containerName).Error("listing existing container")
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(existingContainers) > 0 {
|
||||||
|
err = docker.ContainerRemove(ctx, existingContainers[0].ID, types.ContainerRemoveOptions{Force: true})
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithError(err).WithField("imagename", imageName).WithField("containername", containerName).Error("removing existing container")
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
created, err := docker.ContainerCreate(
|
||||||
|
ctx,
|
||||||
|
&container.Config{
|
||||||
|
Image: imageName,
|
||||||
|
Cmd: cmdLine,
|
||||||
|
Env: []string{},
|
||||||
|
Tty: true,
|
||||||
|
OpenStdin: true,
|
||||||
|
AttachStdout: true,
|
||||||
|
AttachStderr: true,
|
||||||
|
},
|
||||||
|
&container.HostConfig{
|
||||||
|
Privileged: true,
|
||||||
|
},
|
||||||
|
&network.NetworkingConfig{},
|
||||||
|
nil,
|
||||||
|
containerName,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithError(err).WithField("imagename", imageName).WithField("containername", containerName).Error("creating container")
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
err = docker.ContainerStart(ctx, created.ID, types.ContainerStartOptions{})
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithError(err).WithField("imagename", imageName).WithField("containername", containerName).Error("starting container")
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("%s container created and started\n", containerName)
|
||||||
|
|
||||||
|
statusCh, errCh := docker.ContainerWait(ctx, created.ID, container.WaitConditionNotRunning)
|
||||||
|
var statusCode int64
|
||||||
|
select {
|
||||||
|
case err := <-errCh:
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithError(err).WithField("imagename", imageName).WithField("containername", containerName).Error("starting container")
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
case status := <-statusCh:
|
||||||
|
statusCode = status.StatusCode
|
||||||
|
}
|
||||||
|
logrus.WithField("status", statusCode).Debug("container wait status")
|
||||||
|
|
||||||
|
out, err := docker.ContainerLogs(ctx, created.ID, types.ContainerLogsOptions{ShowStdout: true})
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithError(err).WithField("imagename", imageName).WithField("containername", containerName).Error("getting container log")
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = docker.ContainerRemove(ctx, created.ID, types.ContainerRemoveOptions{})
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithError(err).WithField("imagename", imageName).WithField("containername", containerName).Error("removing container")
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
outputBytes, err := ioutil.ReadAll(out)
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithError(err).WithField("imagename", imageName).WithField("containername", containerName).Error("read container output")
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("%s container finished with output:\n%s", containerName, string(outputBytes))
|
||||||
|
|
||||||
|
return string(outputBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *Handler) activateDevice(endpoint *portainer.Endpoint, settings portainer.Settings) error {
|
||||||
|
ctx := context.TODO()
|
||||||
|
|
||||||
|
config := settings.OpenAMTConfiguration
|
||||||
|
cmdLine := []string{
|
||||||
|
"activate",
|
||||||
|
"-n",
|
||||||
|
"-v",
|
||||||
|
"-u", fmt.Sprintf("wss://%s/activate", config.MPSServer),
|
||||||
|
"-profile", openamt.DefaultProfileName,
|
||||||
|
"-d", config.DomainName,
|
||||||
|
"-password", config.MPSPassword,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := handler.PullAndRunContainer(ctx, endpoint, rpcGoImageName, rpcGoContainerName, cmdLine)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *Handler) deactivateDevice(endpoint *portainer.Endpoint, settings portainer.Settings) error {
|
||||||
|
ctx := context.TODO()
|
||||||
|
|
||||||
|
config := settings.OpenAMTConfiguration
|
||||||
|
cmdLine := []string{
|
||||||
|
"deactivate",
|
||||||
|
"-n",
|
||||||
|
"-v",
|
||||||
|
"-u", fmt.Sprintf("wss://%s/activate", config.MPSServer),
|
||||||
|
"-password", config.MPSPassword,
|
||||||
|
}
|
||||||
|
_, err := handler.PullAndRunContainer(ctx, endpoint, rpcGoImageName, rpcGoContainerName, cmdLine)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -8,27 +8,30 @@ import (
|
||||||
httperror "github.com/portainer/libhttp/error"
|
httperror "github.com/portainer/libhttp/error"
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
|
"github.com/portainer/portainer/api/docker"
|
||||||
"github.com/portainer/portainer/api/http/security"
|
"github.com/portainer/portainer/api/http/security"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler is the HTTP handler used to handle OpenAMT operations.
|
// Handler is the HTTP handler used to handle OpenAMT operations.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
*mux.Router
|
*mux.Router
|
||||||
OpenAMTService portainer.OpenAMTService
|
OpenAMTService portainer.OpenAMTService
|
||||||
DataStore dataservices.DataStore
|
DataStore dataservices.DataStore
|
||||||
|
DockerClientFactory *docker.ClientFactory
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler returns a new Handler
|
// NewHandler returns a new Handler
|
||||||
func NewHandler(bouncer *security.RequestBouncer, dataStore dataservices.DataStore) (*Handler, error) {
|
func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
||||||
if !dataStore.Settings().IsFeatureFlagEnabled(portainer.FeatOpenAMT) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
h := &Handler{
|
h := &Handler{
|
||||||
Router: mux.NewRouter(),
|
Router: mux.NewRouter(),
|
||||||
}
|
}
|
||||||
|
|
||||||
h.Handle("/open_amt", bouncer.AdminAccess(httperror.LoggerHandler(h.openAMTConfigureDefault))).Methods(http.MethodPost)
|
h.Handle("/open_amt/configure", bouncer.AdminAccess(httperror.LoggerHandler(h.openAMTConfigure))).Methods(http.MethodPost)
|
||||||
|
h.Handle("/open_amt/{id}/info", bouncer.AdminAccess(httperror.LoggerHandler(h.openAMTHostInfo))).Methods(http.MethodGet)
|
||||||
|
h.Handle("/open_amt/{id}/activate", bouncer.AdminAccess(httperror.LoggerHandler(h.openAMTActivate))).Methods(http.MethodPost)
|
||||||
|
h.Handle("/open_amt/{id}/devices", bouncer.AdminAccess(httperror.LoggerHandler(h.openAMTDevices))).Methods(http.MethodGet)
|
||||||
|
h.Handle("/open_amt/{id}/devices/{deviceId}/action", bouncer.AdminAccess(httperror.LoggerHandler(h.deviceAction))).Methods(http.MethodPost)
|
||||||
|
h.Handle("/open_amt/{id}/devices/{deviceId}/features", bouncer.AdminAccess(httperror.LoggerHandler(h.deviceFeatures))).Methods(http.MethodPost)
|
||||||
|
|
||||||
return h, nil
|
return h
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,6 +42,10 @@ type settingsUpdatePayload struct {
|
||||||
HelmRepositoryURL *string `example:"https://charts.bitnami.com/bitnami"`
|
HelmRepositoryURL *string `example:"https://charts.bitnami.com/bitnami"`
|
||||||
// Kubectl Shell Image
|
// Kubectl Shell Image
|
||||||
KubectlShellImage *string `example:"portainer/kubectl-shell:latest"`
|
KubectlShellImage *string `example:"portainer/kubectl-shell:latest"`
|
||||||
|
// DisableTrustOnFirstConnect makes Portainer require explicit user trust of the edge agent before accepting the connection
|
||||||
|
DisableTrustOnFirstConnect *bool `example:"false"`
|
||||||
|
// EnforceEdgeID makes Portainer store the Edge ID instead of accepting anyone
|
||||||
|
EnforceEdgeID *bool `example:"false"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (payload *settingsUpdatePayload) Validate(r *http.Request) error {
|
func (payload *settingsUpdatePayload) Validate(r *http.Request) error {
|
||||||
|
@ -162,6 +166,14 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) *
|
||||||
settings.EnableEdgeComputeFeatures = *payload.EnableEdgeComputeFeatures
|
settings.EnableEdgeComputeFeatures = *payload.EnableEdgeComputeFeatures
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if payload.DisableTrustOnFirstConnect != nil {
|
||||||
|
settings.DisableTrustOnFirstConnect = *payload.DisableTrustOnFirstConnect
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.EnforceEdgeID != nil {
|
||||||
|
settings.EnforceEdgeID = *payload.EnforceEdgeID
|
||||||
|
}
|
||||||
|
|
||||||
if payload.SnapshotInterval != nil && *payload.SnapshotInterval != settings.SnapshotInterval {
|
if payload.SnapshotInterval != nil && *payload.SnapshotInterval != settings.SnapshotInterval {
|
||||||
err := handler.updateSnapshotInterval(settings, *payload.SnapshotInterval)
|
err := handler.updateSnapshotInterval(settings, *payload.SnapshotInterval)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -154,7 +154,7 @@ func (handler *Handler) checkUniqueStackNameInDocker(endpoint *portainer.Endpoin
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
dockerClient, err := handler.DockerClientFactory.CreateClient(endpoint, "")
|
dockerClient, err := handler.DockerClientFactory.CreateClient(endpoint, "", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
|
@ -68,7 +68,7 @@ func (handler *Handler) executeServiceWebhook(
|
||||||
registryID portainer.RegistryID,
|
registryID portainer.RegistryID,
|
||||||
imageTag string,
|
imageTag string,
|
||||||
) *httperror.HandlerError {
|
) *httperror.HandlerError {
|
||||||
dockerClient, err := handler.DockerClientFactory.CreateClient(endpoint, "")
|
dockerClient, err := handler.DockerClientFactory.CreateClient(endpoint, "", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Error creating docker client", err}
|
return &httperror.HandlerError{http.StatusInternalServerError, "Error creating docker client", err}
|
||||||
}
|
}
|
||||||
|
|
|
@ -122,7 +122,7 @@ func (transport *Transport) createPrivateResourceControl(resourceIdentifier stri
|
||||||
}
|
}
|
||||||
|
|
||||||
func (transport *Transport) getInheritedResourceControlFromServiceOrStack(resourceIdentifier, nodeName string, resourceType portainer.ResourceControlType, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) {
|
func (transport *Transport) getInheritedResourceControlFromServiceOrStack(resourceIdentifier, nodeName string, resourceType portainer.ResourceControlType, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) {
|
||||||
client, err := transport.dockerClientFactory.CreateClient(transport.endpoint, nodeName)
|
client, err := transport.dockerClientFactory.CreateClient(transport.endpoint, nodeName, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
@ -133,7 +133,7 @@ func (transport *Transport) decorateVolumeResourceCreationOperation(request *htt
|
||||||
|
|
||||||
if volumeID != "" {
|
if volumeID != "" {
|
||||||
agentTargetHeader := request.Header.Get(portainer.PortainerAgentTargetHeader)
|
agentTargetHeader := request.Header.Get(portainer.PortainerAgentTargetHeader)
|
||||||
cli, err := transport.dockerClientFactory.CreateClient(transport.endpoint, agentTargetHeader)
|
cli, err := transport.dockerClientFactory.CreateClient(transport.endpoint, agentTargetHeader, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -219,7 +219,7 @@ func (transport *Transport) getDockerID() (string, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := transport.dockerClientFactory.CreateClient(transport.endpoint, "")
|
client, err := transport.dockerClientFactory.CreateClient(transport.endpoint, "", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ package security
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
@ -134,6 +135,19 @@ func (bouncer *RequestBouncer) AuthorizedEdgeEndpointOperation(r *http.Request,
|
||||||
return errors.New("invalid Edge identifier")
|
return errors.New("invalid Edge identifier")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if endpoint.LastCheckInDate > 0 || endpoint.UserTrusted {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
settings, err := bouncer.dataStore.Settings().Settings()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not retrieve the settings: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.DisableTrustOnFirstConnect {
|
||||||
|
return errors.New("the device has not been trusted yet")
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -30,6 +30,7 @@ import (
|
||||||
"github.com/portainer/portainer/api/http/handler/endpoints"
|
"github.com/portainer/portainer/api/http/handler/endpoints"
|
||||||
"github.com/portainer/portainer/api/http/handler/file"
|
"github.com/portainer/portainer/api/http/handler/file"
|
||||||
"github.com/portainer/portainer/api/http/handler/helm"
|
"github.com/portainer/portainer/api/http/handler/helm"
|
||||||
|
"github.com/portainer/portainer/api/http/handler/hostmanagement/fdo"
|
||||||
"github.com/portainer/portainer/api/http/handler/hostmanagement/openamt"
|
"github.com/portainer/portainer/api/http/handler/hostmanagement/openamt"
|
||||||
kubehandler "github.com/portainer/portainer/api/http/handler/kubernetes"
|
kubehandler "github.com/portainer/portainer/api/http/handler/kubernetes"
|
||||||
"github.com/portainer/portainer/api/http/handler/ldap"
|
"github.com/portainer/portainer/api/http/handler/ldap"
|
||||||
|
@ -209,14 +210,12 @@ func (server *Server) Start() error {
|
||||||
var sslHandler = sslhandler.NewHandler(requestBouncer)
|
var sslHandler = sslhandler.NewHandler(requestBouncer)
|
||||||
sslHandler.SSLService = server.SSLService
|
sslHandler.SSLService = server.SSLService
|
||||||
|
|
||||||
openAMTHandler, err := openamt.NewHandler(requestBouncer, server.DataStore)
|
openAMTHandler := openamt.NewHandler(requestBouncer)
|
||||||
if err != nil {
|
openAMTHandler.OpenAMTService = server.OpenAMTService
|
||||||
return err
|
openAMTHandler.DataStore = server.DataStore
|
||||||
}
|
openAMTHandler.DockerClientFactory = server.DockerClientFactory
|
||||||
if openAMTHandler != nil {
|
|
||||||
openAMTHandler.OpenAMTService = server.OpenAMTService
|
fdoHandler := fdo.NewHandler(requestBouncer, server.DataStore, server.FileService)
|
||||||
openAMTHandler.DataStore = server.DataStore
|
|
||||||
}
|
|
||||||
|
|
||||||
var stackHandler = stacks.NewHandler(requestBouncer)
|
var stackHandler = stacks.NewHandler(requestBouncer)
|
||||||
stackHandler.DataStore = server.DataStore
|
stackHandler.DataStore = server.DataStore
|
||||||
|
@ -284,6 +283,7 @@ func (server *Server) Start() error {
|
||||||
KubernetesHandler: kubernetesHandler,
|
KubernetesHandler: kubernetesHandler,
|
||||||
MOTDHandler: motdHandler,
|
MOTDHandler: motdHandler,
|
||||||
OpenAMTHandler: openAMTHandler,
|
OpenAMTHandler: openAMTHandler,
|
||||||
|
FDOHandler: fdoHandler,
|
||||||
RegistryHandler: registryHandler,
|
RegistryHandler: registryHandler,
|
||||||
ResourceControlHandler: resourceControlHandler,
|
ResourceControlHandler: resourceControlHandler,
|
||||||
SettingsHandler: settingsHandler,
|
SettingsHandler: settingsHandler,
|
||||||
|
|
|
@ -46,6 +46,23 @@ func Test_IsKubernetesEndpoint(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Test_IsAgentEndpoint(t *testing.T) {
|
||||||
|
tests := []isEndpointTypeTest{
|
||||||
|
{endpointType: portainer.DockerEnvironment, expected: false},
|
||||||
|
{endpointType: portainer.AgentOnDockerEnvironment, expected: true},
|
||||||
|
{endpointType: portainer.AzureEnvironment, expected: false},
|
||||||
|
{endpointType: portainer.EdgeAgentOnDockerEnvironment, expected: true},
|
||||||
|
{endpointType: portainer.KubernetesLocalEnvironment, expected: false},
|
||||||
|
{endpointType: portainer.AgentOnKubernetesEnvironment, expected: true},
|
||||||
|
{endpointType: portainer.EdgeAgentOnKubernetesEnvironment, expected: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
ans := IsAgentEndpoint(&portainer.Endpoint{Type: test.endpointType})
|
||||||
|
assert.Equal(t, test.expected, ans)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func Test_FilterByExcludeIDs(t *testing.T) {
|
func Test_FilterByExcludeIDs(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
|
@ -30,6 +30,14 @@ func IsEdgeEndpoint(endpoint *portainer.Endpoint) bool {
|
||||||
return endpoint.Type == portainer.EdgeAgentOnDockerEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment
|
return endpoint.Type == portainer.EdgeAgentOnDockerEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsAgentEndpoint returns true if this is an Agent endpoint
|
||||||
|
func IsAgentEndpoint(endpoint *portainer.Endpoint) bool {
|
||||||
|
return endpoint.Type == portainer.AgentOnDockerEnvironment ||
|
||||||
|
endpoint.Type == portainer.EdgeAgentOnDockerEnvironment ||
|
||||||
|
endpoint.Type == portainer.AgentOnKubernetesEnvironment ||
|
||||||
|
endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment
|
||||||
|
}
|
||||||
|
|
||||||
// FilterByExcludeIDs receives an environment(endpoint) array and returns a filtered array using an excludeIds param
|
// FilterByExcludeIDs receives an environment(endpoint) array and returns a filtered array using an excludeIds param
|
||||||
func FilterByExcludeIDs(endpoints []portainer.Endpoint, excludeIds []portainer.EndpointID) []portainer.Endpoint {
|
func FilterByExcludeIDs(endpoints []portainer.Endpoint, excludeIds []portainer.EndpointID) []portainer.Endpoint {
|
||||||
if len(excludeIds) == 0 {
|
if len(excludeIds) == 0 {
|
||||||
|
|
|
@ -16,6 +16,7 @@ type testDatastore struct {
|
||||||
endpoint dataservices.EndpointService
|
endpoint dataservices.EndpointService
|
||||||
endpointGroup dataservices.EndpointGroupService
|
endpointGroup dataservices.EndpointGroupService
|
||||||
endpointRelation dataservices.EndpointRelationService
|
endpointRelation dataservices.EndpointRelationService
|
||||||
|
fdoProfile dataservices.FDOProfileService
|
||||||
helmUserRepository dataservices.HelmUserRepositoryService
|
helmUserRepository dataservices.HelmUserRepositoryService
|
||||||
registry dataservices.RegistryService
|
registry dataservices.RegistryService
|
||||||
resourceControl dataservices.ResourceControlService
|
resourceControl dataservices.ResourceControlService
|
||||||
|
@ -46,6 +47,9 @@ func (d *testDatastore) EdgeJob() dataservices.EdgeJobService { re
|
||||||
func (d *testDatastore) EdgeStack() dataservices.EdgeStackService { return d.edgeStack }
|
func (d *testDatastore) EdgeStack() dataservices.EdgeStackService { return d.edgeStack }
|
||||||
func (d *testDatastore) Endpoint() dataservices.EndpointService { return d.endpoint }
|
func (d *testDatastore) Endpoint() dataservices.EndpointService { return d.endpoint }
|
||||||
func (d *testDatastore) EndpointGroup() dataservices.EndpointGroupService { return d.endpointGroup }
|
func (d *testDatastore) EndpointGroup() dataservices.EndpointGroupService { return d.endpointGroup }
|
||||||
|
func (d *testDatastore) FDOProfile() dataservices.FDOProfileService {
|
||||||
|
return d.fdoProfile
|
||||||
|
}
|
||||||
func (d *testDatastore) EndpointRelation() dataservices.EndpointRelationService {
|
func (d *testDatastore) EndpointRelation() dataservices.EndpointRelationService {
|
||||||
return d.endpointRelation
|
return d.endpointRelation
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,30 +41,54 @@ type (
|
||||||
|
|
||||||
// OpenAMTConfiguration represents the credentials and configurations used to connect to an OpenAMT MPS server
|
// OpenAMTConfiguration represents the credentials and configurations used to connect to an OpenAMT MPS server
|
||||||
OpenAMTConfiguration struct {
|
OpenAMTConfiguration struct {
|
||||||
Enabled bool `json:"Enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
MPSServer string `json:"MPSServer"`
|
MPSServer string `json:"mpsServer"`
|
||||||
Credentials MPSCredentials `json:"Credentials"`
|
MPSUser string `json:"mpsUser"`
|
||||||
DomainConfiguration DomainConfiguration `json:"DomainConfiguration"`
|
MPSPassword string `json:"mpsPassword"`
|
||||||
WirelessConfiguration *WirelessConfiguration `json:"WirelessConfiguration"`
|
MPSToken string `json:"mpsToken"` // retrieved from API
|
||||||
|
CertFileName string `json:"certFileName"`
|
||||||
|
CertFileContent string `json:"certFileContent"`
|
||||||
|
CertFilePassword string `json:"certFilePassword"`
|
||||||
|
DomainName string `json:"domainName"`
|
||||||
}
|
}
|
||||||
|
|
||||||
MPSCredentials struct {
|
// OpenAMTDeviceInformation represents an AMT managed device information
|
||||||
MPSUser string `json:"MPSUser"`
|
OpenAMTDeviceInformation struct {
|
||||||
MPSPassword string `json:"MPSPassword"`
|
GUID string `json:"guid"`
|
||||||
MPSToken string `json:"MPSToken"` // retrieved from API
|
HostName string `json:"hostname"`
|
||||||
|
ConnectionStatus bool `json:"connectionStatus"`
|
||||||
|
PowerState PowerState `json:"powerState"`
|
||||||
|
EnabledFeatures *OpenAMTDeviceEnabledFeatures `json:"features"`
|
||||||
}
|
}
|
||||||
|
|
||||||
DomainConfiguration struct {
|
// OpenAMTDeviceEnabledFeatures represents an AMT managed device features information
|
||||||
CertFileText string `json:"CertFileText"`
|
OpenAMTDeviceEnabledFeatures struct {
|
||||||
CertPassword string `json:"CertPassword"`
|
Redirection bool `json:"redirection"`
|
||||||
DomainName string `json:"DomainName"`
|
KVM bool `json:"KVM"`
|
||||||
|
SOL bool `json:"SOL"`
|
||||||
|
IDER bool `json:"IDER"`
|
||||||
|
UserConsent string `json:"userConsent"`
|
||||||
}
|
}
|
||||||
|
|
||||||
WirelessConfiguration struct {
|
// PowerState represents an AMT managed device power state
|
||||||
AuthenticationMethod string `json:"AuthenticationMethod"`
|
PowerState int
|
||||||
EncryptionMethod string `json:"EncryptionMethod"`
|
|
||||||
SSID string `json:"SSID"`
|
FDOConfiguration struct {
|
||||||
PskPass string `json:"PskPass"`
|
Enabled bool `json:"enabled"`
|
||||||
|
OwnerURL string `json:"ownerURL"`
|
||||||
|
OwnerUsername string `json:"ownerUsername"`
|
||||||
|
OwnerPassword string `json:"ownerPassword"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FDOProfileID represents a fdo profile id
|
||||||
|
FDOProfileID int
|
||||||
|
|
||||||
|
FDOProfile struct {
|
||||||
|
ID FDOProfileID `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
FilePath string `json:"filePath"`
|
||||||
|
NumberDevices int `json:"numberDevices"`
|
||||||
|
DateCreated int64 `json:"dateCreated"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CLIFlags represents the available flags on the CLI
|
// CLIFlags represents the available flags on the CLI
|
||||||
|
@ -298,8 +322,14 @@ type (
|
||||||
ComposeSyntaxMaxVersion string `json:"ComposeSyntaxMaxVersion" example:"3.8"`
|
ComposeSyntaxMaxVersion string `json:"ComposeSyntaxMaxVersion" example:"3.8"`
|
||||||
// Environment(Endpoint) specific security settings
|
// Environment(Endpoint) specific security settings
|
||||||
SecuritySettings EndpointSecuritySettings
|
SecuritySettings EndpointSecuritySettings
|
||||||
|
// The identifier of the AMT Device associated with this environment(endpoint)
|
||||||
|
AMTDeviceGUID string `json:"AMTDeviceGUID,omitempty" example:"4c4c4544-004b-3910-8037-b6c04f504633"`
|
||||||
// LastCheckInDate mark last check-in date on checkin
|
// LastCheckInDate mark last check-in date on checkin
|
||||||
LastCheckInDate int64
|
LastCheckInDate int64
|
||||||
|
// IsEdgeDevice marks if the environment was created as an EdgeDevice
|
||||||
|
IsEdgeDevice bool
|
||||||
|
// Whether the device has been trusted or not by the user
|
||||||
|
UserTrusted bool
|
||||||
|
|
||||||
// Deprecated fields
|
// Deprecated fields
|
||||||
// Deprecated in DBVersion == 4
|
// Deprecated in DBVersion == 4
|
||||||
|
@ -768,7 +798,8 @@ type (
|
||||||
AuthenticationMethod AuthenticationMethod `json:"AuthenticationMethod" example:"1"`
|
AuthenticationMethod AuthenticationMethod `json:"AuthenticationMethod" example:"1"`
|
||||||
LDAPSettings LDAPSettings `json:"LDAPSettings" example:""`
|
LDAPSettings LDAPSettings `json:"LDAPSettings" example:""`
|
||||||
OAuthSettings OAuthSettings `json:"OAuthSettings" example:""`
|
OAuthSettings OAuthSettings `json:"OAuthSettings" example:""`
|
||||||
OpenAMTConfiguration OpenAMTConfiguration `json:"OpenAMTConfiguration" example:""`
|
OpenAMTConfiguration OpenAMTConfiguration `json:"openAMTConfiguration" example:""`
|
||||||
|
FDOConfiguration FDOConfiguration `json:"fdoConfiguration" example:""`
|
||||||
FeatureFlagSettings map[Feature]bool `json:"FeatureFlagSettings" example:""`
|
FeatureFlagSettings map[Feature]bool `json:"FeatureFlagSettings" example:""`
|
||||||
// The interval in which environment(endpoint) snapshots are created
|
// The interval in which environment(endpoint) snapshots are created
|
||||||
SnapshotInterval string `json:"SnapshotInterval" example:"5m"`
|
SnapshotInterval string `json:"SnapshotInterval" example:"5m"`
|
||||||
|
@ -788,6 +819,10 @@ type (
|
||||||
HelmRepositoryURL string `json:"HelmRepositoryURL" example:"https://charts.bitnami.com/bitnami"`
|
HelmRepositoryURL string `json:"HelmRepositoryURL" example:"https://charts.bitnami.com/bitnami"`
|
||||||
// KubectlImage, defaults to portainer/kubectl-shell
|
// KubectlImage, defaults to portainer/kubectl-shell
|
||||||
KubectlShellImage string `json:"KubectlShellImage" example:"portainer/kubectl-shell"`
|
KubectlShellImage string `json:"KubectlShellImage" example:"portainer/kubectl-shell"`
|
||||||
|
// DisableTrustOnFirstConnect makes Portainer require explicit user trust of the edge agent before accepting the connection
|
||||||
|
DisableTrustOnFirstConnect bool `json:"DisableTrustOnFirstConnect" example:"false"`
|
||||||
|
// EnforceEdgeID makes Portainer store the Edge ID instead of accepting anyone
|
||||||
|
EnforceEdgeID bool `json:"EnforceEdgeID" example:"false"`
|
||||||
|
|
||||||
// Deprecated fields
|
// Deprecated fields
|
||||||
DisplayDonationHeader bool
|
DisplayDonationHeader bool
|
||||||
|
@ -1213,6 +1248,7 @@ type (
|
||||||
GetDefaultSSLCertsPath() (string, string)
|
GetDefaultSSLCertsPath() (string, string)
|
||||||
StoreSSLCertPair(cert, key []byte) (string, string, error)
|
StoreSSLCertPair(cert, key []byte) (string, string, error)
|
||||||
CopySSLCertPair(certPath, keyPath string) (string, string, error)
|
CopySSLCertPair(certPath, keyPath string) (string, string, error)
|
||||||
|
StoreFDOProfileFileFromBytes(fdoProfileIdentifier string, data []byte) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GitService represents a service for managing Git
|
// GitService represents a service for managing Git
|
||||||
|
@ -1223,7 +1259,10 @@ type (
|
||||||
|
|
||||||
// OpenAMTService represents a service for managing OpenAMT
|
// OpenAMTService represents a service for managing OpenAMT
|
||||||
OpenAMTService interface {
|
OpenAMTService interface {
|
||||||
ConfigureDefault(configuration OpenAMTConfiguration) error
|
Configure(configuration OpenAMTConfiguration) error
|
||||||
|
DeviceInformation(configuration OpenAMTConfiguration, deviceGUID string) (*OpenAMTDeviceInformation, error)
|
||||||
|
EnableDeviceFeatures(configuration OpenAMTConfiguration, deviceGUID string, features OpenAMTDeviceEnabledFeatures) (string, error)
|
||||||
|
ExecuteDeviceAction(configuration OpenAMTConfiguration, deviceGUID string, action string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// KubeClient represents a service used to query a Kubernetes environment(endpoint)
|
// KubeClient represents a service used to query a Kubernetes environment(endpoint)
|
||||||
|
@ -1353,17 +1392,8 @@ const (
|
||||||
WebSocketKeepAlive = 1 * time.Hour
|
WebSocketKeepAlive = 1 * time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
// Supported feature flags
|
|
||||||
const (
|
|
||||||
FeatOpenAMT Feature = "open-amt"
|
|
||||||
FeatFDO Feature = "fdo"
|
|
||||||
)
|
|
||||||
|
|
||||||
// List of supported features
|
// List of supported features
|
||||||
var SupportedFeatureFlags = []Feature{
|
var SupportedFeatureFlags = []Feature{}
|
||||||
FeatOpenAMT,
|
|
||||||
FeatFDO,
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
_ AuthenticationMethod = iota
|
_ AuthenticationMethod = iota
|
||||||
|
|
|
@ -680,6 +680,7 @@ a[ng-click] {
|
||||||
.image-zoom-modal .modal-dialog {
|
.image-zoom-modal .modal-dialog {
|
||||||
width: 80%;
|
width: 80%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*!bootbox override*/
|
/*!bootbox override*/
|
||||||
|
|
||||||
/*angular-multi-select override*/
|
/*angular-multi-select override*/
|
||||||
|
@ -893,6 +894,16 @@ json-tree .branch-preview {
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon-disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.space-x-1 {
|
.space-x-1 {
|
||||||
margin-left: 0.25rem;
|
margin-left: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* used for bootbox prompt with inputType radio */
|
||||||
|
.form-check.radio {
|
||||||
|
margin-left: 15px;
|
||||||
|
}
|
||||||
|
|
|
@ -264,6 +264,7 @@ html {
|
||||||
--bg-multiselect-helpercontainer: var(--white-color);
|
--bg-multiselect-helpercontainer: var(--white-color);
|
||||||
--text-input-textarea: var(--white-color);
|
--text-input-textarea: var(--white-color);
|
||||||
--bg-service-datatable-thead: var(--grey-23);
|
--bg-service-datatable-thead: var(--grey-23);
|
||||||
|
--bg-inner-datatable-thead: var(--grey-23);
|
||||||
--bg-service-datatable-tbody: var(--grey-24);
|
--bg-service-datatable-tbody: var(--grey-24);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -435,6 +436,7 @@ html {
|
||||||
--bg-multiselect-helpercontainer: var(--grey-1);
|
--bg-multiselect-helpercontainer: var(--grey-1);
|
||||||
--text-input-textarea: var(--grey-1);
|
--text-input-textarea: var(--grey-1);
|
||||||
--bg-service-datatable-thead: var(--grey-1);
|
--bg-service-datatable-thead: var(--grey-1);
|
||||||
|
--bg-inner-datatable-thead: var(--grey-1);
|
||||||
--bg-service-datatable-tbody: var(--grey-1);
|
--bg-service-datatable-tbody: var(--grey-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -472,6 +474,7 @@ html {
|
||||||
--bg-input-sm-color: var(--black-color);
|
--bg-input-sm-color: var(--black-color);
|
||||||
--bg-item-highlighted-color: var(--black-color);
|
--bg-item-highlighted-color: var(--black-color);
|
||||||
--bg-service-datatable-thead: var(--black-color);
|
--bg-service-datatable-thead: var(--black-color);
|
||||||
|
--bg-inner-datatable-thead: var(--black-color);
|
||||||
--bg-service-datatable-tbody: var(--black-color);
|
--bg-service-datatable-tbody: var(--black-color);
|
||||||
--bg-pagination-color: var(--grey-3);
|
--bg-pagination-color: var(--grey-3);
|
||||||
--bg-pagination-span-color: var(--grey-3);
|
--bg-pagination-span-color: var(--grey-3);
|
||||||
|
|
|
@ -140,7 +140,7 @@ export function ContainersDatatable({
|
||||||
<TableContainer>
|
<TableContainer>
|
||||||
<TableTitle icon="fa-cubes" label="Containers">
|
<TableTitle icon="fa-cubes" label="Containers">
|
||||||
<TableTitleActions>
|
<TableTitleActions>
|
||||||
<ColumnVisibilityMenu
|
<ColumnVisibilityMenu<DockerContainer>
|
||||||
columns={columnsToHide}
|
columns={columnsToHide}
|
||||||
onChange={handleChangeColumnsVisibility}
|
onChange={handleChangeColumnsVisibility}
|
||||||
value={settings.hiddenColumns}
|
value={settings.hiddenColumns}
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import angular from 'angular';
|
import angular from 'angular';
|
||||||
|
|
||||||
import edgeStackModule from './views/edge-stacks';
|
import edgeStackModule from './views/edge-stacks';
|
||||||
|
import edgeDevicesModule from './devices';
|
||||||
|
|
||||||
angular.module('portainer.edge', [edgeStackModule]).config(function config($stateRegistryProvider) {
|
angular.module('portainer.edge', [edgeStackModule, edgeDevicesModule]).config(function config($stateRegistryProvider) {
|
||||||
const edge = {
|
const edge = {
|
||||||
name: 'edge',
|
name: 'edge',
|
||||||
url: '/edge',
|
url: '/edge',
|
||||||
|
@ -106,6 +107,16 @@ angular.module('portainer.edge', [edgeStackModule]).config(function config($stat
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const edgeDevices = {
|
||||||
|
name: 'edge.devices',
|
||||||
|
url: '/devices',
|
||||||
|
views: {
|
||||||
|
'content@': {
|
||||||
|
component: 'edgeDevicesView',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
$stateRegistryProvider.register(edge);
|
$stateRegistryProvider.register(edge);
|
||||||
|
|
||||||
$stateRegistryProvider.register(groups);
|
$stateRegistryProvider.register(groups);
|
||||||
|
@ -119,4 +130,6 @@ angular.module('portainer.edge', [edgeStackModule]).config(function config($stat
|
||||||
$stateRegistryProvider.register(edgeJobs);
|
$stateRegistryProvider.register(edgeJobs);
|
||||||
$stateRegistryProvider.register(edgeJob);
|
$stateRegistryProvider.register(edgeJob);
|
||||||
$stateRegistryProvider.register(edgeJobCreation);
|
$stateRegistryProvider.register(edgeJobCreation);
|
||||||
|
|
||||||
|
$stateRegistryProvider.register(edgeDevices);
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,108 @@
|
||||||
|
import { usePagination, useTable } from 'react-table';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableContainer,
|
||||||
|
TableHeaderRow,
|
||||||
|
TableRow,
|
||||||
|
} from '@/portainer/components/datatables/components';
|
||||||
|
import { InnerDatatable } from '@/portainer/components/datatables/components/InnerDatatable';
|
||||||
|
import { Device } from '@/portainer/hostmanagement/open-amt/model';
|
||||||
|
import { useAMTDevices } from '@/edge/devices/components/AMTDevicesDatatable/useAMTDevices';
|
||||||
|
import { RowProvider } from '@/edge/devices/components/AMTDevicesDatatable/columns/RowContext';
|
||||||
|
import { EnvironmentId } from '@/portainer/environments/types';
|
||||||
|
import PortainerError from '@/portainer/error';
|
||||||
|
|
||||||
|
import { useColumns } from './columns';
|
||||||
|
|
||||||
|
export interface AMTDevicesTableProps {
|
||||||
|
environmentId: EnvironmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AMTDevicesDatatable({ environmentId }: AMTDevicesTableProps) {
|
||||||
|
const columns = useColumns();
|
||||||
|
|
||||||
|
const { isLoading, devices, error } = useAMTDevices(environmentId);
|
||||||
|
|
||||||
|
const { getTableProps, getTableBodyProps, headerGroups, page, prepareRow } =
|
||||||
|
useTable<Device>(
|
||||||
|
{
|
||||||
|
columns,
|
||||||
|
data: devices,
|
||||||
|
},
|
||||||
|
usePagination
|
||||||
|
);
|
||||||
|
|
||||||
|
const tableProps = getTableProps();
|
||||||
|
const tbodyProps = getTableBodyProps();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InnerDatatable>
|
||||||
|
<TableContainer>
|
||||||
|
<Table
|
||||||
|
className={tableProps.className}
|
||||||
|
role={tableProps.role}
|
||||||
|
style={tableProps.style}
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
{headerGroups.map((headerGroup) => {
|
||||||
|
const { key, className, role, style } =
|
||||||
|
headerGroup.getHeaderGroupProps();
|
||||||
|
return (
|
||||||
|
<TableHeaderRow<Device>
|
||||||
|
key={key}
|
||||||
|
className={className}
|
||||||
|
role={role}
|
||||||
|
style={style}
|
||||||
|
headers={headerGroup.headers}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</thead>
|
||||||
|
<tbody
|
||||||
|
className={tbodyProps.className}
|
||||||
|
role={tbodyProps.role}
|
||||||
|
style={tbodyProps.style}
|
||||||
|
>
|
||||||
|
{!isLoading && devices && devices.length > 0 ? (
|
||||||
|
page.map((row) => {
|
||||||
|
prepareRow(row);
|
||||||
|
const { key, className, role, style } = row.getRowProps();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RowProvider key={key} environmentId={environmentId}>
|
||||||
|
<TableRow<Device>
|
||||||
|
cells={row.cells}
|
||||||
|
key={key}
|
||||||
|
className={className}
|
||||||
|
role={role}
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
</RowProvider>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="text-center text-muted">
|
||||||
|
{userMessage(isLoading, error)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</InnerDatatable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function userMessage(isLoading: boolean, error?: PortainerError) {
|
||||||
|
if (isLoading) {
|
||||||
|
return 'Loading...';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'No devices found';
|
||||||
|
}
|
|
@ -0,0 +1,43 @@
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
useReducer,
|
||||||
|
PropsWithChildren,
|
||||||
|
} from 'react';
|
||||||
|
import { EnvironmentId } from 'Portainer/environments/types';
|
||||||
|
|
||||||
|
interface RowContextState {
|
||||||
|
environmentId: EnvironmentId;
|
||||||
|
isLoading: boolean;
|
||||||
|
toggleIsLoading(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RowContext = createContext<RowContextState | null>(null);
|
||||||
|
|
||||||
|
export interface RowProviderProps {
|
||||||
|
environmentId: EnvironmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RowProvider({
|
||||||
|
environmentId,
|
||||||
|
children,
|
||||||
|
}: PropsWithChildren<RowProviderProps>) {
|
||||||
|
const [isLoading, toggleIsLoading] = useReducer((state) => !state, false);
|
||||||
|
|
||||||
|
const state = useMemo(
|
||||||
|
() => ({ isLoading, toggleIsLoading, environmentId }),
|
||||||
|
[isLoading, toggleIsLoading, environmentId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <RowContext.Provider value={state}>{children}</RowContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRowContext() {
|
||||||
|
const context = useContext(RowContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('should be nested under RowProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
|
@ -0,0 +1,108 @@
|
||||||
|
import { CellProps, Column } from 'react-table';
|
||||||
|
import { useSref } from '@uirouter/react';
|
||||||
|
import { MenuItem, MenuLink } from '@reach/menu-button';
|
||||||
|
import { useQueryClient } from 'react-query';
|
||||||
|
|
||||||
|
import { Device } from '@/portainer/hostmanagement/open-amt/model';
|
||||||
|
import { ActionsMenu } from '@/portainer/components/datatables/components/ActionsMenu';
|
||||||
|
import { confirmAsync } from '@/portainer/services/modal.service/confirm';
|
||||||
|
import { executeDeviceAction } from '@/portainer/hostmanagement/open-amt/open-amt.service';
|
||||||
|
import * as notifications from '@/portainer/services/notifications';
|
||||||
|
import { ActionsMenuTitle } from '@/portainer/components/datatables/components/ActionsMenuTitle';
|
||||||
|
import { useRowContext } from '@/edge/devices/components/AMTDevicesDatatable/columns/RowContext';
|
||||||
|
import { DeviceAction } from '@/edge/devices/types';
|
||||||
|
|
||||||
|
export const actions: Column<Device> = {
|
||||||
|
Header: 'Actions',
|
||||||
|
accessor: () => 'actions',
|
||||||
|
id: 'actions',
|
||||||
|
disableFilters: true,
|
||||||
|
canHide: true,
|
||||||
|
disableResizing: true,
|
||||||
|
width: '5px',
|
||||||
|
sortType: 'string',
|
||||||
|
Filter: () => null,
|
||||||
|
Cell: ActionsCell,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ActionsCell({ row: { original: device } }: CellProps<Device>) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { isLoading, toggleIsLoading, environmentId } = useRowContext();
|
||||||
|
|
||||||
|
const kvmLinkProps = useSref('portainer.endpoints.endpoint.kvm', {
|
||||||
|
id: environmentId,
|
||||||
|
deviceId: device.guid,
|
||||||
|
deviceName: device.hostname,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionsMenu>
|
||||||
|
<ActionsMenuTitle>AMT Functions</ActionsMenuTitle>
|
||||||
|
<MenuItem
|
||||||
|
disabled={isLoading}
|
||||||
|
onSelect={() => handleDeviceActionClick(DeviceAction.PowerOn)}
|
||||||
|
>
|
||||||
|
Power ON
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
disabled={isLoading}
|
||||||
|
onSelect={() => handleDeviceActionClick(DeviceAction.PowerOff)}
|
||||||
|
>
|
||||||
|
Power OFF
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
disabled={isLoading}
|
||||||
|
onSelect={() => handleDeviceActionClick(DeviceAction.Restart)}
|
||||||
|
>
|
||||||
|
Restart
|
||||||
|
</MenuItem>
|
||||||
|
<MenuLink
|
||||||
|
href={kvmLinkProps.href}
|
||||||
|
onClick={kvmLinkProps.onClick}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
KVM
|
||||||
|
</MenuLink>
|
||||||
|
</ActionsMenu>
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleDeviceActionClick(action: string) {
|
||||||
|
const confirmed = await confirmAsync({
|
||||||
|
title: 'Confirm action',
|
||||||
|
message: `Are you sure you want to ${action} the device?`,
|
||||||
|
buttons: {
|
||||||
|
cancel: {
|
||||||
|
label: 'Cancel',
|
||||||
|
className: 'btn-default',
|
||||||
|
},
|
||||||
|
confirm: {
|
||||||
|
label: 'Confirm',
|
||||||
|
className: 'btn-primary',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
toggleIsLoading();
|
||||||
|
await executeDeviceAction(environmentId, device.guid, action);
|
||||||
|
notifications.success(
|
||||||
|
`${action} action sent successfully`,
|
||||||
|
device.hostname
|
||||||
|
);
|
||||||
|
await queryClient.invalidateQueries(['amt_devices', environmentId]);
|
||||||
|
} catch (err) {
|
||||||
|
notifications.error(
|
||||||
|
'Failure',
|
||||||
|
err as Error,
|
||||||
|
`Failed to ${action} the device`
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
toggleIsLoading();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { Column } from 'react-table';
|
||||||
|
|
||||||
|
import { Device } from '@/portainer/hostmanagement/open-amt/model';
|
||||||
|
|
||||||
|
export const hostname: Column<Device> = {
|
||||||
|
Header: 'Hostname',
|
||||||
|
accessor: (row) => row.hostname || '-',
|
||||||
|
id: 'hostname',
|
||||||
|
disableFilters: true,
|
||||||
|
canHide: true,
|
||||||
|
sortType: 'string',
|
||||||
|
Filter: () => null,
|
||||||
|
};
|
|
@ -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], []);
|
||||||
|
}
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { CellProps, Column } from 'react-table';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
import { Device } from '@/portainer/hostmanagement/open-amt/model';
|
||||||
|
import { useRowContext } from '@/edge/devices/components/AMTDevicesDatatable/columns/RowContext';
|
||||||
|
import { PowerState, PowerStateCode } from '@/edge/devices/types';
|
||||||
|
|
||||||
|
export const powerState: Column<Device> = {
|
||||||
|
Header: 'Power State',
|
||||||
|
accessor: (row) => parsePowerState(row.powerState),
|
||||||
|
id: 'powerState',
|
||||||
|
disableFilters: true,
|
||||||
|
canHide: true,
|
||||||
|
sortType: 'string',
|
||||||
|
Cell: PowerStateCell,
|
||||||
|
Filter: () => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PowerStateCell({
|
||||||
|
row: { original: device },
|
||||||
|
}: CellProps<Device>) {
|
||||||
|
const { isLoading } = useRowContext();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className={clsx({
|
||||||
|
'text-success': device.powerState === PowerStateCode.On,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{parsePowerState(device.powerState)}
|
||||||
|
</span>
|
||||||
|
<span>{isLoading && <i className="fa fa-cog fa-spin space-left" />}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePowerState(value: PowerStateCode) {
|
||||||
|
// https://app.swaggerhub.com/apis-docs/rbheopenamt/mps/1.4.0#/AMT/get_api_v1_amt_power_state__guid_
|
||||||
|
switch (value) {
|
||||||
|
case PowerStateCode.On:
|
||||||
|
return PowerState.Running;
|
||||||
|
case PowerStateCode.SleepLight:
|
||||||
|
case PowerStateCode.SleepDeep:
|
||||||
|
return PowerState.Sleep;
|
||||||
|
case PowerStateCode.OffHard:
|
||||||
|
case PowerStateCode.OffSoft:
|
||||||
|
case PowerStateCode.OffHardGraceful:
|
||||||
|
return PowerState.Off;
|
||||||
|
case PowerStateCode.Hibernate:
|
||||||
|
return PowerState.Hibernate;
|
||||||
|
case PowerStateCode.PowerCycle:
|
||||||
|
return PowerState.PowerCycle;
|
||||||
|
default:
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { CellProps, Column } from 'react-table';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
import { Device } from '@/portainer/hostmanagement/open-amt/model';
|
||||||
|
|
||||||
|
export const status: Column<Device> = {
|
||||||
|
Header: 'MPS Status',
|
||||||
|
id: 'status',
|
||||||
|
disableFilters: true,
|
||||||
|
canHide: true,
|
||||||
|
sortType: 'string',
|
||||||
|
Cell: StatusCell,
|
||||||
|
Filter: () => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusCell({ row: { original: device } }: CellProps<Device>) {
|
||||||
|
return (
|
||||||
|
<span className={clsx({ 'text-success': device.connectionStatus })}>
|
||||||
|
{device.connectionStatus ? 'Connected' : 'Disconnected'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
.kvm-tip {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
|
@ -0,0 +1,265 @@
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
useTable,
|
||||||
|
useExpanded,
|
||||||
|
useSortBy,
|
||||||
|
useFilters,
|
||||||
|
useGlobalFilter,
|
||||||
|
usePagination,
|
||||||
|
} from 'react-table';
|
||||||
|
import { useRowSelectColumn } from '@lineup-lite/hooks';
|
||||||
|
|
||||||
|
import { Environment } from '@/portainer/environments/types';
|
||||||
|
import { PaginationControls } from '@/portainer/components/pagination-controls';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableActions,
|
||||||
|
TableContainer,
|
||||||
|
TableHeaderRow,
|
||||||
|
TableRow,
|
||||||
|
TableSettingsMenu,
|
||||||
|
TableTitle,
|
||||||
|
TableTitleActions,
|
||||||
|
} from '@/portainer/components/datatables/components';
|
||||||
|
import { multiple } from '@/portainer/components/datatables/components/filter-types';
|
||||||
|
import { useTableSettings } from '@/portainer/components/datatables/components/useTableSettings';
|
||||||
|
import { ColumnVisibilityMenu } from '@/portainer/components/datatables/components/ColumnVisibilityMenu';
|
||||||
|
import { useRepeater } from '@/portainer/components/datatables/components/useRepeater';
|
||||||
|
import { useDebounce } from '@/portainer/hooks/useDebounce';
|
||||||
|
import {
|
||||||
|
useSearchBarContext,
|
||||||
|
SearchBar,
|
||||||
|
} from '@/portainer/components/datatables/components/SearchBar';
|
||||||
|
import { useRowSelect } from '@/portainer/components/datatables/components/useRowSelect';
|
||||||
|
import { TableFooter } from '@/portainer/components/datatables/components/TableFooter';
|
||||||
|
import { SelectedRowsCount } from '@/portainer/components/datatables/components/SelectedRowsCount';
|
||||||
|
import { EdgeDeviceTableSettings } from '@/edge/devices/types';
|
||||||
|
import { EdgeDevicesDatatableSettings } from '@/edge/devices/components/EdgeDevicesDatatable/EdgeDevicesDatatableSettings';
|
||||||
|
import { EdgeDevicesDatatableActions } from '@/edge/devices/components/EdgeDevicesDatatable/EdgeDevicesDatatableActions';
|
||||||
|
import { AMTDevicesDatatable } from '@/edge/devices/components/AMTDevicesDatatable/AMTDevicesDatatable';
|
||||||
|
import { TextTip } from '@/portainer/components/Tip/TextTip';
|
||||||
|
|
||||||
|
import { RowProvider } from './columns/RowContext';
|
||||||
|
import { useColumns } from './columns';
|
||||||
|
import styles from './EdgeDevicesDatatable.module.css';
|
||||||
|
|
||||||
|
export interface EdgeDevicesTableProps {
|
||||||
|
isEnabled: boolean;
|
||||||
|
isFdoEnabled: boolean;
|
||||||
|
isOpenAmtEnabled: boolean;
|
||||||
|
disableTrustOnFirstConnect: boolean;
|
||||||
|
mpsServer: string;
|
||||||
|
dataset: Environment[];
|
||||||
|
onRefresh(): Promise<void>;
|
||||||
|
setLoadingMessage(message: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EdgeDevicesDatatable({
|
||||||
|
isFdoEnabled,
|
||||||
|
isOpenAmtEnabled,
|
||||||
|
disableTrustOnFirstConnect,
|
||||||
|
mpsServer,
|
||||||
|
dataset,
|
||||||
|
onRefresh,
|
||||||
|
setLoadingMessage,
|
||||||
|
}: EdgeDevicesTableProps) {
|
||||||
|
const { settings, setTableSettings } =
|
||||||
|
useTableSettings<EdgeDeviceTableSettings>();
|
||||||
|
const [searchBarValue, setSearchBarValue] = useSearchBarContext();
|
||||||
|
|
||||||
|
const columns = useColumns();
|
||||||
|
|
||||||
|
useRepeater(settings.autoRefreshRate, onRefresh);
|
||||||
|
|
||||||
|
const {
|
||||||
|
getTableProps,
|
||||||
|
getTableBodyProps,
|
||||||
|
headerGroups,
|
||||||
|
page,
|
||||||
|
prepareRow,
|
||||||
|
selectedFlatRows,
|
||||||
|
allColumns,
|
||||||
|
gotoPage,
|
||||||
|
setPageSize,
|
||||||
|
setHiddenColumns,
|
||||||
|
setGlobalFilter,
|
||||||
|
state: { pageIndex, pageSize },
|
||||||
|
} = useTable<Environment>(
|
||||||
|
{
|
||||||
|
defaultCanFilter: false,
|
||||||
|
columns,
|
||||||
|
data: dataset,
|
||||||
|
filterTypes: { multiple },
|
||||||
|
initialState: {
|
||||||
|
pageSize: settings.pageSize || 10,
|
||||||
|
hiddenColumns: settings.hiddenColumns,
|
||||||
|
sortBy: [settings.sortBy],
|
||||||
|
globalFilter: searchBarValue,
|
||||||
|
},
|
||||||
|
isRowSelectable() {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
autoResetExpanded: false,
|
||||||
|
autoResetSelectedRows: false,
|
||||||
|
selectColumnWidth: 5,
|
||||||
|
},
|
||||||
|
useFilters,
|
||||||
|
useGlobalFilter,
|
||||||
|
useSortBy,
|
||||||
|
useExpanded,
|
||||||
|
usePagination,
|
||||||
|
useRowSelect,
|
||||||
|
useRowSelectColumn
|
||||||
|
);
|
||||||
|
|
||||||
|
const debouncedSearchValue = useDebounce(searchBarValue);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setGlobalFilter(debouncedSearchValue);
|
||||||
|
}, [debouncedSearchValue, setGlobalFilter]);
|
||||||
|
|
||||||
|
const columnsToHide = allColumns.filter((colInstance) => {
|
||||||
|
const columnDef = columns.find((c) => c.id === colInstance.id);
|
||||||
|
return columnDef?.canHide;
|
||||||
|
});
|
||||||
|
|
||||||
|
const tableProps = getTableProps();
|
||||||
|
const tbodyProps = getTableBodyProps();
|
||||||
|
|
||||||
|
const someDeviceHasAMTActivated = dataset.some(
|
||||||
|
(environment) =>
|
||||||
|
environment.AMTDeviceGUID && environment.AMTDeviceGUID !== ''
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableContainer>
|
||||||
|
<TableTitle icon="fa-plug" label="Edge Devices">
|
||||||
|
<TableTitleActions>
|
||||||
|
<ColumnVisibilityMenu<Environment>
|
||||||
|
columns={columnsToHide}
|
||||||
|
onChange={handleChangeColumnsVisibility}
|
||||||
|
value={settings.hiddenColumns}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TableSettingsMenu>
|
||||||
|
<EdgeDevicesDatatableSettings />
|
||||||
|
</TableSettingsMenu>
|
||||||
|
</TableTitleActions>
|
||||||
|
</TableTitle>
|
||||||
|
|
||||||
|
<TableActions>
|
||||||
|
<EdgeDevicesDatatableActions
|
||||||
|
selectedItems={selectedFlatRows.map((row) => row.original)}
|
||||||
|
isFDOEnabled={isFdoEnabled}
|
||||||
|
isOpenAMTEnabled={isOpenAmtEnabled}
|
||||||
|
setLoadingMessage={setLoadingMessage}
|
||||||
|
/>
|
||||||
|
</TableActions>
|
||||||
|
|
||||||
|
{someDeviceHasAMTActivated && (
|
||||||
|
<div className={styles.kvmTip}>
|
||||||
|
<TextTip color="blue">
|
||||||
|
For the KVM function to work you need to have the MPS server added
|
||||||
|
to your trusted site list, browse to this{' '}
|
||||||
|
<a href={`https://${mpsServer}`} target="_blank" rel="noreferrer">
|
||||||
|
site
|
||||||
|
</a>{' '}
|
||||||
|
and add to your trusted site list
|
||||||
|
</TextTip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SearchBar value={searchBarValue} onChange={handleSearchBarChange} />
|
||||||
|
|
||||||
|
<Table
|
||||||
|
className={tableProps.className}
|
||||||
|
role={tableProps.role}
|
||||||
|
style={tableProps.style}
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
{headerGroups.map((headerGroup) => {
|
||||||
|
const { key, className, role, style } =
|
||||||
|
headerGroup.getHeaderGroupProps();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableHeaderRow<Environment>
|
||||||
|
key={key}
|
||||||
|
className={className}
|
||||||
|
role={role}
|
||||||
|
style={style}
|
||||||
|
headers={headerGroup.headers}
|
||||||
|
onSortChange={handleSortChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</thead>
|
||||||
|
<tbody
|
||||||
|
className={tbodyProps.className}
|
||||||
|
role={tbodyProps.role}
|
||||||
|
style={tbodyProps.style}
|
||||||
|
>
|
||||||
|
{page.map((row) => {
|
||||||
|
prepareRow(row);
|
||||||
|
const { key, className, role, style } = row.getRowProps();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RowProvider
|
||||||
|
key={key}
|
||||||
|
disableTrustOnFirstConnect={disableTrustOnFirstConnect}
|
||||||
|
>
|
||||||
|
<TableRow<Environment>
|
||||||
|
cells={row.cells}
|
||||||
|
key={key}
|
||||||
|
className={className}
|
||||||
|
role={role}
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
{row.isExpanded && (
|
||||||
|
<tr>
|
||||||
|
<td />
|
||||||
|
<td colSpan={row.cells.length - 1}>
|
||||||
|
<AMTDevicesDatatable environmentId={row.original.Id} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</RowProvider>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<TableFooter>
|
||||||
|
<SelectedRowsCount value={selectedFlatRows.length} />
|
||||||
|
<PaginationControls
|
||||||
|
showAll
|
||||||
|
pageLimit={pageSize}
|
||||||
|
page={pageIndex + 1}
|
||||||
|
onPageChange={(p) => gotoPage(p - 1)}
|
||||||
|
totalCount={dataset.length}
|
||||||
|
onPageLimitChange={handlePageSizeChange}
|
||||||
|
/>
|
||||||
|
</TableFooter>
|
||||||
|
</TableContainer>
|
||||||
|
);
|
||||||
|
|
||||||
|
function handlePageSizeChange(pageSize: number) {
|
||||||
|
setPageSize(pageSize);
|
||||||
|
setTableSettings((settings) => ({ ...settings, pageSize }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChangeColumnsVisibility(hiddenColumns: string[]) {
|
||||||
|
setHiddenColumns(hiddenColumns);
|
||||||
|
setTableSettings((settings) => ({ ...settings, hiddenColumns }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearchBarChange(value: string) {
|
||||||
|
setSearchBarValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSortChange(id: string, desc: boolean) {
|
||||||
|
setTableSettings((settings) => ({
|
||||||
|
...settings,
|
||||||
|
sortBy: { id, desc },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,173 @@
|
||||||
|
import { useRouter } from '@uirouter/react';
|
||||||
|
|
||||||
|
import type { Environment } from '@/portainer/environments/types';
|
||||||
|
import { Button } from '@/portainer/components/Button';
|
||||||
|
import { confirmAsync } from '@/portainer/services/modal.service/confirm';
|
||||||
|
import { promptAsync } from '@/portainer/services/modal.service/prompt';
|
||||||
|
import * as notifications from '@/portainer/services/notifications';
|
||||||
|
import { activateDevice } from '@/portainer/hostmanagement/open-amt/open-amt.service';
|
||||||
|
import { deleteEndpoint } from '@/portainer/environments/environment.service';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedItems: Environment[];
|
||||||
|
isFDOEnabled: boolean;
|
||||||
|
isOpenAMTEnabled: boolean;
|
||||||
|
setLoadingMessage(message: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EdgeDevicesDatatableActions({
|
||||||
|
selectedItems,
|
||||||
|
isOpenAMTEnabled,
|
||||||
|
isFDOEnabled,
|
||||||
|
setLoadingMessage,
|
||||||
|
}: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="actionBar">
|
||||||
|
<Button
|
||||||
|
disabled={selectedItems.length < 1}
|
||||||
|
color="danger"
|
||||||
|
onClick={() => onDeleteEdgeDeviceClick()}
|
||||||
|
>
|
||||||
|
<i className="fa fa-trash-alt space-right" aria-hidden="true" />
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button onClick={() => onAddNewDeviceClick()}>
|
||||||
|
<i className="fa fa-plus-circle space-right" aria-hidden="true" />
|
||||||
|
Add Device
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{isOpenAMTEnabled && (
|
||||||
|
<Button
|
||||||
|
disabled={selectedItems.length !== 1}
|
||||||
|
onClick={() => onAssociateOpenAMTClick(selectedItems)}
|
||||||
|
>
|
||||||
|
<i className="fa fa-link space-right" aria-hidden="true" />
|
||||||
|
Associate with OpenAMT
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
async function onDeleteEdgeDeviceClick() {
|
||||||
|
const confirmed = await confirmAsync({
|
||||||
|
title: 'Are you sure ?',
|
||||||
|
message:
|
||||||
|
'This action will remove all configurations associated to your environment(s). Continue?',
|
||||||
|
buttons: {
|
||||||
|
confirm: {
|
||||||
|
label: 'Remove',
|
||||||
|
className: 'btn-danger',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
selectedItems.map(async (environment) => {
|
||||||
|
try {
|
||||||
|
await deleteEndpoint(environment.Id);
|
||||||
|
|
||||||
|
notifications.success(
|
||||||
|
'Environment successfully removed',
|
||||||
|
environment.Name
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
notifications.error(
|
||||||
|
'Failure',
|
||||||
|
err as Error,
|
||||||
|
'Unable to remove environment'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await router.stateService.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onAddNewDeviceClick() {
|
||||||
|
if (!isFDOEnabled) {
|
||||||
|
router.stateService.go('portainer.endpoints.newEdgeDevice');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await promptAsync({
|
||||||
|
title: 'How would you like to add an Edge Device?',
|
||||||
|
inputType: 'radio',
|
||||||
|
inputOptions: [
|
||||||
|
{
|
||||||
|
text: 'Provision bare-metal using Intel FDO',
|
||||||
|
value: 'FDO',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Deploy agent manually',
|
||||||
|
value: 'MANUAL',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
buttons: {
|
||||||
|
confirm: {
|
||||||
|
label: 'Confirm',
|
||||||
|
className: 'btn-primary',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
switch (result) {
|
||||||
|
case 'FDO':
|
||||||
|
router.stateService.go('portainer.endpoints.importDevice');
|
||||||
|
break;
|
||||||
|
case 'MANUAL':
|
||||||
|
router.stateService.go('portainer.endpoints.newEdgeDevice');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onAssociateOpenAMTClick(selectedItems: Environment[]) {
|
||||||
|
const selectedEnvironment = selectedItems[0];
|
||||||
|
|
||||||
|
const confirmed = await confirmAsync({
|
||||||
|
title: '',
|
||||||
|
message: `Associate ${selectedEnvironment.Name} with OpenAMT`,
|
||||||
|
buttons: {
|
||||||
|
cancel: {
|
||||||
|
label: 'Cancel',
|
||||||
|
className: 'btn-default',
|
||||||
|
},
|
||||||
|
confirm: {
|
||||||
|
label: 'Confirm',
|
||||||
|
className: 'btn-primary',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoadingMessage(
|
||||||
|
'Activating Active Management Technology on selected device...'
|
||||||
|
);
|
||||||
|
await activateDevice(selectedEnvironment.Id);
|
||||||
|
notifications.success(
|
||||||
|
'Successfully associated with OpenAMT',
|
||||||
|
selectedEnvironment.Name
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
notifications.error(
|
||||||
|
'Failure',
|
||||||
|
err as Error,
|
||||||
|
'Unable to associate with OpenAMT'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoadingMessage('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { react2angular } from '@/react-tools/react2angular';
|
||||||
|
import { TableSettingsProvider } from '@/portainer/components/datatables/components/useTableSettings';
|
||||||
|
import { SearchBarProvider } from '@/portainer/components/datatables/components/SearchBar';
|
||||||
|
|
||||||
|
import {
|
||||||
|
EdgeDevicesDatatable,
|
||||||
|
EdgeDevicesTableProps,
|
||||||
|
} from './EdgeDevicesDatatable';
|
||||||
|
|
||||||
|
export function EdgeDevicesDatatableContainer({
|
||||||
|
...props
|
||||||
|
}: EdgeDevicesTableProps) {
|
||||||
|
const defaultSettings = {
|
||||||
|
autoRefreshRate: 0,
|
||||||
|
hiddenQuickActions: [],
|
||||||
|
hiddenColumns: [],
|
||||||
|
pageSize: 10,
|
||||||
|
sortBy: { id: 'state', desc: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableSettingsProvider defaults={defaultSettings} storageKey="edgeDevices">
|
||||||
|
<SearchBarProvider storageKey="edgeDevices">
|
||||||
|
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
||||||
|
<EdgeDevicesDatatable {...props} />
|
||||||
|
</SearchBarProvider>
|
||||||
|
</TableSettingsProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EdgeDevicesDatatableAngular = react2angular(
|
||||||
|
EdgeDevicesDatatableContainer,
|
||||||
|
[
|
||||||
|
'dataset',
|
||||||
|
'onRefresh',
|
||||||
|
'setLoadingMessage',
|
||||||
|
'isFdoEnabled',
|
||||||
|
'disableTrustOnFirstConnect',
|
||||||
|
'isOpenAmtEnabled',
|
||||||
|
'mpsServer',
|
||||||
|
]
|
||||||
|
);
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { TableSettingsMenuAutoRefresh } from '@/portainer/components/datatables/components/TableSettingsMenuAutoRefresh';
|
||||||
|
import { useTableSettings } from '@/portainer/components/datatables/components/useTableSettings';
|
||||||
|
import { EdgeDeviceTableSettings } from '@/edge/devices/types';
|
||||||
|
|
||||||
|
export function EdgeDevicesDatatableSettings() {
|
||||||
|
const { settings, setTableSettings } =
|
||||||
|
useTableSettings<EdgeDeviceTableSettings>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableSettingsMenuAutoRefresh
|
||||||
|
value={settings.autoRefreshRate}
|
||||||
|
onChange={handleRefreshRateChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleRefreshRateChange(autoRefreshRate: number) {
|
||||||
|
setTableSettings({ autoRefreshRate });
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { createContext, useContext, useMemo, PropsWithChildren } from 'react';
|
||||||
|
|
||||||
|
interface RowContextState {
|
||||||
|
disableTrustOnFirstConnect: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RowContext = createContext<RowContextState | null>(null);
|
||||||
|
|
||||||
|
export interface RowProviderProps {
|
||||||
|
disableTrustOnFirstConnect: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RowProvider({
|
||||||
|
disableTrustOnFirstConnect,
|
||||||
|
children,
|
||||||
|
}: PropsWithChildren<RowProviderProps>) {
|
||||||
|
const state = useMemo(
|
||||||
|
() => ({ disableTrustOnFirstConnect }),
|
||||||
|
[disableTrustOnFirstConnect]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <RowContext.Provider value={state}>{children}</RowContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRowContext() {
|
||||||
|
const context = useContext(RowContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('should be nested under RowProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
|
@ -0,0 +1,107 @@
|
||||||
|
import { CellProps, Column } from 'react-table';
|
||||||
|
import { MenuItem, MenuLink } from '@reach/menu-button';
|
||||||
|
import { useRouter, useSref } from '@uirouter/react';
|
||||||
|
|
||||||
|
import { Environment } from '@/portainer/environments/types';
|
||||||
|
import { ActionsMenu } from '@/portainer/components/datatables/components/ActionsMenu';
|
||||||
|
import {
|
||||||
|
snapshotEndpoint,
|
||||||
|
trustEndpoint,
|
||||||
|
} from '@/portainer/environments/environment.service';
|
||||||
|
import * as notifications from '@/portainer/services/notifications';
|
||||||
|
import { getRoute } from '@/portainer/environments/utils';
|
||||||
|
import { confirmAsync } from '@/portainer/services/modal.service/confirm';
|
||||||
|
import { useRowContext } from '@/edge/devices/components/EdgeDevicesDatatable/columns/RowContext';
|
||||||
|
|
||||||
|
export const actions: Column<Environment> = {
|
||||||
|
Header: 'Actions',
|
||||||
|
accessor: () => 'actions',
|
||||||
|
id: 'actions',
|
||||||
|
disableFilters: true,
|
||||||
|
canHide: true,
|
||||||
|
disableResizing: true,
|
||||||
|
width: '5px',
|
||||||
|
sortType: 'string',
|
||||||
|
Filter: () => null,
|
||||||
|
Cell: ActionsCell,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ActionsCell({
|
||||||
|
row: { original: environment },
|
||||||
|
}: CellProps<Environment>) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const environmentRoute = getRoute(environment);
|
||||||
|
const browseLinkProps = useSref(environmentRoute, {
|
||||||
|
id: environment.Id,
|
||||||
|
endpointId: environment.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const showRefreshSnapshot = false; // remove and show MenuItem when feature is available
|
||||||
|
|
||||||
|
const { disableTrustOnFirstConnect } = useRowContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionsMenu>
|
||||||
|
<MenuLink href={browseLinkProps.href} onClick={browseLinkProps.onClick}>
|
||||||
|
Browse
|
||||||
|
</MenuLink>
|
||||||
|
{showRefreshSnapshot && (
|
||||||
|
<MenuItem hidden onSelect={() => handleRefreshSnapshotClick()}>
|
||||||
|
Refresh Snapshot
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
{disableTrustOnFirstConnect && !environment.UserTrusted && (
|
||||||
|
<MenuLink onClick={trustDevice}>Trust</MenuLink>
|
||||||
|
)}
|
||||||
|
</ActionsMenu>
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleRefreshSnapshotClick() {
|
||||||
|
try {
|
||||||
|
await snapshotEndpoint(environment.Id);
|
||||||
|
notifications.success('Success', 'Environment updated');
|
||||||
|
} catch (err) {
|
||||||
|
notifications.error(
|
||||||
|
'Failure',
|
||||||
|
err as Error,
|
||||||
|
'An error occurred during environment snapshot'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await router.stateService.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function trustDevice() {
|
||||||
|
const confirmed = await confirmAsync({
|
||||||
|
title: '',
|
||||||
|
message: `Mark ${environment.Name} as trusted?`,
|
||||||
|
buttons: {
|
||||||
|
cancel: {
|
||||||
|
label: 'Cancel',
|
||||||
|
className: 'btn-default',
|
||||||
|
},
|
||||||
|
confirm: {
|
||||||
|
label: 'Trust',
|
||||||
|
className: 'btn-primary',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await trustEndpoint(environment.Id);
|
||||||
|
} catch (err) {
|
||||||
|
notifications.error(
|
||||||
|
'Failure',
|
||||||
|
err as Error,
|
||||||
|
'An error occurred while trusting the environment'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await router.stateService.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { Column } from 'react-table';
|
||||||
|
|
||||||
|
import { Environment } from '@/portainer/environments/types';
|
||||||
|
import { DefaultFilter } from '@/portainer/components/datatables/components/Filter';
|
||||||
|
|
||||||
|
export const group: Column<Environment> = {
|
||||||
|
Header: 'Group',
|
||||||
|
accessor: (row) => row.GroupName || '-',
|
||||||
|
id: 'groupName',
|
||||||
|
Filter: DefaultFilter,
|
||||||
|
canHide: true,
|
||||||
|
};
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { CellProps, Column } from 'react-table';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
import { Environment, EnvironmentStatus } from '@/portainer/environments/types';
|
||||||
|
import { useRowContext } from '@/edge/devices/components/EdgeDevicesDatatable/columns/RowContext';
|
||||||
|
|
||||||
|
export const heartbeat: Column<Environment> = {
|
||||||
|
Header: 'Heartbeat',
|
||||||
|
accessor: 'Status',
|
||||||
|
id: 'status',
|
||||||
|
Cell: StatusCell,
|
||||||
|
disableFilters: true,
|
||||||
|
canHide: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusCell({
|
||||||
|
row: { original: environment },
|
||||||
|
}: CellProps<Environment>) {
|
||||||
|
const { disableTrustOnFirstConnect } = useRowContext();
|
||||||
|
|
||||||
|
if (disableTrustOnFirstConnect && !environment.UserTrusted) {
|
||||||
|
return <span className="label label-default">untrusted</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!environment.LastCheckInDate) {
|
||||||
|
return (
|
||||||
|
<span className="label label-default">
|
||||||
|
<s>associated</s>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<i
|
||||||
|
className={clsx(
|
||||||
|
'fa',
|
||||||
|
'fa-heartbeat',
|
||||||
|
environmentStatusLabel(environment.Status)
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
function environmentStatusLabel(status: EnvironmentStatus) {
|
||||||
|
if (status === EnvironmentStatus.Up) {
|
||||||
|
return 'green-icon';
|
||||||
|
}
|
||||||
|
return 'orange-icon';
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { name } from './name';
|
||||||
|
import { heartbeat } from './heartbeat';
|
||||||
|
import { group } from './group';
|
||||||
|
import { actions } from './actions';
|
||||||
|
|
||||||
|
export function useColumns() {
|
||||||
|
return useMemo(() => [name, heartbeat, group, actions], []);
|
||||||
|
}
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { CellProps, Column, TableInstance } from 'react-table';
|
||||||
|
|
||||||
|
import { Environment } from '@/portainer/environments/types';
|
||||||
|
import { Link } from '@/portainer/components/Link';
|
||||||
|
import { ExpandingCell } from '@/portainer/components/datatables/components/ExpandingCell';
|
||||||
|
|
||||||
|
export const name: Column<Environment> = {
|
||||||
|
Header: 'Name',
|
||||||
|
accessor: (row) => row.Name,
|
||||||
|
id: 'name',
|
||||||
|
Cell: NameCell,
|
||||||
|
disableFilters: true,
|
||||||
|
Filter: () => null,
|
||||||
|
canHide: false,
|
||||||
|
sortType: 'string',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NameCell({ value: name, row }: CellProps<TableInstance>) {
|
||||||
|
return (
|
||||||
|
<ExpandingCell row={row}>
|
||||||
|
<Link
|
||||||
|
to="portainer.endpoints.endpoint"
|
||||||
|
params={{ id: row.original.Id }}
|
||||||
|
title={name}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Link>
|
||||||
|
</ExpandingCell>
|
||||||
|
);
|
||||||
|
}
|
7
app/edge/devices/index.ts
Normal file
7
app/edge/devices/index.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import angular from 'angular';
|
||||||
|
|
||||||
|
import { EdgeDevicesDatatableAngular } from '@/edge/devices/components/EdgeDevicesDatatable/EdgeDevicesDatatableContainer';
|
||||||
|
|
||||||
|
export default angular
|
||||||
|
.module('portainer.edge.devices', [])
|
||||||
|
.component('edgeDevicesDatatable', EdgeDevicesDatatableAngular).name;
|
36
app/edge/devices/types.ts
Normal file
36
app/edge/devices/types.ts
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
export interface EdgeDeviceTableSettings {
|
||||||
|
hiddenColumns: string[];
|
||||||
|
autoRefreshRate: number;
|
||||||
|
pageSize: number;
|
||||||
|
sortBy: { id: string; desc: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FDOProfilesTableSettings {
|
||||||
|
pageSize: number;
|
||||||
|
sortBy: { id: string; desc: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum DeviceAction {
|
||||||
|
PowerOn = 'power on',
|
||||||
|
PowerOff = 'power off',
|
||||||
|
Restart = 'restart',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum PowerState {
|
||||||
|
Running = 'Running',
|
||||||
|
Sleep = 'Sleep',
|
||||||
|
Off = 'Off',
|
||||||
|
Hibernate = 'Hibernate',
|
||||||
|
PowerCycle = 'Power Cycle',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum PowerStateCode {
|
||||||
|
On = 2,
|
||||||
|
SleepLight = 3,
|
||||||
|
SleepDeep = 4,
|
||||||
|
OffHard = 6,
|
||||||
|
Hibernate = 7,
|
||||||
|
OffSoft = 8,
|
||||||
|
PowerCycle = 9,
|
||||||
|
OffHardGraceful = 13,
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
import angular from 'angular';
|
import angular from 'angular';
|
||||||
|
|
||||||
import { ScheduleCreateRequest, ScheduleUpdateRequest } from 'Portainer/models/schedule';
|
import { ScheduleCreateRequest, ScheduleUpdateRequest } from '@/portainer/models/schedule';
|
||||||
|
|
||||||
function EdgeJobService(EdgeJobs, EdgeJobResults, FileUploadService) {
|
function EdgeJobService(EdgeJobs, EdgeJobResults, FileUploadService) {
|
||||||
var service = {};
|
var service = {};
|
||||||
|
|
|
@ -0,0 +1,39 @@
|
||||||
|
<rd-header>
|
||||||
|
<rd-header-title title-text="Edge Devices">
|
||||||
|
<a data-toggle="tooltip" title="Refresh" ui-sref="edge.devices" ui-sref-opts="{reload: true}">
|
||||||
|
<i class="fa fa-sync" aria-hidden="true"></i>
|
||||||
|
</a>
|
||||||
|
</rd-header-title>
|
||||||
|
<rd-header-content>Edge Devices</rd-header-content>
|
||||||
|
</rd-header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="row"
|
||||||
|
style="width: 100%; height: 100%; text-align: center; display: flex; flex-direction: column; align-items: center; justify-content: center"
|
||||||
|
ng-if="$ctrl.loadingMessage"
|
||||||
|
>
|
||||||
|
<div class="sk-fold">
|
||||||
|
<div class="sk-fold-cube"></div>
|
||||||
|
<div class="sk-fold-cube"></div>
|
||||||
|
<div class="sk-fold-cube"></div>
|
||||||
|
<div class="sk-fold-cube"></div>
|
||||||
|
</div>
|
||||||
|
<span style="margin-top: 25px">
|
||||||
|
{{ $ctrl.loadingMessage }}
|
||||||
|
<i class="fa fa-cog fa-spin"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row" ng-if="!$ctrl.loadingMessage">
|
||||||
|
<div class="col-sm-12">
|
||||||
|
<edge-devices-datatable
|
||||||
|
dataset="($ctrl.edgeDevices)"
|
||||||
|
is-fdo-enabled="($ctrl.isFDOEnabled)"
|
||||||
|
is-open-amt-enabled="($ctrl.isOpenAMTEnabled)"
|
||||||
|
disable-trust-on-first-connect="($ctrl.disableTrustOnFirstConnect)"
|
||||||
|
mps-server="($ctrl.mpsServer)"
|
||||||
|
on-refresh="($ctrl.getEnvironments)"
|
||||||
|
set-loading-message="($ctrl.setLoadingMessage)"
|
||||||
|
></edge-devices-datatable>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -0,0 +1,52 @@
|
||||||
|
import EndpointHelper from 'Portainer/helpers/endpointHelper';
|
||||||
|
import { getEndpoints } from 'Portainer/environments/environment.service';
|
||||||
|
import { EnvironmentType } from 'Portainer/environments/types';
|
||||||
|
|
||||||
|
angular.module('portainer.edge').controller('EdgeDevicesViewController', EdgeDevicesViewController);
|
||||||
|
/* @ngInject */
|
||||||
|
export function EdgeDevicesViewController($q, $async, EndpointService, GroupService, SettingsService, ModalService, Notifications) {
|
||||||
|
var ctrl = this;
|
||||||
|
|
||||||
|
ctrl.edgeDevices = [];
|
||||||
|
|
||||||
|
this.getEnvironments = function () {
|
||||||
|
return $async(async () => {
|
||||||
|
try {
|
||||||
|
const [endpointsResponse, groups] = await Promise.all([getEndpoints(0, 100, { types: [EnvironmentType.EdgeAgentOnDocker] }), GroupService.groups()]);
|
||||||
|
EndpointHelper.mapGroupNameToEndpoint(endpointsResponse.value, groups);
|
||||||
|
ctrl.edgeDevices = endpointsResponse.value;
|
||||||
|
} catch (err) {
|
||||||
|
Notifications.error('Failure', err, 'Unable to retrieve edge devices');
|
||||||
|
ctrl.edgeDevices = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
this.getSettings = function () {
|
||||||
|
return $async(async () => {
|
||||||
|
try {
|
||||||
|
const settings = await SettingsService.settings();
|
||||||
|
|
||||||
|
ctrl.isFDOEnabled = settings && settings.EnableEdgeComputeFeatures && settings.fdoConfiguration && settings.fdoConfiguration.enabled;
|
||||||
|
ctrl.disableTrustOnFirstConnect = settings && settings.EnableEdgeComputeFeatures && settings.DisableTrustOnFirstConnect;
|
||||||
|
ctrl.isOpenAMTEnabled = settings && settings.EnableEdgeComputeFeatures && settings.openAMTConfiguration && settings.openAMTConfiguration.enabled;
|
||||||
|
ctrl.mpsServer = ctrl.isOpenAMTEnabled ? settings.openAMTConfiguration.mpsServer : '';
|
||||||
|
} catch (err) {
|
||||||
|
Notifications.error('Failure', err, 'Unable to retrieve settings');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
this.setLoadingMessage = function (message) {
|
||||||
|
return $async(async () => {
|
||||||
|
ctrl.loadingMessage = message;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function initView() {
|
||||||
|
ctrl.getEnvironments();
|
||||||
|
ctrl.getSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
initView();
|
||||||
|
}
|
8
app/edge/views/edge-devices/edgeDevicesView/index.js
Normal file
8
app/edge/views/edge-devices/edgeDevicesView/index.js
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import angular from 'angular';
|
||||||
|
|
||||||
|
import { EdgeDevicesViewController } from './edgeDevicesViewController';
|
||||||
|
|
||||||
|
angular.module('portainer.edge').component('edgeDevicesView', {
|
||||||
|
templateUrl: './edgeDevicesView.html',
|
||||||
|
controller: EdgeDevicesViewController,
|
||||||
|
});
|
2
app/global.d.ts
vendored
2
app/global.d.ts
vendored
|
@ -6,3 +6,5 @@ declare module '*.png' {
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '*.css';
|
declare module '*.css';
|
||||||
|
|
||||||
|
declare module '@open-amt-cloud-toolkit/ui-toolkit-react/reactjs/src/kvm.bundle';
|
||||||
|
|
|
@ -205,6 +205,51 @@ angular
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var edgeDeviceCreation = {
|
||||||
|
name: 'portainer.endpoints.newEdgeDevice',
|
||||||
|
url: '/newEdgeDevice',
|
||||||
|
params: {
|
||||||
|
isEdgeDevice: true,
|
||||||
|
},
|
||||||
|
views: {
|
||||||
|
'content@': {
|
||||||
|
templateUrl: './views/endpoints/create/createendpoint.html',
|
||||||
|
controller: 'CreateEndpointController',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var deviceImport = {
|
||||||
|
name: 'portainer.endpoints.importDevice',
|
||||||
|
url: '/device',
|
||||||
|
views: {
|
||||||
|
'content@': {
|
||||||
|
templateUrl: './views/devices/import/importDevice.html',
|
||||||
|
controller: 'ImportDeviceController',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var addFDOProfile = {
|
||||||
|
name: 'portainer.endpoints.profile',
|
||||||
|
url: '/profile',
|
||||||
|
views: {
|
||||||
|
'content@': {
|
||||||
|
component: 'addProfileView',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var editFDOProfile = {
|
||||||
|
name: 'portainer.endpoints.profile.edit',
|
||||||
|
url: '/:id',
|
||||||
|
views: {
|
||||||
|
'content@': {
|
||||||
|
component: 'editProfileView',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
var endpointAccess = {
|
var endpointAccess = {
|
||||||
name: 'portainer.endpoints.endpoint.access',
|
name: 'portainer.endpoints.endpoint.access',
|
||||||
url: '/access',
|
url: '/access',
|
||||||
|
@ -217,6 +262,17 @@ angular
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var endpointKVM = {
|
||||||
|
name: 'portainer.endpoints.endpoint.kvm',
|
||||||
|
url: '/kvm?deviceId&deviceName',
|
||||||
|
views: {
|
||||||
|
'content@': {
|
||||||
|
templateUrl: './views/endpoints/kvm/endpointKVM.html',
|
||||||
|
controller: 'EndpointKVMController',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
var groups = {
|
var groups = {
|
||||||
name: 'portainer.groups',
|
name: 'portainer.groups',
|
||||||
url: '/groups',
|
url: '/groups',
|
||||||
|
@ -378,6 +434,16 @@ angular
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var settingsEdgeCompute = {
|
||||||
|
name: 'portainer.settings.edgeCompute',
|
||||||
|
url: '/edge',
|
||||||
|
views: {
|
||||||
|
'content@': {
|
||||||
|
component: 'settingsEdgeComputeView',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
var tags = {
|
var tags = {
|
||||||
name: 'portainer.tags',
|
name: 'portainer.tags',
|
||||||
url: '/tags',
|
url: '/tags',
|
||||||
|
@ -444,7 +510,12 @@ angular
|
||||||
$stateRegistryProvider.register(endpoint);
|
$stateRegistryProvider.register(endpoint);
|
||||||
$stateRegistryProvider.register(k8sendpoint);
|
$stateRegistryProvider.register(k8sendpoint);
|
||||||
$stateRegistryProvider.register(endpointAccess);
|
$stateRegistryProvider.register(endpointAccess);
|
||||||
|
$stateRegistryProvider.register(endpointKVM);
|
||||||
|
$stateRegistryProvider.register(edgeDeviceCreation);
|
||||||
$stateRegistryProvider.register(endpointCreation);
|
$stateRegistryProvider.register(endpointCreation);
|
||||||
|
$stateRegistryProvider.register(deviceImport);
|
||||||
|
$stateRegistryProvider.register(addFDOProfile);
|
||||||
|
$stateRegistryProvider.register(editFDOProfile);
|
||||||
$stateRegistryProvider.register(endpointKubernetesConfiguration);
|
$stateRegistryProvider.register(endpointKubernetesConfiguration);
|
||||||
$stateRegistryProvider.register(groups);
|
$stateRegistryProvider.register(groups);
|
||||||
$stateRegistryProvider.register(group);
|
$stateRegistryProvider.register(group);
|
||||||
|
@ -461,6 +532,7 @@ angular
|
||||||
$stateRegistryProvider.register(registryCreation);
|
$stateRegistryProvider.register(registryCreation);
|
||||||
$stateRegistryProvider.register(settings);
|
$stateRegistryProvider.register(settings);
|
||||||
$stateRegistryProvider.register(settingsAuthentication);
|
$stateRegistryProvider.register(settingsAuthentication);
|
||||||
|
$stateRegistryProvider.register(settingsEdgeCompute);
|
||||||
$stateRegistryProvider.register(tags);
|
$stateRegistryProvider.register(tags);
|
||||||
$stateRegistryProvider.register(users);
|
$stateRegistryProvider.register(users);
|
||||||
$stateRegistryProvider.register(user);
|
$stateRegistryProvider.register(user);
|
||||||
|
|
|
@ -3,14 +3,23 @@ import clsx from 'clsx';
|
||||||
|
|
||||||
import styles from './TextTip.module.css';
|
import styles from './TextTip.module.css';
|
||||||
|
|
||||||
export function TextTip({ children }: PropsWithChildren<unknown>) {
|
type Color = 'orange' | 'blue';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
color?: Color;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextTip({
|
||||||
|
color = 'orange',
|
||||||
|
children,
|
||||||
|
}: PropsWithChildren<Props>) {
|
||||||
return (
|
return (
|
||||||
<p className="text-muted small">
|
<p className="text-muted small">
|
||||||
<i
|
<i
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'fa fa-exclamation-circle',
|
'fa fa-exclamation-circle',
|
||||||
'orange-icon',
|
`${color}-icon`,
|
||||||
styles.textMargin
|
styles.textMargin
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -0,0 +1,25 @@
|
||||||
|
.actions {
|
||||||
|
float: right;
|
||||||
|
margin: 5px 10px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-active {
|
||||||
|
color: var(--blue-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-actions-menu-list {
|
||||||
|
padding: 0 10px 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-actions-menu-list [data-reach-menu-item] {
|
||||||
|
padding: 5px 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-actions-menu-btn {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-reach-menu-link] {
|
||||||
|
text-decoration: none !important;
|
||||||
|
}
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { Menu, MenuList, MenuButton } from '@reach/menu-button';
|
||||||
|
|
||||||
|
import styles from './ActionsMenu.module.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActionsMenu({ children }: Props) {
|
||||||
|
return (
|
||||||
|
<Menu className={styles.actions}>
|
||||||
|
{({ isExpanded }) => (
|
||||||
|
<>
|
||||||
|
<MenuButton
|
||||||
|
className={clsx(
|
||||||
|
styles.tableActionsMenuBtn,
|
||||||
|
isExpanded && styles.actionsActive
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<i className="fa fa-ellipsis-v" aria-hidden="true" />
|
||||||
|
</MenuButton>
|
||||||
|
<MenuList>
|
||||||
|
<div className={styles.tableActionsMenuList}>{children}</div>
|
||||||
|
</MenuList>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
.table-actions-title {
|
||||||
|
color: var(--blue-2);
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import styles from './ActionsMenuTitle.module.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActionsMenuTitle({ children }: Props) {
|
||||||
|
return <div className={styles.tableActionsTitle}>{children}</div>;
|
||||||
|
}
|
|
@ -3,17 +3,20 @@ import { Menu, MenuButton, MenuList } from '@reach/menu-button';
|
||||||
import { ColumnInstance } from 'react-table';
|
import { ColumnInstance } from 'react-table';
|
||||||
|
|
||||||
import { Checkbox } from '@/portainer/components/form-components/Checkbox';
|
import { Checkbox } from '@/portainer/components/form-components/Checkbox';
|
||||||
import type { DockerContainer } from '@/docker/containers/types';
|
|
||||||
|
|
||||||
import { useTableContext } from './TableContainer';
|
import { useTableContext } from './TableContainer';
|
||||||
|
|
||||||
interface Props {
|
interface Props<D extends object> {
|
||||||
columns: ColumnInstance<DockerContainer>[];
|
columns: ColumnInstance<D>[];
|
||||||
onChange: (value: string[]) => void;
|
onChange: (value: string[]) => void;
|
||||||
value: string[];
|
value: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ColumnVisibilityMenu({ columns, onChange, value }: Props) {
|
export function ColumnVisibilityMenu<D extends object>({
|
||||||
|
columns,
|
||||||
|
onChange,
|
||||||
|
value,
|
||||||
|
}: Props<D>) {
|
||||||
useTableContext();
|
useTableContext();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { PropsWithChildren } from 'react';
|
||||||
|
import { Row, TableInstance } from 'react-table';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
row: Row<TableInstance>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExpandingCell({ children, row }: PropsWithChildren<Props>) {
|
||||||
|
return (
|
||||||
|
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||||
|
<div {...row.getToggleRowExpandedProps()}>
|
||||||
|
<i
|
||||||
|
className={`fas ${arrowClass(row.isExpanded)} space-right`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
function arrowClass(isExpanded: boolean) {
|
||||||
|
if (isExpanded) {
|
||||||
|
return 'fa-angle-down';
|
||||||
|
}
|
||||||
|
return 'fa-angle-right';
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
.inner-datatable thead {
|
||||||
|
background-color: var(--bg-inner-datatable-thead) !important;
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { PropsWithChildren } from 'react';
|
||||||
|
|
||||||
|
import styles from './InnerDatatable.module.css';
|
||||||
|
|
||||||
|
export function InnerDatatable({ children }: PropsWithChildren<unknown>) {
|
||||||
|
return <div className={styles.innerDatatable}>{children}</div>;
|
||||||
|
}
|
|
@ -5,7 +5,7 @@ import { TableHeaderCell } from './TableHeaderCell';
|
||||||
|
|
||||||
interface Props<D extends Record<string, unknown> = Record<string, unknown>> {
|
interface Props<D extends Record<string, unknown> = Record<string, unknown>> {
|
||||||
headers: HeaderGroup<D>[];
|
headers: HeaderGroup<D>[];
|
||||||
onSortChange(colId: string, desc: boolean): void;
|
onSortChange?(colId: string, desc: boolean): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TableHeaderRow<
|
export function TableHeaderRow<
|
||||||
|
@ -26,13 +26,18 @@ export function TableHeaderRow<
|
||||||
headerProps={{
|
headerProps={{
|
||||||
...column.getHeaderProps({
|
...column.getHeaderProps({
|
||||||
className: column.className,
|
className: column.className,
|
||||||
|
style: {
|
||||||
|
width: column.disableResizing ? column.width : '',
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
key={column.id}
|
key={column.id}
|
||||||
canSort={column.canSort}
|
canSort={column.canSort}
|
||||||
onSortClick={(desc) => {
|
onSortClick={(desc) => {
|
||||||
column.toggleSortBy(desc);
|
column.toggleSortBy(desc);
|
||||||
onSortChange(column.id, desc);
|
if (onSortChange) {
|
||||||
|
onSortChange(column.id, desc);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
isSorted={column.isSorted}
|
isSorted={column.isSorted}
|
||||||
isSortedDesc={column.isSortedDesc}
|
isSortedDesc={column.isSortedDesc}
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { PropsWithChildren, ReactNode } from 'react';
|
||||||
import { useTableContext } from './TableContainer';
|
import { useTableContext } from './TableContainer';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
quickActions: ReactNode;
|
quickActions?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TableSettingsMenu({
|
export function TableSettingsMenu({
|
||||||
|
|
|
@ -15,7 +15,7 @@
|
||||||
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove
|
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-sm btn-primary" ui-sref="portainer.endpoints.new" data-cy="endpoint-addEndpointButton">
|
<button type="button" class="btn btn-sm btn-primary" ui-sref="portainer.endpoints.new" data-cy="endpoint-addEndpointButton">
|
||||||
<i class="fa fa-plus space-right" aria-hidden="true"></i>Add environment
|
<i class="fa fa-plus-circle space-right" aria-hidden="true"></i>Add environment
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="searchBar">
|
<div class="searchBar">
|
||||||
|
|
|
@ -3,6 +3,7 @@ angular.module('portainer.app').controller('EndpointsDatatableController', [
|
||||||
'$controller',
|
'$controller',
|
||||||
'DatatableService',
|
'DatatableService',
|
||||||
'PaginationService',
|
'PaginationService',
|
||||||
|
'Notifications',
|
||||||
function ($scope, $controller, DatatableService, PaginationService) {
|
function ($scope, $controller, DatatableService, PaginationService) {
|
||||||
angular.extend(this, $controller('GenericDatatableController', { $scope: $scope }));
|
angular.extend(this, $controller('GenericDatatableController', { $scope: $scope }));
|
||||||
|
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue