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>pull/6393/head
parent
3ed92e5fee
commit
2c4c638f46
|
@ -554,7 +554,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
|||
oauthService := initOAuthService()
|
||||
gitService := initGitService()
|
||||
|
||||
openAMTService := openamt.NewService(dataStore)
|
||||
openAMTService := openamt.NewService()
|
||||
|
||||
cryptoService := initCryptoService()
|
||||
digitalSignatureService := initDigitalSignatureService()
|
||||
|
|
|
@ -29,11 +29,6 @@ func Test_enableFeaturesFromFlags(t *testing.T) {
|
|||
isSupported bool
|
||||
}{
|
||||
{"test", false},
|
||||
{"openamt", false},
|
||||
{"open-amt", true},
|
||||
{"oPeN-amT", true},
|
||||
{"fdo", true},
|
||||
{"FDO", true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(fmt.Sprintf("%s succeeds:%v", test.featureFlag, test.isSupported), func(t *testing.T) {
|
||||
|
|
|
@ -10,7 +10,7 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
jsonobject = `{"LogoURL":"","BlackListedLabels":[],"AuthenticationMethod":1,"LDAPSettings":{"AnonymousMode":true,"ReaderDN":"","URL":"","TLSConfig":{"TLS":false,"TLSSkipVerify":false},"StartTLS":false,"SearchSettings":[{"BaseDN":"","Filter":"","UserNameAttribute":""}],"GroupSearchSettings":[{"GroupBaseDN":"","GroupFilter":"","GroupAttribute":""}],"AutoCreateUsers":true},"OAuthSettings":{"ClientID":"","AccessTokenURI":"","AuthorizationURI":"","ResourceURI":"","RedirectURI":"","UserIdentifier":"","Scopes":"","OAuthAutoCreateUsers":false,"DefaultTeamID":0,"SSO":true,"LogoutURI":"","KubeSecretKey":"j0zLVtY/lAWBk62ByyF0uP80SOXaitsABP0TTJX8MhI="},"OpenAMTConfiguration":{"Enabled":false,"MPSServer":"","Credentials":{"MPSUser":"","MPSPassword":"","MPSToken":""},"DomainConfiguration":{"CertFileText":"","CertPassword":"","DomainName":""},"WirelessConfiguration":null},"FeatureFlagSettings":{},"SnapshotInterval":"5m","TemplatesURL":"https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json","EdgeAgentCheckinInterval":5,"EnableEdgeComputeFeatures":false,"UserSessionTimeout":"8h","KubeconfigExpiry":"0","EnableTelemetry":true,"HelmRepositoryURL":"https://charts.bitnami.com/bitnami","KubectlShellImage":"portainer/kubectl-shell","DisplayDonationHeader":false,"DisplayExternalContributors":false,"EnableHostManagementFeatures":false,"AllowVolumeBrowserForRegularUsers":false,"AllowBindMountsForRegularUsers":false,"AllowPrivilegedModeForRegularUsers":false,"AllowHostNamespaceForRegularUsers":false,"AllowStackManagementForRegularUsers":false,"AllowDeviceMappingForRegularUsers":false,"AllowContainerCapabilitiesForRegularUsers":false}`
|
||||
jsonobject = `{"LogoURL":"","BlackListedLabels":[],"AuthenticationMethod":1,"LDAPSettings":{"AnonymousMode":true,"ReaderDN":"","URL":"","TLSConfig":{"TLS":false,"TLSSkipVerify":false},"StartTLS":false,"SearchSettings":[{"BaseDN":"","Filter":"","UserNameAttribute":""}],"GroupSearchSettings":[{"GroupBaseDN":"","GroupFilter":"","GroupAttribute":""}],"AutoCreateUsers":true},"OAuthSettings":{"ClientID":"","AccessTokenURI":"","AuthorizationURI":"","ResourceURI":"","RedirectURI":"","UserIdentifier":"","Scopes":"","OAuthAutoCreateUsers":false,"DefaultTeamID":0,"SSO":true,"LogoutURI":"","KubeSecretKey":"j0zLVtY/lAWBk62ByyF0uP80SOXaitsABP0TTJX8MhI="},"OpenAMTConfiguration":{"Enabled":false,"MPSServer":"","MPSUser":"","MPSPassword":"","MPSToken":"","CertFileContent":"","CertFileName":"","CertFilePassword":"","DomainName":""},"FeatureFlagSettings":{},"SnapshotInterval":"5m","TemplatesURL":"https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json","EdgeAgentCheckinInterval":5,"EnableEdgeComputeFeatures":false,"UserSessionTimeout":"8h","KubeconfigExpiry":"0","EnableTelemetry":true,"HelmRepositoryURL":"https://charts.bitnami.com/bitnami","KubectlShellImage":"portainer/kubectl-shell","DisplayDonationHeader":false,"DisplayExternalContributors":false,"EnableHostManagementFeatures":false,"AllowVolumeBrowserForRegularUsers":false,"AllowBindMountsForRegularUsers":false,"AllowPrivilegedModeForRegularUsers":false,"AllowHostNamespaceForRegularUsers":false,"AllowStackManagementForRegularUsers":false,"AllowDeviceMappingForRegularUsers":false,"AllowContainerCapabilitiesForRegularUsers":false}`
|
||||
passphrase = "my secret key"
|
||||
)
|
||||
|
||||
|
|
|
@ -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
|
||||
EndpointGroup() EndpointGroupService
|
||||
EndpointRelation() EndpointRelationService
|
||||
FDOProfile() FDOProfileService
|
||||
HelmUserRepository() HelmUserRepositoryService
|
||||
Registry() RegistryService
|
||||
ResourceControl() ResourceControlService
|
||||
|
@ -122,6 +123,17 @@ type (
|
|||
BucketName() string
|
||||
}
|
||||
|
||||
// FDOProfileService represents a service to manage FDO Profiles
|
||||
FDOProfileService interface {
|
||||
FDOProfiles() ([]portainer.FDOProfile, error)
|
||||
FDOProfile(ID portainer.FDOProfileID) (*portainer.FDOProfile, error)
|
||||
Create(FDOProfile *portainer.FDOProfile) error
|
||||
Update(ID portainer.FDOProfileID, FDOProfile *portainer.FDOProfile) error
|
||||
Delete(ID portainer.FDOProfileID) error
|
||||
GetNextIdentifier() int
|
||||
BucketName() string
|
||||
}
|
||||
|
||||
// HelmUserRepositoryService represents a service to manage HelmUserRepositories
|
||||
HelmUserRepositoryService interface {
|
||||
HelmUserRepositories() ([]portainer.HelmUserRepository, error)
|
||||
|
|
|
@ -30,6 +30,7 @@ func (store *Store) MigrateData() error {
|
|||
EndpointService: store.EndpointService,
|
||||
EndpointRelationService: store.EndpointRelationService,
|
||||
ExtensionService: store.ExtensionService,
|
||||
FDOProfilesService: store.FDOProfilesService,
|
||||
RegistryService: store.RegistryService,
|
||||
ResourceControlService: store.ResourceControlService,
|
||||
RoleService: store.RoleService,
|
||||
|
|
|
@ -7,6 +7,7 @@ import (
|
|||
"github.com/portainer/portainer/api/dataservices/endpointgroup"
|
||||
"github.com/portainer/portainer/api/dataservices/endpointrelation"
|
||||
"github.com/portainer/portainer/api/dataservices/extension"
|
||||
"github.com/portainer/portainer/api/dataservices/fdoprofile"
|
||||
"github.com/portainer/portainer/api/dataservices/registry"
|
||||
"github.com/portainer/portainer/api/dataservices/resourcecontrol"
|
||||
"github.com/portainer/portainer/api/dataservices/role"
|
||||
|
@ -26,12 +27,12 @@ var migrateLog = plog.NewScopedLog("database, migrate")
|
|||
type (
|
||||
// Migrator defines a service to migrate data after a Portainer version update.
|
||||
Migrator struct {
|
||||
currentDBVersion int
|
||||
|
||||
currentDBVersion int
|
||||
endpointGroupService *endpointgroup.Service
|
||||
endpointService *endpoint.Service
|
||||
endpointRelationService *endpointrelation.Service
|
||||
extensionService *extension.Service
|
||||
fdoProfilesService *fdoprofile.Service
|
||||
registryService *registry.Service
|
||||
resourceControlService *resourcecontrol.Service
|
||||
roleService *role.Service
|
||||
|
@ -54,6 +55,7 @@ type (
|
|||
EndpointService *endpoint.Service
|
||||
EndpointRelationService *endpointrelation.Service
|
||||
ExtensionService *extension.Service
|
||||
FDOProfilesService *fdoprofile.Service
|
||||
RegistryService *registry.Service
|
||||
ResourceControlService *resourcecontrol.Service
|
||||
RoleService *role.Service
|
||||
|
@ -78,6 +80,7 @@ func NewMigrator(parameters *MigratorParameters) *Migrator {
|
|||
endpointService: parameters.EndpointService,
|
||||
endpointRelationService: parameters.EndpointRelationService,
|
||||
extensionService: parameters.ExtensionService,
|
||||
fdoProfilesService: parameters.FDOProfilesService,
|
||||
registryService: parameters.RegistryService,
|
||||
resourceControlService: parameters.ResourceControlService,
|
||||
roleService: parameters.RoleService,
|
||||
|
|
|
@ -17,6 +17,7 @@ import (
|
|||
"github.com/portainer/portainer/api/dataservices/endpointgroup"
|
||||
"github.com/portainer/portainer/api/dataservices/endpointrelation"
|
||||
"github.com/portainer/portainer/api/dataservices/extension"
|
||||
"github.com/portainer/portainer/api/dataservices/fdoprofile"
|
||||
"github.com/portainer/portainer/api/dataservices/helmuserrepository"
|
||||
"github.com/portainer/portainer/api/dataservices/registry"
|
||||
"github.com/portainer/portainer/api/dataservices/resourcecontrol"
|
||||
|
@ -50,6 +51,7 @@ type Store struct {
|
|||
EndpointService *endpoint.Service
|
||||
EndpointRelationService *endpointrelation.Service
|
||||
ExtensionService *extension.Service
|
||||
FDOProfilesService *fdoprofile.Service
|
||||
HelmUserRepositoryService *helmuserrepository.Service
|
||||
RegistryService *registry.Service
|
||||
ResourceControlService *resourcecontrol.Service
|
||||
|
@ -129,6 +131,12 @@ func (store *Store) initServices() error {
|
|||
}
|
||||
store.ExtensionService = extensionService
|
||||
|
||||
fdoProfilesService, err := fdoprofile.NewService(store.connection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
store.FDOProfilesService = fdoProfilesService
|
||||
|
||||
helmUserRepositoryService, err := helmuserrepository.NewService(store.connection)
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -257,6 +265,11 @@ func (store *Store) EndpointRelation() dataservices.EndpointRelationService {
|
|||
return store.EndpointRelationService
|
||||
}
|
||||
|
||||
// FDOProfile gives access to the FDOProfile data management layer
|
||||
func (store *Store) FDOProfile() dataservices.FDOProfileService {
|
||||
return store.FDOProfilesService
|
||||
}
|
||||
|
||||
// HelmUserRepository access the helm user repository settings
|
||||
func (store *Store) HelmUserRepository() dataservices.HelmUserRepositoryService {
|
||||
return store.HelmUserRepositoryService
|
||||
|
|
|
@ -15,7 +15,7 @@ import (
|
|||
var errUnsupportedEnvironmentType = errors.New("Environment not supported")
|
||||
|
||||
const (
|
||||
defaultDockerRequestTimeout = 60
|
||||
defaultDockerRequestTimeout = 60 * time.Second
|
||||
dockerClientVersion = "1.37"
|
||||
)
|
||||
|
||||
|
@ -33,22 +33,23 @@ func NewClientFactory(signatureService portainer.DigitalSignatureService, revers
|
|||
}
|
||||
}
|
||||
|
||||
// createClient is a generic function to create a Docker client based on
|
||||
// CreateClient is a generic function to create a Docker client based on
|
||||
// a specific environment(endpoint) configuration. The nodeName parameter can be used
|
||||
// with an agent enabled environment(endpoint) to target a specific node in an agent cluster.
|
||||
func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint, nodeName string) (*client.Client, error) {
|
||||
// The underlying http client timeout may be specified, a default value is used otherwise.
|
||||
func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint, nodeName string, timeout *time.Duration) (*client.Client, error) {
|
||||
if endpoint.Type == portainer.AzureEnvironment {
|
||||
return nil, errUnsupportedEnvironmentType
|
||||
} else if endpoint.Type == portainer.AgentOnDockerEnvironment {
|
||||
return createAgentClient(endpoint, factory.signatureService, nodeName)
|
||||
return createAgentClient(endpoint, factory.signatureService, nodeName, timeout)
|
||||
} else if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment {
|
||||
return createEdgeClient(endpoint, factory.signatureService, factory.reverseTunnelService, nodeName)
|
||||
return createEdgeClient(endpoint, factory.signatureService, factory.reverseTunnelService, nodeName, timeout)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") {
|
||||
return createLocalClient(endpoint)
|
||||
}
|
||||
return createTCPClient(endpoint)
|
||||
return createTCPClient(endpoint, timeout)
|
||||
}
|
||||
|
||||
func createLocalClient(endpoint *portainer.Endpoint) (*client.Client, error) {
|
||||
|
@ -58,8 +59,8 @@ func createLocalClient(endpoint *portainer.Endpoint) (*client.Client, error) {
|
|||
)
|
||||
}
|
||||
|
||||
func createTCPClient(endpoint *portainer.Endpoint) (*client.Client, error) {
|
||||
httpCli, err := httpClient(endpoint)
|
||||
func createTCPClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*client.Client, error) {
|
||||
httpCli, err := httpClient(endpoint, timeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -71,8 +72,8 @@ func createTCPClient(endpoint *portainer.Endpoint) (*client.Client, error) {
|
|||
)
|
||||
}
|
||||
|
||||
func createEdgeClient(endpoint *portainer.Endpoint, signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, nodeName string) (*client.Client, error) {
|
||||
httpCli, err := httpClient(endpoint)
|
||||
func createEdgeClient(endpoint *portainer.Endpoint, signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, nodeName string, timeout *time.Duration) (*client.Client, error) {
|
||||
httpCli, err := httpClient(endpoint, timeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -106,8 +107,8 @@ func createEdgeClient(endpoint *portainer.Endpoint, signatureService portainer.D
|
|||
)
|
||||
}
|
||||
|
||||
func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer.DigitalSignatureService, nodeName string) (*client.Client, error) {
|
||||
httpCli, err := httpClient(endpoint)
|
||||
func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer.DigitalSignatureService, nodeName string, timeout *time.Duration) (*client.Client, error) {
|
||||
httpCli, err := httpClient(endpoint, timeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -134,7 +135,7 @@ func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer.
|
|||
)
|
||||
}
|
||||
|
||||
func httpClient(endpoint *portainer.Endpoint) (*http.Client, error) {
|
||||
func httpClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*http.Client, error) {
|
||||
transport := &http.Transport{}
|
||||
|
||||
if endpoint.TLSConfig.TLS {
|
||||
|
@ -145,8 +146,13 @@ func httpClient(endpoint *portainer.Endpoint) (*http.Client, error) {
|
|||
transport.TLSClientConfig = tlsConfig
|
||||
}
|
||||
|
||||
clientTimeout := defaultDockerRequestTimeout
|
||||
if timeout != nil {
|
||||
clientTimeout = *timeout
|
||||
}
|
||||
|
||||
return &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: defaultDockerRequestTimeout * time.Second,
|
||||
Timeout: clientTimeout,
|
||||
}, nil
|
||||
}
|
||||
|
|
|
@ -26,7 +26,7 @@ func NewSnapshotter(clientFactory *ClientFactory) *Snapshotter {
|
|||
|
||||
// CreateSnapshot creates a snapshot of a specific Docker environment(endpoint)
|
||||
func (snapshotter *Snapshotter) CreateSnapshot(endpoint *portainer.Endpoint) (*portainer.DockerSnapshot, error) {
|
||||
cli, err := snapshotter.clientFactory.CreateClient(endpoint, "")
|
||||
cli, err := snapshotter.clientFactory.CreateClient(endpoint, "", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
@ -35,6 +35,8 @@ const (
|
|||
ManifestFileDefaultName = "k8s-deployment.yml"
|
||||
// EdgeStackStorePath represents the subfolder where edge stack files are stored in the file store folder.
|
||||
EdgeStackStorePath = "edge_stacks"
|
||||
// FDOProfileStorePath represents the subfolder where FDO profiles files are stored in the file store folder.
|
||||
FDOProfileStorePath = "fdo_profiles"
|
||||
// PrivateKeyFile represents the name on disk of the file containing the private key.
|
||||
PrivateKeyFile = "portainer.key"
|
||||
// PublicKeyFile represents the name on disk of the file containing the public key.
|
||||
|
@ -652,3 +654,20 @@ func MoveDirectory(originalPath, newPath string) error {
|
|||
|
||||
return os.Rename(originalPath, newPath)
|
||||
}
|
||||
|
||||
// StoreFDOProfileFileFromBytes creates a subfolder in the FDOProfileStorePath and stores a new file from bytes.
|
||||
// It returns the path to the folder where the file is stored.
|
||||
func (service *Service) StoreFDOProfileFileFromBytes(fdoProfileIdentifier string, data []byte) (string, error) {
|
||||
err := service.createDirectoryInStore(FDOProfileStorePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
filePath := JoinPaths(FDOProfileStorePath, fdoProfileIdentifier)
|
||||
err = service.createFileInStore(filePath, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return service.wrapFileStore(filePath), nil
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ require (
|
|||
github.com/dgrijalva/jwt-go v3.2.0+incompatible
|
||||
github.com/docker/cli v20.10.9+incompatible
|
||||
github.com/docker/docker v20.10.9+incompatible
|
||||
github.com/fxamacker/cbor/v2 v2.3.0
|
||||
github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814
|
||||
github.com/go-git/go-git/v5 v5.3.0
|
||||
github.com/go-ldap/ldap/v3 v3.1.8
|
||||
|
@ -33,6 +34,7 @@ require (
|
|||
github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a
|
||||
github.com/portainer/libhelm v0.0.0-20210929000907-825e93d62108
|
||||
github.com/portainer/libhttp v0.0.0-20211021135806-13e6c55c5fbc
|
||||
github.com/rkl-/digest v0.0.0-20180419075440-8316caa4a777
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/sirupsen/logrus v1.8.1
|
||||
github.com/stretchr/testify v1.7.0
|
||||
|
@ -94,6 +96,7 @@ require (
|
|||
github.com/sergi/go-diff v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.0 // indirect
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
|
||||
|
|
|
@ -300,6 +300,8 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo
|
|||
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa/go.mod h1:KnogPXtdwXqoenmZCw6S+25EAm2MkxbG0deNDu4cbSA=
|
||||
github.com/fxamacker/cbor/v2 v2.3.0 h1:aM45YGMctNakddNNAezPxDUpv38j44Abh+hifNuqXik=
|
||||
github.com/fxamacker/cbor/v2 v2.3.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo=
|
||||
github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814 h1:gWvniJ4GbFfkf700kykAImbLiEMU0Q3QN9hQ26Js1pU=
|
||||
github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814/go.mod h1:secRm32Ro77eD23BmPVbgLbWN+JWDw7pJszenjxI4bI=
|
||||
github.com/garyburd/redigo v0.0.0-20150301180006-535138d7bcd7/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY=
|
||||
|
@ -649,6 +651,8 @@ github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4O
|
|||
github.com/prometheus/procfs v0.2.0/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
|
||||
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
|
||||
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
|
||||
github.com/rkl-/digest v0.0.0-20180419075440-8316caa4a777 h1:rDj3WeO+TiWyxfcydUnKegWAZoR5kQsnW0wzhggdOrw=
|
||||
github.com/rkl-/digest v0.0.0-20180419075440-8316caa4a777/go.mod h1:xRVvTK+cS/dJSvrOufGUQFWfgvE7yXExeng96n8377o=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
|
||||
|
@ -722,6 +726,8 @@ github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17
|
|||
github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
|
||||
github.com/willf/bitset v1.1.11-0.20200630133818-d5bec3311243/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
|
||||
github.com/willf/bitset v1.1.11/go.mod h1:83CECat5yLh5zVOf4P1ErAgKA5UDvKtgyUABdr3+MjI=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xanzy/ssh-agent v0.3.0 h1:wUMzuKtKilRgBAD1sUb8gOwwRr2FGoBVumcjoOACClI=
|
||||
github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=
|
||||
|
|
|
@ -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"`
|
||||
}
|
||||
|
||||
func (service *Service) executeAuthenticationRequest(configuration portainer.OpenAMTConfiguration) (*authenticationResponse, error) {
|
||||
func (service *Service) Authorization(configuration portainer.OpenAMTConfiguration) (string, error) {
|
||||
loginURL := fmt.Sprintf("https://%s/mps/login/api/v1/authorize", configuration.MPSServer)
|
||||
|
||||
payload := map[string]string{
|
||||
"username": configuration.Credentials.MPSUser,
|
||||
"password": configuration.Credentials.MPSPassword,
|
||||
"username": configuration.MPSUser,
|
||||
"password": configuration.MPSPassword,
|
||||
}
|
||||
jsonValue, _ := json.Marshal(payload)
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, loginURL, bytes.NewBuffer(jsonValue))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
response, err := service.httpsClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", err
|
||||
}
|
||||
responseBody, readErr := ioutil.ReadAll(response.Body)
|
||||
if readErr != nil {
|
||||
return nil, readErr
|
||||
return "", readErr
|
||||
}
|
||||
errorResponse := parseError(responseBody)
|
||||
if errorResponse != nil {
|
||||
return nil, errorResponse
|
||||
return "", errorResponse
|
||||
}
|
||||
|
||||
var token authenticationResponse
|
||||
err = json.Unmarshal(responseBody, &token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", err
|
||||
}
|
||||
|
||||
return &token, nil
|
||||
return token.Token, nil
|
||||
}
|
||||
|
|
|
@ -4,7 +4,6 @@ import (
|
|||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
|
@ -47,7 +46,7 @@ func (service *Service) createOrUpdateCIRAConfig(configuration portainer.OpenAMT
|
|||
func (service *Service) getCIRAConfig(configuration portainer.OpenAMTConfiguration, configName string) (*CIRAConfig, error) {
|
||||
url := fmt.Sprintf("https://%s/rps/api/v1/admin/ciraconfigs/%s", configuration.MPSServer, configName)
|
||||
|
||||
responseBody, err := service.executeGetRequest(url, configuration.Credentials.MPSToken)
|
||||
responseBody, err := service.executeGetRequest(url, configuration.MPSToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -89,7 +88,7 @@ func (service *Service) saveCIRAConfig(method string, configuration portainer.Op
|
|||
}
|
||||
payload, _ := json.Marshal(config)
|
||||
|
||||
responseBody, err := service.executeSaveRequest(method, url, configuration.Credentials.MPSToken, payload)
|
||||
responseBody, err := service.executeSaveRequest(method, url, configuration.MPSToken, payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -123,7 +122,7 @@ func (service *Service) getCIRACertificate(configuration portainer.OpenAMTConfig
|
|||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", configuration.Credentials.MPSToken))
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", configuration.MPSToken))
|
||||
|
||||
response, err := service.httpsClient.Do(req)
|
||||
if err != nil {
|
||||
|
@ -131,7 +130,7 @@ func (service *Service) getCIRACertificate(configuration portainer.OpenAMTConfig
|
|||
}
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return "", errors.New(fmt.Sprintf("unexpected status code %s", response.Status))
|
||||
return "", fmt.Errorf("unexpected status code %s", response.Status)
|
||||
}
|
||||
|
||||
certificate, err := io.ReadAll(response.Body)
|
||||
|
|
|
@ -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) {
|
||||
url := fmt.Sprintf("https://%s/rps/api/v1/admin/domains/%s", configuration.MPSServer, configuration.DomainConfiguration.DomainName)
|
||||
url := fmt.Sprintf("https://%s/rps/api/v1/admin/domains/%s", configuration.MPSServer, configuration.DomainName)
|
||||
|
||||
responseBody, err := service.executeGetRequest(url, configuration.Credentials.MPSToken)
|
||||
responseBody, err := service.executeGetRequest(url, configuration.MPSToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -59,15 +59,15 @@ func (service *Service) saveDomain(method string, configuration portainer.OpenAM
|
|||
url := fmt.Sprintf("https://%s/rps/api/v1/admin/domains", configuration.MPSServer)
|
||||
|
||||
profile := Domain{
|
||||
DomainName: configuration.DomainConfiguration.DomainName,
|
||||
DomainSuffix: configuration.DomainConfiguration.DomainName,
|
||||
ProvisioningCert: configuration.DomainConfiguration.CertFileText,
|
||||
ProvisioningCertPassword: configuration.DomainConfiguration.CertPassword,
|
||||
DomainName: configuration.DomainName,
|
||||
DomainSuffix: configuration.DomainName,
|
||||
ProvisioningCert: configuration.CertFileContent,
|
||||
ProvisioningCertPassword: configuration.CertFilePassword,
|
||||
ProvisioningCertStorageFormat: "string",
|
||||
}
|
||||
payload, _ := json.Marshal(profile)
|
||||
|
||||
responseBody, err := service.executeSaveRequest(method, url, configuration.Credentials.MPSToken, payload)
|
||||
responseBody, err := service.executeSaveRequest(method, url, configuration.MPSToken, payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
@ -29,7 +29,7 @@ type (
|
|||
}
|
||||
)
|
||||
|
||||
func (service *Service) createOrUpdateAMTProfile(configuration portainer.OpenAMTConfiguration, profileName string, ciraConfigName string, wirelessConfig string) (*Profile, error) {
|
||||
func (service *Service) createOrUpdateAMTProfile(configuration portainer.OpenAMTConfiguration, profileName string, ciraConfigName string) (*Profile, error) {
|
||||
profile, err := service.getAMTProfile(configuration, profileName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
@ -40,7 +40,7 @@ func (service *Service) createOrUpdateAMTProfile(configuration portainer.OpenAMT
|
|||
method = http.MethodPatch
|
||||
}
|
||||
|
||||
profile, err = service.saveAMTProfile(method, configuration, profileName, ciraConfigName, wirelessConfig)
|
||||
profile, err = service.saveAMTProfile(method, configuration, profileName, ciraConfigName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -50,7 +50,7 @@ func (service *Service) createOrUpdateAMTProfile(configuration portainer.OpenAMT
|
|||
func (service *Service) getAMTProfile(configuration portainer.OpenAMTConfiguration, profileName string) (*Profile, error) {
|
||||
url := fmt.Sprintf("https://%s/rps/api/v1/admin/profiles/%s", configuration.MPSServer, profileName)
|
||||
|
||||
responseBody, err := service.executeGetRequest(url, configuration.Credentials.MPSToken)
|
||||
responseBody, err := service.executeGetRequest(url, configuration.MPSToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -66,7 +66,7 @@ func (service *Service) getAMTProfile(configuration portainer.OpenAMTConfigurati
|
|||
return &result, nil
|
||||
}
|
||||
|
||||
func (service *Service) saveAMTProfile(method string, configuration portainer.OpenAMTConfiguration, profileName string, ciraConfigName string, wirelessConfig string) (*Profile, error) {
|
||||
func (service *Service) saveAMTProfile(method string, configuration portainer.OpenAMTConfiguration, profileName string, ciraConfigName string) (*Profile, error) {
|
||||
url := fmt.Sprintf("https://%s/rps/api/v1/admin/profiles", configuration.MPSServer)
|
||||
|
||||
profile := Profile{
|
||||
|
@ -74,23 +74,15 @@ func (service *Service) saveAMTProfile(method string, configuration portainer.Op
|
|||
Activation: "acmactivate",
|
||||
GenerateRandomAMTPassword: false,
|
||||
GenerateRandomMEBxPassword: false,
|
||||
AMTPassword: configuration.Credentials.MPSPassword,
|
||||
MEBXPassword: configuration.Credentials.MPSPassword,
|
||||
AMTPassword: configuration.MPSPassword,
|
||||
MEBXPassword: configuration.MPSPassword,
|
||||
CIRAConfigName: &ciraConfigName,
|
||||
Tags: []string{},
|
||||
DHCPEnabled: true,
|
||||
}
|
||||
if wirelessConfig != "" {
|
||||
profile.WIFIConfigs = []ProfileWifiConfig{
|
||||
{
|
||||
Priority: 1,
|
||||
ProfileName: DefaultWirelessConfigName,
|
||||
},
|
||||
}
|
||||
}
|
||||
payload, _ := json.Marshal(profile)
|
||||
|
||||
responseBody, err := service.executeSaveRequest(method, url, configuration.Credentials.MPSToken, payload)
|
||||
responseBody, err := service.executeSaveRequest(method, url, configuration.MPSToken, payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultCIRAConfigName = "ciraConfigDefault"
|
||||
DefaultWirelessConfigName = "wirelessProfileDefault"
|
||||
DefaultProfileName = "profileAMTDefault"
|
||||
DefaultCIRAConfigName = "ciraConfigDefault"
|
||||
DefaultProfileName = "profileAMTDefault"
|
||||
|
||||
httpClientTimeout = 5 * time.Minute
|
||||
|
||||
powerOnState portainer.PowerState = 2
|
||||
powerOffState portainer.PowerState = 8
|
||||
restartState portainer.PowerState = 5
|
||||
)
|
||||
|
||||
// Service represents a service for managing an OpenAMT server.
|
||||
|
@ -26,13 +31,10 @@ type Service struct {
|
|||
}
|
||||
|
||||
// NewService initializes a new service.
|
||||
func NewService(dataStore dataservices.DataStore) *Service {
|
||||
if !dataStore.Settings().IsFeatureFlagEnabled(portainer.FeatOpenAMT) {
|
||||
return nil
|
||||
}
|
||||
func NewService() *Service {
|
||||
return &Service{
|
||||
httpsClient: &http.Client{
|
||||
Timeout: time.Second * time.Duration(5),
|
||||
Timeout: httpClientTimeout,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
},
|
||||
|
@ -63,28 +65,19 @@ func parseError(responseBody []byte) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (service *Service) ConfigureDefault(configuration portainer.OpenAMTConfiguration) error {
|
||||
token, err := service.executeAuthenticationRequest(configuration)
|
||||
func (service *Service) Configure(configuration portainer.OpenAMTConfiguration) error {
|
||||
token, err := service.Authorization(configuration)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
configuration.Credentials.MPSToken = token.Token
|
||||
configuration.MPSToken = token
|
||||
|
||||
ciraConfig, err := service.createOrUpdateCIRAConfig(configuration, DefaultCIRAConfigName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
wirelessConfigName := ""
|
||||
if configuration.WirelessConfiguration != nil {
|
||||
wirelessConfig, err := service.createOrUpdateWirelessConfig(configuration, DefaultWirelessConfigName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wirelessConfigName = wirelessConfig.ProfileName
|
||||
}
|
||||
|
||||
_, err = service.createOrUpdateAMTProfile(configuration, DefaultProfileName, ciraConfig.ConfigName, wirelessConfigName)
|
||||
_, err = service.createOrUpdateAMTProfile(configuration, DefaultProfileName, ciraConfig.ConfigName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -119,7 +112,7 @@ func (service *Service) executeSaveRequest(method string, url string, token stri
|
|||
if errorResponse != nil {
|
||||
return nil, errorResponse
|
||||
}
|
||||
return nil, errors.New(fmt.Sprintf("unexpected status code %s", response.Status))
|
||||
return nil, fmt.Errorf("unexpected status code %s", response.Status)
|
||||
}
|
||||
|
||||
return responseBody, nil
|
||||
|
@ -151,8 +144,110 @@ func (service *Service) executeGetRequest(url string, token string) ([]byte, err
|
|||
if errorResponse != nil {
|
||||
return nil, errorResponse
|
||||
}
|
||||
return nil, errors.New(fmt.Sprintf("unexpected status code %s", response.Status))
|
||||
return nil, fmt.Errorf("unexpected status code %s", response.Status)
|
||||
}
|
||||
|
||||
return responseBody, nil
|
||||
}
|
||||
|
||||
func (service *Service) DeviceInformation(configuration portainer.OpenAMTConfiguration, deviceGUID string) (*portainer.OpenAMTDeviceInformation, error) {
|
||||
token, err := service.Authorization(configuration)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
configuration.MPSToken = token
|
||||
|
||||
var g errgroup.Group
|
||||
var resultDevice *Device
|
||||
var resultPowerState *DevicePowerState
|
||||
var resultEnabledFeatures *DeviceEnabledFeatures
|
||||
|
||||
g.Go(func() error {
|
||||
device, err := service.getDevice(configuration, deviceGUID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if device == nil {
|
||||
return fmt.Errorf("device %s not found", deviceGUID)
|
||||
}
|
||||
resultDevice = device
|
||||
return nil
|
||||
})
|
||||
|
||||
g.Go(func() error {
|
||||
powerState, err := service.getDevicePowerState(configuration, deviceGUID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resultPowerState = powerState
|
||||
return nil
|
||||
})
|
||||
|
||||
g.Go(func() error {
|
||||
enabledFeatures, err := service.getDeviceEnabledFeatures(configuration, deviceGUID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resultEnabledFeatures = enabledFeatures
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
deviceInformation := &portainer.OpenAMTDeviceInformation{
|
||||
GUID: resultDevice.GUID,
|
||||
HostName: resultDevice.HostName,
|
||||
ConnectionStatus: resultDevice.ConnectionStatus,
|
||||
}
|
||||
if resultPowerState != nil {
|
||||
deviceInformation.PowerState = resultPowerState.State
|
||||
}
|
||||
if resultEnabledFeatures != nil {
|
||||
deviceInformation.EnabledFeatures = &portainer.OpenAMTDeviceEnabledFeatures{
|
||||
Redirection: resultEnabledFeatures.Redirection,
|
||||
KVM: resultEnabledFeatures.KVM,
|
||||
SOL: resultEnabledFeatures.SOL,
|
||||
IDER: resultEnabledFeatures.IDER,
|
||||
UserConsent: resultEnabledFeatures.UserConsent,
|
||||
}
|
||||
}
|
||||
|
||||
return deviceInformation, nil
|
||||
}
|
||||
|
||||
func (service *Service) ExecuteDeviceAction(configuration portainer.OpenAMTConfiguration, deviceGUID string, action string) error {
|
||||
parsedAction, err := parseAction(action)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
token, err := service.Authorization(configuration)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
configuration.MPSToken = token
|
||||
|
||||
err = service.executeDeviceAction(configuration, deviceGUID, int(parsedAction))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *Service) EnableDeviceFeatures(configuration portainer.OpenAMTConfiguration, deviceGUID string, features portainer.OpenAMTDeviceEnabledFeatures) (string, error) {
|
||||
token, err := service.Authorization(configuration)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
configuration.MPSToken = token
|
||||
|
||||
err = service.enableDeviceFeatures(configuration, deviceGUID, features)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
|
@ -37,6 +38,7 @@ type endpointCreatePayload struct {
|
|||
AzureAuthenticationKey string
|
||||
TagIDs []portainer.TagID
|
||||
EdgeCheckinInterval int
|
||||
IsEdgeDevice bool
|
||||
}
|
||||
|
||||
type endpointCreationEnum int
|
||||
|
@ -144,6 +146,9 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
|
|||
checkinInterval, _ := request.RetrieveNumericMultiPartFormValue(r, "CheckinInterval", true)
|
||||
payload.EdgeCheckinInterval = checkinInterval
|
||||
|
||||
isEdgeDevice, _ := request.RetrieveBooleanMultiPartFormValue(r, "IsEdgeDevice", true)
|
||||
payload.IsEdgeDevice = isEdgeDevice
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -332,6 +337,21 @@ func (handler *Handler) createEdgeAgentEndpoint(payload *endpointCreatePayload)
|
|||
EdgeKey: edgeKey,
|
||||
EdgeCheckinInterval: payload.EdgeCheckinInterval,
|
||||
Kubernetes: portainer.KubernetesDefault(),
|
||||
IsEdgeDevice: payload.IsEdgeDevice,
|
||||
}
|
||||
|
||||
settings, err := handler.DataStore.Settings().Settings()
|
||||
if err != nil {
|
||||
return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve the settings from the database", err}
|
||||
}
|
||||
|
||||
if settings.EnforceEdgeID {
|
||||
edgeID, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
return nil, &httperror.HandlerError{http.StatusInternalServerError, "Cannot generate the Edge ID", err}
|
||||
}
|
||||
|
||||
endpoint.EdgeID = edgeID.String()
|
||||
}
|
||||
|
||||
err = handler.saveEndpointAndUpdateAuthorizations(endpoint)
|
||||
|
@ -370,6 +390,7 @@ func (handler *Handler) createUnsecuredEndpoint(payload *endpointCreatePayload)
|
|||
Status: portainer.EndpointStatusUp,
|
||||
Snapshots: []portainer.DockerSnapshot{},
|
||||
Kubernetes: portainer.KubernetesDefault(),
|
||||
IsEdgeDevice: payload.IsEdgeDevice,
|
||||
}
|
||||
|
||||
err := handler.snapshotAndPersistEndpoint(endpoint)
|
||||
|
@ -435,6 +456,7 @@ func (handler *Handler) createTLSSecuredEndpoint(payload *endpointCreatePayload,
|
|||
Status: portainer.EndpointStatusUp,
|
||||
Snapshots: []portainer.DockerSnapshot{},
|
||||
Kubernetes: portainer.KubernetesDefault(),
|
||||
IsEdgeDevice: payload.IsEdgeDevice,
|
||||
}
|
||||
|
||||
err := handler.storeTLSFiles(endpoint, payload)
|
||||
|
|
|
@ -89,6 +89,11 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
|
|||
filteredEndpoints = filterEndpointsByGroupID(filteredEndpoints, portainer.EndpointGroupID(groupID))
|
||||
}
|
||||
|
||||
edgeDeviceFilter, edgeDeviceFilterErr := request.RetrieveBooleanQueryParameter(r, "edgeDeviceFilter", false)
|
||||
if edgeDeviceFilterErr == nil {
|
||||
filteredEndpoints = filterEndpointsByEdgeDevice(filteredEndpoints, edgeDeviceFilter)
|
||||
}
|
||||
|
||||
if search != "" {
|
||||
tags, err := handler.DataStore.Tag().Tags()
|
||||
if err != nil {
|
||||
|
@ -231,6 +236,17 @@ func filterEndpointsByTypes(endpoints []portainer.Endpoint, endpointTypes []int)
|
|||
return filteredEndpoints
|
||||
}
|
||||
|
||||
func filterEndpointsByEdgeDevice(endpoints []portainer.Endpoint, edgeDeviceFilter bool) []portainer.Endpoint {
|
||||
filteredEndpoints := make([]portainer.Endpoint, 0)
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
if edgeDeviceFilter == endpoint.IsEdgeDevice {
|
||||
filteredEndpoints = append(filteredEndpoints, endpoint)
|
||||
}
|
||||
}
|
||||
return filteredEndpoints
|
||||
}
|
||||
|
||||
func convertTagIDsToTags(tagsMap map[portainer.TagID]string, tagIDs []portainer.TagID) []string {
|
||||
tags := make([]string, 0)
|
||||
for _, tagID := range tagIDs {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package endpoints
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
|
@ -37,7 +38,7 @@ func (handler *Handler) endpointSnapshot(w http.ResponseWriter, r *http.Request)
|
|||
}
|
||||
|
||||
if !snapshot.SupportDirectSnapshot(endpoint) {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Snapshots not supported for this environment", err}
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Snapshots not supported for this environment", errors.New("Snapshots not supported for this environment")}
|
||||
}
|
||||
|
||||
snapshotError := handler.SnapshotService.SnapshotEndpoint(endpoint)
|
||||
|
|
|
@ -46,6 +46,8 @@ type endpointUpdatePayload struct {
|
|||
EdgeCheckinInterval *int `example:"5"`
|
||||
// Associated Kubernetes data
|
||||
Kubernetes *portainer.KubernetesData
|
||||
// Whether the device has been trusted or not by the user
|
||||
UserTrusted *bool
|
||||
}
|
||||
|
||||
func (payload *endpointUpdatePayload) Validate(r *http.Request) error {
|
||||
|
@ -270,6 +272,10 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *
|
|||
}
|
||||
}
|
||||
|
||||
if payload.UserTrusted != nil {
|
||||
endpoint.UserTrusted = *payload.UserTrusted
|
||||
}
|
||||
|
||||
err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist environment changes inside the database", err}
|
||||
|
|
|
@ -17,6 +17,7 @@ import (
|
|||
"github.com/portainer/portainer/api/http/handler/endpoints"
|
||||
"github.com/portainer/portainer/api/http/handler/file"
|
||||
"github.com/portainer/portainer/api/http/handler/helm"
|
||||
"github.com/portainer/portainer/api/http/handler/hostmanagement/fdo"
|
||||
"github.com/portainer/portainer/api/http/handler/hostmanagement/openamt"
|
||||
"github.com/portainer/portainer/api/http/handler/kubernetes"
|
||||
"github.com/portainer/portainer/api/http/handler/ldap"
|
||||
|
@ -64,6 +65,7 @@ type Handler struct {
|
|||
SettingsHandler *settings.Handler
|
||||
SSLHandler *ssl.Handler
|
||||
OpenAMTHandler *openamt.Handler
|
||||
FDOHandler *fdo.Handler
|
||||
StackHandler *stacks.Handler
|
||||
StatusHandler *status.Handler
|
||||
StorybookHandler *storybook.Handler
|
||||
|
@ -228,9 +230,9 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||
case strings.HasPrefix(r.URL.Path, "/api/ssl"):
|
||||
http.StripPrefix("/api", h.SSLHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/open_amt"):
|
||||
if h.OpenAMTHandler != nil {
|
||||
http.StripPrefix("/api", h.OpenAMTHandler).ServeHTTP(w, r)
|
||||
}
|
||||
http.StripPrefix("/api", h.OpenAMTHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/fdo"):
|
||||
http.StripPrefix("/api", h.FDOHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/teams"):
|
||||
http.StripPrefix("/api", h.TeamHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/team_memberships"):
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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}
|
||||
`
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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),
|
||||
})
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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})
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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"
|
||||
)
|
||||
|
||||
type openAMTConfigureDefaultPayload struct {
|
||||
EnableOpenAMT bool
|
||||
MPSServer string
|
||||
MPSUser string
|
||||
MPSPassword string
|
||||
CertFileText string
|
||||
CertPassword string
|
||||
DomainName string
|
||||
UseWirelessConfig bool
|
||||
WifiAuthenticationMethod string
|
||||
WifiEncryptionMethod string
|
||||
WifiSSID string
|
||||
WifiPskPass string
|
||||
type openAMTConfigurePayload struct {
|
||||
Enabled bool
|
||||
MPSServer string
|
||||
MPSUser string
|
||||
MPSPassword string
|
||||
DomainName string
|
||||
CertFileName string
|
||||
CertFileContent string
|
||||
CertFilePassword string
|
||||
}
|
||||
|
||||
func (payload *openAMTConfigureDefaultPayload) Validate(r *http.Request) error {
|
||||
if payload.EnableOpenAMT {
|
||||
func (payload *openAMTConfigurePayload) Validate(r *http.Request) error {
|
||||
if payload.Enabled {
|
||||
if payload.MPSServer == "" {
|
||||
return errors.New("MPS Server must be provided")
|
||||
}
|
||||
|
@ -45,32 +41,21 @@ func (payload *openAMTConfigureDefaultPayload) Validate(r *http.Request) error {
|
|||
if payload.DomainName == "" {
|
||||
return errors.New("domain name must be provided")
|
||||
}
|
||||
if payload.CertFileText == "" {
|
||||
if payload.CertFileContent == "" {
|
||||
return errors.New("certificate file must be provided")
|
||||
}
|
||||
if payload.CertPassword == "" {
|
||||
return errors.New("certificate password must be provided")
|
||||
if payload.CertFileName == "" {
|
||||
return errors.New("certificate file name must be provided")
|
||||
}
|
||||
if payload.UseWirelessConfig {
|
||||
if payload.WifiAuthenticationMethod == "" {
|
||||
return errors.New("wireless authentication method must be provided")
|
||||
}
|
||||
if payload.WifiEncryptionMethod == "" {
|
||||
return errors.New("wireless encryption method must be provided")
|
||||
}
|
||||
if payload.WifiSSID == "" {
|
||||
return errors.New("wireless config SSID must be provided")
|
||||
}
|
||||
if payload.WifiPskPass == "" {
|
||||
return errors.New("wireless config PSK passphrase must be provided")
|
||||
}
|
||||
if payload.CertFilePassword == "" {
|
||||
return errors.New("certificate password must be provided")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// @id OpenAMTConfigureDefault
|
||||
// @id OpenAMTConfigure
|
||||
// @summary Enable Portainer's OpenAMT capabilities
|
||||
// @description Enable Portainer's OpenAMT capabilities
|
||||
// @description **Access policy**: administrator
|
||||
|
@ -78,22 +63,22 @@ func (payload *openAMTConfigureDefaultPayload) Validate(r *http.Request) error {
|
|||
// @security jwt
|
||||
// @accept json
|
||||
// @produce json
|
||||
// @param body body openAMTConfigureDefaultPayload true "OpenAMT Settings"
|
||||
// @param body body openAMTConfigurePayload true "OpenAMT Settings"
|
||||
// @success 204 "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied to access settings"
|
||||
// @failure 500 "Server error"
|
||||
// @router /open_amt [post]
|
||||
func (handler *Handler) openAMTConfigureDefault(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
var payload openAMTConfigureDefaultPayload
|
||||
func (handler *Handler) openAMTConfigure(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
var payload openAMTConfigurePayload
|
||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
logrus.WithError(err).Error("Invalid request payload")
|
||||
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
|
||||
}
|
||||
|
||||
if payload.EnableOpenAMT {
|
||||
certificateErr := validateCertificate(payload.CertFileText, payload.CertPassword)
|
||||
if payload.Enabled {
|
||||
certificateErr := validateCertificate(payload.CertFileContent, payload.CertFilePassword)
|
||||
if certificateErr != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Error validating certificate", Err: certificateErr}
|
||||
}
|
||||
|
@ -143,31 +128,19 @@ func isValidIssuer(issuer string) bool {
|
|||
strings.Contains(formattedIssuer, "godaddy")
|
||||
}
|
||||
|
||||
func (handler *Handler) enableOpenAMT(configurationPayload openAMTConfigureDefaultPayload) error {
|
||||
func (handler *Handler) enableOpenAMT(configurationPayload openAMTConfigurePayload) error {
|
||||
configuration := portainer.OpenAMTConfiguration{
|
||||
Enabled: true,
|
||||
MPSServer: configurationPayload.MPSServer,
|
||||
Credentials: portainer.MPSCredentials{
|
||||
MPSUser: configurationPayload.MPSUser,
|
||||
MPSPassword: configurationPayload.MPSPassword,
|
||||
},
|
||||
DomainConfiguration: portainer.DomainConfiguration{
|
||||
CertFileText: configurationPayload.CertFileText,
|
||||
CertPassword: configurationPayload.CertPassword,
|
||||
DomainName: configurationPayload.DomainName,
|
||||
},
|
||||
Enabled: true,
|
||||
MPSServer: configurationPayload.MPSServer,
|
||||
MPSUser: configurationPayload.MPSUser,
|
||||
MPSPassword: configurationPayload.MPSPassword,
|
||||
CertFileContent: configurationPayload.CertFileContent,
|
||||
CertFileName: configurationPayload.CertFileName,
|
||||
CertFilePassword: configurationPayload.CertFilePassword,
|
||||
DomainName: configurationPayload.DomainName,
|
||||
}
|
||||
|
||||
if configurationPayload.UseWirelessConfig {
|
||||
configuration.WirelessConfiguration = &portainer.WirelessConfiguration{
|
||||
AuthenticationMethod: configurationPayload.WifiAuthenticationMethod,
|
||||
EncryptionMethod: configurationPayload.WifiEncryptionMethod,
|
||||
SSID: configurationPayload.WifiSSID,
|
||||
PskPass: configurationPayload.WifiPskPass,
|
||||
}
|
||||
}
|
||||
|
||||
err := handler.OpenAMTService.ConfigureDefault(configuration)
|
||||
err := handler.OpenAMTService.Configure(configuration)
|
||||
if err != nil {
|
||||
logrus.WithError(err).Error("error configuring OpenAMT server")
|
||||
return err
|
||||
|
@ -189,7 +162,7 @@ func (handler *Handler) saveConfiguration(configuration portainer.OpenAMTConfigu
|
|||
return err
|
||||
}
|
||||
|
||||
configuration.Credentials.MPSToken = ""
|
||||
configuration.MPSToken = ""
|
||||
|
||||
settings.OpenAMTConfiguration = configuration
|
||||
err = handler.DataStore.Settings().UpdateSettings(settings)
|
|
@ -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)
|
||||
}
|
|
@ -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"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/docker"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
)
|
||||
|
||||
// Handler is the HTTP handler used to handle OpenAMT operations.
|
||||
type Handler struct {
|
||||
*mux.Router
|
||||
OpenAMTService portainer.OpenAMTService
|
||||
DataStore dataservices.DataStore
|
||||
OpenAMTService portainer.OpenAMTService
|
||||
DataStore dataservices.DataStore
|
||||
DockerClientFactory *docker.ClientFactory
|
||||
}
|
||||
|
||||
// NewHandler returns a new Handler
|
||||
func NewHandler(bouncer *security.RequestBouncer, dataStore dataservices.DataStore) (*Handler, error) {
|
||||
if !dataStore.Settings().IsFeatureFlagEnabled(portainer.FeatOpenAMT) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
||||
h := &Handler{
|
||||
Router: mux.NewRouter(),
|
||||
}
|
||||
|
||||
h.Handle("/open_amt", bouncer.AdminAccess(httperror.LoggerHandler(h.openAMTConfigureDefault))).Methods(http.MethodPost)
|
||||
h.Handle("/open_amt/configure", bouncer.AdminAccess(httperror.LoggerHandler(h.openAMTConfigure))).Methods(http.MethodPost)
|
||||
h.Handle("/open_amt/{id}/info", bouncer.AdminAccess(httperror.LoggerHandler(h.openAMTHostInfo))).Methods(http.MethodGet)
|
||||
h.Handle("/open_amt/{id}/activate", bouncer.AdminAccess(httperror.LoggerHandler(h.openAMTActivate))).Methods(http.MethodPost)
|
||||
h.Handle("/open_amt/{id}/devices", bouncer.AdminAccess(httperror.LoggerHandler(h.openAMTDevices))).Methods(http.MethodGet)
|
||||
h.Handle("/open_amt/{id}/devices/{deviceId}/action", bouncer.AdminAccess(httperror.LoggerHandler(h.deviceAction))).Methods(http.MethodPost)
|
||||
h.Handle("/open_amt/{id}/devices/{deviceId}/features", bouncer.AdminAccess(httperror.LoggerHandler(h.deviceFeatures))).Methods(http.MethodPost)
|
||||
|
||||
return h, nil
|
||||
return h
|
||||
}
|
||||
|
|
|
@ -42,6 +42,10 @@ type settingsUpdatePayload struct {
|
|||
HelmRepositoryURL *string `example:"https://charts.bitnami.com/bitnami"`
|
||||
// Kubectl Shell Image
|
||||
KubectlShellImage *string `example:"portainer/kubectl-shell:latest"`
|
||||
// DisableTrustOnFirstConnect makes Portainer require explicit user trust of the edge agent before accepting the connection
|
||||
DisableTrustOnFirstConnect *bool `example:"false"`
|
||||
// EnforceEdgeID makes Portainer store the Edge ID instead of accepting anyone
|
||||
EnforceEdgeID *bool `example:"false"`
|
||||
}
|
||||
|
||||
func (payload *settingsUpdatePayload) Validate(r *http.Request) error {
|
||||
|
@ -162,6 +166,14 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) *
|
|||
settings.EnableEdgeComputeFeatures = *payload.EnableEdgeComputeFeatures
|
||||
}
|
||||
|
||||
if payload.DisableTrustOnFirstConnect != nil {
|
||||
settings.DisableTrustOnFirstConnect = *payload.DisableTrustOnFirstConnect
|
||||
}
|
||||
|
||||
if payload.EnforceEdgeID != nil {
|
||||
settings.EnforceEdgeID = *payload.EnforceEdgeID
|
||||
}
|
||||
|
||||
if payload.SnapshotInterval != nil && *payload.SnapshotInterval != settings.SnapshotInterval {
|
||||
err := handler.updateSnapshotInterval(settings, *payload.SnapshotInterval)
|
||||
if err != nil {
|
||||
|
|
|
@ -154,7 +154,7 @@ func (handler *Handler) checkUniqueStackNameInDocker(endpoint *portainer.Endpoin
|
|||
return false, err
|
||||
}
|
||||
|
||||
dockerClient, err := handler.DockerClientFactory.CreateClient(endpoint, "")
|
||||
dockerClient, err := handler.DockerClientFactory.CreateClient(endpoint, "", nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
|
|
@ -68,7 +68,7 @@ func (handler *Handler) executeServiceWebhook(
|
|||
registryID portainer.RegistryID,
|
||||
imageTag string,
|
||||
) *httperror.HandlerError {
|
||||
dockerClient, err := handler.DockerClientFactory.CreateClient(endpoint, "")
|
||||
dockerClient, err := handler.DockerClientFactory.CreateClient(endpoint, "", nil)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Error creating docker client", err}
|
||||
}
|
||||
|
|
|
@ -122,7 +122,7 @@ func (transport *Transport) createPrivateResourceControl(resourceIdentifier stri
|
|||
}
|
||||
|
||||
func (transport *Transport) getInheritedResourceControlFromServiceOrStack(resourceIdentifier, nodeName string, resourceType portainer.ResourceControlType, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) {
|
||||
client, err := transport.dockerClientFactory.CreateClient(transport.endpoint, nodeName)
|
||||
client, err := transport.dockerClientFactory.CreateClient(transport.endpoint, nodeName, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
@ -133,7 +133,7 @@ func (transport *Transport) decorateVolumeResourceCreationOperation(request *htt
|
|||
|
||||
if volumeID != "" {
|
||||
agentTargetHeader := request.Header.Get(portainer.PortainerAgentTargetHeader)
|
||||
cli, err := transport.dockerClientFactory.CreateClient(transport.endpoint, agentTargetHeader)
|
||||
cli, err := transport.dockerClientFactory.CreateClient(transport.endpoint, agentTargetHeader, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -219,7 +219,7 @@ func (transport *Transport) getDockerID() (string, error) {
|
|||
}
|
||||
}
|
||||
|
||||
client, err := transport.dockerClientFactory.CreateClient(transport.endpoint, "")
|
||||
client, err := transport.dockerClientFactory.CreateClient(transport.endpoint, "", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ package security
|
|||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
@ -134,6 +135,19 @@ func (bouncer *RequestBouncer) AuthorizedEdgeEndpointOperation(r *http.Request,
|
|||
return errors.New("invalid Edge identifier")
|
||||
}
|
||||
|
||||
if endpoint.LastCheckInDate > 0 || endpoint.UserTrusted {
|
||||
return nil
|
||||
}
|
||||
|
||||
settings, err := bouncer.dataStore.Settings().Settings()
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not retrieve the settings: %w", err)
|
||||
}
|
||||
|
||||
if settings.DisableTrustOnFirstConnect {
|
||||
return errors.New("the device has not been trusted yet")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
@ -30,6 +30,7 @@ import (
|
|||
"github.com/portainer/portainer/api/http/handler/endpoints"
|
||||
"github.com/portainer/portainer/api/http/handler/file"
|
||||
"github.com/portainer/portainer/api/http/handler/helm"
|
||||
"github.com/portainer/portainer/api/http/handler/hostmanagement/fdo"
|
||||
"github.com/portainer/portainer/api/http/handler/hostmanagement/openamt"
|
||||
kubehandler "github.com/portainer/portainer/api/http/handler/kubernetes"
|
||||
"github.com/portainer/portainer/api/http/handler/ldap"
|
||||
|
@ -209,14 +210,12 @@ func (server *Server) Start() error {
|
|||
var sslHandler = sslhandler.NewHandler(requestBouncer)
|
||||
sslHandler.SSLService = server.SSLService
|
||||
|
||||
openAMTHandler, err := openamt.NewHandler(requestBouncer, server.DataStore)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if openAMTHandler != nil {
|
||||
openAMTHandler.OpenAMTService = server.OpenAMTService
|
||||
openAMTHandler.DataStore = server.DataStore
|
||||
}
|
||||
openAMTHandler := openamt.NewHandler(requestBouncer)
|
||||
openAMTHandler.OpenAMTService = server.OpenAMTService
|
||||
openAMTHandler.DataStore = server.DataStore
|
||||
openAMTHandler.DockerClientFactory = server.DockerClientFactory
|
||||
|
||||
fdoHandler := fdo.NewHandler(requestBouncer, server.DataStore, server.FileService)
|
||||
|
||||
var stackHandler = stacks.NewHandler(requestBouncer)
|
||||
stackHandler.DataStore = server.DataStore
|
||||
|
@ -284,6 +283,7 @@ func (server *Server) Start() error {
|
|||
KubernetesHandler: kubernetesHandler,
|
||||
MOTDHandler: motdHandler,
|
||||
OpenAMTHandler: openAMTHandler,
|
||||
FDOHandler: fdoHandler,
|
||||
RegistryHandler: registryHandler,
|
||||
ResourceControlHandler: resourceControlHandler,
|
||||
SettingsHandler: settingsHandler,
|
||||
|
|
|
@ -46,6 +46,23 @@ func Test_IsKubernetesEndpoint(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func Test_IsAgentEndpoint(t *testing.T) {
|
||||
tests := []isEndpointTypeTest{
|
||||
{endpointType: portainer.DockerEnvironment, expected: false},
|
||||
{endpointType: portainer.AgentOnDockerEnvironment, expected: true},
|
||||
{endpointType: portainer.AzureEnvironment, expected: false},
|
||||
{endpointType: portainer.EdgeAgentOnDockerEnvironment, expected: true},
|
||||
{endpointType: portainer.KubernetesLocalEnvironment, expected: false},
|
||||
{endpointType: portainer.AgentOnKubernetesEnvironment, expected: true},
|
||||
{endpointType: portainer.EdgeAgentOnKubernetesEnvironment, expected: true},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
ans := IsAgentEndpoint(&portainer.Endpoint{Type: test.endpointType})
|
||||
assert.Equal(t, test.expected, ans)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_FilterByExcludeIDs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
|
@ -30,6 +30,14 @@ func IsEdgeEndpoint(endpoint *portainer.Endpoint) bool {
|
|||
return endpoint.Type == portainer.EdgeAgentOnDockerEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment
|
||||
}
|
||||
|
||||
// IsAgentEndpoint returns true if this is an Agent endpoint
|
||||
func IsAgentEndpoint(endpoint *portainer.Endpoint) bool {
|
||||
return endpoint.Type == portainer.AgentOnDockerEnvironment ||
|
||||
endpoint.Type == portainer.EdgeAgentOnDockerEnvironment ||
|
||||
endpoint.Type == portainer.AgentOnKubernetesEnvironment ||
|
||||
endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment
|
||||
}
|
||||
|
||||
// FilterByExcludeIDs receives an environment(endpoint) array and returns a filtered array using an excludeIds param
|
||||
func FilterByExcludeIDs(endpoints []portainer.Endpoint, excludeIds []portainer.EndpointID) []portainer.Endpoint {
|
||||
if len(excludeIds) == 0 {
|
||||
|
|
|
@ -16,6 +16,7 @@ type testDatastore struct {
|
|||
endpoint dataservices.EndpointService
|
||||
endpointGroup dataservices.EndpointGroupService
|
||||
endpointRelation dataservices.EndpointRelationService
|
||||
fdoProfile dataservices.FDOProfileService
|
||||
helmUserRepository dataservices.HelmUserRepositoryService
|
||||
registry dataservices.RegistryService
|
||||
resourceControl dataservices.ResourceControlService
|
||||
|
@ -46,6 +47,9 @@ func (d *testDatastore) EdgeJob() dataservices.EdgeJobService { re
|
|||
func (d *testDatastore) EdgeStack() dataservices.EdgeStackService { return d.edgeStack }
|
||||
func (d *testDatastore) Endpoint() dataservices.EndpointService { return d.endpoint }
|
||||
func (d *testDatastore) EndpointGroup() dataservices.EndpointGroupService { return d.endpointGroup }
|
||||
func (d *testDatastore) FDOProfile() dataservices.FDOProfileService {
|
||||
return d.fdoProfile
|
||||
}
|
||||
func (d *testDatastore) EndpointRelation() dataservices.EndpointRelationService {
|
||||
return d.endpointRelation
|
||||
}
|
||||
|
|
|
@ -41,30 +41,54 @@ type (
|
|||
|
||||
// OpenAMTConfiguration represents the credentials and configurations used to connect to an OpenAMT MPS server
|
||||
OpenAMTConfiguration struct {
|
||||
Enabled bool `json:"Enabled"`
|
||||
MPSServer string `json:"MPSServer"`
|
||||
Credentials MPSCredentials `json:"Credentials"`
|
||||
DomainConfiguration DomainConfiguration `json:"DomainConfiguration"`
|
||||
WirelessConfiguration *WirelessConfiguration `json:"WirelessConfiguration"`
|
||||
Enabled bool `json:"enabled"`
|
||||
MPSServer string `json:"mpsServer"`
|
||||
MPSUser string `json:"mpsUser"`
|
||||
MPSPassword string `json:"mpsPassword"`
|
||||
MPSToken string `json:"mpsToken"` // retrieved from API
|
||||
CertFileName string `json:"certFileName"`
|
||||
CertFileContent string `json:"certFileContent"`
|
||||
CertFilePassword string `json:"certFilePassword"`
|
||||
DomainName string `json:"domainName"`
|
||||
}
|
||||
|
||||
MPSCredentials struct {
|
||||
MPSUser string `json:"MPSUser"`
|
||||
MPSPassword string `json:"MPSPassword"`
|
||||
MPSToken string `json:"MPSToken"` // retrieved from API
|
||||
// OpenAMTDeviceInformation represents an AMT managed device information
|
||||
OpenAMTDeviceInformation struct {
|
||||
GUID string `json:"guid"`
|
||||
HostName string `json:"hostname"`
|
||||
ConnectionStatus bool `json:"connectionStatus"`
|
||||
PowerState PowerState `json:"powerState"`
|
||||
EnabledFeatures *OpenAMTDeviceEnabledFeatures `json:"features"`
|
||||
}
|
||||
|
||||
DomainConfiguration struct {
|
||||
CertFileText string `json:"CertFileText"`
|
||||
CertPassword string `json:"CertPassword"`
|
||||
DomainName string `json:"DomainName"`
|
||||
// OpenAMTDeviceEnabledFeatures represents an AMT managed device features information
|
||||
OpenAMTDeviceEnabledFeatures struct {
|
||||
Redirection bool `json:"redirection"`
|
||||
KVM bool `json:"KVM"`
|
||||
SOL bool `json:"SOL"`
|
||||
IDER bool `json:"IDER"`
|
||||
UserConsent string `json:"userConsent"`
|
||||
}
|
||||
|
||||
WirelessConfiguration struct {
|
||||
AuthenticationMethod string `json:"AuthenticationMethod"`
|
||||
EncryptionMethod string `json:"EncryptionMethod"`
|
||||
SSID string `json:"SSID"`
|
||||
PskPass string `json:"PskPass"`
|
||||
// PowerState represents an AMT managed device power state
|
||||
PowerState int
|
||||
|
||||
FDOConfiguration struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
OwnerURL string `json:"ownerURL"`
|
||||
OwnerUsername string `json:"ownerUsername"`
|
||||
OwnerPassword string `json:"ownerPassword"`
|
||||
}
|
||||
|
||||
// FDOProfileID represents a fdo profile id
|
||||
FDOProfileID int
|
||||
|
||||
FDOProfile struct {
|
||||
ID FDOProfileID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
FilePath string `json:"filePath"`
|
||||
NumberDevices int `json:"numberDevices"`
|
||||
DateCreated int64 `json:"dateCreated"`
|
||||
}
|
||||
|
||||
// CLIFlags represents the available flags on the CLI
|
||||
|
@ -298,8 +322,14 @@ type (
|
|||
ComposeSyntaxMaxVersion string `json:"ComposeSyntaxMaxVersion" example:"3.8"`
|
||||
// Environment(Endpoint) specific security settings
|
||||
SecuritySettings EndpointSecuritySettings
|
||||
// The identifier of the AMT Device associated with this environment(endpoint)
|
||||
AMTDeviceGUID string `json:"AMTDeviceGUID,omitempty" example:"4c4c4544-004b-3910-8037-b6c04f504633"`
|
||||
// LastCheckInDate mark last check-in date on checkin
|
||||
LastCheckInDate int64
|
||||
// IsEdgeDevice marks if the environment was created as an EdgeDevice
|
||||
IsEdgeDevice bool
|
||||
// Whether the device has been trusted or not by the user
|
||||
UserTrusted bool
|
||||
|
||||
// Deprecated fields
|
||||
// Deprecated in DBVersion == 4
|
||||
|
@ -768,7 +798,8 @@ type (
|
|||
AuthenticationMethod AuthenticationMethod `json:"AuthenticationMethod" example:"1"`
|
||||
LDAPSettings LDAPSettings `json:"LDAPSettings" example:""`
|
||||
OAuthSettings OAuthSettings `json:"OAuthSettings" example:""`
|
||||
OpenAMTConfiguration OpenAMTConfiguration `json:"OpenAMTConfiguration" example:""`
|
||||
OpenAMTConfiguration OpenAMTConfiguration `json:"openAMTConfiguration" example:""`
|
||||
FDOConfiguration FDOConfiguration `json:"fdoConfiguration" example:""`
|
||||
FeatureFlagSettings map[Feature]bool `json:"FeatureFlagSettings" example:""`
|
||||
// The interval in which environment(endpoint) snapshots are created
|
||||
SnapshotInterval string `json:"SnapshotInterval" example:"5m"`
|
||||
|
@ -788,6 +819,10 @@ type (
|
|||
HelmRepositoryURL string `json:"HelmRepositoryURL" example:"https://charts.bitnami.com/bitnami"`
|
||||
// KubectlImage, defaults to portainer/kubectl-shell
|
||||
KubectlShellImage string `json:"KubectlShellImage" example:"portainer/kubectl-shell"`
|
||||
// DisableTrustOnFirstConnect makes Portainer require explicit user trust of the edge agent before accepting the connection
|
||||
DisableTrustOnFirstConnect bool `json:"DisableTrustOnFirstConnect" example:"false"`
|
||||
// EnforceEdgeID makes Portainer store the Edge ID instead of accepting anyone
|
||||
EnforceEdgeID bool `json:"EnforceEdgeID" example:"false"`
|
||||
|
||||
// Deprecated fields
|
||||
DisplayDonationHeader bool
|
||||
|
@ -1213,6 +1248,7 @@ type (
|
|||
GetDefaultSSLCertsPath() (string, string)
|
||||
StoreSSLCertPair(cert, key []byte) (string, string, error)
|
||||
CopySSLCertPair(certPath, keyPath string) (string, string, error)
|
||||
StoreFDOProfileFileFromBytes(fdoProfileIdentifier string, data []byte) (string, error)
|
||||
}
|
||||
|
||||
// GitService represents a service for managing Git
|
||||
|
@ -1223,7 +1259,10 @@ type (
|
|||
|
||||
// OpenAMTService represents a service for managing OpenAMT
|
||||
OpenAMTService interface {
|
||||
ConfigureDefault(configuration OpenAMTConfiguration) error
|
||||
Configure(configuration OpenAMTConfiguration) error
|
||||
DeviceInformation(configuration OpenAMTConfiguration, deviceGUID string) (*OpenAMTDeviceInformation, error)
|
||||
EnableDeviceFeatures(configuration OpenAMTConfiguration, deviceGUID string, features OpenAMTDeviceEnabledFeatures) (string, error)
|
||||
ExecuteDeviceAction(configuration OpenAMTConfiguration, deviceGUID string, action string) error
|
||||
}
|
||||
|
||||
// KubeClient represents a service used to query a Kubernetes environment(endpoint)
|
||||
|
@ -1353,17 +1392,8 @@ const (
|
|||
WebSocketKeepAlive = 1 * time.Hour
|
||||
)
|
||||
|
||||
// Supported feature flags
|
||||
const (
|
||||
FeatOpenAMT Feature = "open-amt"
|
||||
FeatFDO Feature = "fdo"
|
||||
)
|
||||
|
||||
// List of supported features
|
||||
var SupportedFeatureFlags = []Feature{
|
||||
FeatOpenAMT,
|
||||
FeatFDO,
|
||||
}
|
||||
var SupportedFeatureFlags = []Feature{}
|
||||
|
||||
const (
|
||||
_ AuthenticationMethod = iota
|
||||
|
|
|
@ -680,6 +680,7 @@ a[ng-click] {
|
|||
.image-zoom-modal .modal-dialog {
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
/*!bootbox override*/
|
||||
|
||||
/*angular-multi-select override*/
|
||||
|
@ -893,6 +894,16 @@ json-tree .branch-preview {
|
|||
white-space: normal;
|
||||
}
|
||||
|
||||
.icon-disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.space-x-1 {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* used for bootbox prompt with inputType radio */
|
||||
.form-check.radio {
|
||||
margin-left: 15px;
|
||||
}
|
||||
|
|
|
@ -264,6 +264,7 @@ html {
|
|||
--bg-multiselect-helpercontainer: var(--white-color);
|
||||
--text-input-textarea: var(--white-color);
|
||||
--bg-service-datatable-thead: var(--grey-23);
|
||||
--bg-inner-datatable-thead: var(--grey-23);
|
||||
--bg-service-datatable-tbody: var(--grey-24);
|
||||
}
|
||||
|
||||
|
@ -435,6 +436,7 @@ html {
|
|||
--bg-multiselect-helpercontainer: var(--grey-1);
|
||||
--text-input-textarea: var(--grey-1);
|
||||
--bg-service-datatable-thead: var(--grey-1);
|
||||
--bg-inner-datatable-thead: var(--grey-1);
|
||||
--bg-service-datatable-tbody: var(--grey-1);
|
||||
}
|
||||
|
||||
|
@ -472,6 +474,7 @@ html {
|
|||
--bg-input-sm-color: var(--black-color);
|
||||
--bg-item-highlighted-color: var(--black-color);
|
||||
--bg-service-datatable-thead: var(--black-color);
|
||||
--bg-inner-datatable-thead: var(--black-color);
|
||||
--bg-service-datatable-tbody: var(--black-color);
|
||||
--bg-pagination-color: var(--grey-3);
|
||||
--bg-pagination-span-color: var(--grey-3);
|
||||
|
|
|
@ -140,7 +140,7 @@ export function ContainersDatatable({
|
|||
<TableContainer>
|
||||
<TableTitle icon="fa-cubes" label="Containers">
|
||||
<TableTitleActions>
|
||||
<ColumnVisibilityMenu
|
||||
<ColumnVisibilityMenu<DockerContainer>
|
||||
columns={columnsToHide}
|
||||
onChange={handleChangeColumnsVisibility}
|
||||
value={settings.hiddenColumns}
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import angular from 'angular';
|
||||
|
||||
import edgeStackModule from './views/edge-stacks';
|
||||
import edgeDevicesModule from './devices';
|
||||
|
||||
angular.module('portainer.edge', [edgeStackModule]).config(function config($stateRegistryProvider) {
|
||||
angular.module('portainer.edge', [edgeStackModule, edgeDevicesModule]).config(function config($stateRegistryProvider) {
|
||||
const edge = {
|
||||
name: 'edge',
|
||||
url: '/edge',
|
||||
|
@ -106,6 +107,16 @@ angular.module('portainer.edge', [edgeStackModule]).config(function config($stat
|
|||
},
|
||||
};
|
||||
|
||||
const edgeDevices = {
|
||||
name: 'edge.devices',
|
||||
url: '/devices',
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'edgeDevicesView',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
$stateRegistryProvider.register(edge);
|
||||
|
||||
$stateRegistryProvider.register(groups);
|
||||
|
@ -119,4 +130,6 @@ angular.module('portainer.edge', [edgeStackModule]).config(function config($stat
|
|||
$stateRegistryProvider.register(edgeJobs);
|
||||
$stateRegistryProvider.register(edgeJob);
|
||||
$stateRegistryProvider.register(edgeJobCreation);
|
||||
|
||||
$stateRegistryProvider.register(edgeDevices);
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
|
@ -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 { ScheduleCreateRequest, ScheduleUpdateRequest } from 'Portainer/models/schedule';
|
||||
import { ScheduleCreateRequest, ScheduleUpdateRequest } from '@/portainer/models/schedule';
|
||||
|
||||
function EdgeJobService(EdgeJobs, EdgeJobResults, FileUploadService) {
|
||||
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();
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
import angular from 'angular';
|
||||
|
||||
import { EdgeDevicesViewController } from './edgeDevicesViewController';
|
||||
|
||||
angular.module('portainer.edge').component('edgeDevicesView', {
|
||||
templateUrl: './edgeDevicesView.html',
|
||||
controller: EdgeDevicesViewController,
|
||||
});
|
|
@ -6,3 +6,5 @@ declare module '*.png' {
|
|||
}
|
||||
|
||||
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 = {
|
||||
name: 'portainer.endpoints.endpoint.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 = {
|
||||
name: 'portainer.groups',
|
||||
url: '/groups',
|
||||
|
@ -378,6 +434,16 @@ angular
|
|||
},
|
||||
};
|
||||
|
||||
var settingsEdgeCompute = {
|
||||
name: 'portainer.settings.edgeCompute',
|
||||
url: '/edge',
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'settingsEdgeComputeView',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
var tags = {
|
||||
name: 'portainer.tags',
|
||||
url: '/tags',
|
||||
|
@ -444,7 +510,12 @@ angular
|
|||
$stateRegistryProvider.register(endpoint);
|
||||
$stateRegistryProvider.register(k8sendpoint);
|
||||
$stateRegistryProvider.register(endpointAccess);
|
||||
$stateRegistryProvider.register(endpointKVM);
|
||||
$stateRegistryProvider.register(edgeDeviceCreation);
|
||||
$stateRegistryProvider.register(endpointCreation);
|
||||
$stateRegistryProvider.register(deviceImport);
|
||||
$stateRegistryProvider.register(addFDOProfile);
|
||||
$stateRegistryProvider.register(editFDOProfile);
|
||||
$stateRegistryProvider.register(endpointKubernetesConfiguration);
|
||||
$stateRegistryProvider.register(groups);
|
||||
$stateRegistryProvider.register(group);
|
||||
|
@ -461,6 +532,7 @@ angular
|
|||
$stateRegistryProvider.register(registryCreation);
|
||||
$stateRegistryProvider.register(settings);
|
||||
$stateRegistryProvider.register(settingsAuthentication);
|
||||
$stateRegistryProvider.register(settingsEdgeCompute);
|
||||
$stateRegistryProvider.register(tags);
|
||||
$stateRegistryProvider.register(users);
|
||||
$stateRegistryProvider.register(user);
|
||||
|
|
|
@ -3,14 +3,23 @@ import clsx from 'clsx';
|
|||
|
||||
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 (
|
||||
<p className="text-muted small">
|
||||
<i
|
||||
aria-hidden="true"
|
||||
className={clsx(
|
||||
'fa fa-exclamation-circle',
|
||||
'orange-icon',
|
||||
`${color}-icon`,
|
||||
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 { Checkbox } from '@/portainer/components/form-components/Checkbox';
|
||||
import type { DockerContainer } from '@/docker/containers/types';
|
||||
|
||||
import { useTableContext } from './TableContainer';
|
||||
|
||||
interface Props {
|
||||
columns: ColumnInstance<DockerContainer>[];
|
||||
interface Props<D extends object> {
|
||||
columns: ColumnInstance<D>[];
|
||||
onChange: (value: string[]) => void;
|
||||
value: string[];
|
||||
}
|
||||
|
||||
export function ColumnVisibilityMenu({ columns, onChange, value }: Props) {
|
||||
export function ColumnVisibilityMenu<D extends object>({
|
||||
columns,
|
||||
onChange,
|
||||
value,
|
||||
}: Props<D>) {
|
||||
useTableContext();
|
||||
|
||||
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>> {
|
||||
headers: HeaderGroup<D>[];
|
||||
onSortChange(colId: string, desc: boolean): void;
|
||||
onSortChange?(colId: string, desc: boolean): void;
|
||||
}
|
||||
|
||||
export function TableHeaderRow<
|
||||
|
@ -26,13 +26,18 @@ export function TableHeaderRow<
|
|||
headerProps={{
|
||||
...column.getHeaderProps({
|
||||
className: column.className,
|
||||
style: {
|
||||
width: column.disableResizing ? column.width : '',
|
||||
},
|
||||
}),
|
||||
}}
|
||||
key={column.id}
|
||||
canSort={column.canSort}
|
||||
onSortClick={(desc) => {
|
||||
column.toggleSortBy(desc);
|
||||
onSortChange(column.id, desc);
|
||||
if (onSortChange) {
|
||||
onSortChange(column.id, desc);
|
||||
}
|
||||
}}
|
||||
isSorted={column.isSorted}
|
||||
isSortedDesc={column.isSortedDesc}
|
||||
|
|
|
@ -5,7 +5,7 @@ import { PropsWithChildren, ReactNode } from 'react';
|
|||
import { useTableContext } from './TableContainer';
|
||||
|
||||
interface Props {
|
||||
quickActions: ReactNode;
|
||||
quickActions?: ReactNode;
|
||||
}
|
||||
|
||||
export function TableSettingsMenu({
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
<div class="searchBar">
|
||||
|
|
|
@ -3,6 +3,7 @@ angular.module('portainer.app').controller('EndpointsDatatableController', [
|
|||
'$controller',
|
||||
'DatatableService',
|
||||
'PaginationService',
|
||||
'Notifications',
|
||||
function ($scope, $controller, DatatableService, PaginationService) {
|
||||
angular.extend(this, $controller('GenericDatatableController', { $scope: $scope }));
|
||||
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue