Compare commits

..

5 Commits

Author SHA1 Message Date
pascal
0dda165230 update alias 2026-07-29 12:31:21 +02:00
pascal
09f0700bb6 fix import 2026-07-29 12:23:25 +02:00
pascal
eb43bc8b35 Merge branch 'main' into fix/remove-math-rand 2026-07-29 12:06:01 +02:00
pascal
0785f379a2 input validation for peer ip allocation 2026-07-29 12:05:30 +02:00
pascal
4e9effcf4c remove old math rand lib 2026-07-20 17:50:31 +02:00
23 changed files with 1970 additions and 768 deletions

View File

@@ -249,35 +249,78 @@ jobs:
docker compose exec management ls -l /var/lib/netbird/ | grep -i GeoLite2-City_[0-9]*.mmdb
docker compose exec management ls -l /var/lib/netbird/ | grep -i geonames_[0-9]*.db
test-legacy-getting-started-scripts:
test-getting-started-script:
runs-on: ubuntu-latest
steps:
- name: Install jq
run: sudo apt-get install -y jq
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Verify Dex retirement notice
run: |
if infrastructure_files/getting-started-with-dex.sh >stdout.txt 2>stderr.txt; then
echo "Expected the retired Dex installer to fail"
exit 1
fi
test ! -s stdout.txt
grep -Fq "Dex support is not deprecated." stderr.txt
grep -Fq "https://docs.netbird.io/selfhosted/selfhosted-quickstart" stderr.txt
grep -Fq "https://docs.netbird.io/selfhosted/identity-providers/local" stderr.txt
grep -Fq "removed in NetBird v0.80" stderr.txt
- name: run script with Zitadel PostgreSQL
run: NETBIRD_DOMAIN=use-ip bash -x infrastructure_files/getting-started-with-zitadel.sh
- name: Verify Zitadel retirement notice
- name: test Caddy file gen postgres
run: test -f Caddyfile
- name: test docker-compose file gen postgres
run: test -f docker-compose.yml
- name: test management.json file gen postgres
run: test -f management.json
- name: test turnserver.conf file gen postgres
run: |
if bash infrastructure_files/getting-started-with-zitadel.sh >stdout.txt 2>stderr.txt; then
echo "Expected the retired Zitadel installer to fail"
exit 1
fi
test ! -s stdout.txt
grep -Fq "Zitadel support and existing Zitadel deployments are not deprecated." stderr.txt
grep -Fq "https://docs.netbird.io/selfhosted/selfhosted-quickstart" stderr.txt
grep -Fq "https://docs.netbird.io/selfhosted/identity-providers/zitadel" stderr.txt
grep -Fq "https://docs.netbird.io/selfhosted/selfhosted-guide" stderr.txt
grep -Fq "removed in NetBird v0.80" stderr.txt
set -x
test -f turnserver.conf
grep external-ip turnserver.conf
- name: test zitadel.env file gen postgres
run: test -f zitadel.env
- name: test dashboard.env file gen postgres
run: test -f dashboard.env
- name: test relay.env file gen postgres
run: test -f relay.env
- name: test zdb.env file gen postgres
run: test -f zdb.env
- name: Postgres run cleanup
run: |
docker compose down --volumes --rmi all
rm -rf docker-compose.yml Caddyfile zitadel.env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json zdb.env
- name: run script with Zitadel CockroachDB
run: bash -x infrastructure_files/getting-started-with-zitadel.sh
env:
NETBIRD_DOMAIN: use-ip
ZITADEL_DATABASE: cockroach
- name: test Caddy file gen CockroachDB
run: test -f Caddyfile
- name: test docker-compose file gen CockroachDB
run: test -f docker-compose.yml
- name: test management.json file gen CockroachDB
run: test -f management.json
- name: test turnserver.conf file gen CockroachDB
run: |
set -x
test -f turnserver.conf
grep external-ip turnserver.conf
- name: test zitadel.env file gen CockroachDB
run: test -f zitadel.env
- name: test dashboard.env file gen CockroachDB
run: test -f dashboard.env
- name: test relay.env file gen CockroachDB
run: test -f relay.env

View File

@@ -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 := parseUsername(localUser.Username)
username, domain := s.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 := parseUsername(localUser.Username)
username, domain := s.parseUsername(localUser.Username)
shell := getUserShell(localUser.Uid)
req := PtyExecutionRequest{

View File

@@ -1,15 +0,0 @@
//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
}
// isWindowsAccountPrivileged is only reachable on Windows. Fail closed if it
// is ever called on another platform.
func isWindowsAccountPrivileged(string) bool {
return true
}

View File

@@ -1,223 +0,0 @@
//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()
}
// isWindowsAccountPrivileged 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. Evaluation errors count as privileged so policy checks fail closed.
func isWindowsAccountPrivileged(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
}

View File

@@ -1,293 +0,0 @@
//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 TestIsWindowsAccountPrivileged(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 := isWindowsAccountPrivileged(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)
}

View File

@@ -420,13 +420,6 @@ 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
@@ -447,11 +440,39 @@ func TestServer_IsPrivilegedUser(t *testing.T) {
expected: false,
description: "empty username should not be privileged",
},
{
username: "Administrator",
expected: false,
description: "Administrator should not be privileged on non-Windows systems",
},
}
// 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",
},
}...)
}
for _, tt := range tests {

View File

@@ -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 := parseUsername(targetUser.Username)
username, domain := s.parseUsername(targetUser.Username)
netbirdPath, err := os.Executable()
if err != nil {

View File

@@ -16,6 +16,11 @@ 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
@@ -24,9 +29,6 @@ var (
getIsProcessPrivileged = isCurrentProcessPrivileged
getEuid = os.Geteuid
getProcessElevated = isProcessElevated
getWindowsAccountPrivileged = isWindowsAccountPrivileged
)
const (
@@ -63,13 +65,6 @@ 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 {
@@ -180,42 +175,6 @@ 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 {
@@ -224,6 +183,13 @@ 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
@@ -287,24 +253,159 @@ 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: 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".
// 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 {
if getCurrentOS() != "windows" {
return username == "root"
}
return getWindowsAccountPrivileged(username)
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
}
// 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 the process token is elevated (administrators, SYSTEM).
// On Windows, this means running as Administrator or SYSTEM.
func isCurrentProcessPrivileged() bool {
if getCurrentOS() == "windows" {
return getProcessElevated()
return isWindowsElevated()
}
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
}

View File

@@ -4,7 +4,6 @@ import (
"errors"
"os/user"
"runtime"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -28,8 +27,8 @@ func setupTestDependencies(currentUser *user.User, currentUserErr error, os stri
originalLookupUser := lookupUser
originalGetCurrentOS := getCurrentOS
originalGetEuid := getEuid
originalGetProcessElevated := getProcessElevated
originalGetWindowsAccountPrivileged := getWindowsAccountPrivileged
// Reset caches to ensure clean test state
// Set test values - inject platform dependencies
getCurrentUser = func() (*user.User, error) {
@@ -54,31 +53,16 @@ func setupTestDependencies(currentUser *user.User, currentUserErr error, os stri
return euid
}
// Simulate the Windows token elevation check based on the fixture user:
// the built-in Administrator (RID 500) and SYSTEM run elevated.
getProcessElevated = func() bool {
// Mock privilege detection based on the test user
getIsProcessPrivileged = func() bool {
if currentUser == nil {
return false
}
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.
getWindowsAccountPrivileged = 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":
// Check both username and SID for Windows systems
if os == "windows" && isWindowsPrivilegedSID(currentUser.Uid) {
return true
}
return false
return isPrivilegedUsername(currentUser.Username)
}
// Return cleanup function
@@ -87,8 +71,10 @@ func setupTestDependencies(currentUser *user.User, currentUserErr error, os stri
lookupUser = originalLookupUser
getCurrentOS = originalGetCurrentOS
getEuid = originalGetEuid
getProcessElevated = originalGetProcessElevated
getWindowsAccountPrivileged = originalGetWindowsAccountPrivileged
getIsProcessPrivileged = isCurrentProcessPrivileged
// Reset caches after test
}
}
@@ -435,9 +421,6 @@ 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
@@ -449,9 +432,25 @@ func TestPrivilegedUsernameDetection(t *testing.T) {
{"unix_regular_user", "alice", "linux", false},
{"unix_root_capital", "Root", "linux", false}, // Case-sensitive
// Windows dispatch to the (mocked) account classifier
// Windows tests
{"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 {
@@ -461,7 +460,49 @@ func TestPrivilegedUsernameDetection(t *testing.T) {
defer cleanup()
result := isPrivilegedUsername(tt.username)
assert.Equal(t, tt.privileged, result, "privilege classification for %s on %s", tt.username, tt.platform)
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)
})
}
}

View File

@@ -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, _ := parseUsername(localUser.Username)
username, _ := s.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 := parseUsername(localUser.Username)
username, domain := s.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 parseUsername(fullUsername string) (username, domain string) {
func (s *Server) parseUsername(fullUsername string) (username, domain string) {
// Handle DOMAIN\username format
if idx := strings.LastIndex(fullUsername, `\`); idx != -1 {
domain = fullUsername[:idx]

View File

@@ -1,14 +0,0 @@
package services
import "github.com/wailsapp/wails/v3/pkg/w32"
// Wails assigns w32.AllowDarkModeForWindow only on builds >= 18334 but calls it
// without a nil check when a window requests the Dark theme, crashing older
// builds such as Windows Server 2019 (17763). Those builds still get a dark
// title bar via the pre-20H1 DWM attribute that w32.SetTheme applies, so a
// no-op stub keeps the Dark theme fully working there.
func init() {
if w32.AllowDarkModeForWindow == nil {
w32.AllowDarkModeForWindow = func(w32.HWND, bool) uintptr { return 0 }
}
}

View File

@@ -1,19 +1,557 @@
#!/usr/bin/env bash
#!/bin/bash
cat >&2 <<'EOF'
ERROR: This legacy installation script has been retired and no longer runs.
set -e
Dex support is not deprecated. For new deployments, use getting-started.sh:
# NetBird Getting Started with Dex IDP
# This script sets up NetBird with Dex as the identity provider
https://docs.netbird.io/selfhosted/selfhosted-quickstart
# Sed pattern to strip base64 padding characters
SED_STRIP_PADDING='s/=//g'
The current installer includes NetBird's embedded Dex-based identity provider.
Local users and external identity providers can be managed through the
NetBird Dashboard:
check_docker_compose() {
if command -v docker-compose &> /dev/null
then
echo "docker-compose"
return
fi
if docker compose --help &> /dev/null
then
echo "docker compose"
return
fi
https://docs.netbird.io/selfhosted/identity-providers/local
echo "docker-compose is not installed or not in PATH. Please follow the steps from the official guide: https://docs.docker.com/engine/install/" > /dev/stderr
exit 1
}
This compatibility notice will be removed in NetBird v0.80.
check_jq() {
if ! command -v jq &> /dev/null
then
echo "jq is not installed or not in PATH, please install with your package manager. e.g. sudo apt install jq" > /dev/stderr
exit 1
fi
return 0
}
get_main_ip_address() {
if [[ "$OSTYPE" == "darwin"* ]]; then
interface=$(route -n get default | grep 'interface:' | awk '{print $2}')
ip_address=$(ifconfig "$interface" | grep 'inet ' | awk '{print $2}')
else
interface=$(ip route | grep default | awk '{print $5}' | head -n 1)
ip_address=$(ip addr show "$interface" | grep 'inet ' | awk '{print $2}' | cut -d'/' -f1)
fi
echo "$ip_address"
return 0
}
check_nb_domain() {
DOMAIN=$1
if [[ "$DOMAIN-x" == "-x" ]]; then
echo "The NETBIRD_DOMAIN variable cannot be empty." > /dev/stderr
return 1
fi
if [[ "$DOMAIN" == "netbird.example.com" ]]; then
echo "The NETBIRD_DOMAIN cannot be netbird.example.com" > /dev/stderr
return 1
fi
return 0
}
read_nb_domain() {
READ_NETBIRD_DOMAIN=""
echo -n "Enter the domain you want to use for NetBird (e.g. netbird.my-domain.com): " > /dev/stderr
read -r READ_NETBIRD_DOMAIN < /dev/tty
if ! check_nb_domain "$READ_NETBIRD_DOMAIN"; then
read_nb_domain
fi
echo "$READ_NETBIRD_DOMAIN"
return 0
}
get_turn_external_ip() {
TURN_EXTERNAL_IP_CONFIG="#external-ip="
IP=$(curl -s -4 https://jsonip.com | jq -r '.ip')
if [[ "x-$IP" != "x-" ]]; then
TURN_EXTERNAL_IP_CONFIG="external-ip=$IP"
fi
echo "$TURN_EXTERNAL_IP_CONFIG"
return 0
}
wait_dex() {
set +e
echo -n "Waiting for Dex to become ready (via $NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN)"
counter=1
while true; do
# Check Dex through Caddy proxy (also validates TLS is working)
if curl -sk -f -o /dev/null "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/dex/.well-known/openid-configuration" 2>/dev/null; then
break
fi
if [[ $counter -eq 60 ]]; then
echo ""
echo "Taking too long. Checking logs..."
$DOCKER_COMPOSE_COMMAND logs --tail=20 caddy
$DOCKER_COMPOSE_COMMAND logs --tail=20 dex
fi
echo -n " ."
sleep 2
counter=$((counter + 1))
done
echo " done"
set -e
return 0
}
init_environment() {
CADDY_SECURE_DOMAIN=""
NETBIRD_PORT=80
NETBIRD_HTTP_PROTOCOL="http"
NETBIRD_RELAY_PROTO="rel"
TURN_USER="self"
TURN_PASSWORD=$(openssl rand -base64 32 | sed "$SED_STRIP_PADDING")
NETBIRD_RELAY_AUTH_SECRET=$(openssl rand -base64 32 | sed "$SED_STRIP_PADDING")
TURN_MIN_PORT=49152
TURN_MAX_PORT=65535
TURN_EXTERNAL_IP_CONFIG=$(get_turn_external_ip)
# Generate secrets for Dex
DEX_DASHBOARD_CLIENT_SECRET=$(openssl rand -base64 32 | sed "$SED_STRIP_PADDING")
# Generate admin password
NETBIRD_ADMIN_PASSWORD=$(openssl rand -base64 16 | sed "$SED_STRIP_PADDING")
if ! check_nb_domain "$NETBIRD_DOMAIN"; then
NETBIRD_DOMAIN=$(read_nb_domain)
fi
if [[ "$NETBIRD_DOMAIN" == "use-ip" ]]; then
NETBIRD_DOMAIN=$(get_main_ip_address)
else
NETBIRD_PORT=443
CADDY_SECURE_DOMAIN=", $NETBIRD_DOMAIN:$NETBIRD_PORT"
NETBIRD_HTTP_PROTOCOL="https"
NETBIRD_RELAY_PROTO="rels"
fi
check_jq
DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
if [[ -f dex.yaml ]]; then
echo "Generated files already exist, if you want to reinitialize the environment, please remove them first."
echo "You can use the following commands:"
echo " $DOCKER_COMPOSE_COMMAND down --volumes # to remove all containers and volumes"
echo " rm -f docker-compose.yml Caddyfile dex.yaml dashboard.env turnserver.conf management.json relay.env"
echo "Be aware that this will remove all data from the database, and you will have to reconfigure the dashboard."
exit 1
fi
echo Rendering initial files...
render_docker_compose > docker-compose.yml
render_caddyfile > Caddyfile
render_dex_config > dex.yaml
render_dashboard_env > dashboard.env
render_management_json > management.json
render_turn_server_conf > turnserver.conf
render_relay_env > relay.env
echo -e "\nStarting Dex IDP\n"
$DOCKER_COMPOSE_COMMAND up -d caddy dex
# Wait for Dex to be ready (through caddy proxy)
sleep 3
wait_dex
echo -e "\nStarting NetBird services\n"
$DOCKER_COMPOSE_COMMAND up -d
echo -e "\nDone!\n"
echo "You can access the NetBird dashboard at $NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN"
echo ""
echo "Login with the following credentials:"
install -m 600 /dev/null .env
printf 'Email: admin@%s\nPassword: %s\n' \
"$NETBIRD_DOMAIN" "$NETBIRD_ADMIN_PASSWORD" >> .env
echo "Email: admin@$NETBIRD_DOMAIN"
echo "Password: $NETBIRD_ADMIN_PASSWORD"
echo ""
echo "Dex admin UI is not available (Dex has no built-in UI)."
echo "To add more users, edit dex.yaml and restart: $DOCKER_COMPOSE_COMMAND restart dex"
return 0
}
render_caddyfile() {
cat <<EOF
{
debug
servers :80,:443 {
protocols h1 h2c h2 h3
}
}
(security_headers) {
header * {
Strict-Transport-Security "max-age=3600; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
X-XSS-Protection "1; mode=block"
-Server
Referrer-Policy strict-origin-when-cross-origin
}
}
:80${CADDY_SECURE_DOMAIN} {
import security_headers
# Relay
reverse_proxy /relay* relay:80
# Signal
reverse_proxy /signalexchange.SignalExchange/* h2c://signal:10000
# Management
reverse_proxy /api/* management:80
reverse_proxy /management.ManagementService/* h2c://management:80
# Dex
reverse_proxy /dex/* dex:5556
# Dashboard
reverse_proxy /* dashboard:80
}
EOF
return 0
}
exit 1
render_dex_config() {
# Generate bcrypt hash of the admin password
# Using a simple approach - htpasswd or python if available
ADMIN_PASSWORD_HASH=""
if command -v htpasswd &> /dev/null; then
ADMIN_PASSWORD_HASH=$(htpasswd -bnBC 10 "" "$NETBIRD_ADMIN_PASSWORD" | tr -d ':\n')
elif command -v python3 &> /dev/null; then
ADMIN_PASSWORD_HASH=$(python3 -c "import bcrypt; print(bcrypt.hashpw('$NETBIRD_ADMIN_PASSWORD'.encode(), bcrypt.gensalt(rounds=10)).decode())" 2>/dev/null || echo "")
fi
# Fallback to a known hash if we can't generate one
if [[ -z "$ADMIN_PASSWORD_HASH" ]]; then
# This is hash of "password" - user should change it
ADMIN_PASSWORD_HASH='$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W'
NETBIRD_ADMIN_PASSWORD="password"
echo "Warning: Could not generate password hash. Using default password: password. Please change it in dex.yaml" > /dev/stderr
fi
cat <<EOF
# Dex configuration for NetBird
# Generated by getting-started-with-dex.sh
issuer: $NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/dex
storage:
type: sqlite3
config:
file: /var/dex/dex.db
web:
http: 0.0.0.0:5556
# gRPC API for user management (used by NetBird IDP manager)
grpc:
addr: 0.0.0.0:5557
oauth2:
skipApprovalScreen: true
# Static OAuth2 clients for NetBird
staticClients:
# Dashboard client
- id: netbird-dashboard
name: NetBird Dashboard
secret: $DEX_DASHBOARD_CLIENT_SECRET
redirectURIs:
- $NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/nb-auth
- $NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/nb-silent-auth
# CLI client (public - uses PKCE)
- id: netbird-cli
name: NetBird CLI
public: true
redirectURIs:
- http://localhost:53000/
- http://localhost:54000/
# Enable password database for static users
enablePasswordDB: true
# Static users - add more users here as needed
staticPasswords:
- email: "admin@$NETBIRD_DOMAIN"
hash: "$ADMIN_PASSWORD_HASH"
username: "admin"
userID: "$(uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid 2>/dev/null || echo "admin-user-id-001")"
# Optional: Add external identity provider connectors
# connectors:
# - type: github
# id: github
# name: GitHub
# config:
# clientID: \$GITHUB_CLIENT_ID
# clientSecret: \$GITHUB_CLIENT_SECRET
# redirectURI: $NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/dex/callback
#
# - type: ldap
# id: ldap
# name: LDAP
# config:
# host: ldap.example.com:636
# insecureNoSSL: false
# bindDN: cn=admin,dc=example,dc=com
# bindPW: admin
# userSearch:
# baseDN: ou=users,dc=example,dc=com
# filter: "(objectClass=person)"
# username: uid
# idAttr: uid
# emailAttr: mail
# nameAttr: cn
EOF
return 0
}
render_turn_server_conf() {
cat <<EOF
listening-port=3478
$TURN_EXTERNAL_IP_CONFIG
tls-listening-port=5349
min-port=$TURN_MIN_PORT
max-port=$TURN_MAX_PORT
fingerprint
lt-cred-mech
user=$TURN_USER:$TURN_PASSWORD
realm=wiretrustee.com
cert=/etc/coturn/certs/cert.pem
pkey=/etc/coturn/private/privkey.pem
log-file=stdout
no-software-attribute
pidfile="/var/tmp/turnserver.pid"
no-cli
EOF
return 0
}
render_management_json() {
cat <<EOF
{
"Stuns": [
{
"Proto": "udp",
"URI": "stun:$NETBIRD_DOMAIN:3478"
}
],
"Relay": {
"Addresses": ["$NETBIRD_RELAY_PROTO://$NETBIRD_DOMAIN:$NETBIRD_PORT"],
"CredentialsTTL": "24h",
"Secret": "$NETBIRD_RELAY_AUTH_SECRET"
},
"Signal": {
"Proto": "$NETBIRD_HTTP_PROTOCOL",
"URI": "$NETBIRD_DOMAIN:$NETBIRD_PORT"
},
"HttpConfig": {
"AuthIssuer": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/dex",
"AuthAudience": "netbird-dashboard",
"OIDCConfigEndpoint": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/dex/.well-known/openid-configuration"
},
"IdpManagerConfig": {
"ManagerType": "dex",
"ClientConfig": {
"Issuer": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/dex"
},
"ExtraConfig": {
"GRPCAddr": "dex:5557"
}
},
"DeviceAuthorizationFlow": {
"Provider": "hosted",
"ProviderConfig": {
"Audience": "netbird-cli",
"ClientID": "netbird-cli",
"Scope": "openid profile email offline_access",
"DeviceAuthEndpoint": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/dex/device/code",
"TokenEndpoint": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/dex/token"
}
},
"PKCEAuthorizationFlow": {
"ProviderConfig": {
"Audience": "netbird-cli",
"ClientID": "netbird-cli",
"Scope": "openid profile email offline_access",
"RedirectURLs": ["http://localhost:53000/", "http://localhost:54000/"]
}
}
}
EOF
return 0
}
render_dashboard_env() {
cat <<EOF
# Endpoints
NETBIRD_MGMT_API_ENDPOINT=$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN
NETBIRD_MGMT_GRPC_API_ENDPOINT=$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN
# OIDC
AUTH_AUDIENCE=netbird-dashboard
AUTH_CLIENT_ID=netbird-dashboard
AUTH_CLIENT_SECRET=$DEX_DASHBOARD_CLIENT_SECRET
AUTH_AUTHORITY=$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/dex
USE_AUTH0=false
AUTH_SUPPORTED_SCOPES=openid profile email offline_access
AUTH_REDIRECT_URI=/nb-auth
AUTH_SILENT_REDIRECT_URI=/nb-silent-auth
# SSL
NGINX_SSL_PORT=443
# Letsencrypt
LETSENCRYPT_DOMAIN=none
EOF
return 0
}
render_relay_env() {
cat <<EOF
NB_LOG_LEVEL=info
NB_LISTEN_ADDRESS=:80
NB_EXPOSED_ADDRESS=$NETBIRD_RELAY_PROTO://$NETBIRD_DOMAIN:$NETBIRD_PORT
NB_AUTH_SECRET=$NETBIRD_RELAY_AUTH_SECRET
EOF
return 0
}
render_docker_compose() {
cat <<EOF
services:
# Caddy reverse proxy
caddy:
image: caddy
container_name: netbird-caddy
restart: unless-stopped
networks: [netbird]
ports:
- '443:443'
- '443:443/udp'
- '80:80'
volumes:
- netbird_caddy_data:/data
- ./Caddyfile:/etc/caddy/Caddyfile
logging:
driver: "json-file"
options:
max-size: "500m"
max-file: "2"
# Dex - identity provider
dex:
image: ghcr.io/dexidp/dex:v2.38.0
container_name: netbird-dex
restart: unless-stopped
networks: [netbird]
volumes:
- ./dex.yaml:/etc/dex/config.docker.yaml:ro
- netbird_dex_data:/var/dex
command: ["dex", "serve", "/etc/dex/config.docker.yaml"]
logging:
driver: "json-file"
options:
max-size: "500m"
max-file: "2"
# UI dashboard
dashboard:
image: netbirdio/dashboard:latest
container_name: netbird-dashboard
restart: unless-stopped
networks: [netbird]
env_file:
- ./dashboard.env
logging:
driver: "json-file"
options:
max-size: "500m"
max-file: "2"
# Signal
signal:
image: netbirdio/signal:latest
container_name: netbird-signal
restart: unless-stopped
networks: [netbird]
logging:
driver: "json-file"
options:
max-size: "500m"
max-file: "2"
# Relay
relay:
image: netbirdio/relay:latest
container_name: netbird-relay
restart: unless-stopped
networks: [netbird]
env_file:
- ./relay.env
logging:
driver: "json-file"
options:
max-size: "500m"
max-file: "2"
# Management
management:
image: netbirdio/management:latest
container_name: netbird-management
restart: unless-stopped
networks: [netbird]
volumes:
- netbird_management:/var/lib/netbird
- ./management.json:/etc/netbird/management.json
command: [
"--port", "80",
"--log-file", "console",
"--log-level", "info",
"--disable-anonymous-metrics=false",
"--single-account-mode-domain=netbird.selfhosted",
"--dns-domain=netbird.selfhosted",
"--idp-sign-key-refresh-enabled",
]
logging:
driver: "json-file"
options:
max-size: "500m"
max-file: "2"
# Coturn, AKA TURN server
coturn:
image: coturn/coturn
container_name: netbird-coturn
restart: unless-stopped
volumes:
- ./turnserver.conf:/etc/turnserver.conf:ro
network_mode: host
command:
- -c /etc/turnserver.conf
logging:
driver: "json-file"
options:
max-size: "500m"
max-file: "2"
volumes:
netbird_caddy_data:
netbird_dex_data:
netbird_management:
networks:
netbird:
EOF
return 0
}
init_environment

File diff suppressed because it is too large Load Diff

View File

@@ -3,9 +3,10 @@ package labelgen
import (
"fmt"
"math/rand"
"sort"
"sync"
"github.com/netbirdio/netbird/management/server/util"
)
// pickAttempts caps the random retries before falling back to the
@@ -40,16 +41,15 @@ func uniqueWords() []string {
// PickUnique selects a label not already in `taken`. It tries up to
// pickAttempts random picks; on exhaustion it scans the deduplicated
// wordlist for any remaining free entry, and if none is left appends
// `-<fallbackSuffix>` to a deterministic word and returns. The caller
// is responsible for seeding rng (math/rand).
func PickUnique(rng *rand.Rand, taken map[string]struct{}, fallbackSuffix string) string {
// `-<fallbackSuffix>` to a random word and returns.
func PickUnique(taken map[string]struct{}, fallbackSuffix string) string {
pool := uniqueWords()
if len(pool) == 0 {
return fallbackSuffix
}
for i := 0; i < pickAttempts; i++ {
w := pool[rng.Intn(len(pool))]
w := pool[util.RandIntn(len(pool))]
if _, ok := taken[w]; !ok {
return w
}
@@ -61,6 +61,6 @@ func PickUnique(rng *rand.Rand, taken map[string]struct{}, fallbackSuffix string
}
}
w := pool[rng.Intn(len(pool))]
w := pool[util.RandIntn(len(pool))]
return fmt.Sprintf("%s-%s", w, fallbackSuffix)
}

View File

@@ -1,7 +1,7 @@
package labelgen
import (
"math/rand"
"slices"
"strings"
"testing"
@@ -9,19 +9,12 @@ import (
"github.com/stretchr/testify/require"
)
// TestPickUnique_DeterministicWithSeededRng locks the property the
// caller relies on: same seed + same taken set → same pick. Without
// that, the bootstrap flow can't reproduce a label across retries.
func TestPickUnique_DeterministicWithSeededRng(t *testing.T) {
taken := map[string]struct{}{}
// TestPickUnique_ReturnsWordFromPool confirms a pick against an empty
// taken set is always drawn verbatim from the wordlist.
func TestPickUnique_ReturnsWordFromPool(t *testing.T) {
got := PickUnique(map[string]struct{}{}, "abcd")
rngA := rand.New(rand.NewSource(42))
rngB := rand.New(rand.NewSource(42))
a := PickUnique(rngA, taken, "abcd")
b := PickUnique(rngB, taken, "abcd")
assert.Equal(t, a, b, "Same seed and taken set must produce identical pick")
assert.True(t, slices.Contains(uniqueWords(), got), "Pick %q must be drawn from the wordlist", got)
}
// TestPickUnique_AvoidsTakenWordsWhenMostAreReserved seeds taken with
@@ -46,8 +39,7 @@ func TestPickUnique_AvoidsTakenWordsWhenMostAreReserved(t *testing.T) {
taken[w] = struct{}{}
}
rng := rand.New(rand.NewSource(7))
got := PickUnique(rng, taken, "abcd")
got := PickUnique(taken, "abcd")
_, isFree := free[got]
assert.True(t, isFree, "PickUnique must return one of the free words; got %q", got)
@@ -65,8 +57,7 @@ func TestPickUnique_FallsBackWhenAllReserved(t *testing.T) {
taken[w] = struct{}{}
}
rng := rand.New(rand.NewSource(99))
got := PickUnique(rng, taken, "abcd")
got := PickUnique(taken, "abcd")
assert.True(t, strings.HasSuffix(got, "-abcd"), "Exhausted pool must produce <word>-<suffix>; got %q", got)

View File

@@ -4,7 +4,6 @@ import (
"context"
"errors"
"fmt"
"math/rand"
"slices"
"strings"
"sync"
@@ -128,11 +127,6 @@ type managerImpl struct {
// accountID, then by synthesised service ID.
reconcileMu sync.Mutex
reconcileCache map[string]map[string]*proto.ProxyMapping
// labelRngMu guards labelRng. PickUnique consumes math/rand.Source
// state; concurrent provider creates would otherwise race.
labelRngMu sync.Mutex
labelRng *rand.Rand
}
// NewManager constructs the persistent Agent Network manager. The
@@ -152,7 +146,6 @@ func NewManager(
permissionsManager: permissionsManager,
proxyController: proxyController,
reconcileCache: make(map[string]map[string]*proto.ProxyMapping),
labelRng: rand.New(rand.NewSource(time.Now().UnixNano())),
}
}
@@ -658,9 +651,7 @@ func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, accountID,
suffix = suffix[:4]
}
m.labelRngMu.Lock()
subdomain := labelgen.PickUnique(m.labelRng, taken, suffix)
m.labelRngMu.Unlock()
subdomain := labelgen.PickUnique(taken, suffix)
now := time.Now().UTC()
settings := &types.Settings{

View File

@@ -4,7 +4,6 @@ import (
"context"
"errors"
"fmt"
"math/rand"
"net"
"net/netip"
"os"
@@ -63,7 +62,7 @@ const (
type userLoggedInOnce bool
func cacheEntryExpiration() time.Duration {
r := rand.Intn(int(nbcache.DefaultIDPCacheExpirationMax.Milliseconds()-nbcache.DefaultIDPCacheExpirationMin.Milliseconds())) + int(nbcache.DefaultIDPCacheExpirationMin.Milliseconds())
r := util.RandIntn(int(nbcache.DefaultIDPCacheExpirationMax.Milliseconds()-nbcache.DefaultIDPCacheExpirationMin.Milliseconds())) + int(nbcache.DefaultIDPCacheExpirationMin.Milliseconds())
return time.Duration(r) * time.Millisecond
}
@@ -2459,8 +2458,7 @@ func (am *DefaultAccountManager) ensureIPv6Subnet(ctx context.Context, transacti
return transaction.UpdateAccountNetworkV6(ctx, accountID, network.NetV6)
}
if network.NetV6.IP == nil {
r := rand.New(rand.NewSource(time.Now().UnixNano()))
network.NetV6 = types.AllocateIPv6Subnet(r)
network.NetV6 = types.AllocateIPv6Subnet()
// Sync settings to match the allocated subnet so SaveAccountSettings persists it.
ones, _ := network.NetV6.Mask.Size()

View File

@@ -2,7 +2,6 @@ package server
import (
"context"
"math/rand"
"testing"
"time"
@@ -28,7 +27,7 @@ func TestGroupIPv6Assignment(t *testing.T) {
require.NoError(t, err)
// Allocate IPv6 subnet for the account
account.Network.NetV6 = types.AllocateIPv6Subnet(rand.New(rand.NewSource(time.Now().UnixNano())))
account.Network.NetV6 = types.AllocateIPv6Subnet()
require.NoError(t, am.Store.SaveAccount(ctx, account))
// Create setup key

View File

@@ -2,11 +2,12 @@ package idp
import (
"encoding/json"
"math/rand"
"net/url"
"os"
"strings"
"time"
"github.com/netbirdio/netbird/management/server/util"
)
var (
@@ -33,31 +34,32 @@ func GeneratePassword(passwordLength, minSpecialChar, minNum, minUpperCase int)
//Set special character
for i := 0; i < minSpecialChar; i++ {
random := rand.Intn(len(specialCharSet))
random := util.RandIntn(len(specialCharSet))
password.WriteString(string(specialCharSet[random]))
}
//Set numeric
for i := 0; i < minNum; i++ {
random := rand.Intn(len(numberSet))
random := util.RandIntn(len(numberSet))
password.WriteString(string(numberSet[random]))
}
//Set uppercase
for i := 0; i < minUpperCase; i++ {
random := rand.Intn(len(upperCharSet))
random := util.RandIntn(len(upperCharSet))
password.WriteString(string(upperCharSet[random]))
}
remainingLength := passwordLength - minSpecialChar - minNum - minUpperCase
for i := 0; i < remainingLength; i++ {
random := rand.Intn(len(allCharSet))
random := util.RandIntn(len(allCharSet))
password.WriteString(string(allCharSet[random]))
}
inRune := []rune(password.String())
rand.Shuffle(len(inRune), func(i, j int) {
for i := len(inRune) - 1; i > 0; i-- {
j := util.RandIntn(i + 1)
inRune[i], inRune[j] = inRune[j], inRune[i]
})
}
return string(inRune)
}

View File

@@ -2,7 +2,6 @@ package types
import (
"context"
"math/rand"
"net"
"net/netip"
@@ -86,8 +85,8 @@ func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule
return sharedtypes.GenerateRouteFirewallRules(ctx, route, rule, groupPeers, direction, includeIPv6)
}
func AllocateIPv6Subnet(r *rand.Rand) net.IPNet {
return sharedtypes.AllocateIPv6Subnet(r)
func AllocateIPv6Subnet() net.IPNet {
return sharedtypes.AllocateIPv6Subnet()
}
func NewNetwork() *Network {

View File

@@ -1,5 +1,20 @@
package util
import (
"crypto/rand"
"math/big"
)
// RandIntn returns a uniformly distributed int in [0, n) sourced from
// crypto/rand. It panics if n <= 0 or the platform randomness source fails.
func RandIntn(n int) int {
v, err := rand.Int(rand.Reader, big.NewInt(int64(n)))
if err != nil {
panic(err)
}
return int(v.Int64())
}
// Difference returns the elements in `a` that aren't in `b`.
func Difference(a, b []string) []string {
mb := make(map[string]struct{}, len(b))

View File

@@ -1,20 +1,20 @@
package types
import (
"crypto/rand"
"encoding/binary"
"fmt"
"math/rand"
"net"
"net/netip"
"slices"
"sync"
"time"
"github.com/c-robinson/iplib"
"github.com/rs/xid"
"golang.org/x/exp/maps"
nbdns "github.com/netbirdio/netbird/dns"
"github.com/netbirdio/netbird/management/server/util"
"github.com/netbirdio/netbird/route"
"github.com/netbirdio/netbird/shared/management/proto"
"github.com/netbirdio/netbird/shared/management/status"
@@ -171,14 +171,12 @@ func NewNetwork() *Network {
n := iplib.NewNet4(net.ParseIP("100.64.0.0"), NetSize)
sub, _ := n.Subnet(SubnetSize)
s := rand.NewSource(time.Now().UnixNano())
r := rand.New(s)
intn := r.Intn(len(sub))
intn := util.RandIntn(len(sub))
return &Network{
Identifier: xid.New().String(),
Net: sub[intn].IPNet,
NetV6: AllocateIPv6Subnet(r),
NetV6: AllocateIPv6Subnet(),
Dns: "",
Serial: 0,
}
@@ -188,18 +186,13 @@ func NewNetwork() *Network {
// The format follows RFC 4193 section 3.1: fd + 40-bit Global ID + 16-bit Subnet ID.
// The Global ID and Subnet ID are randomized (simplified from the SHA-1 algorithm
// in section 3.2.2), giving 2^56 possible /64 subnets across all accounts.
func AllocateIPv6Subnet(r *rand.Rand) net.IPNet {
func AllocateIPv6Subnet() net.IPNet {
ip := make(net.IP, 16)
ip[0] = 0xfd
// Bytes 1-5: 40-bit random Global ID
ip[1] = byte(r.Intn(256))
ip[2] = byte(r.Intn(256))
ip[3] = byte(r.Intn(256))
ip[4] = byte(r.Intn(256))
ip[5] = byte(r.Intn(256))
// Bytes 6-7: 16-bit random Subnet ID
ip[6] = byte(r.Intn(256))
ip[7] = byte(r.Intn(256))
// Bytes 1-5: 40-bit random Global ID, bytes 6-7: 16-bit random Subnet ID
if _, err := rand.Read(ip[1:8]); err != nil {
panic(err)
}
return net.IPNet{
IP: ip,
@@ -233,10 +226,22 @@ func (n *Network) Copy() *Network {
}
}
// validateIPv4Prefix ensures the prefix is an IPv4 network with assignable host addresses.
func validateIPv4Prefix(prefix netip.Prefix) error {
if !prefix.IsValid() || !prefix.Addr().Is4() || prefix.Bits() < 1 || prefix.Bits() >= 31 {
return fmt.Errorf("invalid IPv4 subnet: %s", prefix.String())
}
return nil
}
// AllocatePeerIP picks an available IP from a netip.Prefix.
// This method considers already taken IPs and reuses IPs if there are gaps in takenIps.
// E.g. if prefix=100.30.0.0/16 and takenIps=[100.30.0.1, 100.30.0.4] then the result would be 100.30.0.2 or 100.30.0.3.
func AllocatePeerIP(prefix netip.Prefix, takenIps []netip.Addr) (netip.Addr, error) {
if err := validateIPv4Prefix(prefix); err != nil {
return netip.Addr{}, err
}
b := prefix.Masked().Addr().As4()
baseIP := binary.BigEndian.Uint32(b[:])
hostBits := 32 - prefix.Bits()
@@ -247,15 +252,17 @@ func AllocatePeerIP(prefix netip.Prefix, takenIps []netip.Addr) (netip.Addr, err
taken[baseIP+totalIPs-1] = struct{}{} // reserve broadcast IP
for _, ip := range takenIps {
if !ip.Is4() {
continue
}
ab := ip.As4()
taken[binary.BigEndian.Uint32(ab[:])] = struct{}{}
}
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
maxAttempts := (int(totalIPs) - len(taken)) / 100
for i := 0; i < maxAttempts; i++ {
offset := uint32(rng.Intn(int(totalIPs-2))) + 1
offset := uint32(util.RandIntn(int(totalIPs-2))) + 1
candidate := baseIP + offset
if _, exists := taken[candidate]; !exists {
return uint32ToIP(candidate), nil
@@ -274,13 +281,16 @@ func AllocatePeerIP(prefix netip.Prefix, takenIps []netip.Addr) (netip.Addr, err
// AllocateRandomPeerIP picks a random available IP from a netip.Prefix.
func AllocateRandomPeerIP(prefix netip.Prefix) (netip.Addr, error) {
if err := validateIPv4Prefix(prefix); err != nil {
return netip.Addr{}, err
}
b := prefix.Masked().Addr().As4()
baseIP := binary.BigEndian.Uint32(b[:])
hostBits := 32 - prefix.Bits()
totalIPs := uint32(1 << hostBits)
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
offset := uint32(rng.Intn(int(totalIPs-2))) + 1
offset := uint32(util.RandIntn(int(totalIPs-2))) + 1
candidate := baseIP + offset
return uint32ToIP(candidate), nil
@@ -296,23 +306,26 @@ func AllocateRandomPeerIPv6(prefix netip.Prefix) (netip.Addr, error) {
ip := prefix.Addr().As16()
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
// Determine which byte the host bits start in
firstHostByte := ones / 8
// If the prefix doesn't end on a byte boundary, handle the partial byte
partialBits := ones % 8
var rnd [16]byte
if _, err := rand.Read(rnd[firstHostByte:]); err != nil {
return netip.Addr{}, err
}
if partialBits > 0 {
// Keep the network bits in the partial byte, randomize the rest
hostMask := byte(0xff >> partialBits)
ip[firstHostByte] = (ip[firstHostByte] & ^hostMask) | (byte(rng.Intn(256)) & hostMask)
ip[firstHostByte] = (ip[firstHostByte] & ^hostMask) | (rnd[firstHostByte] & hostMask)
firstHostByte++
}
// Randomize remaining full host bytes
for i := firstHostByte; i < 16; i++ {
ip[i] = byte(rng.Intn(256))
ip[i] = rnd[i]
}
// Avoid all-zeros and all-ones host parts by checking only host bits.

View File

@@ -143,6 +143,34 @@ func TestAllocatePeerIPVariousCIDRs(t *testing.T) {
}
}
func TestAllocateIPv4InvalidPrefixes(t *testing.T) {
prefixes := []netip.Prefix{
{},
netip.MustParsePrefix("0.0.0.0/0"),
netip.MustParsePrefix("192.168.1.0/31"),
netip.MustParsePrefix("192.168.1.1/32"),
netip.MustParsePrefix("fd12:3456:7890:abcd::/64"),
}
for _, prefix := range prefixes {
t.Run(prefix.String(), func(t *testing.T) {
_, err := AllocatePeerIP(prefix, nil)
assert.Error(t, err)
_, err = AllocateRandomPeerIP(prefix)
assert.Error(t, err)
})
}
}
func TestAllocatePeerIPIgnoresNonIPv4TakenIPs(t *testing.T) {
prefix := netip.MustParsePrefix("192.168.1.0/29")
ip, err := AllocatePeerIP(prefix, []netip.Addr{netip.MustParseAddr("fd12:3456:7890:abcd::1")})
require.NoError(t, err)
assert.True(t, prefix.Contains(ip))
}
func TestGenerateIPs(t *testing.T) {
ipNet := net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.IPMask{255, 255, 255, 0}}
ips, ipsLen := generateIPs(&ipNet, map[string]struct{}{"100.64.0.0": {}})