From 4ad14cb46b3e3f0b3ebac54e568bff82e8d50c2d Mon Sep 17 00:00:00 2001 From: Bethuel Mmbaga Date: Wed, 11 Oct 2023 17:09:30 +0300 Subject: [PATCH 01/28] Add Pagination for IdP Users Fetch (#1210) * Retrieve all workspace users via pagination, excluding custom user attributes * Retrieve all authentik users via pagination * Retrieve all Azure AD users via pagination * Simplify user data appending operation Reduced unnecessary iteration and used an efficient way to append all users to 'indexedUsers' * Fix ineffectual assignment to reqURL * Retrieve all Okta users via pagination * Add missing GetAccount metrics * Refactor * minimize memory allocation Refactored the memory allocation for the 'users' slice in the Okta IDP code. Previously, the slice was only initialized but not given a size. Now the size of userList is utilized to optimize memory allocation, reducing potential slice resizing and memory re-allocation costs while appending users. * Add logging for entries received from IdP management Added informative and debug logging statements in account.go file. Logging has been added to identify the number of entries received from Identity Provider (IdP) management. This will aid in tracking and debugging any potential data ingestion issues. --- management/server/account.go | 2 + management/server/idp/authentik.go | 78 +++++++++++---------- management/server/idp/azure.go | 82 ++++++++++++++--------- management/server/idp/google_workspace.go | 56 +++++++++++----- management/server/idp/okta.go | 65 +++++++++++------- 5 files changed, 176 insertions(+), 107 deletions(-) diff --git a/management/server/account.go b/management/server/account.go index 1f8fc497be5..d35ad2566e0 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -946,6 +946,7 @@ func (am *DefaultAccountManager) warmupIDPCache() error { if err != nil { return err } + log.Infof("%d entries received from IdP management", len(userData)) // If the Identity Provider does not support writing AppMetadata, // in cases like this, we expect it to return all users in an "unset" field. @@ -1045,6 +1046,7 @@ func (am *DefaultAccountManager) loadAccount(_ context.Context, accountID interf if err != nil { return nil, err } + log.Debugf("%d entries received from IdP management", len(userData)) dataMap := make(map[string]*idp.UserData, len(userData)) for _, datum := range userData { diff --git a/management/server/idp/authentik.go b/management/server/idp/authentik.go index ca995b2996e..4bbf094045a 100644 --- a/management/server/idp/authentik.go +++ b/management/server/idp/authentik.go @@ -251,34 +251,18 @@ func (am *AuthentikManager) GetUserDataByID(userID string, appMetadata AppMetada // GetAccount returns all the users for a given profile. func (am *AuthentikManager) GetAccount(accountID string) ([]*UserData, error) { - ctx, err := am.authenticationContext() - if err != nil { - return nil, err - } - - userList, resp, err := am.apiClient.CoreApi.CoreUsersList(ctx).Execute() + users, err := am.getAllUsers() if err != nil { return nil, err } - defer resp.Body.Close() if am.appMetrics != nil { am.appMetrics.IDPMetrics().CountGetAccount() } - if resp.StatusCode != http.StatusOK { - if am.appMetrics != nil { - am.appMetrics.IDPMetrics().CountRequestStatusError() - } - return nil, fmt.Errorf("unable to get account %s users, statusCode %d", accountID, resp.StatusCode) - } - - users := make([]*UserData, 0) - for _, user := range userList.Results { - userData := parseAuthentikUser(user) - userData.AppMetadata.WTAccountID = accountID - - users = append(users, userData) + for index, user := range users { + user.AppMetadata.WTAccountID = accountID + users[index] = user } return users, nil @@ -287,35 +271,57 @@ func (am *AuthentikManager) GetAccount(accountID string) ([]*UserData, error) { // GetAllAccounts gets all registered accounts with corresponding user data. // It returns a list of users indexed by accountID. func (am *AuthentikManager) GetAllAccounts() (map[string][]*UserData, error) { - ctx, err := am.authenticationContext() + users, err := am.getAllUsers() if err != nil { return nil, err } - userList, resp, err := am.apiClient.CoreApi.CoreUsersList(ctx).Execute() - if err != nil { - return nil, err - } - defer resp.Body.Close() + indexedUsers := make(map[string][]*UserData) + indexedUsers[UnsetAccountID] = append(indexedUsers[UnsetAccountID], users...) if am.appMetrics != nil { am.appMetrics.IDPMetrics().CountGetAllAccounts() } - if resp.StatusCode != http.StatusOK { - if am.appMetrics != nil { - am.appMetrics.IDPMetrics().CountRequestStatusError() + return indexedUsers, nil +} + +// getAllUsers returns all users in a Authentik account. +func (am *AuthentikManager) getAllUsers() ([]*UserData, error) { + users := make([]*UserData, 0) + + page := int32(1) + for { + ctx, err := am.authenticationContext() + if err != nil { + return nil, err + } + + userList, resp, err := am.apiClient.CoreApi.CoreUsersList(ctx).Page(page).Execute() + if err != nil { + return nil, err + } + _ = resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + if am.appMetrics != nil { + am.appMetrics.IDPMetrics().CountRequestStatusError() + } + return nil, fmt.Errorf("unable to get all accounts, statusCode %d", resp.StatusCode) + } + + for _, user := range userList.Results { + users = append(users, parseAuthentikUser(user)) + } + + page = int32(userList.GetPagination().Next) + if userList.GetPagination().Next == 0 { + break } - return nil, fmt.Errorf("unable to get all accounts, statusCode %d", resp.StatusCode) - } - indexedUsers := make(map[string][]*UserData) - for _, user := range userList.Results { - userData := parseAuthentikUser(user) - indexedUsers[UnsetAccountID] = append(indexedUsers[UnsetAccountID], userData) } - return indexedUsers, nil + return users, nil } // CreateUser creates a new user in authentik Idp and sends an invitation. diff --git a/management/server/idp/azure.go b/management/server/idp/azure.go index e4224c26d96..706e4d33014 100644 --- a/management/server/idp/azure.go +++ b/management/server/idp/azure.go @@ -266,10 +266,7 @@ func (am *AzureManager) GetUserByEmail(email string) ([]*UserData, error) { // GetAccount returns all the users for a given profile. func (am *AzureManager) GetAccount(accountID string) ([]*UserData, error) { - q := url.Values{} - q.Add("$select", profileFields) - - body, err := am.get("users", q) + users, err := am.getAllUsers() if err != nil { return nil, err } @@ -278,18 +275,9 @@ func (am *AzureManager) GetAccount(accountID string) ([]*UserData, error) { am.appMetrics.IDPMetrics().CountGetAccount() } - var profiles struct{ Value []azureProfile } - err = am.helper.Unmarshal(body, &profiles) - if err != nil { - return nil, err - } - - users := make([]*UserData, 0) - for _, profile := range profiles.Value { - userData := profile.userData() - userData.AppMetadata.WTAccountID = accountID - - users = append(users, userData) + for index, user := range users { + user.AppMetadata.WTAccountID = accountID + users[index] = user } return users, nil @@ -298,30 +286,18 @@ func (am *AzureManager) GetAccount(accountID string) ([]*UserData, error) { // GetAllAccounts gets all registered accounts with corresponding user data. // It returns a list of users indexed by accountID. func (am *AzureManager) GetAllAccounts() (map[string][]*UserData, error) { - q := url.Values{} - q.Add("$select", profileFields) - - body, err := am.get("users", q) + users, err := am.getAllUsers() if err != nil { return nil, err } + indexedUsers := make(map[string][]*UserData) + indexedUsers[UnsetAccountID] = append(indexedUsers[UnsetAccountID], users...) + if am.appMetrics != nil { am.appMetrics.IDPMetrics().CountGetAllAccounts() } - var profiles struct{ Value []azureProfile } - err = am.helper.Unmarshal(body, &profiles) - if err != nil { - return nil, err - } - - indexedUsers := make(map[string][]*UserData) - for _, profile := range profiles.Value { - userData := profile.userData() - indexedUsers[UnsetAccountID] = append(indexedUsers[UnsetAccountID], userData) - } - return indexedUsers, nil } @@ -373,6 +349,39 @@ func (am *AzureManager) DeleteUser(userID string) error { return nil } +// getAllUsers returns all users in an Azure AD account. +func (am *AzureManager) getAllUsers() ([]*UserData, error) { + users := make([]*UserData, 0) + + q := url.Values{} + q.Add("$select", profileFields) + q.Add("$top", "500") + + for nextLink := "users"; nextLink != ""; { + body, err := am.get(nextLink, q) + if err != nil { + return nil, err + } + + var profiles struct { + Value []azureProfile + NextLink string `json:"@odata.nextLink"` + } + err = am.helper.Unmarshal(body, &profiles) + if err != nil { + return nil, err + } + + for _, profile := range profiles.Value { + users = append(users, profile.userData()) + } + + nextLink = profiles.NextLink + } + + return users, nil +} + // get perform Get requests. func (am *AzureManager) get(resource string, q url.Values) ([]byte, error) { jwtToken, err := am.credentials.Authenticate() @@ -380,7 +389,14 @@ func (am *AzureManager) get(resource string, q url.Values) ([]byte, error) { return nil, err } - reqURL := fmt.Sprintf("%s/%s?%s", am.GraphAPIEndpoint, resource, q.Encode()) + var reqURL string + if strings.HasPrefix(resource, "https") { + // Already an absolute URL for paging + reqURL = resource + } else { + reqURL = fmt.Sprintf("%s/%s?%s", am.GraphAPIEndpoint, resource, q.Encode()) + } + req, err := http.NewRequest(http.MethodGet, reqURL, nil) if err != nil { return nil, err diff --git a/management/server/idp/google_workspace.go b/management/server/idp/google_workspace.go index ed2de9a4225..896fb707b17 100644 --- a/management/server/idp/google_workspace.go +++ b/management/server/idp/google_workspace.go @@ -96,7 +96,7 @@ func (gm *GoogleWorkspaceManager) UpdateUserAppMetadata(_ string, _ AppMetadata) // GetUserDataByID requests user data from Google Workspace via ID. func (gm *GoogleWorkspaceManager) GetUserDataByID(userID string, appMetadata AppMetadata) (*UserData, error) { - user, err := gm.usersService.Get(userID).Projection("full").Do() + user, err := gm.usersService.Get(userID).Do() if err != nil { return nil, err } @@ -113,41 +113,67 @@ func (gm *GoogleWorkspaceManager) GetUserDataByID(userID string, appMetadata App // GetAccount returns all the users for a given profile. func (gm *GoogleWorkspaceManager) GetAccount(accountID string) ([]*UserData, error) { - usersList, err := gm.usersService.List().Customer(gm.CustomerID).Projection("full").Do() + users, err := gm.getAllUsers() if err != nil { return nil, err } - usersData := make([]*UserData, 0) - for _, user := range usersList.Users { - userData := parseGoogleWorkspaceUser(user) - userData.AppMetadata.WTAccountID = accountID + if gm.appMetrics != nil { + gm.appMetrics.IDPMetrics().CountGetAccount() + } - usersData = append(usersData, userData) + for index, user := range users { + user.AppMetadata.WTAccountID = accountID + users[index] = user } - return usersData, nil + return users, nil } // GetAllAccounts gets all registered accounts with corresponding user data. // It returns a list of users indexed by accountID. func (gm *GoogleWorkspaceManager) GetAllAccounts() (map[string][]*UserData, error) { - usersList, err := gm.usersService.List().Customer(gm.CustomerID).Projection("full").Do() + users, err := gm.getAllUsers() if err != nil { return nil, err } + indexedUsers := make(map[string][]*UserData) + indexedUsers[UnsetAccountID] = append(indexedUsers[UnsetAccountID], users...) + if gm.appMetrics != nil { gm.appMetrics.IDPMetrics().CountGetAllAccounts() } - indexedUsers := make(map[string][]*UserData) - for _, user := range usersList.Users { - userData := parseGoogleWorkspaceUser(user) - indexedUsers[UnsetAccountID] = append(indexedUsers[UnsetAccountID], userData) + return indexedUsers, nil +} + +// getAllUsers returns all users in a Google Workspace account filtered by customer ID. +func (gm *GoogleWorkspaceManager) getAllUsers() ([]*UserData, error) { + users := make([]*UserData, 0) + pageToken := "" + for { + call := gm.usersService.List().Customer(gm.CustomerID).MaxResults(500) + if pageToken != "" { + call.PageToken(pageToken) + } + + resp, err := call.Do() + if err != nil { + return nil, err + } + + for _, user := range resp.Users { + users = append(users, parseGoogleWorkspaceUser(user)) + } + + pageToken = resp.NextPageToken + if pageToken == "" { + break + } } - return indexedUsers, nil + return users, nil } // CreateUser creates a new user in Google Workspace and sends an invitation. @@ -158,7 +184,7 @@ func (gm *GoogleWorkspaceManager) CreateUser(_, _, _, _ string) (*UserData, erro // GetUserByEmail searches users with a given email. // If no users have been found, this function returns an empty list. func (gm *GoogleWorkspaceManager) GetUserByEmail(email string) ([]*UserData, error) { - user, err := gm.usersService.Get(email).Projection("full").Do() + user, err := gm.usersService.Get(email).Do() if err != nil { return nil, err } diff --git a/management/server/idp/okta.go b/management/server/idp/okta.go index 3e7b9357ec6..67341a26ff0 100644 --- a/management/server/idp/okta.go +++ b/management/server/idp/okta.go @@ -9,6 +9,7 @@ import ( "time" "github.com/okta/okta-sdk-golang/v2/okta" + "github.com/okta/okta-sdk-golang/v2/okta/query" "github.com/netbirdio/netbird/management/server/telemetry" ) @@ -160,7 +161,7 @@ func (om *OktaManager) GetUserByEmail(email string) ([]*UserData, error) { // GetAccount returns all the users for a given profile. func (om *OktaManager) GetAccount(accountID string) ([]*UserData, error) { - users, resp, err := om.client.User.ListUsers(context.Background(), nil) + users, err := om.getAllUsers() if err != nil { return nil, err } @@ -169,39 +170,40 @@ func (om *OktaManager) GetAccount(accountID string) ([]*UserData, error) { om.appMetrics.IDPMetrics().CountGetAccount() } - if resp.StatusCode != http.StatusOK { - if om.appMetrics != nil { - om.appMetrics.IDPMetrics().CountRequestStatusError() - } - return nil, fmt.Errorf("unable to get account, statusCode %d", resp.StatusCode) - } - - list := make([]*UserData, 0) - for _, user := range users { - userData, err := parseOktaUser(user) - if err != nil { - return nil, err - } - userData.AppMetadata.WTAccountID = accountID - - list = append(list, userData) + for index, user := range users { + user.AppMetadata.WTAccountID = accountID + users[index] = user } - return list, nil + return users, nil } // GetAllAccounts gets all registered accounts with corresponding user data. // It returns a list of users indexed by accountID. func (om *OktaManager) GetAllAccounts() (map[string][]*UserData, error) { - users, resp, err := om.client.User.ListUsers(context.Background(), nil) + users, err := om.getAllUsers() if err != nil { return nil, err } + indexedUsers := make(map[string][]*UserData) + indexedUsers[UnsetAccountID] = append(indexedUsers[UnsetAccountID], users...) + if om.appMetrics != nil { om.appMetrics.IDPMetrics().CountGetAllAccounts() } + return indexedUsers, nil +} + +// getAllUsers returns all users in an Okta account. +func (om *OktaManager) getAllUsers() ([]*UserData, error) { + qp := query.NewQueryParams(query.WithLimit(200)) + userList, resp, err := om.client.User.ListUsers(context.Background(), qp) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { if om.appMetrics != nil { om.appMetrics.IDPMetrics().CountRequestStatusError() @@ -209,17 +211,34 @@ func (om *OktaManager) GetAllAccounts() (map[string][]*UserData, error) { return nil, fmt.Errorf("unable to get all accounts, statusCode %d", resp.StatusCode) } - indexedUsers := make(map[string][]*UserData) - for _, user := range users { + for resp.HasNextPage() { + paginatedUsers := make([]*okta.User, 0) + resp, err = resp.Next(context.Background(), &paginatedUsers) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + if om.appMetrics != nil { + om.appMetrics.IDPMetrics().CountRequestStatusError() + } + return nil, fmt.Errorf("unable to get all accounts, statusCode %d", resp.StatusCode) + } + + userList = append(userList, paginatedUsers...) + } + + users := make([]*UserData, 0, len(userList)) + for _, user := range userList { userData, err := parseOktaUser(user) if err != nil { return nil, err } - indexedUsers[UnsetAccountID] = append(indexedUsers[UnsetAccountID], userData) + users = append(users, userData) } - return indexedUsers, nil + return users, nil } // UpdateUserAppMetadata updates user app metadata based on userID and metadata map. From 659110f0d5491936e4cd5bb7dc14d2ff543ddeda Mon Sep 17 00:00:00 2001 From: Yury Gargay Date: Wed, 11 Oct 2023 18:11:45 +0200 Subject: [PATCH 02/28] Rework peer connection status based on the update channel existence (#1213) With this change, we don't need to update all peers on startup. We will check the existence of an update channel when returning a list or single peer on API. Then after restarting of server consumers of API will see peer not connected status till the creation of an updated channel which indicates peer successful connection. --- management/server/account.go | 6 ++ management/server/file_store.go | 4 -- management/server/http/peers_handler.go | 33 ++++++++++- management/server/http/peers_handler_test.go | 56 +++++++++++++++++-- management/server/mock_server/account_mock.go | 9 +++ 5 files changed, 97 insertions(+), 11 deletions(-) diff --git a/management/server/account.go b/management/server/account.go index d35ad2566e0..8e453a1fede 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -103,6 +103,7 @@ type AccountManager interface { UpdateAccountSettings(accountID, userID string, newSettings *Settings) (*Account, error) LoginPeer(login PeerLogin) (*Peer, *NetworkMap, error) // used by peer gRPC API SyncPeer(sync PeerSync) (*Peer, *NetworkMap, error) // used by peer gRPC API + GetAllConnectedPeers() (map[string]struct{}, error) } type DefaultAccountManager struct { @@ -1558,6 +1559,11 @@ func (am *DefaultAccountManager) getAccountWithAuthorizationClaims(claims jwtcla } } +// GetAllConnectedPeers returns connected peers based on peersUpdateManager.GetAllConnectedPeers() +func (am *DefaultAccountManager) GetAllConnectedPeers() (map[string]struct{}, error) { + return am.peersUpdateManager.GetAllConnectedPeers(), nil +} + func isDomainValid(domain string) bool { re := regexp.MustCompile(`^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$`) return re.Match([]byte(domain)) diff --git a/management/server/file_store.go b/management/server/file_store.go index ecd02ba9910..b90b1d607eb 100644 --- a/management/server/file_store.go +++ b/management/server/file_store.go @@ -111,10 +111,6 @@ func restore(file string) (*FileStore, error) { for _, peer := range account.Peers { store.PeerKeyID2AccountID[peer.Key] = accountID store.PeerID2AccountID[peer.ID] = accountID - // reset all peers to status = Disconnected - if peer.Status != nil && peer.Status.Connected { - peer.Status.Connected = false - } } for _, user := range account.Users { store.UserID2AccountID[user.Id] = accountID diff --git a/management/server/http/peers_handler.go b/management/server/http/peers_handler.go index adf4a972102..a485d6ccf96 100644 --- a/management/server/http/peers_handler.go +++ b/management/server/http/peers_handler.go @@ -31,6 +31,24 @@ func NewPeersHandler(accountManager server.AccountManager, authCfg AuthCfg) *Pee } } +func (h *PeersHandler) checkPeerStatus(peer *server.Peer) (*server.Peer, error) { + peerToReturn := peer.Copy() + if peer.Status.Connected { + statuses, err := h.accountManager.GetAllConnectedPeers() + if err != nil { + return peerToReturn, err + } + + // Although we have online status in store we do not yet have an updated channel so have to show it as disconnected + // This may happen after server restart when not all peers are yet connected + if _, connected := statuses[peerToReturn.ID]; !connected { + peerToReturn.Status.Connected = false + } + } + + return peerToReturn, nil +} + func (h *PeersHandler) getPeer(account *server.Account, peerID, userID string, w http.ResponseWriter) { peer, err := h.accountManager.GetPeer(account.Id, peerID, userID) if err != nil { @@ -38,7 +56,13 @@ func (h *PeersHandler) getPeer(account *server.Account, peerID, userID string, w return } - util.WriteJSONObject(w, toPeerResponse(peer, account, h.accountManager.GetDNSDomain())) + peerToReturn, err := h.checkPeerStatus(peer) + if err != nil { + util.WriteError(err, w) + return + } + + util.WriteJSONObject(w, toPeerResponse(peerToReturn, account, h.accountManager.GetDNSDomain())) } func (h *PeersHandler) updatePeer(account *server.Account, user *server.User, peerID string, w http.ResponseWriter, r *http.Request) { @@ -120,7 +144,12 @@ func (h *PeersHandler) GetAllPeers(w http.ResponseWriter, r *http.Request) { respBody := []*api.Peer{} for _, peer := range peers { - respBody = append(respBody, toPeerResponse(peer, account, dnsDomain)) + peerToReturn, err := h.checkPeerStatus(peer) + if err != nil { + util.WriteError(err, w) + return + } + respBody = append(respBody, toPeerResponse(peerToReturn, account, dnsDomain)) } util.WriteJSONObject(w, respBody) return diff --git a/management/server/http/peers_handler_test.go b/management/server/http/peers_handler_test.go index 7fe732f2fc2..1856861d549 100644 --- a/management/server/http/peers_handler_test.go +++ b/management/server/http/peers_handler_test.go @@ -3,6 +3,7 @@ package http import ( "bytes" "encoding/json" + "fmt" "io" "net" "net/http" @@ -23,19 +24,33 @@ import ( ) const testPeerID = "test_peer" +const noUpdateChannelTestPeerID = "no-update-channel" func initTestMetaData(peers ...*server.Peer) *PeersHandler { return &PeersHandler{ accountManager: &mock_server.MockAccountManager{ UpdatePeerFunc: func(accountID, userID string, update *server.Peer) (*server.Peer, error) { - p := peers[0].Copy() + var p *server.Peer + for _, peer := range peers { + if update.ID == peer.ID { + p = peer.Copy() + break + } + } p.SSHEnabled = update.SSHEnabled p.LoginExpirationEnabled = update.LoginExpirationEnabled p.Name = update.Name return p, nil }, GetPeerFunc: func(accountID, peerID, userID string) (*server.Peer, error) { - return peers[0], nil + var p *server.Peer + for _, peer := range peers { + if peerID == peer.ID { + p = peer.Copy() + break + } + } + return p, nil }, GetPeersFunc: func(accountID, userID string) ([]*server.Peer, error) { return peers, nil @@ -57,6 +72,16 @@ func initTestMetaData(peers ...*server.Peer) *PeersHandler { }, }, user, nil }, + GetAllConnectedPeersFunc: func() (map[string]struct{}, error) { + statuses := make(map[string]struct{}) + for _, peer := range peers { + if peer.ID == noUpdateChannelTestPeerID { + break + } + statuses[peer.ID] = struct{}{} + } + return statuses, nil + }, }, claimsExtractor: jwtclaims.NewClaimsExtractor( jwtclaims.WithFromRequestContext(func(r *http.Request) jwtclaims.AuthorizationClaims { @@ -79,7 +104,7 @@ func TestGetPeers(t *testing.T) { Key: "key", SetupKey: "setupkey", IP: net.ParseIP("100.64.0.1"), - Status: &server.PeerStatus{}, + Status: &server.PeerStatus{Connected: true}, Name: "PeerName", LoginExpirationEnabled: false, Meta: server.PeerSystemMeta{ @@ -93,11 +118,17 @@ func TestGetPeers(t *testing.T) { }, } + peer1 := peer.Copy() + peer1.ID = noUpdateChannelTestPeerID + expectedUpdatedPeer := peer.Copy() expectedUpdatedPeer.LoginExpirationEnabled = true expectedUpdatedPeer.SSHEnabled = true expectedUpdatedPeer.Name = "New Name" + expectedPeer1 := peer1.Copy() + expectedPeer1.Status.Connected = false + tt := []struct { name string expectedStatus int @@ -116,13 +147,21 @@ func TestGetPeers(t *testing.T) { expectedPeer: peer, }, { - name: "GetPeer", + name: "GetPeer with update channel", requestType: http.MethodGet, requestPath: "/api/peers/" + testPeerID, expectedStatus: http.StatusOK, expectedArray: false, expectedPeer: peer, }, + { + name: "GetPeer with no update channel", + requestType: http.MethodGet, + requestPath: "/api/peers/" + peer1.ID, + expectedStatus: http.StatusOK, + expectedArray: false, + expectedPeer: expectedPeer1, + }, { name: "PutPeer", requestType: http.MethodPut, @@ -136,7 +175,7 @@ func TestGetPeers(t *testing.T) { rr := httptest.NewRecorder() - p := initTestMetaData(peer) + p := initTestMetaData(peer, peer1) for _, tc := range tt { t.Run(tc.name, func(t *testing.T) { @@ -171,6 +210,10 @@ func TestGetPeers(t *testing.T) { t.Fatalf("Sent content is not in correct json format; %v", err) } + // hardcode this check for now as we only have two peers in this suite + assert.Equal(t, len(respBody), 2) + assert.Equal(t, respBody[1].Connected, false) + got = respBody[0] } else { got = &api.Peer{} @@ -180,12 +223,15 @@ func TestGetPeers(t *testing.T) { } } + fmt.Println(got) + assert.Equal(t, got.Name, tc.expectedPeer.Name) assert.Equal(t, got.Version, tc.expectedPeer.Meta.WtVersion) assert.Equal(t, got.Ip, tc.expectedPeer.IP.String()) assert.Equal(t, got.Os, "OS core") assert.Equal(t, got.LoginExpirationEnabled, tc.expectedPeer.LoginExpirationEnabled) assert.Equal(t, got.SshEnabled, tc.expectedPeer.SSHEnabled) + assert.Equal(t, got.Connected, tc.expectedPeer.Status.Connected) }) } } diff --git a/management/server/mock_server/account_mock.go b/management/server/mock_server/account_mock.go index 5432b201bfb..ab3748c01bd 100644 --- a/management/server/mock_server/account_mock.go +++ b/management/server/mock_server/account_mock.go @@ -75,6 +75,7 @@ type MockAccountManager struct { LoginPeerFunc func(login server.PeerLogin) (*server.Peer, *server.NetworkMap, error) SyncPeerFunc func(sync server.PeerSync) (*server.Peer, *server.NetworkMap, error) InviteUserFunc func(accountID string, initiatorUserID string, targetUserEmail string) error + GetAllConnectedPeersFunc func() (map[string]struct{}, error) } // GetUsersFromAccount mock implementation of GetUsersFromAccount from server.AccountManager interface @@ -583,3 +584,11 @@ func (am *MockAccountManager) SyncPeer(sync server.PeerSync) (*server.Peer, *ser } return nil, nil, status.Errorf(codes.Unimplemented, "method SyncPeer is not implemented") } + +// GetAllConnectedPeers mocks GetAllConnectedPeers of the AccountManager interface +func (am *MockAccountManager) GetAllConnectedPeers() (map[string]struct{}, error) { + if am.GetAllConnectedPeersFunc != nil { + return am.GetAllConnectedPeersFunc() + } + return nil, status.Errorf(codes.Unimplemented, "method GetAllConnectedPeers is not implemented") +} From b8599f634c06617092e1f2b1373f1b7d4ff6cf0d Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Wed, 11 Oct 2023 23:00:56 +0200 Subject: [PATCH 03/28] Fix nil pointer exception in group delete (#1211) Fix group delete panic In case if in the db the DNSSettings is null then can cause panic in delete group function because this field is pointer and it was not checked. Because of in the future implementation this variable will be filled in any case then make no sense to keep the pointer type. Fix DNSSettings copy function --- management/server/account.go | 21 +++++-------- management/server/account_test.go | 2 +- management/server/dns.go | 30 +++++-------------- management/server/dns_test.go | 2 +- .../server/http/dns_settings_handler_test.go | 4 +-- 5 files changed, 19 insertions(+), 40 deletions(-) diff --git a/management/server/account.go b/management/server/account.go index 8e453a1fede..0e583e17f3f 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -180,7 +180,7 @@ type Account struct { Policies []*Policy Routes map[string]*route.Route NameServerGroups map[string]*nbdns.NameServerGroup - DNSSettings *DNSSettings + DNSSettings DNSSettings // Settings is a dictionary of Account settings Settings *Settings } @@ -513,13 +513,11 @@ func (a *Account) getUserGroups(userID string) ([]string, error) { func (a *Account) getPeerDNSManagementStatus(peerID string) bool { peerGroups := a.getPeerGroups(peerID) enabled := true - if a.DNSSettings != nil { - for _, groupID := range a.DNSSettings.DisabledManagementGroups { - _, found := peerGroups[groupID] - if found { - enabled = false - break - } + for _, groupID := range a.DNSSettings.DisabledManagementGroups { + _, found := peerGroups[groupID] + if found { + enabled = false + break } } return enabled @@ -606,10 +604,7 @@ func (a *Account) Copy() *Account { nsGroups[id] = nsGroup.Copy() } - var dnsSettings *DNSSettings - if a.DNSSettings != nil { - dnsSettings = a.DNSSettings.Copy() - } + dnsSettings := a.DNSSettings.Copy() var settings *Settings if a.Settings != nil { @@ -1618,7 +1613,7 @@ func newAccountWithId(accountID, userID, domain string) *Account { setupKeys := map[string]*SetupKey{} nameServersGroups := make(map[string]*nbdns.NameServerGroup) users[userID] = NewAdminUser(userID) - dnsSettings := &DNSSettings{ + dnsSettings := DNSSettings{ DisabledManagementGroups: make([]string, 0), } log.Debugf("created new account %s", accountID) diff --git a/management/server/account_test.go b/management/server/account_test.go index 331df2017dd..e47b3b854d6 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -1374,7 +1374,7 @@ func TestAccount_Copy(t *testing.T) { NameServers: []nbdns.NameServer{}, }, }, - DNSSettings: &DNSSettings{DisabledManagementGroups: []string{}}, + DNSSettings: DNSSettings{DisabledManagementGroups: []string{}}, Settings: &Settings{}, } err := hasNilField(account) diff --git a/management/server/dns.go b/management/server/dns.go index 252782aeaac..9707cc372b6 100644 --- a/management/server/dns.go +++ b/management/server/dns.go @@ -24,19 +24,11 @@ type DNSSettings struct { } // Copy returns a copy of the DNS settings -func (d *DNSSettings) Copy() *DNSSettings { - settings := &DNSSettings{ - DisabledManagementGroups: make([]string, 0), +func (d DNSSettings) Copy() DNSSettings { + settings := DNSSettings{ + DisabledManagementGroups: make([]string, len(d.DisabledManagementGroups)), } - - if d == nil { - return settings - } - - if d.DisabledManagementGroups != nil && len(d.DisabledManagementGroups) > 0 { - settings.DisabledManagementGroups = d.DisabledManagementGroups[:] - } - + copy(settings.DisabledManagementGroups, d.DisabledManagementGroups) return settings } @@ -58,12 +50,8 @@ func (am *DefaultAccountManager) GetDNSSettings(accountID string, userID string) if !user.IsAdmin() { return nil, status.Errorf(status.PermissionDenied, "only admins are allowed to view DNS settings") } - - if account.DNSSettings == nil { - return &DNSSettings{}, nil - } - - return account.DNSSettings.Copy(), nil + dnsSettings := account.DNSSettings.Copy() + return &dnsSettings, nil } // SaveDNSSettings validates a user role and updates the account's DNS settings @@ -96,11 +84,7 @@ func (am *DefaultAccountManager) SaveDNSSettings(accountID string, userID string } } - oldSettings := &DNSSettings{} - if account.DNSSettings != nil { - oldSettings = account.DNSSettings.Copy() - } - + oldSettings := account.DNSSettings.Copy() account.DNSSettings = dnsSettingsToSave.Copy() account.Network.IncSerial() diff --git a/management/server/dns_test.go b/management/server/dns_test.go index b089949b282..8c979c2a6ac 100644 --- a/management/server/dns_test.go +++ b/management/server/dns_test.go @@ -42,7 +42,7 @@ func TestGetDNSSettings(t *testing.T) { t.Fatal("DNS settings for new accounts shouldn't return nil") } - account.DNSSettings = &DNSSettings{ + account.DNSSettings = DNSSettings{ DisabledManagementGroups: []string{group1ID}, } diff --git a/management/server/http/dns_settings_handler_test.go b/management/server/http/dns_settings_handler_test.go index c7d135fd13f..a2f65a521ce 100644 --- a/management/server/http/dns_settings_handler_test.go +++ b/management/server/http/dns_settings_handler_test.go @@ -26,7 +26,7 @@ const ( testDNSSettingsUserID = "test_user" ) -var baseExistingDNSSettings = &server.DNSSettings{ +var baseExistingDNSSettings = server.DNSSettings{ DisabledManagementGroups: []string{testDNSSettingsExistingGroup}, } @@ -43,7 +43,7 @@ func initDNSSettingsTestData() *DNSSettingsHandler { return &DNSSettingsHandler{ accountManager: &mock_server.MockAccountManager{ GetDNSSettingsFunc: func(accountID string, userID string) (*server.DNSSettings, error) { - return testingDNSSettingsAccount.DNSSettings, nil + return &testingDNSSettingsAccount.DNSSettings, nil }, SaveDNSSettingsFunc: func(accountID string, userID string, dnsSettingsToSave *server.DNSSettings) error { if dnsSettingsToSave != nil { From 2b90ff8c2486a626e08fb495db6772ddfd5bcd2b Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Wed, 11 Oct 2023 23:01:49 +0200 Subject: [PATCH 04/28] Fix/key backup in config script (#1206) Because we provide the option to regenerate the config files, the encryption key could be lost. - The configure.sh read the existing key and write it back during the config generation - Backup the previously generated config files before overwrite it - Fix invalid json output in the Extras field - Reduce the error logs in case if the encryption key is invalid - Response in the events API with valid user info in any cases - Add extra error handling to the configure.sh. I.e. handle the invalid OpenID urls --- infrastructure_files/configure.sh | 28 +++++++++++++++++++-- infrastructure_files/management.json.tmpl | 1 + management/server/activity/sqlite/sqlite.go | 22 +++++++++++----- 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/infrastructure_files/configure.sh b/infrastructure_files/configure.sh index 3db79906827..09152d78866 100755 --- a/infrastructure_files/configure.sh +++ b/infrastructure_files/configure.sh @@ -1,4 +1,5 @@ #!/bin/bash +set -e if ! which curl >/dev/null 2>&1; then echo "This script uses curl fetch OpenID configuration from IDP." @@ -154,6 +155,8 @@ if [ -n "$NETBIRD_MGMT_IDP" ]; then export NETBIRD_IDP_MGMT_CLIENT_ID export NETBIRD_IDP_MGMT_CLIENT_SECRET export NETBIRD_IDP_MGMT_EXTRA_CONFIG=$EXTRA_CONFIG +else + export NETBIRD_IDP_MGMT_EXTRA_CONFIG={} fi IFS=',' read -r -a REDIRECT_URL_PORTS <<< "$NETBIRD_AUTH_PKCE_REDIRECT_URL_PORTS" @@ -170,8 +173,29 @@ if [ "$NETBIRD_DASH_AUTH_USE_AUDIENCE" = "false" ]; then export NETBIRD_AUTH_PKCE_AUDIENCE= fi +# Read the encryption key +if test -f 'management.json'; then + encKey=$(jq -r ".DataStoreEncryptionKey" management.json) + if [[ "$encKey" != "null" ]]; then + export NETBIRD_DATASTORE_ENC_KEY=$encKey + + fi +fi + env | grep NETBIRD +bkp_postfix="$(date +%s)" +if test -f 'docker-compose.yml'; then + cp docker-compose.yml "docker-compose.yml.bkp.${bkp_postfix}" +fi + +if test -f 'management.json'; then + cp management.json "management.json.bkp.${bkp_postfix}" +fi + +if test -f 'turnserver.conf'; then + cp turnserver.conf "turnserver.conf.bpk.${bkp_postfix}" +fi envsubst docker-compose.yml -envsubst management.json -envsubst turnserver.conf \ No newline at end of file +envsubst management.json +envsubst turnserver.conf diff --git a/infrastructure_files/management.json.tmpl b/infrastructure_files/management.json.tmpl index e185faa6ebd..847ce62223b 100644 --- a/infrastructure_files/management.json.tmpl +++ b/infrastructure_files/management.json.tmpl @@ -27,6 +27,7 @@ "Password": null }, "Datadir": "", + "DataStoreEncryptionKey": "$NETBIRD_DATASTORE_ENC_KEY", "HttpConfig": { "Address": "0.0.0.0:$NETBIRD_MGMT_API_PORT", "AuthIssuer": "$NETBIRD_AUTH_AUTHORITY", diff --git a/management/server/activity/sqlite/sqlite.go b/management/server/activity/sqlite/sqlite.go index 6af4d4d8dbb..a5130b0c5ed 100644 --- a/management/server/activity/sqlite/sqlite.go +++ b/management/server/activity/sqlite/sqlite.go @@ -45,6 +45,9 @@ const ( "VALUES(?, ?, ?, ?, ?, ?)" insertDeleteUserQuery = `INSERT INTO deleted_users(id, email, name) VALUES(?, ?, ?)` + + fallbackName = "unknown" + fallbackEmail = "unknown@unknown.com" ) // Store is the implementation of the activity.Store interface backed by SQLite @@ -128,6 +131,7 @@ func NewSQLiteStore(dataDir string, encryptionKey string) (*Store, error) { func (store *Store) processResult(result *sql.Rows) ([]*activity.Event, error) { events := make([]*activity.Event, 0) + var cryptErr error for result.Next() { var id int64 var operation activity.Activity @@ -156,8 +160,8 @@ func (store *Store) processResult(result *sql.Rows) ([]*activity.Event, error) { if targetUserName != nil { name, err := store.fieldEncrypt.Decrypt(*targetUserName) if err != nil { - log.Errorf("failed to decrypt username for target id: %s", target) - meta["username"] = "" + cryptErr = fmt.Errorf("failed to decrypt username for target id: %s", target) + meta["username"] = fallbackName } else { meta["username"] = name } @@ -166,8 +170,8 @@ func (store *Store) processResult(result *sql.Rows) ([]*activity.Event, error) { if targetEmail != nil { email, err := store.fieldEncrypt.Decrypt(*targetEmail) if err != nil { - log.Errorf("failed to decrypt email address for target id: %s", target) - meta["email"] = "" + cryptErr = fmt.Errorf("failed to decrypt email address for target id: %s", target) + meta["email"] = fallbackEmail } else { meta["email"] = email } @@ -186,7 +190,8 @@ func (store *Store) processResult(result *sql.Rows) ([]*activity.Event, error) { if initiatorName != nil { name, err := store.fieldEncrypt.Decrypt(*initiatorName) if err != nil { - log.Errorf("failed to decrypt username of initiator: %s", initiator) + cryptErr = fmt.Errorf("failed to decrypt username of initiator: %s", initiator) + event.InitiatorName = fallbackName } else { event.InitiatorName = name } @@ -195,7 +200,8 @@ func (store *Store) processResult(result *sql.Rows) ([]*activity.Event, error) { if initiatorEmail != nil { email, err := store.fieldEncrypt.Decrypt(*initiatorEmail) if err != nil { - log.Errorf("failed to decrypt email address of initiator: %s", initiator) + cryptErr = fmt.Errorf("failed to decrypt email address of initiator: %s", initiator) + event.InitiatorEmail = fallbackEmail } else { event.InitiatorEmail = email } @@ -204,6 +210,10 @@ func (store *Store) processResult(result *sql.Rows) ([]*activity.Event, error) { events = append(events, event) } + if cryptErr != nil { + log.Warnf("%s", cryptErr) + } + return events, nil } From 32880c56a41acd604615604e538a8f1054bef11a Mon Sep 17 00:00:00 2001 From: Yury Gargay Date: Thu, 12 Oct 2023 15:42:36 +0200 Subject: [PATCH 05/28] Implement SQLite Store using gorm and relational approach (#1065) Restructure data handling for improved performance and flexibility. Introduce 'G'-prefixed fields to represent Gorm relations, simplifying resource management. Eliminate complexity in lookup tables for enhanced query and write speed. Enable independent operations on data structures, requiring adjustments in the Store interface and Account Manager. --- .github/workflows/golang-test-darwin.yml | 5 +- .github/workflows/golang-test-linux.yml | 18 +- .github/workflows/golang-test-windows.yml | 5 + .gitignore | 3 +- client/cmd/testutil.go | 2 +- client/internal/engine_test.go | 5 +- dns/nameserver.go | 10 +- go.mod | 6 +- go.sum | 12 +- management/client/client_test.go | 2 +- management/cmd/management.go | 2 +- management/cmd/migration_down.go | 66 +++ management/cmd/migration_up.go | 66 +++ management/cmd/root.go | 14 + management/server/account.go | 35 +- management/server/account_test.go | 21 +- management/server/config.go | 2 + management/server/dns.go | 2 +- management/server/dns_test.go | 2 +- management/server/file_store.go | 24 + management/server/file_store_test.go | 2 +- management/server/group.go | 5 +- management/server/group_test.go | 5 + management/server/management_proto_test.go | 2 +- management/server/management_test.go | 3 +- management/server/metrics/selfhosted.go | 2 + management/server/metrics/selfhosted_test.go | 9 + management/server/nameserver_test.go | 2 +- management/server/network.go | 24 +- management/server/peer.go | 13 +- management/server/peer_test.go | 4 +- management/server/personal_access_token.go | 4 +- management/server/policy.go | 21 +- management/server/route_test.go | 2 +- management/server/rule.go | 7 +- management/server/setupkey.go | 9 +- management/server/sqlite_store.go | 457 +++++++++++++++++++ management/server/sqlite_store_test.go | 229 ++++++++++ management/server/store.go | 67 ++- management/server/store_test.go | 88 ++++ management/server/testdata/store.json | 63 ++- management/server/user.go | 10 +- management/server/user_test.go | 6 + route/route.go | 10 +- 44 files changed, 1239 insertions(+), 107 deletions(-) create mode 100644 management/cmd/migration_down.go create mode 100644 management/cmd/migration_up.go create mode 100644 management/server/sqlite_store.go create mode 100644 management/server/sqlite_store_test.go create mode 100644 management/server/store_test.go diff --git a/.github/workflows/golang-test-darwin.yml b/.github/workflows/golang-test-darwin.yml index 97fdeabe8e6..8cd28bcb4c6 100644 --- a/.github/workflows/golang-test-darwin.yml +++ b/.github/workflows/golang-test-darwin.yml @@ -12,6 +12,9 @@ concurrency: jobs: test: + strategy: + matrix: + store: ['JsonFile', 'Sqlite'] runs-on: macos-latest steps: - name: Install Go @@ -33,4 +36,4 @@ jobs: run: go mod tidy - name: Test - run: go test -exec 'sudo --preserve-env=CI' -timeout 5m -p 1 ./... + run: NETBIRD_STORE_KIND=${{ matrix.store }} go test -exec 'sudo --preserve-env=CI' -timeout 5m -p 1 ./... diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml index 13061f6eb0e..026779885f9 100644 --- a/.github/workflows/golang-test-linux.yml +++ b/.github/workflows/golang-test-linux.yml @@ -15,6 +15,7 @@ jobs: strategy: matrix: arch: ['386','amd64'] + store: ['JsonFile', 'Sqlite'] runs-on: ubuntu-latest steps: - name: Install Go @@ -41,17 +42,16 @@ jobs: run: go mod tidy - name: Test - run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} go test -exec 'sudo --preserve-env=CI' -timeout 5m -p 1 ./... + run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} NETBIRD_STORE_KIND=${{ matrix.store }} go test -exec 'sudo --preserve-env=CI' -timeout 5m -p 1 ./... test_client_on_docker: - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 steps: - name: Install Go uses: actions/setup-go@v4 with: go-version: "1.20.x" - - name: Cache Go modules uses: actions/cache@v3 with: @@ -64,7 +64,7 @@ jobs: uses: actions/checkout@v3 - name: Install dependencies - run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev + run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev gcc-multilib - name: Install modules run: go mod tidy @@ -82,7 +82,7 @@ jobs: run: CGO_ENABLED=0 go test -c -o nftablesmanager-testing.bin ./client/firewall/nftables/... - name: Generate Engine Test bin - run: CGO_ENABLED=0 go test -c -o engine-testing.bin ./client/internal + run: CGO_ENABLED=1 go test -c -o engine-testing.bin ./client/internal - name: Generate Peer Test bin run: CGO_ENABLED=0 go test -c -o peer-testing.bin ./client/internal/peer/... @@ -95,15 +95,17 @@ jobs: - name: Run Iface tests in docker run: docker run -t --cap-add=NET_ADMIN --privileged --rm -v $PWD:/ci -w /ci/iface --entrypoint /busybox/sh gcr.io/distroless/base:debug -c /ci/iface-testing.bin -test.timeout 5m -test.parallel 1 - - name: Run RouteManager tests in docker run: docker run -t --cap-add=NET_ADMIN --privileged --rm -v $PWD:/ci -w /ci/client/internal/routemanager --entrypoint /busybox/sh gcr.io/distroless/base:debug -c /ci/routemanager-testing.bin -test.timeout 5m -test.parallel 1 - name: Run nftables Manager tests in docker run: docker run -t --cap-add=NET_ADMIN --privileged --rm -v $PWD:/ci -w /ci/client/firewall --entrypoint /busybox/sh gcr.io/distroless/base:debug -c /ci/nftablesmanager-testing.bin -test.timeout 5m -test.parallel 1 - - name: Run Engine tests in docker - run: docker run -t --cap-add=NET_ADMIN --privileged --rm -v $PWD:/ci -w /ci/client/internal --entrypoint /busybox/sh gcr.io/distroless/base:debug -c /ci/engine-testing.bin -test.timeout 5m -test.parallel 1 + - name: Run Engine tests in docker with file store + run: docker run -t --cap-add=NET_ADMIN --privileged --rm -v $PWD:/ci -w /ci/client/internal -e NETBIRD_STORE_KIND="JsonFile" --entrypoint /busybox/sh gcr.io/distroless/base:debug -c /ci/engine-testing.bin -test.timeout 5m -test.parallel 1 + + - name: Run Engine tests in docker with sqlite store + run: docker run -t --cap-add=NET_ADMIN --privileged --rm -v $PWD:/ci -w /ci/client/internal -e NETBIRD_STORE_KIND="Sqlite" --entrypoint /busybox/sh gcr.io/distroless/base:debug -c /ci/engine-testing.bin -test.timeout 5m -test.parallel 1 - name: Run Peer tests in docker run: docker run -t --cap-add=NET_ADMIN --privileged --rm -v $PWD:/ci -w /ci/client/internal/peer --entrypoint /busybox/sh gcr.io/distroless/base:debug -c /ci/peer-testing.bin -test.timeout 5m -test.parallel 1 \ No newline at end of file diff --git a/.github/workflows/golang-test-windows.yml b/.github/workflows/golang-test-windows.yml index 6dd91666c11..1fc84ff2acb 100644 --- a/.github/workflows/golang-test-windows.yml +++ b/.github/workflows/golang-test-windows.yml @@ -14,6 +14,9 @@ concurrency: jobs: test: + strategy: + matrix: + store: ['JsonFile', 'Sqlite'] runs-on: windows-latest steps: - name: Checkout code @@ -40,6 +43,8 @@ jobs: - run: mv ${{ env.downloadPath }}/wintun/bin/amd64/wintun.dll 'C:\Windows\System32\' - run: choco install -y sysinternals + - run: choco install -y mingw + - run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe env -w GOMODCACHE=C:\Users\runneradmin\go\pkg\mod - run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe env -w GOCACHE=C:\Users\runneradmin\AppData\Local\go-build diff --git a/.gitignore b/.gitignore index dc62780ad6d..7edcc708716 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,5 @@ client/.distfiles/ infrastructure_files/setup.env infrastructure_files/setup-*.env .vscode -.DS_Store \ No newline at end of file +.DS_Store +*.db diff --git a/client/cmd/testutil.go b/client/cmd/testutil.go index 6d47021dd09..47ae9ddb466 100644 --- a/client/cmd/testutil.go +++ b/client/cmd/testutil.go @@ -65,7 +65,7 @@ func startManagement(t *testing.T, config *mgmt.Config) (*grpc.Server, net.Liste t.Fatal(err) } s := grpc.NewServer() - store, err := mgmt.NewFileStore(config.Datadir, nil) + store, err := mgmt.NewStoreFromJson(config.Datadir, nil) if err != nil { t.Fatal(err) } diff --git a/client/internal/engine_test.go b/client/internal/engine_test.go index ea4a23a8de6..42012bd0a10 100644 --- a/client/internal/engine_test.go +++ b/client/internal/engine_test.go @@ -1039,10 +1039,11 @@ func startManagement(dataDir string) (*grpc.Server, string, error) { return nil, "", err } s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp)) - store, err := server.NewFileStore(config.Datadir, nil) + store, err := server.NewStoreFromJson(config.Datadir, nil) if err != nil { - log.Fatalf("failed creating a store: %s: %v", config.Datadir, err) + return nil, "", err } + peersUpdateManager := server.NewPeersUpdateManager() eventStore := &activity.InMemoryEventStore{} if err != nil { diff --git a/dns/nameserver.go b/dns/nameserver.go index 7751f8e1c6d..f3ae2569d39 100644 --- a/dns/nameserver.go +++ b/dns/nameserver.go @@ -50,19 +50,21 @@ func ToNameServerType(typeString string) NameServerType { // NameServerGroup group of nameservers and with group ids type NameServerGroup struct { // ID identifier of group - ID string + ID string `gorm:"primaryKey"` + // AccountID is a reference to Account that this object belongs + AccountID string `gorm:"index"` // Name group name Name string // Description group description Description string // NameServers list of nameservers - NameServers []NameServer + NameServers []NameServer `gorm:"serializer:json"` // Groups list of peer group IDs to distribute the nameservers information - Groups []string + Groups []string `gorm:"serializer:json"` // Primary indicates that the nameserver group is the primary resolver for any dns query Primary bool // Domains indicate the dns query domains to use with this nameserver group - Domains []string + Domains []string `gorm:"serializer:json"` // Enabled group status Enabled bool } diff --git a/go.mod b/go.mod index 8be1599970a..1f8eec24ed3 100644 --- a/go.mod +++ b/go.mod @@ -46,7 +46,7 @@ require ( github.com/hashicorp/go-version v1.6.0 github.com/libp2p/go-netroute v0.2.0 github.com/magiconair/properties v1.8.5 - github.com/mattn/go-sqlite3 v1.14.16 + github.com/mattn/go-sqlite3 v1.14.17 github.com/mdlayher/socket v0.4.0 github.com/miekg/dns v1.1.43 github.com/mitchellh/hashstructure/v2 v2.0.2 @@ -74,6 +74,8 @@ require ( golang.org/x/term v0.8.0 google.golang.org/api v0.126.0 gopkg.in/yaml.v3 v3.0.1 + gorm.io/driver/sqlite v1.5.3 + gorm.io/gorm v1.25.4 ) require ( @@ -110,6 +112,8 @@ require ( github.com/googleapis/gax-go/v2 v2.10.0 // indirect github.com/hashicorp/go-uuid v1.0.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect github.com/josharian/native v1.0.0 // indirect github.com/kelseyhightower/envconfig v1.4.0 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect diff --git a/go.sum b/go.sum index 25182ca85df..15e69283c2d 100644 --- a/go.sum +++ b/go.sum @@ -383,6 +383,10 @@ github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackmordaunt/icns v0.0.0-20181231085925-4f16af745526/go.mod h1:UQkeMHVoNcyXYq9otUupF7/h/2tmHlhrS2zw7ZVvUqc= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= github.com/josephspurrier/goversioninfo v0.0.0-20200309025242-14b0ab84c6ca/go.mod h1:eJTEwMjXb7kZ633hO3Ln9mBUCOjX2+FlTljvpl9SYdE= github.com/josharian/native v0.0.0-20200817173448-b6b71def0850/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= @@ -441,8 +445,8 @@ github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mattbaird/jsonpatch v0.0.0-20171005235357-81af80346b1a/go.mod h1:M1qoD/MqPgTZIk0EWKB38wE28ACRfVcn+cU08jyArI0= github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= -github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= -github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= +github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= @@ -1189,6 +1193,10 @@ gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/sqlite v1.5.3 h1:7/0dUgX28KAcopdfbRWWl68Rflh6osa4rDh+m51KL2g= +gorm.io/driver/sqlite v1.5.3/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4= +gorm.io/gorm v1.25.4 h1:iyNd8fNAe8W9dvtlgeRI5zSVZPsq3OpcTu37cYcpCmw= +gorm.io/gorm v1.25.4/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= gotest.tools/v3 v3.4.0/go.mod h1:CtbdzLSsqVhDgMtKsx03ird5YTGB3ar27v0u/yKBW5g= gvisor.dev/gvisor v0.0.0-20221203005347-703fd9b7fbc0 h1:Wobr37noukisGxpKo5jAsLREcpj61RxrWYzD8uwveOY= diff --git a/management/client/client_test.go b/management/client/client_test.go index 86c598adbd9..b66dacc7382 100644 --- a/management/client/client_test.go +++ b/management/client/client_test.go @@ -53,7 +53,7 @@ func startManagement(t *testing.T) (*grpc.Server, net.Listener) { t.Fatal(err) } s := grpc.NewServer() - store, err := mgmt.NewFileStore(config.Datadir, nil) + store, err := mgmt.NewStoreFromJson(config.Datadir, nil) if err != nil { t.Fatal(err) } diff --git a/management/cmd/management.go b/management/cmd/management.go index f85cf225eaf..fda16566c15 100644 --- a/management/cmd/management.go +++ b/management/cmd/management.go @@ -126,7 +126,7 @@ var ( if err != nil { return err } - store, err := server.NewFileStore(config.Datadir, appMetrics) + store, err := server.NewStore(config.StoreKind, config.Datadir, appMetrics) if err != nil { return fmt.Errorf("failed creating Store: %s: %v", config.Datadir, err) } diff --git a/management/cmd/migration_down.go b/management/cmd/migration_down.go new file mode 100644 index 00000000000..6d136ec1acf --- /dev/null +++ b/management/cmd/migration_down.go @@ -0,0 +1,66 @@ +package cmd + +import ( + "errors" + "flag" + "fmt" + "os" + "path" + + "github.com/netbirdio/netbird/management/server" + "github.com/netbirdio/netbird/util" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +var shortDown = "Rollback SQLite store to JSON file store. Please make a backup of the SQLite file before running this command." + +var downCmd = &cobra.Command{ + Use: "downgrade [--datadir directory] [--log-file console]", + Aliases: []string{"down"}, + Short: shortDown, + Long: shortDown + + "\n\n" + + "This command reads the content of {datadir}/store.db and migrates it to {datadir}/store.json that can be used by File store driver.", + RunE: func(cmd *cobra.Command, args []string) error { + flag.Parse() + err := util.InitLog(logLevel, logFile) + if err != nil { + return fmt.Errorf("failed initializing log %v", err) + } + + sqliteStorePath := path.Join(mgmtDataDir, "store.db") + if _, err := os.Stat(sqliteStorePath); errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("%s doesn't exist, couldn't continue the operation", sqliteStorePath) + } + + fileStorePath := path.Join(mgmtDataDir, "store.json") + if _, err := os.Stat(fileStorePath); err == nil { + return fmt.Errorf("%s already exists, couldn't continue the operation", fileStorePath) + } + + sqlstore, err := server.NewSqliteStore(mgmtDataDir, nil) + if err != nil { + return fmt.Errorf("failed creating file store: %s: %v", mgmtDataDir, err) + } + + sqliteStoreAccounts := len(sqlstore.GetAllAccounts()) + log.Infof("%d account will be migrated from sqlite store %s to file store %s", + sqliteStoreAccounts, sqliteStorePath, fileStorePath) + + store, err := server.NewFilestoreFromSqliteStore(sqlstore, mgmtDataDir, nil) + if err != nil { + return fmt.Errorf("failed creating file store: %s: %v", mgmtDataDir, err) + } + + fsStoreAccounts := len(store.GetAllAccounts()) + if fsStoreAccounts != sqliteStoreAccounts { + return fmt.Errorf("failed to migrate accounts from sqlite to file[]. Expected accounts: %d, got: %d", + sqliteStoreAccounts, fsStoreAccounts) + } + + log.Info("Migration finished successfully") + + return nil + }, +} diff --git a/management/cmd/migration_up.go b/management/cmd/migration_up.go new file mode 100644 index 00000000000..5c7505cfcea --- /dev/null +++ b/management/cmd/migration_up.go @@ -0,0 +1,66 @@ +package cmd + +import ( + "errors" + "flag" + "fmt" + "os" + "path" + + "github.com/netbirdio/netbird/management/server" + "github.com/netbirdio/netbird/util" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +var shortUp = "Migrate JSON file store to SQLite store. Please make a backup of the JSON file before running this command." + +var upCmd = &cobra.Command{ + Use: "upgrade [--datadir directory] [--log-file console]", + Aliases: []string{"up"}, + Short: shortUp, + Long: shortUp + + "\n\n" + + "This command reads the content of {datadir}/store.json and migrates it to {datadir}/store.db that can be used by SQLite store driver.", + RunE: func(cmd *cobra.Command, args []string) error { + flag.Parse() + err := util.InitLog(logLevel, logFile) + if err != nil { + return fmt.Errorf("failed initializing log %v", err) + } + + fileStorePath := path.Join(mgmtDataDir, "store.json") + if _, err := os.Stat(fileStorePath); errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("%s doesn't exist, couldn't continue the operation", fileStorePath) + } + + sqlStorePath := path.Join(mgmtDataDir, "store.db") + if _, err := os.Stat(sqlStorePath); err == nil { + return fmt.Errorf("%s already exists, couldn't continue the operation", sqlStorePath) + } + + fstore, err := server.NewFileStore(mgmtDataDir, nil) + if err != nil { + return fmt.Errorf("failed creating file store: %s: %v", mgmtDataDir, err) + } + + fsStoreAccounts := len(fstore.GetAllAccounts()) + log.Infof("%d account will be migrated from file store %s to sqlite store %s", + fsStoreAccounts, fileStorePath, sqlStorePath) + + store, err := server.NewSqliteStoreFromFileStore(fstore, mgmtDataDir, nil) + if err != nil { + return fmt.Errorf("failed creating file store: %s: %v", mgmtDataDir, err) + } + + sqliteStoreAccounts := len(store.GetAllAccounts()) + if fsStoreAccounts != sqliteStoreAccounts { + return fmt.Errorf("failed to migrate accounts from file to sqlite. Expected accounts: %d, got: %d", + fsStoreAccounts, sqliteStoreAccounts) + } + + log.Info("Migration finished successfully") + + return nil + }, +} diff --git a/management/cmd/root.go b/management/cmd/root.go index 2080a6b29f2..d8a9da53f03 100644 --- a/management/cmd/root.go +++ b/management/cmd/root.go @@ -34,6 +34,12 @@ var ( SilenceUsage: true, } + migrationCmd = &cobra.Command{ + Use: "sqlite-migration", + Short: "Contains sub-commands to perform JSON file store to SQLite store migration and rollback", + Long: "", + SilenceUsage: true, + } // Execution control channel for stopCh signal stopCh chan int ) @@ -63,6 +69,14 @@ func init() { rootCmd.PersistentFlags().StringVar(&logLevel, "log-level", "info", "") rootCmd.PersistentFlags().StringVar(&logFile, "log-file", defaultLogFile, "sets Netbird log path. If console is specified the the log will be output to stdout") rootCmd.AddCommand(mgmtCmd) + + migrationCmd.PersistentFlags().StringVar(&mgmtDataDir, "datadir", defaultMgmtDataDir, "server data directory location") + migrationCmd.MarkFlagRequired("datadir") //nolint + + migrationCmd.AddCommand(upCmd) + migrationCmd.AddCommand(downCmd) + + rootCmd.AddCommand(migrationCmd) } // SetupCloseHandler handles SIGTERM signal and exits with success diff --git a/management/server/account.go b/management/server/account.go index 0e583e17f3f..f78530b4440 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -165,24 +165,33 @@ func (s *Settings) Copy() *Settings { // Account represents a unique account of the system type Account struct { - Id string + // we have to name column to aid as it collides with Network.Id when work with associations + Id string `gorm:"primaryKey"` + // User.Id it was created by CreatedBy string - Domain string + Domain string `gorm:"index"` DomainCategory string IsDomainPrimaryAccount bool - SetupKeys map[string]*SetupKey - Network *Network - Peers map[string]*Peer - Users map[string]*User - Groups map[string]*Group - Rules map[string]*Rule - Policies []*Policy - Routes map[string]*route.Route - NameServerGroups map[string]*nbdns.NameServerGroup - DNSSettings DNSSettings + SetupKeys map[string]*SetupKey `gorm:"-"` + SetupKeysG []SetupKey `json:"-" gorm:"foreignKey:AccountID;references:id"` + Network *Network `gorm:"embedded;embeddedPrefix:network_"` + Peers map[string]*Peer `gorm:"-"` + PeersG []Peer `json:"-" gorm:"foreignKey:AccountID;references:id"` + Users map[string]*User `gorm:"-"` + UsersG []User `json:"-" gorm:"foreignKey:AccountID;references:id"` + Groups map[string]*Group `gorm:"-"` + GroupsG []Group `json:"-" gorm:"foreignKey:AccountID;references:id"` + Rules map[string]*Rule `gorm:"-"` + RulesG []Rule `json:"-" gorm:"foreignKey:AccountID;references:id"` + Policies []*Policy `gorm:"foreignKey:AccountID;references:id"` + Routes map[string]*route.Route `gorm:"-"` + RoutesG []route.Route `json:"-" gorm:"foreignKey:AccountID;references:id"` + NameServerGroups map[string]*nbdns.NameServerGroup `gorm:"-"` + NameServerGroupsG []nbdns.NameServerGroup `json:"-" gorm:"foreignKey:AccountID;references:id"` + DNSSettings DNSSettings `gorm:"embedded;embeddedPrefix:dns_settings_"` // Settings is a dictionary of Account settings - Settings *Settings + Settings *Settings `gorm:"embedded;embeddedPrefix:settings_"` } type UserInfo struct { diff --git a/management/server/account_test.go b/management/server/account_test.go index e47b3b854d6..181e1c3feaf 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -198,11 +198,11 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) { netIP := net.IP{100, 64, 0, 0} netMask := net.IPMask{255, 255, 0, 0} network := &Network{ - Id: "network", - Net: net.IPNet{IP: netIP, Mask: netMask}, - Dns: "netbird.selfhosted", - Serial: 0, - mu: sync.Mutex{}, + Identifier: "network", + Net: net.IPNet{IP: netIP, Mask: netMask}, + Dns: "netbird.selfhosted", + Serial: 0, + mu: sync.Mutex{}, } for _, testCase := range tt { @@ -476,7 +476,7 @@ func TestDefaultAccountManager_GetGroupsFromTheToken(t *testing.T) { // as initAccount was created without account id we have to take the id after account initialization // that happens inside the GetAccountByUserOrAccountID where the id is getting generated // it is important to set the id as it help to avoid creating additional account with empty Id and re-pointing indices to it - initAccount.Id = acc.Id + initAccount = acc claims := jwtclaims.AuthorizationClaims{ AccountId: accountID, // is empty as it is based on accountID right after initialization of initAccount @@ -1025,7 +1025,6 @@ func TestAccountManager_NetworkUpdates(t *testing.T) { wg.Wait() }) - t.Run("delete peer update", func(t *testing.T) { wg.Add(1) go func() { @@ -1309,7 +1308,7 @@ func TestAccount_Copy(t *testing.T) { }, }, Network: &Network{ - Id: "net1", + Identifier: "net1", }, Peers: map[string]*Peer{ "peer1": { @@ -1400,6 +1399,10 @@ func hasNilField(x interface{}) error { rv := reflect.ValueOf(x) rv = rv.Elem() for i := 0; i < rv.NumField(); i++ { + // skip gorm internal fields + if json, ok := rv.Type().Field(i).Tag.Lookup("json"); ok && json == "-" { + continue + } if f := rv.Field(i); f.IsValid() { k := f.Kind() switch k { @@ -2045,7 +2048,7 @@ func createManager(t *testing.T) (*DefaultAccountManager, error) { func createStore(t *testing.T) (Store, error) { dataDir := t.TempDir() - store, err := NewFileStore(dataDir, nil) + store, err := NewStoreFromJson(dataDir, nil) if err != nil { return nil, err } diff --git a/management/server/config.go b/management/server/config.go index 31c1cf45c5d..19a71ff7aae 100644 --- a/management/server/config.go +++ b/management/server/config.go @@ -45,6 +45,8 @@ type Config struct { DeviceAuthorizationFlow *DeviceAuthorizationFlow PKCEAuthorizationFlow *PKCEAuthorizationFlow + + StoreKind StoreKind } // GetAuthAudiences returns the audience from the http config and device authorization flow config diff --git a/management/server/dns.go b/management/server/dns.go index 9707cc372b6..7b25e230f49 100644 --- a/management/server/dns.go +++ b/management/server/dns.go @@ -20,7 +20,7 @@ type lookupMap map[string]struct{} // DNSSettings defines dns settings at the account level type DNSSettings struct { // DisabledManagementGroups groups whose DNS management is disabled - DisabledManagementGroups []string + DisabledManagementGroups []string `gorm:"serializer:json"` } // Copy returns a copy of the DNS settings diff --git a/management/server/dns_test.go b/management/server/dns_test.go index 8c979c2a6ac..a2c9d3aa2f7 100644 --- a/management/server/dns_test.go +++ b/management/server/dns_test.go @@ -196,7 +196,7 @@ func createDNSManager(t *testing.T) (*DefaultAccountManager, error) { func createDNSStore(t *testing.T) (Store, error) { dataDir := t.TempDir() - store, err := NewFileStore(dataDir, nil) + store, err := NewStoreFromJson(dataDir, nil) if err != nil { return nil, err } diff --git a/management/server/file_store.go b/management/server/file_store.go index b90b1d607eb..c8d24433fa1 100644 --- a/management/server/file_store.go +++ b/management/server/file_store.go @@ -54,6 +54,25 @@ func NewFileStore(dataDir string, metrics telemetry.AppMetrics) (*FileStore, err return fs, nil } +// NewFilestoreFromSqliteStore restores a store from Sqlite and stores to Filestore json in the file located in datadir +func NewFilestoreFromSqliteStore(sqlitestore *SqliteStore, dataDir string, metrics telemetry.AppMetrics) (*FileStore, error) { + store, err := NewFileStore(dataDir, metrics) + if err != nil { + return nil, err + } + + err = store.SaveInstallationID(sqlitestore.GetInstallationID()) + if err != nil { + return nil, err + } + + for _, account := range sqlitestore.GetAllAccounts() { + store.Accounts[account.Id] = account + } + + return store, store.persist(store.storeFile) +} + // restore the state of the store from the file. // Creates a new empty store file if doesn't exist func restore(file string) (*FileStore, error) { @@ -595,3 +614,8 @@ func (s *FileStore) Close() error { return s.persist(s.storeFile) } + +// GetStoreKind returns FileStoreKind +func (s *FileStore) GetStoreKind() StoreKind { + return FileStoreKind +} diff --git a/management/server/file_store_test.go b/management/server/file_store_test.go index e2f07acda6e..705e9f14935 100644 --- a/management/server/file_store_test.go +++ b/management/server/file_store_test.go @@ -387,7 +387,7 @@ func TestFileStore_GetAccount(t *testing.T) { assert.Equal(t, expected.DomainCategory, account.DomainCategory) assert.Equal(t, expected.Domain, account.Domain) assert.Equal(t, expected.CreatedBy, account.CreatedBy) - assert.Equal(t, expected.Network.Id, account.Network.Id) + assert.Equal(t, expected.Network.Identifier, account.Network.Identifier) assert.Len(t, account.Peers, len(expected.Peers)) assert.Len(t, account.Users, len(expected.Users)) assert.Len(t, account.SetupKeys, len(expected.SetupKeys)) diff --git a/management/server/group.go b/management/server/group.go index a7502134aa3..28606e02d6f 100644 --- a/management/server/group.go +++ b/management/server/group.go @@ -23,6 +23,9 @@ type Group struct { // ID of the group ID string + // AccountID is a reference to Account that this object belongs + AccountID string `json:"-" gorm:"index"` + // Name visible in the UI Name string @@ -30,7 +33,7 @@ type Group struct { Issued string // Peers list of the group - Peers []string + Peers []string `gorm:"serializer:json"` } // EventMeta returns activity event meta related to the group diff --git a/management/server/group_test.go b/management/server/group_test.go index 3e2d6d3cc64..e300fe7fbfb 100644 --- a/management/server/group_test.go +++ b/management/server/group_test.go @@ -80,6 +80,7 @@ func initTestGroupAccount(am *DefaultAccountManager) (*Account, error) { groupForRoute := &Group{ "grp-for-route", + "account-id", "Group for route", GroupIssuedAPI, make([]string, 0), @@ -87,6 +88,7 @@ func initTestGroupAccount(am *DefaultAccountManager) (*Account, error) { groupForNameServerGroups := &Group{ "grp-for-name-server-grp", + "account-id", "Group for name server groups", GroupIssuedAPI, make([]string, 0), @@ -94,6 +96,7 @@ func initTestGroupAccount(am *DefaultAccountManager) (*Account, error) { groupForPolicies := &Group{ "grp-for-policies", + "account-id", "Group for policies", GroupIssuedAPI, make([]string, 0), @@ -101,6 +104,7 @@ func initTestGroupAccount(am *DefaultAccountManager) (*Account, error) { groupForSetupKeys := &Group{ "grp-for-keys", + "account-id", "Group for setup keys", GroupIssuedAPI, make([]string, 0), @@ -108,6 +112,7 @@ func initTestGroupAccount(am *DefaultAccountManager) (*Account, error) { groupForUsers := &Group{ "grp-for-users", + "account-id", "Group for users", GroupIssuedAPI, make([]string, 0), diff --git a/management/server/management_proto_test.go b/management/server/management_proto_test.go index b4a527e463d..06fc6669de1 100644 --- a/management/server/management_proto_test.go +++ b/management/server/management_proto_test.go @@ -405,7 +405,7 @@ func startManagement(t *testing.T, config *Config) (*grpc.Server, string, error) return nil, "", err } s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp)) - store, err := NewFileStore(config.Datadir, nil) + store, err := NewStoreFromJson(config.Datadir, nil) if err != nil { return nil, "", err } diff --git a/management/server/management_test.go b/management/server/management_test.go index fa35cfdef4e..375e7e634e2 100644 --- a/management/server/management_test.go +++ b/management/server/management_test.go @@ -393,6 +393,7 @@ var _ = Describe("Management service", func() { ipChannel := make(chan string, 20) for i := 0; i < initialPeers; i++ { go func() { + defer GinkgoRecover() key, _ := wgtypes.GenerateKey() loginPeerWithValidSetupKey(serverPubKey, key, client) encryptedBytes, err := encryption.EncryptMessage(serverPubKey, key, &mgmtProto.SyncRequest{}) @@ -496,7 +497,7 @@ func startServer(config *server.Config) (*grpc.Server, net.Listener) { Expect(err).NotTo(HaveOccurred()) s := grpc.NewServer() - store, err := server.NewFileStore(config.Datadir, nil) + store, err := server.NewStoreFromJson(config.Datadir, nil) if err != nil { log.Fatalf("failed creating a store: %s: %v", config.Datadir, err) } diff --git a/management/server/metrics/selfhosted.go b/management/server/metrics/selfhosted.go index 3b3db0baa87..59364b940e9 100644 --- a/management/server/metrics/selfhosted.go +++ b/management/server/metrics/selfhosted.go @@ -48,6 +48,7 @@ type properties map[string]interface{} // DataSource metric data source type DataSource interface { GetAllAccounts() []*server.Account + GetStoreKind() server.StoreKind } // ConnManager peer connection manager that holds state for current active connections @@ -295,6 +296,7 @@ func (w *Worker) generateProperties() properties { metricsProperties["max_active_peer_version"] = maxActivePeerVersion metricsProperties["ui_clients"] = uiClient metricsProperties["idp_manager"] = w.idpManager + metricsProperties["store_kind"] = w.dataSource.GetStoreKind() for protocol, count := range rulesProtocol { metricsProperties["rules_protocol_"+protocol] = count diff --git a/management/server/metrics/selfhosted_test.go b/management/server/metrics/selfhosted_test.go index c61613fd26f..f69c0f8f8ec 100644 --- a/management/server/metrics/selfhosted_test.go +++ b/management/server/metrics/selfhosted_test.go @@ -151,6 +151,11 @@ func (mockDatasource) GetAllAccounts() []*server.Account { } } +// GetStoreKind returns FileStoreKind +func (mockDatasource) GetStoreKind() server.StoreKind { + return server.FileStoreKind +} + // TestGenerateProperties tests and validate the properties generation by using the mockDatasource for the Worker.generateProperties func TestGenerateProperties(t *testing.T) { ds := mockDatasource{} @@ -236,4 +241,8 @@ func TestGenerateProperties(t *testing.T) { if properties["user_peers"] != 2 { t.Errorf("expected 2 user_peers, got %d", properties["user_peers"]) } + + if properties["store_kind"] != server.FileStoreKind { + t.Errorf("expected JsonFile, got %s", properties["store_kind"]) + } } diff --git a/management/server/nameserver_test.go b/management/server/nameserver_test.go index 26977116b86..8809dc8ad9b 100644 --- a/management/server/nameserver_test.go +++ b/management/server/nameserver_test.go @@ -749,7 +749,7 @@ func createNSManager(t *testing.T) (*DefaultAccountManager, error) { func createNSStore(t *testing.T) (Store, error) { dataDir := t.TempDir() - store, err := NewFileStore(dataDir, nil) + store, err := NewStoreFromJson(dataDir, nil) if err != nil { return nil, err } diff --git a/management/server/network.go b/management/server/network.go index 70f218f6688..c5b165caeda 100644 --- a/management/server/network.go +++ b/management/server/network.go @@ -34,14 +34,14 @@ type NetworkMap struct { } type Network struct { - Id string - Net net.IPNet - Dns string + Identifier string `json:"id"` + Net net.IPNet `gorm:"serializer:gob"` + Dns string // Serial is an ID that increments by 1 when any change to the network happened (e.g. new peer has been added). // Used to synchronize state to the client apps. Serial uint64 - mu sync.Mutex `json:"-"` + mu sync.Mutex `json:"-" gorm:"-"` } // NewNetwork creates a new Network initializing it with a Serial=0 @@ -56,10 +56,10 @@ func NewNetwork() *Network { intn := r.Intn(len(sub)) return &Network{ - Id: xid.New().String(), - Net: sub[intn].IPNet, - Dns: "", - Serial: 0} + Identifier: xid.New().String(), + Net: sub[intn].IPNet, + Dns: "", + Serial: 0} } // IncSerial increments Serial by 1 reflecting that the network state has been changed @@ -78,10 +78,10 @@ func (n *Network) CurrentSerial() uint64 { func (n *Network) Copy() *Network { return &Network{ - Id: n.Id, - Net: n.Net, - Dns: n.Dns, - Serial: n.Serial, + Identifier: n.Identifier, + Net: n.Net, + Dns: n.Dns, + Serial: n.Serial, } } diff --git a/management/server/peer.go b/management/server/peer.go index e5c6e39d65d..f38e19e870d 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -72,22 +72,24 @@ type PeerLogin struct { // The Peer is a WireGuard peer identified by a public key type Peer struct { // ID is an internal ID of the peer - ID string + ID string `gorm:"primaryKey"` + // AccountID is a reference to Account that this object belongs + AccountID string `json:"-" gorm:"index;uniqueIndex:idx_peers_account_id_ip"` // WireGuard public key - Key string + Key string `gorm:"index"` // A setup key this peer was registered with SetupKey string // IP address of the Peer - IP net.IP + IP net.IP `gorm:"uniqueIndex:idx_peers_account_id_ip"` // Meta is a Peer system meta data - Meta PeerSystemMeta + Meta PeerSystemMeta `gorm:"embedded;embeddedPrefix:meta_"` // Name is peer's name (machine name) Name string // DNSLabel is the parsed peer name for domain resolution. It is used to form an FQDN by appending the account's // domain to the peer label. e.g. peer-dns-label.netbird.cloud DNSLabel string // Status peer's management connection status - Status *PeerStatus + Status *PeerStatus `gorm:"embedded;embeddedPrefix:peer_status_"` // The user ID that registered the peer UserID string // SSHKey is a public SSH key of the peer @@ -116,6 +118,7 @@ func (p *Peer) Copy() *Peer { } return &Peer{ ID: p.ID, + AccountID: p.AccountID, Key: p.Key, SetupKey: p.SetupKey, IP: p.IP, diff --git a/management/server/peer_test.go b/management/server/peer_test.go index 36e96df4311..9d5a8bfb99d 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -369,8 +369,8 @@ func TestAccountManager_GetPeerNetwork(t *testing.T) { return } - if account.Network.Id != network.Id { - t.Errorf("expecting Account Networks ID to be equal, got %s expected %s", network.Id, account.Network.Id) + if account.Network.Identifier != network.Identifier { + t.Errorf("expecting Account Networks ID to be equal, got %s expected %s", network.Identifier, account.Network.Identifier) } } diff --git a/management/server/personal_access_token.go b/management/server/personal_access_token.go index c7deca9dee4..f466661120f 100644 --- a/management/server/personal_access_token.go +++ b/management/server/personal_access_token.go @@ -26,7 +26,9 @@ const ( // PersonalAccessToken holds all information about a PAT including a hashed version of it for verification type PersonalAccessToken struct { - ID string + ID string `gorm:"primaryKey"` + // User is a reference to Account that this object belongs + UserID string `gorm:"index"` Name string HashedToken string ExpirationDate time.Time diff --git a/management/server/policy.go b/management/server/policy.go index 308a5c3c0db..d470ab4bf72 100644 --- a/management/server/policy.go +++ b/management/server/policy.go @@ -63,7 +63,10 @@ type PolicyUpdateOperation struct { // PolicyRule is the metadata of the policy type PolicyRule struct { // ID of the policy rule - ID string + ID string `gorm:"primaryKey"` + + // PolicyID is a reference to Policy that this object belongs + PolicyID string `json:"-" gorm:"index"` // Name of the rule visible in the UI Name string @@ -78,10 +81,10 @@ type PolicyRule struct { Action PolicyTrafficActionType // Destinations policy destination groups - Destinations []string + Destinations []string `gorm:"serializer:json"` // Sources policy source groups - Sources []string + Sources []string `gorm:"serializer:json"` // Bidirectional define if the rule is applicable in both directions, sources, and destinations Bidirectional bool @@ -90,7 +93,7 @@ type PolicyRule struct { Protocol PolicyRuleProtocolType // Ports or it ranges list - Ports []string + Ports []string `gorm:"serializer:json"` } // Copy returns a copy of a policy rule @@ -128,8 +131,11 @@ func (pm *PolicyRule) ToRule() *Rule { // Policy of the Rego query type Policy struct { - // ID of the policy - ID string + // ID of the policy' + ID string `gorm:"primaryKey"` + + // AccountID is a reference to Account that this object belongs + AccountID string `json:"-" gorm:"index"` // Name of the Policy Name string @@ -141,7 +147,7 @@ type Policy struct { Enabled bool // Rules of the policy - Rules []*PolicyRule + Rules []*PolicyRule `gorm:"foreignKey:PolicyID;references:id"` } // Copy returns a copy of the policy. @@ -201,7 +207,6 @@ type FirewallRule struct { // This function returns the list of peers and firewall rules that are applicable to a given peer. func (a *Account) getPeerConnectionResources(peerID string) ([]*Peer, []*FirewallRule) { generateResources, getAccumulatedResources := a.connResourcesGenerator() - for _, policy := range a.Policies { if !policy.Enabled { continue diff --git a/management/server/route_test.go b/management/server/route_test.go index 00ef3e93a4d..efd73d6c2d1 100644 --- a/management/server/route_test.go +++ b/management/server/route_test.go @@ -1017,7 +1017,7 @@ func createRouterManager(t *testing.T) (*DefaultAccountManager, error) { func createRouterStore(t *testing.T) (Store, error) { dataDir := t.TempDir() - store, err := NewFileStore(dataDir, nil) + store, err := NewStoreFromJson(dataDir, nil) if err != nil { return nil, err } diff --git a/management/server/rule.go b/management/server/rule.go index cb85d633d25..19085840cc7 100644 --- a/management/server/rule.go +++ b/management/server/rule.go @@ -25,6 +25,9 @@ type Rule struct { // ID of the rule ID string + // AccountID is a reference to Account that this object belongs + AccountID string `json:"-" gorm:"index"` + // Name of the rule visible in the UI Name string @@ -35,10 +38,10 @@ type Rule struct { Disabled bool // Source list of groups IDs of peers - Source []string + Source []string `gorm:"serializer:json"` // Destination list of groups IDs of peers - Destination []string + Destination []string `gorm:"serializer:json"` // Flow of the traffic allowed by the rule Flow TrafficFlowType diff --git a/management/server/setupkey.go b/management/server/setupkey.go index 6e626d08411..a33f537a7e6 100644 --- a/management/server/setupkey.go +++ b/management/server/setupkey.go @@ -68,13 +68,15 @@ type SetupKeyType string // SetupKey represents a pre-authorized key used to register machines (peers) type SetupKey struct { - Id string + Id string + // AccountID is a reference to Account that this object belongs + AccountID string `json:"-" gorm:"index"` Key string Name string Type SetupKeyType CreatedAt time.Time ExpiresAt time.Time - UpdatedAt time.Time + UpdatedAt time.Time `gorm:"autoUpdateTime:false"` // Revoked indicates whether the key was revoked or not (we don't remove them for tracking purposes) Revoked bool // UsedTimes indicates how many times the key was used @@ -82,7 +84,7 @@ type SetupKey struct { // LastUsed last time the key was used for peer registration LastUsed time.Time // AutoGroups is a list of Group IDs that are auto assigned to a Peer when it uses this key to register - AutoGroups []string + AutoGroups []string `gorm:"serializer:json"` // UsageLimit indicates the number of times this key can be used to enroll a machine. // The value of 0 indicates the unlimited usage. UsageLimit int @@ -99,6 +101,7 @@ func (key *SetupKey) Copy() *SetupKey { } return &SetupKey{ Id: key.Id, + AccountID: key.AccountID, Key: key.Key, Name: key.Name, Type: key.Type, diff --git a/management/server/sqlite_store.go b/management/server/sqlite_store.go new file mode 100644 index 00000000000..dfe6c3dfa95 --- /dev/null +++ b/management/server/sqlite_store.go @@ -0,0 +1,457 @@ +package server + +import ( + "path/filepath" + "runtime" + "strings" + "sync" + "time" + + nbdns "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/management/server/status" + "github.com/netbirdio/netbird/management/server/telemetry" + "github.com/netbirdio/netbird/route" + log "github.com/sirupsen/logrus" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "gorm.io/gorm/logger" +) + +// SqliteStore represents an account storage backed by a Sqlite DB persisted to disk +type SqliteStore struct { + db *gorm.DB + storeFile string + accountLocks sync.Map + globalAccountLock sync.Mutex + metrics telemetry.AppMetrics + installationPK int +} + +type installation struct { + ID uint `gorm:"primaryKey"` + InstallationIDValue string +} + +// NewSqliteStore restores a store from the file located in the datadir +func NewSqliteStore(dataDir string, metrics telemetry.AppMetrics) (*SqliteStore, error) { + storeStr := "store.db?cache=shared" + if runtime.GOOS == "windows" { + // Vo avoid `The process cannot access the file because it is being used by another process` on Windows + storeStr = "store.db" + } + + file := filepath.Join(dataDir, storeStr) + db, err := gorm.Open(sqlite.Open(file), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + PrepareStmt: true, + }) + if err != nil { + return nil, err + } + + sql, err := db.DB() + if err != nil { + return nil, err + } + conns := runtime.NumCPU() + sql.SetMaxOpenConns(conns) // TODO: make it configurable + + err = db.AutoMigrate( + &SetupKey{}, &Peer{}, &User{}, &PersonalAccessToken{}, &Group{}, &Rule{}, + &Account{}, &Policy{}, &PolicyRule{}, &route.Route{}, &nbdns.NameServerGroup{}, + &installation{}, + ) + if err != nil { + return nil, err + } + + return &SqliteStore{db: db, storeFile: file, metrics: metrics, installationPK: 1}, nil +} + +// NewSqliteStoreFromFileStore restores a store from FileStore and stores SQLite DB in the file located in datadir +func NewSqliteStoreFromFileStore(filestore *FileStore, dataDir string, metrics telemetry.AppMetrics) (*SqliteStore, error) { + store, err := NewSqliteStore(dataDir, metrics) + if err != nil { + return nil, err + } + + err = store.SaveInstallationID(filestore.InstallationID) + if err != nil { + return nil, err + } + + for _, account := range filestore.GetAllAccounts() { + err := store.SaveAccount(account) + if err != nil { + return nil, err + } + } + + return store, nil +} + +// AcquireGlobalLock acquires global lock across all the accounts and returns a function that releases the lock +func (s *SqliteStore) AcquireGlobalLock() (unlock func()) { + log.Debugf("acquiring global lock") + start := time.Now() + s.globalAccountLock.Lock() + + unlock = func() { + s.globalAccountLock.Unlock() + log.Debugf("released global lock in %v", time.Since(start)) + } + + took := time.Since(start) + log.Debugf("took %v to acquire global lock", took) + if s.metrics != nil { + s.metrics.StoreMetrics().CountGlobalLockAcquisitionDuration(took) + } + + return unlock +} + +func (s *SqliteStore) AcquireAccountLock(accountID string) (unlock func()) { + log.Debugf("acquiring lock for account %s", accountID) + + start := time.Now() + value, _ := s.accountLocks.LoadOrStore(accountID, &sync.Mutex{}) + mtx := value.(*sync.Mutex) + mtx.Lock() + + unlock = func() { + mtx.Unlock() + log.Debugf("released lock for account %s in %v", accountID, time.Since(start)) + } + + return unlock +} + +func (s *SqliteStore) SaveAccount(account *Account) error { + start := time.Now() + + for _, key := range account.SetupKeys { + account.SetupKeysG = append(account.SetupKeysG, *key) + } + + for id, peer := range account.Peers { + peer.ID = id + account.PeersG = append(account.PeersG, *peer) + } + + for id, user := range account.Users { + user.Id = id + for id, pat := range user.PATs { + pat.ID = id + user.PATsG = append(user.PATsG, *pat) + } + account.UsersG = append(account.UsersG, *user) + } + + for id, group := range account.Groups { + group.ID = id + account.GroupsG = append(account.GroupsG, *group) + } + + for id, rule := range account.Rules { + rule.ID = id + account.RulesG = append(account.RulesG, *rule) + } + + for id, route := range account.Routes { + route.ID = id + account.RoutesG = append(account.RoutesG, *route) + } + + for id, ns := range account.NameServerGroups { + ns.ID = id + account.NameServerGroupsG = append(account.NameServerGroupsG, *ns) + } + + err := s.db.Transaction(func(tx *gorm.DB) error { + result := tx.Select(clause.Associations).Delete(account.Policies, "account_id = ?", account.Id) + if result.Error != nil { + return result.Error + } + + result = tx.Select(clause.Associations).Delete(account.UsersG, "account_id = ?", account.Id) + if result.Error != nil { + return result.Error + } + + result = tx.Select(clause.Associations).Delete(account) + if result.Error != nil { + return result.Error + } + + result = tx. + Session(&gorm.Session{FullSaveAssociations: true}). + Clauses(clause.OnConflict{UpdateAll: true}).Create(account) + if result.Error != nil { + return result.Error + } + return nil + }) + + took := time.Since(start) + if s.metrics != nil { + s.metrics.StoreMetrics().CountPersistenceDuration(took) + } + log.Debugf("took %d ms to persist an account to the SQLite", took.Milliseconds()) + + return err +} + +func (s *SqliteStore) SaveInstallationID(ID string) error { + installation := installation{InstallationIDValue: ID} + installation.ID = uint(s.installationPK) + + return s.db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&installation).Error +} + +func (s *SqliteStore) GetInstallationID() string { + var installation installation + + if result := s.db.First(&installation, "id = ?", s.installationPK); result.Error != nil { + return "" + } + + return installation.InstallationIDValue +} + +func (s *SqliteStore) SavePeerStatus(accountID, peerID string, peerStatus PeerStatus) error { + var peer Peer + + result := s.db.First(&peer, "account_id = ? and id = ?", accountID, peerID) + if result.Error != nil { + return status.Errorf(status.NotFound, "peer %s not found", peerID) + } + + peer.Status = &peerStatus + + return s.db.Save(peer).Error +} + +// DeleteHashedPAT2TokenIDIndex is noop in Sqlite +func (s *SqliteStore) DeleteHashedPAT2TokenIDIndex(hashedToken string) error { + return nil +} + +// DeleteTokenID2UserIDIndex is noop in Sqlite +func (s *SqliteStore) DeleteTokenID2UserIDIndex(tokenID string) error { + return nil +} + +func (s *SqliteStore) GetAccountByPrivateDomain(domain string) (*Account, error) { + var account Account + + result := s.db.First(&account, "domain = ?", strings.ToLower(domain)) + if result.Error != nil { + return nil, status.Errorf(status.NotFound, "account not found: provided domain is not registered or is not private") + } + + // TODO: rework to not call GetAccount + return s.GetAccount(account.Id) +} + +func (s *SqliteStore) GetAccountBySetupKey(setupKey string) (*Account, error) { + var key SetupKey + result := s.db.Select("account_id").First(&key, "key = ?", strings.ToUpper(setupKey)) + if result.Error != nil { + return nil, status.Errorf(status.NotFound, "account not found: index lookup failed") + } + + if key.AccountID == "" { + return nil, status.Errorf(status.NotFound, "account not found: index lookup failed") + } + + return s.GetAccount(key.AccountID) +} + +func (s *SqliteStore) GetTokenIDByHashedToken(hashedToken string) (string, error) { + var token PersonalAccessToken + result := s.db.First(&token, "hashed_token = ?", hashedToken) + if result.Error != nil { + return "", status.Errorf(status.NotFound, "account not found: index lookup failed") + } + + return token.ID, nil +} + +func (s *SqliteStore) GetUserByTokenID(tokenID string) (*User, error) { + var token PersonalAccessToken + result := s.db.First(&token, "id = ?", tokenID) + if result.Error != nil { + return nil, status.Errorf(status.NotFound, "account not found: index lookup failed") + } + + if token.UserID == "" { + return nil, status.Errorf(status.NotFound, "account not found: index lookup failed") + } + + var user User + result = s.db.Preload("PATsG").First(&user, "id = ?", token.UserID) + if result.Error != nil { + return nil, status.Errorf(status.NotFound, "account not found: index lookup failed") + } + + user.PATs = make(map[string]*PersonalAccessToken, len(user.PATsG)) + for _, pat := range user.PATsG { + user.PATs[pat.ID] = &pat + } + + return &user, nil +} + +func (s *SqliteStore) GetAllAccounts() (all []*Account) { + var accounts []Account + result := s.db.Find(&accounts) + if result.Error != nil { + return all + } + + for _, account := range accounts { + if acc, err := s.GetAccount(account.Id); err == nil { + all = append(all, acc) + } + } + + return all +} + +func (s *SqliteStore) GetAccount(accountID string) (*Account, error) { + var account Account + + result := s.db.Model(&account). + Preload("UsersG.PATsG"). // have to be specifies as this is nester reference + Preload(clause.Associations). + First(&account, "id = ?", accountID) + if result.Error != nil { + return nil, status.Errorf(status.NotFound, "account not found") + } + + // we have to manually preload policy rules as it seems that gorm preloading doesn't do it for us + for i, policy := range account.Policies { + var rules []*PolicyRule + err := s.db.Model(&PolicyRule{}).Find(&rules, "policy_id = ?", policy.ID).Error + if err != nil { + return nil, status.Errorf(status.NotFound, "account not found") + } + account.Policies[i].Rules = rules + } + + account.SetupKeys = make(map[string]*SetupKey, len(account.SetupKeysG)) + for _, key := range account.SetupKeysG { + account.SetupKeys[key.Key] = key.Copy() + } + account.SetupKeysG = nil + + account.Peers = make(map[string]*Peer, len(account.PeersG)) + for _, peer := range account.PeersG { + account.Peers[peer.ID] = peer.Copy() + } + account.PeersG = nil + + account.Users = make(map[string]*User, len(account.UsersG)) + for _, user := range account.UsersG { + user.PATs = make(map[string]*PersonalAccessToken, len(user.PATs)) + for _, pat := range user.PATsG { + user.PATs[pat.ID] = pat.Copy() + } + account.Users[user.Id] = user.Copy() + } + account.UsersG = nil + + account.Groups = make(map[string]*Group, len(account.GroupsG)) + for _, group := range account.GroupsG { + account.Groups[group.ID] = group.Copy() + } + account.GroupsG = nil + + account.Rules = make(map[string]*Rule, len(account.RulesG)) + for _, rule := range account.RulesG { + account.Rules[rule.ID] = rule.Copy() + } + account.RulesG = nil + + account.Routes = make(map[string]*route.Route, len(account.RoutesG)) + for _, route := range account.RoutesG { + account.Routes[route.ID] = route.Copy() + } + account.RoutesG = nil + + account.NameServerGroups = make(map[string]*nbdns.NameServerGroup, len(account.NameServerGroupsG)) + for _, ns := range account.NameServerGroupsG { + account.NameServerGroups[ns.ID] = ns.Copy() + } + account.NameServerGroupsG = nil + + return &account, nil +} + +func (s *SqliteStore) GetAccountByUser(userID string) (*Account, error) { + var user User + result := s.db.Select("account_id").First(&user, "id = ?", userID) + if result.Error != nil { + return nil, status.Errorf(status.NotFound, "account not found: index lookup failed") + } + + if user.AccountID == "" { + return nil, status.Errorf(status.NotFound, "account not found: index lookup failed") + } + + return s.GetAccount(user.AccountID) +} + +func (s *SqliteStore) GetAccountByPeerID(peerID string) (*Account, error) { + var peer Peer + result := s.db.Select("account_id").First(&peer, "id = ?", peerID) + if result.Error != nil { + return nil, status.Errorf(status.NotFound, "account not found: index lookup failed") + } + + if peer.AccountID == "" { + return nil, status.Errorf(status.NotFound, "account not found: index lookup failed") + } + + return s.GetAccount(peer.AccountID) +} + +func (s *SqliteStore) GetAccountByPeerPubKey(peerKey string) (*Account, error) { + var peer Peer + + result := s.db.Select("account_id").First(&peer, "key = ?", peerKey) + if result.Error != nil { + return nil, status.Errorf(status.NotFound, "account not found: index lookup failed") + } + + if peer.AccountID == "" { + return nil, status.Errorf(status.NotFound, "account not found: index lookup failed") + } + + return s.GetAccount(peer.AccountID) +} + +// SaveUserLastLogin stores the last login time for a user in DB. +func (s *SqliteStore) SaveUserLastLogin(accountID, userID string, lastLogin time.Time) error { + var peer Peer + + result := s.db.First(&peer, "account_id = ? and user_id = ?", accountID, userID) + if result.Error != nil { + return status.Errorf(status.NotFound, "user %s not found", userID) + } + + peer.LastLogin = lastLogin + + return s.db.Save(peer).Error +} + +// Close is noop in Sqlite +func (s *SqliteStore) Close() error { + return nil +} + +// GetStoreKind returns SqliteStoreKind +func (s *SqliteStore) GetStoreKind() StoreKind { + return SqliteStoreKind +} diff --git a/management/server/sqlite_store_test.go b/management/server/sqlite_store_test.go new file mode 100644 index 00000000000..4a16e25255d --- /dev/null +++ b/management/server/sqlite_store_test.go @@ -0,0 +1,229 @@ +package server + +import ( + "fmt" + "net" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/google/uuid" + "github.com/netbirdio/netbird/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSqlite_NewStore(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("The SQLite store is not properly supported by Windows yet") + } + + store := newSqliteStore(t) + + if len(store.GetAllAccounts()) != 0 { + t.Errorf("expected to create a new empty Accounts map when creating a new FileStore") + } +} + +func TestSqlite_SaveAccount(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("The SQLite store is not properly supported by Windows yet") + } + + store := newSqliteStore(t) + + account := newAccountWithId("account_id", "testuser", "") + setupKey := GenerateDefaultSetupKey() + account.SetupKeys[setupKey.Key] = setupKey + account.Peers["testpeer"] = &Peer{ + Key: "peerkey", + SetupKey: "peerkeysetupkey", + IP: net.IP{127, 0, 0, 1}, + Meta: PeerSystemMeta{}, + Name: "peer name", + Status: &PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, + } + + err := store.SaveAccount(account) + require.NoError(t, err) + + account2 := newAccountWithId("account_id2", "testuser2", "") + setupKey = GenerateDefaultSetupKey() + account2.SetupKeys[setupKey.Key] = setupKey + account2.Peers["testpeer2"] = &Peer{ + Key: "peerkey2", + SetupKey: "peerkeysetupkey2", + IP: net.IP{127, 0, 0, 2}, + Meta: PeerSystemMeta{}, + Name: "peer name 2", + Status: &PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, + } + + err = store.SaveAccount(account2) + require.NoError(t, err) + + if len(store.GetAllAccounts()) != 2 { + t.Errorf("expecting 2 Accounts to be stored after SaveAccount()") + } + + a, err := store.GetAccount(account.Id) + if a == nil { + t.Errorf("expecting Account to be stored after SaveAccount(): %v", err) + } + + if a != nil && len(a.Policies) != 1 { + t.Errorf("expecting Account to have one policy stored after SaveAccount(), got %d", len(a.Policies)) + } + + if a != nil && len(a.Policies[0].Rules) != 1 { + t.Errorf("expecting Account to have one policy rule stored after SaveAccount(), got %d", len(a.Policies[0].Rules)) + return + } + + if a, err := store.GetAccountByPeerPubKey("peerkey"); a == nil { + t.Errorf("expecting PeerKeyID2AccountID index updated after SaveAccount(): %v", err) + } + + if a, err := store.GetAccountByUser("testuser"); a == nil { + t.Errorf("expecting UserID2AccountID index updated after SaveAccount(): %v", err) + } + + if a, err := store.GetAccountByPeerID("testpeer"); a == nil { + t.Errorf("expecting PeerID2AccountID index updated after SaveAccount(): %v", err) + } + + if a, err := store.GetAccountBySetupKey(setupKey.Key); a == nil { + t.Errorf("expecting SetupKeyID2AccountID index updated after SaveAccount(): %v", err) + } +} + +func TestSqlite_SavePeerStatus(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("The SQLite store is not properly supported by Windows yet") + } + + store := newSqliteStoreFromFile(t, "testdata/store.json") + + account, err := store.GetAccount("bf1c8084-ba50-4ce7-9439-34653001fc3b") + require.NoError(t, err) + + // save status of non-existing peer + newStatus := PeerStatus{Connected: true, LastSeen: time.Now().UTC()} + err = store.SavePeerStatus(account.Id, "non-existing-peer", newStatus) + assert.Error(t, err) + + // save new status of existing peer + account.Peers["testpeer"] = &Peer{ + Key: "peerkey", + ID: "testpeer", + SetupKey: "peerkeysetupkey", + IP: net.IP{127, 0, 0, 1}, + Meta: PeerSystemMeta{}, + Name: "peer name", + Status: &PeerStatus{Connected: false, LastSeen: time.Now().UTC()}, + } + + err = store.SaveAccount(account) + require.NoError(t, err) + + err = store.SavePeerStatus(account.Id, "testpeer", newStatus) + require.NoError(t, err) + + account, err = store.GetAccount(account.Id) + require.NoError(t, err) + + actual := account.Peers["testpeer"].Status + assert.Equal(t, newStatus, *actual) +} + +func TestSqlite_TestGetAccountByPrivateDomain(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("The SQLite store is not properly supported by Windows yet") + } + + store := newSqliteStoreFromFile(t, "testdata/store.json") + + existingDomain := "test.com" + + account, err := store.GetAccountByPrivateDomain(existingDomain) + require.NoError(t, err, "should found account") + require.Equal(t, existingDomain, account.Domain, "domains should match") + + _, err = store.GetAccountByPrivateDomain("missing-domain.com") + require.Error(t, err, "should return error on domain lookup") +} + +func TestSqlite_GetTokenIDByHashedToken(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("The SQLite store is not properly supported by Windows yet") + } + + store := newSqliteStoreFromFile(t, "testdata/store.json") + + hashed := "SoMeHaShEdToKeN" + id := "9dj38s35-63fb-11ec-90d6-0242ac120003" + + token, err := store.GetTokenIDByHashedToken(hashed) + require.NoError(t, err) + require.Equal(t, id, token) +} + +func TestSqlite_GetUserByTokenID(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("The SQLite store is not properly supported by Windows yet") + } + + store := newSqliteStoreFromFile(t, "testdata/store.json") + + id := "9dj38s35-63fb-11ec-90d6-0242ac120003" + + user, err := store.GetUserByTokenID(id) + require.NoError(t, err) + require.Equal(t, id, user.PATs[id].ID) +} + +func newSqliteStore(t *testing.T) *SqliteStore { + t.Helper() + + store, err := NewSqliteStore(t.TempDir(), nil) + require.NoError(t, err) + require.NotNil(t, store) + + return store +} + +func newSqliteStoreFromFile(t *testing.T, filename string) *SqliteStore { + t.Helper() + + storeDir := t.TempDir() + + err := util.CopyFileContents(filename, filepath.Join(storeDir, "store.json")) + require.NoError(t, err) + + fStore, err := NewFileStore(storeDir, nil) + require.NoError(t, err) + + store, err := NewSqliteStoreFromFileStore(fStore, storeDir, nil) + require.NoError(t, err) + require.NotNil(t, store) + + return store +} + +func newAccount(store Store, id int) error { + str := fmt.Sprintf("%s-%d", uuid.New().String(), id) + account := newAccountWithId(str, str+"-testuser", "example.com") + setupKey := GenerateDefaultSetupKey() + account.SetupKeys[setupKey.Key] = setupKey + account.Peers["p"+str] = &Peer{ + Key: "peerkey" + str, + SetupKey: "peerkeysetupkey", + IP: net.IP{127, 0, 0, 1}, + Meta: PeerSystemMeta{}, + Name: "peer name", + Status: &PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, + } + + return store.SaveAccount(account) +} diff --git a/management/server/store.go b/management/server/store.go index 9ebe4123517..6606c91e63d 100644 --- a/management/server/store.go +++ b/management/server/store.go @@ -1,6 +1,12 @@ package server -import "time" +import ( + "fmt" + "os" + "time" + + "github.com/netbirdio/netbird/management/server/telemetry" +) type Store interface { GetAllAccounts() []*Account @@ -25,4 +31,63 @@ type Store interface { SaveUserLastLogin(accountID, userID string, lastLogin time.Time) error // Close should close the store persisting all unsaved data. Close() error + // GetStoreKind should return StoreKind of the current store implementation. + // This is also a method of metrics.DataSource interface. + GetStoreKind() StoreKind +} + +type StoreKind string + +const ( + FileStoreKind StoreKind = "JsonFile" + SqliteStoreKind StoreKind = "Sqlite" +) + +func GetStoreKindFromEnv() StoreKind { + kind, ok := os.LookupEnv("NETBIRD_STORE_KIND") + if !ok { + return FileStoreKind + } + + value := StoreKind(kind) + + if value == FileStoreKind || value == SqliteStoreKind { + return value + } + + return FileStoreKind +} + +func NewStore(kind StoreKind, dataDir string, metrics telemetry.AppMetrics) (Store, error) { + if kind == "" { + // fallback to env. Normally this only should be used from tests + kind = GetStoreKindFromEnv() + } + switch kind { + case FileStoreKind: + return NewFileStore(dataDir, metrics) + case SqliteStoreKind: + return NewSqliteStore(dataDir, metrics) + default: + return nil, fmt.Errorf("unsupported kind of store %s", kind) + } +} + +func NewStoreFromJson(dataDir string, metrics telemetry.AppMetrics) (Store, error) { + fstore, err := NewFileStore(dataDir, nil) + if err != nil { + return nil, err + } + + kind := GetStoreKindFromEnv() + + switch kind { + case FileStoreKind: + return fstore, nil + case SqliteStoreKind: + return NewSqliteStoreFromFileStore(fstore, dataDir, metrics) + default: + return nil, fmt.Errorf("unsupported kind of store %s", kind) + } + } diff --git a/management/server/store_test.go b/management/server/store_test.go new file mode 100644 index 00000000000..72bbaf9498e --- /dev/null +++ b/management/server/store_test.go @@ -0,0 +1,88 @@ +package server + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +type benchCase struct { + name string + storeFn func(b *testing.B) Store + size int +} + +var newFs = func(b *testing.B) Store { + store, _ := NewFileStore(b.TempDir(), nil) + return store +} + +var newSqlite = func(b *testing.B) Store { + store, _ := NewSqliteStore(b.TempDir(), nil) + return store +} + +func BenchmarkTest_StoreWrite(b *testing.B) { + cases := []benchCase{ + {name: "FileStore_Write", storeFn: newFs, size: 100}, + {name: "SqliteStore_Write", storeFn: newSqlite, size: 100}, + {name: "FileStore_Write", storeFn: newFs, size: 500}, + {name: "SqliteStore_Write", storeFn: newSqlite, size: 500}, + {name: "FileStore_Write", storeFn: newFs, size: 1000}, + {name: "SqliteStore_Write", storeFn: newSqlite, size: 1000}, + {name: "FileStore_Write", storeFn: newFs, size: 2000}, + {name: "SqliteStore_Write", storeFn: newSqlite, size: 2000}, + } + + for _, c := range cases { + name := fmt.Sprintf("%s_%d", c.name, c.size) + store := c.storeFn(b) + + for i := 0; i < c.size; i++ { + _ = newAccount(store, i) + } + + b.Run(name, func(b *testing.B) { + b.RunParallel(func(pb *testing.PB) { + i := c.size + for pb.Next() { + i++ + err := newAccount(store, i) + require.NoError(b, err) + } + }) + }) + } +} + +func BenchmarkTest_StoreRead(b *testing.B) { + cases := []benchCase{ + {name: "FileStore_Read", storeFn: newFs, size: 100}, + {name: "SqliteStore_Read", storeFn: newSqlite, size: 100}, + {name: "FileStore_Read", storeFn: newFs, size: 500}, + {name: "SqliteStore_Read", storeFn: newSqlite, size: 500}, + {name: "FileStore_Read", storeFn: newFs, size: 1000}, + {name: "SqliteStore_Read", storeFn: newSqlite, size: 1000}, + } + + for _, c := range cases { + name := fmt.Sprintf("%s_%d", c.name, c.size) + store := c.storeFn(b) + + for i := 0; i < c.size; i++ { + _ = newAccount(store, i) + } + + accounts := store.GetAllAccounts() + id := accounts[c.size-1].Id + + b.Run(name, func(b *testing.B) { + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + _, _ = store.GetAccount(id) + } + }) + }) + } +} diff --git a/management/server/testdata/store.json b/management/server/testdata/store.json index ecde766c374..1fa4e3a9a32 100644 --- a/management/server/testdata/store.json +++ b/management/server/testdata/store.json @@ -2,52 +2,87 @@ "Accounts": { "bf1c8084-ba50-4ce7-9439-34653001fc3b": { "Id": "bf1c8084-ba50-4ce7-9439-34653001fc3b", + "CreatedBy": "", "Domain": "test.com", "DomainCategory": "private", "IsDomainPrimaryAccount": true, "SetupKeys": { "A2C8E62B-38F5-4553-B31E-DD66C696CEBB": { + "Id": "", + "AccountID": "", "Key": "A2C8E62B-38F5-4553-B31E-DD66C696CEBB", "Name": "Default key", "Type": "reusable", "CreatedAt": "2021-08-19T20:46:20.005936822+02:00", "ExpiresAt": "2321-09-18T20:46:20.005936822+02:00", + "UpdatedAt": "0001-01-01T00:00:00Z", "Revoked": false, - "UsedTimes": 0 - + "UsedTimes": 0, + "LastUsed": "0001-01-01T00:00:00Z", + "AutoGroups": null, + "UsageLimit": 0, + "Ephemeral": false } }, "Network": { - "Id": "af1c8024-ha40-4ce2-9418-34653101fc3c", + "id": "af1c8024-ha40-4ce2-9418-34653101fc3c", "Net": { "IP": "100.64.0.0", "Mask": "//8AAA==" }, - "Dns": null + "Dns": "", + "Serial": 0 }, "Peers": {}, "Users": { "edafee4e-63fb-11ec-90d6-0242ac120003": { "Id": "edafee4e-63fb-11ec-90d6-0242ac120003", + "AccountID": "", "Role": "admin", - "PATs": {} + "IsServiceUser": false, + "ServiceUserName": "", + "AutoGroups": null, + "PATs": {}, + "Blocked": false, + "LastLogin": "0001-01-01T00:00:00Z" }, "f4f6d672-63fb-11ec-90d6-0242ac120003": { "Id": "f4f6d672-63fb-11ec-90d6-0242ac120003", + "AccountID": "", "Role": "user", + "IsServiceUser": false, + "ServiceUserName": "", + "AutoGroups": null, "PATs": { "9dj38s35-63fb-11ec-90d6-0242ac120003": { - "ID":"9dj38s35-63fb-11ec-90d6-0242ac120003", - "Description":"some Description", - "HashedToken":"SoMeHaShEdToKeN", - "ExpirationDate":"2023-02-27T00:00:00Z", - "CreatedBy":"user", - "CreatedAt":"2023-01-01T00:00:00Z", - "LastUsed":"2023-02-01T00:00:00Z" + "ID": "9dj38s35-63fb-11ec-90d6-0242ac120003", + "UserID": "", + "Name": "", + "HashedToken": "SoMeHaShEdToKeN", + "ExpirationDate": "2023-02-27T00:00:00Z", + "CreatedBy": "user", + "CreatedAt": "2023-01-01T00:00:00Z", + "LastUsed": "2023-02-01T00:00:00Z" } - } + }, + "Blocked": false, + "LastLogin": "0001-01-01T00:00:00Z" } + }, + "Groups": null, + "Rules": null, + "Policies": [], + "Routes": null, + "NameServerGroups": null, + "DNSSettings": null, + "Settings": { + "PeerLoginExpirationEnabled": false, + "PeerLoginExpiration": 86400000000000, + "GroupsPropagationEnabled": false, + "JWTGroupsEnabled": false, + "JWTGroupsClaimName": "" } } - } + }, + "InstallationID": "" } \ No newline at end of file diff --git a/management/server/user.go b/management/server/user.go index 3169c784f14..5858720805e 100644 --- a/management/server/user.go +++ b/management/server/user.go @@ -44,14 +44,17 @@ type UserRole string // User represents a user of the system type User struct { - Id string + Id string `gorm:"primaryKey"` + // AccountID is a reference to Account that this object belongs + AccountID string `json:"-" gorm:"index"` Role UserRole IsServiceUser bool // ServiceUserName is only set if IsServiceUser is true ServiceUserName string // AutoGroups is a list of Group IDs to auto-assign to peers registered by this user - AutoGroups []string - PATs map[string]*PersonalAccessToken + AutoGroups []string `gorm:"serializer:json"` + PATs map[string]*PersonalAccessToken `gorm:"-"` + PATsG []PersonalAccessToken `json:"-" gorm:"foreignKey:UserID;references:id"` // Blocked indicates whether the user is blocked. Blocked users can't use the system. Blocked bool // LastLogin is the last time the user logged in to IdP @@ -124,6 +127,7 @@ func (u *User) Copy() *User { } return &User{ Id: u.Id, + AccountID: u.AccountID, Role: u.Role, AutoGroups: autoGroups, IsServiceUser: u.IsServiceUser, diff --git a/management/server/user_test.go b/management/server/user_test.go index 1565814b81b..fdaffc693ea 100644 --- a/management/server/user_test.go +++ b/management/server/user_test.go @@ -251,6 +251,7 @@ func TestUser_Copy(t *testing.T) { // this is an imaginary case which will never be in DB this way user := User{ Id: "userId", + AccountID: "accountId", Role: "role", IsServiceUser: true, ServiceUserName: "servicename", @@ -291,6 +292,11 @@ func validateStruct(s interface{}) (err error) { field := structVal.Field(i) fieldName := structType.Field(i).Name + // skip gorm internal fields + if json, ok := structType.Field(i).Tag.Lookup("json"); ok && json == "-" { + continue + } + isSet := field.IsValid() && (!field.IsZero() || field.Type().String() == "bool") if !isSet { diff --git a/route/route.go b/route/route.go index eb7bcba2f32..194e0c80d0f 100644 --- a/route/route.go +++ b/route/route.go @@ -65,17 +65,19 @@ func ToPrefixType(prefix string) NetworkType { // Route represents a route type Route struct { - ID string - Network netip.Prefix + ID string `gorm:"primaryKey"` + // AccountID is a reference to Account that this object belongs + AccountID string `gorm:"index"` + Network netip.Prefix `gorm:"serializer:gob"` NetID string Description string Peer string - PeerGroups []string + PeerGroups []string `gorm:"serializer:gob"` NetworkType NetworkType Masquerade bool Metric int Enabled bool - Groups []string + Groups []string `gorm:"serializer:json"` } // EventMeta returns activity event meta related to the route From 46f5f148daae0b4f261daf3728fb880bcb95e32e Mon Sep 17 00:00:00 2001 From: Yury Gargay Date: Mon, 16 Oct 2023 11:19:39 +0200 Subject: [PATCH 06/28] Move StoreKind under own StoreConfig configuration and rename to Engine (#1219) * Move StoreKind under own StoreConfig configuration parameter * Rename StoreKind option to Engine * Rename StoreKind internal methods and types to Engine * Add template engine value test --------- Co-authored-by: Maycon Santos --- .github/workflows/golang-test-darwin.yml | 4 +- .github/workflows/golang-test-linux.yml | 8 ++-- .github/workflows/golang-test-windows.yml | 2 +- .../workflows/test-infrastructure-files.yml | 5 ++- infrastructure_files/base.setup.env | 6 ++- infrastructure_files/management.json.tmpl | 3 ++ infrastructure_files/tests/setup.env | 3 +- management/cmd/management.go | 2 +- management/server/config.go | 7 +++- management/server/file_store.go | 6 +-- management/server/metrics/selfhosted.go | 4 +- management/server/metrics/selfhosted_test.go | 10 ++--- management/server/sqlite_store.go | 6 +-- management/server/store.go | 41 ++++++++++--------- 14 files changed, 62 insertions(+), 45 deletions(-) diff --git a/.github/workflows/golang-test-darwin.yml b/.github/workflows/golang-test-darwin.yml index 8cd28bcb4c6..5998fab012d 100644 --- a/.github/workflows/golang-test-darwin.yml +++ b/.github/workflows/golang-test-darwin.yml @@ -14,7 +14,7 @@ jobs: test: strategy: matrix: - store: ['JsonFile', 'Sqlite'] + store: ['jsonfile', 'sqlite'] runs-on: macos-latest steps: - name: Install Go @@ -36,4 +36,4 @@ jobs: run: go mod tidy - name: Test - run: NETBIRD_STORE_KIND=${{ matrix.store }} go test -exec 'sudo --preserve-env=CI' -timeout 5m -p 1 ./... + run: NETBIRD_STORE_ENGINE=${{ matrix.store }} go test -exec 'sudo --preserve-env=CI' -timeout 5m -p 1 ./... diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml index 026779885f9..8015fb36a29 100644 --- a/.github/workflows/golang-test-linux.yml +++ b/.github/workflows/golang-test-linux.yml @@ -15,7 +15,7 @@ jobs: strategy: matrix: arch: ['386','amd64'] - store: ['JsonFile', 'Sqlite'] + store: ['jsonfile', 'sqlite'] runs-on: ubuntu-latest steps: - name: Install Go @@ -42,7 +42,7 @@ jobs: run: go mod tidy - name: Test - run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} NETBIRD_STORE_KIND=${{ matrix.store }} go test -exec 'sudo --preserve-env=CI' -timeout 5m -p 1 ./... + run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} NETBIRD_STORE_ENGINE=${{ matrix.store }} go test -exec 'sudo --preserve-env=CI' -timeout 5m -p 1 ./... test_client_on_docker: runs-on: ubuntu-20.04 @@ -102,10 +102,10 @@ jobs: run: docker run -t --cap-add=NET_ADMIN --privileged --rm -v $PWD:/ci -w /ci/client/firewall --entrypoint /busybox/sh gcr.io/distroless/base:debug -c /ci/nftablesmanager-testing.bin -test.timeout 5m -test.parallel 1 - name: Run Engine tests in docker with file store - run: docker run -t --cap-add=NET_ADMIN --privileged --rm -v $PWD:/ci -w /ci/client/internal -e NETBIRD_STORE_KIND="JsonFile" --entrypoint /busybox/sh gcr.io/distroless/base:debug -c /ci/engine-testing.bin -test.timeout 5m -test.parallel 1 + run: docker run -t --cap-add=NET_ADMIN --privileged --rm -v $PWD:/ci -w /ci/client/internal -e NETBIRD_STORE_ENGINE="jsonfile" --entrypoint /busybox/sh gcr.io/distroless/base:debug -c /ci/engine-testing.bin -test.timeout 5m -test.parallel 1 - name: Run Engine tests in docker with sqlite store - run: docker run -t --cap-add=NET_ADMIN --privileged --rm -v $PWD:/ci -w /ci/client/internal -e NETBIRD_STORE_KIND="Sqlite" --entrypoint /busybox/sh gcr.io/distroless/base:debug -c /ci/engine-testing.bin -test.timeout 5m -test.parallel 1 + run: docker run -t --cap-add=NET_ADMIN --privileged --rm -v $PWD:/ci -w /ci/client/internal -e NETBIRD_STORE_ENGINE="sqlite" --entrypoint /busybox/sh gcr.io/distroless/base:debug -c /ci/engine-testing.bin -test.timeout 5m -test.parallel 1 - name: Run Peer tests in docker run: docker run -t --cap-add=NET_ADMIN --privileged --rm -v $PWD:/ci -w /ci/client/internal/peer --entrypoint /busybox/sh gcr.io/distroless/base:debug -c /ci/peer-testing.bin -test.timeout 5m -test.parallel 1 \ No newline at end of file diff --git a/.github/workflows/golang-test-windows.yml b/.github/workflows/golang-test-windows.yml index 1fc84ff2acb..ec5576d8861 100644 --- a/.github/workflows/golang-test-windows.yml +++ b/.github/workflows/golang-test-windows.yml @@ -16,7 +16,7 @@ jobs: test: strategy: matrix: - store: ['JsonFile', 'Sqlite'] + store: ['jsonfile', 'sqlite'] runs-on: windows-latest steps: - name: Checkout code diff --git a/.github/workflows/test-infrastructure-files.yml b/.github/workflows/test-infrastructure-files.yml index c2c4f7598a6..da54ceaf507 100644 --- a/.github/workflows/test-infrastructure-files.yml +++ b/.github/workflows/test-infrastructure-files.yml @@ -56,6 +56,7 @@ jobs: CI_NETBIRD_IDP_MGMT_CLIENT_ID: testing.client.id CI_NETBIRD_IDP_MGMT_CLIENT_SECRET: testing.client.secret CI_NETBIRD_AUTH_SUPPORTED_SCOPES: "openid profile email offline_access api email_verified" + CI_NETBIRD_STORE_CONFIG_ENGINE: "sqlite" - name: check values working-directory: infrastructure_files @@ -81,6 +82,7 @@ jobs: CI_NETBIRD_IDP_MGMT_CLIENT_ID: testing.client.id CI_NETBIRD_IDP_MGMT_CLIENT_SECRET: testing.client.secret CI_NETBIRD_SIGNAL_PORT: 12345 + CI_NETBIRD_STORE_CONFIG_ENGINE: "sqlite" run: | grep AUTH_CLIENT_ID docker-compose.yml | grep $CI_NETBIRD_AUTH_CLIENT_ID @@ -97,7 +99,8 @@ jobs: grep NETBIRD_TOKEN_SOURCE docker-compose.yml | grep $CI_NETBIRD_TOKEN_SOURCE grep AuthUserIDClaim management.json | grep $CI_NETBIRD_AUTH_USER_ID_CLAIM grep -A 3 DeviceAuthorizationFlow management.json | grep -A 1 ProviderConfig | grep Audience | grep $CI_NETBIRD_AUTH_DEVICE_AUTH_AUDIENCE - grep -A 8 DeviceAuthorizationFlow management.json | grep -A 6 ProviderConfig | grep Scope | grep "$CI_NETBIRD_AUTH_DEVICE_AUTH_SCOPE" + grep -A 3 DeviceAuthorizationFlow management.json | grep -A 1 ProviderConfig | grep Audience | grep $CI_NETBIRD_AUTH_DEVICE_AUTH_AUDIENCE + grep Engine management.json | grep "$CI_NETBIRD_STORE_CONFIG_ENGINE" grep UseIDToken management.json | grep false grep -A 1 IdpManagerConfig management.json | grep ManagerType | grep $CI_NETBIRD_MGMT_IDP grep -A 3 IdpManagerConfig management.json | grep -A 1 ClientConfig | grep Issuer | grep $CI_NETBIRD_AUTH_AUTHORITY diff --git a/infrastructure_files/base.setup.env b/infrastructure_files/base.setup.env index f610a9691bc..210b3036404 100644 --- a/infrastructure_files/base.setup.env +++ b/infrastructure_files/base.setup.env @@ -55,6 +55,9 @@ NETBIRD_AUTH_PKCE_AUDIENCE=$NETBIRD_AUTH_AUDIENCE NETBIRD_DASH_AUTH_USE_AUDIENCE=${NETBIRD_DASH_AUTH_USE_AUDIENCE:-true} NETBIRD_DASH_AUTH_AUDIENCE=$NETBIRD_AUTH_AUDIENCE +# Store config +NETBIRD_STORE_CONFIG_ENGINE=${NETBIRD_STORE_CONFIG_ENGINE:-"jsonfile"} + # exports export NETBIRD_DOMAIN export NETBIRD_AUTH_CLIENT_ID @@ -97,4 +100,5 @@ export NETBIRD_AUTH_PKCE_AUTHORIZATION_ENDPOINT export NETBIRD_AUTH_PKCE_USE_ID_TOKEN export NETBIRD_AUTH_PKCE_AUDIENCE export NETBIRD_DASH_AUTH_USE_AUDIENCE -export NETBIRD_DASH_AUTH_AUDIENCE \ No newline at end of file +export NETBIRD_DASH_AUTH_AUDIENCE +export NETBIRD_STORE_CONFIG_ENGINE \ No newline at end of file diff --git a/infrastructure_files/management.json.tmpl b/infrastructure_files/management.json.tmpl index 847ce62223b..7a15bdd2cca 100644 --- a/infrastructure_files/management.json.tmpl +++ b/infrastructure_files/management.json.tmpl @@ -28,6 +28,9 @@ }, "Datadir": "", "DataStoreEncryptionKey": "$NETBIRD_DATASTORE_ENC_KEY", + "StoreConfig": { + "Engine": "$NETBIRD_STORE_CONFIG_ENGINE" + }, "HttpConfig": { "Address": "0.0.0.0:$NETBIRD_MGMT_API_PORT", "AuthIssuer": "$NETBIRD_AUTH_AUTHORITY", diff --git a/infrastructure_files/tests/setup.env b/infrastructure_files/tests/setup.env index b0999eb5122..f6e3b4a1505 100644 --- a/infrastructure_files/tests/setup.env +++ b/infrastructure_files/tests/setup.env @@ -22,4 +22,5 @@ NETBIRD_AUTH_DEVICE_AUTH_SCOPE="openid email" NETBIRD_MGMT_IDP=$CI_NETBIRD_MGMT_IDP NETBIRD_IDP_MGMT_CLIENT_ID=$CI_NETBIRD_IDP_MGMT_CLIENT_ID NETBIRD_IDP_MGMT_CLIENT_SECRET=$CI_NETBIRD_IDP_MGMT_CLIENT_SECRET -NETBIRD_SIGNAL_PORT=12345 \ No newline at end of file +NETBIRD_SIGNAL_PORT=12345 +NETBIRD_STORE_CONFIG_ENGINE=$CI_NETBIRD_STORE_CONFIG_ENGINE \ No newline at end of file diff --git a/management/cmd/management.go b/management/cmd/management.go index fda16566c15..9ad2b7274d5 100644 --- a/management/cmd/management.go +++ b/management/cmd/management.go @@ -126,7 +126,7 @@ var ( if err != nil { return err } - store, err := server.NewStore(config.StoreKind, config.Datadir, appMetrics) + store, err := server.NewStore(config.StoreConfig.Engine, config.Datadir, appMetrics) if err != nil { return fmt.Errorf("failed creating Store: %s: %v", config.Datadir, err) } diff --git a/management/server/config.go b/management/server/config.go index 19a71ff7aae..4fed93bba48 100644 --- a/management/server/config.go +++ b/management/server/config.go @@ -46,7 +46,7 @@ type Config struct { PKCEAuthorizationFlow *PKCEAuthorizationFlow - StoreKind StoreKind + StoreConfig StoreConfig } // GetAuthAudiences returns the audience from the http config and device authorization flow config @@ -138,6 +138,11 @@ type ProviderConfig struct { RedirectURLs []string } +// StoreConfig contains Store configuration +type StoreConfig struct { + Engine StoreEngine +} + // validateURL validates input http url func validateURL(httpURL string) bool { _, err := url.ParseRequestURI(httpURL) diff --git a/management/server/file_store.go b/management/server/file_store.go index c8d24433fa1..0bd137b42e6 100644 --- a/management/server/file_store.go +++ b/management/server/file_store.go @@ -615,7 +615,7 @@ func (s *FileStore) Close() error { return s.persist(s.storeFile) } -// GetStoreKind returns FileStoreKind -func (s *FileStore) GetStoreKind() StoreKind { - return FileStoreKind +// GetStoreEngine returns FileStoreEngine +func (s *FileStore) GetStoreEngine() StoreEngine { + return FileStoreEngine } diff --git a/management/server/metrics/selfhosted.go b/management/server/metrics/selfhosted.go index 59364b940e9..cf6b2e44041 100644 --- a/management/server/metrics/selfhosted.go +++ b/management/server/metrics/selfhosted.go @@ -48,7 +48,7 @@ type properties map[string]interface{} // DataSource metric data source type DataSource interface { GetAllAccounts() []*server.Account - GetStoreKind() server.StoreKind + GetStoreEngine() server.StoreEngine } // ConnManager peer connection manager that holds state for current active connections @@ -296,7 +296,7 @@ func (w *Worker) generateProperties() properties { metricsProperties["max_active_peer_version"] = maxActivePeerVersion metricsProperties["ui_clients"] = uiClient metricsProperties["idp_manager"] = w.idpManager - metricsProperties["store_kind"] = w.dataSource.GetStoreKind() + metricsProperties["store_engine"] = w.dataSource.GetStoreEngine() for protocol, count := range rulesProtocol { metricsProperties["rules_protocol_"+protocol] = count diff --git a/management/server/metrics/selfhosted_test.go b/management/server/metrics/selfhosted_test.go index f69c0f8f8ec..7717ff4094a 100644 --- a/management/server/metrics/selfhosted_test.go +++ b/management/server/metrics/selfhosted_test.go @@ -151,9 +151,9 @@ func (mockDatasource) GetAllAccounts() []*server.Account { } } -// GetStoreKind returns FileStoreKind -func (mockDatasource) GetStoreKind() server.StoreKind { - return server.FileStoreKind +// GetStoreEngine returns FileStoreEngine +func (mockDatasource) GetStoreEngine() server.StoreEngine { + return server.FileStoreEngine } // TestGenerateProperties tests and validate the properties generation by using the mockDatasource for the Worker.generateProperties @@ -242,7 +242,7 @@ func TestGenerateProperties(t *testing.T) { t.Errorf("expected 2 user_peers, got %d", properties["user_peers"]) } - if properties["store_kind"] != server.FileStoreKind { - t.Errorf("expected JsonFile, got %s", properties["store_kind"]) + if properties["store_engine"] != server.FileStoreEngine { + t.Errorf("expected JsonFile, got %s", properties["store_engine"]) } } diff --git a/management/server/sqlite_store.go b/management/server/sqlite_store.go index dfe6c3dfa95..97c759d8a2e 100644 --- a/management/server/sqlite_store.go +++ b/management/server/sqlite_store.go @@ -451,7 +451,7 @@ func (s *SqliteStore) Close() error { return nil } -// GetStoreKind returns SqliteStoreKind -func (s *SqliteStore) GetStoreKind() StoreKind { - return SqliteStoreKind +// GetStoreEngine returns SqliteStoreEngine +func (s *SqliteStore) GetStoreEngine() StoreEngine { + return SqliteStoreEngine } diff --git a/management/server/store.go b/management/server/store.go index 6606c91e63d..458912e97b1 100644 --- a/management/server/store.go +++ b/management/server/store.go @@ -3,6 +3,7 @@ package server import ( "fmt" "os" + "strings" "time" "github.com/netbirdio/netbird/management/server/telemetry" @@ -31,42 +32,43 @@ type Store interface { SaveUserLastLogin(accountID, userID string, lastLogin time.Time) error // Close should close the store persisting all unsaved data. Close() error - // GetStoreKind should return StoreKind of the current store implementation. + // GetStoreEngine should return StoreEngine of the current store implementation. // This is also a method of metrics.DataSource interface. - GetStoreKind() StoreKind + GetStoreEngine() StoreEngine } -type StoreKind string +type StoreEngine string const ( - FileStoreKind StoreKind = "JsonFile" - SqliteStoreKind StoreKind = "Sqlite" + FileStoreEngine StoreEngine = "jsonfile" + SqliteStoreEngine StoreEngine = "sqlite" ) -func GetStoreKindFromEnv() StoreKind { - kind, ok := os.LookupEnv("NETBIRD_STORE_KIND") +func getStoreEngineFromEnv() StoreEngine { + // NETBIRD_STORE_ENGINE supposed to be used in tests. Otherwise rely on the config file. + kind, ok := os.LookupEnv("NETBIRD_STORE_ENGINE") if !ok { - return FileStoreKind + return FileStoreEngine } - value := StoreKind(kind) + value := StoreEngine(strings.ToLower(kind)) - if value == FileStoreKind || value == SqliteStoreKind { + if value == FileStoreEngine || value == SqliteStoreEngine { return value } - return FileStoreKind + return FileStoreEngine } -func NewStore(kind StoreKind, dataDir string, metrics telemetry.AppMetrics) (Store, error) { +func NewStore(kind StoreEngine, dataDir string, metrics telemetry.AppMetrics) (Store, error) { if kind == "" { // fallback to env. Normally this only should be used from tests - kind = GetStoreKindFromEnv() + kind = getStoreEngineFromEnv() } switch kind { - case FileStoreKind: + case FileStoreEngine: return NewFileStore(dataDir, metrics) - case SqliteStoreKind: + case SqliteStoreEngine: return NewSqliteStore(dataDir, metrics) default: return nil, fmt.Errorf("unsupported kind of store %s", kind) @@ -79,15 +81,14 @@ func NewStoreFromJson(dataDir string, metrics telemetry.AppMetrics) (Store, erro return nil, err } - kind := GetStoreKindFromEnv() + kind := getStoreEngineFromEnv() switch kind { - case FileStoreKind: + case FileStoreEngine: return fstore, nil - case SqliteStoreKind: + case SqliteStoreEngine: return NewSqliteStoreFromFileStore(fstore, dataDir, metrics) default: - return nil, fmt.Errorf("unsupported kind of store %s", kind) + return nil, fmt.Errorf("unsupported store engine %s", kind) } - } From 73e57f17ea301210bb82610e8e00ff4cfe25172a Mon Sep 17 00:00:00 2001 From: guangwu Date: Mon, 16 Oct 2023 23:00:05 +0800 Subject: [PATCH 07/28] chore: pkg import only once (#1222) Signed-off-by: guoguangwu --- management/client/client_test.go | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/management/client/client_test.go b/management/client/client_test.go index b66dacc7382..889b7a13143 100644 --- a/management/client/client_test.go +++ b/management/client/client_test.go @@ -16,7 +16,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/netbirdio/netbird/encryption" - "github.com/netbirdio/netbird/management/proto" mgmtProto "github.com/netbirdio/netbird/management/proto" mgmt "github.com/netbirdio/netbird/management/server" "github.com/netbirdio/netbird/management/server/mock_server" @@ -95,8 +94,8 @@ func startMockManagement(t *testing.T) (*grpc.Server, net.Listener, *mock_server } mgmtMockServer := &mock_server.ManagementServiceServerMock{ - GetServerKeyFunc: func(context.Context, *proto.Empty) (*proto.ServerKeyResponse, error) { - response := &proto.ServerKeyResponse{ + GetServerKeyFunc: func(context.Context, *mgmtProto.Empty) (*mgmtProto.ServerKeyResponse, error) { + response := &mgmtProto.ServerKeyResponse{ Key: serverKey.PublicKey().String(), } return response, nil @@ -300,19 +299,19 @@ func Test_SystemMetaDataFromClient(t *testing.T) { log.Fatalf("error while getting server public key from testclient, %v", err) } - var actualMeta *proto.PeerSystemMeta + var actualMeta *mgmtProto.PeerSystemMeta var actualValidKey string var wg sync.WaitGroup wg.Add(1) - mgmtMockServer.LoginFunc = func(ctx context.Context, msg *proto.EncryptedMessage) (*proto.EncryptedMessage, error) { + mgmtMockServer.LoginFunc = func(ctx context.Context, msg *mgmtProto.EncryptedMessage) (*mgmtProto.EncryptedMessage, error) { peerKey, err := wgtypes.ParseKey(msg.GetWgPubKey()) if err != nil { log.Warnf("error while parsing peer's Wireguard public key %s on Sync request.", msg.WgPubKey) return nil, status.Errorf(codes.InvalidArgument, "provided wgPubKey %s is invalid", msg.WgPubKey) } - loginReq := &proto.LoginRequest{} + loginReq := &mgmtProto.LoginRequest{} err = encryption.DecryptMessage(peerKey, serverKey, msg.Body, loginReq) if err != nil { log.Fatal(err) @@ -322,7 +321,7 @@ func Test_SystemMetaDataFromClient(t *testing.T) { actualValidKey = loginReq.GetSetupKey() wg.Done() - loginResp := &proto.LoginResponse{} + loginResp := &mgmtProto.LoginResponse{} encryptedResp, err := encryption.EncryptMessage(peerKey, serverKey, loginResp) if err != nil { return nil, err @@ -343,7 +342,7 @@ func Test_SystemMetaDataFromClient(t *testing.T) { wg.Wait() - expectedMeta := &proto.PeerSystemMeta{ + expectedMeta := &mgmtProto.PeerSystemMeta{ Hostname: info.Hostname, GoOS: info.GoOS, Kernel: info.Kernel, @@ -374,12 +373,12 @@ func Test_GetDeviceAuthorizationFlow(t *testing.T) { log.Fatalf("error while creating testClient: %v", err) } - expectedFlowInfo := &proto.DeviceAuthorizationFlow{ + expectedFlowInfo := &mgmtProto.DeviceAuthorizationFlow{ Provider: 0, - ProviderConfig: &proto.ProviderConfig{ClientID: "client"}, + ProviderConfig: &mgmtProto.ProviderConfig{ClientID: "client"}, } - mgmtMockServer.GetDeviceAuthorizationFlowFunc = func(ctx context.Context, req *mgmtProto.EncryptedMessage) (*proto.EncryptedMessage, error) { + mgmtMockServer.GetDeviceAuthorizationFlowFunc = func(ctx context.Context, req *mgmtProto.EncryptedMessage) (*mgmtProto.EncryptedMessage, error) { encryptedResp, err := encryption.EncryptMessage(serverKey, client.key, expectedFlowInfo) if err != nil { return nil, err @@ -418,14 +417,14 @@ func Test_GetPKCEAuthorizationFlow(t *testing.T) { log.Fatalf("error while creating testClient: %v", err) } - expectedFlowInfo := &proto.PKCEAuthorizationFlow{ - ProviderConfig: &proto.ProviderConfig{ + expectedFlowInfo := &mgmtProto.PKCEAuthorizationFlow{ + ProviderConfig: &mgmtProto.ProviderConfig{ ClientID: "client", ClientSecret: "secret", }, } - mgmtMockServer.GetPKCEAuthorizationFlowFunc = func(ctx context.Context, req *mgmtProto.EncryptedMessage) (*proto.EncryptedMessage, error) { + mgmtMockServer.GetPKCEAuthorizationFlowFunc = func(ctx context.Context, req *mgmtProto.EncryptedMessage) (*mgmtProto.EncryptedMessage, error) { encryptedResp, err := encryption.EncryptMessage(serverKey, client.key, expectedFlowInfo) if err != nil { return nil, err From e4de1d75de50991f662f94fb66cd65820ca95e94 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Tue, 17 Oct 2023 11:37:58 +0200 Subject: [PATCH 08/28] Update contribution guide with go version and Windows driver (#1226) --- CONTRIBUTING.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2982511087c..0d3c736336c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,7 +23,6 @@ If you haven't already, join our slack workspace [here](https://join.slack.com/t - [Test suite](#test-suite) - [Checklist before submitting a PR](#checklist-before-submitting-a-pr) - [Other project repositories](#other-project-repositories) - - [Checklist before submitting a new node](#checklist-before-submitting-a-new-node) - [Contributor License Agreement](#contributor-license-agreement) ## Code of conduct @@ -70,7 +69,7 @@ dependencies are installed. Here is a short guide on how that can be done. ### Requirements -#### Go 1.19 +#### Go 1.21 Follow the installation guide from https://go.dev/ @@ -139,15 +138,14 @@ checked out and set up: ### Build and start #### Client -> Windows clients have a Wireguard driver requirement. We provide a bash script that can be executed in WLS 2 with docker support [wireguard_nt.sh](/client/wireguard_nt.sh). - To start NetBird, execute: ``` cd client -# bash wireguard_nt.sh # if windows go build . ``` +> Windows clients have a Wireguard driver requirement. You can downlowd the wintun driver from https://www.wintun.net/builds/wintun-0.14.1.zip, after decompressing, you can copy the file `windtun\bin\ARCH\wintun.dll` to the same path as your binary file or to `C:\Windows\System32\wintun.dll`. + To start NetBird the client in the foreground: ``` From 7d8a69cc0c82580f652051984df8f0101cabf06b Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Tue, 17 Oct 2023 15:54:50 +0200 Subject: [PATCH 09/28] Use account creator as inviter as a fallback (#1225) When inviting a user using a service user PAT, we need to fall back to a known ID to get the user's email, which is used in the invite message. --- management/server/user.go | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/management/server/user.go b/management/server/user.go index 5858720805e..edb64934026 100644 --- a/management/server/user.go +++ b/management/server/user.go @@ -228,10 +228,20 @@ func (am *DefaultAccountManager) inviteNewUser(accountID, userID string, invite return nil, status.Errorf(status.NotFound, "account %s doesn't exist", accountID) } - // initiator is the one who is inviting the new user - initiatorUser, err := am.lookupUserInCache(userID, account) + initiatorUser, err := account.FindUser(userID) if err != nil { - return nil, status.Errorf(status.NotFound, "user %s doesn't exist in IdP", userID) + return nil, status.Errorf(status.NotFound, "initiator user with ID %s doesn't exist", userID) + } + + inviterID := userID + if initiatorUser.IsServiceUser { + inviterID = account.CreatedBy + } + + // inviterUser is the one who is inviting the new user + inviterUser, err := am.lookupUserInCache(inviterID, account) + if err != nil || inviterUser == nil { + return nil, status.Errorf(status.NotFound, "inviter user with ID %s doesn't exist in IdP", inviterID) } // check if the user is already registered with this email => reject @@ -253,7 +263,7 @@ func (am *DefaultAccountManager) inviteNewUser(accountID, userID string, invite return nil, status.Errorf(status.UserAlreadyExists, "can't invite a user with an existing NetBird account") } - idpUser, err := am.idpManager.CreateUser(invite.Email, invite.Name, accountID, initiatorUser.Email) + idpUser, err := am.idpManager.CreateUser(invite.Email, invite.Name, accountID, inviterUser.Email) if err != nil { return nil, err } From 87cc53b74356ce63999cc93c265d98de429c5f66 Mon Sep 17 00:00:00 2001 From: Yury Gargay Date: Tue, 17 Oct 2023 17:19:47 +0200 Subject: [PATCH 10/28] Add management-integrations (#1227) --- go.mod | 1 + go.sum | 2 + management/server/http/handler.go | 6 ++- .../server/http/middleware/auth_middleware.go | 46 ++++++++----------- .../http/middleware/auth_middleware_test.go | 7 ++- 5 files changed, 32 insertions(+), 30 deletions(-) diff --git a/go.mod b/go.mod index 1f8eec24ed3..76e592f7330 100644 --- a/go.mod +++ b/go.mod @@ -51,6 +51,7 @@ require ( github.com/miekg/dns v1.1.43 github.com/mitchellh/hashstructure/v2 v2.0.2 github.com/nadoo/ipset v0.5.0 + github.com/netbirdio/management-integrations/integrations v0.0.0-20231017101406-322cbabed3da github.com/okta/okta-sdk-golang/v2 v2.18.0 github.com/patrickmn/go-cache v2.1.0+incompatible github.com/pion/logging v0.2.2 diff --git a/go.sum b/go.sum index 15e69283c2d..561d3e17eaf 100644 --- a/go.sum +++ b/go.sum @@ -495,6 +495,8 @@ github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRW github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/nadoo/ipset v0.5.0 h1:5GJUAuZ7ITQQQGne5J96AmFjRtI8Avlbk6CabzYWVUc= github.com/nadoo/ipset v0.5.0/go.mod h1:rYF5DQLRGGoQ8ZSWeK+6eX5amAuPqwFkWjhQlEITGJQ= +github.com/netbirdio/management-integrations/integrations v0.0.0-20231017101406-322cbabed3da h1:S1RoPhLTw3+IhHGnyfcQlj4aqIIaQdVd3SqaiK+MYFY= +github.com/netbirdio/management-integrations/integrations v0.0.0-20231017101406-322cbabed3da/go.mod h1:KSqjzHcqlodTWiuap5lRXxt5KT3vtYRoksL0KIrTK40= github.com/netbirdio/service v0.0.0-20230215170314-b923b89432b0 h1:hirFRfx3grVA/9eEyjME5/z3nxdJlN9kfQpvWWPk32g= github.com/netbirdio/service v0.0.0-20230215170314-b923b89432b0/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM= github.com/netbirdio/systray v0.0.0-20221012095658-dc8eda872c0c h1:wK/s4nyZj/GF/kFJQjX6nqNfE0G3gcqd6hhnPCyp4sw= diff --git a/management/server/http/handler.go b/management/server/http/handler.go index 6e9b029c734..0d415a087e5 100644 --- a/management/server/http/handler.go +++ b/management/server/http/handler.go @@ -6,6 +6,7 @@ import ( "github.com/gorilla/mux" "github.com/rs/cors" + "github.com/netbirdio/management-integrations/integrations" s "github.com/netbirdio/netbird/management/server" "github.com/netbirdio/netbird/management/server/http/middleware" "github.com/netbirdio/netbird/management/server/jwtclaims" @@ -58,6 +59,7 @@ func APIHandler(accountManager s.AccountManager, jwtValidator jwtclaims.JWTValid AuthCfg: authCfg, } + integrations.RegisterHandlers(api.Router, accountManager) api.addAccountsEndpoint() api.addPeersEndpoint() api.addUsersEndpoint() @@ -73,8 +75,8 @@ func APIHandler(accountManager s.AccountManager, jwtValidator jwtclaims.JWTValid err := api.Router.Walk(func(route *mux.Route, _ *mux.Router, _ []*mux.Route) error { methods, err := route.GetMethods() - if err != nil { - return err + if err != nil { // we may have wildcard routes from integrations without methods, skip them for now + methods = []string{} } for _, method := range methods { template, err := route.GetPathTemplate() diff --git a/management/server/http/middleware/auth_middleware.go b/management/server/http/middleware/auth_middleware.go index 7107231243a..99482bfb7f5 100644 --- a/management/server/http/middleware/auth_middleware.go +++ b/management/server/http/middleware/auth_middleware.go @@ -57,10 +57,17 @@ func NewAuthMiddleware(getAccountFromPAT GetAccountFromPATFunc, validateAndParse func (m *AuthMiddleware) Handler(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { auth := strings.Split(r.Header.Get("Authorization"), " ") - authType := auth[0] - switch strings.ToLower(authType) { + authType := strings.ToLower(auth[0]) + + // fallback to token when receive pat as bearer + if len(auth) >= 2 && authType == "bearer" && strings.HasPrefix(auth[1], "nbp_") { + authType = "token" + auth[0] = authType + } + + switch authType { case "bearer": - err := m.CheckJWTFromRequest(w, r) + err := m.checkJWTFromRequest(w, r, auth) if err != nil { log.Errorf("Error when validating JWT claims: %s", err.Error()) util.WriteError(status.Errorf(status.Unauthorized, "token invalid"), w) @@ -68,7 +75,7 @@ func (m *AuthMiddleware) Handler(h http.Handler) http.Handler { } h.ServeHTTP(w, r) case "token": - err := m.CheckPATFromRequest(w, r) + err := m.checkPATFromRequest(w, r, auth) if err != nil { log.Debugf("Error when validating PAT claims: %s", err.Error()) util.WriteError(status.Errorf(status.Unauthorized, "token invalid"), w) @@ -83,9 +90,8 @@ func (m *AuthMiddleware) Handler(h http.Handler) http.Handler { } // CheckJWTFromRequest checks if the JWT is valid -func (m *AuthMiddleware) CheckJWTFromRequest(w http.ResponseWriter, r *http.Request) error { - - token, err := getTokenFromJWTRequest(r) +func (m *AuthMiddleware) checkJWTFromRequest(w http.ResponseWriter, r *http.Request, auth []string) error { + token, err := getTokenFromJWTRequest(auth) // If an error occurs, call the error handler and return an error if err != nil { @@ -110,8 +116,8 @@ func (m *AuthMiddleware) CheckJWTFromRequest(w http.ResponseWriter, r *http.Requ } // CheckPATFromRequest checks if the PAT is valid -func (m *AuthMiddleware) CheckPATFromRequest(w http.ResponseWriter, r *http.Request) error { - token, err := getTokenFromPATRequest(r) +func (m *AuthMiddleware) checkPATFromRequest(w http.ResponseWriter, r *http.Request, auth []string) error { + token, err := getTokenFromPATRequest(auth) // If an error occurs, call the error handler and return an error if err != nil { @@ -143,16 +149,9 @@ func (m *AuthMiddleware) CheckPATFromRequest(w http.ResponseWriter, r *http.Requ return nil } -// getTokenFromJWTRequest is a "TokenExtractor" that takes a give request and extracts +// getTokenFromJWTRequest is a "TokenExtractor" that takes auth header parts and extracts // the JWT token from the Authorization header. -func getTokenFromJWTRequest(r *http.Request) (string, error) { - authHeader := r.Header.Get("Authorization") - if authHeader == "" { - return "", nil // No error, just no token - } - - // TODO: Make this a bit more robust, parsing-wise - authHeaderParts := strings.Fields(authHeader) +func getTokenFromJWTRequest(authHeaderParts []string) (string, error) { if len(authHeaderParts) != 2 || strings.ToLower(authHeaderParts[0]) != "bearer" { return "", errors.New("Authorization header format must be Bearer {token}") } @@ -160,16 +159,9 @@ func getTokenFromJWTRequest(r *http.Request) (string, error) { return authHeaderParts[1], nil } -// getTokenFromPATRequest is a "TokenExtractor" that takes a give request and extracts +// getTokenFromPATRequest is a "TokenExtractor" that takes auth header parts and extracts // the PAT token from the Authorization header. -func getTokenFromPATRequest(r *http.Request) (string, error) { - authHeader := r.Header.Get("Authorization") - if authHeader == "" { - return "", nil // No error, just no token - } - - // TODO: Make this a bit more robust, parsing-wise - authHeaderParts := strings.Fields(authHeader) +func getTokenFromPATRequest(authHeaderParts []string) (string, error) { if len(authHeaderParts) != 2 || strings.ToLower(authHeaderParts[0]) != "token" { return "", errors.New("Authorization header format must be Token {token}") } diff --git a/management/server/http/middleware/auth_middleware_test.go b/management/server/http/middleware/auth_middleware_test.go index 608bf42fa5f..55e5de260f6 100644 --- a/management/server/http/middleware/auth_middleware_test.go +++ b/management/server/http/middleware/auth_middleware_test.go @@ -19,7 +19,7 @@ const ( domain = "domain" userID = "userID" tokenID = "tokenID" - PAT = "PAT" + PAT = "nbp_PAT" JWT = "JWT" wrongToken = "wrongToken" ) @@ -82,6 +82,11 @@ func TestAuthMiddleware_Handler(t *testing.T) { authHeader: "Token " + wrongToken, expectedStatusCode: 401, }, + { + name: "Fallback to PAT Token", + authHeader: "Bearer " + PAT, + expectedStatusCode: 200, + }, { name: "Valid JWT Token", authHeader: "Bearer " + JWT, From f2fc0df104d672d2cc03b804affee7c7439a2383 Mon Sep 17 00:00:00 2001 From: Fabio Fantoni Date: Wed, 18 Oct 2023 18:03:51 +0200 Subject: [PATCH 11/28] Make possible set IdpSignKeyRefreshEnabled from setup.env (#1230) * Make possible set IdpSignKeyRefreshEnabled from setup.env IdpSignKeyRefreshEnabled is default to false but with some idps on token expire of logged users netbird always give error and return usable only on server restart so I think is useful make easier/faster set it on server configuration * add template IdpSignKeyRefreshEnabled value test --- .github/workflows/test-infrastructure-files.yml | 3 +++ infrastructure_files/base.setup.env | 2 ++ infrastructure_files/management.json.tmpl | 1 + infrastructure_files/setup.env.example | 2 ++ infrastructure_files/tests/setup.env | 3 ++- 5 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-infrastructure-files.yml b/.github/workflows/test-infrastructure-files.yml index da54ceaf507..ce6f0b75aee 100644 --- a/.github/workflows/test-infrastructure-files.yml +++ b/.github/workflows/test-infrastructure-files.yml @@ -57,6 +57,7 @@ jobs: CI_NETBIRD_IDP_MGMT_CLIENT_SECRET: testing.client.secret CI_NETBIRD_AUTH_SUPPORTED_SCOPES: "openid profile email offline_access api email_verified" CI_NETBIRD_STORE_CONFIG_ENGINE: "sqlite" + CI_NETBIRD_MGMT_IDP_SIGNKEY_REFRESH: false - name: check values working-directory: infrastructure_files @@ -83,6 +84,7 @@ jobs: CI_NETBIRD_IDP_MGMT_CLIENT_SECRET: testing.client.secret CI_NETBIRD_SIGNAL_PORT: 12345 CI_NETBIRD_STORE_CONFIG_ENGINE: "sqlite" + CI_NETBIRD_MGMT_IDP_SIGNKEY_REFRESH: false run: | grep AUTH_CLIENT_ID docker-compose.yml | grep $CI_NETBIRD_AUTH_CLIENT_ID @@ -101,6 +103,7 @@ jobs: grep -A 3 DeviceAuthorizationFlow management.json | grep -A 1 ProviderConfig | grep Audience | grep $CI_NETBIRD_AUTH_DEVICE_AUTH_AUDIENCE grep -A 3 DeviceAuthorizationFlow management.json | grep -A 1 ProviderConfig | grep Audience | grep $CI_NETBIRD_AUTH_DEVICE_AUTH_AUDIENCE grep Engine management.json | grep "$CI_NETBIRD_STORE_CONFIG_ENGINE" + grep IdpSignKeyRefreshEnabled management.json | grep "$CI_NETBIRD_MGMT_IDP_SIGNKEY_REFRESH" grep UseIDToken management.json | grep false grep -A 1 IdpManagerConfig management.json | grep ManagerType | grep $CI_NETBIRD_MGMT_IDP grep -A 3 IdpManagerConfig management.json | grep -A 1 ClientConfig | grep Issuer | grep $CI_NETBIRD_AUTH_AUTHORITY diff --git a/infrastructure_files/base.setup.env b/infrastructure_files/base.setup.env index 210b3036404..fa337c55d3d 100644 --- a/infrastructure_files/base.setup.env +++ b/infrastructure_files/base.setup.env @@ -14,6 +14,7 @@ NETBIRD_MGMT_API_CERT_KEY_FILE="/etc/letsencrypt/live/$NETBIRD_LETSENCRYPT_DOMAI # By default Management single account mode is enabled and domain set to $NETBIRD_DOMAIN, you may want to set this to your user's email domain NETBIRD_MGMT_SINGLE_ACCOUNT_MODE_DOMAIN=$NETBIRD_DOMAIN NETBIRD_MGMT_DNS_DOMAIN=${NETBIRD_MGMT_DNS_DOMAIN:-netbird.selfhosted} +NETBIRD_MGMT_IDP_SIGNKEY_REFRESH=${NETBIRD_MGMT_IDP_SIGNKEY_REFRESH:-false} # Signal NETBIRD_SIGNAL_PROTOCOL="http" @@ -89,6 +90,7 @@ export LETSENCRYPT_VOLUMESUFFIX export NETBIRD_DISABLE_ANONYMOUS_METRICS export NETBIRD_MGMT_SINGLE_ACCOUNT_MODE_DOMAIN export NETBIRD_MGMT_DNS_DOMAIN +export NETBIRD_MGMT_IDP_SIGNKEY_REFRESH export NETBIRD_SIGNAL_PROTOCOL export NETBIRD_SIGNAL_PORT export NETBIRD_AUTH_USER_ID_CLAIM diff --git a/infrastructure_files/management.json.tmpl b/infrastructure_files/management.json.tmpl index 7a15bdd2cca..7b8d6190d40 100644 --- a/infrastructure_files/management.json.tmpl +++ b/infrastructure_files/management.json.tmpl @@ -39,6 +39,7 @@ "AuthUserIDClaim": "$NETBIRD_AUTH_USER_ID_CLAIM", "CertFile":"$NETBIRD_MGMT_API_CERT_FILE", "CertKey":"$NETBIRD_MGMT_API_CERT_KEY_FILE", + "IdpSignKeyRefreshEnabled": $NETBIRD_MGMT_IDP_SIGNKEY_REFRESH, "OIDCConfigEndpoint":"$NETBIRD_AUTH_OIDC_CONFIGURATION_ENDPOINT" }, "IdpManagerConfig": { diff --git a/infrastructure_files/setup.env.example b/infrastructure_files/setup.env.example index f9ad638465f..00c0c07f9d1 100644 --- a/infrastructure_files/setup.env.example +++ b/infrastructure_files/setup.env.example @@ -53,6 +53,8 @@ NETBIRD_MGMT_IDP="none" # Some IDPs requires different client id and client secret for management api NETBIRD_IDP_MGMT_CLIENT_ID=$NETBIRD_AUTH_CLIENT_ID NETBIRD_IDP_MGMT_CLIENT_SECRET="" +# With some IDPs may be needed enabling automatic refresh of signing keys on expire +# NETBIRD_MGMT_IDP_SIGNKEY_REFRESH=false # NETBIRD_IDP_MGMT_EXTRA_ variables. See https://docs.netbird.io/selfhosted/identity-providers for more information about your IDP of choice. # ------------------------------------------- # Letsencrypt diff --git a/infrastructure_files/tests/setup.env b/infrastructure_files/tests/setup.env index f6e3b4a1505..f02ef3d1477 100644 --- a/infrastructure_files/tests/setup.env +++ b/infrastructure_files/tests/setup.env @@ -23,4 +23,5 @@ NETBIRD_MGMT_IDP=$CI_NETBIRD_MGMT_IDP NETBIRD_IDP_MGMT_CLIENT_ID=$CI_NETBIRD_IDP_MGMT_CLIENT_ID NETBIRD_IDP_MGMT_CLIENT_SECRET=$CI_NETBIRD_IDP_MGMT_CLIENT_SECRET NETBIRD_SIGNAL_PORT=12345 -NETBIRD_STORE_CONFIG_ENGINE=$CI_NETBIRD_STORE_CONFIG_ENGINE \ No newline at end of file +NETBIRD_STORE_CONFIG_ENGINE=$CI_NETBIRD_STORE_CONFIG_ENGINE +NETBIRD_MGMT_IDP_SIGNKEY_REFRESH=$CI_NETBIRD_MGMT_IDP_SIGNKEY_REFRESH \ No newline at end of file From c979a4e9fb4cc50a5a3c745830842c35674a06f0 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Wed, 18 Oct 2023 18:15:18 +0200 Subject: [PATCH 12/28] Explicitly disable CGO for client (#1228) --- CONTRIBUTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0d3c736336c..6d31de65151 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -141,7 +141,7 @@ checked out and set up: To start NetBird, execute: ``` cd client -go build . +CGO_ENABLED=0 go build . ``` > Windows clients have a Wireguard driver requirement. You can downlowd the wintun driver from https://www.wintun.net/builds/wintun-0.14.1.zip, after decompressing, you can copy the file `windtun\bin\ARCH\wintun.dll` to the same path as your binary file or to `C:\Windows\System32\wintun.dll`. @@ -213,4 +213,4 @@ NetBird project is composed of 3 main repositories: That we do not have any potential problems later it is sadly necessary to sign a [Contributor License Agreement](CONTRIBUTOR_LICENSE_AGREEMENT.md). That can be done literally with the push of a button. -A bot will automatically comment on the pull request once it got opened asking for the agreement to be signed. Before it did not get signed it is sadly not possible to merge it in. \ No newline at end of file +A bot will automatically comment on the pull request once it got opened asking for the agreement to be signed. Before it did not get signed it is sadly not possible to merge it in. From a9f5fad6255c3ddb490dae6226147e618350e9f6 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Thu, 19 Oct 2023 10:18:16 +0200 Subject: [PATCH 13/28] Update grpc clients' keepalive interval (#1231) Some reverse proxies might find 15s interval too short and respond with an enhance your-calm message This change is setting the management and signal clients' keepalive interval to 30 seconds to minimize the number of reconnections --- management/client/grpc.go | 2 +- signal/client/grpc.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/management/client/grpc.go b/management/client/grpc.go index e4caed4b0e6..ddb420ee20e 100644 --- a/management/client/grpc.go +++ b/management/client/grpc.go @@ -57,7 +57,7 @@ func NewClient(ctx context.Context, addr string, ourPrivateKey wgtypes.Key, tlsE transportOption, grpc.WithBlock(), grpc.WithKeepaliveParams(keepalive.ClientParameters{ - Time: 15 * time.Second, + Time: 30 * time.Second, Timeout: 10 * time.Second, })) if err != nil { diff --git a/signal/client/grpc.go b/signal/client/grpc.go index 08430e8ef20..fef4431736c 100644 --- a/signal/client/grpc.go +++ b/signal/client/grpc.go @@ -79,7 +79,7 @@ func NewClient(ctx context.Context, addr string, key wgtypes.Key, tlsEnabled boo transportOption, grpc.WithBlock(), grpc.WithKeepaliveParams(keepalive.ClientParameters{ - Time: 15 * time.Second, + Time: 30 * time.Second, Timeout: 10 * time.Second, })) From ee6be58a675742cfda13f6fa32cbbd8c6f14f4c0 Mon Sep 17 00:00:00 2001 From: Bethuel Mmbaga Date: Thu, 19 Oct 2023 18:47:39 +0300 Subject: [PATCH 14/28] Fix update script's failure to update netbird-ui in binary installation (#1218) Resolve the problem with the update script that prevents netbird-ui from updating during binary installation. Introduce the variable UPDATE_NETBIRD. Now we can upgrade the binary installation with A function stop_running_netbird_ui has been added which checks if NetBird UI is currently running. If so, it stops the UI to allow the application update process to proceed smoothly. This was necessary to prevent conflicts or errors during updates if the UI was running. --------- Co-authored-by: Maycon Santos --- .github/workflows/golang-test-windows.yml | 7 +-- release_files/install.sh | 52 ++++++++++++++++------- 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/.github/workflows/golang-test-windows.yml b/.github/workflows/golang-test-windows.yml index ec5576d8861..34f0ec6801c 100644 --- a/.github/workflows/golang-test-windows.yml +++ b/.github/workflows/golang-test-windows.yml @@ -14,9 +14,6 @@ concurrency: jobs: test: - strategy: - matrix: - store: ['jsonfile', 'sqlite'] runs-on: windows-latest steps: - name: Checkout code @@ -42,9 +39,9 @@ jobs: - run: mv ${{ env.downloadPath }}/wintun/bin/amd64/wintun.dll 'C:\Windows\System32\' - - run: choco install -y sysinternals + - run: choco install -y sysinternals --ignore-checksums - run: choco install -y mingw - + - run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe env -w GOMODCACHE=C:\Users\runneradmin\go\pkg\mod - run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe env -w GOCACHE=C:\Users\runneradmin\AppData\Local\go-build diff --git a/release_files/install.sh b/release_files/install.sh index c553cc28a45..e529c229ee7 100755 --- a/release_files/install.sh +++ b/release_files/install.sh @@ -66,9 +66,14 @@ download_release_binary() { if [ "$OS_TYPE" = "darwin" ] && [ "$1" = "$UI_APP" ]; then INSTALL_DIR="/Applications/NetBird UI.app" + if test -d "$INSTALL_DIR" ; then + echo "removing $INSTALL_DIR" + rm -rfv "$INSTALL_DIR" + fi + # Unzip the app and move to INSTALL_DIR unzip -q -o "$BINARY_NAME" - mv "netbird_ui_${OS_TYPE}_${ARCH}" "$INSTALL_DIR" + mv "netbird_ui_${OS_TYPE}_${ARCH}/" "$INSTALL_DIR/" else ${SUDO} mkdir -p "$INSTALL_DIR" tar -xzvf "$BINARY_NAME" @@ -184,16 +189,6 @@ install_netbird() { fi fi - # Checks if SKIP_UI_APP env is set - if [ -z "$SKIP_UI_APP" ]; then - SKIP_UI_APP=false - else - if $SKIP_UI_APP; then - echo "SKIP_UI_APP has been set to true in the environment" - echo "NetBird UI installation will be omitted based on your preference" - fi - fi - # Run the installation, if a desktop environment is not detected # only the CLI will be installed case "$PACKAGE_MANAGER" in @@ -294,6 +289,14 @@ is_bin_package_manager() { fi } +stop_running_netbird_ui() { + NB_UI_PROC=$(ps -ef | grep "[n]etbird-ui" | awk '{print $2}') + if [ -n "$NB_UI_PROC" ]; then + echo "NetBird UI is running with PID $NB_UI_PROC. Stopping it..." + kill -9 "$NB_UI_PROC" + fi +} + update_netbird() { if is_bin_package_manager "$CONFIG_FILE"; then latest_release=$(get_latest_release) @@ -301,7 +304,7 @@ update_netbird() { installed_version=$(netbird version) if [ "$latest_version" = "$installed_version" ]; then - echo "Installed netbird version ($installed_version) is up-to-date" + echo "Installed NetBird version ($installed_version) is up-to-date" exit 0 fi @@ -310,8 +313,9 @@ update_netbird() { echo "" echo "Initiating NetBird update. This will stop the netbird service and restart it after the update" - ${SUDO} netbird service stop - ${SUDO} netbird service uninstall + ${SUDO} netbird service stop || true + ${SUDO} netbird service uninstall || true + stop_running_netbird_ui install_native_binaries ${SUDO} netbird service install @@ -322,6 +326,16 @@ update_netbird() { fi } +# Checks if SKIP_UI_APP env is set +if [ -z "$SKIP_UI_APP" ]; then + SKIP_UI_APP=false +else + if $SKIP_UI_APP; then + echo "SKIP_UI_APP has been set to true in the environment" + echo "NetBird UI installation will be omitted based on your preference" + fi +fi + # Identify OS name and default package manager if type uname >/dev/null 2>&1; then case "$(uname)" in @@ -334,7 +348,7 @@ if type uname >/dev/null 2>&1; then if [ "$ARCH" != "amd64" ] && [ "$ARCH" != "arm64" ] \ && [ "$ARCH" != "x86_64" ];then SKIP_UI_APP=true - echo "NetBird UI installation will be omitted as $ARCH is not a compactible architecture" + echo "NetBird UI installation will be omitted as $ARCH is not a compatible architecture" fi # Allow netbird UI installation for linux running desktop enviroment @@ -376,7 +390,13 @@ if type uname >/dev/null 2>&1; then esac fi -case "$1" in +UPDATE_FLAG=$1 + +if [ "${UPDATE_NETBIRD}-x" = "true-x" ]; then + UPDATE_FLAG="--update" +fi + +case "$UPDATE_FLAG" in --update) update_netbird ;; From e59d2317fee1bdf4479a1f3b85b18dbadc50bd48 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Thu, 19 Oct 2023 19:32:42 +0200 Subject: [PATCH 15/28] Add search domains support (#1224) Supporting search domains will allow users to define match domains to also be added to a list of search domains in their systems Fix Windows registry key configuration for search domains using a key within the netbird interface path --- client/internal/dns/host.go | 2 +- client/internal/dns/host_windows.go | 59 +------ client/internal/engine.go | 5 +- dns/nameserver.go | 20 ++- management/proto/management.pb.go | 149 ++++++++++-------- management/proto/management.proto | 1 + management/server/account.go | 2 +- management/server/dns.go | 5 +- management/server/http/api/openapi.yml | 7 +- management/server/http/api/types.gen.go | 12 +- management/server/http/nameservers_handler.go | 36 +++-- .../server/http/nameservers_handler_test.go | 19 +-- management/server/mock_server/account_mock.go | 6 +- management/server/nameserver.go | 29 ++-- management/server/nameserver_test.go | 16 +- 15 files changed, 179 insertions(+), 189 deletions(-) diff --git a/client/internal/dns/host.go b/client/internal/dns/host.go index 743ececdcc5..4fd164c45b1 100644 --- a/client/internal/dns/host.go +++ b/client/internal/dns/host.go @@ -78,7 +78,7 @@ func dnsConfigToHostDNSConfig(dnsConfig nbdns.Config, ip string, port int) hostD for _, domain := range nsConfig.Domains { config.domains = append(config.domains, domainConfig{ domain: strings.TrimSuffix(domain, "."), - matchOnly: true, + matchOnly: !nsConfig.SearchDomainsEnabled, }) } } diff --git a/client/internal/dns/host_windows.go b/client/internal/dns/host_windows.go index cea806bd2e7..3814be00b7f 100644 --- a/client/internal/dns/host_windows.go +++ b/client/internal/dns/host_windows.go @@ -22,13 +22,11 @@ const ( interfaceConfigPath = "SYSTEM\\CurrentControlSet\\Services\\Tcpip\\Parameters\\Interfaces" interfaceConfigNameServerKey = "NameServer" interfaceConfigSearchListKey = "SearchList" - tcpipParametersPath = "SYSTEM\\CurrentControlSet\\Services\\Tcpip\\Parameters" ) type registryConfigurator struct { - guid string - routingAll bool - existingSearchDomains []string + guid string + routingAll bool } func newHostManager(wgInterface WGIface) (hostManager, error) { @@ -148,30 +146,11 @@ func (r *registryConfigurator) restoreHostDNS() error { log.Error(err) } - return r.updateSearchDomains([]string{}) + return r.deleteInterfaceRegistryKeyProperty(interfaceConfigSearchListKey) } func (r *registryConfigurator) updateSearchDomains(domains []string) error { - value, err := getLocalMachineRegistryKeyStringValue(tcpipParametersPath, interfaceConfigSearchListKey) - if err != nil { - return fmt.Errorf("unable to get current search domains failed with error: %s", err) - } - - valueList := strings.Split(value, ",") - setExisting := false - if len(r.existingSearchDomains) == 0 { - r.existingSearchDomains = valueList - setExisting = true - } - - if len(domains) == 0 && setExisting { - log.Infof("added %d search domains to the registry. Domain list: %s", len(domains), domains) - return nil - } - - newList := append(r.existingSearchDomains, domains...) - - err = setLocalMachineRegistryKeyStringValue(tcpipParametersPath, interfaceConfigSearchListKey, strings.Join(newList, ",")) + err := r.setInterfaceRegistryKeyStringValue(interfaceConfigSearchListKey, strings.Join(domains, ",")) if err != nil { return fmt.Errorf("adding search domain failed with error: %s", err) } @@ -235,33 +214,3 @@ func removeRegistryKeyFromDNSPolicyConfig(regKeyPath string) error { } return nil } - -func getLocalMachineRegistryKeyStringValue(keyPath, key string) (string, error) { - regKey, err := registry.OpenKey(registry.LOCAL_MACHINE, keyPath, registry.QUERY_VALUE) - if err != nil { - return "", fmt.Errorf("unable to open existing key from registry, key path: HKEY_LOCAL_MACHINE\\%s, error: %s", keyPath, err) - } - defer regKey.Close() - - val, _, err := regKey.GetStringValue(key) - if err != nil { - return "", fmt.Errorf("getting %s value for key path HKEY_LOCAL_MACHINE\\%s failed with error: %s", key, keyPath, err) - } - - return val, nil -} - -func setLocalMachineRegistryKeyStringValue(keyPath, key, value string) error { - regKey, err := registry.OpenKey(registry.LOCAL_MACHINE, keyPath, registry.SET_VALUE) - if err != nil { - return fmt.Errorf("unable to open existing key from registry, key path: HKEY_LOCAL_MACHINE\\%s, error: %s", keyPath, err) - } - defer regKey.Close() - - err = regKey.SetStringValue(key, value) - if err != nil { - return fmt.Errorf("setting %s value %s for key path HKEY_LOCAL_MACHINE\\%s failed with error: %s", key, value, keyPath, err) - } - - return nil -} diff --git a/client/internal/engine.go b/client/internal/engine.go index 8a6c0864226..aeeddc37213 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -714,8 +714,9 @@ func toDNSConfig(protoDNSConfig *mgmProto.DNSConfig) nbdns.Config { for _, nsGroup := range protoDNSConfig.GetNameServerGroups() { dnsNSGroup := &nbdns.NameServerGroup{ - Primary: nsGroup.GetPrimary(), - Domains: nsGroup.GetDomains(), + Primary: nsGroup.GetPrimary(), + Domains: nsGroup.GetDomains(), + SearchDomainsEnabled: nsGroup.GetSearchDomainsEnabled(), } for _, ns := range nsGroup.GetNameServers() { dnsNS := nbdns.NameServer{ diff --git a/dns/nameserver.go b/dns/nameserver.go index f3ae2569d39..bb904b16559 100644 --- a/dns/nameserver.go +++ b/dns/nameserver.go @@ -67,6 +67,8 @@ type NameServerGroup struct { Domains []string `gorm:"serializer:json"` // Enabled group status Enabled bool + // SearchDomainsEnabled indicates whether to add match domains to search domains list or not + SearchDomainsEnabled bool } // NameServer represents a DNS nameserver @@ -133,14 +135,15 @@ func ParseNameServerURL(nsURL string) (NameServer, error) { // Copy copies a nameserver group object func (g *NameServerGroup) Copy() *NameServerGroup { nsGroup := &NameServerGroup{ - ID: g.ID, - Name: g.Name, - Description: g.Description, - NameServers: make([]NameServer, len(g.NameServers)), - Groups: make([]string, len(g.Groups)), - Enabled: g.Enabled, - Primary: g.Primary, - Domains: make([]string, len(g.Domains)), + ID: g.ID, + Name: g.Name, + Description: g.Description, + NameServers: make([]NameServer, len(g.NameServers)), + Groups: make([]string, len(g.Groups)), + Enabled: g.Enabled, + Primary: g.Primary, + Domains: make([]string, len(g.Domains)), + SearchDomainsEnabled: g.SearchDomainsEnabled, } copy(nsGroup.NameServers, g.NameServers) @@ -156,6 +159,7 @@ func (g *NameServerGroup) IsEqual(other *NameServerGroup) bool { other.Name == g.Name && other.Description == g.Description && other.Primary == g.Primary && + other.SearchDomainsEnabled == g.SearchDomainsEnabled && compareNameServerList(g.NameServers, other.NameServers) && compareGroupsList(g.Groups, other.Groups) && compareGroupsList(g.Domains, other.Domains) diff --git a/management/proto/management.pb.go b/management/proto/management.pb.go index eb80f929909..45ef49e1f7e 100644 --- a/management/proto/management.pb.go +++ b/management/proto/management.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.26.0 -// protoc v3.21.12 +// protoc v3.21.9 // source: management.proto package proto @@ -1999,9 +1999,10 @@ type NameServerGroup struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - NameServers []*NameServer `protobuf:"bytes,1,rep,name=NameServers,proto3" json:"NameServers,omitempty"` - Primary bool `protobuf:"varint,2,opt,name=Primary,proto3" json:"Primary,omitempty"` - Domains []string `protobuf:"bytes,3,rep,name=Domains,proto3" json:"Domains,omitempty"` + NameServers []*NameServer `protobuf:"bytes,1,rep,name=NameServers,proto3" json:"NameServers,omitempty"` + Primary bool `protobuf:"varint,2,opt,name=Primary,proto3" json:"Primary,omitempty"` + Domains []string `protobuf:"bytes,3,rep,name=Domains,proto3" json:"Domains,omitempty"` + SearchDomainsEnabled bool `protobuf:"varint,4,opt,name=SearchDomainsEnabled,proto3" json:"SearchDomainsEnabled,omitempty"` } func (x *NameServerGroup) Reset() { @@ -2057,6 +2058,13 @@ func (x *NameServerGroup) GetDomains() []string { return nil } +func (x *NameServerGroup) GetSearchDomainsEnabled() bool { + if x != nil { + return x.SearchDomainsEnabled + } + return false +} + // NameServer represents a dns.NameServer type NameServer struct { state protoimpl.MessageState @@ -2444,73 +2452,76 @@ var file_management_proto_rawDesc = []byte{ 0x52, 0x05, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x54, 0x54, 0x4c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x03, 0x54, 0x54, 0x4c, 0x12, 0x14, 0x0a, 0x05, 0x52, 0x44, 0x61, 0x74, 0x61, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x52, 0x44, 0x61, 0x74, 0x61, 0x22, - 0x7f, 0x0a, 0x0f, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, - 0x75, 0x70, 0x12, 0x38, 0x0a, 0x0b, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, - 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, - 0x0b, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x12, 0x18, 0x0a, 0x07, - 0x50, 0x72, 0x69, 0x6d, 0x61, 0x72, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x50, - 0x72, 0x69, 0x6d, 0x61, 0x72, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, - 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, - 0x22, 0x48, 0x0a, 0x0a, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x12, 0x0e, - 0x0a, 0x02, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x50, 0x12, 0x16, - 0x0a, 0x06, 0x4e, 0x53, 0x54, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, - 0x4e, 0x53, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xf0, 0x02, 0x0a, 0x0c, 0x46, - 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x50, - 0x65, 0x65, 0x72, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x50, 0x65, 0x65, - 0x72, 0x49, 0x50, 0x12, 0x40, 0x0a, 0x09, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, - 0x2e, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x44, 0x69, 0x72, 0x65, - 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x37, 0x0a, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0xb3, 0x01, 0x0a, 0x0f, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, + 0x6f, 0x75, 0x70, 0x12, 0x38, 0x0a, 0x0b, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, + 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, + 0x52, 0x0b, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x12, 0x18, 0x0a, + 0x07, 0x50, 0x72, 0x69, 0x6d, 0x61, 0x72, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, + 0x50, 0x72, 0x69, 0x6d, 0x61, 0x72, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, + 0x6e, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, + 0x73, 0x12, 0x32, 0x0a, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, + 0x6e, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x45, 0x6e, + 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x48, 0x0a, 0x0a, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, + 0x76, 0x65, 0x72, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x02, 0x49, 0x50, 0x12, 0x16, 0x0a, 0x06, 0x4e, 0x53, 0x54, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x03, 0x52, 0x06, 0x4e, 0x53, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x50, + 0x6f, 0x72, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x22, + 0xf0, 0x02, 0x0a, 0x0c, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, + 0x12, 0x16, 0x0a, 0x06, 0x50, 0x65, 0x65, 0x72, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x06, 0x50, 0x65, 0x65, 0x72, 0x49, 0x50, 0x12, 0x40, 0x0a, 0x09, 0x44, 0x69, 0x72, 0x65, + 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x22, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, + 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x2e, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, + 0x09, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x37, 0x0a, 0x06, 0x41, 0x63, + 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, + 0x52, 0x75, 0x6c, 0x65, 0x2e, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x41, 0x63, 0x74, + 0x69, 0x6f, 0x6e, 0x12, 0x3d, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x21, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x2e, - 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x3d, - 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, - 0x32, 0x21, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x69, - 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x12, 0x0a, - 0x04, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x50, 0x6f, 0x72, - 0x74, 0x22, 0x1c, 0x0a, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x06, - 0x0a, 0x02, 0x49, 0x4e, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x4f, 0x55, 0x54, 0x10, 0x01, 0x22, - 0x1e, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x0a, 0x0a, 0x06, 0x41, 0x43, 0x43, - 0x45, 0x50, 0x54, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x44, 0x52, 0x4f, 0x50, 0x10, 0x01, 0x22, - 0x3c, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x0b, 0x0a, 0x07, 0x55, - 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x41, 0x4c, 0x4c, 0x10, - 0x01, 0x12, 0x07, 0x0a, 0x03, 0x54, 0x43, 0x50, 0x10, 0x02, 0x12, 0x07, 0x0a, 0x03, 0x55, 0x44, - 0x50, 0x10, 0x03, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x43, 0x4d, 0x50, 0x10, 0x04, 0x32, 0xd1, 0x03, - 0x0a, 0x11, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x72, 0x76, - 0x69, 0x63, 0x65, 0x12, 0x45, 0x0a, 0x05, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, - 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, - 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x46, 0x0a, 0x04, 0x53, 0x79, - 0x6e, 0x63, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, - 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, - 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, - 0x30, 0x01, 0x12, 0x42, 0x0a, 0x0c, 0x47, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, - 0x65, 0x79, 0x12, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x33, 0x0a, 0x09, 0x69, 0x73, 0x48, 0x65, 0x61, 0x6c, - 0x74, 0x68, 0x79, 0x12, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, - 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x5a, 0x0a, 0x1a, 0x47, - 0x65, 0x74, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, - 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, - 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, + 0x6f, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x22, 0x1c, 0x0a, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, + 0x69, 0x6f, 0x6e, 0x12, 0x06, 0x0a, 0x02, 0x49, 0x4e, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x4f, + 0x55, 0x54, 0x10, 0x01, 0x22, 0x1e, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x0a, + 0x0a, 0x06, 0x41, 0x43, 0x43, 0x45, 0x50, 0x54, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x44, 0x52, + 0x4f, 0x50, 0x10, 0x01, 0x22, 0x3c, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, + 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x07, 0x0a, + 0x03, 0x41, 0x4c, 0x4c, 0x10, 0x01, 0x12, 0x07, 0x0a, 0x03, 0x54, 0x43, 0x50, 0x10, 0x02, 0x12, + 0x07, 0x0a, 0x03, 0x55, 0x44, 0x50, 0x10, 0x03, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x43, 0x4d, 0x50, + 0x10, 0x04, 0x32, 0xd1, 0x03, 0x0a, 0x11, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x45, 0x0a, 0x05, 0x4c, 0x6f, 0x67, 0x69, + 0x6e, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, + 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, + 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, + 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, + 0x46, 0x0a, 0x04, 0x53, 0x79, 0x6e, 0x63, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, - 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x58, 0x0a, 0x18, 0x47, 0x65, 0x74, 0x50, 0x4b, - 0x43, 0x45, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, - 0x6c, 0x6f, 0x77, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, - 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, - 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, - 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, - 0x00, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x33, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x22, 0x00, 0x30, 0x01, 0x12, 0x42, 0x0a, 0x0c, 0x47, 0x65, 0x74, 0x53, 0x65, + 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x12, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, + 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x33, 0x0a, 0x09, 0x69, + 0x73, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x79, 0x12, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x11, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, + 0x12, 0x5a, 0x0a, 0x1a, 0x47, 0x65, 0x74, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, + 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x1c, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, + 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, + 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x58, 0x0a, 0x18, + 0x47, 0x65, 0x74, 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, + 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/management/proto/management.proto b/management/proto/management.proto index d5b925d733c..ae90beaf3d0 100644 --- a/management/proto/management.proto +++ b/management/proto/management.proto @@ -317,6 +317,7 @@ message NameServerGroup { repeated NameServer NameServers = 1; bool Primary = 2; repeated string Domains = 3; + bool SearchDomainsEnabled = 4; } // NameServer represents a dns.NameServer diff --git a/management/server/account.go b/management/server/account.go index f78530b4440..9ca44615ae7 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -91,7 +91,7 @@ type AccountManager interface { DeleteRoute(accountID, routeID, userID string) error ListRoutes(accountID, userID string) ([]*route.Route, error) GetNameServerGroup(accountID, nsGroupID string) (*nbdns.NameServerGroup, error) - CreateNameServerGroup(accountID string, name, description string, nameServerList []nbdns.NameServer, groups []string, primary bool, domains []string, enabled bool, userID string) (*nbdns.NameServerGroup, error) + CreateNameServerGroup(accountID string, name, description string, nameServerList []nbdns.NameServer, groups []string, primary bool, domains []string, enabled bool, userID string, searchDomainsEnabled bool) (*nbdns.NameServerGroup, error) SaveNameServerGroup(accountID, userID string, nsGroupToSave *nbdns.NameServerGroup) error DeleteNameServerGroup(accountID, nsGroupID, userID string) error ListNameServerGroups(accountID string) ([]*nbdns.NameServerGroup, error) diff --git a/management/server/dns.go b/management/server/dns.go index 7b25e230f49..9c39a00fa17 100644 --- a/management/server/dns.go +++ b/management/server/dns.go @@ -130,8 +130,9 @@ func toProtocolDNSConfig(update nbdns.Config) *proto.DNSConfig { for _, nsGroup := range update.NameServerGroups { protoGroup := &proto.NameServerGroup{ - Primary: nsGroup.Primary, - Domains: nsGroup.Domains, + Primary: nsGroup.Primary, + Domains: nsGroup.Domains, + SearchDomainsEnabled: nsGroup.SearchDomainsEnabled, } for _, ns := range nsGroup.NameServers { protoNS := &proto.NameServer{ diff --git a/management/server/http/api/openapi.yml b/management/server/http/api/openapi.yml index 658d389f668..30e55571c65 100644 --- a/management/server/http/api/openapi.yml +++ b/management/server/http/api/openapi.yml @@ -857,13 +857,17 @@ components: type: boolean example: true domains: - description: Nameserver group domain list + description: Nameserver group match domain list type: array items: type: string minLength: 1 maxLength: 255 example: "example.com" + search_domains_enabled: + description: Nameserver group search domain status for match domains. It should be true only if domains list is not empty. + type: boolean + example: true required: - name - description @@ -872,6 +876,7 @@ components: - groups - primary - domains + - search_domains_enabled NameserverGroup: allOf: - type: object diff --git a/management/server/http/api/types.gen.go b/management/server/http/api/types.gen.go index fd3eedde30c..cf9b0892e9c 100644 --- a/management/server/http/api/types.gen.go +++ b/management/server/http/api/types.gen.go @@ -1,6 +1,6 @@ // Package api provides primitives to interact with the openapi HTTP API. // -// Code generated by github.com/deepmap/oapi-codegen version v1.15.0 DO NOT EDIT. +// Code generated by github.com/deepmap/oapi-codegen version v1.11.1-0.20220912230023-4a1477f6a8ba DO NOT EDIT. package api import ( @@ -248,7 +248,7 @@ type NameserverGroup struct { // Description Nameserver group description Description string `json:"description"` - // Domains Nameserver group domain list + // Domains Nameserver group match domain list Domains []string `json:"domains"` // Enabled Nameserver group status @@ -268,6 +268,9 @@ type NameserverGroup struct { // Primary Nameserver group primary status Primary bool `json:"primary"` + + // SearchDomainsEnabled Nameserver group search domain status for match domains. It should be true only if domains list is not empty. + SearchDomainsEnabled bool `json:"search_domains_enabled"` } // NameserverGroupRequest defines model for NameserverGroupRequest. @@ -275,7 +278,7 @@ type NameserverGroupRequest struct { // Description Nameserver group description Description string `json:"description"` - // Domains Nameserver group domain list + // Domains Nameserver group match domain list Domains []string `json:"domains"` // Enabled Nameserver group status @@ -292,6 +295,9 @@ type NameserverGroupRequest struct { // Primary Nameserver group primary status Primary bool `json:"primary"` + + // SearchDomainsEnabled Nameserver group search domain status for match domains. It should be true only if domains list is not empty. + SearchDomainsEnabled bool `json:"search_domains_enabled"` } // Peer defines model for Peer. diff --git a/management/server/http/nameservers_handler.go b/management/server/http/nameservers_handler.go index 918988d69c1..871bf639ad5 100644 --- a/management/server/http/nameservers_handler.go +++ b/management/server/http/nameservers_handler.go @@ -79,7 +79,7 @@ func (h *NameserversHandler) CreateNameserverGroup(w http.ResponseWriter, r *htt return } - nsGroup, err := h.accountManager.CreateNameServerGroup(account.Id, req.Name, req.Description, nsList, req.Groups, req.Primary, req.Domains, req.Enabled, user.Id) + nsGroup, err := h.accountManager.CreateNameServerGroup(account.Id, req.Name, req.Description, nsList, req.Groups, req.Primary, req.Domains, req.Enabled, user.Id, req.SearchDomainsEnabled) if err != nil { util.WriteError(err, w) return @@ -119,14 +119,15 @@ func (h *NameserversHandler) UpdateNameserverGroup(w http.ResponseWriter, r *htt } updatedNSGroup := &nbdns.NameServerGroup{ - ID: nsGroupID, - Name: req.Name, - Description: req.Description, - Primary: req.Primary, - Domains: req.Domains, - NameServers: nsList, - Groups: req.Groups, - Enabled: req.Enabled, + ID: nsGroupID, + Name: req.Name, + Description: req.Description, + Primary: req.Primary, + Domains: req.Domains, + NameServers: nsList, + Groups: req.Groups, + Enabled: req.Enabled, + SearchDomainsEnabled: req.SearchDomainsEnabled, } err = h.accountManager.SaveNameServerGroup(account.Id, user.Id, updatedNSGroup) @@ -216,13 +217,14 @@ func toNameserverGroupResponse(serverNSGroup *nbdns.NameServerGroup) *api.Namese } return &api.NameserverGroup{ - Id: serverNSGroup.ID, - Name: serverNSGroup.Name, - Description: serverNSGroup.Description, - Primary: serverNSGroup.Primary, - Domains: serverNSGroup.Domains, - Groups: serverNSGroup.Groups, - Nameservers: nsList, - Enabled: serverNSGroup.Enabled, + Id: serverNSGroup.ID, + Name: serverNSGroup.Name, + Description: serverNSGroup.Description, + Primary: serverNSGroup.Primary, + Domains: serverNSGroup.Domains, + Groups: serverNSGroup.Groups, + Nameservers: nsList, + Enabled: serverNSGroup.Enabled, + SearchDomainsEnabled: serverNSGroup.SearchDomainsEnabled, } } diff --git a/management/server/http/nameservers_handler_test.go b/management/server/http/nameservers_handler_test.go index 100f4b87a7f..b00ff606f87 100644 --- a/management/server/http/nameservers_handler_test.go +++ b/management/server/http/nameservers_handler_test.go @@ -67,16 +67,17 @@ func initNameserversTestData() *NameserversHandler { } return nil, status.Errorf(status.NotFound, "nameserver group with ID %s not found", nsGroupID) }, - CreateNameServerGroupFunc: func(accountID string, name, description string, nameServerList []nbdns.NameServer, groups []string, primary bool, domains []string, enabled bool, _ string) (*nbdns.NameServerGroup, error) { + CreateNameServerGroupFunc: func(accountID string, name, description string, nameServerList []nbdns.NameServer, groups []string, primary bool, domains []string, enabled bool, _ string, searchDomains bool) (*nbdns.NameServerGroup, error) { return &nbdns.NameServerGroup{ - ID: existingNSGroupID, - Name: name, - Description: description, - NameServers: nameServerList, - Groups: groups, - Enabled: enabled, - Primary: primary, - Domains: domains, + ID: existingNSGroupID, + Name: name, + Description: description, + NameServers: nameServerList, + Groups: groups, + Enabled: enabled, + Primary: primary, + Domains: domains, + SearchDomainsEnabled: searchDomains, }, nil }, DeleteNameServerGroupFunc: func(accountID, nsGroupID, _ string) error { diff --git a/management/server/mock_server/account_mock.go b/management/server/mock_server/account_mock.go index ab3748c01bd..ea4a18f56cc 100644 --- a/management/server/mock_server/account_mock.go +++ b/management/server/mock_server/account_mock.go @@ -60,7 +60,7 @@ type MockAccountManager struct { GetPATFunc func(accountID string, initiatorUserID string, targetUserId string, tokenID string) (*server.PersonalAccessToken, error) GetAllPATsFunc func(accountID string, initiatorUserID string, targetUserId string) ([]*server.PersonalAccessToken, error) GetNameServerGroupFunc func(accountID, nsGroupID string) (*nbdns.NameServerGroup, error) - CreateNameServerGroupFunc func(accountID string, name, description string, nameServerList []nbdns.NameServer, groups []string, primary bool, domains []string, enabled bool, userID string) (*nbdns.NameServerGroup, error) + CreateNameServerGroupFunc func(accountID string, name, description string, nameServerList []nbdns.NameServer, groups []string, primary bool, domains []string, enabled bool, userID string, searchDomainsEnabled bool) (*nbdns.NameServerGroup, error) SaveNameServerGroupFunc func(accountID, userID string, nsGroupToSave *nbdns.NameServerGroup) error DeleteNameServerGroupFunc func(accountID, nsGroupID, userID string) error ListNameServerGroupsFunc func(accountID string) ([]*nbdns.NameServerGroup, error) @@ -464,9 +464,9 @@ func (am *MockAccountManager) GetNameServerGroup(accountID, nsGroupID string) (* } // CreateNameServerGroup mocks CreateNameServerGroup of the AccountManager interface -func (am *MockAccountManager) CreateNameServerGroup(accountID string, name, description string, nameServerList []nbdns.NameServer, groups []string, primary bool, domains []string, enabled bool, userID string) (*nbdns.NameServerGroup, error) { +func (am *MockAccountManager) CreateNameServerGroup(accountID string, name, description string, nameServerList []nbdns.NameServer, groups []string, primary bool, domains []string, enabled bool, userID string, searchDomainsEnabled bool) (*nbdns.NameServerGroup, error) { if am.CreateNameServerGroupFunc != nil { - return am.CreateNameServerGroupFunc(accountID, name, description, nameServerList, groups, primary, domains, enabled, userID) + return am.CreateNameServerGroupFunc(accountID, name, description, nameServerList, groups, primary, domains, enabled, userID, searchDomainsEnabled) } return nil, nil } diff --git a/management/server/nameserver.go b/management/server/nameserver.go index 9af5b49adc8..8ae71dbae16 100644 --- a/management/server/nameserver.go +++ b/management/server/nameserver.go @@ -35,7 +35,7 @@ func (am *DefaultAccountManager) GetNameServerGroup(accountID, nsGroupID string) } // CreateNameServerGroup creates and saves a new nameserver group -func (am *DefaultAccountManager) CreateNameServerGroup(accountID string, name, description string, nameServerList []nbdns.NameServer, groups []string, primary bool, domains []string, enabled bool, userID string) (*nbdns.NameServerGroup, error) { +func (am *DefaultAccountManager) CreateNameServerGroup(accountID string, name, description string, nameServerList []nbdns.NameServer, groups []string, primary bool, domains []string, enabled bool, userID string, searchDomainEnabled bool) (*nbdns.NameServerGroup, error) { unlock := am.Store.AcquireAccountLock(accountID) defer unlock() @@ -46,14 +46,15 @@ func (am *DefaultAccountManager) CreateNameServerGroup(accountID string, name, d } newNSGroup := &nbdns.NameServerGroup{ - ID: xid.New().String(), - Name: name, - Description: description, - NameServers: nameServerList, - Groups: groups, - Enabled: enabled, - Primary: primary, - Domains: domains, + ID: xid.New().String(), + Name: name, + Description: description, + NameServers: nameServerList, + Groups: groups, + Enabled: enabled, + Primary: primary, + Domains: domains, + SearchDomainsEnabled: searchDomainEnabled, } err = validateNameServerGroup(false, newNSGroup, account) @@ -174,7 +175,7 @@ func validateNameServerGroup(existingGroup bool, nameserverGroup *nbdns.NameServ } } - err := validateDomainInput(nameserverGroup.Primary, nameserverGroup.Domains) + err := validateDomainInput(nameserverGroup.Primary, nameserverGroup.Domains, nameserverGroup.SearchDomainsEnabled) if err != nil { return err } @@ -197,7 +198,7 @@ func validateNameServerGroup(existingGroup bool, nameserverGroup *nbdns.NameServ return nil } -func validateDomainInput(primary bool, domains []string) error { +func validateDomainInput(primary bool, domains []string, searchDomainsEnabled bool) error { if !primary && len(domains) == 0 { return status.Errorf(status.InvalidArgument, "nameserver group primary status is false and domains are empty,"+ " it should be primary or have at least one domain") @@ -206,6 +207,12 @@ func validateDomainInput(primary bool, domains []string) error { return status.Errorf(status.InvalidArgument, "nameserver group primary status is true and domains are not empty,"+ " you should set either primary or domain") } + + if primary && searchDomainsEnabled { + return status.Errorf(status.InvalidArgument, "nameserver group primary status is true and search domains is enabled,"+ + " you should not set search domains for primary nameservers") + } + for _, domain := range domains { if err := validateDomain(domain); err != nil { return status.Errorf(status.InvalidArgument, "nameserver group got an invalid domain: %s %q", domain, err) diff --git a/management/server/nameserver_test.go b/management/server/nameserver_test.go index 8809dc8ad9b..6210ae538fb 100644 --- a/management/server/nameserver_test.go +++ b/management/server/nameserver_test.go @@ -23,13 +23,14 @@ const ( func TestCreateNameServerGroup(t *testing.T) { type input struct { - name string - description string - enabled bool - groups []string - nameServers []nbdns.NameServer - primary bool - domains []string + name string + description string + enabled bool + groups []string + nameServers []nbdns.NameServer + primary bool + domains []string + searchDomains bool } testCases := []struct { @@ -383,6 +384,7 @@ func TestCreateNameServerGroup(t *testing.T) { testCase.inputArgs.domains, testCase.inputArgs.enabled, userID, + testCase.inputArgs.searchDomains, ) testCase.errFunc(t, err) From eeb38b7ecfa395236315b03d37f07efee8a1fe71 Mon Sep 17 00:00:00 2001 From: pascal-fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Thu, 19 Oct 2023 20:07:25 +0200 Subject: [PATCH 16/28] Update management.json template with all existing configuration parameters (#1182) trigger test on management/cmd and signal/cmd changes. --------- Co-authored-by: Maycon Santos --- .github/workflows/test-infrastructure-files.yml | 14 ++++++++------ infrastructure_files/management.json.tmpl | 15 ++++++++++++--- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test-infrastructure-files.yml b/.github/workflows/test-infrastructure-files.yml index ce6f0b75aee..29f868a646c 100644 --- a/.github/workflows/test-infrastructure-files.yml +++ b/.github/workflows/test-infrastructure-files.yml @@ -8,6 +8,8 @@ on: paths: - 'infrastructure_files/**' - '.github/workflows/test-infrastructure-files.yml' + - 'management/cmd/**' + - 'signal/cmd/**' concurrency: group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }} @@ -111,12 +113,12 @@ jobs: grep -A 5 IdpManagerConfig management.json | grep -A 3 ClientConfig | grep ClientID | grep $CI_NETBIRD_IDP_MGMT_CLIENT_ID grep -A 6 IdpManagerConfig management.json | grep -A 4 ClientConfig | grep ClientSecret | grep $CI_NETBIRD_IDP_MGMT_CLIENT_SECRET grep -A 7 IdpManagerConfig management.json | grep -A 5 ClientConfig | grep GrantType | grep client_credentials - grep -A 2 PKCEAuthorizationFlow management.json | grep -A 1 ProviderConfig | grep Audience | grep $CI_NETBIRD_AUTH_AUDIENCE - grep -A 3 PKCEAuthorizationFlow management.json | grep -A 2 ProviderConfig | grep ClientID | grep $CI_NETBIRD_AUTH_CLIENT_ID - grep -A 4 PKCEAuthorizationFlow management.json | grep -A 3 ProviderConfig | grep ClientSecret | grep $CI_NETBIRD_AUTH_CLIENT_SECRET - grep -A 5 PKCEAuthorizationFlow management.json | grep -A 4 ProviderConfig | grep AuthorizationEndpoint | grep $CI_NETBIRD_AUTH_PKCE_AUTHORIZATION_ENDPOINT - grep -A 6 PKCEAuthorizationFlow management.json | grep -A 5 ProviderConfig | grep TokenEndpoint | grep $CI_NETBIRD_AUTH_TOKEN_ENDPOINT - grep -A 7 PKCEAuthorizationFlow management.json | grep -A 6 ProviderConfig | grep Scope | grep "$CI_NETBIRD_AUTH_SUPPORTED_SCOPES" + grep -A 10 PKCEAuthorizationFlow management.json | grep -A 10 ProviderConfig | grep Audience | grep $CI_NETBIRD_AUTH_AUDIENCE + grep -A 10 PKCEAuthorizationFlow management.json | grep -A 10 ProviderConfig | grep ClientID | grep $CI_NETBIRD_AUTH_CLIENT_ID + grep -A 10 PKCEAuthorizationFlow management.json | grep -A 10 ProviderConfig | grep ClientSecret | grep $CI_NETBIRD_AUTH_CLIENT_SECRET + grep -A 10 PKCEAuthorizationFlow management.json | grep -A 10 ProviderConfig | grep AuthorizationEndpoint | grep $CI_NETBIRD_AUTH_PKCE_AUTHORIZATION_ENDPOINT + grep -A 10 PKCEAuthorizationFlow management.json | grep -A 10 ProviderConfig | grep TokenEndpoint | grep $CI_NETBIRD_AUTH_TOKEN_ENDPOINT + grep -A 10 PKCEAuthorizationFlow management.json | grep -A 10 ProviderConfig | grep Scope | grep "$CI_NETBIRD_AUTH_SUPPORTED_SCOPES" - name: Install modules run: go mod tidy diff --git a/infrastructure_files/management.json.tmpl b/infrastructure_files/management.json.tmpl index 7b8d6190d40..ece953515f0 100644 --- a/infrastructure_files/management.json.tmpl +++ b/infrastructure_files/management.json.tmpl @@ -51,18 +51,25 @@ "ClientSecret": "$NETBIRD_IDP_MGMT_CLIENT_SECRET", "GrantType": "client_credentials" }, - "ExtraConfig": $NETBIRD_IDP_MGMT_EXTRA_CONFIG + "ExtraConfig": $NETBIRD_IDP_MGMT_EXTRA_CONFIG, + "Auth0ClientCredentials": null, + "AzureClientCredentials": null, + "KeycloakClientCredentials": null, + "ZitadelClientCredentials": null }, "DeviceAuthorizationFlow": { "Provider": "$NETBIRD_AUTH_DEVICE_AUTH_PROVIDER", "ProviderConfig": { "Audience": "$NETBIRD_AUTH_DEVICE_AUTH_AUDIENCE", + "AuthorizationEndpoint": "", "Domain": "$NETBIRD_AUTH0_DOMAIN", "ClientID": "$NETBIRD_AUTH_DEVICE_AUTH_CLIENT_ID", + "ClientSecret": "", "TokenEndpoint": "$NETBIRD_AUTH_TOKEN_ENDPOINT", "DeviceAuthEndpoint": "$NETBIRD_AUTH_DEVICE_AUTH_ENDPOINT", "Scope": "$NETBIRD_AUTH_DEVICE_AUTH_SCOPE", - "UseIDToken": $NETBIRD_AUTH_DEVICE_AUTH_USE_ID_TOKEN + "UseIDToken": $NETBIRD_AUTH_DEVICE_AUTH_USE_ID_TOKEN, + "RedirectURLs": null } }, "PKCEAuthorizationFlow": { @@ -70,11 +77,13 @@ "Audience": "$NETBIRD_AUTH_PKCE_AUDIENCE", "ClientID": "$NETBIRD_AUTH_CLIENT_ID", "ClientSecret": "$NETBIRD_AUTH_CLIENT_SECRET", + "Domain": "", "AuthorizationEndpoint": "$NETBIRD_AUTH_PKCE_AUTHORIZATION_ENDPOINT", "TokenEndpoint": "$NETBIRD_AUTH_TOKEN_ENDPOINT", "Scope": "$NETBIRD_AUTH_SUPPORTED_SCOPES", "RedirectURLs": [$NETBIRD_AUTH_PKCE_REDIRECT_URLS], - "UseIDToken": $NETBIRD_AUTH_PKCE_USE_ID_TOKEN + "UseIDToken": $NETBIRD_AUTH_PKCE_USE_ID_TOKEN, + "RedirectURLs": null } } } From 06318a15e1efd74013dd52f86e510076d655cbaa Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Thu, 19 Oct 2023 21:14:05 +0200 Subject: [PATCH 17/28] Log store engine type (#1234) --- management/server/store.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/management/server/store.go b/management/server/store.go index 458912e97b1..66b239f9614 100644 --- a/management/server/store.go +++ b/management/server/store.go @@ -6,6 +6,8 @@ import ( "strings" "time" + log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/management/server/telemetry" ) @@ -67,8 +69,10 @@ func NewStore(kind StoreEngine, dataDir string, metrics telemetry.AppMetrics) (S } switch kind { case FileStoreEngine: + log.Info("using JSON file store engine") return NewFileStore(dataDir, metrics) case SqliteStoreEngine: + log.Info("using SQLite store engine") return NewSqliteStore(dataDir, metrics) default: return nil, fmt.Errorf("unsupported kind of store %s", kind) From 90c2093018bfcd63937193ef6daa69acc567882e Mon Sep 17 00:00:00 2001 From: Yury Gargay Date: Mon, 23 Oct 2023 16:08:21 +0200 Subject: [PATCH 18/28] Fix SaveUserLastLogin in SQLite store (#1241) --- management/server/sqlite_store.go | 8 ++++---- management/server/user.go | 18 +++++++++++++----- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/management/server/sqlite_store.go b/management/server/sqlite_store.go index 97c759d8a2e..c5356581ab0 100644 --- a/management/server/sqlite_store.go +++ b/management/server/sqlite_store.go @@ -434,16 +434,16 @@ func (s *SqliteStore) GetAccountByPeerPubKey(peerKey string) (*Account, error) { // SaveUserLastLogin stores the last login time for a user in DB. func (s *SqliteStore) SaveUserLastLogin(accountID, userID string, lastLogin time.Time) error { - var peer Peer + var user User - result := s.db.First(&peer, "account_id = ? and user_id = ?", accountID, userID) + result := s.db.First(&user, "account_id = ? and id = ?", accountID, userID) if result.Error != nil { return status.Errorf(status.NotFound, "user %s not found", userID) } - peer.LastLogin = lastLogin + user.LastLogin = lastLogin - return s.db.Save(peer).Error + return s.db.Save(user).Error } // Close is noop in Sqlite diff --git a/management/server/user.go b/management/server/user.go index edb64934026..6093d93a21f 100644 --- a/management/server/user.go +++ b/management/server/user.go @@ -299,6 +299,14 @@ func (am *DefaultAccountManager) GetUser(claims jwtclaims.AuthorizationClaims) ( return nil, fmt.Errorf("failed to get account with token claims %v", err) } + unlock := am.Store.AcquireAccountLock(account.Id) + defer unlock() + + account, err = am.Store.GetAccount(account.Id) + if err != nil { + return nil, fmt.Errorf("failed to get an account from store %v", err) + } + user, ok := account.Users[claims.UserId] if !ok { return nil, status.Errorf(status.NotFound, "user not found") @@ -306,16 +314,16 @@ func (am *DefaultAccountManager) GetUser(claims jwtclaims.AuthorizationClaims) ( // this code should be outside of the am.GetAccountFromToken(claims) because this method is called also by the gRPC // server when user authenticates a device. And we need to separate the Dashboard login event from the Device login event. - unlock := am.Store.AcquireAccountLock(account.Id) newLogin := user.LastDashboardLoginChanged(claims.LastLogin) + err = am.Store.SaveUserLastLogin(account.Id, claims.UserId, claims.LastLogin) - unlock() + if err != nil { + log.Errorf("failed saving user last login: %v", err) + } + if newLogin { meta := map[string]any{"timestamp": claims.LastLogin} am.storeEvent(claims.UserId, claims.UserId, account.Id, activity.DashboardLogin, meta) - if err != nil { - log.Errorf("failed saving user last login: %v", err) - } } return user, nil From 7a5c6b24ae2503955046d2cf79c36e0481d42164 Mon Sep 17 00:00:00 2001 From: Yury Gargay Date: Wed, 25 Oct 2023 00:12:10 +0200 Subject: [PATCH 19/28] Fix GetAccountByPrivateDomain for SQLite (#1242) --- management/server/sqlite_store.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/management/server/sqlite_store.go b/management/server/sqlite_store.go index c5356581ab0..ed473e1430c 100644 --- a/management/server/sqlite_store.go +++ b/management/server/sqlite_store.go @@ -245,7 +245,8 @@ func (s *SqliteStore) DeleteTokenID2UserIDIndex(tokenID string) error { func (s *SqliteStore) GetAccountByPrivateDomain(domain string) (*Account, error) { var account Account - result := s.db.First(&account, "domain = ?", strings.ToLower(domain)) + result := s.db.First(&account, "domain = ? and is_domain_primary_account = ? and domain_category = ?", + strings.ToLower(domain), true, PrivateCategory) if result.Error != nil { return nil, status.Errorf(status.NotFound, "account not found: provided domain is not registered or is not private") } From e7d52beeabfe68533c9c252a9f846bff72bef5e2 Mon Sep 17 00:00:00 2001 From: Glenn Sommer Date: Wed, 25 Oct 2023 00:14:00 +0200 Subject: [PATCH 20/28] Support pinning version during binary install (#1237) For installations using the binary release method (using the official installer script), it would be nice to be able to define a specific version to install. A user/developer can choose to define the NETBIRD_RELEASE variable during installation, to pin a specific version during installation. If NETBIRD_RELEASE is not defined, we default to the current behavior of latest --- release_files/install.sh | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/release_files/install.sh b/release_files/install.sh index e529c229ee7..ef5012f0d40 100755 --- a/release_files/install.sh +++ b/release_files/install.sh @@ -23,19 +23,28 @@ if command -v sudo > /dev/null && [ "$(id -u)" -ne 0 ]; then SUDO="sudo" fi -get_latest_release() { +if [ -z ${NETBIRD_RELEASE+x} ]; then + NETBIRD_RELEASE=latest +fi + +get_release() { + local RELEASE=$1 + if [ "$RELEASE" = "latest" ]; then + local TAG="latest" + else + local TAG="tags/${RELEASE}" + fi if [ -n "$GITHUB_TOKEN" ]; then - curl -H "Authorization: token ${GITHUB_TOKEN}" -s "https://api.github.com/repos/${OWNER}/${REPO}/releases/latest" \ + curl -H "Authorization: token ${GITHUB_TOKEN}" -s "https://api.github.com/repos/${OWNER}/${REPO}/releases/${TAG}" \ | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/' else - curl -s "https://api.github.com/repos/${OWNER}/${REPO}/releases/latest" \ + curl -s "https://api.github.com/repos/${OWNER}/${REPO}/releases/${TAG}" \ | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/' fi - } download_release_binary() { - VERSION=$(get_latest_release) + VERSION=$(get_release "$NETBIRD_RELEASE") BASE_URL="https://github.com/${OWNER}/${REPO}/releases/download" BINARY_BASE_NAME="${VERSION#v}_${OS_TYPE}_${ARCH}.tar.gz" @@ -299,7 +308,7 @@ stop_running_netbird_ui() { update_netbird() { if is_bin_package_manager "$CONFIG_FILE"; then - latest_release=$(get_latest_release) + latest_release=$(get_release "latest") latest_version=${latest_release#v} installed_version=$(netbird version) From 40bea645e982b36a77e1182c04bf8b3107be4992 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 25 Oct 2023 00:23:07 +0200 Subject: [PATCH 21/28] Bump golang.org/x/net from 0.10.0 to 0.17.0 (#1214) Bumps [golang.org/x/net](https://github.com/golang/net) from 0.10.0 to 0.17.0. - [Commits](https://github.com/golang/net/compare/v0.10.0...v0.17.0) --- updated-dependencies: - dependency-name: golang.org/x/net dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 10 +++++----- go.sum | 16 ++++++++++------ 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/go.mod b/go.mod index 76e592f7330..e1596aab7e0 100644 --- a/go.mod +++ b/go.mod @@ -17,8 +17,8 @@ require ( github.com/spf13/cobra v1.6.1 github.com/spf13/pflag v1.0.5 github.com/vishvananda/netlink v1.1.0 - golang.org/x/crypto v0.9.0 - golang.org/x/sys v0.8.0 + golang.org/x/crypto v0.14.0 + golang.org/x/sys v0.13.0 golang.zx2c4.com/wireguard v0.0.0-20230223181233-21636207a675 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20211215182854-7a385b3431de golang.zx2c4.com/wireguard/windows v0.5.3 @@ -69,10 +69,10 @@ require ( goauthentik.io/api/v3 v3.2023051.3 golang.org/x/exp v0.0.0-20220518171630-0b5c67f07fdf golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028 - golang.org/x/net v0.10.0 + golang.org/x/net v0.17.0 golang.org/x/oauth2 v0.8.0 golang.org/x/sync v0.2.0 - golang.org/x/term v0.8.0 + golang.org/x/term v0.13.0 google.golang.org/api v0.126.0 gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/sqlite v1.5.3 @@ -142,7 +142,7 @@ require ( go.opentelemetry.io/otel/trace v1.11.1 // indirect golang.org/x/image v0.5.0 // indirect golang.org/x/mod v0.8.0 // indirect - golang.org/x/text v0.9.0 // indirect + golang.org/x/text v0.13.0 // indirect golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac // indirect golang.org/x/tools v0.6.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect diff --git a/go.sum b/go.sum index 561d3e17eaf..07c118e3cd7 100644 --- a/go.sum +++ b/go.sum @@ -730,8 +730,8 @@ golang.org/x/crypto v0.0.0-20211202192323-5770296d904e/go.mod h1:IxCIyHEi3zRg3s0 golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= -golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g= -golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= +golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -838,8 +838,9 @@ golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -963,15 +964,17 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.1-0.20230222185716-a3b23cc77e89/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.8.0 h1:n5xxQn2i3PC0yLAbjTpNT85q/Kgzcr2gIoX9OrJUols= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -985,8 +988,9 @@ golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= From 74ff2619d01625768cd2e5fd4e92a9441d8a5473 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Wed, 25 Oct 2023 00:47:40 +0200 Subject: [PATCH 22/28] Log client version on startup (#1240) --- client/internal/connect.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/internal/connect.go b/client/internal/connect.go index 6eecf420716..66946c4aa67 100644 --- a/client/internal/connect.go +++ b/client/internal/connect.go @@ -22,6 +22,7 @@ import ( mgm "github.com/netbirdio/netbird/management/client" mgmProto "github.com/netbirdio/netbird/management/proto" signal "github.com/netbirdio/netbird/signal/client" + "github.com/netbirdio/netbird/version" ) // RunClient with main logic. @@ -43,6 +44,8 @@ func RunClientMobile(ctx context.Context, config *Config, statusRecorder *peer.S } func runClient(ctx context.Context, config *Config, statusRecorder *peer.Status, mobileDependency MobileDependency) error { + log.Infof("starting NetBird client version %s", version.NetbirdVersion()) + backOff := &backoff.ExponentialBackOff{ InitialInterval: time.Second, RandomizationFactor: 1, From a8d03d8c91fb895a187e19adfb49a1cac06a9f41 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Wed, 25 Oct 2023 17:51:05 +0200 Subject: [PATCH 23/28] Fix redirect urls template processing (#1251) removed duplicated key and added tests --- .github/workflows/test-infrastructure-files.yml | 1 + infrastructure_files/management.json.tmpl | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-infrastructure-files.yml b/.github/workflows/test-infrastructure-files.yml index 29f868a646c..6482b716fe3 100644 --- a/.github/workflows/test-infrastructure-files.yml +++ b/.github/workflows/test-infrastructure-files.yml @@ -119,6 +119,7 @@ jobs: grep -A 10 PKCEAuthorizationFlow management.json | grep -A 10 ProviderConfig | grep AuthorizationEndpoint | grep $CI_NETBIRD_AUTH_PKCE_AUTHORIZATION_ENDPOINT grep -A 10 PKCEAuthorizationFlow management.json | grep -A 10 ProviderConfig | grep TokenEndpoint | grep $CI_NETBIRD_AUTH_TOKEN_ENDPOINT grep -A 10 PKCEAuthorizationFlow management.json | grep -A 10 ProviderConfig | grep Scope | grep "$CI_NETBIRD_AUTH_SUPPORTED_SCOPES" + grep -A 10 PKCEAuthorizationFlow management.json | grep -A 10 ProviderConfig | grep -A 3 RedirectURLs | grep "http://localhost:53000" - name: Install modules run: go mod tidy diff --git a/infrastructure_files/management.json.tmpl b/infrastructure_files/management.json.tmpl index ece953515f0..64c2d081694 100644 --- a/infrastructure_files/management.json.tmpl +++ b/infrastructure_files/management.json.tmpl @@ -82,8 +82,7 @@ "TokenEndpoint": "$NETBIRD_AUTH_TOKEN_ENDPOINT", "Scope": "$NETBIRD_AUTH_SUPPORTED_SCOPES", "RedirectURLs": [$NETBIRD_AUTH_PKCE_REDIRECT_URLS], - "UseIDToken": $NETBIRD_AUTH_PKCE_USE_ID_TOKEN, - "RedirectURLs": null + "UseIDToken": $NETBIRD_AUTH_PKCE_USE_ID_TOKEN } } } From db25ca21a8689d53404ecbf72abe4f6e32d16a0e Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Fri, 27 Oct 2023 09:52:11 +0200 Subject: [PATCH 24/28] Log auth0 batch length (#1255) --- management/server/idp/auth0.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/management/server/idp/auth0.go b/management/server/idp/auth0.go index d3802d8ad96..745136f6283 100644 --- a/management/server/idp/auth0.go +++ b/management/server/idp/auth0.go @@ -337,7 +337,7 @@ func (am *Auth0Manager) GetAccount(accountID string) ([]*UserData, error) { return nil, err } - log.Debugf("returned user batch for accountID %s on page %d, %v", accountID, page, batch) + log.Debugf("returned user batch for accountID %s on page %d, batch length %d", accountID, page, len(batch)) err = res.Body.Close() if err != nil { From 76318f3f0618e4e08d0cef4a6766240e9b4166ff Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Fri, 27 Oct 2023 10:54:26 +0200 Subject: [PATCH 25/28] Fix Windows firewall message check (#1254) The no rules matched message is operating system language specific, and can cause errors Now we check if firewall is reachable by the app and then if the rule is returned or not in two different calls: isWindowsFirewallReachable isFirewallRuleActive --- .../uspfilter/allow_netbird_windows.go | 81 ++++++++++--------- client/internal/acl/manager_create.go | 2 +- 2 files changed, 43 insertions(+), 40 deletions(-) diff --git a/client/firewall/uspfilter/allow_netbird_windows.go b/client/firewall/uspfilter/allow_netbird_windows.go index 05a6d22aed0..140bfc87aae 100644 --- a/client/firewall/uspfilter/allow_netbird_windows.go +++ b/client/firewall/uspfilter/allow_netbird_windows.go @@ -1,21 +1,19 @@ package uspfilter import ( - "errors" "fmt" "os/exec" - "strings" "syscall" + + log "github.com/sirupsen/logrus" ) type action string const ( - addRule action = "add" - deleteRule action = "delete" - - firewallRuleName = "Netbird" - noRulesMatchCriteria = "No rules match the specified criteria" + addRule action = "add" + deleteRule action = "delete" + firewallRuleName = "Netbird" ) // Reset firewall to the default state @@ -26,6 +24,14 @@ func (m *Manager) Reset() error { m.outgoingRules = make(map[string]RuleSet) m.incomingRules = make(map[string]RuleSet) + if !isWindowsFirewallReachable() { + return nil + } + + if !isFirewallRuleActive(firewallRuleName) { + return nil + } + if err := manageFirewallRule(firewallRuleName, deleteRule); err != nil { return fmt.Errorf("couldn't remove windows firewall: %w", err) } @@ -35,6 +41,13 @@ func (m *Manager) Reset() error { // AllowNetbird allows netbird interface traffic func (m *Manager) AllowNetbird() error { + if !isWindowsFirewallReachable() { + return nil + } + + if isFirewallRuleActive(firewallRuleName) { + return nil + } return manageFirewallRule(firewallRuleName, addRule, "dir=in", @@ -45,47 +58,37 @@ func (m *Manager) AllowNetbird() error { ) } -func manageFirewallRule(ruleName string, action action, args ...string) error { - active, err := isFirewallRuleActive(ruleName) - if err != nil { - return err - } - - if (action == addRule && !active) || (action == deleteRule && active) { - baseArgs := []string{"advfirewall", "firewall", string(action), "rule", "name=" + ruleName} - args := append(baseArgs, args...) +func manageFirewallRule(ruleName string, action action, extraArgs ...string) error { - cmd := exec.Command("netsh", args...) - cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} - return cmd.Run() + args := []string{"advfirewall", "firewall", string(action), "rule", "name=" + ruleName} + if action == addRule { + args = append(args, extraArgs...) } - return nil + cmd := exec.Command("netsh", args...) + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + return cmd.Run() } -func isFirewallRuleActive(ruleName string) (bool, error) { - args := []string{"advfirewall", "firewall", "show", "rule", "name=" + ruleName} - +func isWindowsFirewallReachable() bool { + args := []string{"advfirewall", "show", "allprofiles", "state"} cmd := exec.Command("netsh", args...) cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} - output, err := cmd.Output() + + _, err := cmd.Output() if err != nil { - var exitError *exec.ExitError - if errors.As(err, &exitError) { - // if the firewall rule is not active, we expect last exit code to be 1 - exitStatus := exitError.Sys().(syscall.WaitStatus).ExitStatus() - if exitStatus == 1 { - if strings.Contains(string(output), noRulesMatchCriteria) { - return false, nil - } - } - } - return false, err + log.Infof("Windows firewall is not reachable, skipping default rule management. Using only user space rules. Error: %s", err) + return false } - if strings.Contains(string(output), noRulesMatchCriteria) { - return false, nil - } + return true +} + +func isFirewallRuleActive(ruleName string) bool { + args := []string{"advfirewall", "firewall", "show", "rule", "name=" + ruleName} - return true, nil + cmd := exec.Command("netsh", args...) + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + _, err := cmd.Output() + return err == nil } diff --git a/client/internal/acl/manager_create.go b/client/internal/acl/manager_create.go index 2fdca02ae1b..66185749baf 100644 --- a/client/internal/acl/manager_create.go +++ b/client/internal/acl/manager_create.go @@ -20,7 +20,7 @@ func Create(iface IFaceMapper) (manager *DefaultManager, err error) { return nil, err } if err := fm.AllowNetbird(); err != nil { - log.Errorf("failed to allow netbird interface traffic: %v", err) + log.Warnf("failed to allow netbird interface traffic: %v", err) } return newDefaultManager(fm), nil } From e2eef4e3fd6a96d5261dc09770f6c9e3afd6096e Mon Sep 17 00:00:00 2001 From: Yury Gargay Date: Fri, 27 Oct 2023 17:18:44 +0200 Subject: [PATCH 26/28] Pass JWT Claims Extractor to Integrations (#1258) --- go.mod | 2 +- go.sum | 4 ++-- management/server/http/handler.go | 7 ++++++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index e1596aab7e0..e186002f3d8 100644 --- a/go.mod +++ b/go.mod @@ -51,7 +51,7 @@ require ( github.com/miekg/dns v1.1.43 github.com/mitchellh/hashstructure/v2 v2.0.2 github.com/nadoo/ipset v0.5.0 - github.com/netbirdio/management-integrations/integrations v0.0.0-20231017101406-322cbabed3da + github.com/netbirdio/management-integrations/integrations v0.0.0-20231027143200-a966bce7db88 github.com/okta/okta-sdk-golang/v2 v2.18.0 github.com/patrickmn/go-cache v2.1.0+incompatible github.com/pion/logging v0.2.2 diff --git a/go.sum b/go.sum index 07c118e3cd7..f06eb08d0d6 100644 --- a/go.sum +++ b/go.sum @@ -495,8 +495,8 @@ github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRW github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/nadoo/ipset v0.5.0 h1:5GJUAuZ7ITQQQGne5J96AmFjRtI8Avlbk6CabzYWVUc= github.com/nadoo/ipset v0.5.0/go.mod h1:rYF5DQLRGGoQ8ZSWeK+6eX5amAuPqwFkWjhQlEITGJQ= -github.com/netbirdio/management-integrations/integrations v0.0.0-20231017101406-322cbabed3da h1:S1RoPhLTw3+IhHGnyfcQlj4aqIIaQdVd3SqaiK+MYFY= -github.com/netbirdio/management-integrations/integrations v0.0.0-20231017101406-322cbabed3da/go.mod h1:KSqjzHcqlodTWiuap5lRXxt5KT3vtYRoksL0KIrTK40= +github.com/netbirdio/management-integrations/integrations v0.0.0-20231027143200-a966bce7db88 h1:zhe8qseauBuYOS910jpl5sv8Tb+36zxQPXrwYXqll0g= +github.com/netbirdio/management-integrations/integrations v0.0.0-20231027143200-a966bce7db88/go.mod h1:KSqjzHcqlodTWiuap5lRXxt5KT3vtYRoksL0KIrTK40= github.com/netbirdio/service v0.0.0-20230215170314-b923b89432b0 h1:hirFRfx3grVA/9eEyjME5/z3nxdJlN9kfQpvWWPk32g= github.com/netbirdio/service v0.0.0-20230215170314-b923b89432b0/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM= github.com/netbirdio/systray v0.0.0-20221012095658-dc8eda872c0c h1:wK/s4nyZj/GF/kFJQjX6nqNfE0G3gcqd6hhnPCyp4sw= diff --git a/management/server/http/handler.go b/management/server/http/handler.go index 0d415a087e5..c589512e5d4 100644 --- a/management/server/http/handler.go +++ b/management/server/http/handler.go @@ -59,7 +59,12 @@ func APIHandler(accountManager s.AccountManager, jwtValidator jwtclaims.JWTValid AuthCfg: authCfg, } - integrations.RegisterHandlers(api.Router, accountManager) + claimsExtractor := jwtclaims.NewClaimsExtractor( + jwtclaims.WithAudience(authCfg.Audience), + jwtclaims.WithUserIDClaim(authCfg.UserIDClaim), + ) + + integrations.RegisterHandlers(api.Router, accountManager, claimsExtractor) api.addAccountsEndpoint() api.addPeersEndpoint() api.addUsersEndpoint() From 52f5101715e892cf12445f03e346becb33ee6c37 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Mon, 30 Oct 2023 09:25:33 +0100 Subject: [PATCH 27/28] Fix network route adding rule to filter table (#1266) Set filterTable only for ipv4 table --- client/internal/routemanager/client.go | 2 +- client/internal/routemanager/nftables_linux.go | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/client/internal/routemanager/client.go b/client/internal/routemanager/client.go index 62fe4dfc17a..fda7b012f34 100644 --- a/client/internal/routemanager/client.go +++ b/client/internal/routemanager/client.go @@ -119,7 +119,7 @@ func (c *clientNetwork) getBestRouteFromStatuses(routePeerStatuses map[string]ro log.Warnf("the network %s has not been assigned a routing peer as no peers from the list %s are currently connected", c.network, peers) } else if chosen != currID { - log.Infof("new chosen route is %s with peer %s with score %d", chosen, c.routes[chosen].Peer, chosenScore) + log.Infof("new chosen route is %s with peer %s with score %d for network %s", chosen, c.routes[chosen].Peer, chosenScore, c.network) } return chosen diff --git a/client/internal/routemanager/nftables_linux.go b/client/internal/routemanager/nftables_linux.go index 25dc6e7db1d..d6eac96a65c 100644 --- a/client/internal/routemanager/nftables_linux.go +++ b/client/internal/routemanager/nftables_linux.go @@ -135,7 +135,8 @@ func (n *nftablesManager) RestoreOrCreateContainers() error { } for _, table := range tables { - if table.Name == "filter" { + if table.Name == "filter" && table.Family == nftables.TableFamilyIPv4 { + log.Debugf("nftables: found filter table for ipv4") n.filterTable = table continue } From 6d4240a5ae2c1bc16a469dd407a8a6a858c833f0 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Mon, 30 Oct 2023 10:32:48 +0100 Subject: [PATCH 28/28] Feature/update check (#1232) Periodically fetch the latest available version, and the UI will shows a new menu for the download link. It checks both the daemon version and the UI version. --- .github/workflows/release.yml | 1 + .goreleaser_ui.yaml | 6 +- client/ui/client_ui.go | 145 ++++++++++++-- client/ui/connected.ico | Bin 110003 -> 0 bytes client/ui/connected.png | Bin 10290 -> 0 bytes client/ui/disconnected.ico | Bin 106176 -> 0 bytes client/ui/disconnected.png | Bin 6461 -> 0 bytes client/ui/netbird-systemtray-connected.ico | Bin 0 -> 4452 bytes client/ui/netbird-systemtray-connected.png | Bin 0 -> 7251 bytes client/ui/netbird-systemtray-default.ico | Bin 0 -> 2876 bytes client/ui/netbird-systemtray-default.png | Bin 0 -> 4938 bytes client/ui/netbird-systemtray-update-cloud.ico | Bin 0 -> 3647 bytes client/ui/netbird-systemtray-update-cloud.png | Bin 0 -> 5652 bytes client/ui/netbird-systemtray-update.ico | Bin 0 -> 4726 bytes client/ui/netbird-systemtray-update.png | Bin 0 -> 7521 bytes release_files/darwin-ui-installer.sh | 3 +- release_files/darwin_pkg/preinstall | 7 + version/update.go | 184 ++++++++++++++++++ version/update_test.go | 101 ++++++++++ version/url.go | 5 + version/url_darwin.go | 33 ++++ version/url_linux.go | 6 + version/url_windows.go | 19 ++ 23 files changed, 492 insertions(+), 18 deletions(-) delete mode 100644 client/ui/connected.ico delete mode 100644 client/ui/connected.png delete mode 100644 client/ui/disconnected.ico delete mode 100644 client/ui/disconnected.png create mode 100644 client/ui/netbird-systemtray-connected.ico create mode 100644 client/ui/netbird-systemtray-connected.png create mode 100644 client/ui/netbird-systemtray-default.ico create mode 100644 client/ui/netbird-systemtray-default.png create mode 100644 client/ui/netbird-systemtray-update-cloud.ico create mode 100644 client/ui/netbird-systemtray-update-cloud.png create mode 100644 client/ui/netbird-systemtray-update.ico create mode 100644 client/ui/netbird-systemtray-update.png create mode 100644 version/update.go create mode 100644 version/update_test.go create mode 100644 version/url.go create mode 100644 version/url_darwin.go create mode 100644 version/url_linux.go create mode 100644 version/url_windows.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3feefdd49c2..5833638c509 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,6 +17,7 @@ on: - 'release_files/**' - '**/Dockerfile' - '**/Dockerfile.*' + - 'client/ui/**' env: SIGN_PIPE_VER: "v0.0.9" diff --git a/.goreleaser_ui.yaml b/.goreleaser_ui.yaml index c6f7a7c3443..66a22ee34a2 100644 --- a/.goreleaser_ui.yaml +++ b/.goreleaser_ui.yaml @@ -54,7 +54,7 @@ nfpms: contents: - src: client/ui/netbird.desktop dst: /usr/share/applications/netbird.desktop - - src: client/ui/disconnected.png + - src: client/ui/netbird-systemtray-default.png dst: /usr/share/pixmaps/netbird.png dependencies: - netbird @@ -71,7 +71,7 @@ nfpms: contents: - src: client/ui/netbird.desktop dst: /usr/share/applications/netbird.desktop - - src: client/ui/disconnected.png + - src: client/ui/netbird-systemtray-default.png dst: /usr/share/pixmaps/netbird.png dependencies: - netbird @@ -91,4 +91,4 @@ uploads: mode: archive target: https://pkgs.wiretrustee.com/yum/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }} username: dev@wiretrustee.com - method: PUT \ No newline at end of file + method: PUT diff --git a/client/ui/client_ui.go b/client/ui/client_ui.go index 9c7685db03d..e66d03d951e 100644 --- a/client/ui/client_ui.go +++ b/client/ui/client_ui.go @@ -15,8 +15,10 @@ import ( "runtime" "strconv" "strings" + "sync" "syscall" "time" + "unicode" "fyne.io/fyne/v2" "fyne.io/fyne/v2/app" @@ -74,18 +76,30 @@ func main() { } } -//go:embed connected.ico +//go:embed netbird-systemtray-connected.ico var iconConnectedICO []byte -//go:embed connected.png +//go:embed netbird-systemtray-connected.png var iconConnectedPNG []byte -//go:embed disconnected.ico +//go:embed netbird-systemtray-default.ico var iconDisconnectedICO []byte -//go:embed disconnected.png +//go:embed netbird-systemtray-default.png var iconDisconnectedPNG []byte +//go:embed netbird-systemtray-update.ico +var iconUpdateICO []byte + +//go:embed netbird-systemtray-update.png +var iconUpdatePNG []byte + +//go:embed netbird-systemtray-update-cloud.ico +var iconUpdateCloudICO []byte + +//go:embed netbird-systemtray-update-cloud.png +var iconUpdateCloudPNG []byte + type serviceClient struct { ctx context.Context addr string @@ -93,14 +107,20 @@ type serviceClient struct { icConnected []byte icDisconnected []byte + icUpdate []byte + icUpdateCloud []byte // systray menu items - mStatus *systray.MenuItem - mUp *systray.MenuItem - mDown *systray.MenuItem - mAdminPanel *systray.MenuItem - mSettings *systray.MenuItem - mQuit *systray.MenuItem + mStatus *systray.MenuItem + mUp *systray.MenuItem + mDown *systray.MenuItem + mAdminPanel *systray.MenuItem + mSettings *systray.MenuItem + mAbout *systray.MenuItem + mVersionUI *systray.MenuItem + mVersionDaemon *systray.MenuItem + mUpdate *systray.MenuItem + mQuit *systray.MenuItem // application with main windows. app fyne.App @@ -118,6 +138,11 @@ type serviceClient struct { managementURL string preSharedKey string adminURL string + + update *version.Update + daemonVersion string + updateIndicationLock sync.Mutex + isUpdateIconActive bool } // newServiceClient instance constructor @@ -130,14 +155,20 @@ func newServiceClient(addr string, a fyne.App, showSettings bool) *serviceClient app: a, showSettings: showSettings, + update: version.NewUpdate(), } if runtime.GOOS == "windows" { s.icConnected = iconConnectedICO s.icDisconnected = iconDisconnectedICO + s.icUpdate = iconUpdateICO + s.icUpdateCloud = iconUpdateCloudICO + } else { s.icConnected = iconConnectedPNG s.icDisconnected = iconDisconnectedPNG + s.icUpdate = iconUpdatePNG + s.icUpdateCloud = iconUpdateCloudPNG } if showSettings { @@ -328,19 +359,53 @@ func (s *serviceClient) updateStatus() error { return err } + s.updateIndicationLock.Lock() + defer s.updateIndicationLock.Unlock() + + var systrayIconState bool if status.Status == string(internal.StatusConnected) && !s.mUp.Disabled() { - systray.SetIcon(s.icConnected) + if !s.isUpdateIconActive { + systray.SetIcon(s.icConnected) + } systray.SetTooltip("NetBird (Connected)") s.mStatus.SetTitle("Connected") s.mUp.Disable() s.mDown.Enable() + systrayIconState = true } else if status.Status != string(internal.StatusConnected) && s.mUp.Disabled() { - systray.SetIcon(s.icDisconnected) + if !s.isUpdateIconActive { + systray.SetIcon(s.icDisconnected) + } systray.SetTooltip("NetBird (Disconnected)") s.mStatus.SetTitle("Disconnected") s.mDown.Disable() s.mUp.Enable() + systrayIconState = false + } + + // the updater struct notify by the upgrades available only, but if meanwhile the daemon has successfully + // updated must reset the mUpdate visibility state + if s.daemonVersion != status.DaemonVersion { + s.mUpdate.Hide() + s.daemonVersion = status.DaemonVersion + + s.isUpdateIconActive = s.update.SetDaemonVersion(status.DaemonVersion) + if !s.isUpdateIconActive { + if systrayIconState { + systray.SetIcon(s.icConnected) + s.mAbout.SetIcon(s.icConnected) + } else { + systray.SetIcon(s.icDisconnected) + s.mAbout.SetIcon(s.icDisconnected) + } + } + + daemonVersionTitle := normalizedVersion(s.daemonVersion) + s.mVersionDaemon.SetTitle(fmt.Sprintf("Daemon: %s", daemonVersionTitle)) + s.mVersionDaemon.SetTooltip(fmt.Sprintf("Daemon version: %s", daemonVersionTitle)) + s.mVersionDaemon.Show() } + return nil }, &backoff.ExponentialBackOff{ InitialInterval: time.Second, @@ -374,11 +439,24 @@ func (s *serviceClient) onTrayReady() { systray.AddSeparator() s.mSettings = systray.AddMenuItem("Settings", "Settings of the application") systray.AddSeparator() - v := systray.AddMenuItem("v"+version.NetbirdVersion(), "Client Version: "+version.NetbirdVersion()) - v.Disable() + + s.mAbout = systray.AddMenuItem("About", "About") + s.mAbout.SetIcon(s.icDisconnected) + versionString := normalizedVersion(version.NetbirdVersion()) + s.mVersionUI = s.mAbout.AddSubMenuItem(fmt.Sprintf("GUI: %s", versionString), fmt.Sprintf("GUI Version: %s", versionString)) + s.mVersionUI.Disable() + + s.mVersionDaemon = s.mAbout.AddSubMenuItem("", "") + s.mVersionDaemon.Disable() + s.mVersionDaemon.Hide() + + s.mUpdate = s.mAbout.AddSubMenuItem("Download latest version", "Download latest version") + s.mUpdate.Hide() + systray.AddSeparator() s.mQuit = systray.AddMenuItem("Quit", "Quit the client app") + s.update.SetOnUpdateListener(s.onUpdateAvailable) go func() { s.getSrvConfig() for { @@ -436,6 +514,11 @@ func (s *serviceClient) onTrayReady() { case <-s.mQuit.ClickedCh: systray.Quit() return + case <-s.mUpdate.ClickedCh: + err := openURL(version.DownloadUrl()) + if err != nil { + log.Errorf("%s", err) + } } if err != nil { log.Errorf("process connection: %v", err) @@ -444,6 +527,14 @@ func (s *serviceClient) onTrayReady() { }() } +func normalizedVersion(version string) string { + versionString := version + if unicode.IsDigit(rune(versionString[0])) { + versionString = fmt.Sprintf("v%s", versionString) + } + return versionString +} + func (s *serviceClient) onTrayExit() {} // getSrvClient connection to the service. @@ -504,6 +595,32 @@ func (s *serviceClient) getSrvConfig() { } } +func (s *serviceClient) onUpdateAvailable() { + s.updateIndicationLock.Lock() + defer s.updateIndicationLock.Unlock() + + s.mUpdate.Show() + s.mAbout.SetIcon(s.icUpdateCloud) + + s.isUpdateIconActive = true + systray.SetIcon(s.icUpdate) +} + +func openURL(url string) error { + var err error + switch runtime.GOOS { + case "windows": + err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + case "darwin": + err = exec.Command("open", url).Start() + case "linux": + err = exec.Command("xdg-open", url).Start() + default: + err = fmt.Errorf("unsupported platform") + } + return err +} + // checkPIDFile exists and return error, or write new. func checkPIDFile() error { pidFile := path.Join(os.TempDir(), "wiretrustee-ui.pid") diff --git a/client/ui/connected.ico b/client/ui/connected.ico deleted file mode 100644 index 3dd598fa70df6209567ef4613b08d4274b0ca8c0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 110003 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 b2LSaA9%w(j9p>mQ0RevaIt$-sio^X6IH3q4 diff --git a/client/ui/connected.png b/client/ui/connected.png deleted file mode 100644 index 64b25ade12e36b1b1f8ca695f4fac45c38cf162a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10290 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 QuO&71k2Z{ED`d}jv3NLULD{gJTl*vu{%wiJF- zshoVjmLx0`t{E9|^P@2=y$1=?*5>3d^v1B3)=;xMH{TY+g13{fwveLsmPujQ{G%jn z`;6%mTFZ8og{E6inmBeQDn-x|p*C520dRKdGHL9n*^Vh+Q#T${wsHNG6F+OD`m}jB zt&8?$dbXY4Ub)R8JF|3ppMlobjt$$seG=7L@qm%uD~ng@vt}jwt6BwX-ylauOSD-d z5heAm^VK&;)T3GqHdVJ$*6#e)+d6fY`toyGkJMgR^>W@5dE(NS??3atcnA7?`(mEA z-u>srtODvbQT!=KhiV#~QJH0R-szmh3DPl%lAcUCx~_*J^*m`pD2*&xHjUKXr|ldK zW0^7;X3zA&>DK-q!f5ior0J?_O}9LBIkh6QUwTKnE!CS8Z*My5sY`-FwAuiy*FUFn z9w!Ayv>B>WILALd?A)=Gt&0_@5=O^%+&3Py_-hxs@0AE;rU_#(Hoo+ky;48y-u?>< zH@0C8ng4VA^}Dna(aA3jM-(VXQ*(E#czzz8nMs%K^|~OXRjPZ|<8G^h;!DSnx{tIO zGez^u5jK(L{jf8(z}-fA9$n6q z(bH>Kyh8NFBDa&+zCB;V|0}w`)9Y!A`yTBWc@Gyyj+}L(=)o4Zi*~x7AFdC%dO!N@ zLFJbR*KhYw`Z=mFb;~#ZcGT;GF3oj&5#5)n2#v9dlP%TU5|(=>o&&DCF+1jF7zf<8 z&@}lny(?+rl>EK7={x0emZq;{ggiWz_Ejdt%RB6#gwfGXb2MVD-gTog!fE3= zw?NlnHa2nAsV^z*?G>3>G~4~XZzq|X>nFsibi}TnDSZ)=*_pMPJ_T@2DV}^qwQR~Y zhnLfh7Ce7G&bG8OYgleZ{H6sKeqC(KNIu<2*@1Iq#{@_BIq!40>kzem60y`wy8Zj| z)|2LSkxafHIDM_l`WDFw$}3BTNaT}d(G9v?3%^c2w&r%E=D7E^UfZ9XpX8J?so3N zts-*h8^sx3nyv@OzX;KYDrs-uo@sKx;A{MfH|xiB&c@uZmC}(4&#AAK z!i?^QfPN&IYi4SX{`cIQjF{Qu^|oFMGzdOx$Xp+yW0^6m*T`3&R>k)y+WOr0>_3!r zLyyV$83;%+aglh<+eqrUD2}g4P(c@KcsVb5dE6zowkYBeM`ERq;60fA!R(+ z)};^gt%o^j`rVX+lk(3@DOV;%gsnNf%4f%=LqQVeEm$${mVGsJ4%3QS`%f{`<`F5~ z&u<`o?2E}A3l$F(J@1w3CjUm>rH!KdUA5GdkY}T22bLHxCoweo4Z3wtA(}OO0d@O* zjriyv(+-TWKcJ#djh8q<-+3oHXtkmIxqw42I(_RtEO)>wX3HZ7OZ;Cx%dnAz?jc@g z6=~0g+jA2StI%zz-7Ht9mhdz0dSso#V+o?Wg%+`=G}l)EG}8&zv0?AzxVD z>->w!H)7~YR9iX66N^-*j>u%5QgK#I+LEzoMts@8EA%O)-Af8q4b1NU^WW1eGDCXZ zouoo~xYwDgurPbVf!c@i@z)o2gRYK}Dfzn^$MQjQ%+C!t-SHk~M<|#`)f+ zdqqXQbzJJZ`E6>Rk!oKNo5lL8s6 zwK$#ikN3A7H+`*@PD+PAm~n@pp0@NrfMTz}r+Gu3p9{a8&|6=k!(leX_5cCtmL%z@W%rr3;7ONvGF8>55;r#J$+@jv*gix z>-Es+-ET>>6aQ`_U)Hr+>FF3nHh|l zV(Kwv)KE{d4Q9Ts`0B*icYb|7cX>U)cV(x01e`RnW0x1#)&T@u$X7|TFiy%`EGXxB!)RUdv9f~ zJMAG8kw0EBamTgNyo@L_!$}j?7?_$mAB%DEJk#pJnSHzEb38{DzSN{TT#orSWSzf^ z!FQPq+v1$DDo^BR%lAe&9h9@J69KDH`FW2pC%XnpsnGlyPKyZD;#_O<&kqI40CI1 zCbNFdGfqoFNbGg(_Xl)T?o0{RPh@>{?-y?DD=Fug z5FMi__uobg>#%pwuF>DMR(7^FzFsuBfB3u}+Iv}G51Ch~ky6x+uRDFoE1a7C-GsK% z?3LuB{0%9uM)sNWv!8M}<%U_I3}akwUQj!`;b*BiWvzy$=I!`2sf6nF)hYa8TF#y; zN7QAU&Qqn$vSl(HOq3G6+tHQX@4gI+irjDgc)(|gaEA4ly9$p!KAzR}V4K5tjPH7> z6)R&{jKvTCyzK9!x9e~J@L_}+818&Gq4frLJ=^aI2A;R*ly>WiRWa;r-ogDbC7rZ0 zilrZ3b)4X97nw-!bH26Bjk5gP;hmqunC6~xUlKM$c11WjLblK&OR2}frTVK`nI~LsNV;jcPGN!dR*o`+cvdW$9?~NKlZLT@X6q% z-@BbBVd*j%p7e5wnYsff>+4UWZ8!y@Q1l|}=f1FXeR1AYDaZP$BnCZ4_@ppdI|tJ# zodymMHy$7JT0S>I`kXEWUe4h$6cywBM;#8RZ8pTrT4%2@opSWm$lE~GSos|FTd8XH zerlU9X8ZdcWou53>1!mbxoq0Wq2XSoM;9yP2JK#;_Vr$H6s4Wqq&XN?II3{k0bOa9 ze)pBL-oADjX5r8^dp&KmMVwk|H3!psqh~51`*2frRJ`RLe$!&B$MTSlhDZ0RE7KJ$ zBWz8St{j=&0-4weiLzr65gmtjSRMPs%W}Eytydj}1}INGb9v0&jG5ioHl}dwb66Y3 zO6wN1*R<3nSJM{eFEwm==fALRXU`1BFnIjhw=`j7Onsc|K~sqL#mCX8ve<*ZwVa*Xb-; zDuG$_YH6kL^Nxo*#dJ#0E6F`e3nujl*UAU^zrcU#R=;-ag*!pmsG-kB??guvoH<{2EltH&G3c9TPw|Iz)`Kyr^ z-*zcB24Tih+66wg#;{#d83w^Y(tS)BiF3NFV!g2KuPQy3ev7tq*@UIY3i>!m1WCAk z2s^y$ihk0NaN~u^t`}9LP2S05l%jjiu`Ol7B&SwyK7}3L^JMg~JU#P~hojXc3SP;d zIDMdq-9H!{ppD)eN;|8V^WI1QW2(c3)nCR>{B(Ge_BHRIPd(XkkL}|tsSiZ#kw205 z(xHFX-mU>d+q)-H+W9@4hGGA1ve#gCwFt^uxbV;eJ@ea^1!vw#UFDaY@k{HWA zenUe%yIx)`XT5NxkT(QHEj^dgA^2kFL9Q^Uj*GY5AI_%3mfq!zo$VTfVXIj$G_Jh< z*w2}oM>F?a^>7I?LPuTZgEKBS7e?snp1*Z2cIEqE|86htKRX{6Ysa?HR_R)kY_hhferj4ZXEGPEHfntk>~7uO8B)fc?n* zocQXW6g?eHui#tHKKXf=_uI2|NVwUjRb97XSij@BJG)U~s860S?*MM(o>yb zKd-f(iXG8iWqreJs9fQ;tcBU%jy?VMK3cwX_-3aNwapG;W~r;X9f2>zVK-v#zl`V@ z`fNeFHJ5@`#GWyj`fr#@T)0V)jh6gukl&E!U+y0rB6-3gr(^}|g{y0__thwc6+R4AG(-qS?#Pj}6W0}Y_Mqh&qph2?lRG&2UMdbX|ra_iL@?a<(7^`KAO!(_Wir;8JG`?{tmM4J>GSN@OP3FuE6 zv;RbH_N3<@0}7V@m%sl|DSa4iUI3$o0n)!q=WGqCjPn(jaE*|_%RkPBpQgsb)A%}X zK;BUa|0sn%?kf)Lr{ADy4s}*sWIb1D$Q4zIw69LC#iK~}+bq|fdJsURnJzsLbumyU z_S^YIL3z?{ZNII%ZBXiuIkq=!X-i)%G0RUgR(0aI_dB8%hwI3O4x%Z%O?R48JdUKj z_r8?E`J@c_JP?h$%5en?58YU+w<<@-zG)}r;Poa)S4;dTeSX7<&zX<&bn$?|S}ODDd4C3)TXQl3HZr*PVxw;ix4P}_?BdeO#Rb9}YV zuXH|5-C1h!qX#3Tz*oY*6}L-aHi$}ZYO6ok`WOG_Ia1m-(CexF$| z%QvV)^4qo830fWbAm*sq7Wy39TQt z(Bf&?jhMJHbBnb1_l9SCwqFyn2fNLerRsGU@^UFTf>hYv|CQ^^3){8Rq@`$C>!*&)DiN)HbiVyNVB>W}+7xH6r`pteu8TUGGHJ#VX>#EEr6(84^3Zwg~ zoJ^WXN;p^gVwM**_IP2`40S14n#RGtBl7gix+g=ItTC+vX1C*tyYz^HV6`@$1%tO3 zElfUtT#iXHJW{ehD=cv=DPijcr!Qps{USG;!yXQ!FVE9!-R7;7V~4oVtOJSTS0~*_ zo<{$lT<=g>MPEQ8jR63;T=lGnWviv+Ye>C_ZT9o{6i1+8f zD@U%5_P5zuIBD_jTW=^`7WiZH?|r-SsY|-7ll|bg`cEw<%X*k;s~?;5X7I_PBfhBE zesSS6`cCJo_L6Bioo21SeYnVSYthW2FWY@h8T&iRQl;Ueu8X6w@rirA7_E=AW7zsC zZVk)%>O+ft*j}0*t%*&~F1qHpyY2jMPLy>PUDO7eE(lKo)Tgi`6J`gFP#hY*+Iapl zlTjD8_WY{B40Sl;>Yb%?bv#M?&J!bdnUCvQkK1Yz61Vh z3~G|!h@#*y*^njMUdugrWozoaf1Fc*isg!=luhK1y7aC#<2uk;b~-Uf_O#c0Ld_}i zO#GN)Z0dbrTvw8EbZ3?ly+19>Vwhv!g{73EtNle{t9#$qwYx=!k6!E@;gdF*9zU$f8zTeiEPB`2jqq5yc z3|iKaz5N3M5+YLNfvL*+mF!+H_m6c(gG4DmW`qBO1?HPqVI_Gussov%Dnu?CCy5* z!L%r*6Bv$04;A_E8;lQ|2r?;yc%5*3TVTIxR-gBrU?6zD!Q<$)JQ8zFzOVK5y!hR^sYUAr{b5^Ry%g+os!7yw`p0Od)<;p1}4pXP0O07zV6=` zWuNG_o!{x7eLt_v52%^;GG9S!d6?7VeKEdL$Fsr?58kP!o1EIeh}BIy(sfeN(Ud^i zlrDpBbtBD>DEBldnet;#$dQhpJGVO0V^xCJ+ubJ8pnv7oyTC9tBL3+#Ypt?`>a26t z@s!eq@|R4cvEWuiA5+@-kQp=1{HHCo^;T%EN6KBA`HoSCd@*o$9rU;vMpIumb?xKe zE^C-?q`&3u`7o2A71cd<9F|PRG}R52qVv7IO-4NbP8+@S$h9fnQ?ngz7kds=B59jm zDA5GZVb;Uzx3=~y9lp+TThO8M4eyd@^PCfwz%#P*rKU{Ur?5RH{cfsbd zK{u12Q@wOlFbk~~b3A%U0tlowPAHCcku4(Y9gXQ<}KT_W5kBiejl4(&uIWDpEUgXBiC5zm$o%}x7 z=nRvXueZoik=dR>k(ZtIWi(J_uqcw2KaP>r(<{~ln>vSam|74@vr0J9vD5cty)krc ztMQjpT+Yt;C_%S)Dl>h(>4WY%6Xr))rL^?up{ZeRnv`I;MW-XOWBGJHFa+z56L$`~ zw52W6F>cXfnJ}{L(Io>g>J#eD*ts9trakL)?b0o z=NI&m@VAINA!!S>jWbnl8qGT?aiX93qMr_oyhrb5s$tI@mvo##G4&cqA3WOMF*&}c zZhKoByB22*u;7(abHg>qbjta#wda_Kn=X_>|E#Ol%P+P;Pjc_{AZT$K^T5`g=N^`B zQO{n=$P~qN!nSSKGwxu!75Zg<){QWRxe{#Bxaniz+j3Js_)SF@U?K(0v~d1N*q(tH z8@LyUgpdFs0YU zBtS@jkN_b8LIQ*Y2ni4pAS6IYfRI3i1hhT#WdYm(sDBh}%BPZ-BQ}j=^1KpdkIIx)INT|7P34rdM{sjG# zS69(JvH^3}51pmCu-^so$4>&F`<4J{zq1XH*W^_`_xSUVQVW(pKi-Kljs!sWvwm0o zlh^UkJ;x5pbgWshB#`*yCxJm;ObqlN`McQwnZj51oIb&V^+3;=iMA*r{`g1$bgv0e z^1Ijo*+Br^=kb z0Pw9j+W^@~NZsRhv6!`)Klel_k^ty_b+gsKGw9wzi02;HJxa}41v*PvJqRuHmjLKq z8sKHKvjJDo{o;D+9=8Jv)@Ab%=dZx2JSF&yi{M~ne?)v}~HK`3yLHE`o)IHt@ShAM=-P#q5d(i#P zCeyzM=zd8JpFXa?hNkAMEb?+zJHe#z_ud7%R{;1=>^6XOZ_`M+$8BdZYv13iS;096 z-CK)Q|C{p5A%3Iw`5at#qDhOPf9Wr{B>v7jwKf+>fc~F|#Rib>SBORT_}sv)zf-Hi zaSgg32~gJ9Ho%VE5zVtLc>iIY3!AzUe|1R!bbp~S^&FdlWPNPEs4k&r{u0pI zlt%*n-)o2suw!?GiQmAV(Z)_KSogH4a7F_0$5#TNdtHFi`q%(@BRu!bQTMoQS?4a~ z%REuYmH_D9PiXx&Bz^&TF=B z$@1XCI#I-x0O+0~kp5j^KBu9vJ2iFGn0a$nK70?V5sKz70nmL5fE2zqAj;UC#?*Pu zwimN5^5>o?MG^qrPv@zBQOEAo)KMen!8bL8&@z7s%(+yKY~a=}Ho%VEX=1-_1MNfG zBR^VeYa#JH$lnBz{s#d3tgrzTzp?c9);$`paQT^62m3@ZNC0GYfGsbW!*BH1od!PF z0?ju-{1I3G8CstCtq3LamjKABz4oU3Z1VEo{JH1a7MhRd>K~=<{JAGewKwNW!gwiC zpl3@Q&u;^9pmpU{^pEDfRR}HDE&;Mf0h$vy2G4)v#q2gvpTgQDT4z{A|LFI*+PVL` zuMXS89{=ybWySXIi&Q<2ZVeDU1|0w>A*jKEExr0ooxbE>(tTCL8VQbda-u+eW zAWmm|83x{`02J3DAGZ7i+Mh|xy5*(witDiEPypW0y4YTSn+*WZMgaK&$frVvzndQz zy|)D9@5#{&wQyr*#D-#-SFI_w?5BlBh}FczePQGMg5}UpJoHVQzwArddQ=88KSuXQSRd-Y8$AW@2#DF z{{7#fKS}?f^R@u-BGWzI2MDugtElx`M|*YneCo0Z6#FvhPp|=P&pbKc<-VwOkGIz> zV?)4Ch2}1C8ta)xXzncktxW*l>s&8i`uWJ{4V3+M=MKS|PPE6*89sc~S01(3L$Sfl$|qp|92P%fF7Fk>@W5?4z<0L#c&PnG1!Q9kJiRF>vM;IzLky0 zzreC>l6Y~5O{%A_Phh@4w)b^BbDQPbA)w6+fM@KESnUF>4Hk2(SdG|jVRS0&z0L02 zA<*qifXcO6{Qa83@>{R_e>uCc5&Qo}?)Pi*{{dv(Y|b45S|$J#0@S3lY4a#nOdR_I zpz(g)v0b8;g=cdZ3xdu^0OU41-Q(YbjlCBfdguSTrbE=auC2WSka_o}v;n}c1Hf#q zy2t5iwEh0!eS%^UaXd|s1~iraf!F>3Uz&ht;pL6ac58%pe?8+mgr}eXd(}wXHUK*B z0f5FJqUNR1+7orG72kM{xf*&iC_l+m0*Gv^Q^pDYR+ zfNu&Ezj6GJ(>?wkSji_w0OHAO*{4O+dTOlplC?MIHR#--Ayjt!#{NH5_iVmuTRQ~b zhisqD@Au5tw57ET=>tGMoBn9s<9$GtGlGzR=r?-L*VK2-Jo3qRs*epoz4d>C?%6zU z$}c0Y%>5zc+T5D5Zm#*G!s;JnvJ{|s#BVg-_z39>&7GlhCNuze?=#)FG&Hu|pnX)x zg4zJwi_Tv{@nwxoS5vl+)*Yg=1v=nz(cp}A8h33<)5$c7ytx2pm1hd_mdmXgSUf_ME;of!tqXAGqc^Uxb7uoO2=E^4v=tI6~wB7*qWA&N` zLHi6t-8%rymCuII0f=nw8`?s96NqGA3TQ`AzQl1~F3_VawBiM<0ByGh=mRhk0L1}Y1EBt6GXU}< z`2w5)xC9Uca0MV3fCg|8;1s|SfL#D?0CoUqJURwo0D#!`Mdaf93aL2sB2FA<1ytu> ze;ept4?tY~nz(ELjq8U0^~x1c03a)L7AcCW5SM<$fhU%{C7;-5T5uca1^Q?FVK%T> zK=z5-Kmwq91%SXm$Oh1!d#wrO3N8WUHv*Y00YGc;#nDeUHjZtAb5GofO8|7Q0&w{c zumQAI@2}fu8rNe(Q;?kjz}?^64uTshhrhZ$XWj_e0rF=wt98#p*yY&2T8AQH9^~B( z0QtL6d_4NjZtVBBsM=2gzxN-#&$NhCEO0ZR`>p^M0LKAd0HFCOQR%BO+F2BtS@jkN_b8LIQ*Y2ni4pAS6IYfRF$o0YUos(C?%WLNA$zV;5Rpe_j#3Nt9 z5Le?Bd9HYk8v2PK+nxrpvO22RiOVh&t@pSvVAr~ z^cs0;WuDDoWtPog6>``NRw0L-uR;!n7_34Lh8Rk((jLmwtL6>MapUD&g?!awV6ZBB z#9&qQ7-*~-deB%k^uOdKtCIr^RwD-(qH^%#dqU#msBy`{^Z0%4>Fff9iah6r3N|?m z0o$DWDohloQ>~o)$K?YzNu1A$JXa2E4YI3n2&&A}*%vCFIWB_AJO@E#ugT>E$B$E3 znde}v%oD$m1kk3@MgUX*64o95hd(0>+cS^@>^$)wApt@Hgail)5E39HKuCax1Ym6o z+AGWuz^Kt-Q@#?O&Xdq54@8A9*9B)==`NR&+oHfIX5N0wc!Td z(Oz;5S`$v*C;;!A^G#VgmaJ~I(b1IGfPepb+Wg1mayumJwUc&-W379v1NdGU*1T(bL9Z%IvI$IH$m|L@2e31qCS`W zm%anW4}j+h?BU+5_I(AKq``mjq?6WLu|K^;?k5*Ba0pAk=knL5~L$%*z3ViGG>Bs6*rtMno*{_Q4CD0-*YCdag z5AdKfG4lBFj+cpa>pY>e}cPHTwNuadF0<73aW- zl5f-&@Qlta$r6}%{Cx=Lkk-f6tJSe)kNIljx#qP-nL7fWdjez%%RBpiZK+t0TEyrB)mjnhy)MgD(2JmY0({44_e zXnvxrFfxGqr2rcBYz#P`nXe4UYZ|QgsYO{$_GJ6955m2|wqLLNe~EWMV_W!cZY2B z?L49PA(|6`Z$uRTAO_rKyq*KVy+I84Yx0;$cFH@d4d2OP!i~nR@-<}u*OmeN;=gDl zn$Q^?R{&-KNORArZk+c_?a%R+TKG2rTATn-pZ^A+6hKruL1XbmfWrXj49Q>S?%-N? zfbRgJ!pW~46u;K9rVOg)p&X5Sv;YvjXm4+He&uO^8vro?@c?K%oD6{Wf`1AS4S@9O z3*Zi53ZMrdSG8?kzu`XQOZj#FtZ`%#$cv7qybk=&1t5LTA)ZnpZwS{RK`Ck<_UYhkU;zjbJqR&-$rT_> zM|nu;C0A!extu{6n?r;P7+}-DPT>Z2+JOJiJ`vgg7629dMD!x|i2!jBe}n`G3H$~UfbU=( z0DDmZ#G0&txf!vysziLd!e(qcmUc_ z3{^r+9;)o|QN6F^O?Tye<}5#P(a+VvIH12`KKz%M6+vjOe$P+nPyZ1Y4{#1VdX{NO z2R!%Ks7_y{ta)6AXZ{YfC$@EGfJ+_L5fnBwei(nA(V7HkbGF!Y0Pkdo<@<@_Z)mCw zdnW+>X#n+(?G%)Ez=7;PAI0d@D|WZ04WU?GcyBXYVx{Y;>=EwJ@e}#N2qe3xj(?CYP93yAGGgB zO`8xj59Y*QGC&Gp$O!2Gqi=~R?}(B$Y->GZ|*C{78<0IBOi-2ad#^9H+0xw=re6TQfgmlKME$(%+rxos# zt&`rWH=ud|_qpxVg@;p)`qRqqT>iYEwcgd&d-B4uwJKeL{05p^0^AmgcCOyhdK^J? zAjG;Iw4M$;&w~BIz36-WYJF(O>yY_Ybvs1U%MN z@mVLz%YM*x7e`CwMa%|R69?-r7)z)D`9{FcQNjr*{ zy#+8IpfvykprLeEtzXS|2W<}lSPgIjAQk|P&jgKEMc=P5{VXH5y6W zXp8T<4PLa==Q`}Cg1(pM0HAeK4WS#ai|k#jzB|M>;Oql{o()kvZ?B*ovH9+By@Brn z_0fANxOc9G5$WvE#TPg=PUEhCy{egkkC2l#WvH5Ky@( zN-%sx#7^NqSW&~o+<`M_8byq8(sXnyv8|<4!WC3oYy71RJM`6ygMjN4#)lva)cL&I?Nkg6c zz?-vn)Y4qNE&*=OYWUIqR6KlWRqUV$eCECDg|J2?z%2)W{LSUar!KHB-kf!{MpO09 z0&Wwuk6MM_st(^jTMlUcWmEpxdLyk;5%BkdJ&lU1$hYbmmX0_6127hz5IQD}cW*fE|E0 zz==A*v$7`f>u;0*$FETNKNGwqh2SA61b0jcKpA*N5R|jsFR-kP3a$w%+vVa8jtN8X zgBXGjL=Bu33h4lJ@N!T&0NxGoa7dzxs1hB3iToPX{*a#n+!AbGMTMUNMr_UX++ld0 z&|V7hzkq9gJjA@&0V{m}pdR|Zz6(&2`RI1St}3VKK4?N_Au zSg4lx5A^K{UO~3J-D|7MzUGqm5Y7QWW7LW^tNgymt=|i-83Uj=ZwdhFK2TqZTLsqB09ee8q`qPM+}8QBI! z43S-!`A(m|;rBcDyzYJ7=Q;Ov9?$1}JlCZACc1RgSEvC1pwrjWegFU<(iQ|zLP-z1 zz)DxrOT34c)_r{~Ek6H1KUWWL7XT1Vi%L_|8_?uPSSIL|nL&2=__KY=_IPaOog|gi3SR%T@#zWF{`F}ZzG>7iFPZ*4+;!%^(v75&Pj?z) zf6}`OX$ckZr(UnVdq$`orQVR7@GKj$Ir(uTdFdl@CARTb6|Ske~kz+Grd&a3x?>ESqUSP4*fL9mm{ZnUI9&acK2DI^K)!l zu81a%vt#Nz!ILcOOt5EZ)=5`$_m+Ra;_MGKTp$HU*p!e~U@2e>F9x~`zbgDh5N!Fn z*N^K$Ql}~Sw0aB{0gC5n_p~OA+S20UTsz#$=JFiG_Bt?TBZE$g^k1M(hPv7S@!wU{ zQ}LE0q4L+W4gvrg=6?$W^i3Wl4#kX=| zU6tkCNVju(%4>0qcmChsLi_)N@F&qqsP*6Ee8%Xnxc3xoh+rpcMzRCXh7F$m5P<<{ zlu*M#b`TNZCg-N(hW>ELzAj76Ju9q|m<7F1N8dyTpt+E-&gnUUjrcS8J0S5Ca@n*> zgX(5iyc(wVI?a5mA4`{ju0BMK*p?6H z12G=aUz8x5soeH;4miLj(F6t|;Qb?{2A35m(yA(2LJ@Oxp-U3g(*{qDb3FM-8%g); zosLtV4Q|Z-G9IEi(}aPO`?LC&)-s8k1gOC^!F*f&ZPN}Oc||@T6zzW_{Ee#o4ncop5Fr~pYY(`l%>*bVJnctYk8+}&U(ve12CWG9R3~+Wj%E1hS#SY zjkTMK$FA;_LgLo_4(p4;8T9KHA)_+Ng++=-au%-Rx9=d zu#GaO#OUfGA3K)*-QYvfaR6;|2+1=cg1ceG<>-1Fa~3abc9r?hMj+;3H-GGgTok3W zomeQ;pAQ~B4tBM0I0v0?ALf|So!*Vw_0e=VYGn#@t-vVb^vS%)w%>*~v}+hk4i|(Z za$Xs>Cy%@nkWP6E8C8WpQs=6nbwqV3G7t2{x#SYd|3)ya{QZ*$9H%y#(tN|ve5PYT zJ90>{wu2eS7|e+m%zJ!TjSr7@r}e#zwFEkS$>Hy%r1yVy=?h23BvQ zKI6Z5GE@!8D2btY5nae-&{Oj~T)~8nmzXgeP+X%;OKE}DJ#D{>|f?4a@$!*I9k(no7L;lV!8^51yzK=d6N;M z6K)rY0qH*3c^oIZ`95Hqyu0r2dnZnko^YxK+I6>jOM;etp|5or?jFlLvf=I-AZsO- zfw%`-st{fsFGO%5c%Q^LblQ7)X7JEvyx;{s-e4Aa(QH4dA(R71h+j?l8QL$Tn8tkz zqyWa?APQZ{i3Ev ztXE;ETNz_c?d;x58+Fb_F(0bd>Y^S5#2OSlL^gV&$O zuIzNHJKusIhy$A|rZ}hM9{$~<#EQE7=SFDABDby78f$~<4 z|BEj%I|^o3gD7uh?FGjqii>n5k807eBC08a$u>p^n`rvj#lPLA`Q%-5_rquUj ztUo0={1nUnusxM`Ums=X@?9T|`T3lM5}mNvp-R~tJMx4+G`Bo@^$qHAmRm-Bn=v!c zeCUgJ-rk_{Lo(^p5u#&x3ea4-*TO~^Yo{+XX-^a8e`ey3R+y!zbL_EPSk#@dm;3ts z=S(qfBAA+ar01=o$G4lYPIUH1k?7KD@P- zvzIkY#sAgCJH!(F$;$_)Jag!`6EjUzH#yllk}uLCGz8z|F{H0wb-dZjF98;FO5~A) zc;UZzxuNHlov3rTE6Ef(3*dGFS6Mb)i|nj^|K-3@ok^5~QH>_opOt-hfHGx{Yvo1B z`%?J+sOhttgpbAX@1;btH{+I_P$2~!%~1)CbtU$3t!psM0k2*P5SNW0>f)^Wxq`L1 zD~+P({6_etA?csV5#!q^#H&*c+F)lEl=76V4m zMj zMdSP|npC98AR#6?^maD#_aNe8T+H8|f97`R^r5djk=3o+L^+MciNVBwR|%r#0cgo7|vLI*pp@T7!@ zC#ds1+M%?^>o3Pfu&shtG`Ujcun*V;20r6o5Rk+hMO z0AZ~ms&L6YHpl$_h?v{5+SS5ey?gaP$+&7qZ$=Op){e{a6eFofG!m{AYcX9>Ln7vI zrN5V1yrrez*kqa`!UHax8a?#uH3&%z?iRNjJ!y>xVk#c0@cMV+&rbA!V+n8# z@+ml$`Y0eqaByRL=XfanrWaon2ixbP(Lt%qU^QZBe}0U>i_?ukV)>!NiqAQ1sHizV zYG!A@FdZ;5f%t;eUwDoa;!SPJXDm?a@}HmYGWbiqJYY}R@qU9p(*V?CP2N1e-B=ty zG4rb2RaCkcca&lPluLmvK_YDtd-J!a1HXmtxV}A{YEtMZAU1cvhp`PJBrjvawfY`T z=jggiLM8=6JA)vn2C$Fr{1V#{1Q|yi!yzKubklTkXjZ8&5mQU&HJa2q!F+Ol1(nrf<|(>a!=&Z^EV~8QZTZTSWVwReNZBTh5A=M$^EK>s??$jI{KW_B<(jNqE~lY^<3IX4kd}E5`3(# z_N8h0-Pr8Hs^}&9y&U#tYj_1I*Z_rm>(idM9SoOJne!XBnjC{f%`FQBft&0|FT^A> z#zwj1ddFf#wg6JnIDA`c2um~VG%z!%+F3vhar$5=F4=xAXAD4d->5|iVZN#GgY+^e z^R&h$+)0cXW4Wcq{{=gYF0^=_p$EJX0VgAAoTmNd|5)ZqR0n{ismJc+UfOv639o&V z&0zQSqS4F1@5BRIBIijJl8!%P&;wv&hh;TBj^l1fuUw2v+5U0vI<@EJ4TD9>buW(= z`%xbaaS18Q6nS1UUr&RJnlZ~A@*+ae^9OG52*&V zvduhQ_70v47pU}MLrsyGF9b*mfO}qLNi$>bPczK!sK4Zb&iE#_(kqs*dJBWFNp>Ay z*XlIKQ?mv4JZpYXqorG$jy&o&{N4gB>;6Tw-OL<`3w-S)C7-Ys30tEVZ_{{9*KW9080ljS<&aUR-9CgS<-S8v;c+g zcD6W4J=Wrz(G3$aDISd@@Bik4Z>EJKl9Kl%>oAJ0fu&-zKn!6zk zF$%_qkY`~=${!@>Z{IJ%FQFmp579@HRD|gCvlo_Z(#1yGc;B%6JW`Y5B#J6kO<0LV zPd+_VRNi49c#Qr%M;lh>D?){SCkukBB%FYIscw-VZDWI=sFny8J1}3l1enZfU1uRm zn54L>Bag+f0SxhrL)9%5;E~a(rNl0NL(%LfUzl?0gBg?h9{jPw**o*eiXcMg1TC!5 z|4GRd&SD+D;w8+FYo8BK(U5D>B8KnTzn@6))(VmGdt6ek|>DG$`yWC>l#Y zK_dwMGYk;6blFLbL~K(EgwJUU{wW&$#@>V~7$K|j+GPzL^c=wVNxU@`1X_RDen3VI zo!h=8cWWb;q?f}OPV#_*J0FE7*YblBvFGdzWa?cNC)s&}O85D{mm0(Xh(xbe4VJkuTl$C;FJJ{;2S(WJU~ zrzsA?XR~)h+!e|C@XWqBb1?flle;2x^uGcxMLpmXtm#TY$;k{o*NB z{D3{(aufzbR8|qag>7KoPw4_3lC!Tdx!)iCtuQ@)hw~2jK1~K$d=5us`JhoI zq3gZL!ujS$8Lt8Doy%8H^+yj&CY!Qiw2ORO{%nj%1bw8v21@*tQPv=hCZBmMzpR9I z+J_-O91^V8H$FTI=52=XUD?S#eg$r*x+hM)p_x29JRvXdrJ`}8cB}LDzhjLrY0%k6 zK6NXdpttEgUe1#*0(O#QNKu+C4?5-%N0B!Na?PQ=noTb?;b0c6neaa(4zv>PSTfmf z%yD9(u)%~$=xA5_xhm#&ZczN2(@@a$^3kRV|CjHrdYG1QdCZf5SD>1?&JZNKr-28PI3QFZF3 zZm7P~g&LN7_TpJ+c&-G~*VL`c=^lR@x+D?^b+m*+J z;rsX=b92gAd8M5bQto}P8-D|I!`STH@2L9DPGqyc{#iS4+B%=irM-e?Re8;72L-p%TlpBkbs74(s9ni*K8V*{53QbO}6l=wPv?WqH zhhcv|u@rL_0=_B}4jy??`km+#dZTpBi@pEqpq+POCS)&nsI93q1rwkNCcx5bVFu+O z?+-LDT7rUBruL7hElDVF4!0C2^78NAP!0kc>0arI787>cxNb%|a~ket0)F&v>ZZ&@ z-k~|G%+B#t|KQJamTDM6kJQ25fSBkXX~_7P$8+B@l{RE4Ct>$z!<7>p<{phh9^Bs= z3`Vv1M^6gJS3&qZcZte4t-4jSX;Vp=9Oqrvj)tA05g}^R{~)x2uHXX}*7{VRfb`PxE_CCm1s@OdKd*{c4sr?%)1^*zVr)JUTEsfCMI`cFy z{uKH6u2IGrwK0|E;RNqIIro<>6O#w#*QO^r;|(LsRxH7c0ox8@A0b~l|L`E?7gxtp zI!Ol1;gra1Cj|_gU6Gd5l0L-MDlckH99$mzkY?l1JH6tH(yD>mE}V8xw(}_l!bR71 z!?oUTtv%oJu-j(I6v~)XJhkEzHyZ7vTCqzi#0N#0r5($w#xfyAP^&yR#Um-q@4i3h zwnlEM+US;m5R%pH_g|3AvDJG6W#*e-bl_%fv~;VBoIa~t&3frIFcahJ$lDpRTGbv@ zH25P&6ucub>0lLYF!4V}BSkIoOq1IXS6ibts>zAHtY(Fc5vqTV_U4c~&L>RV-<5){ zLCpEP^=pkTH!FJgYC^^P6F7r^{sLzjwOK*1V<~9Tb1h>+JYq?rM4T*i#G@>*fL_7Nn7VVSx2x^T{imwqVAaM;fb|j>QXBPn5Sr z!Sqq(j1n1XLV!G4ibDD4kg{~0x_P`2#bP|og`0~bmFB0V-Y~hnfKF7Z){#W3r0AO! zWMj4u`*72rYiVTy3O6*yoWUD5Q#PwACj4LMU9Vt$zM7Mm`?3*iv}cor%STe3?4FqQ z5UCVs&+OfsIXLG>&HrU+q`$i90h~%xjx-dFMKp}~7_%UIBwcw`ve}MPrakUu{v`3h zk^FV~e9nUVacdSQGc#zqum9rgXSB6sJ!i-r4l&kc_Z5py33Ujbt_`kWEIP0?A^t4& zUD-e1_acR@c$y#eS@H$eY!6Xup^{<;LZC`2&L`?*W9eN^SOh&sMfLpzFY3&6xFn4} zz<;x&R*_6}*!&NVwlam4(QhRUF1b0)x2-V56-R6E*t?q5#TNw+@0Y%8rmneYl_BLk zn_UuvX;0rTyklg_Vs;EvO`997e|`J^%X~SGk?IE#`1+Ca>YKN`bfmdDps!=1U4O?h G=KlcAE9s5^ diff --git a/client/ui/netbird-systemtray-connected.ico b/client/ui/netbird-systemtray-connected.ico new file mode 100644 index 0000000000000000000000000000000000000000..621afce9fb2a65c34e68df372aaa6770ee98c8d0 GIT binary patch literal 4452 zcmb_Ai8~Zr_cLQKNHc@ik~hPMEZHl)e3_xK#Dpl>GTwv~S+a#@(IychgJQf9r6My~ zUfYZ%WTzs@PIksVX8rX0{R`i@&wcJ$?!C`>?z!hV3jhGzAHM+zPzKn;0RY*3{E?%* zwWRnV@qJYCtPRHLxAgafi0m((VXysuQ^8^O7l6GlqjCTMIOHtG{6h4Pm0X9%&xiha z$b)l*-}Q^mPpKV|KPnjs(dmiyoH^erS$MAaQ|iD}Opd&#Q`76rbCKC@4`pN1&(Rz! zRU~vqd$3xE4$Z$bPnEJc`)2WT(m6ZxZ@1NM&y@QW#*TBw=)FSZfx+^_$vta%9MieH z*eUS79{&SqX=;w5F5->eu8ejJD*fy@sdSt+=w0DP8*288d=d-;o72;BGxXkp+07oq zx#l0DL~&DQcDE$WPz@I+c2~_HtVValiYxei;YvhZHf2sx@xHoW#=Y`$jI`?X7LBg1%VW0jstmV60=$-w3(`p9Ldf ztbkSi;f7>yJ*J{t1a5H~jA%s^ewvVolDw5dMI31zTREl}H)#US#kC@8QLe+=Vy8Or zsS|F9^xxt|=B>fvWzhf;frF|YAMsG02_xym#m ziB3Zycv)Z6TF?%P*CVs_K0AGM>O5($fis{-z*f%uG$+xEV7+59ik5x8W?iV z*JQQyMV|`K9obX3(2%M|(A@m7!|auJa12Ury>P;cQ1-(WeMJ_~!<8qrMoNjThI%_B z1+QYtoBvF()Rq&=zF`bYNTnr;8gEkIjh4d>r`y#S*0qsGx9!XO=>!k6^ti$ZKodG0 z%Hz|7c-}|-vkJzZZq=NS6~@Kmo)~ijn>x(wMjU61mFP81J_lS}@-*B1SCMv%b%(lT zyF~+w0bhM+3wn`h>zF<=o}xDOt1fYA$bDItoF7&U=`F5 zSM(;6XO-@;=Pmum-8+lCM&G-wG_-*4Fr}3*9JQ(SeBr@Pvc7?Myw||Xr z>Tn>_+`7`o#&kQw7SxKU+TYL;nNr^`2btDur{G5XtmTm~8mw+FNS)KSmI66)`$#lr zflnusIlH($FTxtRcQZR=WKEu2ojp1$3LARLkORu=-6I;{63|FH&>ce-RHBieY!qjo zw3qYaSWC9S)>r!aSLs^8$BNk5+PgkYMsa>gdnWnDbqY3Zu8gJUlzM$5)=@Gy#>_a9 zfKf(HJ4DR3#lvX8fRLKg1|8>$&L^v%`25sVKix=WQ((pxwE8Sqsk4f>W}K>JXw`aH zmt0h9vuEw4h5|J}3Y9d3>q>!njXkZ)O(BmQTPm87K67n$HR(W08mg+rl^OZ#2om;v zl`xI{XJS`VF`dq+k2aQouJ)zB?5r`?=jH{mkDCMy{=pG# zZZruRC5$Vp+pB{9CiBF7h01iF#t^OPD@0wJvqgQrs(3;Qk;-+JDQed^;yX7xlVd`L7CD6Vh`nnXc+m zR#g{xXNZg@GrEOt*A6Iv!-AQVJJL5;u{s|ul`+_vUS!Rj98e??e@frX!4$6o$X(1b0oyz%@o*c#)&D7B-C^xB4|PtoPfUIOOL7d8CZTh z`qstzRW060J3<8w7GLHE=RQHQKK+#7C*iQ*nk%yEI-o8oNDIqIo?B3qYfmMiEFn+3 zMdUT4FvDdxv{>Xytsw||Vd)j>!XONo8O-Bb9iVgHQQ|*e3OO6~_tRm3kY(L5j)EBf z=j1LeHboVPL=UgEAEZBfmXtwJC0)$Ynz0`4#l)^n*wvXU%bk|GFqke4^b`B@2d@m} z&%5F<$>+)3;LN_4#1N^Jid%&KiQ<+^_S3Nad&;<-r-iYocm!M@hr~vgq^?lMCw|gx znW`YOM8@SL`cvzep{4r`t}pi1!7x%tC;t8-ubFfhtp1H8bv7u!eKn<5G>t9I-4as< zsVEY*W2|@QR0?QfnBuqOpZBI@O5q+S_{W_EbybudFWip@4oJ`zXP*VZQwA+e>tluB ziD<7Kk2kl2xuL6VP&62%$Jx##EB<(c0?xfcr2dj@vMCaG)8hmjWGQdYX-wc!&|o1; zi+PsnZz@>5udC<$j`rHJ!y@AA`H8DJNZ15Ajo0ez5=FQ?Cjh-~_$h}5Hz77((Kt}V zit+~w_YEUE7qG>@R)Miy5Ff+rw+yaw}{6Lkp-{ zLV1KIGbOEy(PNN5pv-otMrce;MNm8e*DI0+D5D<hQ`J_>^KQZKe<214yjy}X!K$_xppA&ZFdAuN&0BP@vNS%Kt}9sZ z;1RfUYj-XNiV>3zsxBUW#4x_pYa+()-xC~JAnYxT(ajc@n3I?26+2PbFzQ6>u7)c2FktDmQ>42;g1x=%!!pKgi#u$B_povMd=F zhaIZ;sF?8-hKoKgyoXomEcjfcjO4MbR0^E$uhJ{$T>vt{f>8b?kD5lka~3^$GIlE} za9_CoamSX6!(~}O*8te}&G%3`JJ*Hz_?oNP?zB)f4CvoF-soh%JCkG+((AQ$XI*jS zmf#!xg}Vk_18*tJ z2!#@DbAs%^W_mEaU*A z>WW{Gu!4n$fDAadd-+F!7Q>^sUK;rAIk8k2{lG`2@g8J;jJY|sbq3J!m%tRiY@{Xq z!RB-K|FI>SwoE-mxIVLIURK-aG2SoteDxqIoe+8>&KT&i(4Of$X^F{im!!k6s1iU? zlwi6P6pI9vlmYkpATt(VCw<;Eu-3oQOffiXPvRp;6bY*NBBHdp;&E z2B9gsLc>zPQ9nu_7NsksaEzmQP9#hNR3c51tFnLJdYtH!`CCVc{;g?~507XBBafj& z&|fy-7_zQr?&fumZ^tS%|6~0Kou-I4&%t9wbca}*p|*}#)kbV0IxWoSbw(iQYjd`b&@Cv zPx!Re4EX4}vuO)P5)Qw{!V}uIre*V;r+dpG8j2(p7Qpv08e4?WVLa);HzW`jU**Ga z5JOE6JO}?m@GPF5{Vh-d_YhG%!vJ-EXKzsn5XbuF( z{kCn}0l<(i>V8EO?_Z}lz_)d86_iYgHpn0cUnt1S;AQASpXobdB2J=Tw*fB06TWS! z@^-(no@;{Wr`8vC%g(LnWY_vX<(UTFUhq`y#{L|<^io{0yIMzz z5kJSR(-^3)11f1F)@inHcA9V6D`sx;3Sl-Ao!TwsYuqdXP@XV|K^Gr_naeU=t(Xh6 zy+pHl0|8f$qxp#8fD3n$l&=)g3wIXJs+Nrsw!!AKHwq@1Dej7{Yglo4-|`UySNx)h`AIh?yBA2`8Jn^_5&>hU)4C? zRSI9WF5{Hx^el6naGIg zr$`*WySP%n?{WQ;`U;k9=OIcl=u5%dH zyAFqKREe~UD2;3@{4yWb(}XK4dJh>tW0y&{MEZ+mF~J9^r0bbiPJZ}U-{&`E?9E*< zrYQjfabWBP(Wn2BOnfre34!2`~4#XqYFO#Aj)y)ad5s({n)_mh-f7h*TjiL zgEwZaIC1Cu;-iPNR^S&_9~>t*@=LF*!dV%-fkPV9I?wBgrXAw$4I=g2(IfMr+jfQH z?e2c@PPZY>LNja>`d{HbTwHh(6%(IFMIgN=Z9*-Qt6iwalm;eB0E$uO- I7T)py1qRz*Pyhe` literal 0 HcmV?d00001 diff --git a/client/ui/netbird-systemtray-connected.png b/client/ui/netbird-systemtray-connected.png new file mode 100644 index 0000000000000000000000000000000000000000..c5878d0187e9a5151814a6dfb29e311e475110b4 GIT binary patch literal 7251 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 literal 0 HcmV?d00001 diff --git a/client/ui/netbird-systemtray-default.ico b/client/ui/netbird-systemtray-default.ico new file mode 100644 index 0000000000000000000000000000000000000000..5a025267599c8bb00849160cc4da5dbdb4ea41ca GIT binary patch literal 2876 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 literal 0 HcmV?d00001 diff --git a/client/ui/netbird-systemtray-default.png b/client/ui/netbird-systemtray-default.png new file mode 100644 index 0000000000000000000000000000000000000000..12e7a2dc1008861fe1d98914b8fb44364990c7aa GIT binary patch literal 4938 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 literal 0 HcmV?d00001 diff --git a/client/ui/netbird-systemtray-update-cloud.ico b/client/ui/netbird-systemtray-update-cloud.ico new file mode 100644 index 0000000000000000000000000000000000000000..b87c6f4b55620eea0b397d6243063e22315933f4 GIT binary patch literal 3647 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 UPWN{{D&hjh2Il(Jy7=h-0rzr)wg3PC literal 0 HcmV?d00001 diff --git a/client/ui/netbird-systemtray-update.ico b/client/ui/netbird-systemtray-update.ico new file mode 100644 index 0000000000000000000000000000000000000000..1a1c4086d5ec7ed97f8c633b4ade786fe24fb522 GIT binary patch literal 4726 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 literal 0 HcmV?d00001 diff --git a/client/ui/netbird-systemtray-update.png b/client/ui/netbird-systemtray-update.png new file mode 100644 index 0000000000000000000000000000000000000000..1f4651df9db198901f4498d240c498c504731f40 GIT binary patch literal 7521 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 literal 0 HcmV?d00001 diff --git a/release_files/darwin-ui-installer.sh b/release_files/darwin-ui-installer.sh index 7e8115b64cf..5179f02d6ed 100644 --- a/release_files/darwin-ui-installer.sh +++ b/release_files/darwin-ui-installer.sh @@ -10,6 +10,7 @@ then wiretrustee service stop || true wiretrustee service uninstall || true fi + # check if netbird is installed NB_BIN=$(which netbird) if [ -z "$NB_BIN" ] @@ -41,4 +42,4 @@ netbird service install 2> /dev/null || true netbird service start || true # start app -open /Applications/Netbird\ UI.app \ No newline at end of file +open /Applications/Netbird\ UI.app diff --git a/release_files/darwin_pkg/preinstall b/release_files/darwin_pkg/preinstall index cdea1465c21..5965e82ebe4 100755 --- a/release_files/darwin_pkg/preinstall +++ b/release_files/darwin_pkg/preinstall @@ -8,6 +8,13 @@ AGENT=/usr/local/bin/netbird mkdir -p /var/log/netbird/ { + # check if it was installed with brew + brew list --formula | grep netbird + if [ $? -eq 0 ] + then + echo "NetBird has been installed with Brew. Please use Brew to update the package." + exit 1 + fi osascript -e 'quit app "Netbird"' || true $AGENT service stop || true diff --git a/version/update.go b/version/update.go new file mode 100644 index 00000000000..1de60ea9a3c --- /dev/null +++ b/version/update.go @@ -0,0 +1,184 @@ +package version + +import ( + "io" + "net/http" + "sync" + "time" + + goversion "github.com/hashicorp/go-version" + log "github.com/sirupsen/logrus" +) + +const ( + fetchPeriod = 30 * time.Minute +) + +var ( + versionURL = "https://pkgs.netbird.io/releases/latest/version" +) + +// Update fetch the version info periodically and notify the onUpdateListener in case the UI version or the +// daemon version are deprecated +type Update struct { + uiVersion *goversion.Version + daemonVersion *goversion.Version + latestAvailable *goversion.Version + versionsLock sync.Mutex + + fetchTicker *time.Ticker + fetchDone chan struct{} + + onUpdateListener func() + listenerLock sync.Mutex +} + +// NewUpdate instantiate Update and start to fetch the new version information +func NewUpdate() *Update { + currentVersion, err := goversion.NewVersion(version) + if err != nil { + currentVersion, _ = goversion.NewVersion("0.0.0") + } + + latestAvailable, _ := goversion.NewVersion("0.0.0") + + u := &Update{ + latestAvailable: latestAvailable, + uiVersion: currentVersion, + fetchTicker: time.NewTicker(fetchPeriod), + fetchDone: make(chan struct{}), + } + go u.startFetcher() + return u +} + +// StopWatch stop the version info fetch loop +func (u *Update) StopWatch() { + u.fetchTicker.Stop() + + select { + case u.fetchDone <- struct{}{}: + default: + } +} + +// SetDaemonVersion update the currently running daemon version. If new version is available it will trigger +// the onUpdateListener +func (u *Update) SetDaemonVersion(newVersion string) bool { + daemonVersion, err := goversion.NewVersion(newVersion) + if err != nil { + daemonVersion, _ = goversion.NewVersion("0.0.0") + } + + u.versionsLock.Lock() + if u.daemonVersion != nil && u.daemonVersion.Equal(daemonVersion) { + u.versionsLock.Unlock() + return false + } + + u.daemonVersion = daemonVersion + u.versionsLock.Unlock() + return u.checkUpdate() +} + +// SetOnUpdateListener set new update listener +func (u *Update) SetOnUpdateListener(updateFn func()) { + u.listenerLock.Lock() + defer u.listenerLock.Unlock() + + u.onUpdateListener = updateFn + if u.isUpdateAvailable() { + u.onUpdateListener() + } +} + +func (u *Update) startFetcher() { + changed := u.fetchVersion() + if changed { + u.checkUpdate() + } + + select { + case <-u.fetchDone: + return + case <-u.fetchTicker.C: + changed := u.fetchVersion() + if changed { + u.checkUpdate() + } + } +} + +func (u *Update) fetchVersion() bool { + resp, err := http.Get(versionURL) + if err != nil { + log.Errorf("failed to fetch version info: %s", err) + return false + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + log.Errorf("invalid status code: %d", resp.StatusCode) + return false + } + + if resp.ContentLength > 100 { + log.Errorf("too large response: %d", resp.ContentLength) + return false + } + + content, err := io.ReadAll(resp.Body) + if err != nil { + log.Errorf("failed to read content: %s", err) + return false + } + + latestAvailable, err := goversion.NewVersion(string(content)) + if err != nil { + log.Errorf("failed to parse the version string: %s", err) + return false + } + + u.versionsLock.Lock() + defer u.versionsLock.Unlock() + + if u.latestAvailable.Equal(latestAvailable) { + return false + } + u.latestAvailable = latestAvailable + + return true +} + +func (u *Update) checkUpdate() bool { + if !u.isUpdateAvailable() { + return false + } + + u.listenerLock.Lock() + defer u.listenerLock.Unlock() + if u.onUpdateListener == nil { + return true + } + + go u.onUpdateListener() + return true +} + +func (u *Update) isUpdateAvailable() bool { + u.versionsLock.Lock() + defer u.versionsLock.Unlock() + + if u.latestAvailable.GreaterThan(u.uiVersion) { + return true + } + + if u.daemonVersion == nil { + return false + } + + if u.latestAvailable.GreaterThan(u.daemonVersion) { + return true + } + return false +} diff --git a/version/update_test.go b/version/update_test.go new file mode 100644 index 00000000000..c2b47749522 --- /dev/null +++ b/version/update_test.go @@ -0,0 +1,101 @@ +package version + +import ( + "fmt" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" +) + +func TestNewUpdate(t *testing.T) { + version = "1.0.0" + svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "10.0.0") + })) + defer svr.Close() + versionURL = svr.URL + + wg := &sync.WaitGroup{} + wg.Add(1) + + onUpdate := false + u := NewUpdate() + defer u.StopWatch() + u.SetOnUpdateListener(func() { + onUpdate = true + wg.Done() + }) + + waitTimeout(wg) + if onUpdate != true { + t.Errorf("update not found") + } +} + +func TestDoNotUpdate(t *testing.T) { + version = "11.0.0" + svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "10.0.0") + })) + defer svr.Close() + versionURL = svr.URL + + wg := &sync.WaitGroup{} + wg.Add(1) + + onUpdate := false + u := NewUpdate() + defer u.StopWatch() + u.SetOnUpdateListener(func() { + onUpdate = true + wg.Done() + }) + + waitTimeout(wg) + if onUpdate == true { + t.Errorf("invalid update") + } +} + +func TestDaemonUpdate(t *testing.T) { + version = "11.0.0" + svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "11.0.0") + })) + defer svr.Close() + versionURL = svr.URL + + wg := &sync.WaitGroup{} + wg.Add(1) + + onUpdate := false + u := NewUpdate() + defer u.StopWatch() + u.SetOnUpdateListener(func() { + onUpdate = true + wg.Done() + }) + + u.SetDaemonVersion("10.0.0") + + waitTimeout(wg) + if onUpdate != true { + t.Errorf("invalid dameon version check") + } +} + +func waitTimeout(wg *sync.WaitGroup) { + c := make(chan struct{}) + go func() { + wg.Wait() + close(c) + }() + select { + case <-c: + return + case <-time.After(time.Second): + return + } +} diff --git a/version/url.go b/version/url.go new file mode 100644 index 00000000000..ed43ab04249 --- /dev/null +++ b/version/url.go @@ -0,0 +1,5 @@ +package version + +const ( + downloadURL = "https://app.netbird.io/install" +) diff --git a/version/url_darwin.go b/version/url_darwin.go new file mode 100644 index 00000000000..cb58612f52b --- /dev/null +++ b/version/url_darwin.go @@ -0,0 +1,33 @@ +package version + +import ( + "os/exec" + "runtime" +) + +const ( + urlMacIntel = "https://pkgs.netbird.io/macos/amd64" + urlMacM1M2 = "https://pkgs.netbird.io/macos/arm64" +) + +// DownloadUrl return with the proper download link +func DownloadUrl() string { + cmd := exec.Command("brew", "list --formula | grep -i netbird") + if err := cmd.Start(); err != nil { + goto PKGINSTALL + } + + if err := cmd.Wait(); err == nil { + return downloadURL + } + +PKGINSTALL: + switch runtime.GOARCH { + case "amd64": + return urlMacIntel + case "arm64": + return urlMacM1M2 + default: + return downloadURL + } +} diff --git a/version/url_linux.go b/version/url_linux.go new file mode 100644 index 00000000000..c8193e30c31 --- /dev/null +++ b/version/url_linux.go @@ -0,0 +1,6 @@ +package version + +// DownloadUrl return with the proper download link +func DownloadUrl() string { + return downloadURL +} diff --git a/version/url_windows.go b/version/url_windows.go new file mode 100644 index 00000000000..f2055b10915 --- /dev/null +++ b/version/url_windows.go @@ -0,0 +1,19 @@ +package version + +import "golang.org/x/sys/windows/registry" + +const ( + urlWinExe = "https://pkgs.netbird.io/windows/x64" +) + +var regKeyAppPath = "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\Netbird" + +// DownloadUrl return with the proper download link +func DownloadUrl() string { + _, err := registry.OpenKey(registry.LOCAL_MACHINE, regKeyAppPath, registry.QUERY_VALUE) + if err == nil { + return urlWinExe + } else { + return downloadURL + } +}