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
Marcelo Rydel 2022-01-23 16:48:04 -03:00 committed by GitHub
parent 3ed92e5fee
commit 2c4c638f46
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
170 changed files with 6834 additions and 819 deletions

View File

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

View File

@ -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) {

View File

@ -10,7 +10,7 @@ import (
)
const (
jsonobject = `{"LogoURL":"","BlackListedLabels":[],"AuthenticationMethod":1,"LDAPSettings":{"AnonymousMode":true,"ReaderDN":"","URL":"","TLSConfig":{"TLS":false,"TLSSkipVerify":false},"StartTLS":false,"SearchSettings":[{"BaseDN":"","Filter":"","UserNameAttribute":""}],"GroupSearchSettings":[{"GroupBaseDN":"","GroupFilter":"","GroupAttribute":""}],"AutoCreateUsers":true},"OAuthSettings":{"ClientID":"","AccessTokenURI":"","AuthorizationURI":"","ResourceURI":"","RedirectURI":"","UserIdentifier":"","Scopes":"","OAuthAutoCreateUsers":false,"DefaultTeamID":0,"SSO":true,"LogoutURI":"","KubeSecretKey":"j0zLVtY/lAWBk62ByyF0uP80SOXaitsABP0TTJX8MhI="},"OpenAMTConfiguration":{"Enabled":false,"MPSServer":"","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"
)

View File

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

View File

@ -31,6 +31,7 @@ type (
Endpoint() EndpointService
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)

View File

@ -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,

View File

@ -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,

View File

@ -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

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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

View File

@ -300,6 +300,8 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/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=

View File

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

View File

@ -14,39 +14,39 @@ type authenticationResponse struct {
Token string `json:"token"`
}
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
}

View File

@ -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)

View File

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

View File

@ -37,9 +37,9 @@ func (service *Service) createOrUpdateDomain(configuration portainer.OpenAMTConf
}
func (service *Service) getDomain(configuration portainer.OpenAMTConfiguration) (*Domain, error) {
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
}

View File

@ -29,7 +29,7 @@ type (
}
)
func (service *Service) createOrUpdateAMTProfile(configuration portainer.OpenAMTConfiguration, profileName string, ciraConfigName string, wirelessConfig string) (*Profile, error) {
func (service *Service) createOrUpdateAMTProfile(configuration portainer.OpenAMTConfiguration, profileName string, ciraConfigName string) (*Profile, error) {
profile, err := service.getAMTProfile(configuration, profileName)
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
}

View File

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

View File

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

View File

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

View File

@ -11,13 +11,18 @@ import (
"time"
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
}

View File

@ -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)

View File

@ -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 {

View File

@ -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)

View File

@ -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}

View File

@ -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"):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -16,23 +16,19 @@ import (
portainer "github.com/portainer/portainer/api"
)
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)

View File

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

View File

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

View File

@ -8,27 +8,30 @@ import (
httperror "github.com/portainer/libhttp/error"
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
}

View File

@ -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 {

View File

@ -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
}

View File

@ -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}
}

View File

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

View File

@ -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
}

View File

@ -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
}

View File

@ -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,

View File

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

View File

@ -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 {

View File

@ -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
}

View File

@ -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

View File

@ -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;
}

View File

@ -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);

View File

@ -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}

View File

@ -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);
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

2
app/global.d.ts vendored
View File

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

View File

@ -205,6 +205,51 @@ angular
},
};
var edgeDeviceCreation = {
name: 'portainer.endpoints.newEdgeDevice',
url: '/newEdgeDevice',
params: {
isEdgeDevice: true,
},
views: {
'content@': {
templateUrl: './views/endpoints/create/createendpoint.html',
controller: 'CreateEndpointController',
},
},
};
var deviceImport = {
name: 'portainer.endpoints.importDevice',
url: '/device',
views: {
'content@': {
templateUrl: './views/devices/import/importDevice.html',
controller: 'ImportDeviceController',
},
},
};
var addFDOProfile = {
name: 'portainer.endpoints.profile',
url: '/profile',
views: {
'content@': {
component: 'addProfileView',
},
},
};
var editFDOProfile = {
name: 'portainer.endpoints.profile.edit',
url: '/:id',
views: {
'content@': {
component: 'editProfileView',
},
},
};
var endpointAccess = {
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);

View File

@ -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
)}
/>

View File

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

View File

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

View File

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

View File

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

View File

@ -3,17 +3,20 @@ import { Menu, MenuButton, MenuList } from '@reach/menu-button';
import { ColumnInstance } from 'react-table';
import { 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 (

View File

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

View File

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

View File

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

View File

@ -5,7 +5,7 @@ import { TableHeaderCell } from './TableHeaderCell';
interface Props<D extends Record<string, unknown> = Record<string, unknown>> {
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}

View File

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

View File

@ -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">

View File

@ -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