diff --git a/api/cron/job_snapshot.go b/api/cron/job_snapshot.go index e9c6e602b..7fedc0f6d 100644 --- a/api/cron/job_snapshot.go +++ b/api/cron/job_snapshot.go @@ -53,7 +53,7 @@ func (runner *SnapshotJobRunner) Run() { } for _, endpoint := range endpoints { - if endpoint.Type == portainer.EdgeAgentEnvironment { + if endpoint.Type == portainer.AzureEnvironment || endpoint.Type == portainer.EdgeAgentEnvironment { continue } diff --git a/api/docker/client.go b/api/docker/client.go index ce8d21ec1..c1bd7a8d0 100644 --- a/api/docker/client.go +++ b/api/docker/client.go @@ -35,7 +35,9 @@ func NewClientFactory(signatureService portainer.DigitalSignatureService, revers // a specific endpoint configuration. The nodeName parameter can be used // with an agent enabled endpoint to target a specific node in an agent cluster. func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint, nodeName string) (*client.Client, error) { - if endpoint.Type == portainer.AgentOnDockerEnvironment { + if endpoint.Type == portainer.AzureEnvironment { + return nil, unsupportedEnvironmentType + } else if endpoint.Type == portainer.AgentOnDockerEnvironment { return createAgentClient(endpoint, factory.signatureService, nodeName) } else if endpoint.Type == portainer.EdgeAgentEnvironment { return createEdgeClient(endpoint, factory.reverseTunnelService, nodeName) diff --git a/api/errors.go b/api/errors.go index bc639d341..8e09838a1 100644 --- a/api/errors.go +++ b/api/errors.go @@ -39,6 +39,11 @@ const ( ErrEndpointAccessDenied = Error("Access denied to endpoint") ) +// Azure environment errors +const ( + ErrAzureInvalidCredentials = Error("Invalid Azure credentials") +) + // Endpoint group errors. const ( ErrCannotRemoveDefaultGroup = Error("Cannot remove the default endpoint group") diff --git a/api/go.sum b/api/go.sum index d3eead3d3..621d3a831 100644 --- a/api/go.sum +++ b/api/go.sum @@ -171,7 +171,6 @@ github.com/portainer/libcrypto v0.0.0-20190723020515-23ebe86ab2c2 h1:0PfgGLys9yH github.com/portainer/libcrypto v0.0.0-20190723020515-23ebe86ab2c2/go.mod h1:/wIeGwJOMYc1JplE/OvYMO5korce39HddIfI8VKGyAM= github.com/portainer/libhttp v0.0.0-20190806161843-ba068f58be33 h1:H8HR2dHdBf8HANSkUyVw4o8+4tegGcd+zyKZ3e599II= github.com/portainer/libhttp v0.0.0-20190806161843-ba068f58be33/go.mod h1:Y2TfgviWI4rT2qaOTHr+hq6MdKIE5YjgQAu7qwptTV0= -github.com/portainer/portainer v0.10.1 h1:I8K345CjGWfUGsVA8c8/gqamwLCC6CIAjxZXSklAFq0= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.1.0 h1:BQ53HtBmfOitExawJ6LokA4x8ov/z0SYYb0+HxJfRI8= diff --git a/api/http/client/client.go b/api/http/client/client.go index 0e6d9d41d..fb690105f 100644 --- a/api/http/client/client.go +++ b/api/http/client/client.go @@ -2,9 +2,12 @@ package client import ( "crypto/tls" + "encoding/json" + "fmt" "io/ioutil" "log" "net/http" + "net/url" "strings" "time" @@ -16,6 +19,55 @@ const ( defaultHTTPTimeout = 5 ) +// HTTPClient represents a client to send HTTP requests. +type HTTPClient struct { + *http.Client +} + +// NewHTTPClient is used to build a new HTTPClient. +func NewHTTPClient() *HTTPClient { + return &HTTPClient{ + &http.Client{ + Timeout: time.Second * time.Duration(defaultHTTPTimeout), + }, + } +} + +// AzureAuthenticationResponse represents an Azure API authentication response. +type AzureAuthenticationResponse struct { + AccessToken string `json:"access_token"` + ExpiresOn string `json:"expires_on"` +} + +// ExecuteAzureAuthenticationRequest is used to execute an authentication request +// against the Azure API. It re-uses the same http.Client. +func (client *HTTPClient) ExecuteAzureAuthenticationRequest(credentials *portainer.AzureCredentials) (*AzureAuthenticationResponse, error) { + loginURL := fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/token", credentials.TenantID) + params := url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {credentials.ApplicationID}, + "client_secret": {credentials.AuthenticationKey}, + "resource": {"https://management.azure.com/"}, + } + + response, err := client.PostForm(loginURL, params) + if err != nil { + return nil, err + } + + if response.StatusCode != http.StatusOK { + return nil, portainer.ErrAzureInvalidCredentials + } + + var token AzureAuthenticationResponse + err = json.NewDecoder(response.Body).Decode(&token) + if err != nil { + return nil, err + } + + return &token, nil +} + // Get executes a simple HTTP GET to the specified URL and returns // the content of the response body. Timeout can be specified via the timeout parameter, // will default to defaultHTTPTimeout if set to 0. diff --git a/api/http/handler/endpointproxy/handler.go b/api/http/handler/endpointproxy/handler.go index 394fa0b54..870f2de80 100644 --- a/api/http/handler/endpointproxy/handler.go +++ b/api/http/handler/endpointproxy/handler.go @@ -23,6 +23,8 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { Router: mux.NewRouter(), requestBouncer: bouncer, } + h.PathPrefix("/{id}/azure").Handler( + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToAzureAPI))) h.PathPrefix("/{id}/docker").Handler( bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToDockerAPI))) h.PathPrefix("/{id}/storidge").Handler( diff --git a/api/http/handler/endpointproxy/proxy_azure.go b/api/http/handler/endpointproxy/proxy_azure.go new file mode 100644 index 000000000..a9e66b66b --- /dev/null +++ b/api/http/handler/endpointproxy/proxy_azure.go @@ -0,0 +1,43 @@ +package endpointproxy + +import ( + "strconv" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/portainer/api" + + "net/http" +) + +func (handler *Handler) proxyRequestsToAzureAPI(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint, false) + if err != nil { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} + } + + var proxy http.Handler + proxy = handler.ProxyManager.GetEndpointProxy(endpoint) + if proxy == nil { + proxy, err = handler.ProxyManager.CreateAndRegisterEndpointProxy(endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create proxy", err} + } + } + + id := strconv.Itoa(endpointID) + http.StripPrefix("/"+id+"/azure", proxy).ServeHTTP(w, r) + return nil +} diff --git a/api/http/handler/endpoints/endpoint_create.go b/api/http/handler/endpoints/endpoint_create.go index d8a1bb898..677ce6222 100644 --- a/api/http/handler/endpoints/endpoint_create.go +++ b/api/http/handler/endpoints/endpoint_create.go @@ -18,19 +18,22 @@ import ( ) type endpointCreatePayload struct { - Name string - URL string - EndpointType int - PublicURL string - GroupID int - TLS bool - TLSSkipVerify bool - TLSSkipClientVerify bool - TLSCACertFile []byte - TLSCertFile []byte - TLSKeyFile []byte - TagIDs []portainer.TagID - EdgeCheckinInterval int + Name string + URL string + EndpointType int + PublicURL string + GroupID int + TLS bool + TLSSkipVerify bool + TLSSkipClientVerify bool + TLSCACertFile []byte + TLSCertFile []byte + TLSKeyFile []byte + AzureApplicationID string + AzureTenantID string + AzureAuthenticationKey string + TagIDs []portainer.TagID + EdgeCheckinInterval int } func (payload *endpointCreatePayload) Validate(r *http.Request) error { @@ -42,7 +45,7 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error { endpointType, err := request.RetrieveNumericMultiPartFormValue(r, "EndpointType", false) if err != nil || endpointType == 0 { - return portainer.Error("Invalid endpoint type value. Value must be one of: 1 (Docker environment), 2 (Agent environment) or 4 (Edge Agent environment)") + return portainer.Error("Invalid endpoint type value. Value must be one of: 1 (Docker environment), 2 (Agent environment), 3 (Azure environment) or 4 (Edge Agent environment)") } payload.EndpointType = endpointType @@ -94,14 +97,35 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error { } } - endpointURL, err := request.RetrieveMultiPartFormValue(r, "URL", true) - if err != nil { - return portainer.Error("Invalid endpoint URL") - } - payload.URL = endpointURL + switch portainer.EndpointType(payload.EndpointType) { + case portainer.AzureEnvironment: + azureApplicationID, err := request.RetrieveMultiPartFormValue(r, "AzureApplicationID", false) + if err != nil { + return portainer.Error("Invalid Azure application ID") + } + payload.AzureApplicationID = azureApplicationID - publicURL, _ := request.RetrieveMultiPartFormValue(r, "PublicURL", true) - payload.PublicURL = publicURL + azureTenantID, err := request.RetrieveMultiPartFormValue(r, "AzureTenantID", false) + if err != nil { + return portainer.Error("Invalid Azure tenant ID") + } + payload.AzureTenantID = azureTenantID + + azureAuthenticationKey, err := request.RetrieveMultiPartFormValue(r, "AzureAuthenticationKey", false) + if err != nil { + return portainer.Error("Invalid Azure authentication key") + } + payload.AzureAuthenticationKey = azureAuthenticationKey + default: + url, err := request.RetrieveMultiPartFormValue(r, "URL", true) + if err != nil { + return portainer.Error("Invalid endpoint URL") + } + payload.URL = url + + publicURL, _ := request.RetrieveMultiPartFormValue(r, "PublicURL", true) + payload.PublicURL = publicURL + } checkinInterval, _ := request.RetrieveNumericMultiPartFormValue(r, "CheckinInterval", true) payload.EdgeCheckinInterval = checkinInterval @@ -158,7 +182,9 @@ func (handler *Handler) endpointCreate(w http.ResponseWriter, r *http.Request) * } func (handler *Handler) createEndpoint(payload *endpointCreatePayload) (*portainer.Endpoint, *httperror.HandlerError) { - if portainer.EndpointType(payload.EndpointType) == portainer.EdgeAgentEnvironment { + if portainer.EndpointType(payload.EndpointType) == portainer.AzureEnvironment { + return handler.createAzureEndpoint(payload) + } else if portainer.EndpointType(payload.EndpointType) == portainer.EdgeAgentEnvironment { return handler.createEdgeAgentEndpoint(payload) } @@ -168,6 +194,44 @@ func (handler *Handler) createEndpoint(payload *endpointCreatePayload) (*portain return handler.createUnsecuredEndpoint(payload) } +func (handler *Handler) createAzureEndpoint(payload *endpointCreatePayload) (*portainer.Endpoint, *httperror.HandlerError) { + credentials := portainer.AzureCredentials{ + ApplicationID: payload.AzureApplicationID, + TenantID: payload.AzureTenantID, + AuthenticationKey: payload.AzureAuthenticationKey, + } + + httpClient := client.NewHTTPClient() + _, err := httpClient.ExecuteAzureAuthenticationRequest(&credentials) + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to authenticate against Azure", err} + } + + endpointID := handler.DataStore.Endpoint().GetNextIdentifier() + endpoint := &portainer.Endpoint{ + ID: portainer.EndpointID(endpointID), + Name: payload.Name, + URL: "https://management.azure.com", + Type: portainer.AzureEnvironment, + GroupID: portainer.EndpointGroupID(payload.GroupID), + PublicURL: payload.PublicURL, + UserAccessPolicies: portainer.UserAccessPolicies{}, + TeamAccessPolicies: portainer.TeamAccessPolicies{}, + Extensions: []portainer.EndpointExtension{}, + AzureCredentials: credentials, + TagIDs: payload.TagIDs, + Status: portainer.EndpointStatusUp, + Snapshots: []portainer.Snapshot{}, + } + + err = handler.saveEndpointAndUpdateAuthorizations(endpoint) + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "An error occured while trying to create the endpoint", err} + } + + return endpoint, nil +} + func (handler *Handler) createEdgeAgentEndpoint(payload *endpointCreatePayload) (*portainer.Endpoint, *httperror.HandlerError) { endpointType := portainer.EdgeAgentEnvironment endpointID := handler.DataStore.Endpoint().GetNextIdentifier() diff --git a/api/http/handler/endpoints/endpoint_snapshot.go b/api/http/handler/endpoints/endpoint_snapshot.go index 21d6b8b0a..18182db17 100644 --- a/api/http/handler/endpoints/endpoint_snapshot.go +++ b/api/http/handler/endpoints/endpoint_snapshot.go @@ -23,6 +23,10 @@ func (handler *Handler) endpointSnapshot(w http.ResponseWriter, r *http.Request) return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} } + if endpoint.Type == portainer.AzureEnvironment { + return &httperror.HandlerError{http.StatusBadRequest, "Snapshots not supported for Azure endpoints", err} + } + snapshot, snapshotError := handler.Snapshotter.CreateSnapshot(endpoint) latestEndpointReference, err := handler.DataStore.Endpoint().Endpoint(endpoint.ID) diff --git a/api/http/handler/endpoints/endpoint_snapshots.go b/api/http/handler/endpoints/endpoint_snapshots.go index 092dc5df1..33d6f30d0 100644 --- a/api/http/handler/endpoints/endpoint_snapshots.go +++ b/api/http/handler/endpoints/endpoint_snapshots.go @@ -17,6 +17,10 @@ func (handler *Handler) endpointSnapshots(w http.ResponseWriter, r *http.Request } for _, endpoint := range endpoints { + if endpoint.Type == portainer.AzureEnvironment { + continue + } + snapshot, snapshotError := handler.Snapshotter.CreateSnapshot(&endpoint) latestEndpointReference, err := handler.DataStore.Endpoint().Endpoint(endpoint.ID) diff --git a/api/http/handler/endpoints/endpoint_update.go b/api/http/handler/endpoints/endpoint_update.go index 4752086c3..766c09207 100644 --- a/api/http/handler/endpoints/endpoint_update.go +++ b/api/http/handler/endpoints/endpoint_update.go @@ -9,21 +9,25 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/client" ) type endpointUpdatePayload struct { - Name *string - URL *string - PublicURL *string - GroupID *int - TLS *bool - TLSSkipVerify *bool - TLSSkipClientVerify *bool - Status *int - TagIDs []portainer.TagID - UserAccessPolicies portainer.UserAccessPolicies - TeamAccessPolicies portainer.TeamAccessPolicies - EdgeCheckinInterval *int + Name *string + URL *string + PublicURL *string + GroupID *int + TLS *bool + TLSSkipVerify *bool + TLSSkipClientVerify *bool + Status *int + AzureApplicationID *string + AzureTenantID *string + AzureAuthenticationKey *string + TagIDs []portainer.TagID + UserAccessPolicies portainer.UserAccessPolicies + TeamAccessPolicies portainer.TeamAccessPolicies + EdgeCheckinInterval *int } func (payload *endpointUpdatePayload) Validate(r *http.Request) error { @@ -138,6 +142,26 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) * } } + if endpoint.Type == portainer.AzureEnvironment { + credentials := endpoint.AzureCredentials + if payload.AzureApplicationID != nil { + credentials.ApplicationID = *payload.AzureApplicationID + } + if payload.AzureTenantID != nil { + credentials.TenantID = *payload.AzureTenantID + } + if payload.AzureAuthenticationKey != nil { + credentials.AuthenticationKey = *payload.AzureAuthenticationKey + } + + httpClient := client.NewHTTPClient() + _, authErr := httpClient.ExecuteAzureAuthenticationRequest(&credentials) + if authErr != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to authenticate against Azure", authErr} + } + endpoint.AzureCredentials = credentials + } + if payload.TLS != nil { folder := strconv.Itoa(endpointID) @@ -182,7 +206,7 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) * } } - if payload.URL != nil || payload.TLS != nil { + if payload.URL != nil || payload.TLS != nil || endpoint.Type == portainer.AzureEnvironment { _, err = handler.ProxyManager.CreateAndRegisterEndpointProxy(endpoint) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to register HTTP proxy for the endpoint", err} diff --git a/api/http/handler/endpoints/handler.go b/api/http/handler/endpoints/handler.go index 15e8a55c0..0fb6e5b06 100644 --- a/api/http/handler/endpoints/handler.go +++ b/api/http/handler/endpoints/handler.go @@ -12,6 +12,7 @@ import ( ) func hideFields(endpoint *portainer.Endpoint) { + endpoint.AzureCredentials = portainer.AzureCredentials{} if len(endpoint.Snapshots) > 0 { endpoint.Snapshots[0].SnapshotRaw = portainer.SnapshotRaw{} } diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index ad0fa92df..8b167b12e 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -90,6 +90,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.StripPrefix("/api/endpoints", h.EndpointProxyHandler).ServeHTTP(w, r) case strings.Contains(r.URL.Path, "/storidge/"): http.StripPrefix("/api/endpoints", h.EndpointProxyHandler).ServeHTTP(w, r) + case strings.Contains(r.URL.Path, "/azure/"): + http.StripPrefix("/api/endpoints", h.EndpointProxyHandler).ServeHTTP(w, r) case strings.Contains(r.URL.Path, "/edge/"): http.StripPrefix("/api/endpoints", h.EndpointEdgeHandler).ServeHTTP(w, r) default: diff --git a/api/http/proxy/factory/azure.go b/api/http/proxy/factory/azure.go new file mode 100644 index 000000000..27b8a26f8 --- /dev/null +++ b/api/http/proxy/factory/azure.go @@ -0,0 +1,20 @@ +package factory + +import ( + "net/http" + "net/url" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/proxy/factory/azure" +) + +func newAzureProxy(endpoint *portainer.Endpoint) (http.Handler, error) { + remoteURL, err := url.Parse(azureAPIBaseURL) + if err != nil { + return nil, err + } + + proxy := newSingleHostReverseProxyWithHostHeader(remoteURL) + proxy.Transport = azure.NewTransport(&endpoint.AzureCredentials) + return proxy, nil +} diff --git a/api/http/proxy/factory/azure/transport.go b/api/http/proxy/factory/azure/transport.go new file mode 100644 index 000000000..0c8505c8b --- /dev/null +++ b/api/http/proxy/factory/azure/transport.go @@ -0,0 +1,80 @@ +package azure + +import ( + "net/http" + "strconv" + "sync" + "time" + + "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/client" +) + +type ( + azureAPIToken struct { + value string + expirationTime time.Time + } + + Transport struct { + credentials *portainer.AzureCredentials + client *client.HTTPClient + token *azureAPIToken + mutex sync.Mutex + } +) + +// NewTransport returns a pointer to a new instance of Transport that implements the HTTP Transport +// interface for proxying requests to the Azure API. +func NewTransport(credentials *portainer.AzureCredentials) *Transport { + return &Transport{ + credentials: credentials, + client: client.NewHTTPClient(), + } +} + +// RoundTrip is the implementation of the the http.RoundTripper interface +func (transport *Transport) RoundTrip(request *http.Request) (*http.Response, error) { + err := transport.retrieveAuthenticationToken() + if err != nil { + return nil, err + } + + request.Header.Set("Authorization", "Bearer "+transport.token.value) + return http.DefaultTransport.RoundTrip(request) +} + +func (transport *Transport) authenticate() error { + token, err := transport.client.ExecuteAzureAuthenticationRequest(transport.credentials) + if err != nil { + return err + } + + expiresOn, err := strconv.ParseInt(token.ExpiresOn, 10, 64) + if err != nil { + return err + } + + transport.token = &azureAPIToken{ + value: token.AccessToken, + expirationTime: time.Unix(expiresOn, 0), + } + + return nil +} + +func (transport *Transport) retrieveAuthenticationToken() error { + transport.mutex.Lock() + defer transport.mutex.Unlock() + + if transport.token == nil { + return transport.authenticate() + } + + timeLimit := time.Now().Add(-5 * time.Minute) + if timeLimit.After(transport.token.expirationTime) { + return transport.authenticate() + } + + return nil +} diff --git a/api/http/proxy/factory/factory.go b/api/http/proxy/factory/factory.go index b81291ab9..6ebedbb9f 100644 --- a/api/http/proxy/factory/factory.go +++ b/api/http/proxy/factory/factory.go @@ -10,6 +10,8 @@ import ( "github.com/portainer/portainer/api/docker" ) +const azureAPIBaseURL = "https://management.azure.com" + var extensionPorts = map[portainer.ExtensionID]string{ portainer.RegistryManagementExtension: "7001", portainer.OAuthAuthenticationExtension: "7002", @@ -69,6 +71,11 @@ func (factory *ProxyFactory) NewLegacyExtensionProxy(extensionAPIURL string) (ht // NewEndpointProxy returns a new reverse proxy (filesystem based or HTTP) to an endpoint API server func (factory *ProxyFactory) NewEndpointProxy(endpoint *portainer.Endpoint) (http.Handler, error) { + switch endpoint.Type { + case portainer.AzureEnvironment: + return newAzureProxy(endpoint) + } + return factory.newDockerProxy(endpoint) } diff --git a/api/portainer.go b/api/portainer.go index 8386ce8f2..a2ec3efd6 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -27,7 +27,7 @@ type ( Authorizations map[Authorization]bool // AzureCredentials represents the credentials used to connect to an Azure - // environment (deprecated). + // environment. AzureCredentials struct { ApplicationID string `json:"ApplicationID"` TenantID string `json:"TenantID"` @@ -163,6 +163,7 @@ type ( PublicURL string `json:"PublicURL"` TLSConfig TLSConfiguration `json:"TLSConfig"` Extensions []EndpointExtension `json:"Extensions"` + AzureCredentials AzureCredentials `json:"AzureCredentials,omitempty"` TagIDs []TagID `json:"TagIds"` Status EndpointStatus `json:"Status"` Snapshots []Snapshot `json:"Snapshots"` @@ -185,9 +186,6 @@ type ( // Deprecated in DBVersion == 22 Tags []string `json:"Tags"` - - // Deprecated in DBVersion == 24 - AzureCredentials AzureCredentials `json:"AzureCredentials,omitempty"` } // EndpointAuthorizations represents the authorizations associated to a set of endpoints @@ -1101,7 +1099,7 @@ const ( DockerEnvironment // AgentOnDockerEnvironment represents an endpoint connected to a Portainer agent deployed on a Docker environment AgentOnDockerEnvironment - // AzureEnvironment represents an endpoint connected to an Azure environment (deprecated) + // AzureEnvironment represents an endpoint connected to an Azure environment AzureEnvironment // EdgeAgentEnvironment represents an endpoint connected to an Edge agent EdgeAgentEnvironment diff --git a/api/swagger.yaml b/api/swagger.yaml index abb4aa686..2ecd61139 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -254,7 +254,7 @@ paths: - name: "EndpointType" in: "formData" type: "integer" - description: "Environment type. Value must be one of: 1 (Docker environment), 2 (Agent environment) or 4 (Edge agent environment)" + description: "Environment type. Value must be one of: 1 (Docker environment), 2 (Agent environment), 3 (Azure environment) or 4 (Edge agent environment)" required: true - name: "URL" in: "formData" @@ -294,6 +294,18 @@ paths: in: "formData" type: "file" description: "TLS client key file" + - name: "AzureApplicationID" + in: "formData" + type: "string" + description: "Azure application ID. Required if endpoint type is set to 3" + - name: "AzureTenantID" + in: "formData" + type: "string" + description: "Azure tenant ID. Required if endpoint type is set to 3" + - name: "AzureAuthenticationKey" + in: "formData" + type: "string" + description: "Azure authentication key. Required if endpoint type is set to 3" responses: 200: description: "Success" @@ -3209,6 +3221,21 @@ definitions: type: "string" example: "/data/tls/key.pem" description: "Path to the TLS client key file" + AzureCredentials: + type: "object" + properties: + ApplicationID: + type: "string" + example: "eag7cdo9-o09l-9i83-9dO9-f0b23oe78db4" + description: "Azure application ID" + TenantID: + type: "string" + example: "34ddc78d-4fel-2358-8cc1-df84c8o839f5" + description: "Azure tenant ID" + AuthenticationKey: + type: "string" + example: "cOrXoK/1D35w8YQ8nH1/8ZGwzz45JIYD5jxHKXEQknk=" + description: "Azure authentication key" LDAPSearchSettings: type: "object" properties: @@ -3480,7 +3507,7 @@ definitions: Type: type: "integer" example: 1 - description: "Endpoint environment type. 1 for a Docker environment or 2 for an agent on Docker environment" + description: "Endpoint environment type. 1 for a Docker environment, 2 for an agent on Docker environment or 3 for an Azure environment." URL: type: "string" example: "docker.mydomain.tld:2375" @@ -3509,6 +3536,8 @@ definitions: description: "Team identifier" TLSConfig: $ref: "#/definitions/TLSConfiguration" + AzureCredentials: + $ref: "#/definitions/AzureCredentials" EndpointSubset: type: "object" properties: @@ -3523,7 +3552,7 @@ definitions: Type: type: "integer" example: 1 - description: "Endpoint environment type. 1 for a Docker environment or 2 for an agent on Docker environment" + description: "Endpoint environment type. 1 for a Docker environment, 2 for an agent on Docker environment, 3 for an Azure environment." URL: type: "string" example: "docker.mydomain.tld:2375" @@ -3703,6 +3732,18 @@ definitions: type: "boolean" example: false description: "Skip client verification when using TLS" + ApplicationID: + type: "string" + example: "eag7cdo9-o09l-9i83-9dO9-f0b23oe78db4" + description: "Azure application ID" + TenantID: + type: "string" + example: "34ddc78d-4fel-2358-8cc1-df84c8o839f5" + description: "Azure tenant ID" + AuthenticationKey: + type: "string" + example: "cOrXoK/1D35w8YQ8nH1/8ZGwzz45JIYD5jxHKXEQknk=" + description: "Azure authentication key" UserAccessPolicies: $ref: "#/definitions/UserAccessPolicies" TeamAccessPolicies: diff --git a/app/__module.js b/app/__module.js index 8e878ded2..0aed3bfa1 100644 --- a/app/__module.js +++ b/app/__module.js @@ -2,6 +2,7 @@ import './assets/css'; import angular from 'angular'; import './agent/_module'; +import './azure/_module'; import './docker/__module'; import './edge/__module'; import './portainer/__module'; @@ -27,6 +28,7 @@ angular.module('portainer', [ 'luegg.directives', 'portainer.app', 'portainer.agent', + 'portainer.azure', 'portainer.docker', 'portainer.edge', 'portainer.extensions', diff --git a/app/assets/css/app.css b/app/assets/css/app.css index f1b18aa53..f57e3802f 100644 --- a/app/assets/css/app.css +++ b/app/assets/css/app.css @@ -236,6 +236,10 @@ a[ng-click] { margin: 10px 4px 0 6px; } +.blocklist-item-logo.endpoint-item.azure { + margin: 0 0 0 10px; +} + .blocklist-item-title { font-size: 1.8em; font-weight: bold; diff --git a/app/azure/_module.js b/app/azure/_module.js new file mode 100644 index 000000000..a11a5aa5e --- /dev/null +++ b/app/azure/_module.js @@ -0,0 +1,51 @@ +angular.module('portainer.azure', ['portainer.app']).config([ + '$stateRegistryProvider', + function ($stateRegistryProvider) { + 'use strict'; + + var azure = { + name: 'azure', + url: '/azure', + parent: 'root', + abstract: true, + }; + + var containerInstances = { + name: 'azure.containerinstances', + url: '/containerinstances', + views: { + 'content@': { + templateUrl: './views/containerinstances/containerinstances.html', + controller: 'AzureContainerInstancesController', + }, + }, + }; + + var containerInstanceCreation = { + name: 'azure.containerinstances.new', + url: '/new/', + views: { + 'content@': { + templateUrl: './views/containerinstances/create/createcontainerinstance.html', + controller: 'AzureCreateContainerInstanceController', + }, + }, + }; + + var dashboard = { + name: 'azure.dashboard', + url: '/dashboard', + views: { + 'content@': { + templateUrl: './views/dashboard/dashboard.html', + controller: 'AzureDashboardController', + }, + }, + }; + + $stateRegistryProvider.register(azure); + $stateRegistryProvider.register(containerInstances); + $stateRegistryProvider.register(containerInstanceCreation); + $stateRegistryProvider.register(dashboard); + }, +]); diff --git a/app/azure/components/azure-endpoint-config/azure-endpoint-config.js b/app/azure/components/azure-endpoint-config/azure-endpoint-config.js new file mode 100644 index 000000000..ff09f0908 --- /dev/null +++ b/app/azure/components/azure-endpoint-config/azure-endpoint-config.js @@ -0,0 +1,8 @@ +angular.module('portainer.azure').component('azureEndpointConfig', { + bindings: { + applicationId: '=', + tenantId: '=', + authenticationKey: '=', + }, + templateUrl: './azureEndpointConfig.html', +}); diff --git a/app/azure/components/azure-endpoint-config/azureEndpointConfig.html b/app/azure/components/azure-endpoint-config/azureEndpointConfig.html new file mode 100644 index 000000000..efc8bd79f --- /dev/null +++ b/app/azure/components/azure-endpoint-config/azureEndpointConfig.html @@ -0,0 +1,36 @@ +
| + + + + + + Name + + + + | ++ + Location + + + + | ++ Published Ports + | +
|---|---|---|
| + + + + + {{ item.Name | truncate: 50 }} + | +{{ item.Location }} | ++ + :{{ p.port }} + + - + | +
| Loading... | +||
| No container available. | +||
diff --git a/app/portainer/filters/filters.js b/app/portainer/filters/filters.js
index e4c232ee5..034dba32c 100644
--- a/app/portainer/filters/filters.js
+++ b/app/portainer/filters/filters.js
@@ -128,6 +128,8 @@ angular
return 'Docker';
} else if (type === 2) {
return 'Agent';
+ } else if (type === 3) {
+ return 'Azure ACI';
} else if (type === 4) {
return 'Edge Agent';
}
diff --git a/app/portainer/services/api/endpointService.js b/app/portainer/services/api/endpointService.js
index 8d7cddd3f..511983ade 100644
--- a/app/portainer/services/api/endpointService.js
+++ b/app/portainer/services/api/endpointService.js
@@ -112,6 +112,20 @@ angular.module('portainer.app').factory('EndpointService', [
return deferred.promise;
};
+ service.createAzureEndpoint = function (name, applicationId, tenantId, authenticationKey, groupId, tagIds) {
+ var deferred = $q.defer();
+
+ FileUploadService.createAzureEndpoint(name, applicationId, tenantId, authenticationKey, groupId, tagIds)
+ .then(function success(response) {
+ deferred.resolve(response.data);
+ })
+ .catch(function error(err) {
+ deferred.reject({ msg: 'Unable to connect to Azure', err: err });
+ });
+
+ return deferred.promise;
+ };
+
service.executeJobFromFileUpload = function (image, jobFile, endpointId, nodeName) {
return FileUploadService.executeEndpointJob(image, jobFile, endpointId, nodeName);
};
diff --git a/app/portainer/services/fileUpload.js b/app/portainer/services/fileUpload.js
index 6477d75ba..5b2e7d67f 100644
--- a/app/portainer/services/fileUpload.js
+++ b/app/portainer/services/fileUpload.js
@@ -137,6 +137,22 @@ angular.module('portainer.app').factory('FileUploadService', [
});
};
+ service.createAzureEndpoint = function (name, applicationId, tenantId, authenticationKey, groupId, tagIds) {
+ return Upload.upload({
+ url: 'api/endpoints',
+ data: {
+ Name: name,
+ EndpointType: 3,
+ GroupID: groupId,
+ TagIds: Upload.json(tagIds),
+ AzureApplicationID: applicationId,
+ AzureTenantID: tenantId,
+ AzureAuthenticationKey: authenticationKey,
+ },
+ ignoreLoadingBar: true,
+ });
+ };
+
service.uploadLDAPTLSFiles = function (TLSCAFile, TLSCertFile, TLSKeyFile) {
var queue = [];
diff --git a/app/portainer/services/stateManager.js b/app/portainer/services/stateManager.js
index 819d8c570..fa2b1c72b 100644
--- a/app/portainer/services/stateManager.js
+++ b/app/portainer/services/stateManager.js
@@ -168,6 +168,14 @@ angular.module('portainer.app').factory('StateManager', [
manager.updateEndpointState = function (endpoint, extensions) {
var deferred = $q.defer();
+ if (endpoint.Type === 3) {
+ state.endpoint.name = endpoint.Name;
+ state.endpoint.mode = { provider: 'AZURE' };
+ LocalStorage.storeEndpointState(state.endpoint);
+ deferred.resolve();
+ return deferred.promise;
+ }
+
$q.all({
version: endpoint.Status === 1 ? SystemService.version() : $q.when(endpoint.Snapshots[0].SnapshotRaw.Version),
info: endpoint.Status === 1 ? SystemService.info() : $q.when(endpoint.Snapshots[0].SnapshotRaw.Info),
diff --git a/app/portainer/views/endpoints/create/createEndpointController.js b/app/portainer/views/endpoints/create/createEndpointController.js
index bea559dd7..ce5d31d60 100644
--- a/app/portainer/views/endpoints/create/createEndpointController.js
+++ b/app/portainer/views/endpoints/create/createEndpointController.js
@@ -46,6 +46,9 @@ angular
PublicURL: '',
GroupId: 1,
SecurityFormData: new EndpointSecurityFormData(),
+ AzureApplicationId: '',
+ AzureTenantId: '',
+ AzureAuthenticationKey: '',
TagIds: [],
CheckinInterval: $scope.state.availableEdgeAgentCheckinOptions[0].value,
};
@@ -102,6 +105,17 @@ angular
addEndpoint(name, 4, URL, '', groupId, tagIds, false, false, false, null, null, null, $scope.formValues.CheckinInterval);
};
+ $scope.addAzureEndpoint = function () {
+ var name = $scope.formValues.Name;
+ var applicationId = $scope.formValues.AzureApplicationId;
+ var tenantId = $scope.formValues.AzureTenantId;
+ var authenticationKey = $scope.formValues.AzureAuthenticationKey;
+ var groupId = $scope.formValues.GroupId;
+ var tagIds = $scope.formValues.TagIds;
+
+ createAzureEndpoint(name, applicationId, tenantId, authenticationKey, groupId, tagIds);
+ };
+
$scope.onCreateTag = function onCreateTag(tagName) {
return $async(onCreateTagAsync, tagName);
};
@@ -116,6 +130,21 @@ angular
}
}
+ function createAzureEndpoint(name, applicationId, tenantId, authenticationKey, groupId, tagIds) {
+ $scope.state.actionInProgress = true;
+ EndpointService.createAzureEndpoint(name, applicationId, tenantId, authenticationKey, groupId, tagIds)
+ .then(function success() {
+ Notifications.success('Endpoint created', name);
+ $state.go('portainer.endpoints', {}, { reload: true });
+ })
+ .catch(function error(err) {
+ Notifications.error('Failure', err, 'Unable to create endpoint');
+ })
+ .finally(function final() {
+ $scope.state.actionInProgress = false;
+ });
+ }
+
function addEndpoint(name, type, URL, PublicURL, groupId, tagIds, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile, CheckinInterval) {
$scope.state.actionInProgress = true;
EndpointService.createRemoteEndpoint(
diff --git a/app/portainer/views/endpoints/create/createendpoint.html b/app/portainer/views/endpoints/create/createendpoint.html
index 4ab68d840..dd01e6142 100644
--- a/app/portainer/views/endpoints/create/createendpoint.html
+++ b/app/portainer/views/endpoints/create/createendpoint.html
@@ -44,6 +44,16 @@
Directly connect to the Docker API
This feature is experimental.
++ Connect to Microsoft Azure to manage Azure Container Instances (ACI). +
++ + Have a look at + the Azure documentation + to retrieve the credentials required below. +
+ +This field is required.
+This field is required.
+This field is required.
+Connect to a Portainer agent
This feature is experimental.
++ Connect to Microsoft Azure to manage Azure Container Instances (ACI). +
++ + Have a look at + the Azure documentation + to retrieve the credentials required below. +
+ +