mirror of
https://github.com/hanzozt/zt.git
synced 2026-10-30 15:04:55 +00:00
The router checked its data model subscription every 30 seconds and renewed it only once it had lapsed, and the controller pushes nothing to a lapsed subscriber, so every five minutes the router went up to 30 seconds without changes and then took them in one burst. Renew once a minute remains, so a check always lands in time. Claude-Session: https://claude.ai/code/session_01HuMDfgFssPUptYUzgZ6Cp8
1617 lines
59 KiB
Go
1617 lines
59 KiB
Go
/*
|
|
Copyright 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 state
|
|
|
|
import (
|
|
"bufio"
|
|
"crypto"
|
|
"crypto/x509"
|
|
"fmt"
|
|
"math/rand"
|
|
"os"
|
|
"runtime/debug"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/kataras/go-events"
|
|
"github.com/michaelquigley/pfxlog"
|
|
"github.com/hanzozt/channel/v4"
|
|
"github.com/hanzozt/channel/v4/protobufs"
|
|
"github.com/hanzozt/foundation/v2/concurrenz"
|
|
"github.com/hanzozt/foundation/v2/goroutines"
|
|
"github.com/hanzozt/sdk-golang/pb/edge_client_pb"
|
|
"github.com/hanzozt/sdk-golang/zt/edge"
|
|
"github.com/hanzozt/zt/v2/common"
|
|
"github.com/hanzozt/zt/v2/common/eid"
|
|
"github.com/hanzozt/zt/v2/common/metrics"
|
|
"github.com/hanzozt/zt/v2/common/pb/edge_ctrl_pb"
|
|
"github.com/hanzozt/zt/v2/common/runner"
|
|
"github.com/hanzozt/zt/v2/controller/oidc_auth"
|
|
"github.com/hanzozt/zt/v2/router/env"
|
|
"github.com/hanzozt/zt/v2/router/posture"
|
|
cmap "github.com/orcaman/concurrent-map/v2"
|
|
"github.com/pkg/errors"
|
|
"github.com/sirupsen/logrus"
|
|
"google.golang.org/protobuf/proto"
|
|
)
|
|
|
|
const (
|
|
EventRemovedEdgeSession = "RemovedEdgeSession"
|
|
|
|
EventAddedApiSession = "AddedApiSession"
|
|
EventUpdatedApiSession = "UpdatedApiSession"
|
|
EventRemovedApiSession = "RemovedApiSession"
|
|
|
|
DefaultSubscriptionTimeout = 5 * time.Minute
|
|
|
|
// subscriptionCheck is how often the router checks its data model subscription.
|
|
subscriptionCheck = 30 * time.Second
|
|
|
|
// subscriptionRenewal is how long before a subscription lapses the router renews it. It
|
|
// exceeds subscriptionCheck, so a check always lands before the lapse: a lapsed
|
|
// subscription stops the controller pushing changes until the next check.
|
|
subscriptionRenewal = 2 * subscriptionCheck
|
|
)
|
|
|
|
// subscriptionDue reports whether a data model subscription lapsing at timeout is due for
|
|
// renewal at now.
|
|
func subscriptionDue(now, timeout time.Time) bool {
|
|
return !now.Before(timeout.Add(-subscriptionRenewal))
|
|
}
|
|
|
|
type RemoveListener func()
|
|
|
|
// ConnState encapsulates the authentication and authorization context for an
|
|
// edge connection, bundling API session credentials, service-specific tokens,
|
|
// and policy enforcement metadata for streamlined access control decisions.
|
|
type ConnState struct {
|
|
ApiSessionToken *ApiSessionToken
|
|
ServiceSessionToken *ServiceSessionToken
|
|
PolicyType edge_ctrl_pb.PolicyType
|
|
}
|
|
|
|
// ConnProvider is an interface used to abstract specific conn implementations from lower level packages
|
|
// (such as xgress_edge) to avoid circular dependencies.
|
|
type ConnProvider interface {
|
|
GetConnIdToSinks() map[uint32]edge.MsgSink[*ConnState]
|
|
CloseConn(connId uint32, reason string) error
|
|
}
|
|
|
|
// ApiSessionTokenProvider abstracts access to API session tokens, enabling
|
|
// decoupled token retrieval across different components while maintaining
|
|
// consistent authentication context access patterns.
|
|
type ApiSessionTokenProvider interface {
|
|
GetApiSessionToken() *ApiSessionToken
|
|
}
|
|
|
|
// DataModelSubscription tracks to which controller and with which subscription id
|
|
// the router is currently subscribed to router data model changes.
|
|
// A subscriptionId is necessary to avoid the following sequence of events:
|
|
//
|
|
// 1. subscribe to controller 1
|
|
// 2. controller starts sending events
|
|
// 3. before receiving any events, controller change event is triggered and we re-subscribe to controller 1
|
|
// 4. controller restarts event stream
|
|
// 5. the resubscribe temporarily reset the current controller to ""
|
|
// 6. in that state we received event 1 from the first stream of events from the controller and discarded it
|
|
// 7. we then were resubscribed and got event 2 from the first stream and processed it
|
|
// 8. At some point we get event 1 again from the second stream, but because it's no longer in order, we discard it
|
|
type DataModelSubscription struct {
|
|
CtrlId string
|
|
SubscriptionId string
|
|
}
|
|
|
|
func (self DataModelSubscription) IsCurrentController(controllerId string) bool {
|
|
return self.CtrlId != "" && self.CtrlId == controllerId
|
|
}
|
|
|
|
// Manager provides the central interface for router state management, encompassing
|
|
// session lifecycle, authentication token processing, posture monitoring, and
|
|
// distributed synchronization with controllers. This interface abstracts the
|
|
// complex state coordination required for zero-trust network operation.
|
|
//
|
|
// The Manager handles the evolution from legacy protobuf-based sessions to modern
|
|
// JWT-based authentication while maintaining backwards compatibility. It coordinates
|
|
// between multiple concerns: session validation, policy enforcement, network topology
|
|
// updates, and connection lifecycle management.
|
|
//
|
|
// Key responsibilities include:
|
|
// - Session token parsing and validation for both JWT and legacy formats
|
|
// - Real-time posture data processing and policy evaluation
|
|
// - Distributed state synchronization with controller clusters
|
|
// - Connection authorization and cleanup coordination
|
|
// - Certificate validation and public key management
|
|
type Manager interface {
|
|
env.Xrctrl
|
|
|
|
// ParseServiceSessionJwt validates and extracts service session tokens from JWT
|
|
// strings, ensuring cryptographic integrity and claim validation while binding
|
|
// the service session to its parent API session context.
|
|
ParseServiceSessionJwt(jwtStr string, apiSessionToken *ApiSessionToken) (*ServiceSessionToken, error)
|
|
|
|
// GetServiceSessionToken creates service session tokens from either JWT strings
|
|
// or service IDs, providing a unified interface that handles both modern JWT-based
|
|
// service access and legacy service ID lookups from the router data model.
|
|
GetServiceSessionToken(serviceToken string, apiSessionToken *ApiSessionToken) (*ServiceSessionToken, error)
|
|
|
|
// RemoveLegacyServiceSession handles the controlled termination of service sessions
|
|
// that have been invalidated by controller policy changes or expiration.
|
|
RemoveLegacyServiceSession(serviceSessionToken *ServiceSessionToken)
|
|
|
|
// AddLegacyServiceSessionRemovedListener enables connection cleanup coordination
|
|
// by notifying components when service sessions become invalid.
|
|
AddLegacyServiceSessionRemovedListener(serviceSessionToken *ServiceSessionToken, callBack func(serviceSessionToken *ServiceSessionToken)) RemoveListener
|
|
|
|
// WasLegacyServiceSessionRecentlyRemoved prevents spurious connection attempts
|
|
// by tracking recently invalidated sessions.
|
|
WasLegacyServiceSessionRecentlyRemoved(token string) bool
|
|
|
|
// MarkLegacyServiceSessionRecentlyRemoved adds session invalidation timestamps
|
|
// to enable fast-path rejection of connection attempts using recently removed sessions.
|
|
MarkLegacyServiceSessionRecentlyRemoved(token string)
|
|
|
|
// ParseApiSessionJwt validates and extracts API session tokens from JWT strings,
|
|
// performing cryptographic signature verification and audience validation to
|
|
// ensure token authenticity and proper scope for Ziti network access.
|
|
ParseApiSessionJwt(jwtStr string) (*ApiSessionToken, error)
|
|
|
|
// GetApiSessionToken retrieves API session tokens from either JWT strings or
|
|
// legacy token lookups, abstracting the underlying token format from callers.
|
|
GetApiSessionToken(apiSessionToken string) *ApiSessionToken
|
|
|
|
// GetApiSessionTokenWithTimeout implements eventual consistency for session lookups
|
|
// during the window between session creation notification and local cache population.
|
|
GetApiSessionTokenWithTimeout(token string, timeout time.Duration) *ApiSessionToken
|
|
|
|
// AddApiSessionRemovedListener provides event notification for API session
|
|
// invalidation, enabling dependent resources to perform coordinated cleanup.
|
|
AddApiSessionRemovedListener(apiSessionToken *ApiSessionToken, callBack func(*ApiSessionToken)) RemoveListener
|
|
|
|
// AddLegacyApiSession registers controller-synchronized API sessions in the legacy
|
|
// tracking store, specifically handling protobuf-based sessions.
|
|
AddLegacyApiSession(apiSession *ApiSessionToken)
|
|
|
|
// UpdateLegacyApiSession refreshes controller-synchronized API session state
|
|
// for legacy sessions, maintaining backwards compatibility.
|
|
UpdateLegacyApiSession(apiSession *ApiSessionToken)
|
|
|
|
// RemoveLegacyApiSession removes controller-synchronized API sessions from
|
|
// legacy tracking stores.
|
|
RemoveLegacyApiSession(apiSession *ApiSessionToken)
|
|
|
|
// RemoveMissingApiSessions reconciles router session state with controller state
|
|
// by removing sessions not present in the authoritative controller list.
|
|
RemoveMissingApiSessions(knownSessions []*ApiSessionToken, beforeSessionId string)
|
|
|
|
// RouterDataModel returns the current router data model containing
|
|
// network topology and policy information.
|
|
RouterDataModel() *common.RouterDataModel
|
|
|
|
// WithRouterDataModel passes the current router data model into the provide function
|
|
WithRouterDataModel(f func(*common.RouterDataModel) error) error
|
|
|
|
// SetRouterDataModel replaces the current router data model with a new one,
|
|
// optionally resetting the controller subscription.
|
|
SetRouterDataModel(model *common.RouterDataModel, resetSubscription bool)
|
|
|
|
// GetRouterDataModelPool returns the goroutine pool used for processing
|
|
// router data model events and updates.
|
|
GetRouterDataModelPool() goroutines.Pool
|
|
|
|
// ResyncRouterDataModel will initiate a fully resync of the router data model from
|
|
// a controller. Used when there's an indication that the router data model has gotten
|
|
// out of sync
|
|
ResyncRouterDataModel()
|
|
|
|
// StartHeartbeat initiates periodic transmission of active legacy session tokens
|
|
// to controllers, enabling distributed session state synchronization.
|
|
StartHeartbeat(env env.RouterEnv, seconds int, closeNotify <-chan struct{})
|
|
|
|
// ValidateSessions performs batch validation of active service sessions against
|
|
// controller state, enabling detection of sessions that have been revoked or expired.
|
|
ValidateSessions(ch channel.Channel, chunkSize uint32, minInterval, maxInterval time.Duration)
|
|
|
|
// DumpApiSessions provides diagnostic output of all tracked API sessions
|
|
// for operational debugging and system health monitoring.
|
|
DumpApiSessions(c *bufio.ReadWriter) error
|
|
|
|
// MarkSyncInProgress sets the current synchronization tracker ID.
|
|
MarkSyncInProgress(trackerId string)
|
|
|
|
// MarkSyncStopped clears the synchronization tracker if it matches the provided ID.
|
|
MarkSyncStopped(trackerId string)
|
|
|
|
// IsSyncInProgress returns whether a synchronization operation is currently active.
|
|
IsSyncInProgress() bool
|
|
|
|
// VerifyClientCert validates client certificates against the router's trusted
|
|
// certificate authorities.
|
|
VerifyClientCert(cert *x509.Certificate) error
|
|
|
|
// StartRouterModelSave begins periodic saving of the router data model to disk.
|
|
StartRouterModelSave(path string, duration time.Duration)
|
|
|
|
// LoadRouterModel initializes the router data model from a saved file,
|
|
// falling back to an empty model if the file doesn't exist.
|
|
LoadRouterModel(filePath string)
|
|
|
|
// ProcessPostureResponses handles incoming posture data from SDK clients and updates
|
|
// the router's posture cache.
|
|
ProcessPostureResponses(ch channel.Channel, response *edge_client_pb.PostureResponses)
|
|
|
|
// GetEnv returns the router environment instance.
|
|
GetEnv() env.RouterEnv
|
|
|
|
// HandleClientApiSessionTokenUpdate propagates JWT token updates to active client
|
|
// connections during token rotation.
|
|
HandleClientApiSessionTokenUpdate(*ApiSessionToken) error
|
|
|
|
// GetCurrentDataModelSubscription returns the current the router data model subscription information.
|
|
GetCurrentDataModelSubscription() DataModelSubscription
|
|
|
|
// SetConnectionTracker registers the connection tracking implementation with the state manager.
|
|
SetConnectionTracker(tracker ConnectionTracker)
|
|
|
|
// HasAccess evaluates whether an identity has access to a service based on
|
|
// current posture data and policy configuration.
|
|
HasAccess(identityId, apiSessionId, serviceId string, policyType edge_ctrl_pb.PolicyType) (*common.ServicePolicy, error)
|
|
|
|
// HasDialAccess evaluates service dialing authorization for an identity.
|
|
HasDialAccess(identityId, apiSessionId, serviceId string) (*common.ServicePolicy, error)
|
|
|
|
// HasBindAccess evaluates service binding authorization for an identity.
|
|
HasBindAccess(identityId, apiSessionId, serviceId string) (*common.ServicePolicy, error)
|
|
|
|
ParseTotpToken(token string) (*common.TotpClaims, error)
|
|
}
|
|
|
|
// ConnectionTracker provides visibility into active channel connections,
|
|
// enabling session management operations to locate and interact with
|
|
// specific client connections based on identity context.
|
|
type ConnectionTracker interface {
|
|
GetChannels() map[string][]channel.Channel
|
|
GetChannelsByIdentityId(identityId string) []channel.Channel
|
|
}
|
|
|
|
var _ Manager = (*ManagerImpl)(nil)
|
|
|
|
// NewManager creates a new state manager instance with all necessary components
|
|
// for session tracking, posture monitoring, and router data model management.
|
|
// This is the primary factory function that initializes the complete state
|
|
// management infrastructure including goroutine pools, caches, and event handlers.
|
|
func NewManager(stateEnv env.RouterEnv) Manager {
|
|
routerDataModelPoolConfig := goroutines.PoolConfig{
|
|
QueueSize: uint32(1000),
|
|
MinWorkers: 1,
|
|
MaxWorkers: uint32(1),
|
|
IdleTime: 30 * time.Second,
|
|
CloseNotify: stateEnv.GetCloseNotify(),
|
|
PanicHandler: func(err interface{}) {
|
|
pfxlog.Logger().
|
|
WithField(logrus.ErrorKey, err).
|
|
WithField("backtrace", string(debug.Stack())).Error("panic during router data model event")
|
|
},
|
|
WorkerFunction: routerDataModelWorker,
|
|
}
|
|
|
|
metrics.ConfigureGoroutinesPoolMetrics(&routerDataModelPoolConfig, stateEnv.GetMetricsRegistry(), "pool.rdm.handler")
|
|
|
|
routerDataModelPool, err := goroutines.NewPool(routerDataModelPoolConfig)
|
|
if err != nil {
|
|
panic(errors.Wrap(err, "error creating rdm goroutine pool"))
|
|
}
|
|
|
|
result := &ManagerImpl{
|
|
EventEmmiter: events.New(),
|
|
legacyApiSessionsByToken: cmap.New[*ApiSessionToken](),
|
|
recentlyRemovedSessions: cmap.New[time.Time](),
|
|
certCache: cmap.New[*x509.Certificate](),
|
|
env: stateEnv,
|
|
routerDataModelPool: routerDataModelPool,
|
|
endpointsChanged: make(chan env.CtrlEvent, 10),
|
|
modelChanged: make(chan struct{}, 1),
|
|
}
|
|
result.postureCache = posture.NewCache(result)
|
|
|
|
result.postureCache.AddUpdateListener(result.onPostureDataUpdate)
|
|
cfg := stateEnv.GetConfig()
|
|
result.LoadRouterModel(stateEnv.GetConfig().Edge.Db)
|
|
|
|
stateEnv.GetNetworkControllers().AddChangeListener(env.CtrlEventListenerFunc(func(event env.CtrlEvent) {
|
|
if event.Type != env.ControllerLeaderChange {
|
|
select {
|
|
case result.endpointsChanged <- event:
|
|
default:
|
|
}
|
|
}
|
|
}))
|
|
|
|
go result.manageRouterDataModelSubscription()
|
|
go result.startConnectionVerification(5 * time.Second)
|
|
|
|
result.StartRouterModelSave(cfg.Edge.Db, cfg.Edge.DbSaveInterval)
|
|
|
|
return result
|
|
}
|
|
|
|
// onPostureDataUpdate responds to changes in posture data by re-evaluating access policies
|
|
// for all connections associated with the affected identity. When posture data changes
|
|
// (such as OS updates, domain changes, or MFA status), this function ensures that all
|
|
// active connections are still compliant with access policies.
|
|
//
|
|
// The function iterates through all channels for the identity, examines each connection,
|
|
// and re-evaluates access using the updated posture data. Connections that no longer
|
|
// meet policy requirements are automatically terminated with appropriate error messages.
|
|
//
|
|
// Parameters:
|
|
// - data: The updated posture instance data containing current device state
|
|
func (self *ManagerImpl) onPostureDataUpdate(data *posture.InstanceData) {
|
|
rdm := self.routerDataModel.Load()
|
|
channels := self.connectionTracker.GetChannelsByIdentityId(data.IdentityId)
|
|
|
|
for _, ch := range channels {
|
|
edgeConn, connIdToSink := GetConnProviderAndSinksFromCh(ch)
|
|
|
|
for connId, sink := range connIdToSink {
|
|
connState := sink.GetData()
|
|
|
|
if connState.ApiSessionToken == nil || connState.ApiSessionToken.Id != data.ApiSessionId {
|
|
continue
|
|
}
|
|
|
|
policy, err := posture.HasAccess(rdm, connState.ApiSessionToken.IdentityId, connState.ServiceSessionToken.ServiceId, data, connState.PolicyType)
|
|
|
|
var closeErr error
|
|
if err != nil {
|
|
closeErr = edgeConn.CloseConn(connId, fmt.Sprintf("could not determine access, encountered error: %s", err))
|
|
} else if policy == nil {
|
|
closeErr = edgeConn.CloseConn(connId, "access revoked, not granting policies found")
|
|
}
|
|
|
|
if closeErr != nil {
|
|
pfxlog.Logger().WithError(err).Error("error closing connection during access check")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// HasAccess evaluates whether an identity has access to a service based on
|
|
// current posture data and policy configuration, providing comprehensive
|
|
// authorization decisions that incorporate real-time device compliance.
|
|
func (self *ManagerImpl) HasAccess(identityId, apiSessionId, serviceId string, policyType edge_ctrl_pb.PolicyType) (*common.ServicePolicy, error) {
|
|
var data *posture.InstanceData
|
|
rdm := self.routerDataModel.Load()
|
|
|
|
instance := self.postureCache.GetInstance(apiSessionId)
|
|
|
|
if instance != nil {
|
|
data = &instance.InstanceData
|
|
}
|
|
|
|
return posture.HasAccess(rdm, identityId, serviceId, data, policyType)
|
|
}
|
|
|
|
func routerDataModelWorker(_ uint32, f func()) {
|
|
f()
|
|
}
|
|
|
|
// HasBindAccess evaluates service binding authorization, determining if an
|
|
// identity can host connections for a specific service based on policy and posture.
|
|
func (self *ManagerImpl) HasBindAccess(identityId, apiSessionId, serviceId string) (*common.ServicePolicy, error) {
|
|
return self.HasAccess(identityId, apiSessionId, serviceId, edge_ctrl_pb.PolicyType_BindPolicy)
|
|
}
|
|
|
|
// HasDialAccess evaluates service dialing authorization, determining if an
|
|
// identity can initiate connections to a specific service based on policy and posture.
|
|
func (self *ManagerImpl) HasDialAccess(identityId, apiSessionId, serviceId string) (*common.ServicePolicy, error) {
|
|
return self.HasAccess(identityId, apiSessionId, serviceId, edge_ctrl_pb.PolicyType_DialPolicy)
|
|
}
|
|
|
|
type ManagerImpl struct {
|
|
env env.RouterEnv
|
|
|
|
// legacyApiSessionsByToken is a store of legacy API Session delivered to the
|
|
// router from the controller. These tokens are backed by UUIDs and not
|
|
// JWTs.
|
|
legacyApiSessionsByToken cmap.ConcurrentMap[string, *ApiSessionToken]
|
|
|
|
// recentlyRemovedSessions tracks sessions that have just been removed and
|
|
// allows the event system to immediately trigger registering callbacks.
|
|
// stored as `id` => `time`. Where `id` and legacy `token` values are the
|
|
// same as the `token` value is no longer used as a secret.
|
|
recentlyRemovedSessions cmap.ConcurrentMap[string, time.Time]
|
|
|
|
Hostname string
|
|
ControllerAddr string
|
|
ClusterId string
|
|
NodeId string
|
|
events.EventEmmiter
|
|
heartbeatRunner runner.Runner
|
|
heartbeatOperation *heartbeatOperation
|
|
currentSync string
|
|
syncLock sync.Mutex
|
|
rdmLock sync.RWMutex
|
|
|
|
certCache cmap.ConcurrentMap[string, *x509.Certificate]
|
|
routerDataModel atomic.Pointer[common.RouterDataModel]
|
|
routerDataModelPool goroutines.Pool
|
|
|
|
resyncRouterDataModel atomic.Bool
|
|
endpointsChanged chan env.CtrlEvent
|
|
modelChanged chan struct{}
|
|
dataModelSubscription concurrenz.AtomicValue[DataModelSubscription]
|
|
dataModelSubTimeout time.Time
|
|
|
|
postureCache *posture.Cache
|
|
|
|
connectionTracker ConnectionTracker
|
|
}
|
|
|
|
func (self *ManagerImpl) ParseTotpToken(jwtStr string) (*common.TotpClaims, error) {
|
|
totpClaims := &common.TotpClaims{}
|
|
token, err := jwt.ParseWithClaims(jwtStr, totpClaims, self.pubKeyLookup)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !totpClaims.HasAudience(common.ClaimAudienceHanzoZT) && !totpClaims.HasAudience(common.ClaimLegacyNative) {
|
|
return nil, fmt.Errorf("provided a totp token with invalid audience '%s' of type [%T], expected: %s or %s", totpClaims.Audience, totpClaims.Audience, common.ClaimAudienceHanzoZT, common.ClaimLegacyNative)
|
|
}
|
|
|
|
if totpClaims.Type != common.TokenTypeTotp {
|
|
return nil, fmt.Errorf("provided a totp token with invalid type '%s' expected '%s'", totpClaims.Type, common.TokenTypeTotp)
|
|
}
|
|
|
|
if !token.Valid {
|
|
return nil, fmt.Errorf("provided totp token that is not valid")
|
|
}
|
|
|
|
return totpClaims, nil
|
|
}
|
|
|
|
// SetConnectionTracker registers the connection tracking implementation with the state manager.
|
|
// The connection tracker provides the state manager with visibility into active network
|
|
// connections, enabling policy enforcement and posture checking operations. This dependency
|
|
// injection pattern allows the xgress_edge package to provide connection tracking capabilities
|
|
// while avoiding circular dependencies.
|
|
//
|
|
// Parameters:
|
|
// - tracker: The ConnectionTracker implementation to use for connection queries
|
|
func (self *ManagerImpl) SetConnectionTracker(tracker ConnectionTracker) {
|
|
self.connectionTracker = tracker
|
|
}
|
|
|
|
// GetCurrentDataModelSubscription return the current router data model subscription information
|
|
func (self *ManagerImpl) GetCurrentDataModelSubscription() DataModelSubscription {
|
|
return self.dataModelSubscription.Load()
|
|
}
|
|
|
|
// manageRouterDataModelSubscription handles automatic subscription management
|
|
// for router data model updates, including controller failover and timeout handling.
|
|
func (self *ManagerImpl) manageRouterDataModelSubscription() {
|
|
ticker := time.NewTicker(subscriptionCheck)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-self.env.GetCloseNotify():
|
|
return
|
|
case endpointChangedEvent := <-self.endpointsChanged:
|
|
// if the controller we're subscribed to has changed, resubscribe
|
|
if self.GetCurrentDataModelSubscription().IsCurrentController(endpointChangedEvent.Controller.Channel().Id()) {
|
|
pfxlog.Logger().WithField("ctrlId", endpointChangedEvent.Controller.Channel().Id()).WithField("change", endpointChangedEvent.Type).
|
|
Info("currently subscribed controller has changed, resubscribing")
|
|
self.dataModelSubscription.Store(DataModelSubscription{})
|
|
}
|
|
case <-ticker.C:
|
|
case <-self.modelChanged:
|
|
}
|
|
|
|
allEndpointChangesProcessed := false
|
|
for !allEndpointChangesProcessed {
|
|
select {
|
|
case endpointChangedEvent := <-self.endpointsChanged:
|
|
// if the controller we're subscribed to has changed, resubscribe
|
|
if self.GetCurrentDataModelSubscription().IsCurrentController(endpointChangedEvent.Controller.Channel().Id()) {
|
|
pfxlog.Logger().WithField("ctrlId", endpointChangedEvent.Controller.Channel().Id()).WithField("change", endpointChangedEvent.Type).
|
|
Info("currently subscribed controller has changed, resubscribing")
|
|
self.dataModelSubscription.Store(DataModelSubscription{})
|
|
}
|
|
default:
|
|
allEndpointChangesProcessed = true
|
|
}
|
|
}
|
|
|
|
self.checkRouterDataModelSubscription()
|
|
}
|
|
}
|
|
|
|
// checkRouterDataModelSubscription verifies subscription health and switches
|
|
// controllers when necessary.
|
|
func (self *ManagerImpl) checkRouterDataModelSubscription() {
|
|
if !self.env.IsRouterDataModelRequired() {
|
|
return
|
|
}
|
|
|
|
currentSubscription := self.GetCurrentDataModelSubscription()
|
|
ctrl := self.env.GetNetworkControllers().GetNetworkController(currentSubscription.CtrlId)
|
|
if currentSubscription.CtrlId == "" || ctrl == nil || subscriptionDue(time.Now(), self.dataModelSubTimeout) {
|
|
if bestCtrl := self.env.GetNetworkControllers().AnyChannel(); bestCtrl != nil {
|
|
logger := pfxlog.Logger().
|
|
WithField("ctrlId", bestCtrl.Id()).
|
|
WithField("subscriptionId", currentSubscription.SubscriptionId).
|
|
WithField("prevCtrlId", self.dataModelSubscription.Load().CtrlId)
|
|
if ctrl == nil {
|
|
logger.Info("no current data model subscription active, subscribing")
|
|
} else {
|
|
logger.Info("current data model subscription due, renewing")
|
|
}
|
|
self.subscribeToDataModelUpdates(bestCtrl)
|
|
}
|
|
} else if !ctrl.IsConnected() || ctrl.TimeSinceLastContact() > 30*time.Second {
|
|
bestCtrl := self.env.GetNetworkControllers().AnyChannel()
|
|
if bestCtrl != nil && bestCtrl.Id() != ctrl.Channel().Id() {
|
|
pfxlog.Logger().
|
|
WithField("ctrlId", bestCtrl.Id()).
|
|
WithField("subscriptionId", currentSubscription.SubscriptionId).
|
|
WithField("prevCtrlId", currentSubscription.CtrlId).
|
|
Info("current data model subscription source unreliable, changing subscription")
|
|
self.subscribeToDataModelUpdates(bestCtrl)
|
|
}
|
|
}
|
|
}
|
|
|
|
// subscribeToDataModelUpdates sends subscription requests to controllers
|
|
// for router data model changes.
|
|
func (self *ManagerImpl) subscribeToDataModelUpdates(ch channel.Channel) {
|
|
currentSubscription := self.GetCurrentDataModelSubscription()
|
|
renew := !self.resyncRouterDataModel.Load() && currentSubscription.CtrlId == ch.Id() && currentSubscription.CtrlId != ""
|
|
|
|
// if we store after success, we may miss an update because the ids don't match yet
|
|
if !renew {
|
|
currentSubscription = DataModelSubscription{
|
|
CtrlId: ch.Id(),
|
|
SubscriptionId: eid.New(),
|
|
}
|
|
self.dataModelSubscription.Store(currentSubscription)
|
|
}
|
|
|
|
var currentIndex uint64
|
|
if !self.resyncRouterDataModel.Load() {
|
|
if rdm := self.routerDataModel.Load(); rdm != nil {
|
|
currentIndex = rdm.CurrentIndex()
|
|
}
|
|
}
|
|
|
|
timelineId := ""
|
|
if rdm := self.routerDataModel.Load(); rdm != nil {
|
|
timelineId = rdm.GetTimelineId()
|
|
}
|
|
|
|
subTimeout := time.Now().Add(DefaultSubscriptionTimeout)
|
|
req := &edge_ctrl_pb.SubscribeToDataModelRequest{
|
|
CurrentIndex: currentIndex,
|
|
SubscriptionDurationSeconds: uint32(DefaultSubscriptionTimeout.Seconds()),
|
|
Renew: renew,
|
|
TimelineId: timelineId,
|
|
SubscriptionId: currentSubscription.SubscriptionId,
|
|
}
|
|
|
|
logger := pfxlog.Logger().
|
|
WithField("ctrlId", ch.Id()).
|
|
WithField("currentIndex", req.CurrentIndex).
|
|
WithField("renew", req.Renew).
|
|
WithField("subscriptionId", currentSubscription.SubscriptionId)
|
|
|
|
if err := protobufs.MarshalTyped(req).WithTimeout(self.env.GetNetworkControllers().DefaultRequestTimeout()).SendAndWaitForWire(ch); err != nil {
|
|
self.dataModelSubscription.Store(DataModelSubscription{})
|
|
logger.WithError(err).Error("error to subscribing to router data model changes")
|
|
} else {
|
|
logger.Info("subscribed to new controller for router data model changes")
|
|
self.dataModelSubTimeout = subTimeout
|
|
self.resyncRouterDataModel.Store(false)
|
|
}
|
|
}
|
|
|
|
// GetRouterDataModelPool returns the goroutine pool used for processing
|
|
// router data model events and updates.
|
|
func (self *ManagerImpl) GetRouterDataModelPool() goroutines.Pool {
|
|
return self.routerDataModelPool
|
|
}
|
|
|
|
// ProcessPostureResponses handles incoming posture data from SDK clients and updates
|
|
// the router's posture cache. Posture responses contain information about the client's
|
|
// device state such as operating system details, domain membership, MAC addresses,
|
|
// running processes, and MFA status. This data is used to evaluate whether the client
|
|
// meets policy requirements for network access.
|
|
//
|
|
// The function extracts identity and session information from the channel's API session
|
|
// and stores the posture data in the cache, which triggers automatic evaluation against
|
|
// configured posture checks and policy enforcement.
|
|
//
|
|
// Parameters:
|
|
// - ch: The channel the posture response was received on
|
|
// - response: The posture response containing device state information
|
|
func (self *ManagerImpl) ProcessPostureResponses(ch channel.Channel, responses *edge_client_pb.PostureResponses) {
|
|
apiSessionToken := GetApiSessionTokenFromCh(ch)
|
|
self.postureCache.AddResponses(apiSessionToken.IdentityId, apiSessionToken.Id, responses)
|
|
}
|
|
|
|
// HandleClientApiSessionTokenUpdate propagates JWT token updates to active client
|
|
// connections, ensuring all channels associated with an identity receive the
|
|
// refreshed authentication credentials during token rotation.
|
|
func (self *ManagerImpl) HandleClientApiSessionTokenUpdate(newApiSession *ApiSessionToken) error {
|
|
if newApiSession == nil {
|
|
return errors.New("nil api session")
|
|
}
|
|
|
|
if newApiSession.Claims == nil {
|
|
return errors.New("nil api session claims")
|
|
}
|
|
|
|
if newApiSession.Claims.Type != common.TokenTypeAccess {
|
|
return fmt.Errorf("bearer token is of invalid type: expected %s, got: %s", common.TokenTypeAccess, newApiSession.Claims.Type)
|
|
}
|
|
|
|
channels := self.connectionTracker.GetChannelsByIdentityId(newApiSession.IdentityId)
|
|
|
|
for _, ch := range channels {
|
|
anyData := ch.GetUserData()
|
|
|
|
if anyData != nil {
|
|
data, ok := anyData.(ApiSessionTokenProvider)
|
|
|
|
if !ok || data == nil {
|
|
continue
|
|
}
|
|
apiSession := data.GetApiSessionToken()
|
|
|
|
if apiSession == nil {
|
|
pfxlog.Logger().WithField("chId", ch.Id()).WithField("identityId", newApiSession.IdentityId).Error("api session not found in channel user data")
|
|
continue
|
|
}
|
|
|
|
if !apiSession.IsOidc() {
|
|
// token updates are for OIDC only, not legacy
|
|
continue
|
|
}
|
|
|
|
if apiSession.Claims == nil {
|
|
pfxlog.Logger().WithField("chId", ch.Id()).WithField("identityId", newApiSession.IdentityId).Error("api session claims not found in channel user data for OIDC api session type")
|
|
continue
|
|
}
|
|
|
|
sameApiSessionId := apiSession.Claims.ApiSessionId == newApiSession.Claims.ApiSessionId
|
|
sameSubject := apiSession.Claims.Subject == newApiSession.Claims.Subject
|
|
|
|
if sameApiSessionId && sameSubject {
|
|
apiSession.UpdateToken(newApiSession)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetEnv returns the router environment instance.
|
|
func (self *ManagerImpl) GetEnv() env.RouterEnv {
|
|
return self.env
|
|
}
|
|
|
|
// StartRouterModelSave begins periodic saving of the router data model
|
|
// to disk at the specified interval.
|
|
func (self *ManagerImpl) StartRouterModelSave(filePath string, duration time.Duration) {
|
|
go func() {
|
|
for {
|
|
select {
|
|
case <-self.env.GetCloseNotify():
|
|
return
|
|
case <-time.After(duration):
|
|
self.RouterDataModel().Save(filePath)
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// LoadRouterModel initializes the router data model from a saved file,
|
|
// falling back to an empty model if the file doesn't exist.
|
|
func (self *ManagerImpl) LoadRouterModel(filePath string) {
|
|
model, err := common.NewReceiverRouterDataModelFromFile(filePath, self.env.GetCloseNotify())
|
|
|
|
if err != nil {
|
|
if !os.IsNotExist(err) {
|
|
pfxlog.Logger().WithError(err).Errorf("could not load router model from file [%s]", filePath)
|
|
} else {
|
|
pfxlog.Logger().Infof("router data model file does not exist [%s]", filePath)
|
|
}
|
|
model = common.NewReceiverRouterDataModel(self.env.GetCloseNotify())
|
|
} else {
|
|
index := model.CurrentIndex()
|
|
pfxlog.Logger().WithField("path", filePath).WithField("index", index).Info("loaded router model from file")
|
|
}
|
|
|
|
self.SetRouterDataModel(model, false)
|
|
}
|
|
|
|
// contains is a generic utility function for slice membership testing.
|
|
func contains[T comparable](values []T, element T) bool {
|
|
for _, val := range values {
|
|
if val == element {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// getX509FromData parses and caches X.509 certificates by key ID.
|
|
func (self *ManagerImpl) getX509FromData(kid string, data []byte) (*x509.Certificate, error) {
|
|
if cert, found := self.certCache.Get(kid); found {
|
|
return cert, nil
|
|
}
|
|
|
|
cert, err := x509.ParseCertificate(data)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
self.certCache.Set(kid, cert)
|
|
|
|
return cert, nil
|
|
}
|
|
|
|
// VerifyClientCert validates client certificates against the router's trusted
|
|
// certificate authorities, ensuring only properly signed certificates can
|
|
// establish authenticated connections.
|
|
func (self *ManagerImpl) VerifyClientCert(cert *x509.Certificate) error {
|
|
|
|
rootPool := x509.NewCertPool()
|
|
|
|
rdm := self.routerDataModel.Load()
|
|
|
|
for keysTuple := range rdm.PublicKeys.IterBuffered() {
|
|
if contains(keysTuple.Val.Usages, edge_ctrl_pb.DataState_PublicKey_ClientX509CertValidation) {
|
|
cert, err := self.getX509FromData(keysTuple.Val.Kid, keysTuple.Val.GetData())
|
|
|
|
if err != nil {
|
|
pfxlog.Logger().WithField("kid", keysTuple.Val.Kid).WithError(err).Error("could not parse x509 certificate data")
|
|
continue
|
|
}
|
|
|
|
rootPool.AddCert(cert)
|
|
}
|
|
}
|
|
|
|
opts := x509.VerifyOptions{
|
|
Roots: rootPool,
|
|
Intermediates: x509.NewCertPool(),
|
|
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
|
|
CurrentTime: cert.NotBefore,
|
|
}
|
|
|
|
if _, err := cert.Verify(opts); err != nil {
|
|
return fmt.Errorf("could not verify client certificate %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ParseServiceSessionJwt validates and extracts service session tokens from JWT
|
|
// strings, ensuring cryptographic integrity and claim validation while binding
|
|
// the service session to its parent API session context.
|
|
func (self *ManagerImpl) ParseServiceSessionJwt(jwtStr string, apiSessionToken *ApiSessionToken) (*ServiceSessionToken, error) {
|
|
serviceAccessClaims := &common.ServiceAccessClaims{}
|
|
jwtToken, err := jwt.ParseWithClaims(jwtStr, serviceAccessClaims, self.pubKeyLookup)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if serviceAccessClaims.TokenType == common.TokenTypeServiceAccess {
|
|
return NewServiceSessionToken(jwtToken, serviceAccessClaims, apiSessionToken)
|
|
}
|
|
|
|
if !jwtToken.Valid {
|
|
return nil, fmt.Errorf("provided service session token that is not valid")
|
|
}
|
|
|
|
return nil, fmt.Errorf("invalid service session token type: %s", serviceAccessClaims.Type)
|
|
}
|
|
|
|
// GetServiceSessionToken creates service session tokens from either JWT strings
|
|
// or service IDs, providing a unified interface that handles both modern JWT-based
|
|
// service access and legacy service ID lookups from the router data model.
|
|
func (self *ManagerImpl) GetServiceSessionToken(token string, apiSessionToken *ApiSessionToken) (*ServiceSessionToken, error) {
|
|
if strings.HasPrefix(token, oidc_auth.JwtTokenPrefix) {
|
|
serviceSessionToken, err := self.ParseServiceSessionJwt(token, apiSessionToken)
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create service token from JWT: %w", err)
|
|
}
|
|
return serviceSessionToken, nil
|
|
}
|
|
|
|
// if we don't have a service session JWT, it might be by service id instead, hydrate from context
|
|
rdm := self.routerDataModel.Load()
|
|
service, ok := rdm.Services.Get(token)
|
|
|
|
if !ok {
|
|
return nil, fmt.Errorf("unable to get service token either by JWT parsing or id lookup")
|
|
}
|
|
|
|
return &ServiceSessionToken{
|
|
ServiceId: service.GetId(),
|
|
ApiSessionToken: apiSessionToken,
|
|
JwtToken: nil,
|
|
Claims: nil,
|
|
}, nil
|
|
}
|
|
|
|
// ParseApiSessionJwt validates and extracts API session tokens from JWT strings,
|
|
// performing cryptographic signature verification and audience validation to
|
|
// ensure token authenticity and proper scope for Ziti network access.
|
|
func (self *ManagerImpl) ParseApiSessionJwt(jwtStr string) (*ApiSessionToken, error) {
|
|
accessClaims := &common.AccessClaims{}
|
|
jwtToken, err := jwt.ParseWithClaims(jwtStr, accessClaims, self.pubKeyLookup)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !accessClaims.HasAudience(common.ClaimAudienceHanzoZT) && !accessClaims.HasAudience(common.ClaimLegacyNative) {
|
|
return nil, fmt.Errorf("provided an api session token with invalid audience '%s' of type [%T], expected: %s or %s", accessClaims.Audience, accessClaims.Audience, common.ClaimAudienceHanzoZT, common.ClaimLegacyNative)
|
|
}
|
|
|
|
if accessClaims.Type != common.TokenTypeAccess {
|
|
return nil, fmt.Errorf("provided an api session token with invalid type '%s'", accessClaims.Type)
|
|
}
|
|
|
|
if !jwtToken.Valid {
|
|
return nil, fmt.Errorf("provided an api session token that is not valid")
|
|
}
|
|
|
|
return NewApiSessionTokenFromJwt(jwtToken, accessClaims)
|
|
}
|
|
|
|
// pubKeyLookup retrieves public keys for JWT signature verification
|
|
// from the router data model.
|
|
func (self *ManagerImpl) pubKeyLookup(token *jwt.Token) (any, error) {
|
|
kidVal, ok := token.Header["kid"]
|
|
|
|
if !ok {
|
|
return nil, errors.New("could not lookup JWT signer, kid header missing")
|
|
}
|
|
|
|
kid, ok := kidVal.(string)
|
|
|
|
if !ok {
|
|
return nil, fmt.Errorf("kid header value is not a string, got type %T", kidVal)
|
|
}
|
|
|
|
kid = strings.TrimSpace(kid)
|
|
|
|
rdm := self.routerDataModel.Load()
|
|
publicKeys := rdm.PublicKeys.IterBuffered()
|
|
for keysTuple := range publicKeys {
|
|
if contains(keysTuple.Val.Usages, edge_ctrl_pb.DataState_PublicKey_JWTValidation) {
|
|
|
|
if kid == keysTuple.Val.Kid {
|
|
return self.parsePublicKey(keysTuple.Val)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil, errors.New("public key not found")
|
|
}
|
|
|
|
// RouterDataModel returns the current router data model containing
|
|
// network topology and policy information.
|
|
func (self *ManagerImpl) RouterDataModel() *common.RouterDataModel {
|
|
return self.routerDataModel.Load()
|
|
}
|
|
|
|
// SetRouterDataModel replaces the current router data model with a new one,
|
|
// optionally resetting the controller subscription.
|
|
func (self *ManagerImpl) SetRouterDataModel(model *common.RouterDataModel, resetSubscription bool) {
|
|
self.rdmLock.Lock()
|
|
defer self.rdmLock.Unlock()
|
|
|
|
index := model.CurrentIndex()
|
|
logger := pfxlog.Logger().WithField("index", index)
|
|
|
|
publicKeys := model.PublicKeys.Items()
|
|
logger.Debugf("number of public keys in rdm: %d", len(publicKeys))
|
|
|
|
if resetSubscription {
|
|
self.dataModelSubscription.Store(DataModelSubscription{})
|
|
}
|
|
logger.Info("replacing router data model")
|
|
existing := self.routerDataModel.Load()
|
|
var existingIndex uint64
|
|
if existing != nil {
|
|
existing.Stop()
|
|
model.InheritLocalData(existing)
|
|
existingIndex = existing.CurrentIndex()
|
|
logger = logger.WithField("existingIndex", existingIndex)
|
|
}
|
|
|
|
self.routerDataModel.Store(model)
|
|
|
|
if index < existingIndex {
|
|
self.env.GetIndexWatchers().NotifyOfIndexReset()
|
|
}
|
|
|
|
model.SyncAllSubscribers()
|
|
|
|
if resetSubscription {
|
|
// notify subscription manager code to resubscribe with updated model and index
|
|
select {
|
|
case self.modelChanged <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
logger.Infof("router data model replacement complete, old: %p, new: %p", existing, model)
|
|
}
|
|
|
|
func (self *ManagerImpl) ResyncRouterDataModel() {
|
|
self.resyncRouterDataModel.Store(true)
|
|
self.dataModelSubscription.Store(DataModelSubscription{})
|
|
|
|
// notify subscription manager code to resubscribe with updated model and index
|
|
select {
|
|
case self.modelChanged <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
func (self *ManagerImpl) WithRouterDataModel(f func(rdm *common.RouterDataModel) error) error {
|
|
self.rdmLock.RLock()
|
|
defer self.rdmLock.RUnlock()
|
|
return f(self.routerDataModel.Load())
|
|
}
|
|
|
|
// MarkSyncInProgress sets the current synchronization tracker ID.
|
|
func (self *ManagerImpl) MarkSyncInProgress(trackerId string) {
|
|
self.syncLock.Lock()
|
|
defer self.syncLock.Unlock()
|
|
self.currentSync = trackerId
|
|
}
|
|
|
|
// MarkSyncStopped clears the synchronization tracker if it matches
|
|
// the provided ID.
|
|
func (self *ManagerImpl) MarkSyncStopped(trackerId string) {
|
|
self.syncLock.Lock()
|
|
defer self.syncLock.Unlock()
|
|
if self.currentSync == trackerId {
|
|
self.currentSync = ""
|
|
}
|
|
}
|
|
|
|
// IsSyncInProgress returns whether a synchronization operation
|
|
// is currently active.
|
|
func (self *ManagerImpl) IsSyncInProgress() bool {
|
|
self.syncLock.Lock()
|
|
defer self.syncLock.Unlock()
|
|
return self.currentSync != ""
|
|
}
|
|
|
|
// AddLegacyApiSession registers controller-synchronized API sessions in the legacy
|
|
// tracking store, specifically handling protobuf-based sessions that require
|
|
// controller state synchronization rather than self-contained JWT tokens.
|
|
func (self *ManagerImpl) AddLegacyApiSession(apiSessionToken *ApiSessionToken) {
|
|
logger := pfxlog.Logger().Entry
|
|
logger = apiSessionToken.AddLoggingFields(logger)
|
|
|
|
if apiSessionToken.Type == ApiSessionTokenLegacyProtobuf {
|
|
logger.Debug("adding legacy api session")
|
|
//for legacy api sessions, token is a UUID
|
|
self.legacyApiSessionsByToken.Set(apiSessionToken.Token(), apiSessionToken)
|
|
self.Emit(EventAddedApiSession, apiSessionToken)
|
|
} else {
|
|
logger.Debug("attempted to add a non-legacy api session to legacy tracking")
|
|
}
|
|
}
|
|
|
|
// UpdateLegacyApiSession refreshes controller-synchronized API session state
|
|
// for legacy sessions, maintaining backwards compatibility with pre-JWT
|
|
// authentication systems that rely on centralized session management.
|
|
func (self *ManagerImpl) UpdateLegacyApiSession(apiSessionToken *ApiSessionToken) {
|
|
logger := pfxlog.Logger().Entry
|
|
logger = apiSessionToken.AddLoggingFields(logger)
|
|
|
|
if apiSessionToken.Type == ApiSessionTokenLegacyProtobuf {
|
|
logger.Debug("update legacy api session")
|
|
|
|
self.legacyApiSessionsByToken.Set(apiSessionToken.Token(), apiSessionToken)
|
|
self.Emit(EventUpdatedApiSession, apiSessionToken)
|
|
} else {
|
|
logger.Debug("attempted to update a non-legacy api session to legacy tracking")
|
|
}
|
|
}
|
|
|
|
// RemoveLegacyApiSession removes controller-synchronized API sessions from
|
|
// legacy tracking stores, handling cleanup for sessions that originated from
|
|
// pre-JWT authentication systems requiring centralized state management.
|
|
func (self *ManagerImpl) RemoveLegacyApiSession(apiSessionToken *ApiSessionToken) {
|
|
logger := pfxlog.Logger().Entry
|
|
logger = apiSessionToken.AddLoggingFields(logger)
|
|
|
|
if ns, ok := self.legacyApiSessionsByToken.Get(apiSessionToken.Token()); ok {
|
|
logger.Debug("removing legacy api session")
|
|
self.legacyApiSessionsByToken.Remove(apiSessionToken.Token())
|
|
eventName := apiSessionToken.RemovedEventName()
|
|
self.Emit(eventName)
|
|
self.RemoveAllListeners(eventName)
|
|
self.Emit(EventRemovedApiSession, ns)
|
|
} else {
|
|
logger.Debug("could not remove legacy api session, not found")
|
|
}
|
|
}
|
|
|
|
// RemoveMissingApiSessions removes API Sessions not present in the knownApiSessions argument. If the beforeSessionId
|
|
// value is not empty string, it will be used as a monotonic comparison between it and API session ids. API session ids
|
|
// later than the sync will be ignored.
|
|
// RemoveMissingApiSessions reconciles router session state with controller state
|
|
// by removing sessions not present in the authoritative controller list. The
|
|
// beforeSessionId parameter enables monotonic comparison to avoid removing sessions
|
|
// created after the synchronization snapshot, preventing race conditions during
|
|
// high-frequency session creation scenarios.
|
|
func (self *ManagerImpl) RemoveMissingApiSessions(knownApiSessions []*ApiSessionToken, beforeSessionId string) {
|
|
validTokens := map[string]bool{}
|
|
for _, apiSession := range knownApiSessions {
|
|
validTokens[apiSession.Token()] = true
|
|
}
|
|
|
|
var tokensToRemove []*ApiSessionToken
|
|
self.legacyApiSessionsByToken.IterCb(func(token string, apiSessionToken *ApiSessionToken) {
|
|
if _, ok := validTokens[token]; !ok && (beforeSessionId == "" || apiSessionToken.Id <= beforeSessionId) {
|
|
tokensToRemove = append(tokensToRemove, apiSessionToken)
|
|
}
|
|
})
|
|
|
|
for _, token := range tokensToRemove {
|
|
self.RemoveLegacyApiSession(token)
|
|
}
|
|
}
|
|
|
|
// RemoveLegacyServiceSession handles the controlled termination of service sessions
|
|
// that have been invalidated by controller policy changes or expiration. This function
|
|
// not only removes the session from tracking but also proactively closes all network
|
|
// connections using the session, ensuring clients receive immediate notification rather
|
|
// than encountering authorization failures on subsequent requests.
|
|
func (self *ManagerImpl) RemoveLegacyServiceSession(serviceSessionToken *ServiceSessionToken) {
|
|
logger := pfxlog.Logger().Entry
|
|
logger = serviceSessionToken.AddLoggingFields(logger)
|
|
|
|
logger.Debug("removing network session")
|
|
|
|
eventName := serviceSessionToken.RemovedEventName()
|
|
self.Emit(eventName)
|
|
self.RemoveAllListeners(eventName)
|
|
|
|
activeChannels := self.connectionTracker.GetChannelsByIdentityId(serviceSessionToken.Claims.IdentityId)
|
|
|
|
for _, activeChannel := range activeChannels {
|
|
edgeConn, connIdToSink := GetConnProviderAndSinksFromCh(activeChannel)
|
|
|
|
for connId, sink := range connIdToSink {
|
|
if serviceSessionToken.TokenId() == sink.GetData().ServiceSessionToken.TokenId() {
|
|
err := edgeConn.CloseConn(connId, fmt.Sprintf("closing connId %d, legacy service session was removed by controller sync", connId))
|
|
|
|
if err != nil {
|
|
logger.WithError(err).WithField("connId", connId).Warnf("failed to close conn")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
self.recentlyRemovedSessions.Set(serviceSessionToken.TokenId(), time.Now())
|
|
}
|
|
|
|
// GetApiSessionTokenWithTimeout implements eventual consistency for session lookups
|
|
// during the window between session creation notification and local cache population.
|
|
// This addresses race conditions where clients attempt to use newly created sessions
|
|
// before synchronization completes, using exponential backoff to balance responsiveness
|
|
// with system load during high session creation rates.
|
|
func (self *ManagerImpl) GetApiSessionTokenWithTimeout(token string, timeout time.Duration) *ApiSessionToken {
|
|
deadline := time.Now().Add(timeout)
|
|
session := self.GetApiSessionToken(token)
|
|
|
|
if session == nil {
|
|
//convert this to return a channel instead of sleeping
|
|
waitTime := time.Millisecond
|
|
for time.Now().Before(deadline) {
|
|
session = self.GetApiSessionToken(token)
|
|
if session != nil {
|
|
return session
|
|
}
|
|
time.Sleep(waitTime)
|
|
if waitTime < time.Second {
|
|
waitTime = waitTime * 2
|
|
}
|
|
}
|
|
}
|
|
return session
|
|
}
|
|
|
|
// GetApiSessionToken retrieves API session tokens from either JWT strings or
|
|
// legacy token lookups, abstracting the underlying token format from callers.
|
|
func (self *ManagerImpl) GetApiSessionToken(token string) *ApiSessionToken {
|
|
if strings.HasPrefix(token, oidc_auth.JwtTokenPrefix) {
|
|
apiSessionToken, err := self.ParseApiSessionJwt(token)
|
|
|
|
if err != nil {
|
|
pfxlog.Logger().WithError(err).Error("failed to create api session from JWT")
|
|
return nil
|
|
}
|
|
return apiSessionToken
|
|
}
|
|
|
|
if apiSession, ok := self.legacyApiSessionsByToken.Get(token); ok {
|
|
return apiSession
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// WasLegacyServiceSessionRecentlyRemoved prevents spurious connection attempts
|
|
// by tracking recently invalidated sessions. This addresses the distributed systems
|
|
// challenge where session removal notifications may arrive out-of-order or be delayed,
|
|
// allowing the router to provide immediate feedback rather than expensive controller
|
|
// round-trips for sessions known to be invalid.
|
|
func (self *ManagerImpl) WasLegacyServiceSessionRecentlyRemoved(token string) bool {
|
|
return self.recentlyRemovedSessions.Has(token)
|
|
}
|
|
|
|
// MarkLegacyServiceSessionRecentlyRemoved adds session invalidation timestamps
|
|
// to enable fast-path rejection of connection attempts using recently removed
|
|
// sessions. This optimization reduces controller query load and improves client
|
|
// error response times during session cleanup scenarios.
|
|
func (self *ManagerImpl) MarkLegacyServiceSessionRecentlyRemoved(token string) {
|
|
self.recentlyRemovedSessions.Set(token, time.Now())
|
|
}
|
|
|
|
// AddLegacyServiceSessionRemovedListener enables connection cleanup coordination
|
|
// by notifying components when service sessions become invalid. This event-driven
|
|
// architecture prevents connections from persisting with stale authorization,
|
|
// immediately triggering cleanup callbacks when controller synchronization
|
|
// indicates session removal.
|
|
func (self *ManagerImpl) AddLegacyServiceSessionRemovedListener(serviceSessionToken *ServiceSessionToken, callBack func(serviceSessionToken *ServiceSessionToken)) RemoveListener {
|
|
// only legacy service sessions will emit these events as newer controllers use JWTs or raw service ids and
|
|
// do not rely on service session syncs from the controller
|
|
if !serviceSessionToken.Claims.IsLegacy {
|
|
return func() {}
|
|
}
|
|
|
|
// service session has already been removed, immediately trigger callback
|
|
if self.recentlyRemovedSessions.Has(serviceSessionToken.TokenId()) {
|
|
go callBack(serviceSessionToken)
|
|
return func() {}
|
|
}
|
|
|
|
eventName := serviceSessionToken.RemovedEventName()
|
|
|
|
listener := func(args ...interface{}) {
|
|
go callBack(serviceSessionToken)
|
|
}
|
|
self.AddListener(eventName, listener)
|
|
|
|
once := &sync.Once{}
|
|
return func() {
|
|
once.Do(func() {
|
|
go self.RemoveListener(eventName, listener) // likely to be called from Emit, which will cause a deadlock
|
|
})
|
|
}
|
|
}
|
|
|
|
// AddApiSessionRemovedListener provides event notification for API session
|
|
// invalidation, enabling dependent resources to perform coordinated cleanup.
|
|
// This decouples session lifecycle management from connection handling logic.
|
|
func (self *ManagerImpl) AddApiSessionRemovedListener(apiSessionToken *ApiSessionToken, callBack func(apiSessionToken *ApiSessionToken)) RemoveListener {
|
|
eventName := apiSessionToken.RemovedEventName()
|
|
listener := func(args ...interface{}) {
|
|
callBack(apiSessionToken)
|
|
}
|
|
self.AddListener(eventName, listener)
|
|
|
|
return func() {
|
|
go self.RemoveListener(eventName, listener) // likely to be called from Emit, which will cause a deadlock
|
|
}
|
|
}
|
|
|
|
// StartHeartbeat initiates periodic transmission of active legacy session tokens
|
|
// to controllers, enabling distributed session state synchronization. This mechanism
|
|
// allows controllers to detect router failures and perform session cleanup, while
|
|
// also serving as a distributed liveness check for the router infrastructure.
|
|
func (self *ManagerImpl) StartHeartbeat(env env.RouterEnv, intervalSeconds int, closeNotify <-chan struct{}) {
|
|
self.heartbeatOperation = newHeartbeatOperation(env, time.Duration(intervalSeconds)*time.Second, self)
|
|
|
|
var err error
|
|
self.heartbeatRunner, err = runner.NewRunner(1*time.Second, 24*time.Hour, func(e error, operation runner.Operation) {
|
|
pfxlog.Logger().WithError(err).Error("error during heartbeat runner")
|
|
})
|
|
|
|
if err != nil {
|
|
pfxlog.Logger().WithError(err).Panic("could not create heartbeat runner")
|
|
}
|
|
|
|
if err := self.heartbeatRunner.AddOperation(self.heartbeatOperation); err != nil {
|
|
pfxlog.Logger().WithError(err).Panic("could not add heartbeat operation to runner")
|
|
}
|
|
|
|
if err := self.heartbeatRunner.Start(closeNotify); err != nil {
|
|
pfxlog.Logger().WithError(err).Panic("could not start heartbeat runner")
|
|
}
|
|
|
|
pfxlog.Logger().Info("heartbeat starting")
|
|
}
|
|
|
|
// ActiveServiceSessionTokens gathers all service session tokens from active
|
|
// connections, enabling session validation and cleanup operations across
|
|
// the router's connection pool.
|
|
func (self *ManagerImpl) ActiveServiceSessionTokens() []*ServiceSessionToken {
|
|
identityToChannels := self.connectionTracker.GetChannels()
|
|
|
|
activeTokens := map[string]*ServiceSessionToken{}
|
|
|
|
for _, channels := range identityToChannels {
|
|
for _, ch := range channels {
|
|
if !ch.IsClosed() {
|
|
_, sinks := GetConnProviderAndSinksFromCh(ch)
|
|
|
|
for _, sink := range sinks {
|
|
connState := sink.GetData()
|
|
|
|
if connState != nil && connState.ServiceSessionToken != nil {
|
|
activeTokens[connState.ServiceSessionToken.TokenId()] = connState.ServiceSessionToken
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
result := make([]*ServiceSessionToken, 0, len(activeTokens))
|
|
for _, serviceSessionToken := range activeTokens {
|
|
result = append(result, serviceSessionToken)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// ActiveApiSessionTokens collects all API session tokens currently in use
|
|
// across active client connections, providing visibility into live sessions
|
|
// for heartbeat and synchronization operations.
|
|
func (self *ManagerImpl) ActiveApiSessionTokens() []*ApiSessionToken {
|
|
identityToChannels := self.connectionTracker.GetChannels()
|
|
|
|
activeTokens := map[string]*ApiSessionToken{}
|
|
|
|
for _, channels := range identityToChannels {
|
|
for _, ch := range channels {
|
|
if !ch.IsClosed() {
|
|
apiSessionTokenProvider := GetApiSessionTokenProviderFromCh(ch)
|
|
|
|
if apiSessionTokenProvider != nil {
|
|
apiSessionToken := apiSessionTokenProvider.GetApiSessionToken()
|
|
|
|
if apiSessionToken != nil {
|
|
activeTokens[apiSessionToken.Id] = apiSessionToken
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
result := make([]*ApiSessionToken, 0, len(activeTokens))
|
|
for _, apiSessionToken := range activeTokens {
|
|
result = append(result, apiSessionToken)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// flushRecentlyRemoved cleans up expired entries from the recently
|
|
// removed sessions tracking cache.
|
|
func (self *ManagerImpl) flushRecentlyRemoved() {
|
|
now := time.Now()
|
|
var toRemove []string
|
|
self.recentlyRemovedSessions.IterCb(func(key string, t time.Time) {
|
|
if now.Sub(t) >= 5*time.Minute {
|
|
toRemove = append(toRemove, key)
|
|
}
|
|
})
|
|
|
|
for _, key := range toRemove {
|
|
self.recentlyRemovedSessions.Remove(key)
|
|
}
|
|
}
|
|
|
|
// DumpApiSessions provides diagnostic output of all tracked API sessions
|
|
// for operational debugging and system health monitoring. The implementation
|
|
// includes timeout protection to prevent blocking during high session volumes.
|
|
func (self *ManagerImpl) DumpApiSessions(c *bufio.ReadWriter) error {
|
|
ch := make(chan string, 15)
|
|
|
|
go func() {
|
|
defer close(ch)
|
|
i := 0
|
|
deadline := time.After(time.Second)
|
|
timedOut := false
|
|
|
|
for _, session := range self.legacyApiSessionsByToken.Items() {
|
|
i++
|
|
val := fmt.Sprintf("%v: id: %v, token: %v\n", i, session.Id, session.Token())
|
|
select {
|
|
case ch <- val:
|
|
case <-deadline:
|
|
timedOut = true
|
|
}
|
|
if timedOut {
|
|
break
|
|
}
|
|
if i%10000 == 0 {
|
|
// allow a second to dump each 10k entries
|
|
deadline = time.After(time.Second)
|
|
}
|
|
|
|
}
|
|
|
|
if timedOut {
|
|
select {
|
|
case ch <- "timed out":
|
|
case <-time.After(time.Second):
|
|
}
|
|
}
|
|
}()
|
|
|
|
for val := range ch {
|
|
if _, err := c.WriteString(val); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return c.Flush()
|
|
}
|
|
|
|
// ValidateSessions performs batch validation of active service sessions against
|
|
// controller state, enabling detection of sessions that have been revoked or expired.
|
|
// The chunked approach with randomized intervals prevents thundering herd effects
|
|
// while maintaining reasonable validation latency for large session volumes.
|
|
func (self *ManagerImpl) ValidateSessions(ch channel.Channel, chunkSize uint32, minInterval, maxInterval time.Duration) {
|
|
|
|
sessionTokens := self.ActiveServiceSessionTokens()
|
|
|
|
legacySessionTokens := make([]*ServiceSessionToken, 0, len(sessionTokens))
|
|
for _, sessionToken := range sessionTokens {
|
|
if ok, _ := sessionToken.IsLegacyApiSession(); ok {
|
|
legacySessionTokens = append(legacySessionTokens, sessionToken)
|
|
}
|
|
}
|
|
|
|
for len(legacySessionTokens) > 0 {
|
|
var chunk []*ServiceSessionToken
|
|
|
|
if len(legacySessionTokens) > int(chunkSize) {
|
|
chunk = legacySessionTokens[:chunkSize]
|
|
legacySessionTokens = legacySessionTokens[chunkSize:]
|
|
} else {
|
|
chunk = legacySessionTokens
|
|
legacySessionTokens = nil
|
|
}
|
|
tokens := make([]string, 0, len(chunk))
|
|
for _, sessionToken := range chunk {
|
|
tokens = append(tokens, sessionToken.TokenId())
|
|
}
|
|
|
|
request := &edge_ctrl_pb.ValidateSessionsRequest{
|
|
SessionTokens: tokens,
|
|
}
|
|
|
|
logrus.Debugf("validating edge sessions: %v", chunk)
|
|
|
|
body, err := proto.Marshal(request)
|
|
if err != nil {
|
|
logrus.WithError(err).Error("failed to marshal validate sessions request")
|
|
return
|
|
}
|
|
|
|
msg := channel.NewMessage(request.GetContentType(), body)
|
|
if err := ch.Send(msg); err != nil {
|
|
logrus.WithError(err).Error("failed to send validate sessions request")
|
|
return
|
|
}
|
|
|
|
if len(legacySessionTokens) > 0 {
|
|
interval := minInterval
|
|
if minInterval < maxInterval {
|
|
/* #nosec */
|
|
delta := rand.Int63n(int64(maxInterval - minInterval))
|
|
interval += minInterval + time.Duration(delta)
|
|
}
|
|
time.Sleep(interval)
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
// parsePublicKey converts protobuf public key data into Go crypto types
|
|
// based on the specified format.
|
|
func (self *ManagerImpl) parsePublicKey(publicKey *edge_ctrl_pb.DataState_PublicKey) (crypto.PublicKey, error) {
|
|
switch publicKey.Format {
|
|
case edge_ctrl_pb.DataState_PublicKey_X509CertDer:
|
|
certs, err := x509.ParseCertificates(publicKey.Data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(certs) == 0 {
|
|
return nil, errors.New("could not parse certificates, der was empty")
|
|
}
|
|
|
|
return certs[0].PublicKey, nil
|
|
case edge_ctrl_pb.DataState_PublicKey_PKIXPublicKey:
|
|
return x509.ParsePKIXPublicKey(publicKey.Data)
|
|
}
|
|
|
|
return nil, fmt.Errorf("unsupported public key format: %s", publicKey.Format.String())
|
|
}
|
|
|
|
// LoadConfig satisfies the configuration interface but performs no operations
|
|
// as the state manager's configuration is handled during initialization.
|
|
func (self *ManagerImpl) LoadConfig(_ map[interface{}]interface{}) error {
|
|
return nil
|
|
}
|
|
|
|
// BindChannel registers message handlers for controller communication protocols,
|
|
// establishing the router's ability to receive session updates, data model changes,
|
|
// and other control plane messages essential for distributed operation.
|
|
func (self *ManagerImpl) BindChannel(binding channel.Binding) error {
|
|
binding.AddTypedReceiveHandler(NewHelloHandler(self, self.env.GetConfig().Edge.EdgeListeners))
|
|
binding.AddTypedReceiveHandler(NewExtendEnrollmentCertsHandler(self.env))
|
|
|
|
binding.AddTypedReceiveHandler(NewSessionRemovedHandler(self))
|
|
binding.AddTypedReceiveHandler(NewApiSessionAddedHandler(self, binding))
|
|
binding.AddTypedReceiveHandler(NewApiSessionRemovedHandler(self))
|
|
binding.AddTypedReceiveHandler(NewApiSessionUpdatedHandler(self))
|
|
binding.AddTypedReceiveHandler(NewDataStateHandler(self))
|
|
binding.AddTypedReceiveHandler(NewDataStateEventHandler(self))
|
|
binding.AddTypedReceiveHandler(NewValidateDataStateRequestHandler(self, self.env))
|
|
return nil
|
|
}
|
|
|
|
// Enabled indicates this component is always active in router operation.
|
|
func (self *ManagerImpl) Enabled() bool {
|
|
return true
|
|
}
|
|
|
|
// Run satisfies the component interface but performs no ongoing operations
|
|
// as state management is event-driven rather than polling-based.
|
|
func (self *ManagerImpl) Run(env.RouterEnv) error {
|
|
return nil
|
|
}
|
|
|
|
// NotifyOfReconnect handles controller reconnection events but currently
|
|
// performs no specific actions as session resynchronization is handled
|
|
// through other mechanisms.
|
|
func (self *ManagerImpl) NotifyOfReconnect(_ channel.Channel) {
|
|
}
|
|
|
|
// GetTraceDecoders returns message decoders for debugging and monitoring
|
|
// purposes, currently returning nil as no specialized tracing is implemented.
|
|
func (self *ManagerImpl) GetTraceDecoders() []channel.TraceMessageDecoder {
|
|
return nil
|
|
}
|
|
|
|
// startConnectionVerification periodically verifies active connections for validity, running until the manager is closed.
|
|
func (sm *ManagerImpl) startConnectionVerification(resolution time.Duration) {
|
|
|
|
//todo: we can be much smarter about this if we have scale concerns. We can bucket and create workers.
|
|
// We could also store lowest next expiration as connections are being made and only tick then.
|
|
// We could also use per connection context deadlines (internal timers).
|
|
// This should be good <100k connections.
|
|
for {
|
|
select {
|
|
case <-time.After(resolution):
|
|
sm.CheckConnections()
|
|
case <-sm.env.GetCloseNotify():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// CheckConnections iterates over tracked channels and closes connections with expired or invalid API session tokens.
|
|
// This only inspections connections backed by JWTs. It will end connections that have no inspectable API Session.
|
|
func (sm *ManagerImpl) CheckConnections() {
|
|
connections := sm.connectionTracker.GetChannels()
|
|
|
|
now := time.Now()
|
|
for _, channels := range connections {
|
|
for _, ch := range channels {
|
|
if !ch.IsClosed() {
|
|
apiSessionTokenProvider := GetApiSessionTokenProviderFromCh(ch)
|
|
|
|
if apiSessionTokenProvider == nil {
|
|
_ = ch.Close()
|
|
continue
|
|
}
|
|
|
|
apiSession := apiSessionTokenProvider.GetApiSessionToken()
|
|
|
|
if apiSession == nil {
|
|
_ = ch.Close()
|
|
continue
|
|
}
|
|
|
|
if apiSession.Type == ApiSessionTokenJwt {
|
|
if apiSession.Claims == nil {
|
|
_ = ch.Close()
|
|
continue
|
|
}
|
|
|
|
exp := apiSession.Claims.Expiration
|
|
|
|
if exp.AsTime().Before(now) {
|
|
_ = ch.Close()
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// GetConnProviderAndSinksFromCh extracts connection provider and sinks
|
|
// from channel user data for connection management operations.
|
|
func GetConnProviderAndSinksFromCh(ch channel.Channel) (ConnProvider, map[uint32]edge.MsgSink[*ConnState]) {
|
|
userData := ch.GetUserData()
|
|
|
|
if userData != nil {
|
|
edgeConn := userData.(ConnProvider)
|
|
connIdToSink := edgeConn.GetConnIdToSinks()
|
|
return edgeConn, connIdToSink
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// GetApiSessionTokenProviderFromCh extracts the API session token provider
|
|
// from channel user data.
|
|
func GetApiSessionTokenProviderFromCh(ch channel.Channel) ApiSessionTokenProvider {
|
|
userData := ch.GetUserData()
|
|
if userData != nil {
|
|
edgeConn := userData.(ApiSessionTokenProvider)
|
|
return edgeConn
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetApiSessionTokenFromCh retrieves the API session token associated
|
|
// with a channel.
|
|
func GetApiSessionTokenFromCh(ch channel.Channel) *ApiSessionToken {
|
|
tokenProvider := GetApiSessionTokenProviderFromCh(ch)
|
|
|
|
if tokenProvider == nil {
|
|
return nil
|
|
}
|
|
|
|
return tokenProvider.GetApiSessionToken()
|
|
}
|