mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-09 06:59:08 +02:00
[management] Record reverse proxy usage for activity accounting (#7116)
People who only ever reach private services through the reverse proxy were invisible to activity accounting. Active users are counted from user.LastLogin or from the LastSeen of a peer they own, and neither column was written on the proxy paths — so a person signing in via SSO to a proxied service, or a peer serving one over the mesh, never showed up in the 24 hour numbers. Both writes now happen where the proxy already authenticates: - GenerateSessionToken stamps LastLogin after the session token is signed, the same column and the same way the dashboard and device login paths do. - ValidateTunnelPeer stamps the calling peer's LastSeen, the column its owner activates through. The policy lives in a new reverseproxy/activity manager rather than in the gRPC service, matching the module layout the other reverse proxy domains use. It skips what can never count — service users, embedded proxy peers and WASM clients — and throttles peer writes to once an hour, well inside the window accounting asks about and far above the proxy's five minute tunnel cache. The peer write is a single indexed UPDATE that touches only peer_status_last_seen. Connected and SessionStartedAt are left alone so the session-ownership fencing MarkPeerConnectedIfNewerSession relies on is never disturbed, and the timestamp comes from the database clock rather than the caller, for the same reason the other status writers take it from there. The caller's cutoff travels into the statement's WHERE, so concurrent requests for one peer collapse into a single write instead of each acting on its own stale read, and a peer that was never seen — NULL last seen, since Status is an embedded pointer — still records its first activity. Nothing outside the reverse proxy changes behaviour: the only addition elsewhere is the RefreshPeerLastSeen store method the manager calls.
This commit is contained in:
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
|
||||
activitymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/activity/manager"
|
||||
nbproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
|
||||
@@ -221,6 +222,7 @@ func setupAuthCallbackTest(t *testing.T) *testSetup {
|
||||
)
|
||||
|
||||
proxyService.SetServiceManager(&testServiceManager{store: testStore})
|
||||
proxyService.SetActivityManager(activitymanager.NewManager(testStore))
|
||||
|
||||
handler := NewAuthCallbackHandler(proxyService, nil)
|
||||
|
||||
@@ -538,6 +540,55 @@ func TestAuthCallback_UserAllowedToLogin(t *testing.T) {
|
||||
// TestAuthCallback_UserDeniedByAccountStatus asserts that a user whose account
|
||||
// is pending approval or blocked never receives a session token from the OIDC
|
||||
// callback, and that the redirect carries a description the proxy can render.
|
||||
// TestAuthCallback_RecordsUserLogin drives the real OIDC callback and asserts
|
||||
// the login lands on the user row. That timestamp is what activity accounting
|
||||
// reads, and it is the only signal that can ever count someone who reaches
|
||||
// proxy-protected services from a browser and never opens the dashboard.
|
||||
func TestAuthCallback_RecordsUserLogin(t *testing.T) {
|
||||
setup := setupAuthCallbackTest(t)
|
||||
defer setup.cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
before, err := setup.store.GetUserByUserID(ctx, store.LockingStrengthNone, "allowedUserId")
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, before.LastLogin, "fixture user starts with no login on record")
|
||||
|
||||
setup.oidcServer.tokenSubject = "allowedUserId"
|
||||
state := createTestState(t, setup.proxyService, "https://test-proxy.example.com/dashboard")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/reverse-proxy/callback?code=test-auth-code&state="+url.QueryEscape(state), nil)
|
||||
rec := httptest.NewRecorder()
|
||||
setup.router.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusFound, rec.Code)
|
||||
|
||||
after, err := setup.store.GetUserByUserID(ctx, store.LockingStrengthNone, "allowedUserId")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, after.LastLogin, "a completed proxy SSO login must be recorded on the user")
|
||||
require.WithinDuration(t, time.Now().UTC(), after.LastLogin.UTC(), time.Minute, "login should be stamped at sign-in time")
|
||||
}
|
||||
|
||||
// TestAuthCallback_DeniedUserLoginNotRecorded keeps the write on the granted
|
||||
// path: a refused sign-in is not a login.
|
||||
func TestAuthCallback_DeniedUserLoginNotRecorded(t *testing.T) {
|
||||
setup := setupAuthCallbackTest(t)
|
||||
defer setup.cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
setup.oidcServer.tokenSubject = "blockedUserId"
|
||||
state := createTestState(t, setup.proxyService, "https://test-proxy.example.com/dashboard")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/reverse-proxy/callback?code=test-auth-code&state="+url.QueryEscape(state), nil)
|
||||
rec := httptest.NewRecorder()
|
||||
setup.router.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusFound, rec.Code)
|
||||
|
||||
after, err := setup.store.GetUserByUserID(ctx, store.LockingStrengthNone, "blockedUserId")
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, after.LastLogin, "a denied user must not be recorded as having logged in")
|
||||
}
|
||||
|
||||
func TestAuthCallback_UserDeniedByAccountStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
Reference in New Issue
Block a user