From 756ce96da96149c0c470993074997561fe8f3b9f Mon Sep 17 00:00:00 2001 From: Misha Bragin Date: Tue, 14 Feb 2023 10:14:00 +0100 Subject: [PATCH] Add login expiration fields to peer HTTP API (#687) Return login expiration related fields in the Peer HTTP GET endpoint. Support enable/disable peer's login expiration via HTTP PUT. --- management/server/activity/codes.go | 16 ++++ management/server/http/api/openapi.yml | 20 ++++- management/server/http/api/types.gen.go | 16 +++- management/server/http/peers.go | 35 ++++---- management/server/http/peers_test.go | 102 ++++++++++++++++-------- management/server/peer.go | 26 ++++-- 6 files changed, 157 insertions(+), 58 deletions(-) diff --git a/management/server/activity/codes.go b/management/server/activity/codes.go index 96cadd99d..9517aa2e3 100644 --- a/management/server/activity/codes.go +++ b/management/server/activity/codes.go @@ -61,6 +61,10 @@ const ( PeerSSHDisabled // PeerRenamed indicates that a user renamed a peer PeerRenamed + // PeerLoginExpirationEnabled indicates that a user enabled login expiration of a peer + PeerLoginExpirationEnabled + // PeerLoginExpirationDisabled indicates that a user disabled login expiration of a peer + PeerLoginExpirationDisabled // NameserverGroupCreated indicates that a user created a nameservers group NameserverGroupCreated // NameserverGroupDeleted indicates that a user deleted a nameservers group @@ -130,6 +134,10 @@ const ( PeerSSHDisabledMessage string = "Peer SSH server disabled" // PeerRenamedMessage is a human-readable text message of the PeerRenamed activity PeerRenamedMessage string = "Peer renamed" + // PeerLoginExpirationDisabledMessage is a human-readable text message of the PeerLoginExpirationDisabled activity + PeerLoginExpirationDisabledMessage string = "Peer login expiration disabled" + // PeerLoginExpirationEnabledMessage is a human-readable text message of the PeerLoginExpirationEnabled activity + PeerLoginExpirationEnabledMessage string = "Peer login expiration enabled" // NameserverGroupCreatedMessage is a human-readable text message of the NameserverGroupCreated activity NameserverGroupCreatedMessage string = "Nameserver group created" // NameserverGroupDeletedMessage is a human-readable text message of the NameserverGroupDeleted activity @@ -202,6 +210,10 @@ func (a Activity) Message() string { return PeerSSHEnabledMessage case PeerSSHDisabled: return PeerSSHDisabledMessage + case PeerLoginExpirationEnabled: + return PeerLoginExpirationEnabledMessage + case PeerLoginExpirationDisabled: + return PeerLoginExpirationDisabledMessage case PeerRenamed: return PeerRenamedMessage case NameserverGroupCreated: @@ -278,6 +290,10 @@ func (a Activity) StringCode() string { return "peer.ssh.enable" case PeerSSHDisabled: return "peer.ssh.disable" + case PeerLoginExpirationDisabled: + return "peer.login.expiration.disable" + case PeerLoginExpirationEnabled: + return "peer.login.expiration.enable" case NameserverGroupCreated: return "nameserver.group.add" case NameserverGroupDeleted: diff --git a/management/server/http/api/openapi.yml b/management/server/http/api/openapi.yml index 7f4078c3a..cbe61275f 100644 --- a/management/server/http/api/openapi.yml +++ b/management/server/http/api/openapi.yml @@ -145,6 +145,16 @@ components: dns_label: description: Peer's DNS label 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 type: string + login_expiration_enabled: + description: Indicates whether peer login expiration has been enabled or not + type: boolean + login_expired: + description: Indicates whether peer's login expired or not + type: boolean + last_login: + description: Last time this peer performed log in (authentication). E.g., user authenticated. + type: string + format: date-time required: - ip - connected @@ -155,6 +165,9 @@ components: - ssh_enabled - hostname - dns_label + - login_expiration_enabled + - login_expired + - last_login SetupKey: type: object properties: @@ -542,7 +555,7 @@ components: "account.create", "dns.setting.disabled.management.group.delete", "route.add", "route.delete", "route.update", "nameserver.group.add", "nameserver.group.delete", "nameserver.group.update", - "peer.ssh.disable", "peer.ssh.enable", "peer.rename" + "peer.ssh.disable", "peer.ssh.enable", "peer.rename", "peer.login.expiration.disable", "peer.login.expiration.enable" ] initiator_id: description: The ID of the initiator of the event. E.g., an ID of a user that triggered the event. @@ -741,7 +754,7 @@ paths: type: string description: The Peer ID requestBody: - description: update to peers + description: update a peer content: 'application/json': schema: @@ -751,9 +764,12 @@ paths: type: string ssh_enabled: type: boolean + login_expiration_enabled: + type: boolean required: - name - ssh_enabled + - login_expiration_enabled responses: '200': description: A Peer object diff --git a/management/server/http/api/types.gen.go b/management/server/http/api/types.gen.go index 2f12a2e87..f41cfcd10 100644 --- a/management/server/http/api/types.gen.go +++ b/management/server/http/api/types.gen.go @@ -21,6 +21,8 @@ const ( EventActivityCodeNameserverGroupAdd EventActivityCode = "nameserver.group.add" EventActivityCodeNameserverGroupDelete EventActivityCode = "nameserver.group.delete" EventActivityCodeNameserverGroupUpdate EventActivityCode = "nameserver.group.update" + EventActivityCodePeerLoginExpirationDisable EventActivityCode = "peer.login.expiration.disable" + EventActivityCodePeerLoginExpirationEnable EventActivityCode = "peer.login.expiration.enable" EventActivityCodePeerRename EventActivityCode = "peer.rename" EventActivityCodePeerSshDisable EventActivityCode = "peer.ssh.disable" EventActivityCodePeerSshEnable EventActivityCode = "peer.ssh.enable" @@ -326,9 +328,18 @@ type Peer struct { // Ip Peer's IP address Ip string `json:"ip"` + // LastLogin Last time this peer performed log in (authentication). E.g., user authenticated. + LastLogin time.Time `json:"last_login"` + // LastSeen Last time peer connected to Netbird's management service LastSeen time.Time `json:"last_seen"` + // LoginExpirationEnabled Indicates whether peer login expiration has been enabled or not + LoginExpirationEnabled bool `json:"login_expiration_enabled"` + + // LoginExpired Indicates whether peer's login expired or not + LoginExpired bool `json:"login_expired"` + // Name Peer's hostname Name string `json:"name"` @@ -626,8 +637,9 @@ type PutApiGroupsIdJSONBody struct { // PutApiPeersIdJSONBody defines parameters for PutApiPeersId. type PutApiPeersIdJSONBody struct { - Name string `json:"name"` - SshEnabled bool `json:"ssh_enabled"` + LoginExpirationEnabled bool `json:"login_expiration_enabled"` + Name string `json:"name"` + SshEnabled bool `json:"ssh_enabled"` } // PatchApiRoutesIdJSONBody defines parameters for PatchApiRoutesId. diff --git a/management/server/http/peers.go b/management/server/http/peers.go index 5d991c8ab..a4048b875 100644 --- a/management/server/http/peers.go +++ b/management/server/http/peers.go @@ -47,7 +47,8 @@ func (h *Peers) updatePeer(account *server.Account, user *server.User, peerID st return } - update := &server.Peer{ID: peerID, SSHEnabled: req.SshEnabled, Name: req.Name} + update := &server.Peer{ID: peerID, SSHEnabled: req.SshEnabled, Name: req.Name, + LoginExpirationEnabled: req.LoginExpirationEnabled} peer, err := h.accountManager.UpdatePeer(account.Id, user.Id, update) if err != nil { util.WriteError(err, w) @@ -150,19 +151,25 @@ func toPeerResponse(peer *server.Peer, account *server.Account, dnsDomain string if fqdn == "" { fqdn = peer.DNSLabel } + + expired, _ := peer.LoginExpired(account.Settings) + return &api.Peer{ - Id: peer.ID, - Name: peer.Name, - Ip: peer.IP.String(), - Connected: peer.Status.Connected, - LastSeen: peer.Status.LastSeen, - Os: fmt.Sprintf("%s %s", peer.Meta.OS, peer.Meta.Core), - Version: peer.Meta.WtVersion, - Groups: groupsInfo, - SshEnabled: peer.SSHEnabled, - Hostname: peer.Meta.Hostname, - UserId: &peer.UserID, - UiVersion: &peer.Meta.UIVersion, - DnsLabel: fqdn, + Id: peer.ID, + Name: peer.Name, + Ip: peer.IP.String(), + Connected: peer.Status.Connected, + LastSeen: peer.Status.LastSeen, + Os: fmt.Sprintf("%s %s", peer.Meta.OS, peer.Meta.Core), + Version: peer.Meta.WtVersion, + Groups: groupsInfo, + SshEnabled: peer.SSHEnabled, + Hostname: peer.Meta.Hostname, + UserId: &peer.UserID, + UiVersion: &peer.Meta.UIVersion, + DnsLabel: fqdn, + LoginExpirationEnabled: peer.LoginExpirationEnabled, + LastLogin: peer.LastLogin, + LoginExpired: expired, } } diff --git a/management/server/http/peers_test.go b/management/server/http/peers_test.go index 4671352fe..d6810ffe2 100644 --- a/management/server/http/peers_test.go +++ b/management/server/http/peers_test.go @@ -1,6 +1,7 @@ package http import ( + "bytes" "encoding/json" "github.com/gorilla/mux" "io" @@ -8,6 +9,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/netbirdio/netbird/management/server/http/api" @@ -23,6 +25,13 @@ const testPeerID = "test_peer" func initTestMetaData(peers ...*server.Peer) *Peers { return &Peers{ accountManager: &mock_server.MockAccountManager{ + UpdatePeerFunc: func(accountID, userID string, update *server.Peer) (*server.Peer, error) { + p := peers[0].Copy() + 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 }, @@ -40,6 +49,10 @@ func initTestMetaData(peers ...*server.Peer) *Peers { Users: map[string]*server.User{ "test_user": user, }, + Settings: &server.Settings{ + PeerLoginExpirationEnabled: true, + PeerLoginExpiration: time.Hour, + }, }, user, nil }, }, @@ -58,38 +71,15 @@ func initTestMetaData(peers ...*server.Peer) *Peers { // Tests the GetPeers endpoint reachable in the route /api/peers // Use the metadata generated by initTestMetaData() to check for values func TestGetPeers(t *testing.T) { - tt := []struct { - name string - expectedStatus int - requestType string - requestPath string - requestBody io.Reader - expectedArray bool - }{ - { - name: "GetPeersMetaData", - requestType: http.MethodGet, - requestPath: "/api/peers/", - expectedStatus: http.StatusOK, - expectedArray: true, - }, - { - name: "GetPeer", - requestType: http.MethodGet, - requestPath: "/api/peers/" + testPeerID, - expectedStatus: http.StatusOK, - expectedArray: false, - }, - } - rr := httptest.NewRecorder() peer := &server.Peer{ - ID: testPeerID, - Key: "key", - SetupKey: "setupkey", - IP: net.ParseIP("100.64.0.1"), - Status: &server.PeerStatus{}, - Name: "PeerName", + ID: testPeerID, + Key: "key", + SetupKey: "setupkey", + IP: net.ParseIP("100.64.0.1"), + Status: &server.PeerStatus{}, + Name: "PeerName", + LoginExpirationEnabled: false, Meta: server.PeerSystemMeta{ Hostname: "hostname", GoOS: "GoOS", @@ -101,6 +91,49 @@ func TestGetPeers(t *testing.T) { }, } + expectedUpdatedPeer := peer.Copy() + expectedUpdatedPeer.LoginExpirationEnabled = true + expectedUpdatedPeer.SSHEnabled = true + expectedUpdatedPeer.Name = "New Name" + + tt := []struct { + name string + expectedStatus int + requestType string + requestPath string + requestBody io.Reader + expectedArray bool + expectedPeer *server.Peer + }{ + { + name: "GetPeersMetaData", + requestType: http.MethodGet, + requestPath: "/api/peers/", + expectedStatus: http.StatusOK, + expectedArray: true, + expectedPeer: peer, + }, + { + name: "GetPeer", + requestType: http.MethodGet, + requestPath: "/api/peers/" + testPeerID, + expectedStatus: http.StatusOK, + expectedArray: false, + expectedPeer: peer, + }, + { + name: "PutPeer", + requestType: http.MethodPut, + requestPath: "/api/peers/" + testPeerID, + expectedStatus: http.StatusOK, + expectedArray: false, + requestBody: bytes.NewBufferString("{\"login_expiration_enabled\":true,\"name\":\"New Name\",\"ssh_enabled\":true}"), + expectedPeer: expectedUpdatedPeer, + }, + } + + rr := httptest.NewRecorder() + p := initTestMetaData(peer) for _, tc := range tt { @@ -112,6 +145,7 @@ func TestGetPeers(t *testing.T) { router := mux.NewRouter() router.HandleFunc("/api/peers/", p.GetPeers).Methods("GET") router.HandleFunc("/api/peers/{id}", p.HandlePeer).Methods("GET") + router.HandleFunc("/api/peers/{id}", p.HandlePeer).Methods("PUT") router.ServeHTTP(recorder, req) res := recorder.Result() @@ -144,10 +178,12 @@ func TestGetPeers(t *testing.T) { } } - assert.Equal(t, got.Name, peer.Name) - assert.Equal(t, got.Version, peer.Meta.WtVersion) - assert.Equal(t, got.Ip, peer.IP.String()) + 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) }) } } diff --git a/management/server/peer.go b/management/server/peer.go index 74c30cc10..92d17e885 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -86,15 +86,17 @@ func (p *Peer) Copy() *Peer { } } -// LoginExpired indicates whether peer's login has expired or not. -// If Peer.LastLogin plus the expiresIn duration has happened already then login has expired. -// Return true if login has expired, false otherwise and time left to expiration (negative when expired). -// Expiration can be disabled/enabled on the Account or Peer level. +// LoginExpired indicates whether the peer's login has expired or not. +// If Peer.LastLogin plus the expiresIn duration has happened already; then login has expired. +// Return true if a login has expired, false otherwise, and time left to expiration (negative when expired). +// Login expiration can be disabled/enabled on a Peer level via Peer.LoginExpirationEnabled property. +// Login expiration can also be disabled/enabled globally on the Account level via Settings.PeerLoginExpirationEnabled +// and if disabled on the Account level, then Peer.LoginExpirationEnabled is ineffective. func (p *Peer) LoginExpired(accountSettings *Settings) (bool, time.Duration) { expiresAt := p.LastLogin.Add(accountSettings.PeerLoginExpiration) now := time.Now() - left := expiresAt.Sub(now) - return accountSettings.PeerLoginExpirationEnabled && p.LoginExpirationEnabled && (left <= 0), left + timeLeft := expiresAt.Sub(now) + return accountSettings.PeerLoginExpirationEnabled && p.LoginExpirationEnabled && (timeLeft <= 0), timeLeft } // FQDN returns peers FQDN combined of the peer's DNS label and the system's DNS domain @@ -206,7 +208,7 @@ func (am *DefaultAccountManager) MarkPeerConnected(peerPubKey string, connected return nil } -// UpdatePeer updates peer. Only Peer.Name and Peer.SSHEnabled can be updated. +// UpdatePeer updates peer. Only Peer.Name, Peer.SSHEnabled, and Peer.LoginExpirationEnabled can be updated. func (am *DefaultAccountManager) UpdatePeer(accountID, userID string, update *Peer) (*Peer, error) { unlock := am.Store.AcquireAccountLock(accountID) @@ -246,6 +248,16 @@ func (am *DefaultAccountManager) UpdatePeer(accountID, userID string, update *Pe am.storeEvent(userID, peer.ID, accountID, activity.PeerRenamed, peer.EventMeta(am.GetDNSDomain())) } + if peer.LoginExpirationEnabled != update.LoginExpirationEnabled { + peer.LoginExpirationEnabled = update.LoginExpirationEnabled + + event := activity.PeerLoginExpirationEnabled + if !update.LoginExpirationEnabled { + event = activity.PeerLoginExpirationDisabled + } + am.storeEvent(userID, peer.IP.String(), accountID, event, peer.EventMeta(am.GetDNSDomain())) + } + account.UpdatePeer(peer) err = am.Store.SaveAccount(account)