feat(openamt): Configuration of the OpenAMT capability [INT-6] (#6071)

Co-authored-by: Sven Dowideit <sven.dowideit@portainer.io>
pull/6185/head
Marcelo Rydel 2021-11-29 06:06:50 -07:00 committed by GitHub
parent ab0849d0f3
commit 47c1af93ea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 1373 additions and 8 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,6 @@
import controller from './open-amt.controller.js';
export const openAMT = {
templateUrl: './open-amt.html',
controller,
};

View File

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

View File

@ -0,0 +1,259 @@
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-header icon="fa-laptop" title-text="Intel OpenAMT"></rd-widget-header>
<rd-widget-body>
<form class="form-horizontal" name="openAMTForm">
<por-switch-field ng-model="$ctrl.formValues.enableOpenAMT" label="Enable edge OpenAMT"></por-switch-field>
<span class="small">
<p class="text-muted" style="margin-top: 10px;">
<i class="fa fa-exclamation-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
When enabled, this will allow Portainer to interact with an OpenAMT MPS API.
</p>
</span>
<div ng-show="$ctrl.formValues.enableOpenAMT">
<hr />
<div class="form-group">
<label for="mps_url" class="col-sm-3 control-label text-left">
MPS URL
</label>
<div class="col-sm-8">
<input
type="text"
class="form-control"
ng-model="$ctrl.formValues.mpsURL"
id="mps_url"
name="mps_url"
placeholder="Enter the MPS URL"
ng-required="$ctrl.formValues.enableOpenAMT"
/>
</div>
</div>
<div class="form-group col-md-12" ng-show="openAMTForm.mps_url.$invalid">
<div class="small text-warning">
<div ng-messages="openAMTForm.mps_url.$error">
<p ng-message="required"> <i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
</div>
</div>
</div>
<div class="form-group">
<label for="mps_user" class="col-sm-3 control-label text-left">
MPS User
</label>
<div class="col-sm-8">
<input
type="text"
class="form-control"
ng-model="$ctrl.formValues.mpsUser"
id="mps_user"
name="mps_user"
placeholder="Enter the MPS User"
ng-required="$ctrl.formValues.enableOpenAMT"
/>
</div>
</div>
<div class="form-group col-md-12" ng-show="openAMTForm.mps_user.$invalid">
<div class="small text-warning">
<div ng-messages="openAMTForm.mps_user.$error">
<p ng-message="required"> <i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
</div>
</div>
</div>
<div class="form-group">
<label for="mps_user" class="col-sm-3 control-label text-left">
MPS Password
<portainer-tooltip
position="bottom"
message="Needs to be 8-32 characters including one uppercase, one lowercase letters, one base-10 digit and one special character."
></portainer-tooltip>
</label>
<div class="col-sm-8">
<input
type="password"
class="form-control"
ng-model="$ctrl.formValues.mpsPassword"
id="mps_password"
name="mps_password"
placeholder="Enter the MPS Password"
ng-required="$ctrl.formValues.enableOpenAMT"
/>
</div>
</div>
<div class="form-group col-md-12" ng-show="openAMTForm.mps_password.$invalid">
<div class="small text-warning">
<div ng-messages="openAMTForm.mps_password.$error">
<p ng-message="required"> <i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
</div>
</div>
</div>
<hr />
<div class="form-group">
<label for="domain_name" class="col-sm-3 control-label text-left">
Domain Name
<portainer-tooltip position="bottom" message="Enter the FQDN that is associated with the provisioning certificate (i.e amtdomain.com)"></portainer-tooltip>
</label>
<div class="col-sm-8">
<input
type="text"
class="form-control"
ng-model="$ctrl.formValues.domainName"
id="domain_name"
name="domain_name"
placeholder="Enter the Domain Name"
ng-required="$ctrl.formValues.enableOpenAMT"
/>
</div>
</div>
<div class="form-group col-md-12" ng-show="openAMTForm.domain_name.$invalid">
<div class="small text-warning">
<div ng-messages="openAMTForm.domain_name.$error">
<p ng-message="required"> <i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
</div>
</div>
</div>
<div class="form-group">
<label for="certificate_file" class="col-sm-3 control-label text-left">
Provisioning Certificate File (.pfx)
<portainer-tooltip
position="bottom"
message="Supported CAs are Comodo, DigiCert, Entrust and GoDaddy. The certificate must contain the private key."
></portainer-tooltip>
</label>
<div class="col-sm-8">
<button style="margin-left: 0px !important;" class="btn btn-sm btn-primary" ngf-select ng-model="$ctrl.formValues.certFile" ngf-pattern=".pfx" name="certFile">
Upload file
</button>
<span style="margin-left: 5px;">
{{ $ctrl.formValues.certFile.name }}
<i class="fa fa-times red-icon" ng-if="!$ctrl.formValues.certFile" aria-hidden="true"></i>
</span>
</div>
</div>
<div class="form-group col-md-12" ng-show="openAMTForm.certFile.$invalid">
<div class="small text-warning">
<div ng-messages="openAMTForm.certFile.$error">
<p ng-message="pattern"> <i class="fa fa-exclamation-triangle" aria-hidden="true"></i> File type is invalid.</p>
</div>
</div>
</div>
<div class="form-group">
<label for="certificate_password" class="col-sm-3 control-label text-left">
Provisioning Certificate Password
<portainer-tooltip
position="bottom"
message="Needs to be 8-32 characters including one uppercase, one lowercase letters, one base-10 digit and one special character."
></portainer-tooltip>
</label>
<div class="col-sm-8">
<input
type="password"
class="form-control"
ng-model="$ctrl.formValues.certPassword"
id="certificate_password"
name="certificate_password"
placeholder="**********"
ng-required="$ctrl.formValues.enableOpenAMT"
/>
</div>
</div>
<div class="form-group col-md-12" ng-show="openAMTForm.certificate_password.$invalid">
<div class="small text-warning">
<div ng-messages="openAMTForm.certificate_password.$error">
<p ng-message="required"> <i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
</div>
</div>
</div>
<hr />
<div class="form-group">
<div class="col-sm-12">
<por-switch-field ng-model="$ctrl.formValues.useWirelessConfig" label="Wireless Configuration"></por-switch-field>
</div>
</div>
<div ng-show="$ctrl.formValues.useWirelessConfig">
<div class="form-group">
<label for="wifi_auth_method" class="col-sm-3 control-label text-left">
Authentication Method
</label>
<div class="col-sm-8">
<select class="form-control" ng-model="$ctrl.formValues.wifiAuthenticationMethod">
<option value="4">WPA PSK</option>
<option value="6">WPA2 PSK</option>
</select>
</div>
</div>
<div class="form-group">
<label for="wifi_encrypt_method" class="col-sm-3 control-label text-left">
Encryption Method
</label>
<div class="col-sm-8">
<select class="form-control" ng-model="$ctrl.formValues.wifiEncryptionMethod" id="wifi_encrypt_method">
<option value="3">TKIP</option>
<option value="4">CCMP</option>
</select>
</div>
</div>
<div class="form-group">
<label for="wifi_ssid" class="col-sm-3 control-label text-left">
SSID
</label>
<div class="col-sm-8">
<input
type="text"
class="form-control"
ng-model="$ctrl.formValues.wifiSsid"
id="wifi_ssid"
placeholder="SSIID"
ng-required="$ctrl.formValues.enableOpenAMT && $ctrl.formValues.useWirelessConfig"
/>
</div>
</div>
<div class="form-group">
<label for="wifi_pass" class="col-sm-3 control-label text-left">
PSK Passphrase
<portainer-tooltip position="bottom" message="PSK Passphrase length should be greater than or equal to 8 and less than or equal to 63"></portainer-tooltip>
</label>
<div class="col-sm-8">
<input
type="password"
class="form-control"
ng-model="$ctrl.formValues.wifiPskPass"
id="wifi_pass"
placeholder="******"
ng-required="$ctrl.formValues.enableOpenAMT && $ctrl.formValues.useWirelessConfig"
/>
</div>
</div>
</div>
</div>
<hr />
<div class="form-group">
<div class="col-sm-12">
<button
type="button"
class="btn btn-primary btn-sm"
ng-disabled="$ctrl.state.actionInProgress || !openAMTForm.$valid || !$ctrl.isFormChanged() || !$ctrl.isFormValid()"
ng-click="$ctrl.save()"
button-spinner="$ctrl.state.actionInProgress"
>
<span ng-hide="$ctrl.state.actionInProgress">Save Settings</span>
<span ng-show="$ctrl.state.actionInProgress">In progress...</span>
</button>
<span class="text-danger" ng-if="state.formValidationError" style="margin-left: 5px;">{{ state.formValidationError }}</span>
</div>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@ -184,6 +184,8 @@
<ssl-certificate-settings></ssl-certificate-settings>
<open-amt-settings ng-show="settings.FeatureFlagSettings && settings.FeatureFlagSettings['open-amt']"></open-amt-settings>
<div class="row">
<div class="col-sm-12">
<rd-widget>