mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-17 20:19:55 +00:00
* [management,proxy] Agent network: per-account LLM gateway (policy, metering, multi-provider) (#6555) * [agent-network] Shared proto, OpenAPI schema, and generated types * [agent-network] Management: store, manager, synthesizer, policy engine, provider catalog, HTTP/gRPC API Adds the account-scoped agent-network module: provider/policy/budget CRUD and store, the reverse-proxy service synthesizer, policy selection + limit enforcement, the provider catalog (incl. Vertex AI and AWS Bedrock entries), and the management HTTP + proxy gRPC surfaces. * [management] Fix agent-network proxy-peer fan-out on affected-peer recompute The affected-peers resolver loaded only persisted reverse-proxy services, but agent-network services are synthesized on demand and never persisted. As a result the embedded proxy peer was never folded into the affected set when a client's group changed, so the proxy received no network-map update for a newly authorised client and rejected its handshake until a full resync (restart). loadProxyServices now merges the synthesized agent-network services (injected via a registration hook to avoid an import cycle), so proxy peers learn newly authorised clients immediately. * [proxy] Reverse-proxy middleware framework, chain, and request plumbing The per-target middleware chain (slots, dispatcher, mutation gate, metadata merger), body capture, access-log terminal sink, and the proxy wiring that builds + runs chains for synthesized agent-network services. * [proxy] LLM parsers, pricing, and builtin middlewares (OpenAI, Anthropic, Vertex AI, AWS Bedrock) Request/response parsers and SSE/event-stream metering, the embedded pricing table, and the builtin middleware set: request parser, router, policy limit-check/record, cost meter, guardrail, identity inject, response parser. Includes the path-routed providers — Google Vertex AI (keyfile:: service-account OAuth minting) and AWS Bedrock (bearer auth, invoke/converse/streaming, optional /bedrock prefix) — plus the Models allowlist and unmeterable-publisher deny. * [proxy] IPv6 in-place apply and TCP accept-loop hardening on netstack listeners * [agent-network] End-to-end test suite, module docs, and deployment preset * [agent-network] Fix codespell typos and exclude false positives - labelgen word pool: vermillion -> vermilion, racoon -> raccoon. - codespell ignore list: add flate (Go compress/flate package), recordin (a test-local identifier), and unparseable (a valid alternative spelling used consistently across identifiers + a metadata-value constant). * [management] Set LastSeen on injected proxy peer in realstack test (MySQL strict-mode) The injected embedded proxy peer had a PeerStatus with a zero LastSeen, which serializes to '0000-00-00' and is rejected by MySQL in strict mode (SQLite tolerates it). Set LastSeen to a valid time so SaveAccount succeeds on both engines. * [agent-network] Remove e2e shell-script suite from this branch The end-to-end shell scripts under scripts/e2e/ are maintained in a separate testing suite and are not part of this change set. * [agent-network] Polish module docs: remove internal review scaffolding, fix links, verify diagrams Strip PR-review framing, commit references, absolute paths, and stale internal references from the agent-network module docs; fix broken relative links; verify all diagrams against the current architecture. Remove the internal AI-reviewer prompt file. * [management] Refine session expiration handling to support 3-state encoding for SSO deadlines * [agent-network] Relocate agentnetwork package to internals/modules Move management/server/agentnetwork (and its catalog/, labelgen/, types/ subpackages) to management/internals/modules/agentnetwork, alongside the reverse-proxy module, and rewrite all importers. Pure relocation: package names, the synthesizer + affectedpeers registration hook, and store access (shared store.Store) are unchanged, so no import cycle is introduced (affectedpeers still depends only on the agentnetwork/types leaf). * [agent-network] Co-locate HTTP handlers in the module (RegisterEndpoints) Move the agent-network HTTP handlers from server/http/handlers/agentnetwork into the module at internals/modules/agentnetwork/handlers (package handlers) and rename the entrypoint AddEndpoints -> RegisterEndpoints, matching the reverse-proxy module convention. Wiring in http/handler.go updated accordingly. * Update getting started to point to rc when agent network enabled * Add a reference to a commercial license * Fix docs localhost link * Fix docs localhost link * Add private services domain note * [management] Add agent-network telemetry metrics (#6561) Surface agent-network adoption and usage in the self-hosted metrics worker: distinct accounts, providers, policies, budget rules, accounts with log collection enabled, and aggregated input/output tokens plus cost. Tokens and cost are summed from agent_network_request_usage (the always-written per-request ledger) so the figures are accurate regardless of the log-collection toggle and carry no double-counting. All values come from a handful of indexed aggregate queries run only on the worker's periodic tick. Adds store.AgentNetworkMetrics with GetAgentNetworkMetrics on the Store interface, the SqlStore implementation, and a zero-valued FileStore stub. * Update NetBird server and proxy image versions to 0.74.0-rc.2 * [management,proxy] Reduce agent-network cognitive complexity (#6566) Address the SonarCloud quality-gate findings in new agent-network code by extracting focused helpers. No behavior change. - synthesizer.go: split buildIdentityInjectConfigJSON into per-shape rule builders; extract mergeGuardrail from mergeGuardrails to cut nesting depth. - llm_identity_inject: extract injectionEmitsAnything validation predicate from New. - llm_response_parser/streaming.go: extract applyOpenAIStreamUsage and applyAnthropicStreamUsage (via a named anthropicStreamUsage type) and simplify the OpenAI scanner loop. - reverseproxy.go: decompose ServeHTTP into serveRouteError, buildTargetContext, serveDirect, serveWithChain, captureRequestForChain, serveDeny, newResponseWriter, observeResponse, and forwardUpstream, preserving the defer ordering so response observation still reads the captured writer before it is released. * [management] Move agent-network access-log ingest into the agentnetwork module (#6568) The agent-network access-log ingest path (metaKey wire contract, flatten, usage derivation, and the dual-write of the usage ledger + settings-gated full row) lived in the reverseproxy accesslogs manager, even though the agentnetwork module already owns the rest of that domain — types, read (ListAccessLogs / GetUsageOverview), the budget-counter writes, and retention cleanup. Move it next to the rest: a stateless agentnetwork.IngestAccessLog(ctx, store, entry) that the reverseproxy SaveAccessLog delegates to when the entry is agent-network. Removes the agentNetworkTypes import from the reverseproxy manager. No behavior change; the write/read table separation is unchanged. Adds real-store coverage for the disable->enable log-collection toggle (usage ledger always written, full row gated) plus the metadata parse and group-dedup helpers, which previously had no dedicated tests. * Add session view support in the access log * [management,proxy] Container-based agent-network e2e harness (#6577) * [e2e] Add container-based agent-network e2e harness (Pillar 1) Introduce a self-contained, OIDC-free e2e harness that stands up NetBird in containers, so suites no longer depend on the hand-maintained Tilt stack or a real IdP. - harness brings up the combined server (management + signal + relay + STUN + embedded IdP) in a single container built from combined/Dockerfile.multistage, and mints an admin PAT through the unauthenticated /api/setup bootstrap (NB_SETUP_PAT_ENABLED). API access goes through the existing shared/management/client/rest typed client. - the image is built via the docker CLI (BuildKit) so the Dockerfile's cache mounts are honored; testcontainers then runs the tagged image. - everything is behind the `e2e` build tag so normal builds and unit tests never pull in testcontainers. Adds BuildKit cache mounts to combined/Dockerfile.multistage so source changes recompile incrementally rather than from scratch. Pillar 1 proven by TestCombinedBootstrap: server builds, boots, mints a PAT, and the PAT authenticates a real management API call. * [e2e] Add management-side agent-network scenarios (Pillar 2) Port the API-driven agent-network scenarios from the bash suites to Go, sharing one combined server per package run (TestMain) with each test owning its resource cleanup. Drives the /api/agent-network/* endpoints through the shared REST client's NewRequest primitive with the generated api types. Scenarios: - provider lifecycle (create/get/list/delete + 404 after delete) - provider validation (missing api_key, unknown catalog id → 4xx) - settings collection-toggle round-trip with cluster/subdomain immutability - policy window floor (reject <60s enabled limit, accept at 60s) - consumption read endpoint returns an array All deterministic and dependency-free (dummy provider keys; no upstream calls), so they run headless in CI. * [e2e] Add live chat-through-proxy scenario (Pillar 3) Stand up the full agent-network data path in containers and drive a real chat-completion through the gateway: - harness: a shared docker network (combined server reachable by alias), a proxy container built from the published reverse-proxy image (NB_PROXY_PRIVATE, NB_PROXY_ALLOW_INSECURE, NB_RELAY_TRANSPORT=ws to match the combined server's WS-multiplexed relay) with a generated self-signed wildcard cert, and a netbird client container that joins via a setup key. - the combined image, proxy image, and client image default to the published rc.2 releases (overridable via NB_E2E_*_IMAGE; a bare local tag is built from source instead). Geolocation download is disabled so the server starts without external fetches. - one shared domain is used for the management exposed address, the proxy domain, and the agent-network cluster; the proxy token is minted via the server CLI (global) to match the manual install. TestChatCompletionThroughProxy provisions provider+policy+group+setup key, runs proxy+client, drives an OpenAI chat-completion through the tunnel, and asserts a 200 plus the ingested access-log row. Requires OPENAI_TOKEN (skips otherwise). The provider must be created with enabled=true explicitly — the create default is false despite the API doc. * [e2e] Run the live chat scenario across a provider matrix Replace the single-provider chat test with a data-driven matrix that runs the same scenario through every provider whose credentials are present in the environment (keys/URLs sourced from ~/.llm-keys locally, Actions secrets in CI): - OpenAI (chat), Anthropic (messages), Vercel, OpenRouter, Cloudflare (OpenAI-compatible gateways), and Bedrock (path-routed, bearer, via the messages shape) — covering both wire shapes and the gateway routing. - all providers are created enabled with a unique model string so the proxy's connect-time snapshot carries them all and model->provider routing is unambiguous (provider toggles after connect don't reconcile to a connected proxy). - the client supports both wire shapes (/v1/chat/completions and /v1/messages); Cloudflare gets the openai provider segment appended to its gateway URL. Each provider must return 200 through the tunnel and produce an ingested access-log row. Vertex is intentionally excluded from the uniform matrix: it needs a bespoke rawPredict request shape rather than the shared chat/messages path, so it warrants a dedicated scenario. * [ci] Add manual workflow for the agent-network e2e suite The e2e suite (build tag `e2e`) stands up the combined server + proxy + client in Docker and drives live chat-completions, so it is slow and needs provider credentials. Gate it out of normal CI (it already is, via the build tag) and run it on demand via workflow_dispatch. Provider scenarios skip when their secret is unset, so it degrades gracefully. * [e2e] Add Vertex to the provider matrix; run e2e on ubuntu-latest Vertex (Anthropic-on-Vertex) doesn't share the chat/messages wire shapes: the model travels in a rawPredict path and the proxy mints the service account's OAuth token. Add a Vertex client method that posts /v1/projects/<project>/locations/<region>/publishers/anthropic/models/<model>:rawPredict with the Vertex anthropic_version body, and wire it into the matrix as a path-routed provider (created without a models array). It is keyed off GOOGLE_VERTEX_SA_BASE64 + GOOGLE_VERTEX_PROJECT (region defaults to "global", model to a pinned claude snapshot, both overridable). Also bump the e2e workflow runner to ubuntu-latest and add the Vertex secrets. * Add docker/docker and docker/go-connections as direct dependencies in go.mod * [ci] Trigger agent-network e2e workflow on push to main and pull requests * [e2e] Fix proxy cert permission denied on Linux CI runners The proxy bind-mounts a temp dir of self-signed certs. MkdirTemp creates it 0700 and the key was 0600, which Docker Desktop on macOS ignores but a non-root proxy container on Linux runners cannot traverse/read, so the cert watcher failed with "open /certs/tls.crt: permission denied" and the container exited. Widen the cert dir to 0755 and write the throwaway key 0644 so the proxy uid can read the bind-mounted material. * [e2e] Build images from source by default instead of pulling rc.2 The agent-network code under test lives in this branch, so the e2e should exercise it rather than a frozen published release. Flip the harness default: combined/proxy/client are now built from their in-repo Dockerfiles (combined/Dockerfile.multistage, proxy/Dockerfile.multistage, e2e/harness/Dockerfile.client) under local tags. Pulling a published image stays available by setting NB_E2E_*_IMAGE to a registry reference. Builds now go through buildx --load so the Dockerfile cache mounts are honored and the result is loaded for testcontainers. The CI workflow adds a container-driver builder and a local layer cache (NB_E2E_BUILDX_CACHE) persisted via actions/cache, which caches the base/apt/dep-download layers across runs. The Go compile still re-runs each time, as BuildKit mount caches cannot be exported to the GitHub cache. * [e2e] Cover real providers in lifecycle + assert real consumption metering - TestProviderLifecycle now runs per available real provider (create → get → list → delete → 404) instead of a single dummy provider, exercising each catalog's create and field round-trip. Create is offline, so it stays fast and burns no provider quota; falls back to a synthetic OpenAI provider when no keys are set. - TestProvidersMatrix attaches a token limit (high caps, 60s window) to its policy, which switches on usage metering, and asserts consumption rows are recorded with positive token counts after the live traffic. Consumption is account-scoped (keyed by source group / user and window, not per provider), so the assertion is aggregate. - TestProviderValidation gains invalid-upstream and blank-name cases. Create validation is uniform across catalogs (no per-provider required-field rules), so per-provider rejection cases would be redundant. * [e2e] Assert session id propagates per provider Each matrix request now sends a unique session id as the universal x-session-id header and asserts it round-trips into that provider's access-log row. This guards the session-grouping contract end to end for every provider (header extraction runs in llm_request_parser ahead of the parser-specific body extraction, so it is provider-agnostic). * [e2e] Drop accidentally committed sync-phases dashboard netbird-sync-phases.json was swept into the Pillar 1 commit by a broad git add; it belongs to the unrelated sync-phases metrics work, not this e2e harness. Remove it from the branch so the PR diff is scoped to the e2e changes. * [e2e] Revert accidentally committed sync-phase ingest spec The netbird_sync_phase measurement spec in metrics ingest was swept into the Pillar 1 commit; it belongs to the unrelated sync-phases metrics work, not this e2e harness. Its emission side never landed here, so the spec was orphaned anyway. Restore ingest/main.go to its origin/main state. * Fix golint issues * Fix sonar * Add access log session test * Fix access log tests --------- Co-authored-by: braginini <bangvalo@gmail.com> Co-authored-by: Zoltan Papp <zoltan.pmail@gmail.com>
2021 lines
69 KiB
Go
2021 lines
69 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/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/internals/modules/agentnetwork"
|
|
"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(),
|
|
})
|
|
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)
|
|
}
|
|
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)
|
|
}
|
|
|
|
conn.cancel()
|
|
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
|
|
}
|
|
|
|
// 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.
|
|
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)
|
|
}
|
|
|
|
var (
|
|
email string
|
|
groupIDs []string
|
|
groupNames []string
|
|
)
|
|
if s.usersManager != nil {
|
|
user, userGroups, uerr := s.usersManager.GetUserWithGroups(ctx, userID)
|
|
if uerr != nil {
|
|
log.WithContext(ctx).Debugf("session token mint: lookup user %s: %v", userID, uerr)
|
|
} else if user != nil {
|
|
email = user.Email
|
|
groupIDs, groupNames = pairGroupIDsAndNames(userGroups)
|
|
}
|
|
}
|
|
|
|
return sessionkey.SignToken(
|
|
service.SessionPrivateKey,
|
|
userID,
|
|
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)
|
|
}
|
|
|
|
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 &proto.ValidateSessionResponse{
|
|
Valid: false,
|
|
DeniedReason: "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 &proto.ValidateSessionResponse{
|
|
Valid: false,
|
|
DeniedReason: "service_not_found",
|
|
}, nil
|
|
}
|
|
|
|
if err := enforceAccountScope(ctx, service.AccountID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
pubKeyBytes, err := base64.StdEncoding.DecodeString(service.SessionPublicKey)
|
|
if err != nil {
|
|
log.WithFields(log.Fields{
|
|
"domain": domain,
|
|
"error": err.Error(),
|
|
}).Error("ValidateSession: decode public key")
|
|
//nolint:nilerr
|
|
return &proto.ValidateSessionResponse{
|
|
Valid: false,
|
|
DeniedReason: "invalid_service_config",
|
|
}, nil
|
|
}
|
|
|
|
userID, _, _, _, _, err := proxyauth.ValidateSessionJWT(sessionToken, domain, pubKeyBytes)
|
|
if err != nil {
|
|
log.WithFields(log.Fields{
|
|
"domain": domain,
|
|
"error": err.Error(),
|
|
}).Debug("ValidateSession: invalid session token")
|
|
//nolint:nilerr
|
|
return &proto.ValidateSessionResponse{
|
|
Valid: false,
|
|
DeniedReason: "invalid_token",
|
|
}, nil
|
|
}
|
|
|
|
user, userGroups, err := s.usersManager.GetUserWithGroups(ctx, userID)
|
|
if err != nil {
|
|
log.WithFields(log.Fields{
|
|
"domain": domain,
|
|
"user_id": userID,
|
|
"error": err.Error(),
|
|
}).Debug("ValidateSession: user not found")
|
|
//nolint:nilerr
|
|
return &proto.ValidateSessionResponse{
|
|
Valid: false,
|
|
DeniedReason: "user_not_found",
|
|
}, nil
|
|
}
|
|
|
|
if 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")
|
|
//nolint:nilerr
|
|
return &proto.ValidateSessionResponse{
|
|
Valid: false,
|
|
DeniedReason: "account_mismatch",
|
|
}, nil
|
|
}
|
|
|
|
if err := s.checkGroupAccess(service, user); err != nil {
|
|
log.WithFields(log.Fields{
|
|
"domain": domain,
|
|
"user_id": userID,
|
|
"error": err.Error(),
|
|
}).Debug("ValidateSession: access denied")
|
|
groupIDs, groupNames := pairGroupIDsAndNames(userGroups)
|
|
//nolint:nilerr
|
|
return &proto.ValidateSessionResponse{
|
|
Valid: false,
|
|
UserId: user.Id,
|
|
UserEmail: user.Email,
|
|
DeniedReason: "not_in_group",
|
|
PeerGroupIds: groupIDs,
|
|
PeerGroupNames: groupNames,
|
|
}, nil
|
|
}
|
|
|
|
log.WithFields(log.Fields{
|
|
"domain": domain,
|
|
"user_id": userID,
|
|
"email": user.Email,
|
|
}).Debug("ValidateSession: access granted")
|
|
|
|
groupIDs, groupNames := pairGroupIDsAndNames(userGroups)
|
|
return &proto.ValidateSessionResponse{
|
|
Valid: true,
|
|
UserId: user.Id,
|
|
UserEmail: user.Email,
|
|
PeerGroupIds: groupIDs,
|
|
PeerGroupNames: groupNames,
|
|
}, nil
|
|
}
|
|
|
|
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)
|
|
principalID, displayIdentity := s.getTunnelPeerInfo(ctx, domain, service, peer)
|
|
|
|
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
|
|
}
|
|
|
|
// getTunnelPeerInfo returns the principal ID and display name for a peer, e.g. a
|
|
// user or peer ID, and peer name or user email.
|
|
func (s *ProxyServiceServer) getTunnelPeerInfo(ctx context.Context, domain string, service *rpservice.Service, peer *peer.Peer) (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 user, uerr := s.usersManager.GetUser(ctx, peer.UserID); uerr == nil && user != nil {
|
|
principalID = user.Id
|
|
if user.Email != "" {
|
|
displayIdentity = user.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")
|
|
}
|