Add user invite link feature for embedded IdP (#5157)

This commit is contained in:
Misha Bragin
2026-01-27 09:42:20 +01:00
committed by GitHub
parent 44ab454a13
commit 7d791620a6
21 changed files with 4832 additions and 2 deletions

View File

@@ -2,18 +2,54 @@ package instance
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/mail"
"strings"
"sync"
"time"
goversion "github.com/hashicorp/go-version"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/management/server/idp"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/shared/management/status"
"github.com/netbirdio/netbird/version"
)
const (
// Version endpoints
managementVersionURL = "https://pkgs.netbird.io/releases/latest/version"
dashboardReleasesURL = "https://api.github.com/repos/netbirdio/dashboard/releases/latest"
// Cache TTL for version information
versionCacheTTL = 60 * time.Minute
// HTTP client timeout
httpTimeout = 5 * time.Second
)
// VersionInfo contains version information for NetBird components
type VersionInfo struct {
// CurrentVersion is the running management server version
CurrentVersion string
// DashboardVersion is the latest available dashboard version from GitHub
DashboardVersion string
// ManagementVersion is the latest available management version from GitHub
ManagementVersion string
// ManagementUpdateAvailable indicates if a newer management version is available
ManagementUpdateAvailable bool
}
// githubRelease represents a GitHub release response
type githubRelease struct {
TagName string `json:"tag_name"`
}
// Manager handles instance-level operations like initial setup.
type Manager interface {
// IsSetupRequired checks if instance setup is required.
@@ -23,6 +59,9 @@ type Manager interface {
// CreateOwnerUser creates the initial owner user in the embedded IDP.
// This should only be called when IsSetupRequired returns true.
CreateOwnerUser(ctx context.Context, email, password, name string) (*idp.UserData, error)
// GetVersionInfo returns version information for NetBird components.
GetVersionInfo(ctx context.Context) (*VersionInfo, error)
}
// DefaultManager is the default implementation of Manager.
@@ -32,6 +71,12 @@ type DefaultManager struct {
setupRequired bool
setupMu sync.RWMutex
// Version caching
httpClient *http.Client
versionMu sync.RWMutex
cachedVersions *VersionInfo
lastVersionFetch time.Time
}
// NewManager creates a new instance manager.
@@ -43,6 +88,9 @@ func NewManager(ctx context.Context, store store.Store, idpManager idp.Manager)
store: store,
embeddedIdpManager: embeddedIdp,
setupRequired: false,
httpClient: &http.Client{
Timeout: httpTimeout,
},
}
if embeddedIdp != nil {
@@ -134,3 +182,130 @@ func (m *DefaultManager) validateSetupInfo(email, password, name string) error {
}
return nil
}
// GetVersionInfo returns version information for NetBird components.
func (m *DefaultManager) GetVersionInfo(ctx context.Context) (*VersionInfo, error) {
m.versionMu.RLock()
if m.cachedVersions != nil && time.Since(m.lastVersionFetch) < versionCacheTTL {
cached := *m.cachedVersions
m.versionMu.RUnlock()
return &cached, nil
}
m.versionMu.RUnlock()
return m.fetchVersionInfo(ctx)
}
func (m *DefaultManager) fetchVersionInfo(ctx context.Context) (*VersionInfo, error) {
m.versionMu.Lock()
// Double-check after acquiring write lock
if m.cachedVersions != nil && time.Since(m.lastVersionFetch) < versionCacheTTL {
cached := *m.cachedVersions
m.versionMu.Unlock()
return &cached, nil
}
m.versionMu.Unlock()
info := &VersionInfo{
CurrentVersion: version.NetbirdVersion(),
}
// Fetch management version from pkgs.netbird.io (plain text)
mgmtVersion, err := m.fetchPlainTextVersion(ctx, managementVersionURL)
if err != nil {
log.WithContext(ctx).Warnf("failed to fetch management version: %v", err)
} else {
info.ManagementVersion = mgmtVersion
info.ManagementUpdateAvailable = isNewerVersion(info.CurrentVersion, mgmtVersion)
}
// Fetch dashboard version from GitHub
dashVersion, err := m.fetchGitHubRelease(ctx, dashboardReleasesURL)
if err != nil {
log.WithContext(ctx).Warnf("failed to fetch dashboard version from GitHub: %v", err)
} else {
info.DashboardVersion = dashVersion
}
// Update cache
m.versionMu.Lock()
m.cachedVersions = info
m.lastVersionFetch = time.Now()
m.versionMu.Unlock()
return info, nil
}
// isNewerVersion returns true if latestVersion is greater than currentVersion
func isNewerVersion(currentVersion, latestVersion string) bool {
current, err := goversion.NewVersion(currentVersion)
if err != nil {
return false
}
latest, err := goversion.NewVersion(latestVersion)
if err != nil {
return false
}
return latest.GreaterThan(current)
}
func (m *DefaultManager) fetchPlainTextVersion(ctx context.Context, url string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", fmt.Errorf("create request: %w", err)
}
req.Header.Set("User-Agent", "NetBird-Management/"+version.NetbirdVersion())
resp, err := m.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("execute request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 100))
if err != nil {
return "", fmt.Errorf("read response: %w", err)
}
return strings.TrimSpace(string(body)), nil
}
func (m *DefaultManager) fetchGitHubRelease(ctx context.Context, url string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", fmt.Errorf("create request: %w", err)
}
req.Header.Set("Accept", "application/vnd.github.v3+json")
req.Header.Set("User-Agent", "NetBird-Management/"+version.NetbirdVersion())
resp, err := m.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("execute request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
var release githubRelease
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return "", fmt.Errorf("decode response: %w", err)
}
// Remove 'v' prefix if present
tag := release.TagName
if len(tag) > 0 && tag[0] == 'v' {
tag = tag[1:]
}
return tag, nil
}

View File

@@ -0,0 +1,285 @@
package instance
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// mockRoundTripper implements http.RoundTripper for testing
type mockRoundTripper struct {
callCount atomic.Int32
managementVersion string
dashboardVersion string
}
func (m *mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
m.callCount.Add(1)
var body string
if strings.Contains(req.URL.String(), "pkgs.netbird.io") {
// Plain text response for management version
body = m.managementVersion
} else if strings.Contains(req.URL.String(), "github.com") {
// JSON response for dashboard version
jsonResp, _ := json.Marshal(githubRelease{TagName: "v" + m.dashboardVersion})
body = string(jsonResp)
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(body)),
Header: make(http.Header),
}, nil
}
func TestDefaultManager_GetVersionInfo_ReturnsCurrentVersion(t *testing.T) {
mockTransport := &mockRoundTripper{
managementVersion: "0.65.0",
dashboardVersion: "2.10.0",
}
m := &DefaultManager{
httpClient: &http.Client{Transport: mockTransport},
}
ctx := context.Background()
info, err := m.GetVersionInfo(ctx)
require.NoError(t, err)
// CurrentVersion should always be set
assert.NotEmpty(t, info.CurrentVersion)
assert.Equal(t, "0.65.0", info.ManagementVersion)
assert.Equal(t, "2.10.0", info.DashboardVersion)
assert.Equal(t, int32(2), mockTransport.callCount.Load()) // 2 calls: management + dashboard
}
func TestDefaultManager_GetVersionInfo_CachesResults(t *testing.T) {
mockTransport := &mockRoundTripper{
managementVersion: "0.65.0",
dashboardVersion: "2.10.0",
}
m := &DefaultManager{
httpClient: &http.Client{Transport: mockTransport},
}
ctx := context.Background()
// First call
info1, err := m.GetVersionInfo(ctx)
require.NoError(t, err)
assert.NotEmpty(t, info1.CurrentVersion)
assert.Equal(t, "0.65.0", info1.ManagementVersion)
initialCallCount := mockTransport.callCount.Load()
// Second call should use cache (no additional HTTP calls)
info2, err := m.GetVersionInfo(ctx)
require.NoError(t, err)
assert.Equal(t, info1.CurrentVersion, info2.CurrentVersion)
assert.Equal(t, info1.ManagementVersion, info2.ManagementVersion)
assert.Equal(t, info1.DashboardVersion, info2.DashboardVersion)
// Verify no additional HTTP calls were made (cache was used)
assert.Equal(t, initialCallCount, mockTransport.callCount.Load())
}
func TestDefaultManager_FetchGitHubRelease_ParsesTagName(t *testing.T) {
tests := []struct {
name string
tagName string
expected string
shouldError bool
}{
{
name: "tag with v prefix",
tagName: "v1.2.3",
expected: "1.2.3",
},
{
name: "tag without v prefix",
tagName: "1.2.3",
expected: "1.2.3",
},
{
name: "tag with prerelease",
tagName: "v2.0.0-beta.1",
expected: "2.0.0-beta.1",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(githubRelease{TagName: tc.tagName})
}))
defer server.Close()
m := &DefaultManager{
httpClient: &http.Client{Timeout: 5 * time.Second},
}
version, err := m.fetchGitHubRelease(context.Background(), server.URL)
if tc.shouldError {
assert.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, tc.expected, version)
}
})
}
}
func TestDefaultManager_FetchGitHubRelease_HandlesErrors(t *testing.T) {
tests := []struct {
name string
statusCode int
body string
}{
{
name: "not found",
statusCode: http.StatusNotFound,
body: `{"message": "Not Found"}`,
},
{
name: "rate limited",
statusCode: http.StatusForbidden,
body: `{"message": "API rate limit exceeded"}`,
},
{
name: "server error",
statusCode: http.StatusInternalServerError,
body: `{"message": "Internal Server Error"}`,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tc.statusCode)
_, _ = w.Write([]byte(tc.body))
}))
defer server.Close()
m := &DefaultManager{
httpClient: &http.Client{Timeout: 5 * time.Second},
}
_, err := m.fetchGitHubRelease(context.Background(), server.URL)
assert.Error(t, err)
})
}
}
func TestDefaultManager_FetchGitHubRelease_InvalidJSON(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{invalid json}`))
}))
defer server.Close()
m := &DefaultManager{
httpClient: &http.Client{Timeout: 5 * time.Second},
}
_, err := m.fetchGitHubRelease(context.Background(), server.URL)
assert.Error(t, err)
}
func TestDefaultManager_FetchGitHubRelease_ContextCancellation(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(1 * time.Second)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(githubRelease{TagName: "v1.0.0"})
}))
defer server.Close()
m := &DefaultManager{
httpClient: &http.Client{Timeout: 5 * time.Second},
}
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
_, err := m.fetchGitHubRelease(ctx, server.URL)
assert.Error(t, err)
}
func TestIsNewerVersion(t *testing.T) {
tests := []struct {
name string
currentVersion string
latestVersion string
expected bool
}{
{
name: "latest is newer - minor version",
currentVersion: "0.64.1",
latestVersion: "0.65.0",
expected: true,
},
{
name: "latest is newer - patch version",
currentVersion: "0.64.1",
latestVersion: "0.64.2",
expected: true,
},
{
name: "latest is newer - major version",
currentVersion: "0.64.1",
latestVersion: "1.0.0",
expected: true,
},
{
name: "versions are equal",
currentVersion: "0.64.1",
latestVersion: "0.64.1",
expected: false,
},
{
name: "current is newer - minor version",
currentVersion: "0.65.0",
latestVersion: "0.64.1",
expected: false,
},
{
name: "current is newer - patch version",
currentVersion: "0.64.2",
latestVersion: "0.64.1",
expected: false,
},
{
name: "development version",
currentVersion: "development",
latestVersion: "0.65.0",
expected: false,
},
{
name: "invalid latest version",
currentVersion: "0.64.1",
latestVersion: "invalid",
expected: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := isNewerVersion(tc.currentVersion, tc.latestVersion)
assert.Equal(t, tc.expected, result)
})
}
}