From 47c1af93eac8d0dde9e51ae3063073b69c0447c0 Mon Sep 17 00:00:00 2001 From: Marcelo Rydel Date: Mon, 29 Nov 2021 06:06:50 -0700 Subject: [PATCH] feat(openamt): Configuration of the OpenAMT capability [INT-6] (#6071) Co-authored-by: Sven Dowideit --- api/bolt/settings/settings.go | 14 + api/cmd/portainer/main.go | 17 +- api/go.mod | 1 + api/go.sum | 2 + api/hostmanagement/openamt/authorization.go | 52 ++++ api/hostmanagement/openamt/configCIRA.go | 143 ++++++++++ api/hostmanagement/openamt/configDomain.go | 81 ++++++ api/hostmanagement/openamt/configProfile.go | 104 +++++++ api/hostmanagement/openamt/configWireless.go | 91 ++++++ api/hostmanagement/openamt/openamt.go | 157 +++++++++++ api/http/handler/handler.go | 6 + .../handler/hostmanagement/openamt/handler.go | 33 +++ .../handler/hostmanagement/openamt/openamt.go | 218 +++++++++++++++ api/http/server.go | 12 + api/internal/testhelpers/datastore.go | 3 + api/portainer.go | 35 +++ app/portainer/models/settings.js | 1 + app/portainer/rest/openAMT.js | 16 ++ app/portainer/services/api/openAMTService.js | 14 + app/portainer/settings/general/index.js | 3 +- .../settings/general/open-amt/index.js | 6 + .../general/open-amt/open-amt.controller.js | 111 ++++++++ .../settings/general/open-amt/open-amt.html | 259 ++++++++++++++++++ app/portainer/views/settings/settings.html | 2 + 24 files changed, 1373 insertions(+), 8 deletions(-) create mode 100644 api/hostmanagement/openamt/authorization.go create mode 100644 api/hostmanagement/openamt/configCIRA.go create mode 100644 api/hostmanagement/openamt/configDomain.go create mode 100644 api/hostmanagement/openamt/configProfile.go create mode 100644 api/hostmanagement/openamt/configWireless.go create mode 100644 api/hostmanagement/openamt/openamt.go create mode 100644 api/http/handler/hostmanagement/openamt/handler.go create mode 100644 api/http/handler/hostmanagement/openamt/openamt.go create mode 100644 app/portainer/rest/openAMT.js create mode 100644 app/portainer/services/api/openAMTService.js create mode 100644 app/portainer/settings/general/open-amt/index.js create mode 100644 app/portainer/settings/general/open-amt/open-amt.controller.js create mode 100644 app/portainer/settings/general/open-amt/open-amt.html diff --git a/api/bolt/settings/settings.go b/api/bolt/settings/settings.go index 60f8735c1..061023685 100644 --- a/api/bolt/settings/settings.go +++ b/api/bolt/settings/settings.go @@ -44,3 +44,17 @@ func (service *Service) Settings() (*portainer.Settings, error) { func (service *Service) UpdateSettings(settings *portainer.Settings) error { return internal.UpdateObject(service.connection, BucketName, []byte(settingsKey), settings) } + +func (service *Service) IsFeatureFlagEnabled(feature portainer.Feature) bool { + settings, err := service.Settings() + if err != nil { + return false + } + + featureFlagSetting, ok := settings.FeatureFlagSettings[portainer.FeatOpenAMT] + if ok { + return featureFlagSetting + } + + return false +} diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 60214e3aa..bfb2a3416 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -8,17 +8,17 @@ import ( "strconv" "strings" + "github.com/portainer/libhelm" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt" "github.com/portainer/portainer/api/chisel" "github.com/portainer/portainer/api/cli" "github.com/portainer/portainer/api/crypto" "github.com/portainer/portainer/api/docker" - - "github.com/portainer/libhelm" "github.com/portainer/portainer/api/exec" "github.com/portainer/portainer/api/filesystem" "github.com/portainer/portainer/api/git" + "github.com/portainer/portainer/api/hostmanagement/openamt" "github.com/portainer/portainer/api/http" "github.com/portainer/portainer/api/http/client" "github.com/portainer/portainer/api/http/proxy" @@ -462,12 +462,19 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { log.Fatalf("failed initializing JWT service: %v", err) } + err = enableFeaturesFromFlags(dataStore, flags) + if err != nil { + log.Fatalf("failed enabling feature flag: %v", err) + } + ldapService := initLDAPService() oauthService := initOAuthService() gitService := initGitService() + openAMTService := openamt.NewService(dataStore) + cryptoService := initCryptoService() digitalSignatureService := initDigitalSignatureService() @@ -537,11 +544,6 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { } } - err = enableFeaturesFromFlags(dataStore, flags) - if err != nil { - log.Fatalf("failed enabling feature flag: %v", err) - } - err = edge.LoadEdgeJobs(dataStore, reverseTunnelService) if err != nil { log.Fatalf("failed loading edge jobs from database: %v", err) @@ -623,6 +625,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { LDAPService: ldapService, OAuthService: oauthService, GitService: gitService, + OpenAMTService: openAMTService, ProxyManager: proxyManager, KubernetesTokenCacheManager: kubernetesTokenCacheManager, KubeConfigService: kubeConfigService, diff --git a/api/go.mod b/api/go.mod index bfca6833c..d28c6e5b2 100644 --- a/api/go.mod +++ b/api/go.mod @@ -46,4 +46,5 @@ require ( k8s.io/api v0.22.2 k8s.io/apimachinery v0.22.2 k8s.io/client-go v0.22.2 + software.sslmate.com/src/go-pkcs12 v0.0.0-20210415151418-c5206de65a78 ) diff --git a/api/go.sum b/api/go.sum index 541f18754..d7a2d2bd7 100644 --- a/api/go.sum +++ b/api/go.sum @@ -1124,3 +1124,5 @@ sigs.k8s.io/structured-merge-diff/v4 v4.1.2/go.mod h1:j/nl6xW8vLS49O8YvXW1ocPhZa sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= sigs.k8s.io/yaml v1.2.0 h1:kr/MCeFWJWTwyaHoR9c8EjH9OumOmoF9YGiZd7lFm/Q= sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= +software.sslmate.com/src/go-pkcs12 v0.0.0-20210415151418-c5206de65a78 h1:SqYE5+A2qvRhErbsXFfUEUmpWEKxxRSMgGLkvRAFOV4= +software.sslmate.com/src/go-pkcs12 v0.0.0-20210415151418-c5206de65a78/go.mod h1:B7Wf0Ya4DHF9Yw+qfZuJijQYkWicqDa+79Ytmmq3Kjg= diff --git a/api/hostmanagement/openamt/authorization.go b/api/hostmanagement/openamt/authorization.go new file mode 100644 index 000000000..ec0fc4813 --- /dev/null +++ b/api/hostmanagement/openamt/authorization.go @@ -0,0 +1,52 @@ +package openamt + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + + portainer "github.com/portainer/portainer/api" +) + +type authenticationResponse struct { + Token string `json:"token"` +} + +func (service *Service) executeAuthenticationRequest(configuration portainer.OpenAMTConfiguration) (*authenticationResponse, error) { + loginURL := fmt.Sprintf("https://%s/mps/login/api/v1/authorize", configuration.MPSURL) + + payload := map[string]string{ + "username": configuration.Credentials.MPSUser, + "password": configuration.Credentials.MPSPassword, + } + jsonValue, _ := json.Marshal(payload) + + req, err := http.NewRequest(http.MethodPost, loginURL, bytes.NewBuffer(jsonValue)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + response, err := service.httpsClient.Do(req) + if err != nil { + return nil, err + } + responseBody, readErr := ioutil.ReadAll(response.Body) + if readErr != nil { + return nil, readErr + } + errorResponse := parseError(responseBody) + if errorResponse != nil { + return nil, errorResponse + } + + var token authenticationResponse + err = json.Unmarshal(responseBody, &token) + if err != nil { + return nil, err + } + + return &token, nil +} diff --git a/api/hostmanagement/openamt/configCIRA.go b/api/hostmanagement/openamt/configCIRA.go new file mode 100644 index 000000000..5678b5628 --- /dev/null +++ b/api/hostmanagement/openamt/configCIRA.go @@ -0,0 +1,143 @@ +package openamt + +import ( + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "net" + "net/http" + "strings" + + portainer "github.com/portainer/portainer/api" +) + +type CIRAConfig struct { + ConfigName string `json:"configName"` + MPSServerAddress string `json:"mpsServerAddress"` + ServerAddressFormat int `json:"serverAddressFormat"` + CommonName string `json:"commonName"` + MPSPort int `json:"mpsPort"` + Username string `json:"username"` + MPSRootCertificate string `json:"mpsRootCertificate"` + RegeneratePassword bool `json:"regeneratePassword"` + AuthMethod int `json:"authMethod"` +} + +func (service *Service) createOrUpdateCIRAConfig(configuration portainer.OpenAMTConfiguration, configName string) (*CIRAConfig, error) { + ciraConfig, err := service.getCIRAConfig(configuration, configName) + if err != nil { + return nil, err + } + + method := http.MethodPost + if ciraConfig != nil { + method = http.MethodPatch + } + + ciraConfig, err = service.saveCIRAConfig(method, configuration, configName) + if err != nil { + return nil, err + } + return ciraConfig, nil +} + +func (service *Service) getCIRAConfig(configuration portainer.OpenAMTConfiguration, configName string) (*CIRAConfig, error) { + url := fmt.Sprintf("https://%s/rps/api/v1/admin/ciraconfigs/%s", configuration.MPSURL, configName) + + responseBody, err := service.executeGetRequest(url, configuration.Credentials.MPSToken) + if err != nil { + return nil, err + } + if responseBody == nil { + return nil, nil + } + + var result CIRAConfig + err = json.Unmarshal(responseBody, &result) + if err != nil { + return nil, err + } + return &result, nil +} + +func (service *Service) saveCIRAConfig(method string, configuration portainer.OpenAMTConfiguration, configName string) (*CIRAConfig, error) { + url := fmt.Sprintf("https://%s/rps/api/v1/admin/ciraconfigs", configuration.MPSURL) + + certificate, err := service.getCIRACertificate(configuration) + if err != nil { + return nil, err + } + + addressFormat, err := addressFormat(configuration.MPSURL) + if err != nil { + return nil, err + } + + config := CIRAConfig{ + ConfigName: configName, + MPSServerAddress: configuration.MPSURL, + CommonName: configuration.MPSURL, + ServerAddressFormat: addressFormat, + MPSPort: 4433, + Username: "admin", + MPSRootCertificate: certificate, + RegeneratePassword: false, + AuthMethod: 2, + } + payload, _ := json.Marshal(config) + + responseBody, err := service.executeSaveRequest(method, url, configuration.Credentials.MPSToken, payload) + if err != nil { + return nil, err + } + + var result CIRAConfig + err = json.Unmarshal(responseBody, &result) + if err != nil { + return nil, err + } + return &result, nil +} + +func addressFormat(url string) (int, error) { + ip := net.ParseIP(url) + if ip == nil { + return 201, nil // FQDN + } + if strings.Contains(url, ".") { + return 3, nil // IPV4 + } + if strings.Contains(url, ":") { + return 4, nil // IPV6 + } + return 0, fmt.Errorf("could not determine server address format for %s", url) +} + +func (service *Service) getCIRACertificate(configuration portainer.OpenAMTConfiguration) (string, error) { + loginURL := fmt.Sprintf("https://%s/mps/api/v1/ciracert", configuration.MPSURL) + + req, err := http.NewRequest(http.MethodGet, loginURL, nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", configuration.Credentials.MPSToken)) + + response, err := service.httpsClient.Do(req) + if err != nil { + return "", err + } + + if response.StatusCode != http.StatusOK { + return "", errors.New(fmt.Sprintf("unexpected status code %s", response.Status)) + } + + certificate, err := io.ReadAll(response.Body) + if err != nil { + return "", err + } + block, _ := pem.Decode(certificate) + return base64.StdEncoding.EncodeToString(block.Bytes), nil +} diff --git a/api/hostmanagement/openamt/configDomain.go b/api/hostmanagement/openamt/configDomain.go new file mode 100644 index 000000000..b3b04385a --- /dev/null +++ b/api/hostmanagement/openamt/configDomain.go @@ -0,0 +1,81 @@ +package openamt + +import ( + "encoding/json" + "fmt" + "net/http" + + portainer "github.com/portainer/portainer/api" +) + +type ( + Domain struct { + DomainName string `json:"profileName"` + DomainSuffix string `json:"domainSuffix"` + ProvisioningCert string `json:"provisioningCert"` + ProvisioningCertPassword string `json:"provisioningCertPassword"` + ProvisioningCertStorageFormat string `json:"provisioningCertStorageFormat"` + } +) + +func (service *Service) createOrUpdateDomain(configuration portainer.OpenAMTConfiguration) (*Domain, error) { + domain, err := service.getDomain(configuration) + if err != nil { + return nil, err + } + + method := http.MethodPost + if domain != nil { + method = http.MethodPatch + } + + domain, err = service.saveDomain(method, configuration) + if err != nil { + return nil, err + } + return domain, nil +} + +func (service *Service) getDomain(configuration portainer.OpenAMTConfiguration) (*Domain, error) { + url := fmt.Sprintf("https://%s/rps/api/v1/admin/domains/%s", configuration.MPSURL, configuration.DomainConfiguration.DomainName) + + responseBody, err := service.executeGetRequest(url, configuration.Credentials.MPSToken) + if err != nil { + return nil, err + } + if responseBody == nil { + return nil, nil + } + + var result Domain + err = json.Unmarshal(responseBody, &result) + if err != nil { + return nil, err + } + return &result, nil +} + +func (service *Service) saveDomain(method string, configuration portainer.OpenAMTConfiguration) (*Domain, error) { + url := fmt.Sprintf("https://%s/rps/api/v1/admin/domains", configuration.MPSURL) + + profile := Domain{ + DomainName: configuration.DomainConfiguration.DomainName, + DomainSuffix: configuration.DomainConfiguration.DomainName, + ProvisioningCert: configuration.DomainConfiguration.CertFileText, + ProvisioningCertPassword: configuration.DomainConfiguration.CertPassword, + ProvisioningCertStorageFormat: "string", + } + payload, _ := json.Marshal(profile) + + responseBody, err := service.executeSaveRequest(method, url, configuration.Credentials.MPSToken, payload) + if err != nil { + return nil, err + } + + var result Domain + err = json.Unmarshal(responseBody, &result) + if err != nil { + return nil, err + } + return &result, nil +} diff --git a/api/hostmanagement/openamt/configProfile.go b/api/hostmanagement/openamt/configProfile.go new file mode 100644 index 000000000..c50f66f6f --- /dev/null +++ b/api/hostmanagement/openamt/configProfile.go @@ -0,0 +1,104 @@ +package openamt + +import ( + "encoding/json" + "fmt" + "net/http" + + portainer "github.com/portainer/portainer/api" +) + +type ( + Profile struct { + ProfileName string `json:"profileName"` + Activation string `json:"activation"` + CIRAConfigName *string `json:"ciraConfigName"` + GenerateRandomAMTPassword bool `json:"generateRandomPassword"` + AMTPassword string `json:"amtPassword"` + GenerateRandomMEBxPassword bool `json:"generateRandomMEBxPassword"` + MEBXPassword string `json:"mebxPassword"` + Tags []string `json:"tags"` + DHCPEnabled bool `json:"dhcpEnabled"` + TenantId string `json:"tenantId"` + WIFIConfigs []ProfileWifiConfig `json:"wifiConfigs"` + } + + ProfileWifiConfig struct { + Priority int `json:"priority"` + ProfileName string `json:"profileName"` + } +) + +func (service *Service) createOrUpdateAMTProfile(configuration portainer.OpenAMTConfiguration, profileName string, ciraConfigName string, wirelessConfig string) (*Profile, error) { + profile, err := service.getAMTProfile(configuration, profileName) + if err != nil { + return nil, err + } + + method := http.MethodPost + if profile != nil { + method = http.MethodPatch + } + + profile, err = service.saveAMTProfile(method, configuration, profileName, ciraConfigName, wirelessConfig) + if err != nil { + return nil, err + } + return profile, nil +} + +func (service *Service) getAMTProfile(configuration portainer.OpenAMTConfiguration, profileName string) (*Profile, error) { + url := fmt.Sprintf("https://%s/rps/api/v1/admin/profiles/%s", configuration.MPSURL, profileName) + + responseBody, err := service.executeGetRequest(url, configuration.Credentials.MPSToken) + if err != nil { + return nil, err + } + if responseBody == nil { + return nil, nil + } + + var result Profile + err = json.Unmarshal(responseBody, &result) + if err != nil { + return nil, err + } + return &result, nil +} + +func (service *Service) saveAMTProfile(method string, configuration portainer.OpenAMTConfiguration, profileName string, ciraConfigName string, wirelessConfig string) (*Profile, error) { + url := fmt.Sprintf("https://%s/rps/api/v1/admin/profiles", configuration.MPSURL) + + profile := Profile{ + ProfileName: profileName, + Activation: "acmactivate", + GenerateRandomAMTPassword: false, + GenerateRandomMEBxPassword: false, + AMTPassword: configuration.Credentials.MPSPassword, + MEBXPassword: configuration.Credentials.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) + if err != nil { + return nil, err + } + + var result Profile + err = json.Unmarshal(responseBody, &result) + if err != nil { + return nil, err + } + return &result, nil +} diff --git a/api/hostmanagement/openamt/configWireless.go b/api/hostmanagement/openamt/configWireless.go new file mode 100644 index 000000000..0a3938fa0 --- /dev/null +++ b/api/hostmanagement/openamt/configWireless.go @@ -0,0 +1,91 @@ +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.MPSURL, 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.MPSURL) + + config := WirelessProfile{ + ProfileName: configName, + AuthenticationMethod: parsedAuthenticationMethod, + EncryptionMethod: parsedEncryptionMethod, + SSID: configuration.WirelessConfiguration.SSID, + PSKPassphrase: configuration.WirelessConfiguration.PskPass, + } + payload, _ := json.Marshal(config) + + responseBody, err := service.executeSaveRequest(method, url, configuration.Credentials.MPSToken, payload) + if err != nil { + return nil, err + } + + var result WirelessProfile + err = json.Unmarshal(responseBody, &result) + if err != nil { + return nil, err + } + return &result, nil +} diff --git a/api/hostmanagement/openamt/openamt.go b/api/hostmanagement/openamt/openamt.go new file mode 100644 index 000000000..61b047fd7 --- /dev/null +++ b/api/hostmanagement/openamt/openamt.go @@ -0,0 +1,157 @@ +package openamt + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "time" + + portainer "github.com/portainer/portainer/api" +) + +const ( + DefaultCIRAConfigName = "ciraConfigDefault" + DefaultWirelessConfigName = "wirelessProfileDefault" + DefaultProfileName = "profileAMTDefault" +) + +// Service represents a service for managing an OpenAMT server. +type Service struct { + httpsClient *http.Client +} + +// NewService initializes a new service. +func NewService(dataStore portainer.DataStore) *Service { + if !dataStore.Settings().IsFeatureFlagEnabled(portainer.FeatOpenAMT) { + return nil + } + return &Service{ + httpsClient: &http.Client{ + Timeout: time.Second * time.Duration(5), + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + }, + } +} + +type openAMTError struct { + ErrorMsg string `json:"message"` + Errors []struct { + ErrorMsg string `json:"msg"` + } `json:"errors"` +} + +func parseError(responseBody []byte) error { + var errorResponse openAMTError + err := json.Unmarshal(responseBody, &errorResponse) + if err != nil { + return err + } + + if len(errorResponse.Errors) > 0 { + return errors.New(errorResponse.Errors[0].ErrorMsg) + } + if errorResponse.ErrorMsg != "" { + return errors.New(errorResponse.ErrorMsg) + } + return nil +} + +func (service *Service) ConfigureDefault(configuration portainer.OpenAMTConfiguration) error { + token, err := service.executeAuthenticationRequest(configuration) + if err != nil { + return err + } + configuration.Credentials.MPSToken = token.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) + if err != nil { + return err + } + + _, err = service.createOrUpdateDomain(configuration) + if err != nil { + return err + } + + return nil +} + +func (service *Service) executeSaveRequest(method string, url string, token string, payload []byte) ([]byte, error) { + req, err := http.NewRequest(method, url, bytes.NewBuffer(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + response, err := service.httpsClient.Do(req) + if err != nil { + return nil, err + } + responseBody, readErr := ioutil.ReadAll(response.Body) + if readErr != nil { + return nil, readErr + } + + if response.StatusCode < 200 || response.StatusCode > 300 { + errorResponse := parseError(responseBody) + if errorResponse != nil { + return nil, errorResponse + } + return nil, errors.New(fmt.Sprintf("unexpected status code %s", response.Status)) + } + + return responseBody, nil +} + +func (service *Service) executeGetRequest(url string, token string) ([]byte, error) { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + response, err := service.httpsClient.Do(req) + if err != nil { + return nil, err + } + responseBody, readErr := ioutil.ReadAll(response.Body) + if readErr != nil { + return nil, readErr + } + + if response.StatusCode < 200 || response.StatusCode > 300 { + if response.StatusCode == http.StatusNotFound { + return nil, nil + } + errorResponse := parseError(responseBody) + if errorResponse != nil { + return nil, errorResponse + } + return nil, errors.New(fmt.Sprintf("unexpected status code %s", response.Status)) + } + + return responseBody, nil +} diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index bed74146a..6e22efa59 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -17,6 +17,7 @@ import ( "github.com/portainer/portainer/api/http/handler/endpoints" "github.com/portainer/portainer/api/http/handler/file" "github.com/portainer/portainer/api/http/handler/helm" + "github.com/portainer/portainer/api/http/handler/hostmanagement/openamt" "github.com/portainer/portainer/api/http/handler/kubernetes" "github.com/portainer/portainer/api/http/handler/ldap" "github.com/portainer/portainer/api/http/handler/motd" @@ -62,6 +63,7 @@ type Handler struct { RoleHandler *roles.Handler SettingsHandler *settings.Handler SSLHandler *ssl.Handler + OpenAMTHandler *openamt.Handler StackHandler *stacks.Handler StatusHandler *status.Handler StorybookHandler *storybook.Handler @@ -221,6 +223,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.StripPrefix("/api", h.UserHandler).ServeHTTP(w, r) 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) + } case strings.HasPrefix(r.URL.Path, "/api/teams"): http.StripPrefix("/api", h.TeamHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/team_memberships"): diff --git a/api/http/handler/hostmanagement/openamt/handler.go b/api/http/handler/hostmanagement/openamt/handler.go new file mode 100644 index 000000000..eab16299d --- /dev/null +++ b/api/http/handler/hostmanagement/openamt/handler.go @@ -0,0 +1,33 @@ +package openamt + +import ( + "net/http" + + "github.com/gorilla/mux" + + httperror "github.com/portainer/libhttp/error" + portainer "github.com/portainer/portainer/api" + "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 portainer.DataStore +} + +// NewHandler returns a new Handler +func NewHandler(bouncer *security.RequestBouncer, dataStore portainer.DataStore) (*Handler, error) { + if !dataStore.Settings().IsFeatureFlagEnabled(portainer.FeatOpenAMT) { + return nil, nil + } + + h := &Handler{ + Router: mux.NewRouter(), + } + + h.Handle("/open_amt", bouncer.AdminAccess(httperror.LoggerHandler(h.openAMTConfigureDefault))).Methods(http.MethodPost) + + return h, nil +} diff --git a/api/http/handler/hostmanagement/openamt/openamt.go b/api/http/handler/hostmanagement/openamt/openamt.go new file mode 100644 index 000000000..59754712b --- /dev/null +++ b/api/http/handler/hostmanagement/openamt/openamt.go @@ -0,0 +1,218 @@ +package openamt + +import ( + "encoding/base64" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/sirupsen/logrus" + "software.sslmate.com/src/go-pkcs12" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + portainer "github.com/portainer/portainer/api" +) + +type openAMTConfigureDefaultPayload struct { + EnableOpenAMT bool + MPSURL string + MPSUser string + MPSPassword string + CertFileText string + CertPassword string + DomainName string + UseWirelessConfig bool + WifiAuthenticationMethod string + WifiEncryptionMethod string + WifiSSID string + WifiPskPass string +} + +func (payload *openAMTConfigureDefaultPayload) Validate(r *http.Request) error { + if payload.EnableOpenAMT { + if payload.MPSURL == "" { + return errors.New("MPS Url must be provided") + } + if payload.MPSUser == "" { + return errors.New("MPS User must be provided") + } + if payload.MPSPassword == "" { + return errors.New("MPS Password must be provided") + } + if payload.DomainName == "" { + return errors.New("domain name must be provided") + } + if payload.CertFileText == "" { + return errors.New("certificate file must be provided") + } + if payload.CertPassword == "" { + return errors.New("certificate password 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") + } + } + } + + return nil +} + +// @id OpenAMTConfigureDefault +// @summary Enable Portainer's OpenAMT capabilities +// @description Enable Portainer's OpenAMT capabilities +// @description **Access policy**: administrator +// @tags intel +// @security jwt +// @accept json +// @produce json +// @param body body openAMTConfigureDefaultPayload 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 + 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 certificateErr != nil { + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Error validating certificate", Err: certificateErr} + } + + err = handler.enableOpenAMT(payload) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Error enabling OpenAMT", Err: err} + } + return response.Empty(w) + } + + err = handler.disableOpenAMT() + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Error disabling OpenAMT", Err: err} + } + return response.Empty(w) +} + +func validateCertificate(certificateRaw string, certificatePassword string) error { + certificateData, err := base64.StdEncoding.Strict().DecodeString(certificateRaw) + if err != nil { + return err + } + + _, certificate, _, err := pkcs12.DecodeChain(certificateData, certificatePassword) + if err != nil { + return err + } + + if certificate == nil { + return errors.New("certificate could not be decoded") + } + + issuer := certificate.Issuer.CommonName + if !isValidIssuer(issuer) { + return fmt.Errorf("certificate issuer is invalid: %v", issuer) + } + + return nil +} + +func isValidIssuer(issuer string) bool { + formattedIssuer := strings.ToLower(strings.ReplaceAll(issuer, " ", "")) + return strings.Contains(formattedIssuer, "comodo") || + strings.Contains(formattedIssuer, "digicert") || + strings.Contains(formattedIssuer, "entrust") || + strings.Contains(formattedIssuer, "godaddy") +} + +func (handler *Handler) enableOpenAMT(configurationPayload openAMTConfigureDefaultPayload) error { + configuration := portainer.OpenAMTConfiguration{ + Enabled: true, + MPSURL: configurationPayload.MPSURL, + Credentials: portainer.MPSCredentials{ + MPSUser: configurationPayload.MPSUser, + MPSPassword: configurationPayload.MPSPassword, + }, + DomainConfiguration: portainer.DomainConfiguration{ + CertFileText: configurationPayload.CertFileText, + CertPassword: configurationPayload.CertPassword, + 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) + if err != nil { + logrus.WithError(err).Error("error configuring OpenAMT server") + return err + } + + err = handler.saveConfiguration(configuration) + if err != nil { + logrus.WithError(err).Error("error updating OpenAMT configurations") + return err + } + + logrus.Info("OpenAMT successfully enabled") + return nil +} + +func (handler *Handler) saveConfiguration(configuration portainer.OpenAMTConfiguration) error { + settings, err := handler.DataStore.Settings().Settings() + if err != nil { + return err + } + + configuration.Credentials.MPSToken = "" + + settings.OpenAMTConfiguration = configuration + err = handler.DataStore.Settings().UpdateSettings(settings) + if err != nil { + return err + } + + return nil +} + +func (handler *Handler) disableOpenAMT() error { + settings, err := handler.DataStore.Settings().Settings() + if err != nil { + return err + } + + settings.OpenAMTConfiguration.Enabled = false + + err = handler.DataStore.Settings().UpdateSettings(settings) + if err != nil { + return err + } + + logrus.Info("OpenAMT successfully disabled") + return nil +} diff --git a/api/http/server.go b/api/http/server.go index 0981be1c0..67e87be51 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -28,6 +28,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/openamt" kubehandler "github.com/portainer/portainer/api/http/handler/kubernetes" "github.com/portainer/portainer/api/http/handler/ldap" "github.com/portainer/portainer/api/http/handler/motd" @@ -75,6 +76,7 @@ type Server struct { FileService portainer.FileService DataStore portainer.DataStore GitService portainer.GitService + OpenAMTService portainer.OpenAMTService JWTService portainer.JWTService LDAPService portainer.LDAPService OAuthService portainer.OAuthService @@ -203,6 +205,15 @@ 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 + } + var stackHandler = stacks.NewHandler(requestBouncer) stackHandler.DataStore = server.DataStore stackHandler.DockerClientFactory = server.DockerClientFactory @@ -268,6 +279,7 @@ func (server *Server) Start() error { HelmTemplatesHandler: helmTemplatesHandler, KubernetesHandler: kubernetesHandler, MOTDHandler: motdHandler, + OpenAMTHandler: openAMTHandler, RegistryHandler: registryHandler, ResourceControlHandler: resourceControlHandler, SettingsHandler: settingsHandler, diff --git a/api/internal/testhelpers/datastore.go b/api/internal/testhelpers/datastore.go index 15a1fa87d..a62dc5366 100644 --- a/api/internal/testhelpers/datastore.go +++ b/api/internal/testhelpers/datastore.go @@ -86,6 +86,9 @@ func (s *stubSettingsService) UpdateSettings(settings *portainer.Settings) error s.settings = settings return nil } +func (s *stubSettingsService) IsFeatureFlagEnabled(feature portainer.Feature) bool { + return false +} func WithSettingsService(settings *portainer.Settings) datastoreOption { return func(d *datastore) { d.settings = &stubSettingsService{ diff --git a/api/portainer.go b/api/portainer.go index e189d7b79..6be22c7d7 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -39,6 +39,34 @@ type ( AuthenticationKey string `json:"AuthenticationKey" example:"cOrXoK/1D35w8YQ8nH1/8ZGwzz45JIYD5jxHKXEQknk="` } + // OpenAMTConfiguration represents the credentials and configurations used to connect to an OpenAMT MPS server + OpenAMTConfiguration struct { + Enabled bool `json:"Enabled"` + MPSURL string `json:"MPSURL"` + Credentials MPSCredentials `json:"Credentials"` + DomainConfiguration DomainConfiguration `json:"DomainConfiguration"` + WirelessConfiguration *WirelessConfiguration `json:"WirelessConfiguration"` + } + + MPSCredentials struct { + MPSUser string `json:"MPSUser"` + MPSPassword string `json:"MPSPassword"` + MPSToken string `json:"MPSToken"` // retrieved from API + } + + DomainConfiguration struct { + CertFileText string `json:"CertFileText"` + CertPassword string `json:"CertPassword"` + DomainName string `json:"DomainName"` + } + + WirelessConfiguration struct { + AuthenticationMethod string `json:"AuthenticationMethod"` + EncryptionMethod string `json:"EncryptionMethod"` + SSID string `json:"SSID"` + PskPass string `json:"PskPass"` + } + // CLIFlags represents the available flags on the CLI CLIFlags struct { Addr *string @@ -707,6 +735,7 @@ type ( AuthenticationMethod AuthenticationMethod `json:"AuthenticationMethod" example:"1"` LDAPSettings LDAPSettings `json:"LDAPSettings" example:""` OAuthSettings OAuthSettings `json:"OAuthSettings" example:""` + OpenAMTConfiguration OpenAMTConfiguration `json:"OpenAMTConfiguration" example:""` FeatureFlagSettings map[Feature]bool `json:"FeatureFlagSettings" example:""` // The interval in which environment(endpoint) snapshots are created SnapshotInterval string `json:"SnapshotInterval" example:"5m"` @@ -1256,6 +1285,11 @@ type ( LatestCommitID(repositoryURL, referenceName, username, password string) (string, error) } + // OpenAMTService represents a service for managing OpenAMT + OpenAMTService interface { + ConfigureDefault(configuration OpenAMTConfiguration) error + } + // HelmUserRepositoryService represents a service to manage HelmUserRepositories HelmUserRepositoryService interface { HelmUserRepositoryByUserID(userID UserID) ([]HelmUserRepository, error) @@ -1360,6 +1394,7 @@ type ( SettingsService interface { Settings() (*Settings, error) UpdateSettings(settings *Settings) error + IsFeatureFlagEnabled(feature Feature) bool } // Server defines the interface to serve the API diff --git a/app/portainer/models/settings.js b/app/portainer/models/settings.js index 06226b5bb..ba77d8bae 100644 --- a/app/portainer/models/settings.js +++ b/app/portainer/models/settings.js @@ -4,6 +4,7 @@ export function SettingsViewModel(data) { this.AuthenticationMethod = data.AuthenticationMethod; this.LDAPSettings = data.LDAPSettings; this.OAuthSettings = new OAuthSettingsViewModel(data.OAuthSettings); + this.OpenAMTConfiguration = data.OpenAMTConfiguration; this.SnapshotInterval = data.SnapshotInterval; this.TemplatesURL = data.TemplatesURL; this.EdgeAgentCheckinInterval = data.EdgeAgentCheckinInterval; diff --git a/app/portainer/rest/openAMT.js b/app/portainer/rest/openAMT.js new file mode 100644 index 000000000..7212d0b28 --- /dev/null +++ b/app/portainer/rest/openAMT.js @@ -0,0 +1,16 @@ +import angular from 'angular'; + +const API_ENDPOINT_OPEN_AMT = 'api/open_amt'; + +angular.module('portainer.app').factory('OpenAMT', OpenAMTFactory); + +/* @ngInject */ +function OpenAMTFactory($resource) { + return $resource( + API_ENDPOINT_OPEN_AMT, + {}, + { + submit: { method: 'POST' }, + } + ); +} diff --git a/app/portainer/services/api/openAMTService.js b/app/portainer/services/api/openAMTService.js new file mode 100644 index 000000000..e3b42e5da --- /dev/null +++ b/app/portainer/services/api/openAMTService.js @@ -0,0 +1,14 @@ +import angular from 'angular'; + +angular.module('portainer.app').service('OpenAMTService', OpenAMTServiceFactory); + +/* @ngInject */ +function OpenAMTServiceFactory(OpenAMT) { + return { + submit, + }; + + function submit(formValues) { + return OpenAMT.submit(formValues).$promise; + } +} diff --git a/app/portainer/settings/general/index.js b/app/portainer/settings/general/index.js index ab9158702..a6c71b73c 100644 --- a/app/portainer/settings/general/index.js +++ b/app/portainer/settings/general/index.js @@ -1,5 +1,6 @@ import angular from 'angular'; import { sslCertificate } from './ssl-certificate'; +import { openAMT } from './open-amt'; -export default angular.module('portainer.settings.general', []).component('sslCertificateSettings', sslCertificate).name; +export default angular.module('portainer.settings.general', []).component('sslCertificateSettings', sslCertificate).component('openAmtSettings', openAMT).name; diff --git a/app/portainer/settings/general/open-amt/index.js b/app/portainer/settings/general/open-amt/index.js new file mode 100644 index 000000000..c6c493280 --- /dev/null +++ b/app/portainer/settings/general/open-amt/index.js @@ -0,0 +1,6 @@ +import controller from './open-amt.controller.js'; + +export const openAMT = { + templateUrl: './open-amt.html', + controller, +}; diff --git a/app/portainer/settings/general/open-amt/open-amt.controller.js b/app/portainer/settings/general/open-amt/open-amt.controller.js new file mode 100644 index 000000000..e2a835413 --- /dev/null +++ b/app/portainer/settings/general/open-amt/open-amt.controller.js @@ -0,0 +1,111 @@ +class OpenAmtController { + /* @ngInject */ + constructor($async, $state, OpenAMTService, SettingsService, Notifications) { + Object.assign(this, { $async, $state, OpenAMTService, SettingsService, Notifications }); + + this.originalValues = {}; + this.formValues = { + enableOpenAMT: false, + mpsURL: '', + mpsUser: '', + mpsPassword: '', + domainName: '', + certFile: null, + certPassword: '', + useWirelessConfig: false, + wifiAuthenticationMethod: '4', + wifiEncryptionMethod: '3', + wifiSsid: '', + wifiPskPass: '', + }; + + this.originalValues = { + ...this.formValues, + }; + + this.state = { + actionInProgress: false, + }; + + this.save = this.save.bind(this); + } + + isFormChanged() { + return Object.entries(this.originalValues).some(([key, value]) => value !== this.formValues[key]); + } + + isFormValid() { + return !this.formValues.enableOpenAMT || this.formValues.certFile != null; + } + + async readFile() { + return new Promise((resolve, reject) => { + const file = this.formValues.certFile; + if (file) { + const fileReader = new FileReader(); + fileReader.fileName = file.name; + fileReader.onload = (e) => { + const base64 = e.target.result; + // remove prefix of "data:application/x-pkcs12;base64," returned by "readAsDataURL()" + const index = base64.indexOf('base64,'); + const cert = base64.substring(index + 7, base64.length); + resolve(cert); + }; + fileReader.onerror = () => { + reject(new Error('error reading provisioning certificate file')); + }; + fileReader.readAsDataURL(file); + } + }); + } + + async save() { + return this.$async(async () => { + this.state.actionInProgress = true; + try { + this.formValues.certFileText = this.formValues.certFile ? await this.readFile(this.formValues.certFile) : null; + await this.OpenAMTService.submit(this.formValues); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + this.Notifications.success(`OpenAMT successfully ${this.formValues.enableOpenAMT ? 'enabled' : 'disabled'}`); + } catch (err) { + this.Notifications.error('Failure', err, 'Failed applying changes'); + } + this.state.actionInProgress = false; + }); + } + + async $onInit() { + return this.$async(async () => { + try { + const data = await this.SettingsService.settings(); + const config = data.OpenAMTConfiguration; + + if (config) { + this.formValues = { + ...this.formValues, + enableOpenAMT: config.Enabled, + mpsURL: config.MPSURL, + mpsUser: config.Credentials.MPSUser, + domainName: config.DomainConfiguration.DomainName, + }; + + if (config.WirelessConfiguration) { + this.formValues.useWirelessConfig = true; + this.formValues.wifiAuthenticationMethod = config.WirelessConfiguration.AuthenticationMethod; + this.formValues.wifiEncryptionMethod = config.WirelessConfiguration.EncryptionMethod; + this.formValues.wifiSsid = config.WirelessConfiguration.SSID; + } + + this.originalValues = { + ...this.formValues, + }; + } + } catch (err) { + this.Notifications.error('Failure', err, 'Failed loading settings'); + } + }); + } +} + +export default OpenAmtController; diff --git a/app/portainer/settings/general/open-amt/open-amt.html b/app/portainer/settings/general/open-amt/open-amt.html new file mode 100644 index 000000000..7926f5aaa --- /dev/null +++ b/app/portainer/settings/general/open-amt/open-amt.html @@ -0,0 +1,259 @@ +
+
+ + + +
+ + +

+ + When enabled, this will allow Portainer to interact with an OpenAMT MPS API. +

+
+ +
+
+ +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ +
+ +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ +
+ +
+ + + {{ $ctrl.formValues.certFile.name }} + + +
+
+
+
+
+

File type is invalid.

+
+
+
+ +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ +
+ +
+
+ +
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+ +
+
+
+ + {{ state.formValidationError }} +
+
+
+
+
+
+
diff --git a/app/portainer/views/settings/settings.html b/app/portainer/views/settings/settings.html index 6a0246268..67a4380b8 100644 --- a/app/portainer/views/settings/settings.html +++ b/app/portainer/views/settings/settings.html @@ -184,6 +184,8 @@ + +