mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-12 02:31:28 +02:00
Compare commits
5 Commits
agent-netw
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
052cf5a748 | ||
|
|
95a458801c | ||
|
|
14f9f8ce22 | ||
|
|
f805c149d9 | ||
|
|
99048e2bf2 |
@@ -112,6 +112,7 @@ aligns with our security standards and design expectations.
|
||||
- [Test suite](#test-suite)
|
||||
- [Checklist before submitting a PR](#checklist-before-submitting-a-pr)
|
||||
- [When we close a PR](#when-we-close-a-pr)
|
||||
- [Translations](#translations)
|
||||
- [Other project repositories](#other-project-repositories)
|
||||
- [Contributor License Agreement](#contributor-license-agreement)
|
||||
|
||||
@@ -612,6 +613,17 @@ A closed PR is not a rejected idea. Take it back to the
|
||||
[discussion](https://github.com/netbirdio/netbird/discussions), settle the
|
||||
approach, and reopen the work from there.
|
||||
|
||||
## Translations
|
||||
|
||||
Desktop UI translations are not contributed through pull requests. Translate on
|
||||
[Crowdin](https://crowdin.com/project/netbird) instead: no ticket needed, just
|
||||
join the project and pick your language. Crowdin syncs with this repository and
|
||||
opens the service PRs itself, so hand-edited locale files would conflict with
|
||||
the next sync. Style, terminology, and review guidance live in
|
||||
[client/ui/i18n/TRANSLATING.md](client/ui/i18n/TRANSLATING.md). To request a
|
||||
language the project does not offer yet, ask on the Crowdin project page or in
|
||||
a [discussion](https://github.com/netbirdio/netbird/discussions).
|
||||
|
||||
## Other project repositories
|
||||
|
||||
NetBird project is composed of 3 main repositories:
|
||||
|
||||
@@ -243,7 +243,7 @@ func (s *Server) setUserEnvironmentVariables(envMap map[string]string, userProfi
|
||||
|
||||
// prepareCommandEnv prepares environment variables for command execution on Windows
|
||||
func (s *Server) prepareCommandEnv(logger *log.Entry, localUser *user.User, session ssh.Session) []string {
|
||||
username, domain := s.parseUsername(localUser.Username)
|
||||
username, domain := parseUsername(localUser.Username)
|
||||
userEnv, err := s.getUserEnvironment(logger, username, domain)
|
||||
if err != nil {
|
||||
log.Debugf("failed to get user environment for %s\\%s, using fallback: %v", domain, username, err)
|
||||
@@ -383,7 +383,7 @@ func (s *Server) executeCommandWithPty(logger *log.Entry, session ssh.Session, _
|
||||
return false
|
||||
}
|
||||
|
||||
username, domain := s.parseUsername(localUser.Username)
|
||||
username, domain := parseUsername(localUser.Username)
|
||||
shell := getUserShell(localUser.Uid)
|
||||
|
||||
req := PtyExecutionRequest{
|
||||
|
||||
@@ -133,7 +133,12 @@ func (s *Server) checkPrivilegedPortAccess(forwardType string, port uint32, resu
|
||||
return nil
|
||||
}
|
||||
|
||||
if result.User != nil && isPrivilegedUsername(result.User.Username) {
|
||||
// Only uid 0 may bind below the threshold, which is the kernel's own rule and
|
||||
// is asked directly rather than through isPrivilegedOrUnknown: that helper
|
||||
// reports an account it cannot evaluate as privileged, which is safe for a
|
||||
// refusal and unsafe for a grant such as this one. Windows has returned
|
||||
// above, so Uid here is a Unix uid and never a SID.
|
||||
if result.User != nil && result.User.Uid == "0" {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
16
client/ssh/server/privileges_other.go
Normal file
16
client/ssh/server/privileges_other.go
Normal file
@@ -0,0 +1,16 @@
|
||||
//go:build !windows
|
||||
|
||||
package server
|
||||
|
||||
// isProcessElevated is only meaningful on Windows; other platforms use the
|
||||
// effective UID check in isCurrentProcessPrivileged.
|
||||
func isProcessElevated() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// isWindowsAccountPrivilegedOrUnknown is only reachable on Windows. Report
|
||||
// privileged on other platforms so a caller refusing privileged accounts fails
|
||||
// closed.
|
||||
func isWindowsAccountPrivilegedOrUnknown(string) bool {
|
||||
return true
|
||||
}
|
||||
228
client/ssh/server/privileges_windows.go
Normal file
228
client/ssh/server/privileges_windows.go
Normal file
@@ -0,0 +1,228 @@
|
||||
//go:build windows
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unsafe"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var (
|
||||
netapi32 = windows.NewLazySystemDLL("netapi32.dll")
|
||||
procNetUserGetLocalGroups = netapi32.NewProc("NetUserGetLocalGroups")
|
||||
)
|
||||
|
||||
const (
|
||||
// lgIncludeIndirect makes NetUserGetLocalGroups also return local groups
|
||||
// the user belongs to through a global group.
|
||||
lgIncludeIndirect = 0x1
|
||||
maxPreferredLength = 0xFFFFFFFF
|
||||
)
|
||||
|
||||
// localGroupUsersInfo0 mirrors LOCALGROUP_USERS_INFO_0.
|
||||
type localGroupUsersInfo0 struct {
|
||||
name *uint16
|
||||
}
|
||||
|
||||
// isProcessElevated reports whether the current process token is elevated
|
||||
// (TokenElevation): true for elevated administrators, the built-in
|
||||
// Administrator, administrators with UAC disabled, and SYSTEM; false for
|
||||
// standard users and administrators running with a UAC-filtered token.
|
||||
func isProcessElevated() bool {
|
||||
return windows.GetCurrentProcessToken().IsElevated()
|
||||
}
|
||||
|
||||
// isWindowsAccountPrivilegedOrUnknown reports whether the account is privileged
|
||||
// on this machine: a well-known service account, a built-in Administrator
|
||||
// (RID 500), or a member of the local Administrators group, directly or through
|
||||
// nested groups.
|
||||
//
|
||||
// An account whose privilege cannot be determined counts as privileged, which
|
||||
// is why the name says "or unknown". That is fail-closed for a caller that
|
||||
// refuses privileged accounts, and fail-open for a caller that grants something
|
||||
// to them, so only the former may use this.
|
||||
func isWindowsAccountPrivilegedOrUnknown(username string) bool {
|
||||
sid, _, _, err := windows.LookupSID("", username)
|
||||
if err != nil {
|
||||
log.Warnf("privilege check: SID lookup for %q failed, treating as privileged: %v", username, err)
|
||||
return true
|
||||
}
|
||||
|
||||
if isPrivilegedUserSID(sid) {
|
||||
return true
|
||||
}
|
||||
|
||||
member, err := isLocalAdminsMember(username)
|
||||
if err != nil {
|
||||
log.Warnf("privilege check: cannot determine Administrators membership for %q, treating as privileged: %v", username, err)
|
||||
return true
|
||||
}
|
||||
return member
|
||||
}
|
||||
|
||||
// isPrivilegedUserSID reports whether the SID itself identifies a privileged
|
||||
// principal, without consulting group membership.
|
||||
func isPrivilegedUserSID(sid *windows.SID) bool {
|
||||
wellKnown := []windows.WELL_KNOWN_SID_TYPE{
|
||||
windows.WinLocalSystemSid,
|
||||
windows.WinLocalServiceSid,
|
||||
windows.WinNetworkServiceSid,
|
||||
windows.WinBuiltinAdministratorsSid,
|
||||
}
|
||||
for _, sidType := range wellKnown {
|
||||
if sid.IsWellKnown(sidType) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return isBuiltinAdministratorSID(sid)
|
||||
}
|
||||
|
||||
// isBuiltinAdministratorSID reports whether the SID is a machine or domain
|
||||
// built-in Administrator account (S-1-5-21-...-500). RID 500 is reserved for
|
||||
// that account; it can be renamed but cannot be removed from the
|
||||
// Administrators group.
|
||||
func isBuiltinAdministratorSID(sid *windows.SID) bool {
|
||||
if sid.IdentifierAuthority() != windows.SECURITY_NT_AUTHORITY {
|
||||
return false
|
||||
}
|
||||
count := sid.SubAuthorityCount()
|
||||
if count < 2 || sid.SubAuthority(0) != 21 {
|
||||
return false
|
||||
}
|
||||
return sid.SubAuthority(uint32(count-1)) == 500
|
||||
}
|
||||
|
||||
// isLocalAdminsMember reports whether the account is a member of the local
|
||||
// Administrators group.
|
||||
//
|
||||
// Local accounts are checked against the local SAM, which is authoritative for
|
||||
// them and, unlike a token, cannot under-report: UAC filters the tokens of
|
||||
// local administrators, and a filtered token carries Administrators as
|
||||
// deny-only, which a membership check on the token would read as "not a
|
||||
// member". Domain accounts are exempt from that filtering, so for them an S4U
|
||||
// token is preferred because its group list is LSA's transitive expansion and
|
||||
// therefore covers nested and universal groups plus the machine's own local
|
||||
// groups. NetUserGetLocalGroups expands only one global-group hop but needs no
|
||||
// logon, so it serves as the fallback when no token can be obtained.
|
||||
func isLocalAdminsMember(username string) (bool, error) {
|
||||
adminSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("create Administrators SID: %w", err)
|
||||
}
|
||||
|
||||
account, domain := parseUsername(username)
|
||||
if NewPrivilegeDropper().isLocalUser(domain) {
|
||||
return localGroupsContainSID(account, adminSid)
|
||||
}
|
||||
|
||||
member, s4uErr := s4uTokenIsMember(account, domain, adminSid)
|
||||
if s4uErr == nil {
|
||||
return member, nil
|
||||
}
|
||||
log.Debugf("privilege check: S4U membership check for %q failed, falling back to local group enumeration: %v", username, s4uErr)
|
||||
|
||||
member, err = localGroupsContainSID(buildUserCpn(account, domain), adminSid)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("S4U check: %w; local group enumeration: %w", s4uErr, err)
|
||||
}
|
||||
return member, nil
|
||||
}
|
||||
|
||||
// s4uTokenIsMember obtains an S4U token for the account and checks whether the
|
||||
// given SID is enabled in it.
|
||||
func s4uTokenIsMember(account, domain string, sid *windows.SID) (bool, error) {
|
||||
token, err := generateS4UUserToken(log.NewEntry(log.StandardLogger()), account, domain)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer func() {
|
||||
if err := windows.CloseHandle(token); err != nil {
|
||||
log.Debugf("close S4U token: %v", err)
|
||||
}
|
||||
}()
|
||||
return windows.Token(token).IsMember(sid)
|
||||
}
|
||||
|
||||
// localGroupsContainSID reports whether the wanted group is among the local
|
||||
// groups the account belongs to, directly or through a global group.
|
||||
//
|
||||
// The wanted SID is resolved to its group name once and compared against the
|
||||
// enumerated names. Well-known SIDs resolve from a static table, so that lookup
|
||||
// needs no domain controller, and it keeps the comparison correct for a renamed
|
||||
// or localized group because both sides then carry the new name. Resolving each
|
||||
// enumerated name back to a SID instead would add a lookup per group that can
|
||||
// block until it times out while a domain controller is unreachable, and cannot
|
||||
// change the outcome: the names enumerated here are local groups of this
|
||||
// machine, whose names are unique, so a name match identifies the group.
|
||||
//
|
||||
// A failure to resolve the wanted SID is returned rather than reported as
|
||||
// "not a member", so a privilege check built on this fails closed.
|
||||
func localGroupsContainSID(username string, want *windows.SID) (bool, error) {
|
||||
wantName, _, _, err := want.LookupAccount("")
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("resolve group SID %s to a name: %w", want, err)
|
||||
}
|
||||
|
||||
groups, err := netUserGetLocalGroups(username)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
for _, group := range groups {
|
||||
if strings.EqualFold(group, wantName) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// netUserGetLocalGroups returns the names of the local groups the account is a
|
||||
// member of, including indirect membership through global groups.
|
||||
func netUserGetLocalGroups(username string) ([]string, error) {
|
||||
name16, err := windows.UTF16PtrFromString(username)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("convert username: %w", err)
|
||||
}
|
||||
|
||||
var buf *byte
|
||||
var entriesRead, totalEntries uint32
|
||||
status, _, _ := procNetUserGetLocalGroups.Call(
|
||||
0, // local server
|
||||
uintptr(unsafe.Pointer(name16)),
|
||||
0, // level 0: LOCALGROUP_USERS_INFO_0
|
||||
lgIncludeIndirect,
|
||||
uintptr(unsafe.Pointer(&buf)),
|
||||
maxPreferredLength,
|
||||
uintptr(unsafe.Pointer(&entriesRead)),
|
||||
uintptr(unsafe.Pointer(&totalEntries)),
|
||||
)
|
||||
if status != 0 {
|
||||
return nil, fmt.Errorf("NetUserGetLocalGroups for %q: status %d", username, status)
|
||||
}
|
||||
if buf == nil {
|
||||
return nil, nil
|
||||
}
|
||||
defer func() {
|
||||
if err := windows.NetApiBufferFree(buf); err != nil {
|
||||
log.Debugf("free NetApi buffer: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// MAX_PREFERRED_LENGTH makes the API allocate as much as it needs, so a
|
||||
// short read is not expected. Report it rather than silently returning a
|
||||
// subset of the account's groups.
|
||||
if entriesRead != totalEntries {
|
||||
return nil, fmt.Errorf("NetUserGetLocalGroups for %q returned %d of %d groups", username, entriesRead, totalEntries)
|
||||
}
|
||||
|
||||
entries := unsafe.Slice((*localGroupUsersInfo0)(unsafe.Pointer(buf)), entriesRead)
|
||||
groups := make([]string, 0, entriesRead)
|
||||
for _, entry := range entries {
|
||||
groups = append(groups, windows.UTF16PtrToString(entry.name))
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
293
client/ssh/server/privileges_windows_test.go
Normal file
293
client/ssh/server/privileges_windows_test.go
Normal file
@@ -0,0 +1,293 @@
|
||||
//go:build windows
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"os/user"
|
||||
"testing"
|
||||
"unsafe"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// filterNormalAccount limits NetUserEnum to normal user accounts.
|
||||
const filterNormalAccount = 0x2
|
||||
|
||||
// TOKEN_ELEVATION_TYPE values.
|
||||
const (
|
||||
tokenElevationTypeDefault = 1
|
||||
tokenElevationTypeFull = 2
|
||||
tokenElevationTypeLimited = 3
|
||||
)
|
||||
|
||||
// tokenElevationType reads TokenElevationType from a token.
|
||||
func tokenElevationType(token windows.Token) (uint32, error) {
|
||||
var elevationType, returnedLen uint32
|
||||
err := windows.GetTokenInformation(token, windows.TokenElevationType,
|
||||
(*byte)(unsafe.Pointer(&elevationType)), uint32(unsafe.Sizeof(elevationType)), &returnedLen)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return elevationType, nil
|
||||
}
|
||||
|
||||
// userInfo0 mirrors USER_INFO_0.
|
||||
type userInfo0 struct {
|
||||
name *uint16
|
||||
}
|
||||
|
||||
func mustParseSID(t *testing.T, s string) *windows.SID {
|
||||
t.Helper()
|
||||
sid, err := windows.StringToSid(s)
|
||||
require.NoError(t, err, "parse SID %s", s)
|
||||
return sid
|
||||
}
|
||||
|
||||
// localAccountNames returns the names of the local user accounts.
|
||||
func localAccountNames(t *testing.T) []string {
|
||||
t.Helper()
|
||||
|
||||
var buf *byte
|
||||
var entriesRead, totalEntries, resume uint32
|
||||
err := windows.NetUserEnum(nil, 0, filterNormalAccount, &buf, maxPreferredLength,
|
||||
&entriesRead, &totalEntries, &resume)
|
||||
require.NoError(t, err, "enumerate local users")
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, windows.NetApiBufferFree(buf), "free NetApi buffer")
|
||||
})
|
||||
|
||||
entries := unsafe.Slice((*userInfo0)(unsafe.Pointer(buf)), entriesRead)
|
||||
names := make([]string, 0, entriesRead)
|
||||
for _, entry := range entries {
|
||||
names = append(names, windows.UTF16PtrToString(entry.name))
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// localAccountNameByRID returns the name of the local account carrying the
|
||||
// given RID. Accounts such as Administrator and Guest can be renamed and are
|
||||
// localized, so tests must not name them literally.
|
||||
func localAccountNameByRID(t *testing.T, rid uint32) string {
|
||||
t.Helper()
|
||||
|
||||
for _, name := range localAccountNames(t) {
|
||||
sid, _, _, err := windows.LookupSID("", name)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if sid.IdentifierAuthority() != windows.SECURITY_NT_AUTHORITY {
|
||||
continue
|
||||
}
|
||||
count := sid.SubAuthorityCount()
|
||||
if count < 2 || sid.SubAuthority(0) != 21 {
|
||||
continue
|
||||
}
|
||||
if sid.SubAuthority(uint32(count-1)) == rid {
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatalf("no local account with RID %d", rid)
|
||||
return ""
|
||||
}
|
||||
|
||||
// wellKnownAccountName resolves a well-known SID to the qualified account name
|
||||
// the local system uses for it, which is localized.
|
||||
func wellKnownAccountName(t *testing.T, sidType windows.WELL_KNOWN_SID_TYPE) string {
|
||||
t.Helper()
|
||||
|
||||
sid, err := windows.CreateWellKnownSid(sidType)
|
||||
require.NoError(t, err, "create well-known SID")
|
||||
name, domain, _, err := sid.LookupAccount("")
|
||||
require.NoError(t, err, "resolve %s to an account name", sid)
|
||||
if domain == "" {
|
||||
return name
|
||||
}
|
||||
return domain + `\` + name
|
||||
}
|
||||
|
||||
func TestIsBuiltinAdministratorSID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sid string
|
||||
want bool
|
||||
}{
|
||||
{"machine_administrator", "S-1-5-21-1111111111-2222222222-3333333333-500", true},
|
||||
{"domain_administrator", "S-1-5-21-3390233681-4087452608-412898826-500", true},
|
||||
{"regular_user", "S-1-5-21-1111111111-2222222222-3333333333-1001", false},
|
||||
{"guest_account", "S-1-5-21-1111111111-2222222222-3333333333-501", false},
|
||||
{"domain_admins_group", "S-1-5-21-1111111111-2222222222-3333333333-512", false},
|
||||
{"system", "S-1-5-18", false},
|
||||
{"administrators_group", "S-1-5-32-544", false},
|
||||
{"non_nt_authority", "S-1-1-0", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isBuiltinAdministratorSID(mustParseSID(t, tt.sid))
|
||||
assert.Equal(t, tt.want, result, "RID 500 detection for %s", tt.sid)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPrivilegedUserSID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sid string
|
||||
want bool
|
||||
}{
|
||||
{"local_system", "S-1-5-18", true},
|
||||
{"local_service", "S-1-5-19", true},
|
||||
{"network_service", "S-1-5-20", true},
|
||||
{"administrators_group", "S-1-5-32-544", true},
|
||||
{"builtin_administrator", "S-1-5-21-1111111111-2222222222-3333333333-500", true},
|
||||
{"regular_user", "S-1-5-21-1111111111-2222222222-3333333333-1001", false},
|
||||
{"users_group", "S-1-5-32-545", false},
|
||||
{"everyone", "S-1-1-0", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isPrivilegedUserSID(mustParseSID(t, tt.sid))
|
||||
assert.Equal(t, tt.want, result, "SID privilege classification for %s", tt.sid)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsWindowsAccountPrivilegedOrUnknown(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
username string
|
||||
want bool
|
||||
}{
|
||||
{"system", wellKnownAccountName(t, windows.WinLocalSystemSid), true},
|
||||
{"local_service", wellKnownAccountName(t, windows.WinLocalServiceSid), true},
|
||||
{"network_service", wellKnownAccountName(t, windows.WinNetworkServiceSid), true},
|
||||
{"administrators_group", wellKnownAccountName(t, windows.WinBuiltinAdministratorsSid), true},
|
||||
// The built-in Administrator (RID 500) and Guest (RID 501) accounts
|
||||
// exist on every Windows installation, though they may be disabled.
|
||||
{"builtin_administrator", localAccountNameByRID(t, 500), true},
|
||||
{"guest", localAccountNameByRID(t, 501), false},
|
||||
// Unresolvable accounts fail closed.
|
||||
{"nonexistent_user", "netbird-no-such-user", true},
|
||||
{"empty_username", "", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isWindowsAccountPrivilegedOrUnknown(tt.username)
|
||||
assert.Equal(t, tt.want, result, "account privilege classification for %q", tt.username)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsProcessElevated(t *testing.T) {
|
||||
elevated := isProcessElevated()
|
||||
|
||||
// TokenElevationType is a second, independent view of the same token:
|
||||
// Full means elevated and Limited means a filtered administrator, while
|
||||
// Default covers both a standard user and an administrator with no linked
|
||||
// token (UAC off, the built-in Administrator, SYSTEM), so it implies nothing.
|
||||
elevationType, err := tokenElevationType(windows.GetCurrentProcessToken())
|
||||
require.NoError(t, err, "read token elevation type")
|
||||
|
||||
adminSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
|
||||
require.NoError(t, err, "create Administrators SID")
|
||||
|
||||
// Token(0) makes CheckTokenMembership evaluate the caller's own token. It
|
||||
// counts only enabled SIDs, so a filtered administrator reports false here.
|
||||
member, err := windows.Token(0).IsMember(adminSid)
|
||||
require.NoError(t, err, "check own Administrators membership")
|
||||
|
||||
t.Logf("elevated=%v elevationType=%d memberOfAdministrators=%v", elevated, elevationType, member)
|
||||
|
||||
switch elevationType {
|
||||
case tokenElevationTypeFull:
|
||||
assert.True(t, elevated, "a token of elevation type Full must report elevated")
|
||||
case tokenElevationTypeLimited:
|
||||
assert.False(t, elevated, "a filtered administrator token must not report elevated")
|
||||
}
|
||||
|
||||
// Administrators enabled in the token means the token wields administrative
|
||||
// rights, which is what elevation reports.
|
||||
if member {
|
||||
assert.True(t, elevated, "token with enabled Administrators membership must report elevated")
|
||||
}
|
||||
}
|
||||
|
||||
// TestS4UMembershipAgreesWithLocalGroups exercises the S4U token path used
|
||||
// for domain accounts. S4U logons need the TCB privilege, so the test runs
|
||||
// only as SYSTEM (which is how CI executes the suite). For local accounts the
|
||||
// token's Administrators membership must agree with the SAM enumeration.
|
||||
func TestS4UMembershipAgreesWithLocalGroups(t *testing.T) {
|
||||
system, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid)
|
||||
require.NoError(t, err, "create SYSTEM SID")
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err, "get current user")
|
||||
if current.Uid != system.String() {
|
||||
t.Skipf("S4U logon requires SYSTEM (running as %s)", current.Username)
|
||||
}
|
||||
|
||||
adminSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
|
||||
require.NoError(t, err, "create Administrators SID")
|
||||
|
||||
checked := 0
|
||||
for _, name := range localAccountNames(t) {
|
||||
viaToken, err := s4uTokenIsMember(name, ".", adminSid)
|
||||
if err != nil {
|
||||
// Disabled or logon-restricted accounts cannot get an S4U logon.
|
||||
t.Logf("skipping %s: %v", name, err)
|
||||
continue
|
||||
}
|
||||
viaSAM, err := localGroupsContainSID(name, adminSid)
|
||||
require.NoError(t, err, "enumerate local groups for %s", name)
|
||||
|
||||
assert.Equal(t, viaSAM, viaToken, "S4U token and SAM enumeration must agree on Administrators membership for %s", name)
|
||||
checked++
|
||||
}
|
||||
// Ineligible accounts are skipped, so without this the test could report
|
||||
// success while comparing nothing at all.
|
||||
require.Positive(t, checked, "no local account completed an S4U logon, so nothing was compared")
|
||||
t.Logf("checked %d local accounts via S4U", checked)
|
||||
}
|
||||
|
||||
// TestLocalGroupsContainSID_Administrator checks the positive case against the
|
||||
// built-in Administrator, a member of Administrators on every installation.
|
||||
func TestLocalGroupsContainSID_Administrator(t *testing.T) {
|
||||
adminSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
|
||||
require.NoError(t, err, "create Administrators SID")
|
||||
|
||||
administrator := localAccountNameByRID(t, 500)
|
||||
member, err := localGroupsContainSID(administrator, adminSid)
|
||||
require.NoError(t, err, "enumerate local groups for %s", administrator)
|
||||
assert.True(t, member, "%s is a member of the Administrators group", administrator)
|
||||
}
|
||||
|
||||
// TestLocalGroupsContainSID_UnresolvableGroupFailsClosed covers a wanted SID
|
||||
// that resolves to no group: the error must surface rather than being reported
|
||||
// as "not a member", so the privilege check treats the account as privileged.
|
||||
func TestLocalGroupsContainSID_UnresolvableGroupFailsClosed(t *testing.T) {
|
||||
unknown := mustParseSID(t, "S-1-5-21-1111111111-2222222222-3333333333-4444")
|
||||
|
||||
_, err := localGroupsContainSID(localAccountNameByRID(t, 500), unknown)
|
||||
require.Error(t, err, "must report an error when the wanted group cannot be identified")
|
||||
}
|
||||
|
||||
func TestLocalGroupsContainSID_Guest(t *testing.T) {
|
||||
guestsSid, err := windows.CreateWellKnownSid(windows.WinBuiltinGuestsSid)
|
||||
require.NoError(t, err, "create Guests SID")
|
||||
adminsSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
|
||||
require.NoError(t, err, "create Administrators SID")
|
||||
|
||||
guest := localAccountNameByRID(t, 501)
|
||||
|
||||
inGuests, err := localGroupsContainSID(guest, guestsSid)
|
||||
require.NoError(t, err, "enumerate local groups for %s", guest)
|
||||
assert.True(t, inGuests, "%s is a member of the Guests group", guest)
|
||||
|
||||
inAdmins, err := localGroupsContainSID(guest, adminsSid)
|
||||
require.NoError(t, err, "enumerate local groups for %s", guest)
|
||||
assert.False(t, inAdmins, "%s is not a member of the Administrators group", guest)
|
||||
}
|
||||
@@ -239,6 +239,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
|
||||
forwardType string
|
||||
port uint32
|
||||
username string
|
||||
uid string
|
||||
expectError bool
|
||||
errorMsg string
|
||||
skipOnWindows bool
|
||||
@@ -248,6 +249,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
|
||||
forwardType: "remote",
|
||||
port: 80,
|
||||
username: "testuser",
|
||||
uid: "1000",
|
||||
expectError: true,
|
||||
errorMsg: "cannot bind to privileged port",
|
||||
skipOnWindows: true,
|
||||
@@ -257,6 +259,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
|
||||
forwardType: "tcpip-forward",
|
||||
port: 443,
|
||||
username: "testuser",
|
||||
uid: "1000",
|
||||
expectError: true,
|
||||
errorMsg: "cannot bind to privileged port",
|
||||
skipOnWindows: true,
|
||||
@@ -266,6 +269,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
|
||||
forwardType: "remote",
|
||||
port: 8080,
|
||||
username: "testuser",
|
||||
uid: "1000",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
@@ -273,6 +277,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
|
||||
forwardType: "remote",
|
||||
port: 0,
|
||||
username: "testuser",
|
||||
uid: "1000",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
@@ -280,13 +285,35 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
|
||||
forwardType: "remote",
|
||||
port: 22,
|
||||
username: "root",
|
||||
uid: "0",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
// Only uid 0 is privileged, whatever the account is called.
|
||||
name: "uid 0 under another name may bind a privileged port",
|
||||
forwardType: "remote",
|
||||
port: 22,
|
||||
username: "toor",
|
||||
uid: "0",
|
||||
expectError: false,
|
||||
skipOnWindows: true,
|
||||
},
|
||||
{
|
||||
name: "account named root without uid 0 may not",
|
||||
forwardType: "remote",
|
||||
port: 22,
|
||||
username: "root",
|
||||
uid: "1000",
|
||||
expectError: true,
|
||||
errorMsg: "cannot bind to privileged port",
|
||||
skipOnWindows: true,
|
||||
},
|
||||
{
|
||||
name: "local forward privileged port allowed for non-root",
|
||||
forwardType: "local",
|
||||
port: 80,
|
||||
username: "testuser",
|
||||
uid: "1000",
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
@@ -299,7 +326,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
|
||||
|
||||
result := PrivilegeCheckResult{
|
||||
Allowed: true,
|
||||
User: &user.User{Username: tt.username},
|
||||
User: &user.User{Username: tt.username, Uid: tt.uid},
|
||||
}
|
||||
|
||||
err := server.checkPrivilegedPortAccess(tt.forwardType, tt.port, result)
|
||||
@@ -420,6 +447,13 @@ func TestServer_PortConflictHandling(t *testing.T) {
|
||||
|
||||
func TestServer_IsPrivilegedUser(t *testing.T) {
|
||||
|
||||
// Windows classification depends on account SIDs and group membership, and
|
||||
// the accounts involved carry localized, renameable names. It is covered by
|
||||
// TestIsWindowsAccountPrivileged, which resolves them from well-known SIDs.
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("covered by TestIsWindowsAccountPrivileged")
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
username string
|
||||
expected bool
|
||||
@@ -440,44 +474,16 @@ func TestServer_IsPrivilegedUser(t *testing.T) {
|
||||
expected: false,
|
||||
description: "empty username should not be privileged",
|
||||
},
|
||||
}
|
||||
|
||||
// Add Windows-specific tests
|
||||
if runtime.GOOS == "windows" {
|
||||
tests = append(tests, []struct {
|
||||
username string
|
||||
expected bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
username: "Administrator",
|
||||
expected: true,
|
||||
description: "Administrator should be considered privileged on Windows",
|
||||
},
|
||||
{
|
||||
username: "administrator",
|
||||
expected: true,
|
||||
description: "administrator should be considered privileged on Windows (case insensitive)",
|
||||
},
|
||||
}...)
|
||||
} else {
|
||||
// On non-Windows systems, Administrator should not be privileged
|
||||
tests = append(tests, []struct {
|
||||
username string
|
||||
expected bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
username: "Administrator",
|
||||
expected: false,
|
||||
description: "Administrator should not be privileged on non-Windows systems",
|
||||
},
|
||||
}...)
|
||||
{
|
||||
username: "Administrator",
|
||||
expected: false,
|
||||
description: "Administrator should not be privileged on non-Windows systems",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.description, func(t *testing.T) {
|
||||
result := isPrivilegedUsername(tt.username)
|
||||
result := isPrivilegedOrUnknown(tt.username)
|
||||
assert.Equal(t, tt.expected, result, tt.description)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
// createSftpCommand creates a Windows SFTP command with user switching.
|
||||
// The caller must close the returned token handle after starting the process.
|
||||
func (s *Server) createSftpCommand(targetUser *user.User, sess ssh.Session) (*exec.Cmd, windows.Token, error) {
|
||||
username, domain := s.parseUsername(targetUser.Username)
|
||||
username, domain := parseUsername(targetUser.Username)
|
||||
|
||||
netbirdPath, err := os.Executable()
|
||||
if err != nil {
|
||||
|
||||
@@ -16,11 +16,6 @@ var (
|
||||
ErrPrivilegedUserSwitch = errors.New("cannot switch to privileged user - current user lacks required privileges")
|
||||
)
|
||||
|
||||
// isPlatformUnix returns true for Unix-like platforms (Linux, macOS, etc.)
|
||||
func isPlatformUnix() bool {
|
||||
return getCurrentOS() != "windows"
|
||||
}
|
||||
|
||||
// Dependency injection variables for testing - allows mocking dynamic runtime checks
|
||||
var (
|
||||
getCurrentUser = currentUserWithGetent
|
||||
@@ -29,6 +24,9 @@ var (
|
||||
getIsProcessPrivileged = isCurrentProcessPrivileged
|
||||
|
||||
getEuid = os.Geteuid
|
||||
|
||||
getProcessElevated = isProcessElevated
|
||||
getWindowsAccountPrivilegedOrUnknown = isWindowsAccountPrivilegedOrUnknown
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -65,6 +63,13 @@ type PrivilegeCheckResult struct {
|
||||
RequiresUserSwitching bool
|
||||
}
|
||||
|
||||
// privilegeCheckContext holds all context needed for privilege checking
|
||||
type privilegeCheckContext struct {
|
||||
currentUser *user.User
|
||||
currentUserPrivileged bool
|
||||
allowRoot bool
|
||||
}
|
||||
|
||||
// CheckPrivileges performs comprehensive privilege checking for all SSH features.
|
||||
// This is the single source of truth for privilege decisions across the SSH server.
|
||||
func (s *Server) CheckPrivileges(req PrivilegeCheckRequest) PrivilegeCheckResult {
|
||||
@@ -75,7 +80,7 @@ func (s *Server) CheckPrivileges(req PrivilegeCheckRequest) PrivilegeCheckResult
|
||||
|
||||
// Handle empty username case - but still check root access controls
|
||||
if req.RequestedUsername == "" {
|
||||
if isPrivilegedUsername(context.currentUser.Username) && !context.allowRoot {
|
||||
if isPrivilegedOrUnknown(context.currentUser.Username) && !context.allowRoot {
|
||||
return PrivilegeCheckResult{
|
||||
Allowed: false,
|
||||
Error: &PrivilegedUserError{Username: context.currentUser.Username},
|
||||
@@ -135,7 +140,7 @@ func (s *Server) checkUserRequest(ctx *privilegeCheckContext, req PrivilegeCheck
|
||||
|
||||
needsUserSwitching := !isSameResolvedUser(resolvedUser, ctx.currentUser)
|
||||
|
||||
if isPrivilegedUsername(resolvedUser.Username) && !ctx.allowRoot {
|
||||
if isPrivilegedOrUnknown(resolvedUser.Username) && !ctx.allowRoot {
|
||||
return PrivilegeCheckResult{
|
||||
Allowed: false,
|
||||
Error: &PrivilegedUserError{Username: resolvedUser.Username},
|
||||
@@ -175,6 +180,42 @@ func (s *Server) resolveRequestedUser(requestedUsername string) (*user.User, err
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// SetAllowRootLogin configures root login access
|
||||
func (s *Server) SetAllowRootLogin(allow bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.allowRootLogin = allow
|
||||
}
|
||||
|
||||
// userNameLookup performs user lookup with root login permission check
|
||||
func (s *Server) userNameLookup(username string) (*user.User, error) {
|
||||
result, err := s.userPrivilegeCheck(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.User, nil
|
||||
}
|
||||
|
||||
// userPrivilegeCheck performs user lookup with full privilege check result
|
||||
func (s *Server) userPrivilegeCheck(username string) (PrivilegeCheckResult, error) {
|
||||
result := s.CheckPrivileges(PrivilegeCheckRequest{
|
||||
RequestedUsername: username,
|
||||
FeatureSupportsUserSwitch: true,
|
||||
FeatureName: FeatureSSHLogin,
|
||||
})
|
||||
|
||||
if !result.Allowed {
|
||||
return result, result.Error
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// isPlatformUnix returns true for Unix-like platforms (Linux, macOS, etc.)
|
||||
func isPlatformUnix() bool {
|
||||
return getCurrentOS() != "windows"
|
||||
}
|
||||
|
||||
// isSameResolvedUser compares two resolved user identities
|
||||
func isSameResolvedUser(user1, user2 *user.User) bool {
|
||||
if user1 == nil || user2 == nil {
|
||||
@@ -183,13 +224,6 @@ func isSameResolvedUser(user1, user2 *user.User) bool {
|
||||
return user1.Uid == user2.Uid
|
||||
}
|
||||
|
||||
// privilegeCheckContext holds all context needed for privilege checking
|
||||
type privilegeCheckContext struct {
|
||||
currentUser *user.User
|
||||
currentUserPrivileged bool
|
||||
allowRoot bool
|
||||
}
|
||||
|
||||
// isSameUser checks if two usernames refer to the same user
|
||||
// SECURITY: This function must be conservative - it should only return true
|
||||
// when we're certain both usernames refer to the exact same user identity
|
||||
@@ -253,159 +287,30 @@ func isWindowsSameUser(requestedUsername, currentUsername string) bool {
|
||||
return strings.EqualFold(reqDomain, curDomain)
|
||||
}
|
||||
|
||||
// SetAllowRootLogin configures root login access
|
||||
func (s *Server) SetAllowRootLogin(allow bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.allowRootLogin = allow
|
||||
}
|
||||
|
||||
// userNameLookup performs user lookup with root login permission check
|
||||
func (s *Server) userNameLookup(username string) (*user.User, error) {
|
||||
result := s.CheckPrivileges(PrivilegeCheckRequest{
|
||||
RequestedUsername: username,
|
||||
FeatureSupportsUserSwitch: true,
|
||||
FeatureName: FeatureSSHLogin,
|
||||
})
|
||||
|
||||
if !result.Allowed {
|
||||
return nil, result.Error
|
||||
}
|
||||
|
||||
return result.User, nil
|
||||
}
|
||||
|
||||
// userPrivilegeCheck performs user lookup with full privilege check result
|
||||
func (s *Server) userPrivilegeCheck(username string) (PrivilegeCheckResult, error) {
|
||||
result := s.CheckPrivileges(PrivilegeCheckRequest{
|
||||
RequestedUsername: username,
|
||||
FeatureSupportsUserSwitch: true,
|
||||
FeatureName: FeatureSSHLogin,
|
||||
})
|
||||
|
||||
if !result.Allowed {
|
||||
return result, result.Error
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// isPrivilegedUsername checks if the given username represents a privileged user across platforms.
|
||||
// On Unix: root
|
||||
// On Windows: Administrator, SYSTEM (case-insensitive)
|
||||
// Handles domain-qualified usernames like "DOMAIN\Administrator" or "user@domain.com"
|
||||
func isPrivilegedUsername(username string) bool {
|
||||
// isPrivilegedOrUnknown reports whether the given username represents a
|
||||
// privileged user, or on Windows an account whose privilege could not be
|
||||
// determined.
|
||||
// On Unix: root.
|
||||
// On Windows: well-known service accounts, built-in Administrator accounts,
|
||||
// and members of the local Administrators group; handles domain-qualified
|
||||
// usernames like "DOMAIN\user" or "user@domain.com". An account that cannot be
|
||||
// resolved or evaluated is reported as privileged.
|
||||
//
|
||||
// Use this to refuse privileged accounts, never to grant them anything: the
|
||||
// undetermined case is safe for a refusal and unsafe for a grant.
|
||||
func isPrivilegedOrUnknown(username string) bool {
|
||||
if getCurrentOS() != "windows" {
|
||||
return username == "root"
|
||||
}
|
||||
|
||||
bareUsername := username
|
||||
// Handle Windows domain format: DOMAIN\username
|
||||
if idx := strings.LastIndex(username, `\`); idx != -1 {
|
||||
bareUsername = username[idx+1:]
|
||||
}
|
||||
// Handle email-style format: username@domain.com
|
||||
if idx := strings.Index(bareUsername, "@"); idx != -1 {
|
||||
bareUsername = bareUsername[:idx]
|
||||
}
|
||||
|
||||
return isWindowsPrivilegedUser(bareUsername)
|
||||
}
|
||||
|
||||
// isWindowsPrivilegedUser checks if a bare username (domain already stripped) represents a Windows privileged account
|
||||
func isWindowsPrivilegedUser(bareUsername string) bool {
|
||||
// common privileged usernames (case insensitive)
|
||||
privilegedNames := []string{
|
||||
"administrator",
|
||||
"admin",
|
||||
"root",
|
||||
"system",
|
||||
"localsystem",
|
||||
"networkservice",
|
||||
"localservice",
|
||||
}
|
||||
|
||||
usernameLower := strings.ToLower(bareUsername)
|
||||
for _, privilegedName := range privilegedNames {
|
||||
if usernameLower == privilegedName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// computer accounts (ending with $) are not privileged by themselves
|
||||
// They only gain privileges through group membership or specific SIDs
|
||||
|
||||
if targetUser, err := lookupUser(bareUsername); err == nil {
|
||||
return isWindowsPrivilegedSID(targetUser.Uid)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// isWindowsPrivilegedSID checks if a Windows SID represents a privileged account
|
||||
func isWindowsPrivilegedSID(sid string) bool {
|
||||
privilegedSIDs := []string{
|
||||
"S-1-5-18", // Local System (SYSTEM)
|
||||
"S-1-5-19", // Local Service (NT AUTHORITY\LOCAL SERVICE)
|
||||
"S-1-5-20", // Network Service (NT AUTHORITY\NETWORK SERVICE)
|
||||
"S-1-5-32-544", // Administrators group (BUILTIN\Administrators)
|
||||
"S-1-5-500", // Built-in Administrator account (local machine RID 500)
|
||||
}
|
||||
|
||||
for _, privilegedSID := range privilegedSIDs {
|
||||
if sid == privilegedSID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check for domain administrator accounts (RID 500 in any domain)
|
||||
// Format: S-1-5-21-domain-domain-domain-500
|
||||
// This is reliable as RID 500 is reserved for the domain Administrator account
|
||||
if strings.HasPrefix(sid, "S-1-5-21-") && strings.HasSuffix(sid, "-500") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for other well-known privileged RIDs in domain contexts
|
||||
// RID 512 = Domain Admins group, RID 516 = Domain Controllers group
|
||||
if strings.HasPrefix(sid, "S-1-5-21-") {
|
||||
if strings.HasSuffix(sid, "-512") || // Domain Admins group
|
||||
strings.HasSuffix(sid, "-516") || // Domain Controllers group
|
||||
strings.HasSuffix(sid, "-519") { // Enterprise Admins group
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
return getWindowsAccountPrivilegedOrUnknown(username)
|
||||
}
|
||||
|
||||
// isCurrentProcessPrivileged checks if the current process is running with elevated privileges.
|
||||
// On Unix systems, this means running as root (UID 0).
|
||||
// On Windows, this means running as Administrator or SYSTEM.
|
||||
// On Windows, this means the process token is elevated (administrators, SYSTEM).
|
||||
func isCurrentProcessPrivileged() bool {
|
||||
if getCurrentOS() == "windows" {
|
||||
return isWindowsElevated()
|
||||
return getProcessElevated()
|
||||
}
|
||||
return getEuid() == 0
|
||||
}
|
||||
|
||||
// isWindowsElevated checks if the current process is running with elevated privileges on Windows
|
||||
func isWindowsElevated() bool {
|
||||
currentUser, err := getCurrentUser()
|
||||
if err != nil {
|
||||
log.Errorf("failed to get current user for privilege check, assuming non-privileged: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
if isWindowsPrivilegedSID(currentUser.Uid) {
|
||||
log.Debugf("Windows user switching supported: running as privileged SID %s", currentUser.Uid)
|
||||
return true
|
||||
}
|
||||
|
||||
if isPrivilegedUsername(currentUser.Username) {
|
||||
log.Debugf("Windows user switching supported: running as privileged username %s", currentUser.Username)
|
||||
return true
|
||||
}
|
||||
|
||||
log.Debugf("Windows user switching not supported: not running as privileged user (current: %s)", currentUser.Uid)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"os/user"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -27,8 +28,8 @@ func setupTestDependencies(currentUser *user.User, currentUserErr error, os stri
|
||||
originalLookupUser := lookupUser
|
||||
originalGetCurrentOS := getCurrentOS
|
||||
originalGetEuid := getEuid
|
||||
|
||||
// Reset caches to ensure clean test state
|
||||
originalGetProcessElevated := getProcessElevated
|
||||
originalGetWindowsAccountPrivilegedOrUnknown := getWindowsAccountPrivilegedOrUnknown
|
||||
|
||||
// Set test values - inject platform dependencies
|
||||
getCurrentUser = func() (*user.User, error) {
|
||||
@@ -53,16 +54,31 @@ func setupTestDependencies(currentUser *user.User, currentUserErr error, os stri
|
||||
return euid
|
||||
}
|
||||
|
||||
// Mock privilege detection based on the test user
|
||||
getIsProcessPrivileged = func() bool {
|
||||
// Simulate the Windows token elevation check based on the fixture user:
|
||||
// the built-in Administrator (RID 500) and SYSTEM run elevated.
|
||||
getProcessElevated = func() bool {
|
||||
if currentUser == nil {
|
||||
return false
|
||||
}
|
||||
// Check both username and SID for Windows systems
|
||||
if os == "windows" && isWindowsPrivilegedSID(currentUser.Uid) {
|
||||
return currentUser.Uid == "S-1-5-18" || strings.HasSuffix(currentUser.Uid, "-500")
|
||||
}
|
||||
|
||||
// Simulate the Windows account classifier for the fixture accounts.
|
||||
// "root" does not exist on Windows; the real classifier fails closed on
|
||||
// unresolvable accounts, so it counts as privileged here too.
|
||||
getWindowsAccountPrivilegedOrUnknown = func(username string) bool {
|
||||
bare := username
|
||||
if idx := strings.LastIndex(bare, `\`); idx != -1 {
|
||||
bare = bare[idx+1:]
|
||||
}
|
||||
if idx := strings.Index(bare, "@"); idx != -1 {
|
||||
bare = bare[:idx]
|
||||
}
|
||||
switch strings.ToLower(bare) {
|
||||
case "administrator", "system", "root":
|
||||
return true
|
||||
}
|
||||
return isPrivilegedUsername(currentUser.Username)
|
||||
return false
|
||||
}
|
||||
|
||||
// Return cleanup function
|
||||
@@ -71,10 +87,8 @@ func setupTestDependencies(currentUser *user.User, currentUserErr error, os stri
|
||||
lookupUser = originalLookupUser
|
||||
getCurrentOS = originalGetCurrentOS
|
||||
getEuid = originalGetEuid
|
||||
|
||||
getIsProcessPrivileged = isCurrentProcessPrivileged
|
||||
|
||||
// Reset caches after test
|
||||
getProcessElevated = originalGetProcessElevated
|
||||
getWindowsAccountPrivilegedOrUnknown = originalGetWindowsAccountPrivilegedOrUnknown
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,6 +435,9 @@ func TestUsedFallback_MeansNoPrivilegeDropping(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPrivilegedUsernameDetection(t *testing.T) {
|
||||
// Windows classification is syscall-backed (SID resolution, group
|
||||
// membership) and is covered by privileges_windows_test.go; here only the
|
||||
// Unix logic and the platform dispatch are exercised.
|
||||
tests := []struct {
|
||||
name string
|
||||
username string
|
||||
@@ -432,25 +449,9 @@ func TestPrivilegedUsernameDetection(t *testing.T) {
|
||||
{"unix_regular_user", "alice", "linux", false},
|
||||
{"unix_root_capital", "Root", "linux", false}, // Case-sensitive
|
||||
|
||||
// Windows tests
|
||||
// Windows dispatch to the (mocked) account classifier
|
||||
{"windows_administrator", "Administrator", "windows", true},
|
||||
{"windows_system", "SYSTEM", "windows", true},
|
||||
{"windows_admin", "admin", "windows", true},
|
||||
{"windows_admin_lowercase", "administrator", "windows", true}, // Case-insensitive
|
||||
{"windows_domain_admin", "DOMAIN\\Administrator", "windows", true},
|
||||
{"windows_email_admin", "admin@domain.com", "windows", true},
|
||||
{"windows_regular_user", "alice", "windows", false},
|
||||
{"windows_domain_user", "DOMAIN\\alice", "windows", false},
|
||||
{"windows_localsystem", "localsystem", "windows", true},
|
||||
{"windows_networkservice", "networkservice", "windows", true},
|
||||
{"windows_localservice", "localservice", "windows", true},
|
||||
|
||||
// Computer accounts (these depend on current user context in real implementation)
|
||||
{"windows_computer_account", "WIN2K19-C2$", "windows", false}, // Computer account by itself not privileged
|
||||
{"windows_domain_computer", "DOMAIN\\COMPUTER$", "windows", false}, // Domain computer account
|
||||
|
||||
// Cross-platform
|
||||
{"root_on_windows", "root", "windows", true}, // Root should be privileged everywhere
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -459,50 +460,8 @@ func TestPrivilegedUsernameDetection(t *testing.T) {
|
||||
cleanup := setupTestDependencies(nil, nil, tt.platform, 1000, nil, nil)
|
||||
defer cleanup()
|
||||
|
||||
result := isPrivilegedUsername(tt.username)
|
||||
assert.Equal(t, tt.privileged, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsPrivilegedSIDDetection(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sid string
|
||||
privileged bool
|
||||
description string
|
||||
}{
|
||||
// Well-known system accounts
|
||||
{"system_account", "S-1-5-18", true, "Local System (SYSTEM)"},
|
||||
{"local_service", "S-1-5-19", true, "Local Service"},
|
||||
{"network_service", "S-1-5-20", true, "Network Service"},
|
||||
{"administrators_group", "S-1-5-32-544", true, "Administrators group"},
|
||||
{"builtin_administrator", "S-1-5-500", true, "Built-in Administrator"},
|
||||
|
||||
// Domain accounts
|
||||
{"domain_administrator", "S-1-5-21-1234567890-1234567890-1234567890-500", true, "Domain Administrator (RID 500)"},
|
||||
{"domain_admins_group", "S-1-5-21-1234567890-1234567890-1234567890-512", true, "Domain Admins group"},
|
||||
{"domain_controllers_group", "S-1-5-21-1234567890-1234567890-1234567890-516", true, "Domain Controllers group"},
|
||||
{"enterprise_admins_group", "S-1-5-21-1234567890-1234567890-1234567890-519", true, "Enterprise Admins group"},
|
||||
|
||||
// Regular users
|
||||
{"regular_user", "S-1-5-21-1234567890-1234567890-1234567890-1001", false, "Regular domain user"},
|
||||
{"another_regular_user", "S-1-5-21-1234567890-1234567890-1234567890-1234", false, "Another regular user"},
|
||||
{"local_user", "S-1-5-21-1234567890-1234567890-1234567890-1000", false, "Local regular user"},
|
||||
|
||||
// Groups that are not privileged
|
||||
{"domain_users", "S-1-5-21-1234567890-1234567890-1234567890-513", false, "Domain Users group"},
|
||||
{"power_users", "S-1-5-32-547", false, "Power Users group"},
|
||||
|
||||
// Invalid SIDs
|
||||
{"malformed_sid", "S-1-5-invalid", false, "Malformed SID"},
|
||||
{"empty_sid", "", false, "Empty SID"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isWindowsPrivilegedSID(tt.sid)
|
||||
assert.Equal(t, tt.privileged, result, "Failed for %s: %s", tt.description, tt.sid)
|
||||
result := isPrivilegedOrUnknown(tt.username)
|
||||
assert.Equal(t, tt.privileged, result, "privilege classification for %s on %s", tt.username, tt.platform)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ func validateUsernameFormat(username string) error {
|
||||
func (s *Server) createExecutorCommand(logger *log.Entry, session ssh.Session, localUser *user.User, hasPty bool) (*exec.Cmd, func(), error) {
|
||||
logger.Debugf("creating Windows executor command for user %s (Pty: %v)", localUser.Username, hasPty)
|
||||
|
||||
username, _ := s.parseUsername(localUser.Username)
|
||||
username, _ := parseUsername(localUser.Username)
|
||||
if err := validateUsername(username); err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid username %q: %w", username, err)
|
||||
}
|
||||
@@ -102,7 +102,7 @@ func (s *Server) createExecutorCommand(logger *log.Entry, session ssh.Session, l
|
||||
// createUserSwitchCommand creates a command with Windows user switching.
|
||||
// Returns the command and a cleanup function that must be called after starting the process.
|
||||
func (s *Server) createUserSwitchCommand(logger *log.Entry, session ssh.Session, localUser *user.User) (*exec.Cmd, func(), error) {
|
||||
username, domain := s.parseUsername(localUser.Username)
|
||||
username, domain := parseUsername(localUser.Username)
|
||||
|
||||
shell := getUserShell(localUser.Uid)
|
||||
|
||||
@@ -138,7 +138,7 @@ func (s *Server) createUserSwitchCommand(logger *log.Entry, session ssh.Session,
|
||||
}
|
||||
|
||||
// parseUsername extracts username and domain from a Windows username
|
||||
func (s *Server) parseUsername(fullUsername string) (username, domain string) {
|
||||
func parseUsername(fullUsername string) (username, domain string) {
|
||||
// Handle DOMAIN\username format
|
||||
if idx := strings.LastIndex(fullUsername, `\`); idx != -1 {
|
||||
domain = fullUsername[:idx]
|
||||
|
||||
@@ -2,9 +2,24 @@
|
||||
|
||||
A short brief for translating the desktop UI — for any translator, human or AI agent (*"you"* = whoever's translating).
|
||||
|
||||
**Drive an agent with:** *"Read `i18n/TRANSLATING.md` and translate the UI to Russian"* — or *"…and review the existing German translation."*
|
||||
**Translations are managed on Crowdin: <https://crowdin.com/project/netbird>.** Join the project, pick your language, and translate in the editor. Each string carries a context note (the `description` from the source file) telling you what it is and where it shows up, and the project's glossary, style guide, and QA checks mirror this document.
|
||||
|
||||
> 💡 **The one habit that matters most:** read each key's `description` before translating it. Labels are terse and ambiguous on their own; the `description` tells you what the string is, where it shows up, what to keep verbatim, and what it actually means.
|
||||
> 💡 **The one habit that matters most:** read each string's context before translating it. Labels are terse and ambiguous on their own; the context tells you what the string is, where it shows up, what to keep verbatim, and what it actually means.
|
||||
|
||||
---
|
||||
|
||||
## How contributions flow
|
||||
|
||||
```text
|
||||
i18n/locales/en/common.json ──sync──▶ Crowdin ──service PR──▶ i18n/locales/<code>/common.json
|
||||
```
|
||||
|
||||
- `i18n/locales/en/common.json` is the source of truth. New and changed strings sync to Crowdin automatically (see `crowdin.yml` in the repository root).
|
||||
- Crowdin opens and updates a service pull request with the translated bundles, keeping the source's file shape and key order. Keys nobody has translated yet are left out of the export; the app falls back to English for them at runtime. Maintainers review and merge that PR.
|
||||
- Don't hand-edit `i18n/locales/<code>/common.json` in your own PRs: the next sync would conflict with or overwrite your changes. Translate on Crowdin instead.
|
||||
- Missing your language? Request it on the Crowdin project page or in a [GitHub discussion](https://github.com/netbirdio/netbird/discussions). When a language first ships, a maintainer adds its row to `i18n/locales/_index.json` with `code`, `displayName` (the native name), and `englishName`, which puts it in the app's language picker.
|
||||
|
||||
**Prefer translating with an AI agent?** That still works: drive it with *"Read `i18n/TRANSLATING.md` and translate the UI to Russian"* as before, but deliver the result to Crowdin instead of a pull request. Download your language's file from the Crowdin editor, let the agent translate it, and upload it back (the editor's offline translation flow). Crowdin runs its QA checks on upload, and the next service PR carries the strings into the repo.
|
||||
|
||||
---
|
||||
|
||||
@@ -30,25 +45,6 @@ A **business zero-trust VPN** — an encrypted **overlay mesh** between a compan
|
||||
|
||||
---
|
||||
|
||||
## The files
|
||||
|
||||
```
|
||||
i18n/locales/_index.json shipped-language list
|
||||
i18n/locales/en/common.json source of truth — message + description
|
||||
i18n/locales/<code>/common.json a target — message only
|
||||
```
|
||||
|
||||
Chrome-extension JSON, each key → `{ "message", "description" }`. You translate the **`message`**.
|
||||
|
||||
| ✅ Do | ❌ Don't |
|
||||
|---|---|
|
||||
| Keep **every key** from `en`, in the same order | Translate, rename, reorder, drop, or add keys (they're identifiers; the set grows over time) |
|
||||
| Put **only `message`** in target bundles | Copy `description` into a target bundle |
|
||||
| Give every key a non-empty `message` | Leave keys missing or empty |
|
||||
| Save valid UTF-8 JSON, no BOM | Add trailing commas or break the JSON |
|
||||
|
||||
---
|
||||
|
||||
## Hard rules — get these exactly right
|
||||
|
||||
These are the usual ways a translation *breaks the app*, not just reads oddly.
|
||||
@@ -58,7 +54,7 @@ These are the usual ways a translation *breaks the app*, not just reads oddly.
|
||||
| Copy `{placeholders}` verbatim — `{version}`, `{count}`, `{name}`… | Translate the word inside the braces (`{verbleibend}` breaks it) |
|
||||
| Reposition a placeholder so the sentence flows | Drop or duplicate a placeholder |
|
||||
| Preserve every `\n`, leading/trailing space, and trailing `...` | Trim "invisible" spaces or the `...` (they're load-bearing) |
|
||||
| Keep `®` in WireGuard® and quotes around `{name}` | Strip punctuation the description flags |
|
||||
| Keep `®` in WireGuard® and quotes around `{name}` | Strip punctuation the context flags |
|
||||
|
||||
**Plurals:** the app has only a *one / other* split — the singular key fires only when `count == 1`; the `{count}` key covers everything else (0, 2, 5, 100…). Languages with more than two forms (ru, pl, uk) can't be fully correct here — use the form that fits the widest range (Russian genitive plural: `минут` / `часов` / `дней`). Don't invent extra keys or cram multiple forms into one string. When no single form fits every value — a unit label after a number field, say — reach for a number-agnostic form (an abbreviation, or wording that reads the same for 1 and 100) instead of forcing a plural the *one / other* split can't supply.
|
||||
|
||||
@@ -78,13 +74,15 @@ When a brand sits beside a common noun, keep its exact spelling but join them th
|
||||
|
||||
> **Use the word that language's IT users actually say.** Translate when a natural, common term exists; keep the English term *only* when the literal translation would be awkward or no one in that field really uses it.
|
||||
|
||||
Apply each term **consistently** — same English term → same translation everywhere — and keep a term once you've settled it. Whether a term stays English or takes a native word is **language-dependent**: a technical loanword (e.g. *Daemon*, *Handshake*) often stays, an everyday word (e.g. *Latency*, *Public key*) usually localizes, and some (*Exit Node*, *Peer*) go either way depending on the language. Decide per term with the rule above — a foreign origin alone is no reason to keep English. **Your main reference is the existing bundles:** match how a term was already rendered for your language rather than re-deciding it.
|
||||
Apply each term **consistently** — same English term → same translation everywhere — and keep a term once you've settled it. Whether a term stays English or takes a native word is **language-dependent**: a technical loanword (e.g. *Daemon*, *Handshake*) often stays, an everyday word (e.g. *Latency*, *Public key*) usually localizes, and some (*Exit Node*, *Peer*) go either way depending on the language. Decide per term with the rule above — a foreign origin alone is no reason to keep English. **Your main reference is the existing translation:** match how a term was already rendered for your language rather than re-deciding it.
|
||||
|
||||
Two checks before you commit a term:
|
||||
|
||||
- **Prefer established localized wording.** If a widely used tool in this space (for example WireGuard) ships your language, its wording for a shared term such as *handshake* is what users already expect — look at the translated app, not just English docs. For generic UI verbs and formal address, follow your OS vendor's style guide (Microsoft / Apple / Google).
|
||||
- **Watch for false friends.** A literal translation can collide with a *different* established term in your field — confirm your word doesn't already mean something else in this domain before using it.
|
||||
|
||||
These tiers are mirrored in the Crowdin project glossary, so the editor highlights them inline. When you settle a new Tier C term for your language, add its translation to the glossary entry so it sticks for everyone who comes after you.
|
||||
|
||||
---
|
||||
|
||||
## Style
|
||||
@@ -98,7 +96,7 @@ Two checks before you commit a term:
|
||||
|
||||
Where it reads naturally, aim to keep each string **roughly the same length** as the English — the UI is tight and over-long strings can wrap or truncate. It's a soft preference, not a rule: if your language simply needs more words, use them.
|
||||
|
||||
A few habits that keep a bundle reading like one product rather than a word-for-word port:
|
||||
A few habits that keep a translation reading like one product rather than a word-for-word port:
|
||||
|
||||
- **Translate meaning, not words.** Render what a string *does*. An idiom or an awkward source phrase should become natural in your language, not a literal calque.
|
||||
- **Keep one voice within a family.** Sibling strings — the connection states, every settings *help* caption, every "… Failed" title — should share a grammatical form. If one member sounds wrong in that form, re-voice the whole family rather than leave one odd sibling.
|
||||
@@ -107,27 +105,26 @@ A few habits that keep a bundle reading like one product rather than a word-for-
|
||||
|
||||
---
|
||||
|
||||
## Procedure
|
||||
## Reviewing a language
|
||||
|
||||
**New language** — read `en/common.json` *with* descriptions → settle your Tier C terms → write `i18n/locales/<code>/common.json` (same keys and order as `en`, `message` only, placeholders & brands preserved) → add a row to `_index.json` (`{"code","displayName"` = native name`,"englishName"}`) → run the QA list. Use the locale-code style the existing entries use (e.g. `fr`, `pt`, `zh-CN`).
|
||||
**On Crowdin:** proofread in the editor — context, glossary highlights, and QA flags sit inline next to each string.
|
||||
|
||||
**Review (de / hu / …)** — read source and target side by side; for each key check glossary conformance (e.g. de `Exit-Node` → `Exit Node`, hu `Kilépő csomópont` → `Exit Node`), placeholder/`\n` integrity, consistency, tone, and that the meaning matches the English `description`. Fix in place, then report what you changed (especially term standardizations) so a native speaker can sanity-check.
|
||||
**In the repo** — e.g. driving an AI agent with *"Read `i18n/TRANSLATING.md` and review the existing German translation"* — read source and target side by side; for each key check glossary conformance (e.g. de `Exit-Node` → `Exit Node`, hu `Kilépő csomópont` → `Exit Node`), placeholder/`\n` integrity, consistency, tone, and that the meaning matches the English `description`. Report what you found, and apply the fixes **on Crowdin** — direct edits to the locale files are overwritten by the next sync.
|
||||
|
||||
---
|
||||
|
||||
## QA before you finish
|
||||
|
||||
- [ ] Valid JSON · **every `en` key** present, same order · **no `description`** fields
|
||||
- [ ] Every `{placeholder}`, `\n`, and intentional space preserved · `...` / `… Failed` / `{name}` quotes kept
|
||||
- [ ] Tier A/B left intact · Tier C applied consistently (and matching the existing bundle for your language)
|
||||
- [ ] Tier A/B left intact · Tier C applied consistently (and matching the existing translation for your language)
|
||||
- [ ] Buttons & tray short · locale punctuation and capitalization applied
|
||||
- [ ] New language added to `_index.json`
|
||||
- [ ] Crowdin QA flags resolved (variables, glossary terms, punctuation)
|
||||
- [ ] **Tested in the running app** ↓
|
||||
|
||||
---
|
||||
|
||||
## Test it in the app
|
||||
|
||||
A bundle can pass every check above and still read wrong on screen. **Run the app, switch to your language, and click through the real surfaces** — tray menu, main window, every Settings tab, the dialogs. Watch for text overflow or truncation, labels that are technically right but wrong *for what the control does*, leaked placeholders, and terms that drift between screens.
|
||||
A translation can pass every check above and still read wrong on screen. **Run the app, switch to your language, and click through the real surfaces** — tray menu, main window, every Settings tab, the dialogs. Watch for text overflow or truncation, labels that are technically right but wrong *for what the control does*, leaked placeholders, and terms that drift between screens.
|
||||
|
||||
How to run the app and switch language: see the project README. Can't run it (e.g. a headless agent)? Say so in your summary — don't silently skip this step.
|
||||
|
||||
11
crowdin.yml
Normal file
11
crowdin.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
skip_untranslated_strings: true
|
||||
skip_untranslated_files: true
|
||||
import_eq_suggestions: true
|
||||
|
||||
files:
|
||||
- source: /client/ui/i18n/locales/en/common.json
|
||||
translation: /client/ui/i18n/locales/%two_letters_code%/common.json
|
||||
type: chrome
|
||||
languages_mapping:
|
||||
two_letters_code:
|
||||
zh-CN: zh-CN
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/e2e/harness"
|
||||
"github.com/netbirdio/netbird/shared/management/client/rest"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
@@ -178,89 +177,3 @@ func TestSettingsBootstrapSelfAddressed(t *testing.T) {
|
||||
require.NoError(t, err, "bootstrap after delete must succeed")
|
||||
assert.Equal(t, "gw2.e2e.netbird.selfhosted", recreated.Endpoint, "the fresh bootstrap claims the new hostname")
|
||||
}
|
||||
|
||||
// TestSettingsConditionalWrites covers the lost-update guard end to end, over
|
||||
// the same REST client the Terraform provider uses: read the settings, take
|
||||
// the entity-tag, and have a write refused when the row moved underneath it.
|
||||
//
|
||||
// The scenario is the one that motivates the feature. A client reads the
|
||||
// settings and computes an update. An operator turns PII redaction on in the
|
||||
// dashboard in the meantime. Without a precondition the client's write puts
|
||||
// redaction straight back off — no error, no drift warning, a
|
||||
// compliance-relevant control silently disabled. With one, the write is
|
||||
// refused and the client can read again.
|
||||
func TestSettingsConditionalWrites(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
fresh, err := harnessStartFresh(ctx, t)
|
||||
require.NoError(t, err, "start dedicated combined server")
|
||||
|
||||
const cluster = "eu.e2e.netbird.selfhosted"
|
||||
bootstrapped, err := fresh.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{
|
||||
ProxyAddress: ptr(cluster),
|
||||
})
|
||||
require.NoError(t, err, "bootstrap must succeed")
|
||||
|
||||
// What the client plans against.
|
||||
planned, etag, err := fresh.GetSettingsWithETag(ctx)
|
||||
require.NoError(t, err, "read must succeed")
|
||||
require.NotEmpty(t, etag, "the read must carry a validator")
|
||||
assert.Equal(t, bootstrapped.Endpoint, planned.Endpoint)
|
||||
|
||||
_, again, err := fresh.GetSettingsWithETag(ctx)
|
||||
require.NoError(t, err, "second read must succeed")
|
||||
assert.Equal(t, etag, again, "an unchanged row must read as the same validator")
|
||||
|
||||
update := func(redactPii bool, retention int) api.AgentNetworkSettingsRequest {
|
||||
return api.AgentNetworkSettingsRequest{
|
||||
Endpoint: planned.Endpoint,
|
||||
ProxyAddress: planned.ProxyAddress,
|
||||
EnableLogCollection: true,
|
||||
EnablePromptCollection: true,
|
||||
RedactPii: redactPii,
|
||||
AccessLogRetentionDays: retention,
|
||||
}
|
||||
}
|
||||
|
||||
// The operator's change, which the planning client never saw.
|
||||
_, err = fresh.UpdateSettings(ctx, update(true, 21))
|
||||
require.NoError(t, err, "the intervening update must succeed")
|
||||
|
||||
// The client's write, planned against the earlier read, would have turned
|
||||
// redaction back off. It is refused instead.
|
||||
_, _, err = fresh.UpdateSettingsIfMatch(ctx, update(false, 7), etag)
|
||||
require.Error(t, err, "a stale precondition must be refused")
|
||||
require.True(t, rest.IsPreconditionFailed(err),
|
||||
"the refusal must be a precondition failure, got: %v", err)
|
||||
|
||||
intact, current, err := fresh.GetSettingsWithETag(ctx)
|
||||
require.NoError(t, err, "read after the refusal must succeed")
|
||||
assert.True(t, intact.RedactPii, "the refused write must not have turned redaction off")
|
||||
require.NotNil(t, intact.AccessLogRetentionDays)
|
||||
assert.Equal(t, 21, *intact.AccessLogRetentionDays, "the refused write must not have changed retention")
|
||||
assert.NotEqual(t, etag, current, "the validator must have moved with the intervening update")
|
||||
|
||||
// Retrying against the current validator goes through, and hands back the
|
||||
// validator for the write after it.
|
||||
updated, next, err := fresh.UpdateSettingsIfMatch(ctx, update(true, 7), current)
|
||||
require.NoError(t, err, "a matching precondition must be honoured")
|
||||
require.NotNil(t, updated.AccessLogRetentionDays)
|
||||
assert.Equal(t, 7, *updated.AccessLogRetentionDays, "the conditional write must apply")
|
||||
assert.NotEmpty(t, next, "the write must return a validator")
|
||||
assert.NotEqual(t, current, next, "the write must move the validator")
|
||||
|
||||
// The delete is conditional too, and refusing a stale one leaves the
|
||||
// endpoint claimed.
|
||||
err = fresh.DeleteSettingsIfMatch(ctx, etag)
|
||||
require.Error(t, err, "a stale precondition must refuse the delete")
|
||||
require.True(t, rest.IsPreconditionFailed(err),
|
||||
"the delete must be refused for staleness rather than for a state guard or a server error, got: %v", err)
|
||||
stillThere, err := fresh.GetSettings(ctx)
|
||||
require.NoError(t, err, "read after the refused delete must succeed")
|
||||
assert.Equal(t, planned.Endpoint, stillThere.Endpoint, "the refused delete must leave the endpoint claimed")
|
||||
|
||||
require.NoError(t, fresh.DeleteSettingsIfMatch(ctx, next), "a matching precondition must be honoured")
|
||||
gone, err := fresh.GetSettings(ctx)
|
||||
require.NoError(t, err, "read after the delete must succeed")
|
||||
assert.Empty(t, gone.Endpoint, "the row must be gone")
|
||||
}
|
||||
|
||||
@@ -153,37 +153,6 @@ func (c *Combined) DeleteSettings(ctx context.Context) error {
|
||||
return anDelete(ctx, c, "/api/agent-network/settings")
|
||||
}
|
||||
|
||||
// The conditional-request wrappers go through the typed REST client rather
|
||||
// than anRequest, so the e2e run exercises the client's own header handling —
|
||||
// the quoting on the way out and the unquoting on the way back — against a
|
||||
// real server, which is the path the Terraform provider takes.
|
||||
|
||||
// GetSettingsWithETag reads the settings along with the entity-tag that makes
|
||||
// a following write conditional.
|
||||
func (c *Combined) GetSettingsWithETag(ctx context.Context) (api.AgentNetworkSettings, string, error) {
|
||||
settings, etag, err := c.api.AgentNetwork.GetSettingsWithETag(ctx)
|
||||
if err != nil {
|
||||
return api.AgentNetworkSettings{}, "", err
|
||||
}
|
||||
return *settings, etag, nil
|
||||
}
|
||||
|
||||
// UpdateSettingsIfMatch applies the update only if etag is still current,
|
||||
// returning the entity-tag of the row it wrote.
|
||||
func (c *Combined) UpdateSettingsIfMatch(ctx context.Context, req api.AgentNetworkSettingsRequest, etag string) (api.AgentNetworkSettings, string, error) {
|
||||
settings, newETag, err := c.api.AgentNetwork.UpdateSettingsIfMatch(ctx, req, etag)
|
||||
if err != nil {
|
||||
return api.AgentNetworkSettings{}, "", err
|
||||
}
|
||||
return *settings, newETag, nil
|
||||
}
|
||||
|
||||
// DeleteSettingsIfMatch deletes the settings row only if etag is still
|
||||
// current.
|
||||
func (c *Combined) DeleteSettingsIfMatch(ctx context.Context, etag string) error {
|
||||
return c.api.AgentNetwork.DeleteSettingsIfMatch(ctx, etag)
|
||||
}
|
||||
|
||||
// ListConsumption returns the account's consumption rows (possibly empty).
|
||||
func (c *Combined) ListConsumption(ctx context.Context) ([]api.AgentNetworkConsumption, error) {
|
||||
return anRequest[[]api.AgentNetworkConsumption](ctx, c, http.MethodGet, "/api/agent-network/consumption", nil)
|
||||
|
||||
@@ -92,13 +92,6 @@ func newAgentNetworkHandlerFixture(t *testing.T) *agentNetworkHandlerFixture {
|
||||
}
|
||||
|
||||
func (f *agentNetworkHandlerFixture) do(t *testing.T, method, path, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return f.doWithHeaders(t, method, path, body, nil)
|
||||
}
|
||||
|
||||
// doWithHeaders is do with request headers, for the cases where the header is
|
||||
// the thing under test (conditional requests).
|
||||
func (f *agentNetworkHandlerFixture) doWithHeaders(t *testing.T, method, path, body string, headers map[string]string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var reader io.Reader
|
||||
if body != "" {
|
||||
@@ -108,9 +101,6 @@ func (f *agentNetworkHandlerFixture) doWithHeaders(t *testing.T, method, path, b
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
for name, value := range headers {
|
||||
req.Header.Set(name, value)
|
||||
}
|
||||
req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{
|
||||
UserId: testUserID,
|
||||
AccountId: testAccountID,
|
||||
|
||||
@@ -60,20 +60,12 @@ func (h *handler) createSettings(w http.ResponseWriter, r *http.Request) {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
// Emitting the validator here lets a client that just bootstrapped issue a
|
||||
// conditional PUT without an intervening GET.
|
||||
util.SetETag(w, created.ETag())
|
||||
util.WriteJSONObject(r.Context(), w, created.ToAPIResponse())
|
||||
}
|
||||
|
||||
// updateSettings replaces the mutable settings fields on the account's row.
|
||||
// A request carrying a cluster bootstraps the row when the account doesn't
|
||||
// have one yet.
|
||||
//
|
||||
// An If-Match header makes the update conditional: it is honoured against the
|
||||
// stored row inside the write's transaction, and a stale validator is refused
|
||||
// with 412 rather than overwriting what changed since the client read. Omitting
|
||||
// the header keeps the pre-existing last-write-wins behaviour.
|
||||
func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
@@ -90,12 +82,11 @@ func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
settings := &types.Settings{AccountID: userAuth.AccountId}
|
||||
settings.FromAPIRequest(&req)
|
||||
|
||||
updated, err := h.manager.UpdateSettings(r.Context(), userAuth.UserId, settings, util.IfMatch(r))
|
||||
updated, err := h.manager.UpdateSettings(r.Context(), userAuth.UserId, settings)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
util.SetETag(w, updated.ETag())
|
||||
util.WriteJSONObject(r.Context(), w, updated.ToAPIResponse())
|
||||
}
|
||||
|
||||
@@ -103,11 +94,6 @@ func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
// The manager refuses (412) while providers exist or a proxy is actively
|
||||
// serving the endpoint; a later POST bootstraps fresh, allocating a new
|
||||
// endpoint.
|
||||
//
|
||||
// An If-Match header makes the delete conditional, and is worth sending here
|
||||
// even more than on update: both existing guards are about state rather than
|
||||
// staleness, so nothing else stops a client from deleting a row that was
|
||||
// replaced since it read one.
|
||||
func (h *handler) deleteSettings(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
@@ -115,7 +101,7 @@ func (h *handler) deleteSettings(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.manager.DeleteSettings(r.Context(), userAuth.AccountId, userAuth.UserId, util.IfMatch(r)); err != nil {
|
||||
if err := h.manager.DeleteSettings(r.Context(), userAuth.AccountId, userAuth.UserId); err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
@@ -137,9 +123,5 @@ func (h *handler) getSettings(w http.ResponseWriter, r *http.Request) {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
// The pre-bootstrap defaults are a representation like any other and carry
|
||||
// a validator too, so an If-Match taken before bootstrap cannot silently
|
||||
// match the row that appeared since.
|
||||
util.SetETag(w, settings.ETag())
|
||||
util.WriteJSONObject(r.Context(), w, settings.ToAPIResponse())
|
||||
}
|
||||
|
||||
@@ -393,202 +393,3 @@ func TestSettingsHandler_DeleteReleasesEndpointForFreshBootstrap(t *testing.T) {
|
||||
"the fresh row must carry bootstrap defaults, not the deleted row's toggles")
|
||||
assert.NotNil(t, second.CreatedAt, "the fresh row is persisted and carries timestamps")
|
||||
}
|
||||
|
||||
// bootstrapForETag bootstraps a settings row and returns the response body
|
||||
// alongside the validator the bootstrap emitted, which is what a client would
|
||||
// carry into its first conditional write.
|
||||
func bootstrapForETag(t *testing.T, f *agentNetworkHandlerFixture) (api.AgentNetworkSettings, string) {
|
||||
t.Helper()
|
||||
|
||||
rec := f.do(t, http.MethodPost, "/agent-network/settings",
|
||||
`{"proxy_address": "eu.proxy.netbird.io", "enable_prompt_collection": true, "redact_pii": true, "access_log_retention_days": 14}`)
|
||||
require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String())
|
||||
|
||||
var settings api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &settings))
|
||||
|
||||
etag := rec.Header().Get("ETag")
|
||||
require.NotEmpty(t, etag, "bootstrap must emit a validator so a client can PUT without an intervening GET")
|
||||
return settings, etag
|
||||
}
|
||||
|
||||
// putBody renders a complete settings update — every field, with the identity
|
||||
// echo the endpoint requires — so the conditional-request tests differ only in
|
||||
// their headers.
|
||||
func putBody(settings api.AgentNetworkSettings, redactPii bool, retention int) string {
|
||||
return fmt.Sprintf(
|
||||
`{"endpoint": %q, "proxy_address": %q, "enable_log_collection": true, "enable_prompt_collection": true, "redact_pii": %t, "access_log_retention_days": %d}`,
|
||||
settings.Endpoint, settings.ProxyAddress, redactPii, retention)
|
||||
}
|
||||
|
||||
// TestSettingsHandler_EmitsETag pins that every read and every write hands the
|
||||
// client back a validator, quoted as a strong entity-tag. Without one on the
|
||||
// write responses a client would have to re-GET after every update to stay
|
||||
// able to make the next one conditional.
|
||||
func TestSettingsHandler_EmitsETag(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
// The pre-bootstrap defaults are a representation too, and validate like
|
||||
// one — an If-Match taken here must not match the row that appears later.
|
||||
rec := f.do(t, http.MethodGet, "/agent-network/settings", "")
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
defaultsETag := rec.Header().Get("ETag")
|
||||
assert.NotEmpty(t, defaultsETag, "the unbootstrapped view must carry a validator")
|
||||
|
||||
settings, bootstrapETag := bootstrapForETag(t, f)
|
||||
assert.Regexp(t, `^"[0-9a-f]+"$`, bootstrapETag, "the validator must be a quoted strong entity-tag")
|
||||
assert.NotEqual(t, defaultsETag, bootstrapETag, "bootstrapping must move the validator")
|
||||
|
||||
rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, bootstrapETag, rec.Header().Get("ETag"),
|
||||
"reading an unchanged row must derive the same validator the bootstrap returned")
|
||||
|
||||
rec = f.do(t, http.MethodPut, "/agent-network/settings", putBody(settings, false, 7))
|
||||
require.Equal(t, http.StatusOK, rec.Code, "update must succeed: %s", rec.Body.String())
|
||||
assert.NotEqual(t, bootstrapETag, rec.Header().Get("ETag"),
|
||||
"an update that changed the representation must return a different validator")
|
||||
}
|
||||
|
||||
// TestSettingsHandler_PutIfMatch walks the conditional-update contract. The
|
||||
// stale case is the one the feature exists for: a client that planned against
|
||||
// an earlier read must be refused rather than silently reverting whatever
|
||||
// changed in between — RedactPii above all, where a silent revert turns a
|
||||
// compliance control off with no error and no drift warning.
|
||||
func TestSettingsHandler_PutIfMatch(t *testing.T) {
|
||||
t.Run("matching validator succeeds", func(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
settings, etag := bootstrapForETag(t, f)
|
||||
|
||||
rec := f.doWithHeaders(t, http.MethodPut, "/agent-network/settings",
|
||||
putBody(settings, false, 7), map[string]string{"If-Match": etag})
|
||||
require.Equal(t, http.StatusOK, rec.Code,
|
||||
"a matching precondition must be honoured: got %d body=%s", rec.Code, rec.Body.String())
|
||||
assert.NotEqual(t, etag, rec.Header().Get("ETag"),
|
||||
"the response must carry the new validator, not the one that was matched")
|
||||
})
|
||||
|
||||
t.Run("stale validator is refused and changes nothing", func(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
settings, stale := bootstrapForETag(t, f)
|
||||
|
||||
// Someone else writes in between — the dashboard operator enabling
|
||||
// something the planning client never saw.
|
||||
rec := f.do(t, http.MethodPut, "/agent-network/settings", putBody(settings, true, 21))
|
||||
require.Equal(t, http.StatusOK, rec.Code, "the intervening update must succeed: %s", rec.Body.String())
|
||||
var intervened api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &intervened))
|
||||
|
||||
rec = f.doWithHeaders(t, http.MethodPut, "/agent-network/settings",
|
||||
putBody(settings, false, 7), map[string]string{"If-Match": stale})
|
||||
require.Equal(t, http.StatusPreconditionFailed, rec.Code,
|
||||
"a stale precondition must be refused: got %d body=%s", rec.Code, rec.Body.String())
|
||||
|
||||
// Asserting the state, not just the status: a partial write would pass
|
||||
// a status-only check.
|
||||
rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var after api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &after))
|
||||
assert.Equal(t, intervened, after, "the refused update must leave the row byte-identical")
|
||||
})
|
||||
|
||||
t.Run("star matches the existing row", func(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
settings, _ := bootstrapForETag(t, f)
|
||||
|
||||
rec := f.doWithHeaders(t, http.MethodPut, "/agent-network/settings",
|
||||
putBody(settings, false, 7), map[string]string{"If-Match": "*"})
|
||||
assert.Equal(t, http.StatusOK, rec.Code,
|
||||
"* must match any current representation: got %d body=%s", rec.Code, rec.Body.String())
|
||||
})
|
||||
|
||||
t.Run("no precondition still succeeds", func(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
settings, _ := bootstrapForETag(t, f)
|
||||
|
||||
// The back-compatibility guarantee: clients that predate conditional
|
||||
// requests — the dashboard among them — keep last-write-wins.
|
||||
rec := f.do(t, http.MethodPut, "/agent-network/settings", putBody(settings, false, 7))
|
||||
assert.Equal(t, http.StatusOK, rec.Code,
|
||||
"an unconditional update must keep working: got %d body=%s", rec.Code, rec.Body.String())
|
||||
})
|
||||
|
||||
t.Run("precondition is checked before the immutability echo", func(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
settings, stale := bootstrapForETag(t, f)
|
||||
|
||||
rec := f.do(t, http.MethodPut, "/agent-network/settings", putBody(settings, true, 21))
|
||||
require.Equal(t, http.StatusOK, rec.Code, "the intervening update must succeed: %s", rec.Body.String())
|
||||
|
||||
// A client stale enough to hold an old validator may be stale in its
|
||||
// identity echo too. Answering 412 tells it the useful thing — go and
|
||||
// read again — where 422 would send it hunting an immutability bug.
|
||||
body := fmt.Sprintf(
|
||||
`{"endpoint": "other.gateway.example.com", "proxy_address": %q, "enable_log_collection": true, "enable_prompt_collection": true, "redact_pii": false, "access_log_retention_days": 7}`,
|
||||
settings.ProxyAddress)
|
||||
rec = f.doWithHeaders(t, http.MethodPut, "/agent-network/settings", body,
|
||||
map[string]string{"If-Match": stale})
|
||||
assert.Equal(t, http.StatusPreconditionFailed, rec.Code,
|
||||
"staleness must be reported ahead of the identity mismatch: got %d body=%s", rec.Code, rec.Body.String())
|
||||
})
|
||||
}
|
||||
|
||||
// TestSettingsHandler_DeleteIfMatch covers the conditional delete, which
|
||||
// carries more weight than the conditional update: both existing delete guards
|
||||
// are about state — no providers, no serving proxy — so nothing else stops a
|
||||
// client from deleting a row that was replaced since it read one.
|
||||
func TestSettingsHandler_DeleteIfMatch(t *testing.T) {
|
||||
t.Run("stale validator is refused and the row survives", func(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
settings, stale := bootstrapForETag(t, f)
|
||||
|
||||
rec := f.do(t, http.MethodPut, "/agent-network/settings", putBody(settings, true, 21))
|
||||
require.Equal(t, http.StatusOK, rec.Code, "the intervening update must succeed: %s", rec.Body.String())
|
||||
|
||||
rec = f.doWithHeaders(t, http.MethodDelete, "/agent-network/settings", "",
|
||||
map[string]string{"If-Match": stale})
|
||||
require.Equal(t, http.StatusPreconditionFailed, rec.Code,
|
||||
"a stale precondition must refuse the delete: got %d body=%s", rec.Code, rec.Body.String())
|
||||
|
||||
rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var after api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &after))
|
||||
assert.Equal(t, settings.Endpoint, after.Endpoint, "the refused delete must leave the row in place")
|
||||
})
|
||||
|
||||
t.Run("matching validator deletes", func(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
_, etag := bootstrapForETag(t, f)
|
||||
|
||||
rec := f.doWithHeaders(t, http.MethodDelete, "/agent-network/settings", "",
|
||||
map[string]string{"If-Match": etag})
|
||||
require.Equal(t, http.StatusOK, rec.Code,
|
||||
"a matching precondition must be honoured: got %d body=%s", rec.Code, rec.Body.String())
|
||||
|
||||
rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var after api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &after))
|
||||
assert.Empty(t, after.Endpoint, "the row must be gone")
|
||||
})
|
||||
|
||||
t.Run("precondition is checked before the state guards", func(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
settings, stale := bootstrapForETag(t, f)
|
||||
|
||||
rec := f.do(t, http.MethodPut, "/agent-network/settings", putBody(settings, true, 21))
|
||||
require.Equal(t, http.StatusOK, rec.Code, "the intervening update must succeed: %s", rec.Body.String())
|
||||
f.seedProvider(t, "prov-precondition")
|
||||
|
||||
// Both refusals are 412, so the status cannot tell them apart — the
|
||||
// message must, or a stale client is sent to delete providers it may
|
||||
// not even know about.
|
||||
rec = f.doWithHeaders(t, http.MethodDelete, "/agent-network/settings", "",
|
||||
map[string]string{"If-Match": stale})
|
||||
require.Equal(t, http.StatusPreconditionFailed, rec.Code, "the delete must be refused: %s", rec.Body.String())
|
||||
assert.Contains(t, rec.Body.String(), "if-match",
|
||||
"staleness must be reported ahead of the provider guard: %s", rec.Body.String())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ import (
|
||||
"github.com/netbirdio/netbird/management/server/permissions/modules"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/operations"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
httputil "github.com/netbirdio/netbird/shared/management/http/util"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
@@ -72,8 +71,8 @@ type Manager interface {
|
||||
|
||||
GetSettings(ctx context.Context, accountID, userID string) (*types.Settings, error)
|
||||
CreateSettings(ctx context.Context, userID string, settings *types.Settings, proxyAddress, endpoint string) (*types.Settings, error)
|
||||
UpdateSettings(ctx context.Context, userID string, settings *types.Settings, precondition *httputil.Precondition) (*types.Settings, error)
|
||||
DeleteSettings(ctx context.Context, accountID, userID string, precondition *httputil.Precondition) error
|
||||
UpdateSettings(ctx context.Context, userID string, settings *types.Settings) (*types.Settings, error)
|
||||
DeleteSettings(ctx context.Context, accountID, userID string) error
|
||||
|
||||
ListConsumption(ctx context.Context, accountID, userID string) ([]*types.Consumption, error)
|
||||
ListAccessLogs(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLog, int64, error)
|
||||
@@ -545,13 +544,6 @@ func (m *managerImpl) DeleteBudgetRule(ctx context.Context, accountID, userID, r
|
||||
return nil
|
||||
}
|
||||
|
||||
// stalePreconditionMsg is the refusal both conditional settings writes return.
|
||||
// Shared so the two cannot drift: DeleteSettings answers 412 for its state
|
||||
// guards as well, so the message is the only thing telling a client that it is
|
||||
// working from an old read rather than tripping over providers or a serving
|
||||
// proxy.
|
||||
const stalePreconditionMsg = "if-match precondition failed: the settings have changed since they were read; GET them again and retry"
|
||||
|
||||
// UpdateSettings replaces the mutable account-level settings — the collection
|
||||
// toggles and retention — on the account's row. The identity fields (Domain,
|
||||
// ProxyAddress) are assigned at bootstrap (CreateSettings) and immutable: the
|
||||
@@ -562,11 +554,7 @@ const stalePreconditionMsg = "if-match precondition failed: the settings have ch
|
||||
// Because the collection toggles change the synthesised service config
|
||||
// (prompt-capture gating, access-log emission), a reconcile is triggered so
|
||||
// the proxy and peer network maps converge on the new state.
|
||||
//
|
||||
// precondition carries the caller's If-Match, and is nil for an unconditional
|
||||
// update — last write wins, which is what the dashboard wants and what every
|
||||
// client that predates conditional requests gets.
|
||||
func (m *managerImpl) UpdateSettings(ctx context.Context, userID string, settings *types.Settings, precondition *httputil.Precondition) (*types.Settings, error) {
|
||||
func (m *managerImpl) UpdateSettings(ctx context.Context, userID string, settings *types.Settings) (*types.Settings, error) {
|
||||
if err := m.requirePermission(ctx, settings.AccountID, userID, modules.AgentNetworkSettings, operations.Update); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -585,20 +573,6 @@ func (m *managerImpl) UpdateSettings(ctx context.Context, userID string, setting
|
||||
return fmt.Errorf("get agent network settings: %w", err)
|
||||
}
|
||||
|
||||
// Evaluated here, under the row lock and inside the write's own
|
||||
// transaction, rather than in the handler: comparing before the
|
||||
// transaction only narrows the race, since two requests can both pass
|
||||
// the check before either writes. Locking the row first makes it a
|
||||
// genuine compare-and-set.
|
||||
//
|
||||
// It comes before the identity comparison because a client holding a
|
||||
// stale validator is stale in its identity echo too, and "you are
|
||||
// working from an old read" is the more accurate answer than "the
|
||||
// endpoint is immutable".
|
||||
if !precondition.Matches(existing.ETag()) {
|
||||
return status.Errorf(status.PreconditionFailed, "%s", stalePreconditionMsg)
|
||||
}
|
||||
|
||||
// The identity echo is compared leniently (trimmed, case-insensitive):
|
||||
// the stored values are normalized lowercase, and a client replaying a
|
||||
// GET response must never be rejected over casing it didn't choose.
|
||||
@@ -661,12 +635,7 @@ func hostnamesEquivalent(supplied, stored string) bool {
|
||||
// is not reserved. That full-reset semantic is what gives clients that model
|
||||
// immutability as replace-on-change (e.g. Terraform's RequiresReplace) a real
|
||||
// path: tear down providers, delete, re-create.
|
||||
//
|
||||
// precondition carries the caller's If-Match, and is nil for an unconditional
|
||||
// delete. It matters more here than on update: the two guards above are about
|
||||
// state rather than staleness, so without it nothing stops a client from
|
||||
// deleting a row that was replaced since it last read one.
|
||||
func (m *managerImpl) DeleteSettings(ctx context.Context, accountID, userID string, precondition *httputil.Precondition) error {
|
||||
func (m *managerImpl) DeleteSettings(ctx context.Context, accountID, userID string) error {
|
||||
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkSettings, operations.Delete); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -682,13 +651,6 @@ func (m *managerImpl) DeleteSettings(ctx context.Context, accountID, userID stri
|
||||
return fmt.Errorf("get agent network settings: %w", err)
|
||||
}
|
||||
|
||||
// Under the row lock, for the same reason as in UpdateSettings, and
|
||||
// before the state guards: a caller working from an old read should
|
||||
// learn that first, not be told about providers it may not know exist.
|
||||
if !precondition.Matches(existing.ETag()) {
|
||||
return status.Errorf(status.PreconditionFailed, "%s", stalePreconditionMsg)
|
||||
}
|
||||
|
||||
providers, err := tx.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, accountID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get agent network providers: %w", err)
|
||||
@@ -1138,13 +1100,11 @@ func (*mockManager) CreateSettings(_ context.Context, _ string, s *types.Setting
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (*mockManager) UpdateSettings(_ context.Context, _ string, s *types.Settings, _ *httputil.Precondition) (*types.Settings, error) {
|
||||
func (*mockManager) UpdateSettings(_ context.Context, _ string, s *types.Settings) (*types.Settings, error) {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (*mockManager) DeleteSettings(_ context.Context, _, _ string, _ *httputil.Precondition) error {
|
||||
return nil
|
||||
}
|
||||
func (*mockManager) DeleteSettings(_ context.Context, _, _ string) error { return nil }
|
||||
|
||||
func (*mockManager) ListConsumption(_ context.Context, _, _ string) ([]*types.Consumption, error) {
|
||||
return nil, nil
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/modules"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/operations"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
httputil "github.com/netbirdio/netbird/shared/management/http/util"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
// ifMatch builds the precondition a client sending this validator would
|
||||
// produce, by going through the same header parse the handler uses rather than
|
||||
// reaching past it.
|
||||
func ifMatch(t *testing.T, etag string) *httputil.Precondition {
|
||||
t.Helper()
|
||||
|
||||
r := httptest.NewRequest(http.MethodPut, "/", nil)
|
||||
r.Header.Set("If-Match", strconv.Quote(etag))
|
||||
return httputil.IfMatch(r)
|
||||
}
|
||||
|
||||
// updateFor renders a complete update for the given row, echoing the identity
|
||||
// fields the endpoint requires and setting retention to tell writers apart.
|
||||
func updateFor(settings *types.Settings, retention int) *types.Settings {
|
||||
return &types.Settings{
|
||||
AccountID: settings.AccountID,
|
||||
Domain: settings.Domain,
|
||||
ProxyAddress: settings.ProxyAddress,
|
||||
EnableLogCollection: true,
|
||||
EnablePromptCollection: true,
|
||||
RedactPii: true,
|
||||
AccessLogRetentionDays: retention,
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateSettingsPreconditionSerializesConcurrentWriters is the test the
|
||||
// design rests on. Two writers start from the same validator and race; exactly
|
||||
// one may win.
|
||||
//
|
||||
// An implementation that compares the validator before opening the write
|
||||
// transaction passes every sequential test in this suite and fails here: both
|
||||
// writers read the same row, both find their precondition satisfied, and both
|
||||
// then write — which is the lost update the feature exists to prevent, merely
|
||||
// narrowed to a smaller window. Holding the row under LockingStrengthUpdate
|
||||
// and comparing inside the write's own transaction is what makes it a genuine
|
||||
// compare-and-set.
|
||||
//
|
||||
// The test store is sqlite, which serializes writers of its own accord, so
|
||||
// what this pins directly is the outcome — exactly one success — rather than
|
||||
// the mechanism. It still has teeth against the check-before-transaction
|
||||
// shape, whose two reads interleave freely before either write. Running it
|
||||
// against postgres (NB_STORE_ENGINE_POSTGRES_DSN) exercises real concurrent
|
||||
// transactions.
|
||||
func TestUpdateSettingsPreconditionSerializesConcurrentWriters(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
|
||||
const accountID, userID = "account1", "user1"
|
||||
f.expectPermission(accountID, userID, modules.AgentNetworkSettings, operations.Create, true)
|
||||
f.expectPermission(accountID, userID, modules.AgentNetworkSettings, operations.Update, true)
|
||||
f.expectPermission(accountID, userID, modules.AgentNetworkSettings, operations.Update, true)
|
||||
|
||||
created, err := f.createSettings(ctx, accountID, userID, "cluster1.example.com", "")
|
||||
require.NoError(t, err, "bootstrap must succeed")
|
||||
|
||||
// Both writers plan against this one read, as a client that read, computed
|
||||
// a diff and is about to write the whole object back would.
|
||||
shared := created.ETag()
|
||||
|
||||
// noWrite is a retention value neither writer sends and the API would
|
||||
// never store, so an assertion that lands on it is a test bug rather than
|
||||
// a silently satisfied comparison. Zero would not do: the API documents 0
|
||||
// as "keep indefinitely", so it is a value the row could legitimately hold.
|
||||
const noWrite = -1
|
||||
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
start = make(chan struct{})
|
||||
errs = make([]error, 2)
|
||||
wrote = []int{7, 21}
|
||||
returned = []int{noWrite, noWrite}
|
||||
)
|
||||
for i := range 2 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
updated, err := f.manager.UpdateSettings(ctx, userID, updateFor(created, wrote[i]), ifMatch(t, shared))
|
||||
errs[i] = err
|
||||
if err == nil {
|
||||
returned[i] = updated.AccessLogRetentionDays
|
||||
}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
succeeded, winner := 0, noWrite
|
||||
for i, err := range errs {
|
||||
if err == nil {
|
||||
succeeded++
|
||||
winner = wrote[i]
|
||||
assert.Equal(t, wrote[i], returned[i], "the winner's response must carry what it sent")
|
||||
continue
|
||||
}
|
||||
assert.Truef(t, isPreconditionFailed(err),
|
||||
"the losing writer must be refused for staleness, got: %v (writer %d)", err, i)
|
||||
}
|
||||
require.Equal(t, 1, succeeded, "exactly one writer may win: %v", errs)
|
||||
|
||||
// The row must carry the winner's value and nothing blended.
|
||||
stored, err := f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
|
||||
require.NoError(t, err, "the row must survive the race")
|
||||
assert.Equal(t, winner, stored.AccessLogRetentionDays,
|
||||
"the stored row must be exactly what the winning writer sent")
|
||||
assert.NotEqual(t, shared, stored.ETag(), "the surviving row must derive a new validator")
|
||||
}
|
||||
|
||||
// TestUpdateSettingsUnconditionalIgnoresStaleness pins the back-compatibility
|
||||
// half: without a precondition the manager keeps last-write-wins, which is
|
||||
// what the dashboard relies on and what any client that predates conditional
|
||||
// requests does.
|
||||
func TestUpdateSettingsUnconditionalIgnoresStaleness(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
|
||||
const accountID, userID = "account1", "user1"
|
||||
f.expectPermission(accountID, userID, modules.AgentNetworkSettings, operations.Create, true)
|
||||
f.expectPermission(accountID, userID, modules.AgentNetworkSettings, operations.Update, true)
|
||||
f.expectPermission(accountID, userID, modules.AgentNetworkSettings, operations.Update, true)
|
||||
|
||||
created, err := f.createSettings(ctx, accountID, userID, "cluster1.example.com", "")
|
||||
require.NoError(t, err, "bootstrap must succeed")
|
||||
|
||||
_, err = f.manager.UpdateSettings(ctx, userID, updateFor(created, 21), nil)
|
||||
require.NoError(t, err, "the first unconditional update must succeed")
|
||||
|
||||
// The second writer is working from a read that is now stale, and with no
|
||||
// precondition it overwrites regardless.
|
||||
updated, err := f.manager.UpdateSettings(ctx, userID, updateFor(created, 7), nil)
|
||||
require.NoError(t, err, "an unconditional update must not be refused for staleness")
|
||||
assert.Equal(t, 7, updated.AccessLogRetentionDays, "last write wins without a precondition")
|
||||
}
|
||||
|
||||
// TestDeleteSettingsPreconditionRefusesStale pins the conditional delete at
|
||||
// the manager level: a stale validator refuses, and the row is still there
|
||||
// afterwards. Deletion is the destructive operation and its two other guards
|
||||
// are about state rather than staleness, so this is the only thing standing
|
||||
// between a client working from an old read and a released endpoint.
|
||||
func TestDeleteSettingsPreconditionRefusesStale(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
|
||||
const accountID, userID = "account1", "user1"
|
||||
f.expectPermission(accountID, userID, modules.AgentNetworkSettings, operations.Create, true)
|
||||
f.expectPermission(accountID, userID, modules.AgentNetworkSettings, operations.Update, true)
|
||||
f.expectPermission(accountID, userID, modules.AgentNetworkSettings, operations.Delete, true)
|
||||
f.expectPermission(accountID, userID, modules.AgentNetworkSettings, operations.Delete, true)
|
||||
|
||||
created, err := f.createSettings(ctx, accountID, userID, "cluster1.example.com", "")
|
||||
require.NoError(t, err, "bootstrap must succeed")
|
||||
stale := created.ETag()
|
||||
|
||||
updated, err := f.manager.UpdateSettings(ctx, userID, updateFor(created, 21), nil)
|
||||
require.NoError(t, err, "the intervening update must succeed")
|
||||
|
||||
err = f.manager.DeleteSettings(ctx, accountID, userID, ifMatch(t, stale))
|
||||
require.Error(t, err, "a stale precondition must refuse the delete")
|
||||
assert.True(t, isPreconditionFailed(err), "the refusal must be a precondition failure, got: %v", err)
|
||||
|
||||
stored, err := f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
|
||||
require.NoError(t, err, "the refused delete must leave the row in place")
|
||||
assert.Equal(t, created.Domain, stored.Domain, "the endpoint must not have been released")
|
||||
|
||||
// The validator the intervening update returned is the current one, and
|
||||
// deleting with it goes through.
|
||||
require.NoError(t, f.manager.DeleteSettings(ctx, accountID, userID, ifMatch(t, updated.ETag())),
|
||||
"a matching precondition must be honoured")
|
||||
_, err = f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
|
||||
assert.Error(t, err, "the row must be gone")
|
||||
}
|
||||
|
||||
// isPreconditionFailed reports whether err is the 412-mapped status error.
|
||||
func isPreconditionFailed(err error) bool {
|
||||
var sErr *status.Error
|
||||
return errors.As(err, &sErr) && sErr.Type() == status.PreconditionFailed
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -69,64 +67,6 @@ func DefaultSettings(accountID string) *Settings {
|
||||
}
|
||||
}
|
||||
|
||||
// etagLength is how much of the hash the validator carries. 16 hex characters
|
||||
// — 64 bits — is far more than enough to make an accidental collision between
|
||||
// two representations of one account's settings unreachable, and keeps the
|
||||
// header short enough to read in a log line.
|
||||
const etagLength = 16
|
||||
|
||||
// ETag returns a strong validator over the settings representation, for
|
||||
// conditional requests (RFC 9110 If-Match). The value is unquoted; applying
|
||||
// the quoting is the transport layer's job.
|
||||
//
|
||||
// The hash covers an explicit field tuple rather than the marshalled API
|
||||
// representation: field ordering in the generated API types is not a contract,
|
||||
// so hashing serialized output would make the validator churn with codegen.
|
||||
// Two exclusions are deliberate:
|
||||
//
|
||||
// - AccountID identifies the resource — it is the URL, not the
|
||||
// representation. Including it would make the validator differ between
|
||||
// accounts whose settings are genuinely identical, which no client can
|
||||
// observe and no precondition needs.
|
||||
// - UpdatedAt is excluded so that equal representations always yield equal
|
||||
// validators. A write that changes nothing must not invalidate a
|
||||
// precondition another client is holding.
|
||||
//
|
||||
// Everything else is in, including the identity fields and CreatedAt. A
|
||||
// validator that covered only the mutable toggles would survive a delete
|
||||
// followed by a fresh bootstrap onto the same toggle values, and an If-Match
|
||||
// held across that gap would then authorize a write against what is really a
|
||||
// different resource. CreatedAt is what distinguishes the re-bootstrapped row.
|
||||
//
|
||||
// CreatedAt is hashed at whole-second precision because the validator has to
|
||||
// agree across a store round-trip. A freshly bootstrapped row derives its
|
||||
// validator in memory, from a time.Time carrying nanoseconds, while every
|
||||
// later comparison derives it from a row read back out of the store — and the
|
||||
// engines truncate: PostgreSQL to microseconds, MySQL DATETIME to whole
|
||||
// seconds without an fsp. At nanosecond precision the two never agree again,
|
||||
// so the validator a bootstrap hands out is permanently unusable. Seconds is
|
||||
// the floor every supported engine preserves. The cost is that a delete and
|
||||
// re-bootstrap within the same second, onto the same endpoint and the same
|
||||
// toggles, derives the same validator; a labeled bootstrap draws a fresh
|
||||
// random label, so that needs a self-addressed endpoint reclaimed inside one
|
||||
// second.
|
||||
//
|
||||
// Adding a field to Settings means deciding whether it belongs here; the
|
||||
// field-count guard in the tests is what forces that decision.
|
||||
func (s *Settings) ETag() string {
|
||||
h := sha256.New()
|
||||
fmt.Fprintf(h, "%s\x00%s\x00%t\x00%t\x00%t\x00%d\x00%d",
|
||||
s.Domain,
|
||||
s.ProxyAddress,
|
||||
s.EnableLogCollection,
|
||||
s.EnablePromptCollection,
|
||||
s.RedactPii,
|
||||
s.AccessLogRetentionDays,
|
||||
s.CreatedAt.Unix(),
|
||||
)
|
||||
return hex.EncodeToString(h.Sum(nil))[:etagLength]
|
||||
}
|
||||
|
||||
// Endpoint returns the bare hostname agents reach this account at — the
|
||||
// Domain column. Empty until the row is bootstrapped.
|
||||
func (s *Settings) Endpoint() string { return s.Domain }
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// etagSettings is a fully populated settings row — every hashed field set to a
|
||||
// distinctive value — so a mutation test can flip exactly one thing at a time.
|
||||
// The timestamp carries sub-second precision on purpose: a whole-second value
|
||||
// would make the precision test below pass without proving anything.
|
||||
func etagSettings() *Settings {
|
||||
created := time.Date(2026, 8, 11, 9, 30, 0, 123456789, time.UTC)
|
||||
return &Settings{
|
||||
AccountID: "acc-1",
|
||||
Domain: "cool-otter.eu.proxy.netbird.io",
|
||||
ProxyAddress: "eu.proxy.netbird.io",
|
||||
EnableLogCollection: true,
|
||||
EnablePromptCollection: true,
|
||||
RedactPii: true,
|
||||
AccessLogRetentionDays: 30,
|
||||
CreatedAt: created,
|
||||
UpdatedAt: created,
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettings_ETagShape pins the wire shape of the validator: a bare
|
||||
// lowercase hex string of the documented length, with no quoting — quoting is
|
||||
// the transport layer's job, and a validator that arrived pre-quoted would be
|
||||
// double-quoted on the way out.
|
||||
func TestSettings_ETagShape(t *testing.T) {
|
||||
etag := etagSettings().ETag()
|
||||
|
||||
assert.Len(t, etag, etagLength, "the validator must be exactly etagLength characters")
|
||||
assert.NotContains(t, etag, `"`, "the derived validator must not carry its own quoting")
|
||||
for _, r := range etag {
|
||||
require.Truef(t, (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f'),
|
||||
"the validator must be lowercase hex, got %q in %q", r, etag)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettings_ETagIsStable covers the guarantee every conditional request
|
||||
// rests on: an unchanged row derives the same validator every time, including
|
||||
// across a fresh struct built from the same values. A validator that varied
|
||||
// per derivation would fail every If-Match and make the feature unusable.
|
||||
func TestSettings_ETagIsStable(t *testing.T) {
|
||||
s := etagSettings()
|
||||
|
||||
first := s.ETag()
|
||||
assert.Equal(t, first, s.ETag(), "repeated derivation from one value must agree")
|
||||
assert.Equal(t, first, etagSettings().ETag(), "an equal row must derive an equal validator")
|
||||
}
|
||||
|
||||
// TestSettings_ETagSensitivity is the other half of the contract: every field
|
||||
// the validator covers must actually move it. The cases are also what makes
|
||||
// the field-count guard meaningful — a new field that belongs in the tuple but
|
||||
// is missing from it has no case here, and the guard is what catches that.
|
||||
//
|
||||
// The mutations are checked to be pairwise distinct, not merely different from
|
||||
// the baseline: that is what catches an ambiguous concatenation, where moving
|
||||
// a character across a field boundary would hash identically without the
|
||||
// delimiter.
|
||||
func TestSettings_ETagSensitivity(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(*Settings)
|
||||
}{
|
||||
{"domain", func(s *Settings) { s.Domain = "brave-otter.eu.proxy.netbird.io" }},
|
||||
{"proxy address", func(s *Settings) { s.ProxyAddress = "us.proxy.netbird.io" }},
|
||||
{"log collection", func(s *Settings) { s.EnableLogCollection = false }},
|
||||
{"prompt collection", func(s *Settings) { s.EnablePromptCollection = false }},
|
||||
{"redact pii", func(s *Settings) { s.RedactPii = false }},
|
||||
{"retention", func(s *Settings) { s.AccessLogRetentionDays = 14 }},
|
||||
{"created at", func(s *Settings) { s.CreatedAt = s.CreatedAt.Add(time.Second) }},
|
||||
// Moving characters across the Domain/ProxyAddress boundary leaves
|
||||
// the two fields' concatenation byte-identical, so this case passes
|
||||
// only because the tuple is delimited.
|
||||
{"identity boundary shifted", func(s *Settings) {
|
||||
joined := s.Domain + s.ProxyAddress
|
||||
split := len(s.Domain) - 3
|
||||
s.Domain, s.ProxyAddress = joined[:split], joined[split:]
|
||||
}},
|
||||
}
|
||||
|
||||
baseline := etagSettings().ETag()
|
||||
seen := map[string]string{"baseline": baseline}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := etagSettings()
|
||||
tc.mutate(s)
|
||||
|
||||
etag := s.ETag()
|
||||
assert.NotEqual(t, baseline, etag, "changing %s must change the validator", tc.name)
|
||||
|
||||
if other, clash := seen[etag]; clash {
|
||||
t.Fatalf("changing %s derives the same validator as %s (%s) — the field tuple is ambiguous", tc.name, other, etag)
|
||||
}
|
||||
seen[etag] = tc.name
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettings_ETagExclusions pins the two deliberate omissions. AccountID is
|
||||
// the resource's identity rather than its representation. UpdatedAt is left
|
||||
// out so that a write which changes nothing observable does not invalidate a
|
||||
// precondition another client is holding — equal representations must always
|
||||
// derive equal validators.
|
||||
func TestSettings_ETagExclusions(t *testing.T) {
|
||||
baseline := etagSettings().ETag()
|
||||
|
||||
other := etagSettings()
|
||||
other.AccountID = "acc-2"
|
||||
assert.Equal(t, baseline, other.ETag(), "the account id must not reach the validator")
|
||||
|
||||
touched := etagSettings()
|
||||
touched.UpdatedAt = touched.UpdatedAt.Add(time.Hour)
|
||||
assert.Equal(t, baseline, touched.ETag(), "a write that changed nothing must not move the validator")
|
||||
}
|
||||
|
||||
// TestSettings_ETagSurvivesTimestampTruncation pins the store round-trip the
|
||||
// validator has to survive. A freshly bootstrapped row derives its validator
|
||||
// in memory, from a time.Time carrying nanoseconds; every later comparison
|
||||
// derives it from a row read back out of the store, and the engines truncate
|
||||
// on the way through — PostgreSQL to microseconds, MySQL DATETIME to whole
|
||||
// seconds without an fsp. If the hash is sensitive below its coarsest engine's
|
||||
// precision, the validator a bootstrap hands out never matches again and the
|
||||
// documented "conditional PUT without an intervening GET" is a permanent 412.
|
||||
//
|
||||
// Asserted on the type rather than through a store, so it holds without running
|
||||
// the suite against every engine. The sqlite test store preserves nanoseconds,
|
||||
// so a sqlite-only suite cannot observe the truncation at all.
|
||||
func TestSettings_ETagSurvivesTimestampTruncation(t *testing.T) {
|
||||
inMemory := etagSettings()
|
||||
require.NotZero(t, inMemory.CreatedAt.Nanosecond(), "the fixture must carry sub-second precision to prove anything")
|
||||
|
||||
for name, truncation := range map[string]time.Duration{
|
||||
"postgres (microseconds)": time.Microsecond,
|
||||
"mysql (milliseconds)": time.Millisecond,
|
||||
"mysql datetime (seconds)": time.Second,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
roundTripped := etagSettings()
|
||||
roundTripped.CreatedAt = roundTripped.CreatedAt.Truncate(truncation)
|
||||
|
||||
assert.Equal(t, inMemory.ETag(), roundTripped.ETag(),
|
||||
"a validator derived before the write must still match one derived after reading the row back")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettings_ETagOfDefaults covers the pre-bootstrap view, which GET serves
|
||||
// as a real representation and therefore validates like one. It must derive
|
||||
// without panicking on the zero CreatedAt, and it must not collide with a
|
||||
// bootstrapped row — otherwise an If-Match taken before bootstrap would
|
||||
// authorize a write against the row that appeared since.
|
||||
func TestSettings_ETagOfDefaults(t *testing.T) {
|
||||
defaults := DefaultSettings("acc-1").ETag()
|
||||
|
||||
assert.Len(t, defaults, etagLength, "the default view must derive a well-formed validator")
|
||||
assert.NotEqual(t, etagSettings().ETag(), defaults,
|
||||
"the unbootstrapped view must not validate as a bootstrapped row")
|
||||
}
|
||||
|
||||
// etagFieldCount is the number of fields Settings carries. ETag hashes an
|
||||
// explicit tuple rather than the struct, so a field added here is silently
|
||||
// outside the validator until someone decides otherwise — the worst kind of
|
||||
// gap, because the mechanism looks present and works for every other field.
|
||||
//
|
||||
// If this constant needs updating, that is the decision point: either add the
|
||||
// new field to ETag and give it a case in TestSettings_ETagSensitivity, or
|
||||
// record here why it stays out.
|
||||
const etagFieldCount = 9
|
||||
|
||||
// TestSettings_ETagFieldCountGuard fails when a field is added to or removed
|
||||
// from Settings, forcing the question of whether it belongs in the validator.
|
||||
func TestSettings_ETagFieldCountGuard(t *testing.T) {
|
||||
assert.Equal(t, etagFieldCount, reflect.TypeFor[Settings]().NumField(),
|
||||
"Settings gained or lost a field: decide whether it belongs in ETag(), then update etagFieldCount")
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Package activity records that a principal used a reverse proxy service, so
|
||||
// that activity accounting counts people and devices which reach services
|
||||
// through the proxy but never touch the dashboard or the management API.
|
||||
package activity
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
)
|
||||
|
||||
// Manager records reverse proxy usage against the timestamps activity
|
||||
// accounting reads. Both methods are best effort from the caller's point of
|
||||
// view: a lost record is corrected by the next request, and no authorization
|
||||
// decision reads them back.
|
||||
type Manager interface {
|
||||
// RecordUserLogin records a completed SSO sign-in to a proxied service.
|
||||
// Service users have no interactive login and are ignored.
|
||||
RecordUserLogin(ctx context.Context, accountID string, user *types.User) error
|
||||
// RecordPeerSeen records that a peer reached a private service over the
|
||||
// mesh, which is what lets its owner count as active. Peers activity
|
||||
// accounting excludes, and peers already seen recently, are ignored.
|
||||
RecordPeerSeen(ctx context.Context, accountID string, peer *peer.Peer) error
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/activity"
|
||||
"github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
)
|
||||
|
||||
// peerSeenInterval is how stale a peer's LastSeen must be before reaching a
|
||||
// private service refreshes it. Positive tunnel validations are cached on the
|
||||
// proxy for five minutes, so without a floor a busy peer would rewrite its row
|
||||
// behind every request; an hour still sits well inside the window activity
|
||||
// accounting asks about.
|
||||
const peerSeenInterval = time.Hour
|
||||
|
||||
type managerImpl struct {
|
||||
store store.Store
|
||||
}
|
||||
|
||||
// NewManager returns the activity manager backed by the management store.
|
||||
func NewManager(store store.Store) activity.Manager {
|
||||
return &managerImpl{store: store}
|
||||
}
|
||||
|
||||
// RecordUserLogin stamps the login the same way the dashboard and device login
|
||||
// paths do, so a person who only ever reaches proxied services still has a
|
||||
// login on record.
|
||||
func (m *managerImpl) RecordUserLogin(ctx context.Context, accountID string, user *types.User) error {
|
||||
if user == nil || user.IsServiceUser {
|
||||
return nil
|
||||
}
|
||||
|
||||
return m.store.SaveUserLastLogin(ctx, accountID, user.Id, time.Now().UTC())
|
||||
}
|
||||
|
||||
// RecordPeerSeen stamps LastSeen, the column a peer activates its owner
|
||||
// through. The peer the caller already holds answers the throttle without a
|
||||
// query, so a peer seen inside the interval costs nothing to skip; the same
|
||||
// cutoff goes to the store, which enforces it inside the UPDATE so concurrent
|
||||
// requests for one peer cannot each write off their own stale read.
|
||||
func (m *managerImpl) RecordPeerSeen(ctx context.Context, accountID string, peer *peer.Peer) error {
|
||||
if peer == nil || !countsTowardActivity(peer) {
|
||||
return nil
|
||||
}
|
||||
|
||||
staleBefore := time.Now().UTC().Add(-peerSeenInterval)
|
||||
if peer.Status != nil && peer.Status.LastSeen.After(staleBefore) {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := m.store.RefreshPeerLastSeen(ctx, accountID, peer.ID, staleBefore)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// countsTowardActivity reports whether the peer represents a device a person
|
||||
// actually runs. Embedded proxy peers are infrastructure and browser (WASM)
|
||||
// clients are ephemeral sessions, so activity accounting ignores both and a
|
||||
// write for them could never count.
|
||||
func countsTowardActivity(peer *peer.Peer) bool {
|
||||
return !peer.ProxyMeta.Embedded && peer.Meta.KernelVersion != "wasm"
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
)
|
||||
|
||||
// recordingStore captures the two writes the activity manager makes. The
|
||||
// embedded interface satisfies the rest and panics if anything else is called,
|
||||
// which keeps the manager honest about its surface.
|
||||
type recordingStore struct {
|
||||
store.Store
|
||||
logins []loginWrite
|
||||
seen []seenWrite
|
||||
}
|
||||
|
||||
type loginWrite struct {
|
||||
accountID string
|
||||
userID string
|
||||
at time.Time
|
||||
}
|
||||
|
||||
type seenWrite struct {
|
||||
accountID string
|
||||
peerID string
|
||||
staleBefore time.Time
|
||||
}
|
||||
|
||||
func (s *recordingStore) SaveUserLastLogin(_ context.Context, accountID, userID string, lastLogin time.Time) error {
|
||||
s.logins = append(s.logins, loginWrite{accountID: accountID, userID: userID, at: lastLogin})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *recordingStore) RefreshPeerLastSeen(_ context.Context, accountID, peerID string, staleBefore time.Time) (bool, error) {
|
||||
s.seen = append(s.seen, seenWrite{accountID: accountID, peerID: peerID, staleBefore: staleBefore})
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func TestRecordUserLogin(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
user *types.User
|
||||
expectWrite bool
|
||||
}{
|
||||
{
|
||||
name: "regular user is recorded",
|
||||
user: &types.User{Id: "user1", AccountID: "account1"},
|
||||
expectWrite: true,
|
||||
},
|
||||
{
|
||||
// Activity accounting never counts service users, so a row for one
|
||||
// would be noise.
|
||||
name: "service user is ignored",
|
||||
user: &types.User{Id: "svc1", AccountID: "account1", IsServiceUser: true},
|
||||
expectWrite: false,
|
||||
},
|
||||
{
|
||||
name: "missing user is ignored",
|
||||
user: nil,
|
||||
expectWrite: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
st := &recordingStore{}
|
||||
require.NoError(t, NewManager(st).RecordUserLogin(context.Background(), "account1", tt.user))
|
||||
|
||||
if !tt.expectWrite {
|
||||
assert.Empty(t, st.logins, "no login should have been recorded")
|
||||
return
|
||||
}
|
||||
|
||||
require.Len(t, st.logins, 1, "exactly one login should have been recorded")
|
||||
assert.Equal(t, "account1", st.logins[0].accountID, "login must be recorded against the service account")
|
||||
assert.Equal(t, tt.user.Id, st.logins[0].userID, "login must be recorded against the signing-in user")
|
||||
assert.Equal(t, time.UTC, st.logins[0].at.Location(), "timestamps are written in UTC")
|
||||
assert.WithinDuration(t, time.Now().UTC(), st.logins[0].at, time.Minute, "login should be stamped now")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordPeerSeen(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
peer *peer.Peer
|
||||
expectWrite bool
|
||||
}{
|
||||
{
|
||||
name: "peer seen long ago is recorded",
|
||||
peer: &peer.Peer{ID: "peer1", Status: &peer.PeerStatus{LastSeen: time.Now().Add(-3 * time.Hour)}},
|
||||
expectWrite: true,
|
||||
},
|
||||
{
|
||||
name: "peer never seen is recorded",
|
||||
peer: &peer.Peer{ID: "peer1", Status: &peer.PeerStatus{}},
|
||||
expectWrite: true,
|
||||
},
|
||||
{
|
||||
// The throttle. The caller already holds the peer, so skipping a
|
||||
// recently seen one costs nothing.
|
||||
name: "peer seen inside the interval is skipped",
|
||||
peer: &peer.Peer{ID: "peer1", Status: &peer.PeerStatus{LastSeen: time.Now().Add(-10 * time.Minute)}},
|
||||
expectWrite: false,
|
||||
},
|
||||
{
|
||||
name: "embedded proxy peer is skipped",
|
||||
peer: &peer.Peer{ID: "peer1", ProxyMeta: peer.ProxyMeta{Embedded: true}, Status: &peer.PeerStatus{LastSeen: time.Now().Add(-3 * time.Hour)}},
|
||||
expectWrite: false,
|
||||
},
|
||||
{
|
||||
name: "browser client is skipped",
|
||||
peer: &peer.Peer{ID: "peer1", Meta: peer.PeerSystemMeta{KernelVersion: "wasm"}, Status: &peer.PeerStatus{LastSeen: time.Now().Add(-3 * time.Hour)}},
|
||||
expectWrite: false,
|
||||
},
|
||||
{
|
||||
name: "missing peer is ignored",
|
||||
peer: nil,
|
||||
expectWrite: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
st := &recordingStore{}
|
||||
require.NoError(t, NewManager(st).RecordPeerSeen(context.Background(), "account1", tt.peer))
|
||||
|
||||
if !tt.expectWrite {
|
||||
assert.Empty(t, st.seen, "no activity should have been recorded")
|
||||
return
|
||||
}
|
||||
|
||||
require.Len(t, st.seen, 1, "exactly one activity write should have been recorded")
|
||||
assert.Equal(t, "account1", st.seen[0].accountID, "activity must be recorded against the service account")
|
||||
assert.Equal(t, tt.peer.ID, st.seen[0].peerID, "activity must be recorded against the calling peer")
|
||||
assert.Equal(t, time.UTC, st.seen[0].staleBefore.Location(), "cutoffs are passed in UTC")
|
||||
assert.WithinDuration(t, time.Now().UTC().Add(-peerSeenInterval), st.seen[0].staleBefore, time.Minute,
|
||||
"the store must enforce the same interval the local check applies")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,8 @@ import (
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
|
||||
accesslogsmanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs/manager"
|
||||
proxyactivity "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/activity"
|
||||
proxyactivitymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/activity/manager"
|
||||
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
@@ -231,6 +233,7 @@ func (s *BaseServer) ReverseProxyGRPCServer() *nbgrpc.ProxyServiceServer {
|
||||
proxyService := nbgrpc.NewProxyServiceServer(s.AccessLogsManager(), s.ProxyTokenStore(), s.PKCEVerifierStore(), s.proxyOIDCConfig(), s.PeersManager(), s.UsersManager(), s.IdpManager(), s.ProxyManager(), s.Store())
|
||||
s.AfterInit(func(s *BaseServer) {
|
||||
proxyService.SetServiceManager(s.ServiceManager())
|
||||
proxyService.SetActivityManager(s.ProxyActivityManager())
|
||||
proxyService.SetProxyController(s.ServiceProxyController())
|
||||
proxyService.SetAgentNetworkSynthesizer(newAgentNetworkSynthesizer(s.Store()))
|
||||
proxyService.SetAgentNetworkLimitsService(s.AgentNetworkManager())
|
||||
@@ -290,6 +293,13 @@ func (s *BaseServer) PKCEVerifierStore() *nbgrpc.PKCEVerifierStore {
|
||||
})
|
||||
}
|
||||
|
||||
// ProxyActivityManager records reverse proxy usage for activity accounting.
|
||||
func (s *BaseServer) ProxyActivityManager() proxyactivity.Manager {
|
||||
return Create(s, func() proxyactivity.Manager {
|
||||
return proxyactivitymanager.NewManager(s.Store())
|
||||
})
|
||||
}
|
||||
|
||||
func (s *BaseServer) AccessLogsManager() accesslogs.Manager {
|
||||
return Create(s, func() accesslogs.Manager {
|
||||
accessLogManager := accesslogsmanager.NewManager(s.Store(), s.PermissionsManager(), s.GeoLocationManager())
|
||||
|
||||
@@ -32,6 +32,7 @@ import (
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/peers"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/activity"
|
||||
"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"
|
||||
@@ -128,6 +129,9 @@ type ProxyServiceServer struct {
|
||||
// Manager for IdP-enriched user data (may be nil when no IdP is configured)
|
||||
idpManager idp.Manager
|
||||
|
||||
// Manager that records reverse proxy usage for activity accounting
|
||||
activityManager activity.Manager
|
||||
|
||||
// Store for one-time authentication tokens
|
||||
tokenStore *OneTimeTokenStore
|
||||
|
||||
@@ -250,6 +254,13 @@ func (s *ProxyServiceServer) SetServiceManager(manager rpservice.Manager) {
|
||||
s.serviceManager = manager
|
||||
}
|
||||
|
||||
// SetActivityManager wires the manager that records reverse proxy usage.
|
||||
func (s *ProxyServiceServer) SetActivityManager(manager activity.Manager) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.activityManager = 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
|
||||
@@ -1717,7 +1728,7 @@ func (s *ProxyServiceServer) GenerateSessionToken(ctx context.Context, domain, u
|
||||
|
||||
groupIDs, groupNames := pairGroupIDsAndNames(userGroups)
|
||||
|
||||
return sessionkey.SignToken(
|
||||
token, err := sessionkey.SignToken(
|
||||
service.SessionPrivateKey,
|
||||
userID,
|
||||
user.Email,
|
||||
@@ -1727,6 +1738,25 @@ func (s *ProxyServiceServer) GenerateSessionToken(ctx context.Context, domain, u
|
||||
groupNames,
|
||||
proxyauth.DefaultSessionExpiry,
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
s.recordUserLogin(ctx, service.AccountID, user)
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// recordUserLogin hands the sign-in to the activity manager. The RPC must not
|
||||
// fail on it, so the error is logged and dropped here rather than returned.
|
||||
func (s *ProxyServiceServer) recordUserLogin(ctx context.Context, accountID string, user *types.User) {
|
||||
if s.activityManager == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.activityManager.RecordUserLogin(ctx, accountID, user); err != nil {
|
||||
log.WithContext(ctx).Debugf("record proxy login for user %s: %v", user.Id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateUserGroupAccess checks if a user has access to a service.
|
||||
@@ -2076,6 +2106,8 @@ func (s *ProxyServiceServer) ValidateTunnelPeer(ctx context.Context, req *proto.
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.recordPeerSeen(ctx, service.AccountID, peer)
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"domain": domain,
|
||||
"tunnel_ip": tunnelIPStr,
|
||||
@@ -2093,6 +2125,18 @@ func (s *ProxyServiceServer) ValidateTunnelPeer(ctx context.Context, req *proto.
|
||||
}, nil
|
||||
}
|
||||
|
||||
// recordPeerSeen hands the mesh request to the activity manager. The RPC must
|
||||
// not fail on it, so the error is logged and dropped here rather than returned.
|
||||
func (s *ProxyServiceServer) recordPeerSeen(ctx context.Context, accountID string, peer *peer.Peer) {
|
||||
if s.activityManager == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.activityManager.RecordPeerSeen(ctx, accountID, peer); err != nil {
|
||||
log.WithContext(ctx).Debugf("record proxy activity for peer %s: %v", peer.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// resolvePeerOwner returns the user a peer is linked to, once per request so
|
||||
// the status gate and the identity resolution below share a single lookup.
|
||||
// Unlinked peers (machine agents) have no owner. A lookup that fails returns
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -155,6 +156,27 @@ type mockTunnelPeersManager struct {
|
||||
groupsErr error
|
||||
}
|
||||
|
||||
// mockActivityManager records what the RPC handed to the activity manager. The
|
||||
// policy (throttling, exclusions) is the manager's and is tested there; these
|
||||
// tests only pin which requests reach it.
|
||||
type mockActivityManager struct {
|
||||
seenMarks []seenMark
|
||||
}
|
||||
|
||||
type seenMark struct {
|
||||
accountID string
|
||||
peerID string
|
||||
}
|
||||
|
||||
func (m *mockActivityManager) RecordUserLogin(_ context.Context, _ string, _ *types.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockActivityManager) RecordPeerSeen(_ context.Context, accountID string, peer *peer.Peer) error {
|
||||
m.seenMarks = append(m.seenMarks, seenMark{accountID: accountID, peerID: peer.ID})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockTunnelPeersManager) GetPeerByTunnelIP(_ context.Context, _ string, _ net.IP) (*peer.Peer, error) {
|
||||
return m.peer, m.peerErr
|
||||
}
|
||||
@@ -745,6 +767,78 @@ func TestValidateTunnelPeerOwnerStatus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateTunnelPeerRecordsActivity pins that a granted mesh request is
|
||||
// handed to the activity manager. Which of those the manager then writes is its
|
||||
// own decision, covered by its tests.
|
||||
func TestValidateTunnelPeerRecordsActivity(t *testing.T) {
|
||||
const (
|
||||
domain = "app.example.com"
|
||||
accountID = "account1"
|
||||
peerID = "peer1"
|
||||
)
|
||||
|
||||
activityManager := &mockActivityManager{}
|
||||
server := &ProxyServiceServer{
|
||||
activityManager: activityManager,
|
||||
serviceManager: &mockReverseProxyManager{
|
||||
proxiesByAccount: map[string][]*service.Service{
|
||||
accountID: {{Domain: domain, AccountID: accountID}},
|
||||
},
|
||||
},
|
||||
peersManager: &mockTunnelPeersManager{
|
||||
peer: &peer.Peer{ID: peerID, Name: "agent", Status: &peer.PeerStatus{LastSeen: time.Now().Add(-3 * time.Hour)}},
|
||||
},
|
||||
usersManager: &mockUsersManager{users: map[string]*types.User{}},
|
||||
}
|
||||
|
||||
resp, err := server.ValidateTunnelPeer(context.Background(), &proto.ValidateTunnelPeerRequest{
|
||||
Domain: domain,
|
||||
TunnelIp: "100.64.0.1",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, resp.GetValid(), "peer should be granted access")
|
||||
|
||||
require.Len(t, activityManager.seenMarks, 1, "a granted peer should reach the activity manager once")
|
||||
assert.Equal(t, accountID, activityManager.seenMarks[0].accountID, "activity must be attributed to the service account")
|
||||
assert.Equal(t, peerID, activityManager.seenMarks[0].peerID, "activity must be attributed to the calling peer")
|
||||
}
|
||||
|
||||
// TestValidateTunnelPeerDeniedRecordsNoActivity keeps the write on the granted
|
||||
// path only: a refused peer is not evidence its owner was active.
|
||||
func TestValidateTunnelPeerDeniedRecordsNoActivity(t *testing.T) {
|
||||
const (
|
||||
domain = "app.example.com"
|
||||
accountID = "account1"
|
||||
)
|
||||
|
||||
activityManager := &mockActivityManager{}
|
||||
server := &ProxyServiceServer{
|
||||
activityManager: activityManager,
|
||||
serviceManager: &mockReverseProxyManager{
|
||||
proxiesByAccount: map[string][]*service.Service{
|
||||
accountID: {{Domain: domain, AccountID: accountID}},
|
||||
},
|
||||
},
|
||||
peersManager: &mockTunnelPeersManager{
|
||||
peer: &peer.Peer{ID: "peer1", Name: "agent", UserID: "user1", Status: &peer.PeerStatus{LastSeen: time.Now().Add(-3 * time.Hour)}},
|
||||
},
|
||||
// The owner is blocked, so the tunnel gate denies before the mint.
|
||||
usersManager: &mockUsersManager{users: map[string]*types.User{
|
||||
"user1": {Id: "user1", AccountID: accountID, Blocked: true},
|
||||
}},
|
||||
}
|
||||
|
||||
resp, err := server.ValidateTunnelPeer(context.Background(), &proto.ValidateTunnelPeerRequest{
|
||||
Domain: domain,
|
||||
TunnelIp: "100.64.0.1",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.False(t, resp.GetValid(), "blocked owner should be denied")
|
||||
assert.Empty(t, activityManager.seenMarks, "a denied peer must not be marked seen")
|
||||
}
|
||||
|
||||
func TestGetAccountProxyByDomain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -117,7 +117,7 @@ func TestAgentNetwork_UpdateSettings_PreservesImmutableAndTogglesCollection(t *t
|
||||
EnablePromptCollection: true,
|
||||
RedactPii: true,
|
||||
AccessLogRetentionDays: before.AccessLogRetentionDays,
|
||||
}, nil)
|
||||
})
|
||||
require.NoError(t, err, "UpdateSettings must succeed")
|
||||
assert.Equal(t, before.Domain, updated.Domain, "domain is immutable and must be preserved")
|
||||
assert.Equal(t, before.ProxyAddress, updated.ProxyAddress, "proxy address is immutable and must be preserved")
|
||||
@@ -147,7 +147,7 @@ func TestAgentNetwork_UpdateSettings_PreservesImmutableAndTogglesCollection(t *t
|
||||
EnablePromptCollection: false,
|
||||
RedactPii: false,
|
||||
AccessLogRetentionDays: before.AccessLogRetentionDays,
|
||||
}, nil)
|
||||
})
|
||||
assert.Error(t, err, "a mismatched identity echo must be rejected")
|
||||
assert.ErrorContains(t, err, "immutable", "the rejection must name the immutability rule")
|
||||
})
|
||||
|
||||
@@ -98,7 +98,7 @@ func NewAPIHandler(ctx context.Context, router *mux.Router, accountManager accou
|
||||
isValidChildAccount,
|
||||
)
|
||||
|
||||
corsMiddleware := newCORSMiddleware()
|
||||
corsMiddleware := cors.AllowAll()
|
||||
|
||||
metricsMiddleware := appMetrics.HTTPMiddleware()
|
||||
|
||||
@@ -145,32 +145,3 @@ func NewAPIHandler(ctx context.Context, router *mux.Router, accountManager accou
|
||||
|
||||
return router, nil
|
||||
}
|
||||
|
||||
// newCORSMiddleware builds the API's CORS policy: cors.AllowAll() plus ETag in
|
||||
// ExposedHeaders.
|
||||
//
|
||||
// The addition is what makes conditional requests usable from a browser. A
|
||||
// response header that is not CORS-safelisted is invisible to JavaScript
|
||||
// unless it is named in Access-Control-Expose-Headers, and ETag is not on that
|
||||
// list — so without this the server can hand a browser client a validator it
|
||||
// has no way to read, leaving conditional requests to non-browser clients
|
||||
// only. If-Match needs nothing further, since AllowedHeaders is already "*".
|
||||
//
|
||||
// Everything else mirrors cors.AllowAll() exactly. It is spelled out rather
|
||||
// than called because the library offers no way to extend it.
|
||||
func newCORSMiddleware() *cors.Cors {
|
||||
return cors.New(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{
|
||||
http.MethodHead,
|
||||
http.MethodGet,
|
||||
http.MethodPost,
|
||||
http.MethodPut,
|
||||
http.MethodPatch,
|
||||
http.MethodDelete,
|
||||
},
|
||||
AllowedHeaders: []string{"*"},
|
||||
ExposedHeaders: []string{"ETag"},
|
||||
AllowCredentials: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestCORSExposesETag pins the reason this policy is spelled out instead of
|
||||
// being cors.AllowAll(). ETag is not a CORS-safelisted response header, so
|
||||
// without it named in Access-Control-Expose-Headers a browser client is handed
|
||||
// a validator it cannot read — conditional requests would work for the CLI,
|
||||
// the REST client and Terraform, and silently not for the dashboard.
|
||||
//
|
||||
// Collapsing this back to cors.AllowAll() is exactly the simplification that
|
||||
// would reintroduce that, which is what this test is here to catch.
|
||||
func TestCORSExposesETag(t *testing.T) {
|
||||
handler := newCORSMiddleware().Handler(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("ETag", `"9f86d081884c7d65"`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/agent-network/settings", nil)
|
||||
req.Header.Set("Origin", "https://app.netbird.io")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
// Compared canonicalized: the library normalizes the name it echoes, so
|
||||
// this reads "Etag" rather than "ETag". Browsers match the exposed-header
|
||||
// list case-insensitively, so the spelling does not matter — but asserting
|
||||
// it byte-exactly would fail for a reason that has nothing to do with the
|
||||
// behaviour being pinned.
|
||||
assert.Equal(t, http.CanonicalHeaderKey("ETag"),
|
||||
http.CanonicalHeaderKey(rec.Header().Get("Access-Control-Expose-Headers")),
|
||||
"browser clients must be allowed to read the validator they are sent")
|
||||
}
|
||||
|
||||
// TestCORSAllowsIfMatchPreflight covers the request half. It needs nothing
|
||||
// beyond the wildcard AllowedHeaders that was already there, so this is a
|
||||
// regression guard rather than a new grant: narrowing AllowedHeaders to a list
|
||||
// later must not drop If-Match and leave writes readable but not conditional.
|
||||
func TestCORSAllowsIfMatchPreflight(t *testing.T) {
|
||||
handler := newCORSMiddleware().Handler(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodOptions, "/api/agent-network/settings", nil)
|
||||
req.Header.Set("Origin", "https://app.netbird.io")
|
||||
req.Header.Set("Access-Control-Request-Method", http.MethodPut)
|
||||
req.Header.Set("Access-Control-Request-Headers", "If-Match")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
assert.Contains(t, rec.Header().Get("Access-Control-Allow-Headers"), "If-Match",
|
||||
"a conditional write must survive preflight")
|
||||
assert.Contains(t, rec.Header().Get("Access-Control-Allow-Methods"), http.MethodPut,
|
||||
"the conditional write's method must survive preflight")
|
||||
}
|
||||
|
||||
// TestCORSMatchesAllowAllOtherwise pins the rest of the policy, which is a
|
||||
// verbatim copy of cors.AllowAll(). Spelling the options out is what let ETag
|
||||
// be added; it also means a change to the library's defaults no longer reaches
|
||||
// this API, so the settings that matter are asserted here rather than assumed.
|
||||
func TestCORSMatchesAllowAllOtherwise(t *testing.T) {
|
||||
handler := newCORSMiddleware().Handler(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodOptions, "/api/peers", nil)
|
||||
req.Header.Set("Origin", "https://anywhere.example.com")
|
||||
req.Header.Set("Access-Control-Request-Method", http.MethodDelete)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, "*", rec.Header().Get("Access-Control-Allow-Origin"), "any origin must still be allowed")
|
||||
assert.Empty(t, rec.Header().Get("Access-Control-Allow-Credentials"),
|
||||
"credentials must stay disallowed — allowing them alongside a wildcard origin would be a real weakening")
|
||||
assert.Contains(t, rec.Header().Get("Access-Control-Allow-Methods"), http.MethodDelete,
|
||||
"the full method set must still be allowed")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -599,6 +599,34 @@ func (s *SqlStore) ApproveAccountPeers(ctx context.Context, accountID string) (i
|
||||
return int(result.RowsAffected), nil
|
||||
}
|
||||
|
||||
// RefreshPeerLastSeen updates only peer_status_last_seen. Every other status
|
||||
// column is left untouched: peer_status_connected and
|
||||
// peer_status_session_started_at belong to the sync stream that owns the
|
||||
// session, and a blind write here would corrupt the fencing
|
||||
// MarkPeerConnectedIfNewerSession relies on.
|
||||
//
|
||||
// LastSeen comes from the database clock for the same reason it does there: a
|
||||
// Go-side timestamp is taken before the write and can land after a connect that
|
||||
// used CURRENT_TIMESTAMP, dragging the column backwards.
|
||||
//
|
||||
// staleBefore carries the caller's throttle into the same statement, so
|
||||
// concurrent requests for one peer collapse into a single write instead of
|
||||
// each racing on its own stale read. The column is nullable — Status is an
|
||||
// embedded pointer, so a peer stored without one leaves it NULL — and NULL
|
||||
// loses every comparison, hence the explicit branch for a peer never seen.
|
||||
func (s *SqlStore) RefreshPeerLastSeen(ctx context.Context, accountID, peerID string, staleBefore time.Time) (bool, error) {
|
||||
result := s.db.WithContext(ctx).
|
||||
Model(&nbpeer.Peer{}).
|
||||
Where(accountAndIDQueryCondition, accountID, peerID).
|
||||
Where("(peer_status_last_seen IS NULL OR peer_status_last_seen < ?)", staleBefore).
|
||||
Update("peer_status_last_seen", gorm.Expr("CURRENT_TIMESTAMP"))
|
||||
if result.Error != nil {
|
||||
return false, status.Errorf(status.Internal, "refresh peer last seen: %v", result.Error)
|
||||
}
|
||||
|
||||
return result.RowsAffected > 0, nil
|
||||
}
|
||||
|
||||
// SaveUsers saves the given list of users to the database.
|
||||
func (s *SqlStore) SaveUsers(ctx context.Context, users []*types.User) error {
|
||||
if len(users) == 0 {
|
||||
|
||||
122
management/server/store/sql_store_activity_test.go
Normal file
122
management/server/store/sql_store_activity_test.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
)
|
||||
|
||||
const activityAccountID = "activityAccountId"
|
||||
|
||||
func newActivityTestStore(t *testing.T) Store {
|
||||
t.Helper()
|
||||
|
||||
store, cleanUp, err := NewTestStoreFromSQL(context.Background(), "", t.TempDir())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(cleanUp)
|
||||
|
||||
require.NoError(t, store.SaveAccount(context.Background(), &types.Account{
|
||||
Id: activityAccountID,
|
||||
Domain: "activity.example.com",
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}))
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
func TestRefreshPeerLastSeen(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newActivityTestStore(t)
|
||||
stored := time.Now().UTC().Add(-3 * time.Hour)
|
||||
require.NoError(t, store.AddPeerToAccount(ctx, activityPeer(stored)))
|
||||
|
||||
refreshed, err := store.RefreshPeerLastSeen(ctx, activityAccountID, "activityPeer", time.Now().UTC().Add(-time.Hour))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, refreshed, "a peer seen three hours ago is stale enough to refresh")
|
||||
|
||||
peer, err := store.GetPeerByID(ctx, LockingStrengthNone, activityAccountID, "activityPeer")
|
||||
require.NoError(t, err)
|
||||
assert.WithinDuration(t, time.Now().UTC(), peer.Status.LastSeen.UTC(), time.Minute, "last seen should be stamped at write time")
|
||||
assert.True(t, peer.Status.LastSeen.After(stored), "last seen must move forward")
|
||||
}
|
||||
|
||||
// TestRefreshPeerLastSeenHonoursCutoff covers the throttle the caller relies on:
|
||||
// two concurrent requests both read the same stale peer, but only the statement
|
||||
// that still finds LastSeen behind the cutoff writes.
|
||||
func TestRefreshPeerLastSeenHonoursCutoff(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newActivityTestStore(t)
|
||||
stored := time.Now().UTC().Add(-10 * time.Minute)
|
||||
require.NoError(t, store.AddPeerToAccount(ctx, activityPeer(stored)))
|
||||
|
||||
refreshed, err := store.RefreshPeerLastSeen(ctx, activityAccountID, "activityPeer", time.Now().UTC().Add(-time.Hour))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, refreshed, "a peer seen inside the interval must not be written")
|
||||
|
||||
peer, err := store.GetPeerByID(ctx, LockingStrengthNone, activityAccountID, "activityPeer")
|
||||
require.NoError(t, err)
|
||||
assert.WithinDuration(t, stored, peer.Status.LastSeen.UTC(), time.Second, "last seen must be left where it was")
|
||||
}
|
||||
|
||||
// TestRefreshPeerLastSeenRecordsNeverSeenPeer covers the nullable column. Status
|
||||
// is an embedded pointer, so a peer stored without one leaves last seen NULL,
|
||||
// and NULL loses the cutoff comparison — such a peer would never record its
|
||||
// first activity.
|
||||
func TestRefreshPeerLastSeenRecordsNeverSeenPeer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newActivityTestStore(t)
|
||||
stored := activityPeer(time.Time{})
|
||||
stored.Status = nil
|
||||
require.NoError(t, store.AddPeerToAccount(ctx, stored))
|
||||
|
||||
refreshed, err := store.RefreshPeerLastSeen(ctx, activityAccountID, "activityPeer", time.Now().UTC().Add(-time.Hour))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, refreshed, "a peer that was never seen must record its first activity")
|
||||
|
||||
peer, err := store.GetPeerByID(ctx, LockingStrengthNone, activityAccountID, "activityPeer")
|
||||
require.NoError(t, err)
|
||||
assert.WithinDuration(t, time.Now().UTC(), peer.Status.LastSeen.UTC(), time.Minute, "last seen should be stamped at write time")
|
||||
}
|
||||
|
||||
// TestRefreshPeerLastSeenLeavesSessionStateAlone pins the column boundary: the
|
||||
// connected flag and the session token belong to the sync stream that owns the
|
||||
// peer's session, and a blind write here would corrupt its fencing. This is why
|
||||
// SavePeerStatus is not reused for an activity bump.
|
||||
func TestRefreshPeerLastSeenLeavesSessionStateAlone(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newActivityTestStore(t)
|
||||
|
||||
stored := activityPeer(time.Date(2026, 3, 1, 9, 0, 0, 0, time.UTC))
|
||||
stored.Status.Connected = true
|
||||
stored.Status.SessionStartedAt = 1234567890
|
||||
require.NoError(t, store.AddPeerToAccount(ctx, stored))
|
||||
|
||||
refreshed, err := store.RefreshPeerLastSeen(ctx, activityAccountID, "activityPeer", time.Now().UTC().Add(-time.Hour))
|
||||
require.NoError(t, err)
|
||||
require.True(t, refreshed, "the peer is stale enough to refresh")
|
||||
|
||||
peer, err := store.GetPeerByID(ctx, LockingStrengthNone, activityAccountID, "activityPeer")
|
||||
require.NoError(t, err)
|
||||
assert.WithinDuration(t, time.Now().UTC(), peer.Status.LastSeen.UTC(), time.Minute, "last seen should move forward")
|
||||
assert.True(t, peer.Status.Connected, "connected flag must survive an activity write")
|
||||
assert.Equal(t, int64(1234567890), peer.Status.SessionStartedAt, "session token must survive an activity write")
|
||||
}
|
||||
|
||||
func activityPeer(lastSeen time.Time) *nbpeer.Peer {
|
||||
return &nbpeer.Peer{
|
||||
ID: "activityPeer",
|
||||
AccountID: activityAccountID,
|
||||
Key: "activityPeerKey",
|
||||
IP: netip.MustParseAddr("100.64.0.9"),
|
||||
Name: "activity-peer",
|
||||
DNSLabel: "activity-peer",
|
||||
Status: &nbpeer.PeerStatus{LastSeen: lastSeen},
|
||||
}
|
||||
}
|
||||
@@ -180,6 +180,14 @@ type Store interface {
|
||||
// Returns true when the update happened, false when this stream lost
|
||||
// the race against a newer session.
|
||||
MarkPeerConnectedIfNewerSession(ctx context.Context, accountID, peerID string, newSessionStartedAt int64) (bool, error)
|
||||
// RefreshPeerLastSeen records that a peer was just seen, stamping the
|
||||
// database clock like the other status writers. Connected and
|
||||
// SessionStartedAt are left alone, so this never interferes with the
|
||||
// session-ownership protocol MarkPeerConnectedIfNewerSession implements.
|
||||
// The write only lands when the stored LastSeen is older than
|
||||
// staleBefore, which keeps a caller's throttle atomic under concurrent
|
||||
// requests for the same peer. Returns true when the update happened.
|
||||
RefreshPeerLastSeen(ctx context.Context, accountID, peerID string, staleBefore time.Time) (bool, error)
|
||||
// MarkPeerDisconnectedIfSameSession sets the peer to disconnected and
|
||||
// resets SessionStartedAt to zero, but only when the stored
|
||||
// SessionStartedAt equals the given sessionStartedAt. LastSeen is
|
||||
|
||||
@@ -3261,6 +3261,21 @@ func (mr *MockStoreMockRecorder) MarkProxyAccessTokenUsed(ctx, tokenID interface
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkProxyAccessTokenUsed", reflect.TypeOf((*MockStore)(nil).MarkProxyAccessTokenUsed), ctx, tokenID)
|
||||
}
|
||||
|
||||
// RefreshPeerLastSeen mocks base method.
|
||||
func (m *MockStore) RefreshPeerLastSeen(ctx context.Context, accountID, peerID string, staleBefore time.Time) (bool, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "RefreshPeerLastSeen", ctx, accountID, peerID, staleBefore)
|
||||
ret0, _ := ret[0].(bool)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// RefreshPeerLastSeen indicates an expected call of RefreshPeerLastSeen.
|
||||
func (mr *MockStoreMockRecorder) RefreshPeerLastSeen(ctx, accountID, peerID, staleBefore interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RefreshPeerLastSeen", reflect.TypeOf((*MockStore)(nil).RefreshPeerLastSeen), ctx, accountID, peerID, staleBefore)
|
||||
}
|
||||
|
||||
// RemovePeerFromAllGroups mocks base method.
|
||||
func (m *MockStore) RemovePeerFromAllGroups(ctx context.Context, peerID string) error {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
@@ -338,35 +336,25 @@ func (a *AgentNetworkAPI) DeleteBudgetRule(ctx context.Context, ruleID string) e
|
||||
// to an APIError matchable via IsNotFound rather than fabricating defaults
|
||||
// the server never stated.
|
||||
func (a *AgentNetworkAPI) GetSettings(ctx context.Context) (*api.AgentNetworkSettings, error) {
|
||||
settings, _, err := a.GetSettingsWithETag(ctx)
|
||||
return settings, err
|
||||
}
|
||||
|
||||
// GetSettingsWithETag is GetSettings, additionally returning the entity-tag
|
||||
// the server derived for the settings it returned. Hand that validator to
|
||||
// UpdateSettingsIfMatch or DeleteSettingsIfMatch to make the write conditional
|
||||
// on nothing having changed in between — the read-modify-write cycle that
|
||||
// otherwise silently reverts a concurrent change.
|
||||
func (a *AgentNetworkAPI) GetSettingsWithETag(ctx context.Context) (*api.AgentNetworkSettings, string, error) {
|
||||
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/settings", nil, nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
return nil, err
|
||||
}
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
return nil, err
|
||||
}
|
||||
if trimmed := bytes.TrimSpace(body); len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
|
||||
return nil, "", &APIError{StatusCode: http.StatusNotFound, Message: "agent network settings not found"}
|
||||
return nil, &APIError{StatusCode: http.StatusNotFound, Message: "agent network settings not found"}
|
||||
}
|
||||
var ret api.AgentNetworkSettings
|
||||
if err := json.Unmarshal(body, &ret); err != nil {
|
||||
return nil, "", err
|
||||
return nil, err
|
||||
}
|
||||
return &ret, etagFrom(resp), nil
|
||||
return &ret, nil
|
||||
}
|
||||
|
||||
// CreateSettings bootstraps the account's Agent Network settings row,
|
||||
@@ -375,30 +363,19 @@ func (a *AgentNetworkAPI) GetSettingsWithETag(ctx context.Context) (*api.AgentNe
|
||||
// request.Endpoint (self-addressed dedicated endpoint, claimed verbatim) must
|
||||
// be set. Returns a conflict when the account already has a settings row.
|
||||
func (a *AgentNetworkAPI) CreateSettings(ctx context.Context, request api.PostApiAgentNetworkSettingsJSONRequestBody) (*api.AgentNetworkSettings, error) {
|
||||
settings, _, err := a.CreateSettingsWithETag(ctx, request)
|
||||
return settings, err
|
||||
}
|
||||
|
||||
// CreateSettingsWithETag is CreateSettings, additionally returning the
|
||||
// entity-tag of the row it bootstrapped, so a client can follow the bootstrap
|
||||
// with a conditional write without an intervening read.
|
||||
func (a *AgentNetworkAPI) CreateSettingsWithETag(ctx context.Context, request api.PostApiAgentNetworkSettingsJSONRequestBody) (*api.AgentNetworkSettings, string, error) {
|
||||
requestBytes, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
return nil, err
|
||||
}
|
||||
resp, err := a.c.NewRequest(ctx, "POST", "/api/agent-network/settings", bytes.NewReader(requestBytes), nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
return nil, err
|
||||
}
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
ret, err := parseResponse[api.AgentNetworkSettings](resp)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return &ret, etagFrom(resp), nil
|
||||
return &ret, err
|
||||
}
|
||||
|
||||
// UpdateSettings updates the account's Agent Network settings; the request
|
||||
@@ -408,35 +385,19 @@ func (a *AgentNetworkAPI) CreateSettingsWithETag(ctx context.Context, request ap
|
||||
// a request carrying different values is rejected. Returns not-found until
|
||||
// the account is bootstrapped.
|
||||
func (a *AgentNetworkAPI) UpdateSettings(ctx context.Context, request api.PutApiAgentNetworkSettingsJSONRequestBody) (*api.AgentNetworkSettings, error) {
|
||||
settings, _, err := a.UpdateSettingsIfMatch(ctx, request, "")
|
||||
return settings, err
|
||||
}
|
||||
|
||||
// UpdateSettingsIfMatch is UpdateSettings made conditional on etag — the
|
||||
// validator from an earlier read — still being current, and returns the
|
||||
// validator of the row it wrote. This is what closes the read-modify-write
|
||||
// window: a settings change made between the read and this write makes the
|
||||
// request fail with a precondition-failed APIError instead of reverting it.
|
||||
//
|
||||
// An empty etag sends no precondition and updates unconditionally, which is
|
||||
// what UpdateSettings does.
|
||||
func (a *AgentNetworkAPI) UpdateSettingsIfMatch(ctx context.Context, request api.PutApiAgentNetworkSettingsJSONRequestBody, etag string) (*api.AgentNetworkSettings, string, error) {
|
||||
requestBytes, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
return nil, err
|
||||
}
|
||||
resp, err := a.c.newRequest(ctx, "PUT", "/api/agent-network/settings", bytes.NewReader(requestBytes), nil, ifMatchHeader(etag))
|
||||
resp, err := a.c.NewRequest(ctx, "PUT", "/api/agent-network/settings", bytes.NewReader(requestBytes), nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
return nil, err
|
||||
}
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
ret, err := parseResponse[api.AgentNetworkSettings](resp)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return &ret, etagFrom(resp), nil
|
||||
return &ret, err
|
||||
}
|
||||
|
||||
// DeleteSettings deletes the account's Agent Network settings row, releasing
|
||||
@@ -444,20 +405,7 @@ func (a *AgentNetworkAPI) UpdateSettingsIfMatch(ctx context.Context, request api
|
||||
// exists for the account or while a proxy is actively serving the endpoint.
|
||||
// Bootstrapping again afterwards allocates a new endpoint.
|
||||
func (a *AgentNetworkAPI) DeleteSettings(ctx context.Context) error {
|
||||
return a.DeleteSettingsIfMatch(ctx, "")
|
||||
}
|
||||
|
||||
// DeleteSettingsIfMatch is DeleteSettings made conditional on etag — the
|
||||
// validator from an earlier read — still being current. Sending it matters
|
||||
// more here than on update: the server's other two refusals are about state
|
||||
// (no providers, no serving proxy), so this is the only thing that stops a
|
||||
// client working from an old read of one row from releasing the endpoint of
|
||||
// the row that replaced it.
|
||||
//
|
||||
// An empty etag sends no precondition and deletes unconditionally, which is
|
||||
// what DeleteSettings does.
|
||||
func (a *AgentNetworkAPI) DeleteSettingsIfMatch(ctx context.Context, etag string) error {
|
||||
resp, err := a.c.newRequest(ctx, "DELETE", "/api/agent-network/settings", nil, nil, ifMatchHeader(etag))
|
||||
resp, err := a.c.NewRequest(ctx, "DELETE", "/api/agent-network/settings", nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -467,20 +415,3 @@ func (a *AgentNetworkAPI) DeleteSettingsIfMatch(ctx context.Context, etag string
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// etagFrom returns the bare validator from a response, with the transport's
|
||||
// quoting stripped so a caller can hand it straight back to an If-Match
|
||||
// parameter without knowing the wire syntax.
|
||||
func etagFrom(resp *http.Response) string {
|
||||
return strings.Trim(resp.Header.Get("ETag"), `"`)
|
||||
}
|
||||
|
||||
// ifMatchHeader renders the precondition headers for a bare validator,
|
||||
// re-applying the quoting etagFrom stripped. An empty validator yields no
|
||||
// headers at all — an unconditional request.
|
||||
func ifMatchHeader(etag string) map[string]string {
|
||||
if etag == "" {
|
||||
return nil
|
||||
}
|
||||
return map[string]string{"If-Match": strconv.Quote(etag)}
|
||||
}
|
||||
|
||||
@@ -559,123 +559,3 @@ func TestAgentNetwork_DeleteSettings_Guarded(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "cannot be deleted")
|
||||
})
|
||||
}
|
||||
|
||||
// TestAgentNetwork_GetSettings_ETag pins that the validator surfaces to the
|
||||
// caller with the transport's quoting stripped, so it can be handed straight
|
||||
// back to a conditional write without the caller knowing the wire syntax.
|
||||
func TestAgentNetwork_GetSettings_ETag(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("ETag", `"9f86d081884c7d65"`)
|
||||
retBytes, _ := json.Marshal(testAgentNetworkSettings)
|
||||
_, err := w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
ret, etag, err := c.AgentNetwork.GetSettingsWithETag(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testAgentNetworkSettings, *ret)
|
||||
assert.Equal(t, "9f86d081884c7d65", etag, "the validator must arrive unquoted")
|
||||
})
|
||||
}
|
||||
|
||||
// TestAgentNetwork_UpdateSettings_IfMatch covers the round trip that makes the
|
||||
// whole feature usable: a validator taken from a read goes back out quoted on
|
||||
// the write, and the write's own validator comes back for the next one.
|
||||
func TestAgentNetwork_UpdateSettings_IfMatch(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, `"9f86d081884c7d65"`, r.Header.Get("If-Match"),
|
||||
"the precondition must go out quoted as a strong entity-tag")
|
||||
w.Header().Set("ETag", `"0011223344556677"`)
|
||||
retBytes, _ := json.Marshal(testAgentNetworkSettings)
|
||||
_, err := w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
_, etag, err := c.AgentNetwork.UpdateSettingsIfMatch(context.Background(), api.PutApiAgentNetworkSettingsJSONRequestBody{
|
||||
Endpoint: "brave-otter.eu.proxy.netbird.io",
|
||||
ProxyAddress: "eu.proxy.netbird.io",
|
||||
EnableLogCollection: true,
|
||||
}, "9f86d081884c7d65")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "0011223344556677", etag, "the write must return the new validator")
|
||||
})
|
||||
}
|
||||
|
||||
// TestAgentNetwork_UpdateSettings_NoPrecondition pins the back-compatible
|
||||
// path: the plain method sends no If-Match at all, rather than an empty or
|
||||
// wildcard one, so it stays the unconditional update it has always been.
|
||||
func TestAgentNetwork_UpdateSettings_NoPrecondition(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Empty(t, r.Header.Values("If-Match"), "an unconditional update must send no precondition")
|
||||
retBytes, _ := json.Marshal(testAgentNetworkSettings)
|
||||
_, err := w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
_, err := c.AgentNetwork.UpdateSettings(context.Background(), api.PutApiAgentNetworkSettingsJSONRequestBody{
|
||||
Endpoint: "brave-otter.eu.proxy.netbird.io",
|
||||
ProxyAddress: "eu.proxy.netbird.io",
|
||||
EnableLogCollection: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestAgentNetwork_UpdateSettings_StalePrecondition pins how a refused write
|
||||
// reaches the caller: as an APIError a client can recognise as staleness and
|
||||
// answer by reading again, rather than as an opaque failure.
|
||||
func TestAgentNetwork_UpdateSettings_StalePrecondition(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
|
||||
retBytes, _ := json.Marshal(util.ErrorResponse{Message: "if-match precondition failed: the settings have changed since they were read; get them again and retry", Code: 412})
|
||||
w.WriteHeader(412)
|
||||
_, err := w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
_, _, err := c.AgentNetwork.UpdateSettingsIfMatch(context.Background(), api.PutApiAgentNetworkSettingsJSONRequestBody{
|
||||
Endpoint: "brave-otter.eu.proxy.netbird.io",
|
||||
ProxyAddress: "eu.proxy.netbird.io",
|
||||
EnableLogCollection: true,
|
||||
}, "9f86d081884c7d65")
|
||||
require.Error(t, err)
|
||||
assert.True(t, rest.IsPreconditionFailed(err), "a refused precondition must be recognisable as one")
|
||||
assert.False(t, rest.IsNotFound(err), "it must not be confused with an unbootstrapped account")
|
||||
})
|
||||
}
|
||||
|
||||
// TestAgentNetwork_CreateSettings_ETag pins that the bootstrap hands back a
|
||||
// validator, which is what lets a client follow it with a conditional write
|
||||
// without an intervening read.
|
||||
func TestAgentNetwork_CreateSettings_ETag(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("ETag", `"9f86d081884c7d65"`)
|
||||
retBytes, _ := json.Marshal(testAgentNetworkSettings)
|
||||
_, err := w.Write(retBytes)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
_, etag, err := c.AgentNetwork.CreateSettingsWithETag(context.Background(), api.PostApiAgentNetworkSettingsJSONRequestBody{
|
||||
ProxyAddress: ptr("eu.proxy.netbird.io"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "9f86d081884c7d65", etag, "the bootstrap must return a validator")
|
||||
})
|
||||
}
|
||||
|
||||
// TestAgentNetwork_DeleteSettings_IfMatch covers the conditional delete on the
|
||||
// wire, and that the plain method still sends nothing.
|
||||
func TestAgentNetwork_DeleteSettings_IfMatch(t *testing.T) {
|
||||
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
|
||||
var seen []string
|
||||
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "DELETE", r.Method)
|
||||
seen = append(seen, r.Header.Get("If-Match"))
|
||||
_, err := w.Write([]byte("{}"))
|
||||
require.NoError(t, err)
|
||||
})
|
||||
require.NoError(t, c.AgentNetwork.DeleteSettingsIfMatch(context.Background(), "9f86d081884c7d65"))
|
||||
require.NoError(t, c.AgentNetwork.DeleteSettings(context.Background()))
|
||||
assert.Equal(t, []string{`"9f86d081884c7d65"`, ""}, seen,
|
||||
"the conditional delete must carry the quoted validator and the plain one must carry nothing")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -31,19 +31,6 @@ func IsNotFound(err error) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// IsPreconditionFailed returns true if the error represents a 412 Precondition
|
||||
// Failed response — an If-Match the server refused, or an endpoint's own
|
||||
// precondition. A caller that sent a conditional request can use this to tell
|
||||
// "someone else changed it, read again and retry" apart from a real failure;
|
||||
// the message distinguishes it from an endpoint's other 412s.
|
||||
func IsPreconditionFailed(err error) bool {
|
||||
var apiErr *APIError
|
||||
if ok := errors.As(err, &apiErr); ok {
|
||||
return apiErr.StatusCode == http.StatusPreconditionFailed
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Client Management service HTTP REST API Client
|
||||
type Client struct {
|
||||
managementURL string
|
||||
@@ -231,12 +218,6 @@ func (c *Client) initialize() {
|
||||
|
||||
// NewRequest creates and executes new management API request
|
||||
func (c *Client) NewRequest(ctx context.Context, method, path string, body io.Reader, query map[string]string) (*http.Response, error) {
|
||||
return c.newRequest(ctx, method, path, body, query, nil)
|
||||
}
|
||||
|
||||
// newRequest is NewRequest with request headers, for the endpoints whose
|
||||
// contract includes one — conditional requests carrying If-Match.
|
||||
func (c *Client) newRequest(ctx context.Context, method, path string, body io.Reader, query, headers map[string]string) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.managementURL+path, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -250,9 +231,6 @@ func (c *Client) newRequest(ctx context.Context, method, path string, body io.Re
|
||||
if c.userAgent != "" {
|
||||
req.Header.Set("User-Agent", c.userAgent)
|
||||
}
|
||||
for name, value := range headers {
|
||||
req.Header.Set(name, value)
|
||||
}
|
||||
|
||||
if len(query) != 0 {
|
||||
q := req.URL.Query()
|
||||
|
||||
@@ -6438,15 +6438,6 @@ components:
|
||||
schema:
|
||||
type: string
|
||||
example: cot7r4n3l3vh3qj4qveg
|
||||
ETag:
|
||||
description: |
|
||||
Strong entity-tag identifying the returned representation. Send it back
|
||||
in `If-Match` on a subsequent write to make that write conditional, so
|
||||
a change made between the read and the write is refused with `412`
|
||||
rather than silently overwritten.
|
||||
schema:
|
||||
type: string
|
||||
example: '"9f86d081884c7d65"'
|
||||
securitySchemes:
|
||||
BearerAuth:
|
||||
type: http
|
||||
@@ -13742,9 +13733,6 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: Agent Network settings for the account
|
||||
headers:
|
||||
ETag:
|
||||
$ref: '#/components/headers/ETag'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -13772,9 +13760,6 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: The freshly bootstrapped Agent Network settings
|
||||
headers:
|
||||
ETag:
|
||||
$ref: '#/components/headers/ETag'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -13793,25 +13778,11 @@ paths:
|
||||
"$ref": "#/components/responses/internal_error"
|
||||
put:
|
||||
summary: Update Agent Network settings
|
||||
description: Updates the account-level Agent Network settings; the request carries every field, replacing the mutable ones (collection toggles and retention). Returns 404 when the account has no settings row yet — bootstrap it with POST first. The endpoint and proxy address are assigned at bootstrap and immutable; the request must carry them unchanged, and a request carrying different values is rejected. Supply `If-Match` to make the update conditional; without it the update is unconditional and the last write wins.
|
||||
description: Updates the account-level Agent Network settings; the request carries every field, replacing the mutable ones (collection toggles and retention). Returns 404 when the account has no settings row yet — bootstrap it with POST first. The endpoint and proxy address are assigned at bootstrap and immutable; the request must carry them unchanged, and a request carrying different values is rejected.
|
||||
tags: [ Agent Network ]
|
||||
security:
|
||||
- BearerAuth: [ ]
|
||||
- TokenAuth: [ ]
|
||||
parameters:
|
||||
- name: If-Match
|
||||
in: header
|
||||
required: false
|
||||
description: |
|
||||
Makes the update conditional on the settings not having changed since
|
||||
they were read. Send the `ETag` from an earlier `GET`, `POST` or `PUT`,
|
||||
or `*` to require only that a settings row exists. The precondition is
|
||||
evaluated against the stored row inside the update's own transaction,
|
||||
so two clients starting from the same `ETag` cannot both succeed.
|
||||
Omitting the header leaves the update unconditional.
|
||||
schema:
|
||||
type: string
|
||||
example: '"9f86d081884c7d65"'
|
||||
requestBody:
|
||||
description: Settings update request
|
||||
content:
|
||||
@@ -13821,9 +13792,6 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: Updated Agent Network settings
|
||||
headers:
|
||||
ETag:
|
||||
$ref: '#/components/headers/ETag'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -13836,34 +13804,17 @@ paths:
|
||||
"$ref": "#/components/responses/forbidden"
|
||||
'404':
|
||||
"$ref": "#/components/responses/not_found"
|
||||
'412':
|
||||
description: The `If-Match` precondition failed — the settings changed since they were read. The stored settings are unmodified; read them again and retry.
|
||||
content: { }
|
||||
'422':
|
||||
"$ref": "#/components/responses/validation_failed"
|
||||
'500':
|
||||
"$ref": "#/components/responses/internal_error"
|
||||
delete:
|
||||
summary: Delete Agent Network settings
|
||||
description: Deletes the account's Agent Network settings row, releasing the endpoint. Guarded — the delete is refused with 412 while any Agent Network provider exists for the account or while a proxy is actively serving the endpoint. Bootstrapping again after a delete allocates a new endpoint; the released hostname is not reserved. Supply `If-Match` to make the delete conditional, which is worth doing here even more than on update — the other two guards are about state rather than staleness, so nothing else stops a client from deleting a row that was replaced since it read one.
|
||||
description: Deletes the account's Agent Network settings row, releasing the endpoint. Guarded — the delete is refused with 412 while any Agent Network provider exists for the account or while a proxy is actively serving the endpoint. Bootstrapping again after a delete allocates a new endpoint; the released hostname is not reserved.
|
||||
tags: [ Agent Network ]
|
||||
security:
|
||||
- BearerAuth: [ ]
|
||||
- TokenAuth: [ ]
|
||||
parameters:
|
||||
- name: If-Match
|
||||
in: header
|
||||
required: false
|
||||
description: |
|
||||
Makes the delete conditional on the settings not having changed since
|
||||
they were read. Send the `ETag` from an earlier `GET`, `POST` or `PUT`,
|
||||
or `*` to require only that a settings row exists. The precondition is
|
||||
evaluated inside the delete's own transaction, ahead of the provider
|
||||
and serving-proxy guards. Omitting the header leaves the delete
|
||||
unconditional.
|
||||
schema:
|
||||
type: string
|
||||
example: '"9f86d081884c7d65"'
|
||||
responses:
|
||||
'200':
|
||||
description: Settings deleted
|
||||
@@ -13874,7 +13825,7 @@ paths:
|
||||
'404':
|
||||
"$ref": "#/components/responses/not_found"
|
||||
'412':
|
||||
description: Delete refused — the `If-Match` precondition failed, or Agent Network providers still exist for the account, or a proxy is actively serving the endpoint. The stored settings are unmodified in every case; the response message distinguishes them.
|
||||
description: Delete refused — Agent Network providers still exist for the account, or a proxy is actively serving the endpoint
|
||||
content: { }
|
||||
'500':
|
||||
"$ref": "#/components/responses/internal_error"
|
||||
|
||||
@@ -5939,28 +5939,6 @@ type GetApiAgentNetworkAccessLogsParamsSortBy string
|
||||
// GetApiAgentNetworkAccessLogsParamsSortOrder defines parameters for GetApiAgentNetworkAccessLogs.
|
||||
type GetApiAgentNetworkAccessLogsParamsSortOrder string
|
||||
|
||||
// DeleteApiAgentNetworkSettingsParams defines parameters for DeleteApiAgentNetworkSettings.
|
||||
type DeleteApiAgentNetworkSettingsParams struct {
|
||||
// IfMatch Makes the delete conditional on the settings not having changed since
|
||||
// they were read. Send the `ETag` from an earlier `GET`, `POST` or `PUT`,
|
||||
// or `*` to require only that a settings row exists. The precondition is
|
||||
// evaluated inside the delete's own transaction, ahead of the provider
|
||||
// and serving-proxy guards. Omitting the header leaves the delete
|
||||
// unconditional.
|
||||
IfMatch *string `json:"If-Match,omitempty"`
|
||||
}
|
||||
|
||||
// PutApiAgentNetworkSettingsParams defines parameters for PutApiAgentNetworkSettings.
|
||||
type PutApiAgentNetworkSettingsParams struct {
|
||||
// IfMatch Makes the update conditional on the settings not having changed since
|
||||
// they were read. Send the `ETag` from an earlier `GET`, `POST` or `PUT`,
|
||||
// or `*` to require only that a settings row exists. The precondition is
|
||||
// evaluated against the stored row inside the update's own transaction,
|
||||
// so two clients starting from the same `ETag` cannot both succeed.
|
||||
// Omitting the header leaves the update unconditional.
|
||||
IfMatch *string `json:"If-Match,omitempty"`
|
||||
}
|
||||
|
||||
// GetApiAgentNetworkUsageOverviewParams defines parameters for GetApiAgentNetworkUsageOverview.
|
||||
type GetApiAgentNetworkUsageOverviewParams struct {
|
||||
// Granularity Time bucket width. Defaults to day.
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
etagHeader = "ETag"
|
||||
ifMatchHeader = "If-Match"
|
||||
|
||||
// matchAny is the If-Match value that matches any current representation
|
||||
// of the resource (RFC 9110 §13.1.1).
|
||||
matchAny = "*"
|
||||
|
||||
// weakPrefix marks a weak validator. If-Match is defined in terms of the
|
||||
// strong comparison function, under which a weak validator never matches.
|
||||
weakPrefix = "W/"
|
||||
)
|
||||
|
||||
// SetETag writes etag as a strong ETag response header, quoted per RFC 9110.
|
||||
// The value passed in is the bare validator — callers derive it (typically
|
||||
// from the type being served) and this applies the wire syntax, so the quoting
|
||||
// is decided in one place rather than at every handler.
|
||||
//
|
||||
// Call it before writing the body: once the response is committed the header
|
||||
// no longer reaches the client. An empty etag writes no header at all, so a
|
||||
// caller with nothing to validate against does not have to special-case it.
|
||||
func SetETag(w http.ResponseWriter, etag string) {
|
||||
if etag == "" {
|
||||
return
|
||||
}
|
||||
w.Header().Set(etagHeader, strconv.Quote(etag))
|
||||
}
|
||||
|
||||
// Precondition is a parsed If-Match request precondition. The zero value
|
||||
// matches nothing; a nil *Precondition is an unconditional request and matches
|
||||
// everything, so a handler can pass the result of IfMatch straight through
|
||||
// without a presence check.
|
||||
type Precondition struct {
|
||||
// tags are the strong entity-tags the client will accept, unquoted.
|
||||
tags []string
|
||||
|
||||
// any records the "*" form, which matches any current representation.
|
||||
any bool
|
||||
}
|
||||
|
||||
// IfMatch parses the request's If-Match precondition. It returns nil when the
|
||||
// header is absent — an unconditional request, which is the back-compatible
|
||||
// default: clients that know nothing of conditional requests keep working.
|
||||
//
|
||||
// A header that is present but carries nothing usable — empty, or nothing but
|
||||
// weak validators — yields a precondition that matches nothing rather than
|
||||
// nil. Failing closed is the only safe direction: a client that meant to send
|
||||
// a precondition must not have it silently dropped and its write let through
|
||||
// unguarded.
|
||||
func IfMatch(r *http.Request) *Precondition {
|
||||
values := r.Header.Values(ifMatchHeader)
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
p := &Precondition{}
|
||||
for _, value := range values {
|
||||
for raw := range strings.SplitSeq(value, ",") {
|
||||
candidate := strings.TrimSpace(raw)
|
||||
switch {
|
||||
case candidate == "":
|
||||
// Tolerated rather than rejected: a stray comma changes
|
||||
// nothing about what the client is willing to accept.
|
||||
case candidate == matchAny:
|
||||
p.any = true
|
||||
case strings.HasPrefix(candidate, weakPrefix):
|
||||
// Dropped, not unwrapped. If-Match uses strong comparison, so
|
||||
// a weak validator cannot satisfy it — and unwrapping one into
|
||||
// a strong tag would quietly grant the match the client's own
|
||||
// header said it could not have.
|
||||
default:
|
||||
p.tags = append(p.tags, strings.Trim(candidate, `"`))
|
||||
}
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// Matches reports whether etag — the bare validator of the resource as it
|
||||
// currently stands — satisfies the precondition. A nil precondition matches
|
||||
// everything.
|
||||
//
|
||||
// Callers must establish that the resource exists before consulting this: the
|
||||
// "*" form asks whether there is any current representation, a question only
|
||||
// the caller can answer, and this reports true for it.
|
||||
func (p *Precondition) Matches(etag string) bool {
|
||||
if p == nil {
|
||||
return true
|
||||
}
|
||||
if p.any {
|
||||
return true
|
||||
}
|
||||
return slices.Contains(p.tags, etag)
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestSetETag covers the wire syntax: the bare validator goes in, a quoted
|
||||
// strong entity-tag comes out. Handlers pass what the type derived, so the
|
||||
// quoting has to happen here or every handler re-decides it.
|
||||
func TestSetETag(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
SetETag(rec, "9f86d081884c7d65")
|
||||
|
||||
assert.Equal(t, `"9f86d081884c7d65"`, rec.Header().Get("ETag"),
|
||||
"the validator must be emitted quoted")
|
||||
}
|
||||
|
||||
// TestSetETagEmpty pins the no-op: a caller with nothing to validate against
|
||||
// must not emit an empty entity-tag, which would be a validator that every
|
||||
// later request could match.
|
||||
func TestSetETagEmpty(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
SetETag(rec, "")
|
||||
|
||||
assert.Empty(t, rec.Header().Values("ETag"), "an empty validator must write no header")
|
||||
}
|
||||
|
||||
// TestSetETagRoundTrip closes the loop between the two halves of the helper:
|
||||
// what SetETag emits is what IfMatch accepts back. A client echoing the header
|
||||
// it was given must match, or conditional requests never succeed in practice.
|
||||
func TestSetETagRoundTrip(t *testing.T) {
|
||||
const etag = "9f86d081884c7d65"
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
SetETag(rec, etag)
|
||||
|
||||
r := httptest.NewRequest(http.MethodPut, "/", nil)
|
||||
r.Header.Set("If-Match", rec.Header().Get("ETag"))
|
||||
|
||||
assert.True(t, IfMatch(r).Matches(etag), "an echoed ETag header must satisfy the precondition")
|
||||
}
|
||||
|
||||
// TestIfMatchAbsent pins the back-compatibility guarantee: a request with no
|
||||
// If-Match is unconditional, and the nil precondition it yields matches
|
||||
// anything so handlers need no presence check.
|
||||
func TestIfMatchAbsent(t *testing.T) {
|
||||
p := IfMatch(httptest.NewRequest(http.MethodPut, "/", nil))
|
||||
|
||||
require.Nil(t, p, "an absent header must yield no precondition")
|
||||
assert.True(t, p.Matches("9f86d081884c7d65"), "a nil precondition must match anything")
|
||||
assert.True(t, p.Matches(""), "a nil precondition must not depend on the validator")
|
||||
}
|
||||
|
||||
// TestIfMatchParsing walks the header forms a client can send. The weak and
|
||||
// unusable cases are the ones that matter: each must yield a precondition that
|
||||
// exists and refuses, never one that is absent and waves the write through.
|
||||
func TestIfMatchParsing(t *testing.T) {
|
||||
const current = "9f86d081884c7d65"
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
header string
|
||||
match bool
|
||||
reason string
|
||||
}{
|
||||
{
|
||||
name: "quoted current validator",
|
||||
header: `"9f86d081884c7d65"`,
|
||||
match: true,
|
||||
reason: "the ordinary conditional request must be honoured",
|
||||
},
|
||||
{
|
||||
name: "unquoted current validator",
|
||||
header: "9f86d081884c7d65",
|
||||
match: true,
|
||||
reason: "a client that omits the quoting means the same thing, and only an exact value can match",
|
||||
},
|
||||
{
|
||||
name: "stale validator",
|
||||
header: `"0000000000000000"`,
|
||||
match: false,
|
||||
reason: "a validator from an earlier read must not match",
|
||||
},
|
||||
{
|
||||
name: "star",
|
||||
header: "*",
|
||||
match: true,
|
||||
reason: "* matches any current representation",
|
||||
},
|
||||
{
|
||||
name: "list containing the current validator",
|
||||
header: `"0000000000000000", "9f86d081884c7d65"`,
|
||||
match: true,
|
||||
reason: "If-Match is a list; any member matching is a match",
|
||||
},
|
||||
{
|
||||
name: "list of stale validators",
|
||||
header: `"0000000000000000", "1111111111111111"`,
|
||||
match: false,
|
||||
reason: "a list none of whose members match must not match",
|
||||
},
|
||||
{
|
||||
name: "surrounding whitespace",
|
||||
header: ` "9f86d081884c7d65" `,
|
||||
match: true,
|
||||
reason: "list whitespace is not part of the entity-tag",
|
||||
},
|
||||
{
|
||||
name: "stray comma",
|
||||
header: `"9f86d081884c7d65", `,
|
||||
match: true,
|
||||
reason: "an empty list element says nothing about what the client accepts",
|
||||
},
|
||||
{
|
||||
name: "weak validator of the current representation",
|
||||
header: `W/"9f86d081884c7d65"`,
|
||||
match: false,
|
||||
reason: "If-Match uses strong comparison, so a weak validator never satisfies it",
|
||||
},
|
||||
{
|
||||
name: "weak validator alongside a strong one",
|
||||
header: `W/"0000000000000000", "9f86d081884c7d65"`,
|
||||
match: true,
|
||||
reason: "dropping the weak member must not discard the rest of the list",
|
||||
},
|
||||
{
|
||||
name: "empty header",
|
||||
header: "",
|
||||
match: false,
|
||||
reason: "a precondition the server cannot make sense of must fail closed, not vanish",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodPut, "/", nil)
|
||||
r.Header.Set("If-Match", tc.header)
|
||||
|
||||
p := IfMatch(r)
|
||||
require.NotNil(t, p, "a header that was sent must yield a precondition: %s", tc.reason)
|
||||
assert.Equal(t, tc.match, p.Matches(current), tc.reason)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIfMatchRepeatedHeader covers the same list split across header lines,
|
||||
// which is semantically identical to the comma form and which a proxy is free
|
||||
// to produce.
|
||||
func TestIfMatchRepeatedHeader(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodPut, "/", nil)
|
||||
r.Header.Add("If-Match", `"0000000000000000"`)
|
||||
r.Header.Add("If-Match", `"9f86d081884c7d65"`)
|
||||
|
||||
assert.True(t, IfMatch(r).Matches("9f86d081884c7d65"),
|
||||
"entity-tags split across header lines must be read as one list")
|
||||
}
|
||||
Reference in New Issue
Block a user