mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-10 12:06:41 -04:00
A user in the Pending Approval state could complete SSO and reach any SSO-protected reverse proxy service distributed to a group they belong to, including the All Users group. The reverse proxy authorization path checked the session token signature, that the user exists, that the user's account matches the service's account, and group membership — never the user's account status. The REST API (`permissions/manager.go`) and peer registration both gate on that state, but the proxy gRPC service does not go through the permissions manager, so neither gate applied. A pending user is persisted as blocked and pending approval, so blocked users reached those services the same way. `ValidateSession` now denies on account status, reporting `pending_approval` or `user_blocked` so the proxy access log and the denied page carry the cause rather than a generic refusal. `GenerateSessionToken` refuses to mint a token for such a user at all, so the browser never receives a session cookie and the OIDC callback can tell the user why instead of showing "Service configuration error". `ValidateUserGroupAccess` and `ValidateTunnelPeer` close the same gap; for the tunnel path this covers a user blocked after their peer was registered, since peer group membership alone kept mesh-origin access open. A single helper produces both the denied reason for the RPC responses and the sentinel error for the error-returning callers, so the four entry points cannot drift apart. A user the store cannot resolve is denied rather than passed through. One thing deliberately left out: session cookies are validated locally by the proxy against the service public key with no management round-trip, so a cookie issued before a user is blocked stays valid until it expires (24h by default). That is a revocation-propagation problem rather than this authorization gap, and every option for it (per-request validation with a cache, short-lived tokens with refresh, push-based revocation) changes the proxy hot path or the proxy/management protocol. Worth its own ticket.
2172 lines
75 KiB
Go
2172 lines
75 KiB
Go
package grpc
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/coreos/go-oidc/v3/oidc"
|
|
"github.com/google/uuid"
|
|
log "github.com/sirupsen/logrus"
|
|
"golang.org/x/oauth2"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
|
|
"github.com/netbirdio/netbird/shared/management/domain"
|
|
|
|
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
|
"github.com/netbirdio/netbird/management/internals/modules/peers"
|
|
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
|
|
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
|
|
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
|
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey"
|
|
"github.com/netbirdio/netbird/management/server/idp"
|
|
"github.com/netbirdio/netbird/management/server/peer"
|
|
"github.com/netbirdio/netbird/management/server/types"
|
|
"github.com/netbirdio/netbird/management/server/users"
|
|
proxyauth "github.com/netbirdio/netbird/proxy/auth"
|
|
"github.com/netbirdio/netbird/shared/hash/argon2id"
|
|
"github.com/netbirdio/netbird/shared/management/proto"
|
|
nbstatus "github.com/netbirdio/netbird/shared/management/status"
|
|
)
|
|
|
|
type ProxyOIDCConfig struct {
|
|
Issuer string
|
|
ClientID string
|
|
Scopes []string
|
|
CallbackURL string
|
|
HMACKey []byte
|
|
|
|
Audience string
|
|
KeysLocation string
|
|
}
|
|
|
|
// ProxyTokenChecker checks whether a proxy access token is still valid.
|
|
type ProxyTokenChecker interface {
|
|
IsProxyAccessTokenValid(ctx context.Context, tokenID string) (bool, error)
|
|
}
|
|
|
|
// ProxyServiceServer implements the ProxyService gRPC server
|
|
// AgentNetworkSynthesizer produces in-memory reverse-proxy services from
|
|
// Agent Network provider/policy state for the proxy snapshot path; synthesised
|
|
// services never appear in the reverseproxy_services table.
|
|
type AgentNetworkSynthesizer interface {
|
|
SynthesizeServicesForCluster(ctx context.Context, clusterAddr string) ([]*rpservice.Service, error)
|
|
SynthesizeServicesForAccount(ctx context.Context, accountID string) ([]*rpservice.Service, error)
|
|
SynthesizeServiceForDomain(ctx context.Context, domain string) (*rpservice.Service, error)
|
|
}
|
|
|
|
// AgentNetworkLimitsService is the minimal slice of agentnetwork.Manager the
|
|
// gRPC layer needs for CheckLLMPolicyLimits + RecordLLMUsage — kept narrow so
|
|
// the grpc package doesn't take a hard import on the full manager.
|
|
type AgentNetworkLimitsService interface {
|
|
SelectPolicyForRequest(ctx context.Context, in agentnetwork.PolicySelectionInput) (*agentnetwork.PolicySelectionResult, error)
|
|
RecordUsage(ctx context.Context, in agentnetwork.RecordUsageInput) error
|
|
}
|
|
|
|
type ProxyServiceServer struct {
|
|
proto.UnimplementedProxyServiceServer
|
|
|
|
// Map of connected proxies: proxy_id -> proxy connection
|
|
connectedProxies sync.Map
|
|
|
|
// Manager for access logs
|
|
accessLogManager accesslogs.Manager
|
|
|
|
mu sync.RWMutex
|
|
// Manager for reverse proxy operations
|
|
serviceManager rpservice.Manager
|
|
// agentNetworkSynth produces synthesised reverse-proxy services from
|
|
// Agent Network state. Optional — when nil the snapshot path only ships
|
|
// persisted services.
|
|
agentNetworkSynth AgentNetworkSynthesizer
|
|
// agentNetworkLimits handles the pre-flight selection (CheckLLMPolicyLimits)
|
|
// and the post-flight consumption write (RecordLLMUsage). Optional — when
|
|
// nil both RPCs return Unimplemented.
|
|
agentNetworkLimits AgentNetworkLimitsService
|
|
// ProxyController for service updates and cluster management
|
|
proxyController proxy.Controller
|
|
|
|
// Manager for proxy connections
|
|
proxyManager proxy.Manager
|
|
|
|
// Manager for peers
|
|
peersManager peers.Manager
|
|
|
|
// Manager for users
|
|
usersManager users.Manager
|
|
|
|
// Manager for IdP-enriched user data (may be nil when no IdP is configured)
|
|
idpManager idp.Manager
|
|
|
|
// Store for one-time authentication tokens
|
|
tokenStore *OneTimeTokenStore
|
|
|
|
// Checker for proxy access token validity
|
|
tokenChecker ProxyTokenChecker
|
|
|
|
// OIDC configuration for proxy authentication
|
|
oidcConfig ProxyOIDCConfig
|
|
|
|
// Store for PKCE verifiers
|
|
pkceVerifierStore *PKCEVerifierStore
|
|
|
|
// tokenTTL is the lifetime of one-time tokens generated for proxy
|
|
// authentication. Defaults to defaultProxyTokenTTL when zero.
|
|
tokenTTL time.Duration
|
|
|
|
// snapshotBatchSize is the number of mappings per gRPC message during
|
|
// initial snapshot delivery. Configurable via NB_PROXY_SNAPSHOT_BATCH_SIZE.
|
|
snapshotBatchSize int
|
|
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
const pkceVerifierTTL = 10 * time.Minute
|
|
|
|
const defaultProxyTokenTTL = 5 * time.Minute
|
|
|
|
const defaultSnapshotBatchSize = 500
|
|
|
|
func snapshotBatchSizeFromEnv() int {
|
|
if v := os.Getenv("NB_PROXY_SNAPSHOT_BATCH_SIZE"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
|
return n
|
|
}
|
|
}
|
|
return defaultSnapshotBatchSize
|
|
}
|
|
|
|
// proxyTokenTTL returns the configured token TTL or the default when unset.
|
|
func (s *ProxyServiceServer) proxyTokenTTL() time.Duration {
|
|
if s.tokenTTL > 0 {
|
|
return s.tokenTTL
|
|
}
|
|
return defaultProxyTokenTTL
|
|
}
|
|
|
|
// proxyConnection represents a connected proxy
|
|
type proxyConnection struct {
|
|
proxyID string
|
|
sessionID string
|
|
address string
|
|
accountID *string
|
|
tokenID string
|
|
capabilities *proto.ProxyCapabilities
|
|
stream proto.ProxyService_GetMappingUpdateServer
|
|
// syncStream is set when the proxy connected via SyncMappings.
|
|
// When non-nil, the sender goroutine uses this instead of stream.
|
|
syncStream proto.ProxyService_SyncMappingsServer
|
|
sendChan chan *proto.GetMappingUpdateResponse
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
func enforceAccountScope(ctx context.Context, requestAccountID string) error {
|
|
token := GetProxyTokenFromContext(ctx)
|
|
if token == nil || token.AccountID == nil {
|
|
return nil
|
|
}
|
|
if requestAccountID == "" || *token.AccountID != requestAccountID {
|
|
return status.Errorf(codes.PermissionDenied, "account-scoped token cannot access account %s", requestAccountID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// NewProxyServiceServer creates a new proxy service server.
|
|
func NewProxyServiceServer(accessLogMgr accesslogs.Manager, tokenStore *OneTimeTokenStore, pkceStore *PKCEVerifierStore, oidcConfig ProxyOIDCConfig, peersManager peers.Manager, usersManager users.Manager, idpManager idp.Manager, proxyMgr proxy.Manager, tokenChecker ProxyTokenChecker) *ProxyServiceServer {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
s := &ProxyServiceServer{
|
|
accessLogManager: accessLogMgr,
|
|
oidcConfig: oidcConfig,
|
|
tokenStore: tokenStore,
|
|
pkceVerifierStore: pkceStore,
|
|
peersManager: peersManager,
|
|
usersManager: usersManager,
|
|
idpManager: idpManager,
|
|
proxyManager: proxyMgr,
|
|
tokenChecker: tokenChecker,
|
|
snapshotBatchSize: snapshotBatchSizeFromEnv(),
|
|
cancel: cancel,
|
|
}
|
|
go s.cleanupStaleProxies(ctx)
|
|
return s
|
|
}
|
|
|
|
// cleanupStaleProxies periodically removes proxies that haven't sent heartbeat in 10 minutes
|
|
func (s *ProxyServiceServer) cleanupStaleProxies(ctx context.Context) {
|
|
ticker := time.NewTicker(5 * time.Minute)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
if err := s.proxyManager.CleanupStale(ctx, 1*time.Hour); err != nil {
|
|
log.WithContext(ctx).Debugf("Failed to cleanup stale proxies: %v", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Close stops background goroutines.
|
|
func (s *ProxyServiceServer) Close() {
|
|
s.cancel()
|
|
}
|
|
|
|
// SetServiceManager sets the service manager. Must be called before serving.
|
|
func (s *ProxyServiceServer) SetServiceManager(manager rpservice.Manager) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.serviceManager = manager
|
|
}
|
|
|
|
// SetAgentNetworkSynthesizer wires the agent-network service synthesiser.
|
|
// Optional — when nil the snapshot path skips agent-network synthesis. The
|
|
// modules layer injects this after both the proxy server and the agent-network
|
|
// manager are constructed.
|
|
func (s *ProxyServiceServer) SetAgentNetworkSynthesizer(synth AgentNetworkSynthesizer) {
|
|
s.mu.Lock()
|
|
s.agentNetworkSynth = synth
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// SetAgentNetworkLimitsService wires the policy-selection + post-flight
|
|
// consumption sink. Pass nil to disable; both RPCs return Unimplemented while
|
|
// unset so partial wiring surfaces during integration.
|
|
func (s *ProxyServiceServer) SetAgentNetworkLimitsService(svc AgentNetworkLimitsService) {
|
|
s.mu.Lock()
|
|
s.agentNetworkLimits = svc
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// agentNetworkSynthesizer returns the synthesiser under read lock.
|
|
func (s *ProxyServiceServer) agentNetworkSynthesizer() AgentNetworkSynthesizer {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.agentNetworkSynth
|
|
}
|
|
|
|
// CheckLLMPolicyLimits is the pre-flight policy gate the proxy calls before
|
|
// forwarding an LLM request upstream. Delegates to the agent-network selector,
|
|
// which scores applicable policies by remaining headroom and returns the
|
|
// policy that pays for this request (or a deny when all are exhausted).
|
|
func (s *ProxyServiceServer) CheckLLMPolicyLimits(ctx context.Context, req *proto.CheckLLMPolicyLimitsRequest) (*proto.CheckLLMPolicyLimitsResponse, error) {
|
|
s.mu.RLock()
|
|
svc := s.agentNetworkLimits
|
|
s.mu.RUnlock()
|
|
if svc == nil {
|
|
return nil, status.Errorf(codes.Unimplemented, "agent-network limits service not configured on management")
|
|
}
|
|
if req.GetAccountId() == "" {
|
|
return nil, status.Errorf(codes.InvalidArgument, "account_id is required")
|
|
}
|
|
if err := enforceAccountScope(ctx, req.GetAccountId()); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
res, err := svc.SelectPolicyForRequest(ctx, agentnetwork.PolicySelectionInput{
|
|
AccountID: req.GetAccountId(),
|
|
UserID: req.GetUserId(),
|
|
GroupIDs: req.GetGroupIds(),
|
|
ProviderID: req.GetProviderId(),
|
|
Model: req.GetModel(),
|
|
})
|
|
if err != nil {
|
|
log.WithContext(ctx).Errorf("select policy for request: %v", err)
|
|
return nil, status.Error(codes.Internal, "select policy failed")
|
|
}
|
|
|
|
if !res.Allow {
|
|
return &proto.CheckLLMPolicyLimitsResponse{
|
|
Decision: "deny",
|
|
SelectedPolicyId: res.SelectedPolicyID,
|
|
AttributionGroupId: res.AttributionGroupID,
|
|
WindowSeconds: res.WindowSeconds,
|
|
DenyCode: res.DenyCode,
|
|
DenyReason: res.DenyReason,
|
|
}, nil
|
|
}
|
|
return &proto.CheckLLMPolicyLimitsResponse{
|
|
Decision: "allow",
|
|
SelectedPolicyId: res.SelectedPolicyID,
|
|
AttributionGroupId: res.AttributionGroupID,
|
|
WindowSeconds: res.WindowSeconds,
|
|
}, nil
|
|
}
|
|
|
|
// RecordLLMUsage increments the per-(dimension, window) consumption counter for
|
|
// the user and optional attribution group after a served request. Returns
|
|
// Unimplemented when the agent-network limits service hasn't been wired.
|
|
func (s *ProxyServiceServer) RecordLLMUsage(ctx context.Context, req *proto.RecordLLMUsageRequest) (*proto.RecordLLMUsageResponse, error) {
|
|
s.mu.RLock()
|
|
svc := s.agentNetworkLimits
|
|
s.mu.RUnlock()
|
|
if svc == nil {
|
|
return nil, status.Errorf(codes.Unimplemented, "agent-network limits service not configured on management")
|
|
}
|
|
|
|
accountID := req.GetAccountId()
|
|
if accountID == "" {
|
|
return nil, status.Errorf(codes.InvalidArgument, "account_id is required")
|
|
}
|
|
if err := enforceAccountScope(ctx, accountID); err != nil {
|
|
return nil, err
|
|
}
|
|
tokensIn := req.GetTokensInput()
|
|
tokensOut := req.GetTokensOutput()
|
|
costUSD := req.GetCostUsd()
|
|
|
|
// Reject impossible counters at the boundary instead of recording them:
|
|
// a negative window, negative tokens, or a negative / non-finite cost
|
|
// would otherwise decrement or poison the persisted consumption totals.
|
|
if req.GetWindowSeconds() < 0 || tokensIn < 0 || tokensOut < 0 || costUSD < 0 || math.IsNaN(costUSD) || math.IsInf(costUSD, 0) {
|
|
return nil, status.Errorf(codes.InvalidArgument, "usage counters must be non-negative and finite")
|
|
}
|
|
|
|
// Book the policy-window dimensions (when a policy cap bound this request)
|
|
// and every applicable account budget rule's window in a single batched
|
|
// transaction.
|
|
if err := svc.RecordUsage(ctx, agentnetwork.RecordUsageInput{
|
|
AccountID: accountID,
|
|
UserID: req.GetUserId(),
|
|
AttributionGroupID: req.GetGroupId(),
|
|
GroupIDs: req.GetGroupIds(),
|
|
WindowSeconds: req.GetWindowSeconds(),
|
|
TokensIn: tokensIn,
|
|
TokensOut: tokensOut,
|
|
CostUSD: costUSD,
|
|
}); err != nil {
|
|
log.WithContext(ctx).Errorf("record usage: %v", err)
|
|
return nil, status.Error(codes.Internal, "record usage failed")
|
|
}
|
|
return &proto.RecordLLMUsageResponse{}, nil
|
|
}
|
|
|
|
// SetProxyController sets the proxy controller. Must be called before serving.
|
|
func (s *ProxyServiceServer) SetProxyController(proxyController proxy.Controller) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.proxyController = proxyController
|
|
}
|
|
|
|
// proxyConnectParams holds the validated parameters extracted from either
|
|
// a GetMappingUpdateRequest or a SyncMappingsInit message.
|
|
type proxyConnectParams struct {
|
|
proxyID string
|
|
address string
|
|
capabilities *proto.ProxyCapabilities
|
|
}
|
|
|
|
// GetMappingUpdate handles the control stream with proxy clients
|
|
func (s *ProxyServiceServer) GetMappingUpdate(req *proto.GetMappingUpdateRequest, stream proto.ProxyService_GetMappingUpdateServer) error {
|
|
params, err := s.validateProxyConnect(req.GetProxyId(), req.GetAddress(), stream.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
params.capabilities = req.GetCapabilities()
|
|
|
|
conn, proxyRecord, err := s.registerProxyConnection(stream.Context(), params, &proxyConnection{
|
|
stream: stream,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := s.sendSnapshot(stream.Context(), conn); err != nil {
|
|
s.cleanupFailedSnapshot(stream.Context(), conn)
|
|
return fmt.Errorf("send snapshot to proxy %s: %w", params.proxyID, err)
|
|
}
|
|
|
|
errChan := make(chan error, 2)
|
|
go s.sender(conn, errChan)
|
|
|
|
return s.serveProxyConnection(conn, proxyRecord, errChan, false)
|
|
}
|
|
|
|
// SyncMappings implements the bidirectional SyncMappings RPC.
|
|
// It mirrors GetMappingUpdate but provides application-level back-pressure:
|
|
// management waits for an ack from the proxy before sending the next batch.
|
|
func (s *ProxyServiceServer) SyncMappings(stream proto.ProxyService_SyncMappingsServer) error {
|
|
init, err := recvSyncInit(stream)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
params, err := s.validateProxyConnect(init.GetProxyId(), init.GetAddress(), stream.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
params.capabilities = init.GetCapabilities()
|
|
|
|
conn, proxyRecord, err := s.registerProxyConnection(stream.Context(), params, &proxyConnection{
|
|
syncStream: stream,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := s.sendSnapshotSync(stream.Context(), conn, stream); err != nil {
|
|
s.cleanupFailedSnapshot(stream.Context(), conn)
|
|
return fmt.Errorf("send snapshot to proxy %s: %w", params.proxyID, err)
|
|
}
|
|
|
|
errChan := make(chan error, 2)
|
|
go s.sender(conn, errChan)
|
|
go s.drainRecv(stream, errChan)
|
|
|
|
return s.serveProxyConnection(conn, proxyRecord, errChan, true)
|
|
}
|
|
|
|
// recvSyncInit receives and validates the first message on a SyncMappings stream.
|
|
func recvSyncInit(stream proto.ProxyService_SyncMappingsServer) (*proto.SyncMappingsInit, error) {
|
|
firstMsg, err := stream.Recv()
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "receive init: %v", err)
|
|
}
|
|
init := firstMsg.GetInit()
|
|
if init == nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "first message must be init")
|
|
}
|
|
return init, nil
|
|
}
|
|
|
|
// validateProxyConnect validates the proxy ID and address, and checks cluster
|
|
// address availability for account-scoped tokens.
|
|
func (s *ProxyServiceServer) validateProxyConnect(proxyID, address string, ctx context.Context) (proxyConnectParams, error) {
|
|
if proxyID == "" {
|
|
return proxyConnectParams{}, status.Errorf(codes.InvalidArgument, "proxy_id is required")
|
|
}
|
|
if !isProxyAddressValid(address) {
|
|
return proxyConnectParams{}, status.Errorf(codes.InvalidArgument, "proxy address is invalid")
|
|
}
|
|
|
|
token := GetProxyTokenFromContext(ctx)
|
|
if token != nil && token.AccountID != nil {
|
|
available, err := s.proxyManager.IsClusterAddressAvailable(ctx, address, *token.AccountID)
|
|
if err != nil {
|
|
return proxyConnectParams{}, status.Errorf(codes.Internal, "check cluster address: %v", err)
|
|
}
|
|
if !available {
|
|
return proxyConnectParams{}, status.Errorf(codes.AlreadyExists, "cluster address %s is already in use", address)
|
|
}
|
|
}
|
|
|
|
return proxyConnectParams{proxyID: proxyID, address: address}, nil
|
|
}
|
|
|
|
// registerProxyConnection creates a proxyConnection, registers it with the
|
|
// proxy manager and cluster, and stores it in connectedProxies. The caller
|
|
// provides a partially initialised connSeed with stream-specific fields set;
|
|
// the remaining fields are filled in here.
|
|
func (s *ProxyServiceServer) registerProxyConnection(ctx context.Context, params proxyConnectParams, connSeed *proxyConnection) (*proxyConnection, *proxy.Proxy, error) {
|
|
peerInfo := PeerIPFromContext(ctx)
|
|
|
|
var accountID *string
|
|
var tokenID string
|
|
if token := GetProxyTokenFromContext(ctx); token != nil {
|
|
if token.AccountID != nil {
|
|
accountID = token.AccountID
|
|
}
|
|
tokenID = token.ID
|
|
}
|
|
|
|
sessionID := uuid.NewString()
|
|
s.supersedePriorConnection(params.proxyID, sessionID)
|
|
|
|
connCtx, cancel := context.WithCancel(ctx)
|
|
connSeed.proxyID = params.proxyID
|
|
connSeed.sessionID = sessionID
|
|
connSeed.address = params.address
|
|
connSeed.accountID = accountID
|
|
connSeed.tokenID = tokenID
|
|
connSeed.capabilities = params.capabilities
|
|
connSeed.sendChan = make(chan *proto.GetMappingUpdateResponse, 100)
|
|
connSeed.ctx = connCtx
|
|
connSeed.cancel = cancel
|
|
|
|
var caps *proxy.Capabilities
|
|
if c := params.capabilities; c != nil {
|
|
caps = &proxy.Capabilities{
|
|
SupportsCustomPorts: c.SupportsCustomPorts,
|
|
RequireSubdomain: c.RequireSubdomain,
|
|
SupportsCrowdsec: c.SupportsCrowdsec,
|
|
Private: c.Private,
|
|
}
|
|
}
|
|
|
|
proxyRecord, err := s.proxyManager.Connect(ctx, params.proxyID, sessionID, params.address, peerInfo, accountID, caps)
|
|
if err != nil {
|
|
cancel()
|
|
if accountID != nil {
|
|
return nil, nil, status.Errorf(codes.Internal, "failed to register BYOP proxy: %v", err)
|
|
}
|
|
log.WithContext(ctx).Warnf("failed to register proxy %s in database: %v", params.proxyID, err)
|
|
return nil, nil, status.Errorf(codes.Internal, "register proxy in database: %v", err)
|
|
}
|
|
|
|
s.connectedProxies.Store(params.proxyID, connSeed)
|
|
if err := s.proxyController.RegisterProxyToCluster(ctx, params.address, params.proxyID); err != nil {
|
|
log.WithContext(ctx).Warnf("Failed to register proxy %s in cluster: %v", params.proxyID, err)
|
|
}
|
|
|
|
return connSeed, proxyRecord, nil
|
|
}
|
|
|
|
// supersedePriorConnection cancels any existing connection for the given proxy.
|
|
func (s *ProxyServiceServer) supersedePriorConnection(proxyID, newSessionID string) {
|
|
if old, loaded := s.connectedProxies.Load(proxyID); loaded {
|
|
oldConn := old.(*proxyConnection)
|
|
log.WithFields(log.Fields{
|
|
"proxy_id": proxyID,
|
|
"old_session_id": oldConn.sessionID,
|
|
"new_session_id": newSessionID,
|
|
}).Info("Superseding existing proxy connection")
|
|
oldConn.cancel()
|
|
}
|
|
}
|
|
|
|
// cleanupFailedSnapshot removes the connection from the cluster and store
|
|
// after a snapshot send failure.
|
|
func (s *ProxyServiceServer) cleanupFailedSnapshot(ctx context.Context, conn *proxyConnection) {
|
|
if s.connectedProxies.CompareAndDelete(conn.proxyID, conn) {
|
|
if err := s.proxyController.UnregisterProxyFromCluster(context.Background(), conn.address, conn.proxyID); err != nil {
|
|
log.WithContext(ctx).Debugf("cleanup after snapshot failure for proxy %s: %v", conn.proxyID, err)
|
|
}
|
|
}
|
|
conn.cancel()
|
|
if err := s.proxyManager.Disconnect(context.Background(), conn.proxyID, conn.sessionID); err != nil {
|
|
log.WithContext(ctx).Debugf("cleanup after snapshot failure for proxy %s: %v", conn.proxyID, err)
|
|
}
|
|
}
|
|
|
|
// drainRecv consumes and discards messages from a bidirectional stream.
|
|
// The proxy sends an ack for every incremental update; we don't need them
|
|
// after the snapshot phase. Recv errors are forwarded to errChan.
|
|
func (s *ProxyServiceServer) drainRecv(stream proto.ProxyService_SyncMappingsServer, errChan chan<- error) {
|
|
for {
|
|
if _, err := stream.Recv(); err != nil {
|
|
errChan <- err
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// serveProxyConnection runs the post-snapshot lifecycle: heartbeat, sender,
|
|
// and wait for termination. When bidi is true, normal stream closure (EOF,
|
|
// canceled) is treated as a clean disconnect rather than an error.
|
|
func (s *ProxyServiceServer) serveProxyConnection(conn *proxyConnection, proxyRecord *proxy.Proxy, errChan <-chan error, bidi bool) error {
|
|
log.WithFields(log.Fields{
|
|
"proxy_id": conn.proxyID,
|
|
"session_id": conn.sessionID,
|
|
"address": conn.address,
|
|
"cluster_addr": conn.address,
|
|
"account_id": conn.accountID,
|
|
"total_proxies": len(s.GetConnectedProxies()),
|
|
}).Info("Proxy registered in cluster")
|
|
|
|
defer s.disconnectProxy(conn)
|
|
go s.heartbeat(conn.ctx, conn, proxyRecord)
|
|
|
|
select {
|
|
case err := <-errChan:
|
|
if bidi && isStreamClosed(err) {
|
|
log.Infof("Proxy %s stream closed", conn.proxyID)
|
|
return nil
|
|
}
|
|
log.Warnf("Failed to send update: %v", err)
|
|
return fmt.Errorf("send update to proxy %s: %w", conn.proxyID, err)
|
|
case <-conn.ctx.Done():
|
|
log.Infof("Proxy %s context canceled", conn.proxyID)
|
|
return conn.ctx.Err()
|
|
}
|
|
}
|
|
|
|
// disconnectProxy removes the connection from cluster and store, unless it
|
|
// has already been superseded by a newer connection.
|
|
func (s *ProxyServiceServer) disconnectProxy(conn *proxyConnection) {
|
|
if !s.connectedProxies.CompareAndDelete(conn.proxyID, conn) {
|
|
log.Infof("Proxy %s session %s: skipping cleanup, superseded by new connection", conn.proxyID, conn.sessionID)
|
|
conn.cancel()
|
|
return
|
|
}
|
|
|
|
if err := s.proxyController.UnregisterProxyFromCluster(context.Background(), conn.address, conn.proxyID); err != nil {
|
|
log.Warnf("Failed to unregister proxy %s from cluster: %v", conn.proxyID, err)
|
|
}
|
|
conn.cancel()
|
|
if err := s.proxyManager.Disconnect(context.Background(), conn.proxyID, conn.sessionID); err != nil {
|
|
log.Warnf("Failed to mark proxy %s as disconnected: %v", conn.proxyID, err)
|
|
}
|
|
|
|
log.Infof("Proxy %s session %s disconnected", conn.proxyID, conn.sessionID)
|
|
}
|
|
|
|
// sendSnapshotSync sends the initial snapshot with back-pressure: it sends
|
|
// one batch, then waits for the proxy to ack before sending the next.
|
|
func (s *ProxyServiceServer) sendSnapshotSync(ctx context.Context, conn *proxyConnection, stream proto.ProxyService_SyncMappingsServer) error {
|
|
if !isProxyAddressValid(conn.address) {
|
|
return fmt.Errorf("proxy address is invalid")
|
|
}
|
|
if s.snapshotBatchSize <= 0 {
|
|
return fmt.Errorf("invalid snapshot batch size: %d", s.snapshotBatchSize)
|
|
}
|
|
|
|
mappings, err := s.snapshotServiceMappings(ctx, conn)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for i := 0; i < len(mappings); i += s.snapshotBatchSize {
|
|
end := i + s.snapshotBatchSize
|
|
if end > len(mappings) {
|
|
end = len(mappings)
|
|
}
|
|
for _, m := range mappings[i:end] {
|
|
token, err := s.tokenStore.GenerateToken(m.AccountId, m.Id, s.proxyTokenTTL())
|
|
if err != nil {
|
|
return fmt.Errorf("generate auth token for service %s: %w", m.Id, err)
|
|
}
|
|
m.AuthToken = token
|
|
}
|
|
if err := stream.Send(&proto.SyncMappingsResponse{
|
|
Mapping: mappings[i:end],
|
|
InitialSyncComplete: end == len(mappings),
|
|
}); err != nil {
|
|
return fmt.Errorf("send snapshot batch: %w", err)
|
|
}
|
|
|
|
if err := waitForAck(stream); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if len(mappings) == 0 {
|
|
if err := stream.Send(&proto.SyncMappingsResponse{
|
|
InitialSyncComplete: true,
|
|
}); err != nil {
|
|
return fmt.Errorf("send snapshot completion: %w", err)
|
|
}
|
|
|
|
if err := waitForAck(stream); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func waitForAck(stream proto.ProxyService_SyncMappingsServer) error {
|
|
msg, err := stream.Recv()
|
|
if err != nil {
|
|
return fmt.Errorf("receive ack: %w", err)
|
|
}
|
|
if msg.GetAck() == nil {
|
|
return fmt.Errorf("expected ack, got %T", msg.GetMsg())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// heartbeat updates the proxy's last_seen timestamp every minute and
|
|
// disconnects the proxy if its access token has been revoked.
|
|
func (s *ProxyServiceServer) heartbeat(ctx context.Context, conn *proxyConnection, p *proxy.Proxy) {
|
|
ticker := time.NewTicker(1 * time.Minute)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
if err := s.proxyManager.Heartbeat(ctx, p); err != nil {
|
|
log.WithContext(ctx).Debugf("Failed to update proxy %s heartbeat: %v", p.ID, err)
|
|
}
|
|
|
|
if conn.tokenID != "" && s.tokenChecker != nil {
|
|
valid, err := s.tokenChecker.IsProxyAccessTokenValid(ctx, conn.tokenID)
|
|
if err != nil {
|
|
log.WithContext(ctx).Warnf("failed to check token validity for proxy %s: %v", conn.proxyID, err)
|
|
continue
|
|
}
|
|
if !valid {
|
|
log.WithContext(ctx).Warnf("proxy %s token revoked or expired, disconnecting", conn.proxyID)
|
|
conn.cancel()
|
|
return
|
|
}
|
|
}
|
|
case <-ctx.Done():
|
|
log.WithContext(ctx).Infof("proxy %s heartbeat stopped: context canceled", p.ID)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *ProxyServiceServer) sendSnapshot(ctx context.Context, conn *proxyConnection) error {
|
|
if !isProxyAddressValid(conn.address) {
|
|
return fmt.Errorf("proxy address is invalid")
|
|
}
|
|
if s.snapshotBatchSize <= 0 {
|
|
return fmt.Errorf("invalid snapshot batch size: %d", s.snapshotBatchSize)
|
|
}
|
|
|
|
mappings, err := s.snapshotServiceMappings(ctx, conn)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Send mappings in batches to reduce per-message gRPC overhead while
|
|
// staying well within the default 4 MB message size limit.
|
|
for i := 0; i < len(mappings); i += s.snapshotBatchSize {
|
|
end := i + s.snapshotBatchSize
|
|
if end > len(mappings) {
|
|
end = len(mappings)
|
|
}
|
|
for _, m := range mappings[i:end] {
|
|
token, err := s.tokenStore.GenerateToken(m.AccountId, m.Id, s.proxyTokenTTL())
|
|
if err != nil {
|
|
return fmt.Errorf("generate auth token for service %s: %w", m.Id, err)
|
|
}
|
|
m.AuthToken = token
|
|
}
|
|
if err := conn.stream.Send(&proto.GetMappingUpdateResponse{
|
|
Mapping: mappings[i:end],
|
|
InitialSyncComplete: end == len(mappings),
|
|
}); err != nil {
|
|
return fmt.Errorf("send snapshot batch: %w", err)
|
|
}
|
|
}
|
|
|
|
if len(mappings) == 0 {
|
|
if err := conn.stream.Send(&proto.GetMappingUpdateResponse{
|
|
InitialSyncComplete: true,
|
|
}); err != nil {
|
|
return fmt.Errorf("send snapshot completion: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *ProxyServiceServer) snapshotServiceMappings(ctx context.Context, conn *proxyConnection) ([]*proto.ProxyMapping, error) {
|
|
var services []*rpservice.Service
|
|
var err error
|
|
if conn.accountID != nil {
|
|
services, err = s.serviceManager.GetAccountServices(ctx, *conn.accountID)
|
|
} else {
|
|
services, err = s.serviceManager.GetGlobalServices(ctx)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get services from store: %w", err)
|
|
}
|
|
|
|
if synth := s.agentNetworkSynthesizer(); synth != nil {
|
|
var synthesised []*rpservice.Service
|
|
var serr error
|
|
// Account-scoped connections synthesise only their own account, so the
|
|
// snapshot can never carry another tenant's mappings (which embed the
|
|
// upstream auth header derived from that tenant's provider API key).
|
|
// Global connections still see the whole cluster.
|
|
if conn.accountID != nil {
|
|
synthesised, serr = synth.SynthesizeServicesForAccount(ctx, *conn.accountID)
|
|
} else {
|
|
synthesised, serr = synth.SynthesizeServicesForCluster(ctx, conn.address)
|
|
}
|
|
if serr != nil {
|
|
// Surface a real synthesis failure instead of silently shipping an
|
|
// incomplete snapshot (which would drop the account's agent-network
|
|
// routes). Consistent with the persisted-services error above; the
|
|
// proxy retries the snapshot on connection error.
|
|
return nil, fmt.Errorf("synthesise agent-network services: %w", serr)
|
|
}
|
|
services = append(services, synthesised...)
|
|
}
|
|
|
|
oidcCfg := s.GetOIDCValidationConfig()
|
|
var mappings []*proto.ProxyMapping
|
|
for _, service := range services {
|
|
if !service.Enabled || service.ProxyCluster == "" || service.ProxyCluster != conn.address {
|
|
continue
|
|
}
|
|
// Defense in depth: an account-scoped proxy must never receive another
|
|
// account's mapping, matching the per-account filtering the incremental
|
|
// update path already applies.
|
|
if conn.accountID != nil && service.AccountID != *conn.accountID {
|
|
continue
|
|
}
|
|
|
|
m := service.ToProtoMapping(rpservice.Create, "", oidcCfg)
|
|
if !proxyAcceptsMapping(conn, m) {
|
|
continue
|
|
}
|
|
mappings = append(mappings, m)
|
|
}
|
|
return mappings, nil
|
|
}
|
|
|
|
// isProxyAddressValid validates a proxy address (domain name or IP address)
|
|
func isProxyAddressValid(addr string) bool {
|
|
if addr == "" {
|
|
return false
|
|
}
|
|
if net.ParseIP(addr) != nil {
|
|
return true
|
|
}
|
|
_, err := domain.ValidateDomains([]string{addr})
|
|
return err == nil
|
|
}
|
|
|
|
// isStreamClosed returns true for errors that indicate normal stream
|
|
// termination: io.EOF, context cancellation, or gRPC Canceled.
|
|
func isStreamClosed(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) {
|
|
return true
|
|
}
|
|
return status.Code(err) == codes.Canceled
|
|
}
|
|
|
|
// sender handles sending messages to proxy.
|
|
// When conn.syncStream is set the message is sent as SyncMappingsResponse;
|
|
// otherwise the legacy GetMappingUpdateResponse stream is used.
|
|
func (s *ProxyServiceServer) sender(conn *proxyConnection, errChan chan<- error) {
|
|
for {
|
|
select {
|
|
case resp := <-conn.sendChan:
|
|
if err := conn.sendResponse(resp); err != nil {
|
|
errChan <- err
|
|
log.WithContext(conn.ctx).Tracef("Failed to send response to proxy %s: %v", conn.proxyID, err)
|
|
return
|
|
}
|
|
log.WithContext(conn.ctx).Tracef("Send response to proxy %s", conn.proxyID)
|
|
case <-conn.ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// sendResponse sends a mapping update on whichever stream the proxy connected with.
|
|
func (conn *proxyConnection) sendResponse(resp *proto.GetMappingUpdateResponse) error {
|
|
if conn.syncStream != nil {
|
|
return conn.syncStream.Send(&proto.SyncMappingsResponse{
|
|
Mapping: resp.Mapping,
|
|
InitialSyncComplete: resp.InitialSyncComplete,
|
|
})
|
|
}
|
|
return conn.stream.Send(resp)
|
|
}
|
|
|
|
// SendAccessLog processes access log from proxy
|
|
func (s *ProxyServiceServer) SendAccessLog(ctx context.Context, req *proto.SendAccessLogRequest) (*proto.SendAccessLogResponse, error) {
|
|
accessLog := req.GetLog()
|
|
|
|
if err := enforceAccountScope(ctx, accessLog.GetAccountId()); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
fields := log.Fields{
|
|
"service_id": accessLog.GetServiceId(),
|
|
"account_id": accessLog.GetAccountId(),
|
|
"host": accessLog.GetHost(),
|
|
"source_ip": accessLog.GetSourceIp(),
|
|
}
|
|
if mechanism := accessLog.GetAuthMechanism(); mechanism != "" {
|
|
fields["auth_mechanism"] = mechanism
|
|
}
|
|
if userID := accessLog.GetUserId(); userID != "" {
|
|
fields["user_id"] = userID
|
|
}
|
|
if !accessLog.GetAuthSuccess() {
|
|
fields["auth_success"] = false
|
|
}
|
|
log.WithFields(fields).Debugf("%s %s %d (%dms)",
|
|
accessLog.GetMethod(),
|
|
accessLog.GetPath(),
|
|
accessLog.GetResponseCode(),
|
|
accessLog.GetDurationMs(),
|
|
)
|
|
|
|
logEntry := &accesslogs.AccessLogEntry{}
|
|
logEntry.FromProto(accessLog)
|
|
|
|
if err := s.accessLogManager.SaveAccessLog(ctx, logEntry); err != nil {
|
|
log.WithContext(ctx).Errorf("failed to save access log: %v", err)
|
|
return nil, status.Errorf(codes.Internal, "save access log: %v", err)
|
|
}
|
|
|
|
return &proto.SendAccessLogResponse{}, nil
|
|
}
|
|
|
|
// SendServiceUpdate broadcasts a service update to all connected proxy servers.
|
|
// Management should call this when services are created/updated/removed.
|
|
// For create/update operations a unique one-time auth token is generated per
|
|
// proxy so that every replica can independently authenticate with management.
|
|
// BYOP proxies only receive updates for their own account's services.
|
|
func (s *ProxyServiceServer) SendServiceUpdate(update *proto.GetMappingUpdateResponse) {
|
|
log.Debugf("Broadcasting service update to all connected proxy servers")
|
|
updateAccountIDs := make(map[string]struct{})
|
|
for _, m := range update.Mapping {
|
|
if m.AccountId != "" {
|
|
updateAccountIDs[m.AccountId] = struct{}{}
|
|
}
|
|
}
|
|
s.connectedProxies.Range(func(key, value interface{}) bool {
|
|
conn := value.(*proxyConnection)
|
|
connUpdate := update
|
|
if conn.accountID != nil && len(updateAccountIDs) > 0 {
|
|
if _, ok := updateAccountIDs[*conn.accountID]; !ok {
|
|
return true
|
|
}
|
|
filtered := filterMappingsForAccount(update.Mapping, *conn.accountID)
|
|
if len(filtered) == 0 {
|
|
return true
|
|
}
|
|
connUpdate = &proto.GetMappingUpdateResponse{
|
|
Mapping: filtered,
|
|
InitialSyncComplete: update.InitialSyncComplete,
|
|
}
|
|
}
|
|
// Drop mappings the proxy lacks capability for (e.g. private without SupportsPrivateService).
|
|
connUpdate = filterMappingsForProxy(conn, connUpdate)
|
|
if connUpdate == nil || len(connUpdate.Mapping) == 0 {
|
|
return true
|
|
}
|
|
resp := s.perProxyMessage(connUpdate, conn.proxyID)
|
|
if resp == nil {
|
|
log.Warnf("Token generation failed for proxy %s, disconnecting to force resync", conn.proxyID)
|
|
conn.cancel()
|
|
return true
|
|
}
|
|
select {
|
|
case conn.sendChan <- resp:
|
|
log.Debugf("Sent service update to proxy server %s", conn.proxyID)
|
|
default:
|
|
log.Warnf("Send channel full for proxy %s, disconnecting to force resync", conn.proxyID)
|
|
conn.cancel()
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
|
|
// ForceDisconnect cancels the gRPC stream for a connected proxy, causing it to disconnect.
|
|
func (s *ProxyServiceServer) ForceDisconnect(proxyID string) {
|
|
if connVal, ok := s.connectedProxies.Load(proxyID); ok {
|
|
conn := connVal.(*proxyConnection)
|
|
conn.cancel()
|
|
s.connectedProxies.Delete(proxyID)
|
|
log.WithFields(log.Fields{"proxyID": proxyID}).Info("force disconnected proxy")
|
|
}
|
|
}
|
|
|
|
func filterMappingsForAccount(mappings []*proto.ProxyMapping, accountID string) []*proto.ProxyMapping {
|
|
var filtered []*proto.ProxyMapping
|
|
for _, m := range mappings {
|
|
if m.AccountId == accountID {
|
|
filtered = append(filtered, m)
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
// GetConnectedProxies returns a list of connected proxy IDs
|
|
func (s *ProxyServiceServer) GetConnectedProxies() []string {
|
|
var proxies []string
|
|
s.connectedProxies.Range(func(key, value interface{}) bool {
|
|
proxies = append(proxies, key.(string))
|
|
return true
|
|
})
|
|
return proxies
|
|
}
|
|
|
|
// GetConnectedProxyURLs returns a deduplicated list of URLs from all connected proxies.
|
|
func (s *ProxyServiceServer) GetConnectedProxyURLs() []string {
|
|
seenUrls := make(map[string]struct{})
|
|
var urls []string
|
|
var proxyCount int
|
|
s.connectedProxies.Range(func(key, value interface{}) bool {
|
|
proxyCount++
|
|
conn := value.(*proxyConnection)
|
|
log.WithFields(log.Fields{
|
|
"proxy_id": conn.proxyID,
|
|
"address": conn.address,
|
|
}).Debug("checking connected proxy for URL")
|
|
if _, seen := seenUrls[conn.address]; conn.address != "" && !seen {
|
|
seenUrls[conn.address] = struct{}{}
|
|
urls = append(urls, conn.address)
|
|
}
|
|
return true
|
|
})
|
|
log.WithFields(log.Fields{
|
|
"total_proxies": proxyCount,
|
|
"unique_urls": len(urls),
|
|
"connected_urls": urls,
|
|
}).Debug("GetConnectedProxyURLs result")
|
|
return urls
|
|
}
|
|
|
|
// SendServiceUpdateToCluster sends a service update to all proxy servers in a specific cluster.
|
|
// If clusterAddr is empty, broadcasts to all connected proxy servers (backward compatibility).
|
|
// For create/update operations a unique one-time auth token is generated per
|
|
// proxy so that every replica can independently authenticate with management.
|
|
func (s *ProxyServiceServer) SendServiceUpdateToCluster(ctx context.Context, update *proto.ProxyMapping, clusterAddr string) {
|
|
updateResponse := &proto.GetMappingUpdateResponse{
|
|
Mapping: []*proto.ProxyMapping{update},
|
|
}
|
|
|
|
if clusterAddr == "" {
|
|
s.SendServiceUpdate(updateResponse)
|
|
return
|
|
}
|
|
|
|
if s.proxyController == nil {
|
|
log.WithContext(ctx).Debugf("ProxyController not set, cannot send to cluster %s", clusterAddr)
|
|
return
|
|
}
|
|
|
|
proxyIDs := s.proxyController.GetProxiesForCluster(clusterAddr)
|
|
if len(proxyIDs) == 0 {
|
|
log.WithContext(ctx).Debugf("No proxies connected for cluster %s", clusterAddr)
|
|
return
|
|
}
|
|
|
|
log.Debugf("Sending service update to cluster %s", clusterAddr)
|
|
for _, proxyID := range proxyIDs {
|
|
connVal, ok := s.connectedProxies.Load(proxyID)
|
|
if !ok {
|
|
continue
|
|
}
|
|
conn := connVal.(*proxyConnection)
|
|
if conn.accountID != nil && update.AccountId != "" && *conn.accountID != update.AccountId {
|
|
continue
|
|
}
|
|
if !proxyAcceptsMapping(conn, update) {
|
|
log.WithContext(ctx).Debugf("Skipping proxy %s: does not support custom ports for mapping %s", proxyID, update.Id)
|
|
continue
|
|
}
|
|
msg := s.perProxyMessage(updateResponse, proxyID)
|
|
if msg == nil {
|
|
log.WithContext(ctx).Warnf("Token generation failed for proxy %s in cluster %s, disconnecting to force resync", proxyID, clusterAddr)
|
|
conn.cancel()
|
|
continue
|
|
}
|
|
select {
|
|
case conn.sendChan <- msg:
|
|
log.WithContext(ctx).Debugf("Sent service update with id %s to proxy %s in cluster %s", update.Id, proxyID, clusterAddr)
|
|
default:
|
|
log.WithContext(ctx).Warnf("Send channel full for proxy %s in cluster %s, disconnecting to force resync", proxyID, clusterAddr)
|
|
conn.cancel()
|
|
}
|
|
}
|
|
}
|
|
|
|
// proxyAcceptsMapping returns whether the proxy can receive this mapping.
|
|
// Private mappings require SupportsPrivateService; custom-port L4 mappings
|
|
// require SupportsCustomPorts. Remove operations always pass so proxies can
|
|
// clean up.
|
|
func proxyAcceptsMapping(conn *proxyConnection, mapping *proto.ProxyMapping) bool {
|
|
if mapping.Type == proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED {
|
|
return true
|
|
}
|
|
if mapping.GetPrivate() {
|
|
caps := conn.capabilities
|
|
if caps == nil || caps.SupportsPrivateService == nil || !*caps.SupportsPrivateService {
|
|
return false
|
|
}
|
|
}
|
|
if mapping.ListenPort == 0 || mapping.Mode == "tls" {
|
|
return true
|
|
}
|
|
// Old proxies that never reported capabilities don't understand
|
|
// custom port mappings.
|
|
return conn.capabilities != nil && conn.capabilities.SupportsCustomPorts != nil
|
|
}
|
|
|
|
// filterMappingsForProxy drops mappings the proxy cannot safely receive
|
|
// (e.g. private mappings to a proxy without SupportsPrivateService).
|
|
// Returns the input unchanged when no filtering is needed.
|
|
func filterMappingsForProxy(conn *proxyConnection, update *proto.GetMappingUpdateResponse) *proto.GetMappingUpdateResponse {
|
|
if update == nil || len(update.Mapping) == 0 {
|
|
return update
|
|
}
|
|
kept := make([]*proto.ProxyMapping, 0, len(update.Mapping))
|
|
for _, m := range update.Mapping {
|
|
if !proxyAcceptsMapping(conn, m) {
|
|
continue
|
|
}
|
|
kept = append(kept, m)
|
|
}
|
|
if len(kept) == len(update.Mapping) {
|
|
return update
|
|
}
|
|
return &proto.GetMappingUpdateResponse{
|
|
Mapping: kept,
|
|
InitialSyncComplete: update.InitialSyncComplete,
|
|
}
|
|
}
|
|
|
|
// perProxyMessage returns a copy of update with a fresh one-time token for
|
|
// create/update operations. For delete operations the original mapping is
|
|
// used unchanged because proxies do not need to authenticate for removal.
|
|
// Returns nil if token generation fails; the caller must disconnect the
|
|
// proxy so it can resync via a fresh snapshot on reconnect.
|
|
func (s *ProxyServiceServer) perProxyMessage(update *proto.GetMappingUpdateResponse, proxyID string) *proto.GetMappingUpdateResponse {
|
|
resp := make([]*proto.ProxyMapping, 0, len(update.Mapping))
|
|
for _, mapping := range update.Mapping {
|
|
if mapping.Type == proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED {
|
|
resp = append(resp, mapping)
|
|
continue
|
|
}
|
|
|
|
token, err := s.tokenStore.GenerateToken(mapping.AccountId, mapping.Id, s.proxyTokenTTL())
|
|
if err != nil {
|
|
log.Warnf("Failed to generate token for proxy %s: %v", proxyID, err)
|
|
return nil
|
|
}
|
|
|
|
msg := shallowCloneMapping(mapping)
|
|
msg.AuthToken = token
|
|
resp = append(resp, msg)
|
|
}
|
|
|
|
return &proto.GetMappingUpdateResponse{
|
|
Mapping: resp,
|
|
}
|
|
}
|
|
|
|
// shallowCloneMapping creates a shallow copy of a ProxyMapping, reusing the
|
|
// same slice/pointer fields. Only scalar fields that differ per proxy (AuthToken)
|
|
// should be set on the copy.
|
|
func shallowCloneMapping(m *proto.ProxyMapping) *proto.ProxyMapping {
|
|
return &proto.ProxyMapping{
|
|
Type: m.Type,
|
|
Id: m.Id,
|
|
AccountId: m.AccountId,
|
|
Domain: m.Domain,
|
|
Path: m.Path,
|
|
Auth: m.Auth,
|
|
PassHostHeader: m.PassHostHeader,
|
|
RewriteRedirects: m.RewriteRedirects,
|
|
Mode: m.Mode,
|
|
ListenPort: m.ListenPort,
|
|
AccessRestrictions: m.AccessRestrictions,
|
|
Private: m.Private,
|
|
}
|
|
}
|
|
|
|
func (s *ProxyServiceServer) Authenticate(ctx context.Context, req *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error) {
|
|
if err := enforceAccountScope(ctx, req.GetAccountId()); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
service, err := s.serviceManager.GetServiceByID(ctx, req.GetAccountId(), req.GetId())
|
|
if err != nil {
|
|
log.WithContext(ctx).Debugf("failed to get service from store: %v", err)
|
|
return nil, status.Errorf(codes.FailedPrecondition, "get service from store: %v", err)
|
|
}
|
|
|
|
authenticated, userId, method := s.authenticateRequest(ctx, req, service)
|
|
|
|
// Non-OIDC schemes (PIN/Password/Header) authenticate against per-service
|
|
// secrets and have no user-level group context, so groups stay nil. Email
|
|
// is also empty — these schemes don't resolve a user record at sign time.
|
|
token, err := s.generateSessionToken(ctx, authenticated, service, userId, "", method, nil, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &proto.AuthenticateResponse{
|
|
Success: authenticated,
|
|
SessionToken: token,
|
|
}, nil
|
|
}
|
|
|
|
func (s *ProxyServiceServer) authenticateRequest(ctx context.Context, req *proto.AuthenticateRequest, service *rpservice.Service) (bool, string, proxyauth.Method) {
|
|
switch v := req.GetRequest().(type) {
|
|
case *proto.AuthenticateRequest_Pin:
|
|
return s.authenticatePIN(ctx, req.GetId(), v, service.Auth.PinAuth)
|
|
case *proto.AuthenticateRequest_Password:
|
|
return s.authenticatePassword(ctx, req.GetId(), v, service.Auth.PasswordAuth)
|
|
case *proto.AuthenticateRequest_HeaderAuth:
|
|
return s.authenticateHeader(ctx, req.GetId(), v, service.Auth.HeaderAuths)
|
|
default:
|
|
return false, "", ""
|
|
}
|
|
}
|
|
|
|
func (s *ProxyServiceServer) authenticatePIN(ctx context.Context, serviceID string, req *proto.AuthenticateRequest_Pin, auth *rpservice.PINAuthConfig) (bool, string, proxyauth.Method) {
|
|
if auth == nil || !auth.Enabled {
|
|
log.WithContext(ctx).Debugf("PIN authentication attempted but not enabled for service %s", serviceID)
|
|
return false, "", ""
|
|
}
|
|
|
|
if err := argon2id.Verify(req.Pin.GetPin(), auth.Pin); err != nil {
|
|
s.logAuthenticationError(ctx, err, "PIN")
|
|
return false, "", ""
|
|
}
|
|
|
|
return true, "pin-user", proxyauth.MethodPIN
|
|
}
|
|
|
|
func (s *ProxyServiceServer) authenticatePassword(ctx context.Context, serviceID string, req *proto.AuthenticateRequest_Password, auth *rpservice.PasswordAuthConfig) (bool, string, proxyauth.Method) {
|
|
if auth == nil || !auth.Enabled {
|
|
log.WithContext(ctx).Debugf("password authentication attempted but not enabled for service %s", serviceID)
|
|
return false, "", ""
|
|
}
|
|
|
|
if err := argon2id.Verify(req.Password.GetPassword(), auth.Password); err != nil {
|
|
s.logAuthenticationError(ctx, err, "Password")
|
|
return false, "", ""
|
|
}
|
|
|
|
return true, "password-user", proxyauth.MethodPassword
|
|
}
|
|
|
|
func (s *ProxyServiceServer) authenticateHeader(ctx context.Context, serviceID string, req *proto.AuthenticateRequest_HeaderAuth, auths []*rpservice.HeaderAuthConfig) (bool, string, proxyauth.Method) {
|
|
if len(auths) == 0 {
|
|
log.WithContext(ctx).Debugf("header authentication attempted but no header auths configured for service %s", serviceID)
|
|
return false, "", ""
|
|
}
|
|
|
|
headerName := http.CanonicalHeaderKey(req.HeaderAuth.GetHeaderName())
|
|
|
|
var lastErr error
|
|
for _, auth := range auths {
|
|
if auth == nil || !auth.Enabled {
|
|
continue
|
|
}
|
|
if headerName != "" && http.CanonicalHeaderKey(auth.Header) != headerName {
|
|
continue
|
|
}
|
|
if err := argon2id.Verify(req.HeaderAuth.GetHeaderValue(), auth.Value); err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
return true, "header-user", proxyauth.MethodHeader
|
|
}
|
|
|
|
if lastErr != nil {
|
|
s.logAuthenticationError(ctx, lastErr, "Header")
|
|
}
|
|
return false, "", ""
|
|
}
|
|
|
|
func (s *ProxyServiceServer) logAuthenticationError(ctx context.Context, err error, authType string) {
|
|
if errors.Is(err, argon2id.ErrMismatchedHashAndPassword) {
|
|
log.WithContext(ctx).Tracef("%s authentication failed: invalid credentials", authType)
|
|
} else {
|
|
log.WithContext(ctx).Errorf("%s authentication error: %v", authType, err)
|
|
}
|
|
}
|
|
|
|
func (s *ProxyServiceServer) generateSessionToken(ctx context.Context, authenticated bool, service *rpservice.Service, userId, userEmail string, method proxyauth.Method, groupIDs, groupNames []string) (string, error) {
|
|
if !authenticated || service.SessionPrivateKey == "" {
|
|
return "", nil
|
|
}
|
|
|
|
token, err := sessionkey.SignToken(
|
|
service.SessionPrivateKey,
|
|
userId,
|
|
userEmail,
|
|
service.Domain,
|
|
method,
|
|
groupIDs,
|
|
groupNames,
|
|
proxyauth.DefaultSessionExpiry,
|
|
)
|
|
if err != nil {
|
|
log.WithContext(ctx).WithError(err).Error("failed to sign session token")
|
|
return "", status.Errorf(codes.Internal, "sign session token: %v", err)
|
|
}
|
|
|
|
return token, nil
|
|
}
|
|
|
|
// pairGroupIDsAndNames splits a slice of resolved *types.Group records
|
|
// into parallel id and name slices. ids[i] and names[i] always pair to
|
|
// the same group. nil entries (orphan ids the manager couldn't resolve)
|
|
// are skipped so the consumer can rely on positional pairing.
|
|
func pairGroupIDsAndNames(groups []*types.Group) (ids, names []string) {
|
|
if len(groups) == 0 {
|
|
return nil, nil
|
|
}
|
|
ids = make([]string, 0, len(groups))
|
|
names = make([]string, 0, len(groups))
|
|
for _, g := range groups {
|
|
if g == nil {
|
|
continue
|
|
}
|
|
ids = append(ids, g.ID)
|
|
names = append(names, g.Name)
|
|
}
|
|
return ids, names
|
|
}
|
|
|
|
// SendStatusUpdate handles status updates from proxy clients.
|
|
func (s *ProxyServiceServer) SendStatusUpdate(ctx context.Context, req *proto.SendStatusUpdateRequest) (*proto.SendStatusUpdateResponse, error) {
|
|
if err := enforceAccountScope(ctx, req.GetAccountId()); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
accountID := req.GetAccountId()
|
|
serviceID := req.GetServiceId()
|
|
protoStatus := req.GetStatus()
|
|
certificateIssued := req.GetCertificateIssued()
|
|
|
|
log.WithFields(log.Fields{
|
|
"service_id": serviceID,
|
|
"account_id": accountID,
|
|
"status": protoStatus,
|
|
"certificate_issued": certificateIssued,
|
|
"error_message": req.GetErrorMessage(),
|
|
}).Debug("Status update from proxy server")
|
|
|
|
if serviceID == "" || accountID == "" {
|
|
return nil, status.Errorf(codes.InvalidArgument, "service_id and account_id are required")
|
|
}
|
|
|
|
internalStatus := protoStatusToInternal(protoStatus)
|
|
|
|
if err := s.serviceManager.SetStatus(ctx, accountID, serviceID, internalStatus); err != nil {
|
|
sErr, isNbErr := nbstatus.FromError(err)
|
|
if isNbErr && sErr.Type() == nbstatus.NotFound {
|
|
return nil, status.Errorf(codes.NotFound, "service %s not found", serviceID)
|
|
}
|
|
log.WithContext(ctx).WithError(err).Error("failed to update service status")
|
|
return nil, status.Errorf(codes.Internal, "update service status: %v", err)
|
|
}
|
|
|
|
if certificateIssued {
|
|
if err := s.serviceManager.SetCertificateIssuedAt(ctx, accountID, serviceID); err != nil {
|
|
log.WithContext(ctx).WithError(err).Error("failed to set certificate issued timestamp")
|
|
return nil, status.Errorf(codes.Internal, "update certificate timestamp: %v", err)
|
|
}
|
|
log.WithFields(log.Fields{
|
|
"service_id": serviceID,
|
|
"account_id": accountID,
|
|
}).Info("Certificate issued timestamp updated")
|
|
}
|
|
|
|
log.WithFields(log.Fields{
|
|
"service_id": serviceID,
|
|
"account_id": accountID,
|
|
"status": internalStatus,
|
|
}).Info("Service status updated")
|
|
|
|
return &proto.SendStatusUpdateResponse{}, nil
|
|
}
|
|
|
|
// protoStatusToInternal maps proto status to internal service status.
|
|
func protoStatusToInternal(protoStatus proto.ProxyStatus) rpservice.Status {
|
|
switch protoStatus {
|
|
case proto.ProxyStatus_PROXY_STATUS_PENDING:
|
|
return rpservice.StatusPending
|
|
case proto.ProxyStatus_PROXY_STATUS_ACTIVE:
|
|
return rpservice.StatusActive
|
|
case proto.ProxyStatus_PROXY_STATUS_TUNNEL_NOT_CREATED:
|
|
return rpservice.StatusTunnelNotCreated
|
|
case proto.ProxyStatus_PROXY_STATUS_CERTIFICATE_PENDING:
|
|
return rpservice.StatusCertificatePending
|
|
case proto.ProxyStatus_PROXY_STATUS_CERTIFICATE_FAILED:
|
|
return rpservice.StatusCertificateFailed
|
|
case proto.ProxyStatus_PROXY_STATUS_ERROR:
|
|
return rpservice.StatusError
|
|
default:
|
|
return rpservice.StatusError
|
|
}
|
|
}
|
|
|
|
// CreateProxyPeer handles proxy peer creation with one-time token authentication
|
|
func (s *ProxyServiceServer) CreateProxyPeer(ctx context.Context, req *proto.CreateProxyPeerRequest) (*proto.CreateProxyPeerResponse, error) {
|
|
if err := enforceAccountScope(ctx, req.GetAccountId()); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
serviceID := req.GetServiceId()
|
|
accountID := req.GetAccountId()
|
|
token := req.GetToken()
|
|
cluster := req.GetCluster()
|
|
key := req.WireguardPublicKey
|
|
|
|
log.WithFields(log.Fields{
|
|
"service_id": serviceID,
|
|
"account_id": accountID,
|
|
"cluster": cluster,
|
|
}).Debug("CreateProxyPeer request received")
|
|
|
|
if serviceID == "" || accountID == "" || token == "" {
|
|
log.Warn("CreateProxyPeer: missing required fields")
|
|
return &proto.CreateProxyPeerResponse{
|
|
Success: false,
|
|
ErrorMessage: strPtr("missing required fields: service_id, account_id, and token are required"),
|
|
}, nil
|
|
}
|
|
|
|
if err := s.tokenStore.ValidateAndConsume(token, accountID, serviceID); err != nil {
|
|
log.WithFields(log.Fields{
|
|
"service_id": serviceID,
|
|
"account_id": accountID,
|
|
}).WithError(err).Warn("CreateProxyPeer: token validation failed")
|
|
return &proto.CreateProxyPeerResponse{
|
|
Success: false,
|
|
ErrorMessage: strPtr("authentication failed: invalid or expired token"),
|
|
}, status.Errorf(codes.Unauthenticated, "token validation: %v", err)
|
|
}
|
|
|
|
err := s.peersManager.CreateProxyPeer(ctx, accountID, key, cluster)
|
|
if err != nil {
|
|
log.WithFields(log.Fields{
|
|
"service_id": serviceID,
|
|
"account_id": accountID,
|
|
}).WithError(err).Error("failed to create proxy peer")
|
|
return &proto.CreateProxyPeerResponse{
|
|
Success: false,
|
|
ErrorMessage: strPtr(fmt.Sprintf("create proxy peer: %v", err)),
|
|
}, status.Errorf(codes.Internal, "create proxy peer: %v", err)
|
|
}
|
|
|
|
return &proto.CreateProxyPeerResponse{
|
|
Success: true,
|
|
}, nil
|
|
}
|
|
|
|
// strPtr is a helper to create a string pointer for optional proto fields
|
|
func strPtr(s string) *string {
|
|
return &s
|
|
}
|
|
|
|
func (s *ProxyServiceServer) GetOIDCURL(ctx context.Context, req *proto.GetOIDCURLRequest) (*proto.GetOIDCURLResponse, error) {
|
|
if err := enforceAccountScope(ctx, req.GetAccountId()); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
redirectURL, err := url.Parse(req.GetRedirectUrl())
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "parse redirect url: %v", err)
|
|
}
|
|
if redirectURL.Scheme != "https" && redirectURL.Scheme != "http" {
|
|
return nil, status.Errorf(codes.InvalidArgument, "redirect URL must use http or https scheme")
|
|
}
|
|
// Validate redirectURL against known service endpoints to avoid abuse of OIDC redirection.
|
|
services, err := s.serviceManager.GetAccountServices(ctx, req.GetAccountId())
|
|
if err != nil {
|
|
log.WithContext(ctx).Errorf("failed to get account services: %v", err)
|
|
return nil, status.Errorf(codes.FailedPrecondition, "get account services: %v", err)
|
|
}
|
|
var found bool
|
|
for _, service := range services {
|
|
if service.Domain == redirectURL.Hostname() {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
log.WithContext(ctx).Debugf("OIDC redirect URL %q does not match any service domain", redirectURL.Hostname())
|
|
return nil, status.Errorf(codes.FailedPrecondition, "service not found in store")
|
|
}
|
|
|
|
provider, err := oidc.NewProvider(ctx, s.oidcConfig.Issuer)
|
|
if err != nil {
|
|
log.WithContext(ctx).Errorf("failed to create OIDC provider: %v", err)
|
|
return nil, status.Errorf(codes.FailedPrecondition, "create OIDC provider: %v", err)
|
|
}
|
|
|
|
scopes := s.oidcConfig.Scopes
|
|
if len(scopes) == 0 {
|
|
scopes = []string{oidc.ScopeOpenID, "profile", "email"}
|
|
}
|
|
|
|
// Generate a random nonce to ensure each OIDC request gets a unique state.
|
|
// Without this, multiple requests to the same URL would generate the same state
|
|
// but different PKCE verifiers, causing the later verifier to overwrite the earlier one.
|
|
nonce := make([]byte, 16)
|
|
if _, err := rand.Read(nonce); err != nil {
|
|
return nil, status.Errorf(codes.Internal, "generate nonce: %v", err)
|
|
}
|
|
nonceB64 := base64.URLEncoding.EncodeToString(nonce)
|
|
|
|
// Using an HMAC here to avoid redirection state being modified.
|
|
// State format: base64(redirectURL)|nonce|hmac(redirectURL|nonce)
|
|
payload := redirectURL.String() + "|" + nonceB64
|
|
hmacSum := s.generateHMAC(payload)
|
|
state := fmt.Sprintf("%s|%s|%s", base64.URLEncoding.EncodeToString([]byte(redirectURL.String())), nonceB64, hmacSum)
|
|
|
|
codeVerifier := oauth2.GenerateVerifier()
|
|
if err := s.pkceVerifierStore.Store(state, codeVerifier, pkceVerifierTTL); err != nil {
|
|
log.WithContext(ctx).Errorf("failed to store PKCE verifier: %v", err)
|
|
return nil, status.Errorf(codes.Internal, "store PKCE verifier: %v", err)
|
|
}
|
|
|
|
return &proto.GetOIDCURLResponse{
|
|
Url: (&oauth2.Config{
|
|
ClientID: s.oidcConfig.ClientID,
|
|
Endpoint: provider.Endpoint(),
|
|
RedirectURL: s.oidcConfig.CallbackURL,
|
|
Scopes: scopes,
|
|
}).AuthCodeURL(state, oauth2.S256ChallengeOption(codeVerifier)),
|
|
}, nil
|
|
}
|
|
|
|
// GetOIDCConfig returns the OIDC configuration for token validation.
|
|
func (s *ProxyServiceServer) GetOIDCConfig() ProxyOIDCConfig {
|
|
return s.oidcConfig
|
|
}
|
|
|
|
// GetOIDCValidationConfig returns the OIDC configuration for token validation
|
|
// in the format needed by ToProtoMapping.
|
|
func (s *ProxyServiceServer) GetOIDCValidationConfig() proxy.OIDCValidationConfig {
|
|
return proxy.OIDCValidationConfig{
|
|
Issuer: s.oidcConfig.Issuer,
|
|
Audiences: []string{s.oidcConfig.Audience},
|
|
KeysLocation: s.oidcConfig.KeysLocation,
|
|
MaxTokenAgeSeconds: 0, // No max token age by default
|
|
}
|
|
}
|
|
|
|
func (s *ProxyServiceServer) generateHMAC(input string) string {
|
|
mac := hmac.New(sha256.New, s.oidcConfig.HMACKey)
|
|
mac.Write([]byte(input))
|
|
return hex.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
// ValidateState validates the state parameter from an OAuth callback.
|
|
// Returns the original redirect URL if valid, or an error if invalid.
|
|
// The HMAC is verified before consuming the PKCE verifier to prevent
|
|
// an attacker from invalidating a legitimate user's auth flow.
|
|
func (s *ProxyServiceServer) ValidateState(state string) (verifier, redirectURL string, err error) {
|
|
// State format: base64(redirectURL)|nonce|hmac(redirectURL|nonce)
|
|
parts := strings.Split(state, "|")
|
|
if len(parts) != 3 {
|
|
return "", "", errors.New("invalid state format")
|
|
}
|
|
|
|
encodedURL := parts[0]
|
|
nonce := parts[1]
|
|
providedHMAC := parts[2]
|
|
|
|
redirectURLBytes, err := base64.URLEncoding.DecodeString(encodedURL)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("invalid state encoding: %w", err)
|
|
}
|
|
redirectURL = string(redirectURLBytes)
|
|
|
|
payload := redirectURL + "|" + nonce
|
|
expectedHMAC := s.generateHMAC(payload)
|
|
|
|
if !hmac.Equal([]byte(providedHMAC), []byte(expectedHMAC)) {
|
|
return "", "", errors.New("invalid state signature")
|
|
}
|
|
|
|
// Consume the PKCE verifier only after HMAC validation passes.
|
|
verifier, ok := s.pkceVerifierStore.LoadAndDelete(state)
|
|
if !ok {
|
|
return "", "", errors.New("no verifier for state")
|
|
}
|
|
|
|
return verifier, redirectURL, nil
|
|
}
|
|
|
|
// Denied reasons reported to the proxy when access is refused because of the
|
|
// account status of the user behind the request.
|
|
const (
|
|
deniedReasonPendingApproval = "pending_approval"
|
|
deniedReasonUserBlocked = "user_blocked"
|
|
deniedReasonUserNotFound = "user_not_found"
|
|
)
|
|
|
|
var (
|
|
// ErrUserPendingApproval reports a user whose account still awaits approval
|
|
// by an administrator and may therefore not hold a proxy session.
|
|
ErrUserPendingApproval = errors.New("user pending approval")
|
|
|
|
// ErrUserBlocked reports a blocked user, who may not hold a proxy session.
|
|
ErrUserBlocked = errors.New("user blocked")
|
|
|
|
errUserUnresolved = errors.New("user could not be resolved")
|
|
)
|
|
|
|
// checkUserStatus reports whether the user's account status permits reverse
|
|
// proxy access, returning the denied reason for the proxy access log together
|
|
// with the sentinel error callers match on. A user awaiting approval is stored
|
|
// as both pending and blocked, so the pending state is reported first: it is
|
|
// the one an administrator can act on.
|
|
func checkUserStatus(user *types.User) (string, error) {
|
|
switch {
|
|
case user == nil:
|
|
return deniedReasonUserNotFound, errUserUnresolved
|
|
case user.PendingApproval:
|
|
return deniedReasonPendingApproval, ErrUserPendingApproval
|
|
case user.IsBlocked():
|
|
return deniedReasonUserBlocked, ErrUserBlocked
|
|
default:
|
|
return "", nil
|
|
}
|
|
}
|
|
|
|
// userStatusDeniedReason returns the denied reason for callers that report a
|
|
// decision rather than an error, and an empty string when the user may proceed.
|
|
func userStatusDeniedReason(user *types.User) string {
|
|
reason, _ := checkUserStatus(user)
|
|
return reason
|
|
}
|
|
|
|
// sameAccount reports whether a user belongs to a service's account. An empty
|
|
// identifier on either side never matches: two unset accounts must not compare
|
|
// equal into a grant.
|
|
func sameAccount(userAccountID, serviceAccountID string) bool {
|
|
return userAccountID != "" && serviceAccountID != "" && userAccountID == serviceAccountID
|
|
}
|
|
|
|
// GenerateSessionToken creates a signed session JWT for the given domain and
|
|
// user. The user's group memberships are embedded in the token so policy-aware
|
|
// middlewares on the proxy can authorise without an extra management round-trip.
|
|
// A user the store cannot resolve, or whose account is pending approval or
|
|
// blocked, gets no token at all, so the browser never receives a session cookie.
|
|
func (s *ProxyServiceServer) GenerateSessionToken(ctx context.Context, domain, userID string, method proxyauth.Method) (string, error) {
|
|
service, err := s.getServiceByDomain(ctx, domain)
|
|
if err != nil {
|
|
return "", fmt.Errorf("service not found for domain %s: %w", domain, err)
|
|
}
|
|
|
|
if service.SessionPrivateKey == "" {
|
|
return "", fmt.Errorf("no session key configured for domain: %s", domain)
|
|
}
|
|
|
|
if s.usersManager == nil {
|
|
return "", errors.New("users manager not configured")
|
|
}
|
|
|
|
user, userGroups, err := s.usersManager.GetUserWithGroups(ctx, userID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("get user %s: %w", userID, err)
|
|
}
|
|
|
|
if user == nil {
|
|
return "", fmt.Errorf("get user %s: %w", userID, errUserUnresolved)
|
|
}
|
|
|
|
// Bind the OIDC identity to the service's account before signing anything
|
|
// with that service's session key. The proxy validates an installed cookie
|
|
// locally against the service public key, so a token minted for a user of
|
|
// another account would be honoured without a management round-trip.
|
|
if !sameAccount(user.AccountID, service.AccountID) {
|
|
return "", fmt.Errorf("user %s does not belong to the service account", userID)
|
|
}
|
|
|
|
if _, err := checkUserStatus(user); err != nil {
|
|
return "", fmt.Errorf("session token for user %s: %w", userID, err)
|
|
}
|
|
|
|
groupIDs, groupNames := pairGroupIDsAndNames(userGroups)
|
|
|
|
return sessionkey.SignToken(
|
|
service.SessionPrivateKey,
|
|
userID,
|
|
user.Email,
|
|
domain,
|
|
method,
|
|
groupIDs,
|
|
groupNames,
|
|
proxyauth.DefaultSessionExpiry,
|
|
)
|
|
}
|
|
|
|
// ValidateUserGroupAccess checks if a user has access to a service.
|
|
// It looks up the service within the user's account only, then optionally checks
|
|
// group membership if BearerAuth with DistributionGroups is configured.
|
|
func (s *ProxyServiceServer) ValidateUserGroupAccess(ctx context.Context, domain, userID string) error {
|
|
user, err := s.usersManager.GetUser(ctx, userID)
|
|
if err != nil {
|
|
return fmt.Errorf("user not found: %s", userID)
|
|
}
|
|
|
|
if _, err := checkUserStatus(user); err != nil {
|
|
return fmt.Errorf("user %s denied access to domain %s: %w", userID, domain, err)
|
|
}
|
|
|
|
service, err := s.getAccountServiceByDomain(ctx, user.AccountID, domain)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if service.Auth.BearerAuth == nil || !service.Auth.BearerAuth.Enabled {
|
|
return nil
|
|
}
|
|
|
|
allowedGroups := service.Auth.BearerAuth.DistributionGroups
|
|
if len(allowedGroups) == 0 {
|
|
return nil
|
|
}
|
|
|
|
allowedSet := make(map[string]bool, len(allowedGroups))
|
|
for _, groupID := range allowedGroups {
|
|
allowedSet[groupID] = true
|
|
}
|
|
|
|
for _, groupID := range user.AutoGroups {
|
|
if allowedSet[groupID] {
|
|
log.WithFields(log.Fields{
|
|
"user_id": user.Id,
|
|
"group_id": groupID,
|
|
"domain": domain,
|
|
}).Debug("User granted access via group membership")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return fmt.Errorf("user %s not in allowed groups for domain %s", user.Id, domain)
|
|
}
|
|
|
|
func (s *ProxyServiceServer) getAccountServiceByDomain(ctx context.Context, accountID, domain string) (*rpservice.Service, error) {
|
|
services, err := s.serviceManager.GetAccountServices(ctx, accountID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get account services: %w", err)
|
|
}
|
|
|
|
for _, service := range services {
|
|
if service.Domain == domain {
|
|
return service, nil
|
|
}
|
|
}
|
|
|
|
return nil, fmt.Errorf("service not found for domain %s in account %s", domain, accountID)
|
|
}
|
|
|
|
// ValidateSession validates a session token and checks if the user has access to the domain.
|
|
func (s *ProxyServiceServer) ValidateSession(ctx context.Context, req *proto.ValidateSessionRequest) (*proto.ValidateSessionResponse, error) {
|
|
domain := req.GetDomain()
|
|
sessionToken := req.GetSessionToken()
|
|
|
|
if domain == "" || sessionToken == "" {
|
|
return deniedSessionResponse("missing domain or session_token"), nil
|
|
}
|
|
|
|
service, err := s.getServiceByDomain(ctx, domain)
|
|
if err != nil {
|
|
log.WithFields(log.Fields{
|
|
"domain": domain,
|
|
"error": err.Error(),
|
|
}).Debug("ValidateSession: service not found")
|
|
//nolint:nilerr
|
|
return deniedSessionResponse("service_not_found"), nil
|
|
}
|
|
|
|
if err := enforceAccountScope(ctx, service.AccountID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
userID, reason := sessionTokenSubject(domain, service, sessionToken)
|
|
if reason != "" {
|
|
return deniedSessionResponse(reason), nil
|
|
}
|
|
|
|
user, userGroups, err := s.usersManager.GetUserWithGroups(ctx, userID)
|
|
if err != nil || user == nil {
|
|
log.WithFields(log.Fields{
|
|
"domain": domain,
|
|
"user_id": userID,
|
|
"error": err,
|
|
}).Debug("ValidateSession: user not found")
|
|
//nolint:nilerr
|
|
return deniedSessionResponse(deniedReasonUserNotFound), nil
|
|
}
|
|
|
|
// A user from another account gets a bare response: none of their identity
|
|
// belongs in an answer to a proxy serving a different account.
|
|
if !sameAccount(user.AccountID, service.AccountID) {
|
|
log.WithFields(log.Fields{
|
|
"domain": domain,
|
|
"user_id": userID,
|
|
"user_account": user.AccountID,
|
|
"service_account": service.AccountID,
|
|
}).Debug("ValidateSession: user account mismatch")
|
|
return deniedSessionResponse("account_mismatch"), nil
|
|
}
|
|
|
|
groupIDs, groupNames := pairGroupIDsAndNames(userGroups)
|
|
|
|
if reason := s.accountUserDeniedReason(domain, service, user); reason != "" {
|
|
return &proto.ValidateSessionResponse{
|
|
Valid: false,
|
|
UserId: user.Id,
|
|
UserEmail: user.Email,
|
|
DeniedReason: reason,
|
|
PeerGroupIds: groupIDs,
|
|
PeerGroupNames: groupNames,
|
|
}, nil
|
|
}
|
|
|
|
log.WithFields(log.Fields{
|
|
"domain": domain,
|
|
"user_id": userID,
|
|
"email": user.Email,
|
|
}).Debug("ValidateSession: access granted")
|
|
|
|
return &proto.ValidateSessionResponse{
|
|
Valid: true,
|
|
UserId: user.Id,
|
|
UserEmail: user.Email,
|
|
PeerGroupIds: groupIDs,
|
|
PeerGroupNames: groupNames,
|
|
}, nil
|
|
}
|
|
|
|
// deniedSessionResponse builds a denial that carries no identity, for the
|
|
// checks that run before a user of this service's account is resolved.
|
|
func deniedSessionResponse(reason string) *proto.ValidateSessionResponse {
|
|
return &proto.ValidateSessionResponse{
|
|
Valid: false,
|
|
DeniedReason: reason,
|
|
}
|
|
}
|
|
|
|
// sessionTokenSubject verifies the session token against the service's session
|
|
// key and returns the user it was minted for, or the reason it cannot be
|
|
// trusted.
|
|
func sessionTokenSubject(domain string, service *rpservice.Service, sessionToken string) (userID, deniedReason string) {
|
|
pubKeyBytes, err := base64.StdEncoding.DecodeString(service.SessionPublicKey)
|
|
if err != nil {
|
|
log.WithFields(log.Fields{
|
|
"domain": domain,
|
|
"error": err.Error(),
|
|
}).Error("ValidateSession: decode public key")
|
|
return "", "invalid_service_config"
|
|
}
|
|
|
|
userID, _, _, _, _, err = proxyauth.ValidateSessionJWT(sessionToken, domain, pubKeyBytes)
|
|
if err != nil {
|
|
log.WithFields(log.Fields{
|
|
"domain": domain,
|
|
"error": err.Error(),
|
|
}).Debug("ValidateSession: invalid session token")
|
|
return "", "invalid_token"
|
|
}
|
|
|
|
return userID, ""
|
|
}
|
|
|
|
// accountUserDeniedReason gates a user of the service's own account, returning
|
|
// an empty string when access is granted. Account status comes before group
|
|
// membership: a user awaiting approval or blocked has no access regardless of
|
|
// the groups they were auto-assigned.
|
|
func (s *ProxyServiceServer) accountUserDeniedReason(domain string, service *rpservice.Service, user *types.User) string {
|
|
if reason := userStatusDeniedReason(user); reason != "" {
|
|
log.WithFields(log.Fields{
|
|
"domain": domain,
|
|
"user_id": user.Id,
|
|
"reason": reason,
|
|
}).Debug("ValidateSession: user status denies access")
|
|
return reason
|
|
}
|
|
|
|
if err := s.checkGroupAccess(service, user); err != nil {
|
|
log.WithFields(log.Fields{
|
|
"domain": domain,
|
|
"user_id": user.Id,
|
|
"error": err.Error(),
|
|
}).Debug("ValidateSession: access denied")
|
|
return "not_in_group"
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func (s *ProxyServiceServer) getServiceByDomain(ctx context.Context, domain string) (*rpservice.Service, error) {
|
|
service, err := s.serviceManager.GetServiceByDomain(ctx, domain)
|
|
if err == nil {
|
|
return service, nil
|
|
}
|
|
|
|
// Fall back to the Agent Network synthesiser scoped directly to the domain's
|
|
// account. Synthesised services are never persisted, so they must resolve
|
|
// here for OIDC / session / tunnel-peer flows against agent-network
|
|
// endpoints. Resolving by domain synthesises only the owning account rather
|
|
// than every tenant on the cluster.
|
|
if synth := s.agentNetworkSynthesizer(); synth != nil {
|
|
svc, serr := synth.SynthesizeServiceForDomain(ctx, domain)
|
|
if serr != nil {
|
|
// A real synthesis failure must surface, not be masked by the
|
|
// original store miss — otherwise a transient DB error looks like
|
|
// "no such service".
|
|
return nil, fmt.Errorf("synthesize agent-network service for %s: %w", domain, serr)
|
|
}
|
|
if svc != nil {
|
|
return svc, nil
|
|
}
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
func (s *ProxyServiceServer) checkGroupAccess(service *rpservice.Service, user *types.User) error {
|
|
if service.Auth.BearerAuth == nil || !service.Auth.BearerAuth.Enabled {
|
|
return nil
|
|
}
|
|
|
|
allowedGroups := service.Auth.BearerAuth.DistributionGroups
|
|
if len(allowedGroups) == 0 {
|
|
return nil
|
|
}
|
|
|
|
allowedSet := make(map[string]bool, len(allowedGroups))
|
|
for _, groupID := range allowedGroups {
|
|
allowedSet[groupID] = true
|
|
}
|
|
|
|
for _, groupID := range user.AutoGroups {
|
|
if allowedSet[groupID] {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return fmt.Errorf("user not in allowed groups")
|
|
}
|
|
|
|
func ptr[T any](v T) *T { return &v }
|
|
|
|
// ValidateTunnelPeer resolves an inbound peer by its WireGuard tunnel IP and
|
|
// checks the peer's group membership against the service's access groups.
|
|
// Peers without a user (machine agents, automation workloads) are first-class
|
|
// callers; authorisation runs off peer-group memberships rather than the
|
|
// optional owning user's auto-groups. On success a session JWT is minted so
|
|
// the proxy can install a cookie and skip subsequent management round-trips.
|
|
func (s *ProxyServiceServer) ValidateTunnelPeer(ctx context.Context, req *proto.ValidateTunnelPeerRequest) (*proto.ValidateTunnelPeerResponse, error) {
|
|
domain := req.GetDomain()
|
|
tunnelIPStr := req.GetTunnelIp()
|
|
|
|
if domain == "" || tunnelIPStr == "" {
|
|
return &proto.ValidateTunnelPeerResponse{
|
|
Valid: false,
|
|
DeniedReason: "missing domain or tunnel_ip",
|
|
}, nil
|
|
}
|
|
|
|
tunnelIP := net.ParseIP(tunnelIPStr)
|
|
if tunnelIP == nil {
|
|
return &proto.ValidateTunnelPeerResponse{
|
|
Valid: false,
|
|
DeniedReason: "invalid_tunnel_ip",
|
|
}, nil
|
|
}
|
|
|
|
service, err := s.getServiceByDomain(ctx, domain)
|
|
if err != nil {
|
|
log.WithFields(log.Fields{"domain": domain, "error": err.Error()}).Debug("ValidateTunnelPeer: service not found")
|
|
//nolint:nilerr
|
|
return &proto.ValidateTunnelPeerResponse{
|
|
Valid: false,
|
|
DeniedReason: "service_not_found",
|
|
}, nil
|
|
}
|
|
|
|
// Mirror ValidateSession: account-scoped (BYOP) proxy tokens may only
|
|
// validate and mint session cookies for their own account's domains.
|
|
if err := enforceAccountScope(ctx, service.AccountID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
peer, err := s.peersManager.GetPeerByTunnelIP(ctx, service.AccountID, tunnelIP)
|
|
if err != nil || peer == nil {
|
|
log.WithFields(log.Fields{"domain": domain, "tunnel_ip": tunnelIPStr}).Debug("ValidateTunnelPeer: peer not found")
|
|
//nolint:nilerr
|
|
return &proto.ValidateTunnelPeerResponse{
|
|
Valid: false,
|
|
DeniedReason: "peer_not_found",
|
|
}, nil
|
|
}
|
|
|
|
_, peerGroups, err := s.peersManager.GetPeerWithGroups(ctx, service.AccountID, peer.ID)
|
|
if err != nil {
|
|
log.WithFields(log.Fields{"domain": domain, "peer_id": peer.ID, "error": err.Error()}).Debug("ValidateTunnelPeer: peer groups lookup failed")
|
|
//nolint:nilerr
|
|
return &proto.ValidateTunnelPeerResponse{
|
|
Valid: false,
|
|
DeniedReason: "peer_not_found",
|
|
}, nil
|
|
}
|
|
|
|
groupIDs, groupNames := pairGroupIDsAndNames(peerGroups)
|
|
owner := s.resolvePeerOwner(ctx, peer, service.AccountID)
|
|
principalID, displayIdentity := s.getTunnelPeerInfo(ctx, domain, service, peer, owner)
|
|
|
|
if reason := peerOwnerDeniedReason(peer, owner); reason != "" {
|
|
log.WithFields(log.Fields{"domain": domain, "peer_id": peer.ID, "user_id": peer.UserID, "reason": reason}).Debug("ValidateTunnelPeer: owner status denies access")
|
|
return &proto.ValidateTunnelPeerResponse{
|
|
Valid: false,
|
|
UserId: principalID,
|
|
UserEmail: displayIdentity,
|
|
DeniedReason: reason,
|
|
PeerGroupIds: groupIDs,
|
|
PeerGroupNames: groupNames,
|
|
}, nil
|
|
}
|
|
|
|
if err := checkPeerGroupAccess(service, groupIDs); err != nil {
|
|
log.WithFields(log.Fields{"domain": domain, "peer_id": peer.ID, "error": err.Error()}).Debug("ValidateTunnelPeer: access denied")
|
|
//nolint:nilerr
|
|
return &proto.ValidateTunnelPeerResponse{
|
|
Valid: false,
|
|
UserId: principalID,
|
|
UserEmail: displayIdentity,
|
|
DeniedReason: "not_in_group",
|
|
PeerGroupIds: groupIDs,
|
|
PeerGroupNames: groupNames,
|
|
}, nil
|
|
}
|
|
|
|
token, err := s.generateSessionToken(ctx, true, service, principalID, displayIdentity, proxyauth.MethodOIDC, groupIDs, groupNames)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
log.WithFields(log.Fields{
|
|
"domain": domain,
|
|
"tunnel_ip": tunnelIPStr,
|
|
"peer_id": peer.ID,
|
|
"principal_id": principalID,
|
|
}).Debug("ValidateTunnelPeer: access granted")
|
|
|
|
return &proto.ValidateTunnelPeerResponse{
|
|
Valid: true,
|
|
UserId: principalID,
|
|
UserEmail: displayIdentity,
|
|
SessionToken: token,
|
|
PeerGroupIds: groupIDs,
|
|
PeerGroupNames: groupNames,
|
|
}, nil
|
|
}
|
|
|
|
// resolvePeerOwner returns the user a peer is linked to, once per request so
|
|
// the status gate and the identity resolution below share a single lookup.
|
|
// Unlinked peers (machine agents) have no owner. A lookup that fails returns
|
|
// nil rather than an error: both callers treat an unresolved owner the same
|
|
// way, and neither may trust one it could not read.
|
|
func (s *ProxyServiceServer) resolvePeerOwner(ctx context.Context, peer *peer.Peer, accountID string) *types.User {
|
|
if peer.UserID == "" {
|
|
return nil
|
|
}
|
|
|
|
user, err := s.usersManager.GetUser(ctx, peer.UserID)
|
|
if err != nil {
|
|
log.WithContext(ctx).Debugf("ValidateTunnelPeer: look up owner %s of peer %s: %v", peer.UserID, peer.ID, err)
|
|
return nil
|
|
}
|
|
|
|
// The lookup is by user ID alone, so a peer row pointing outside the
|
|
// service's account would otherwise resolve a foreign user. Leave the owner
|
|
// unresolved instead: the gate denies it, and neither the response nor the
|
|
// minted token carries an identity from another account.
|
|
if !sameAccount(user.AccountID, accountID) {
|
|
log.WithContext(ctx).Debugf("ValidateTunnelPeer: owner %s of peer %s belongs to another account", peer.UserID, peer.ID)
|
|
return nil
|
|
}
|
|
|
|
return user
|
|
}
|
|
|
|
// peerOwnerDeniedReason gates the mesh fast-path on the account status of the
|
|
// peer's owning user, so a user blocked after registering a peer loses
|
|
// mesh-origin access too. Unlinked peers (machine agents) have no owner to gate
|
|
// on and stay first-class callers. An owner the store cannot resolve denies:
|
|
// an unavailable lookup must not grant access.
|
|
func peerOwnerDeniedReason(peer *peer.Peer, owner *types.User) string {
|
|
if peer.UserID == "" {
|
|
return ""
|
|
}
|
|
|
|
if owner == nil {
|
|
return deniedReasonUserNotFound
|
|
}
|
|
|
|
return userStatusDeniedReason(owner)
|
|
}
|
|
|
|
// getTunnelPeerInfo returns the principal ID and display name for a peer, e.g. a
|
|
// user or peer ID, and peer name or user email. owner is the already-resolved
|
|
// user the peer is linked to, or nil.
|
|
func (s *ProxyServiceServer) getTunnelPeerInfo(ctx context.Context, domain string, service *rpservice.Service, peer *peer.Peer, owner *types.User) (string, string) {
|
|
// Resolve the principal: when the peer is linked to a user, the human is the
|
|
// principal so multiple peers owned by the same user share a single
|
|
// identity. Unlinked peers (machine agents) are their own principal keyed on
|
|
// peer.ID. displayIdentity is what upstream gateways tag spend with —
|
|
// user.Email when linked, peer.Name when not.
|
|
|
|
// If the peer isn't associated with a user, return the peer info directly.
|
|
if peer.UserID == "" {
|
|
return peer.ID, peer.Name
|
|
}
|
|
|
|
// Otherwise, if the peer is linked to a user, the user is the principal and
|
|
// if an IdP is available, we gather details on the user from it.
|
|
principalID := peer.UserID
|
|
displayIdentity := peer.Name
|
|
// Stored column first (cheap, but often empty for OIDC-provisioned users).
|
|
if owner != nil {
|
|
principalID = owner.Id
|
|
if owner.Email != "" {
|
|
displayIdentity = owner.Email
|
|
}
|
|
}
|
|
// IdP enrichment wins when available — the stored email column is a
|
|
// best-effort cache and is frequently empty for OIDC users. Enrichment
|
|
// failures must never fail the RPC; we simply keep the stored/peer identity.
|
|
if s.idpManager != nil {
|
|
if ud, uerr := s.idpManager.GetUserDataByID(ctx, peer.UserID, idp.AppMetadata{WTAccountID: service.AccountID}); uerr == nil && ud != nil && ud.Email != "" {
|
|
displayIdentity = ud.Email
|
|
} else if uerr != nil {
|
|
log.WithFields(log.Fields{"domain": domain, "user_id": peer.UserID, "error": uerr.Error()}).Debug("ValidateTunnelPeer: IdP user enrichment failed; using stored/peer identity")
|
|
}
|
|
}
|
|
|
|
return principalID, displayIdentity
|
|
}
|
|
|
|
// checkPeerGroupAccess gates ValidateTunnelPeer by the service's required
|
|
// groups. Private services authorise against AccessGroups (empty list fails
|
|
// closed — Validate() rejects that at save time but the RPC is the security
|
|
// boundary and must not trust upstream state). Bearer-auth services authorise
|
|
// against DistributionGroups when populated. Non-private non-bearer services
|
|
// are open.
|
|
func checkPeerGroupAccess(service *rpservice.Service, peerGroupIDs []string) error {
|
|
if service.Private {
|
|
if len(service.AccessGroups) == 0 {
|
|
return fmt.Errorf("private service has no access groups")
|
|
}
|
|
return matchAnyGroup(service.AccessGroups, peerGroupIDs)
|
|
}
|
|
if service.Auth.BearerAuth != nil && service.Auth.BearerAuth.Enabled && len(service.Auth.BearerAuth.DistributionGroups) > 0 {
|
|
return matchAnyGroup(service.Auth.BearerAuth.DistributionGroups, peerGroupIDs)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// matchAnyGroup returns nil when peerGroupIDs intersects allowedGroups,
|
|
// else a non-nil error.
|
|
func matchAnyGroup(allowedGroups, peerGroupIDs []string) error {
|
|
if len(allowedGroups) == 0 {
|
|
return fmt.Errorf("no allowed groups configured")
|
|
}
|
|
allowed := make(map[string]struct{}, len(allowedGroups))
|
|
for _, g := range allowedGroups {
|
|
allowed[g] = struct{}{}
|
|
}
|
|
for _, g := range peerGroupIDs {
|
|
if _, ok := allowed[g]; ok {
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("peer not in allowed groups")
|
|
}
|