507 lines
16 KiB
Go
507 lines
16 KiB
Go
/*
|
|
Copyright 2019 NetFoundry Inc.
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
https://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
package ziti
|
|
|
|
import (
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/sha1"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"github.com/go-openapi/strfmt"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/google/uuid"
|
|
"github.com/michaelquigley/pfxlog"
|
|
"github.com/openziti/edge-api/rest_client_api_client/authentication"
|
|
"github.com/openziti/edge-api/rest_client_api_client/current_api_session"
|
|
"github.com/openziti/edge-api/rest_client_api_client/current_identity"
|
|
"github.com/openziti/edge-api/rest_client_api_client/informational"
|
|
"github.com/openziti/edge-api/rest_client_api_client/posture_checks"
|
|
"github.com/openziti/edge-api/rest_client_api_client/service"
|
|
"github.com/openziti/edge-api/rest_client_api_client/session"
|
|
"github.com/openziti/edge-api/rest_model"
|
|
"github.com/openziti/edge-api/rest_util"
|
|
"github.com/openziti/foundation/v2/genext"
|
|
nfPem "github.com/openziti/foundation/v2/pem"
|
|
"github.com/openziti/foundation/v2/versions"
|
|
"github.com/openziti/identity"
|
|
apis "github.com/openziti/sdk-golang/edge-apis"
|
|
"github.com/openziti/sdk-golang/ziti/edge/posture"
|
|
"github.com/openziti/transport/v2"
|
|
"github.com/pkg/errors"
|
|
"strings"
|
|
"sync/atomic"
|
|
)
|
|
|
|
// CtrlClient is a stateful version of ZitiEdgeClient that simplifies operations
|
|
type CtrlClient struct {
|
|
*apis.ClientApiClient
|
|
Credentials apis.Credentials
|
|
|
|
lastServiceUpdate *strfmt.DateTime
|
|
|
|
ApiSessionCertificateDetail rest_model.CurrentAPISessionCertificateDetail
|
|
ApiSessionCsr x509.CertificateRequest
|
|
ApiSessionCertificate *x509.Certificate
|
|
ApiSessionPrivateKey *ecdsa.PrivateKey
|
|
ApiSessionCertInstance string
|
|
|
|
PostureCache *posture.Cache
|
|
ConfigTypes []string
|
|
supportsConfigTypesOnServiceList atomic.Bool
|
|
capabilitiesLoaded atomic.Bool
|
|
}
|
|
|
|
// GetCurrentApiSession returns the current cached ApiSession or nil
|
|
func (self *CtrlClient) GetCurrentApiSession() apis.ApiSession {
|
|
return self.ClientApiClient.GetCurrentApiSession()
|
|
}
|
|
|
|
// Refresh will contact the controller extending the current ApiSession for legacy API Sessions
|
|
func (self *CtrlClient) Refresh() (apis.ApiSession, error) {
|
|
if apiSession := self.GetCurrentApiSession(); apiSession != nil {
|
|
newApiSession, err := self.API.RefreshApiSession(apiSession, self.HttpClient)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
self.ApiSession.Store(&newApiSession)
|
|
|
|
return newApiSession, nil
|
|
}
|
|
|
|
return nil, errors.New("no api session")
|
|
}
|
|
|
|
// IsServiceListUpdateAvailable will contact the controller to determine if a new set of services are available. Service
|
|
// updates could entail gaining/losing services access via policy or runtime authorization revocation due to posture
|
|
// checks.
|
|
func (self *CtrlClient) IsServiceListUpdateAvailable() (bool, *strfmt.DateTime, error) {
|
|
resp, err := self.API.CurrentAPISession.ListServiceUpdates(current_api_session.NewListServiceUpdatesParams(), self.GetCurrentApiSession())
|
|
|
|
if err != nil {
|
|
return true, nil, err
|
|
}
|
|
|
|
return self.lastServiceUpdate == nil || !resp.Payload.Data.LastChangeAt.Equal(*self.lastServiceUpdate), resp.Payload.Data.LastChangeAt, nil
|
|
}
|
|
|
|
// Authenticate attempts to use authenticate, overwriting any existing ApiSession.
|
|
func (self *CtrlClient) Authenticate() (apis.ApiSession, error) {
|
|
var err error
|
|
|
|
self.ApiSessionCertificate = nil
|
|
|
|
apiSession, err := self.ClientApiClient.Authenticate(self.Credentials, self.ConfigTypes)
|
|
|
|
if err != nil {
|
|
return nil, rest_util.WrapErr(err)
|
|
}
|
|
|
|
_, err = self.GetIdentity()
|
|
if err != nil {
|
|
return nil, rest_util.WrapErr(err)
|
|
}
|
|
|
|
return apiSession, nil
|
|
}
|
|
|
|
// AuthenticateMFA handles MFA authentication queries may be provided. AuthenticateMFA allows
|
|
// the current identity for their current api session to attempt to pass MFA authentication.
|
|
func (self *CtrlClient) AuthenticateMFA(code string) error {
|
|
params := authentication.NewAuthenticateMfaParams()
|
|
params.MfaAuth = &rest_model.MfaCode{
|
|
Code: &code,
|
|
}
|
|
_, err := self.API.Authentication.AuthenticateMfa(params, self.GetCurrentApiSession())
|
|
|
|
if err != nil {
|
|
return rest_util.WrapErr(err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SendPostureResponse creates a posture response (some state data the controller has requested) for services. This
|
|
// information is used to determine runtime authorization access to services via posture checks.
|
|
func (self *CtrlClient) SendPostureResponse(response rest_model.PostureResponseCreate) error {
|
|
params := posture_checks.NewCreatePostureResponseParams()
|
|
params.PostureResponse = response
|
|
_, err := self.API.PostureChecks.CreatePostureResponse(params, self.GetCurrentApiSession())
|
|
|
|
if err != nil {
|
|
return rest_util.WrapErr(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SendPostureResponseBulk provides the same functionality as SendPostureResponse but allows multiple responses
|
|
// to be sent in a single request.
|
|
func (self *CtrlClient) SendPostureResponseBulk(responses []rest_model.PostureResponseCreate) error {
|
|
params := posture_checks.NewCreatePostureResponseBulkParams()
|
|
params.PostureResponse = responses
|
|
_, err := self.API.PostureChecks.CreatePostureResponseBulk(params, self.GetCurrentApiSession())
|
|
|
|
if err != nil {
|
|
return rest_util.WrapErr(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetCurrentIdentity returns the rest_model.IdentityDetail for the currently authenticated ApiSession.
|
|
func (self *CtrlClient) GetCurrentIdentity() (*rest_model.IdentityDetail, error) {
|
|
params := current_identity.NewGetCurrentIdentityParams()
|
|
resp, err := self.API.CurrentIdentity.GetCurrentIdentity(params, self.GetCurrentApiSession())
|
|
|
|
if err != nil {
|
|
return nil, rest_util.WrapErr(err)
|
|
}
|
|
|
|
return resp.Payload.Data, nil
|
|
}
|
|
|
|
// GetSession returns the full rest_model.SessionDetail for a specific id. Does not function with JWT backed sessions.
|
|
func (self *CtrlClient) GetSession(id string) (*rest_model.SessionDetail, error) {
|
|
params := session.NewDetailSessionParams()
|
|
params.ID = id
|
|
resp, err := self.API.Session.DetailSession(params, self.GetCurrentApiSession())
|
|
|
|
if err != nil {
|
|
return nil, rest_util.WrapErr(err)
|
|
}
|
|
|
|
self.sanitizeSessionUrls(resp.Payload.Data)
|
|
return resp.Payload.Data, nil
|
|
}
|
|
|
|
func (self *CtrlClient) GetSessionFromJwt(sessionToken string) (*rest_model.SessionDetail, error) {
|
|
parser := jwt.NewParser()
|
|
serviceAccessClaims := &apis.ServiceAccessClaims{}
|
|
|
|
_, _, err := parser.ParseUnverified(sessionToken, serviceAccessClaims)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
params := service.NewListServiceEdgeRoutersParams()
|
|
params.SessionToken = &sessionToken
|
|
params.ID = serviceAccessClaims.Subject //service id
|
|
|
|
resp, err := self.API.Service.ListServiceEdgeRouters(params, self.GetCurrentApiSession())
|
|
|
|
if err != nil {
|
|
return nil, rest_util.WrapErr(err)
|
|
}
|
|
createdAt := strfmt.DateTime(serviceAccessClaims.IssuedAt.Time)
|
|
sessionType := rest_model.DialBind(serviceAccessClaims.Type)
|
|
|
|
sessionDetail := &rest_model.SessionDetail{
|
|
BaseEntity: rest_model.BaseEntity{
|
|
Links: nil,
|
|
CreatedAt: &createdAt,
|
|
ID: &serviceAccessClaims.ID,
|
|
},
|
|
APISessionID: &serviceAccessClaims.ApiSessionId,
|
|
IdentityID: &serviceAccessClaims.IdentityId,
|
|
ServiceID: &serviceAccessClaims.Subject,
|
|
Token: &sessionToken,
|
|
Type: &sessionType,
|
|
}
|
|
|
|
for _, er := range resp.Payload.Data.EdgeRouters {
|
|
sessionDetail.EdgeRouters = append(sessionDetail.EdgeRouters, &rest_model.SessionEdgeRouter{
|
|
CommonEdgeRouterProperties: *er,
|
|
})
|
|
}
|
|
|
|
self.sanitizeSessionUrls(sessionDetail)
|
|
|
|
return sessionDetail, nil
|
|
}
|
|
|
|
// GetIdentity returns the identity.Identity used to facilitate authentication. Each identity.Identity instance
|
|
// may provide authentication material in the form of x509 certificates and private keys and/or trusted CA pools.
|
|
func (self *CtrlClient) GetIdentity() (identity.Identity, error) {
|
|
if idProvider, ok := self.Credentials.(apis.IdentityProvider); ok {
|
|
return idProvider.GetIdentity(), nil
|
|
}
|
|
|
|
if self.ApiSessionCertificate == nil {
|
|
err := self.EnsureApiSessionCertificate()
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not ensure an API Session certificate is available: %v", err)
|
|
}
|
|
}
|
|
|
|
return identity.NewClientTokenIdentityWithPool([]*x509.Certificate{self.ApiSessionCertificate}, self.ApiSessionPrivateKey, self.HttpTransport.TLSClientConfig.RootCAs), nil
|
|
}
|
|
|
|
// EnsureApiSessionCertificate will create an ApiSessionCertificate if one does not already exist.
|
|
func (self *CtrlClient) EnsureApiSessionCertificate() error {
|
|
if self.ApiSessionCertificate == nil {
|
|
return self.NewApiSessionCertificate()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// NewApiSessionCertificate will create a new ephemeral private key used to generate an ephemeral certificate
|
|
// that may be used with the current ApiSession. The generated certificate and private key are scoped to the
|
|
// ApiSession used to create it.
|
|
func (self *CtrlClient) NewApiSessionCertificate() error {
|
|
if self.ApiSessionCertInstance == "" {
|
|
self.ApiSessionCertInstance = uuid.NewString()
|
|
}
|
|
|
|
if self.ApiSessionPrivateKey == nil {
|
|
var err error
|
|
self.ApiSessionPrivateKey, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("could not generate private key for api session certificate: %v", err)
|
|
}
|
|
}
|
|
|
|
csrTemplate := &x509.CertificateRequest{
|
|
Subject: pkix.Name{
|
|
Organization: []string{"Ziti SDK"},
|
|
OrganizationalUnit: []string{"golang"},
|
|
CommonName: "golang-sdk-" + self.ApiSessionCertInstance + "-" + uuid.NewString(),
|
|
},
|
|
}
|
|
|
|
csrBytes, err := x509.CreateCertificateRequest(rand.Reader, csrTemplate, self.ApiSessionPrivateKey)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
block := &pem.Block{
|
|
Type: "CERTIFICATE REQUEST",
|
|
Headers: nil,
|
|
Bytes: csrBytes,
|
|
}
|
|
csrPemString := string(pem.EncodeToMemory(block))
|
|
|
|
params := current_api_session.NewCreateCurrentAPISessionCertificateParams()
|
|
params.SessionCertificate = &rest_model.CurrentAPISessionCertificateCreate{
|
|
Csr: &csrPemString,
|
|
}
|
|
|
|
resp, err := self.API.CurrentAPISession.CreateCurrentAPISessionCertificate(params, self.GetCurrentApiSession())
|
|
|
|
if err != nil {
|
|
return rest_util.WrapErr(err)
|
|
}
|
|
|
|
certs := nfPem.PemBytesToCertificates([]byte(*resp.Payload.Data.Certificate))
|
|
|
|
if len(certs) == 0 {
|
|
return fmt.Errorf("expected at least 1 certificate creating an API Session Certificate, got 0")
|
|
}
|
|
|
|
pfxlog.Logger().Infof("new API Session Certificate: %x", sha1.Sum(certs[0].Raw))
|
|
|
|
self.ApiSessionCertificate = certs[0]
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetServices will fetch the list of services that the identity of the current ApiSession has access to for dialing
|
|
// or binding.
|
|
func (self *CtrlClient) GetServices() ([]*rest_model.ServiceDetail, error) {
|
|
params := service.NewListServicesParams()
|
|
|
|
pageOffset := int64(0)
|
|
|
|
pageLimit := int64(500)
|
|
params.Limit = &pageLimit
|
|
|
|
if self.supportsSetOfConfigTypesOnServiceList() {
|
|
params.ConfigTypes = self.ConfigTypes
|
|
}
|
|
|
|
var services []*rest_model.ServiceDetail
|
|
|
|
for {
|
|
params.Offset = &pageOffset
|
|
resp, err := self.API.Service.ListServices(params, self.GetCurrentApiSession())
|
|
|
|
if err != nil {
|
|
return nil, rest_util.WrapErr(err)
|
|
}
|
|
|
|
if services == nil {
|
|
services = make([]*rest_model.ServiceDetail, 0, *resp.Payload.Meta.Pagination.TotalCount)
|
|
}
|
|
|
|
services = append(services, resp.Payload.Data...)
|
|
|
|
pageOffset += pageLimit
|
|
if pageOffset >= *resp.Payload.Meta.Pagination.TotalCount {
|
|
break
|
|
}
|
|
}
|
|
|
|
return services, nil
|
|
}
|
|
|
|
// GetService will fetch the specific service requested. If the service doesn't exist,
|
|
// nil will be returned
|
|
func (self *CtrlClient) GetService(name string) (*rest_model.ServiceDetail, error) {
|
|
params := service.NewListServicesParams()
|
|
|
|
filter := fmt.Sprintf(`name="%s"`, name)
|
|
params.Filter = &filter
|
|
|
|
resp, err := self.API.Service.ListServices(params, nil)
|
|
|
|
if err != nil {
|
|
return nil, rest_util.WrapErr(err)
|
|
}
|
|
|
|
if len(resp.Payload.Data) > 0 {
|
|
return resp.Payload.Data[0], nil
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
// GetServiceTerminators returns the client terminator details for a specific service.
|
|
func (self *CtrlClient) GetServiceTerminators(svc *rest_model.ServiceDetail, offset int, limit int) ([]*rest_model.TerminatorClientDetail, int, error) {
|
|
params := service.NewListServiceTerminatorsParams()
|
|
|
|
pageOffset := int64(offset)
|
|
params.Offset = &pageOffset
|
|
|
|
pageLimit := int64(limit)
|
|
params.Limit = &pageLimit
|
|
|
|
params.ID = *svc.ID
|
|
|
|
resp, err := self.API.Service.ListServiceTerminators(params, self.GetCurrentApiSession())
|
|
|
|
if err != nil {
|
|
return nil, 0, rest_util.WrapErr(err)
|
|
}
|
|
|
|
return resp.Payload.Data, int(*resp.Payload.Meta.Pagination.TotalCount), nil
|
|
}
|
|
|
|
// CreateSession will attempt to obtain a session token for a specific service id and type.
|
|
func (self *CtrlClient) CreateSession(id string, sessionType SessionType) (*rest_model.SessionDetail, error) {
|
|
params := session.NewCreateSessionParams()
|
|
params.Session = &rest_model.SessionCreate{
|
|
ServiceID: id,
|
|
Type: rest_model.DialBind(sessionType),
|
|
}
|
|
|
|
resp, err := self.API.Session.CreateSession(params, self.GetCurrentApiSession())
|
|
|
|
if err != nil {
|
|
return nil, rest_util.WrapErr(err)
|
|
}
|
|
|
|
self.sanitizeSessionUrls(resp.Payload.Data)
|
|
return resp.Payload.Data, nil
|
|
}
|
|
|
|
// EnrollMfa will attempt to start TOTP MFA enrollment for the currently authenticated identity.
|
|
func (self *CtrlClient) EnrollMfa() (*rest_model.DetailMfa, error) {
|
|
enrollMfaParams := current_identity.NewEnrollMfaParams()
|
|
|
|
apiSession := self.GetCurrentApiSession()
|
|
|
|
_, enrollMfaErr := self.API.CurrentIdentity.EnrollMfa(enrollMfaParams, apiSession)
|
|
|
|
if enrollMfaErr != nil {
|
|
return nil, enrollMfaErr
|
|
}
|
|
|
|
detailMfaParams := current_identity.NewDetailMfaParams()
|
|
detailMfaResp, detailMfaErr := self.API.CurrentIdentity.DetailMfa(detailMfaParams, apiSession)
|
|
|
|
if detailMfaErr != nil {
|
|
return nil, rest_util.WrapErr(detailMfaErr)
|
|
}
|
|
|
|
return detailMfaResp.Payload.Data, nil
|
|
}
|
|
|
|
// VerifyMfa will complete a TOTP MFA enrollment created via EnrollMfa.
|
|
func (self *CtrlClient) VerifyMfa(code string) error {
|
|
params := current_identity.NewVerifyMfaParams()
|
|
|
|
params.MfaValidation = &rest_model.MfaCode{
|
|
Code: &code,
|
|
}
|
|
|
|
_, err := self.API.CurrentIdentity.VerifyMfa(params, self.GetCurrentApiSession())
|
|
|
|
return rest_util.WrapErr(err)
|
|
}
|
|
|
|
// RemoveMfa will remove the currently enrolled TOTP MFA added by EnrollMfa() and verified by VerifyMfa()
|
|
func (self *CtrlClient) RemoveMfa(code string) error {
|
|
params := current_identity.NewDeleteMfaParams()
|
|
params.MfaValidationCode = &code
|
|
|
|
_, err := self.API.CurrentIdentity.DeleteMfa(params, self.GetCurrentApiSession())
|
|
|
|
return rest_util.WrapErr(err)
|
|
}
|
|
|
|
// sanitizeSessionUrls will transform ER urls to transport friendly URIs and remove
|
|
// any addresses that cannot be parsed
|
|
func (self *CtrlClient) sanitizeSessionUrls(session *rest_model.SessionDetail) {
|
|
for _, edgeRouter := range session.EdgeRouters {
|
|
newUrls := map[string]string{}
|
|
for protocol, url := range edgeRouter.SupportedProtocols {
|
|
url = strings.Replace(url, "://", ":", 1)
|
|
if _, err := transport.ParseAddress(url); err == nil {
|
|
newUrls[protocol] = url
|
|
} else {
|
|
pfxlog.Logger().WithError(err).Debugf("ignoring address [%s] for router [%s], as it can't be parsed", url, genext.OrDefault(edgeRouter.Name))
|
|
}
|
|
}
|
|
edgeRouter.SupportedProtocols = newUrls
|
|
}
|
|
}
|
|
|
|
func (self *CtrlClient) loadCtrlCapabilities() {
|
|
result, _ := self.API.Informational.ListVersion(informational.NewListVersionParams())
|
|
if result != nil && result.Payload != nil && result.Payload.Data != nil {
|
|
if sv, err := versions.ParseSemVer(result.Payload.Data.Version); err == nil {
|
|
if sv.Equals(versions.MustParseSemVer("0.0.0")) || sv.CompareTo(versions.MustParseSemVer("1.1.0")) >= 0 {
|
|
self.supportsConfigTypesOnServiceList.Store(true)
|
|
}
|
|
}
|
|
}
|
|
self.capabilitiesLoaded.Store(true)
|
|
}
|
|
|
|
func (self *CtrlClient) supportsSetOfConfigTypesOnServiceList() bool {
|
|
if !self.capabilitiesLoaded.Load() {
|
|
self.loadCtrlCapabilities()
|
|
}
|
|
return self.supportsConfigTypesOnServiceList.Load()
|
|
}
|