mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-23 08:51:29 +02:00
Compare commits
7 Commits
fix/includ
...
ensure-sch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
820ea80e68 | ||
|
|
59cf4cd97b | ||
|
|
5a3d9e401f | ||
|
|
fde1a2196c | ||
|
|
0aeb87742a | ||
|
|
6d747b2f83 | ||
|
|
199bf73103 |
@@ -106,8 +106,8 @@ export NETBIRD_DOMAIN=netbird.example.com; curl -fsSL https://github.com/netbird
|
||||
See a complete [architecture overview](https://docs.netbird.io/about-netbird/how-netbird-works#architecture) for details.
|
||||
|
||||
### Community projects
|
||||
- [NetBird on OpenWRT](https://github.com/messense/openwrt-netbird)
|
||||
- [NetBird installer script](https://github.com/physk/netbird-installer)
|
||||
- [NetBird ansible collection by Dominion Solutions](https://galaxy.ansible.com/ui/repo/published/dominion_solutions/netbird/)
|
||||
|
||||
**Note**: The `main` branch may be in an *unstable or even broken state* during development.
|
||||
For stable versions, see [releases](https://github.com/netbirdio/netbird/releases).
|
||||
|
||||
@@ -5,6 +5,9 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/relay"
|
||||
"github.com/netbirdio/netbird/iface"
|
||||
)
|
||||
@@ -376,6 +379,24 @@ func (d *Status) GetManagementState() ManagementState {
|
||||
}
|
||||
}
|
||||
|
||||
// IsLoginRequired determines if a peer's login has expired.
|
||||
func (d *Status) IsLoginRequired() bool {
|
||||
d.mux.Lock()
|
||||
defer d.mux.Unlock()
|
||||
|
||||
// if peer is connected to the management then login is not expired
|
||||
if d.managementState {
|
||||
return false
|
||||
}
|
||||
|
||||
s, ok := gstatus.FromError(d.managementError)
|
||||
if ok && (s.Code() == codes.InvalidArgument || s.Code() == codes.PermissionDenied) {
|
||||
return true
|
||||
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (d *Status) GetSignalState() SignalState {
|
||||
return SignalState{
|
||||
d.signalAddress,
|
||||
|
||||
82
client/internal/session.go
Normal file
82
client/internal/session.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
)
|
||||
|
||||
type SessionWatcher struct {
|
||||
ctx context.Context
|
||||
mutex sync.Mutex
|
||||
|
||||
peerStatusRecorder *peer.Status
|
||||
watchTicker *time.Ticker
|
||||
|
||||
sendNotification bool
|
||||
onExpireListener func()
|
||||
}
|
||||
|
||||
// NewSessionWatcher creates a new instance of SessionWatcher.
|
||||
func NewSessionWatcher(ctx context.Context, peerStatusRecorder *peer.Status) *SessionWatcher {
|
||||
s := &SessionWatcher{
|
||||
ctx: ctx,
|
||||
peerStatusRecorder: peerStatusRecorder,
|
||||
watchTicker: time.NewTicker(2 * time.Second),
|
||||
}
|
||||
go s.startWatcher()
|
||||
return s
|
||||
}
|
||||
|
||||
// SetOnExpireListener sets the callback func to be called when the session expires.
|
||||
func (s *SessionWatcher) SetOnExpireListener(onExpire func()) {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
s.onExpireListener = onExpire
|
||||
}
|
||||
|
||||
// startWatcher continuously checks if the session requires login and
|
||||
// calls the onExpireListener if login is required.
|
||||
func (s *SessionWatcher) startWatcher() {
|
||||
for {
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
s.watchTicker.Stop()
|
||||
return
|
||||
case <-s.watchTicker.C:
|
||||
managementState := s.peerStatusRecorder.GetManagementState()
|
||||
if managementState.Connected {
|
||||
s.sendNotification = true
|
||||
}
|
||||
|
||||
isLoginRequired := s.peerStatusRecorder.IsLoginRequired()
|
||||
if isLoginRequired && s.sendNotification && s.onExpireListener != nil {
|
||||
s.mutex.Lock()
|
||||
s.onExpireListener()
|
||||
s.sendNotification = false
|
||||
s.mutex.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CheckUIApp checks whether UI application is running.
|
||||
func CheckUIApp() bool {
|
||||
cmd := exec.Command("ps", "-ef")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
lines := strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "netbird-ui") && !strings.Contains(line, "grep") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package server
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -39,6 +41,7 @@ type Server struct {
|
||||
proto.UnimplementedDaemonServiceServer
|
||||
|
||||
statusRecorder *peer.Status
|
||||
sessionWatcher *internal.SessionWatcher
|
||||
|
||||
mgmProbe *internal.Probe
|
||||
signalProbe *internal.Probe
|
||||
@@ -116,6 +119,11 @@ func (s *Server) Start() error {
|
||||
s.statusRecorder.UpdateManagementAddress(config.ManagementURL.String())
|
||||
s.statusRecorder.UpdateRosenpass(config.RosenpassEnabled, config.RosenpassPermissive)
|
||||
|
||||
if s.sessionWatcher == nil {
|
||||
s.sessionWatcher = internal.NewSessionWatcher(s.rootCtx, s.statusRecorder)
|
||||
s.sessionWatcher.SetOnExpireListener(s.onSessionExpire)
|
||||
}
|
||||
|
||||
if !config.DisableAutoConnect {
|
||||
go func() {
|
||||
if err := internal.RunClientWithProbes(ctx, config, s.statusRecorder, s.mgmProbe, s.signalProbe, s.relayProbe, s.wgProbe); err != nil {
|
||||
@@ -542,6 +550,17 @@ func (s *Server) GetConfig(_ context.Context, _ *proto.GetConfigRequest) (*proto
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) onSessionExpire() {
|
||||
if runtime.GOOS != "windows" {
|
||||
isUIActive := internal.CheckUIApp()
|
||||
if !isUIActive {
|
||||
if err := sendTerminalNotification(); err != nil {
|
||||
log.Errorf("send session expire terminal notification: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toProtoFullStatus(fullStatus peer.FullStatus) *proto.FullStatus {
|
||||
pbFullStatus := proto.FullStatus{
|
||||
ManagementState: &proto.ManagementState{},
|
||||
@@ -604,3 +623,31 @@ func toProtoFullStatus(fullStatus peer.FullStatus) *proto.FullStatus {
|
||||
|
||||
return &pbFullStatus
|
||||
}
|
||||
|
||||
// sendTerminalNotification sends a terminal notification message
|
||||
// to inform the user that the NetBird connection session has expired.
|
||||
func sendTerminalNotification() error {
|
||||
message := "NetBird connection session expired\n\nPlease re-authenticate to connect to the network."
|
||||
echoCmd := exec.Command("echo", message)
|
||||
wallCmd := exec.Command("sudo", "wall")
|
||||
|
||||
echoCmdStdout, err := echoCmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wallCmd.Stdin = echoCmdStdout
|
||||
|
||||
if err := echoCmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := wallCmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := echoCmd.Wait(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return wallCmd.Wait()
|
||||
}
|
||||
|
||||
@@ -165,6 +165,10 @@ func sysProductName() (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// `ComputerSystemProduct` could be empty on some virtualized systems
|
||||
if len(dst) < 1 {
|
||||
return "unknown", nil
|
||||
}
|
||||
return dst[0].Name, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -72,7 +72,6 @@ type AccountManager interface {
|
||||
CheckUserAccessByJWTGroups(claims jwtclaims.AuthorizationClaims) error
|
||||
GetAccountFromPAT(pat string) (*Account, *User, *PersonalAccessToken, error)
|
||||
DeleteAccount(accountID, userID string) error
|
||||
GetUsage(ctx context.Context, accountID string, start time.Time, end time.Time) (*AccountUsageStats, error)
|
||||
MarkPATUsed(tokenID string) error
|
||||
GetUser(claims jwtclaims.AuthorizationClaims) (*User, error)
|
||||
ListUsers(accountID string) ([]*User, error)
|
||||
@@ -233,14 +232,6 @@ type Account struct {
|
||||
RulesG []Rule `json:"-" gorm:"-"`
|
||||
}
|
||||
|
||||
// AccountUsageStats represents the current usage statistics for an account
|
||||
type AccountUsageStats struct {
|
||||
ActiveUsers int64 `json:"active_users"`
|
||||
TotalUsers int64 `json:"total_users"`
|
||||
ActivePeers int64 `json:"active_peers"`
|
||||
TotalPeers int64 `json:"total_peers"`
|
||||
}
|
||||
|
||||
type UserInfo struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
@@ -464,6 +455,11 @@ func (a *Account) GetNextPeerExpiration() (time.Duration, bool) {
|
||||
}
|
||||
_, duration := peer.LoginExpired(a.Settings.PeerLoginExpiration)
|
||||
if nextExpiry == nil || duration < *nextExpiry {
|
||||
// if expiration is below 1s return 1s duration
|
||||
// this avoids issues with ticker that can't be set to < 0
|
||||
if duration < time.Second {
|
||||
return time.Second, true
|
||||
}
|
||||
nextExpiry = &duration
|
||||
}
|
||||
}
|
||||
@@ -1121,17 +1117,6 @@ func (am *DefaultAccountManager) DeleteAccount(accountID, userID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUsage returns the usage stats for the given account.
|
||||
// This cannot be used to calculate usage stats for a period in the past as it relies on peers' last seen time.
|
||||
func (am *DefaultAccountManager) GetUsage(ctx context.Context, accountID string, start time.Time, end time.Time) (*AccountUsageStats, error) {
|
||||
usageStats, err := am.Store.CalculateUsageStats(ctx, accountID, start, end)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to calculate usage stats: %w", err)
|
||||
}
|
||||
|
||||
return usageStats, nil
|
||||
}
|
||||
|
||||
// GetAccountByUserOrAccountID looks for an account by user or accountID, if no account is provided and
|
||||
// userID doesn't have an account associated with it, one account is created
|
||||
// domain is used to create a new account if no account is found
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -664,40 +662,3 @@ func (s *FileStore) Close() error {
|
||||
func (s *FileStore) GetStoreEngine() StoreEngine {
|
||||
return FileStoreEngine
|
||||
}
|
||||
|
||||
// CalculateUsageStats returns the usage stats for an account
|
||||
// start and end are inclusive.
|
||||
func (s *FileStore) CalculateUsageStats(_ context.Context, accountID string, start time.Time, end time.Time) (*AccountUsageStats, error) {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
|
||||
account, exists := s.Accounts[accountID]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("account not found")
|
||||
}
|
||||
|
||||
stats := &AccountUsageStats{
|
||||
TotalUsers: 0,
|
||||
TotalPeers: int64(len(account.Peers)),
|
||||
}
|
||||
|
||||
for _, user := range account.Users {
|
||||
if !user.IsServiceUser {
|
||||
stats.TotalUsers++
|
||||
}
|
||||
}
|
||||
|
||||
activeUsers := make(map[string]bool)
|
||||
for _, peer := range account.Peers {
|
||||
lastSeen := peer.Status.LastSeen
|
||||
if lastSeen.Compare(start) >= 0 && lastSeen.Compare(end) <= 0 {
|
||||
if _, exists := account.Users[peer.UserID]; exists && !activeUsers[peer.UserID] {
|
||||
activeUsers[peer.UserID] = true
|
||||
stats.ActiveUsers++
|
||||
}
|
||||
stats.ActivePeers++
|
||||
}
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"net"
|
||||
"path/filepath"
|
||||
@@ -658,32 +657,3 @@ func newStore(t *testing.T) *FileStore {
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
func TestFileStore_CalculateUsageStats(t *testing.T) {
|
||||
storeDir := t.TempDir()
|
||||
|
||||
err := util.CopyFileContents("testdata/store_stats.json", filepath.Join(storeDir, "store.json"))
|
||||
require.NoError(t, err)
|
||||
|
||||
store, err := NewFileStore(storeDir, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
startDate := time.Date(2024, time.February, 1, 0, 0, 0, 0, time.UTC)
|
||||
endDate := startDate.AddDate(0, 1, 0).Add(-time.Nanosecond)
|
||||
|
||||
stats1, err := store.CalculateUsageStats(context.TODO(), "account-1", startDate, endDate)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, int64(2), stats1.ActiveUsers)
|
||||
assert.Equal(t, int64(4), stats1.TotalUsers)
|
||||
assert.Equal(t, int64(3), stats1.ActivePeers)
|
||||
assert.Equal(t, int64(7), stats1.TotalPeers)
|
||||
|
||||
stats2, err := store.CalculateUsageStats(context.TODO(), "account-2", startDate, endDate)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, int64(1), stats2.ActiveUsers)
|
||||
assert.Equal(t, int64(2), stats2.TotalUsers)
|
||||
assert.Equal(t, int64(1), stats2.ActivePeers)
|
||||
assert.Equal(t, int64(2), stats2.TotalPeers)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package mock_server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
@@ -93,7 +92,6 @@ type MockAccountManager struct {
|
||||
SavePostureChecksFunc func(accountID, userID string, postureChecks *posture.Checks) error
|
||||
DeletePostureChecksFunc func(accountID, postureChecksID, userID string) error
|
||||
ListPostureChecksFunc func(accountID, userID string) ([]*posture.Checks, error)
|
||||
GetUsageFunc func(ctx context.Context, accountID string, start, end time.Time) (*server.AccountUsageStats, error)
|
||||
GetIdpManagerFunc func() idp.Manager
|
||||
}
|
||||
|
||||
@@ -707,14 +705,6 @@ func (am *MockAccountManager) ListPostureChecks(accountID, userID string) ([]*po
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListPostureChecks is not implemented")
|
||||
}
|
||||
|
||||
// GetUsage mocks GetUsage of the AccountManager interface
|
||||
func (am *MockAccountManager) GetUsage(ctx context.Context, accountID string, start time.Time, end time.Time) (*server.AccountUsageStats, error) {
|
||||
if am.GetUsageFunc != nil {
|
||||
return am.GetUsageFunc(ctx, accountID, start, end)
|
||||
}
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetUsage is not implemented")
|
||||
}
|
||||
|
||||
// GetIdpManager mocks GetIdpManager of the AccountManager interface
|
||||
func (am *MockAccountManager) GetIdpManager() idp.Manager {
|
||||
if am.GetIdpManagerFunc != nil {
|
||||
|
||||
@@ -85,6 +85,11 @@ func (wm *DefaultScheduler) Schedule(in time.Duration, ID string, job func() (ne
|
||||
return
|
||||
}
|
||||
|
||||
if in < time.Second {
|
||||
log.Warnf("job for %s was scheduled to run in %s which is under 1s. Adjusting that.", ID, in.String())
|
||||
in = time.Second
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(in)
|
||||
|
||||
wm.jobs[ID] = cancel
|
||||
@@ -112,6 +117,10 @@ func (wm *DefaultScheduler) Schedule(in time.Duration, ID string, job func() (ne
|
||||
}
|
||||
// we need this comparison to avoid resetting the ticker with the same duration and missing the current elapsesed time
|
||||
if runIn != in {
|
||||
if runIn < time.Second {
|
||||
log.Warnf("job for %s was rescheduled to run in %s which is under 1s. Adjusting that.", ID, runIn.String())
|
||||
runIn = time.Second
|
||||
}
|
||||
ticker.Reset(runIn)
|
||||
}
|
||||
case <-cancel:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -498,48 +497,3 @@ func (s *SqliteStore) Close() error {
|
||||
func (s *SqliteStore) GetStoreEngine() StoreEngine {
|
||||
return SqliteStoreEngine
|
||||
}
|
||||
|
||||
// CalculateUsageStats returns the usage stats for an account
|
||||
// start and end are inclusive.
|
||||
func (s *SqliteStore) CalculateUsageStats(ctx context.Context, accountID string, start time.Time, end time.Time) (*AccountUsageStats, error) {
|
||||
stats := &AccountUsageStats{}
|
||||
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
err := tx.Model(&nbpeer.Peer{}).
|
||||
Where("account_id = ? AND peer_status_last_seen BETWEEN ? AND ?", accountID, start, end).
|
||||
Distinct("user_id").
|
||||
Count(&stats.ActiveUsers).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("get active users: %w", err)
|
||||
}
|
||||
|
||||
err = tx.Model(&User{}).
|
||||
Where("account_id = ? AND is_service_user = ?", accountID, false).
|
||||
Count(&stats.TotalUsers).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("get total users: %w", err)
|
||||
}
|
||||
|
||||
err = tx.Model(&nbpeer.Peer{}).
|
||||
Where("account_id = ? AND peer_status_last_seen BETWEEN ? AND ?", accountID, start, end).
|
||||
Count(&stats.ActivePeers).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("get active peers: %w", err)
|
||||
}
|
||||
|
||||
err = tx.Model(&nbpeer.Peer{}).
|
||||
Where("account_id = ?", accountID).
|
||||
Count(&stats.TotalPeers).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("get total peers: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("transaction: %w", err)
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"path/filepath"
|
||||
@@ -347,29 +346,3 @@ func newAccount(store Store, id int) error {
|
||||
|
||||
return store.SaveAccount(account)
|
||||
}
|
||||
|
||||
func TestSqliteStore_CalculateUsageStats(t *testing.T) {
|
||||
store := newSqliteStoreFromFile(t, "testdata/store_stats.json")
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, store.Close())
|
||||
})
|
||||
|
||||
startDate := time.Date(2024, time.February, 1, 0, 0, 0, 0, time.UTC)
|
||||
endDate := startDate.AddDate(0, 1, 0).Add(-time.Nanosecond)
|
||||
|
||||
stats1, err := store.CalculateUsageStats(context.TODO(), "account-1", startDate, endDate)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, int64(2), stats1.ActiveUsers)
|
||||
assert.Equal(t, int64(4), stats1.TotalUsers)
|
||||
assert.Equal(t, int64(3), stats1.ActivePeers)
|
||||
assert.Equal(t, int64(7), stats1.TotalPeers)
|
||||
|
||||
stats2, err := store.CalculateUsageStats(context.TODO(), "account-2", startDate, endDate)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, int64(1), stats2.ActiveUsers)
|
||||
assert.Equal(t, int64(2), stats2.TotalUsers)
|
||||
assert.Equal(t, int64(1), stats2.ActivePeers)
|
||||
assert.Equal(t, int64(2), stats2.TotalPeers)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -42,7 +41,6 @@ type Store interface {
|
||||
// GetStoreEngine should return StoreEngine of the current store implementation.
|
||||
// This is also a method of metrics.DataSource interface.
|
||||
GetStoreEngine() StoreEngine
|
||||
CalculateUsageStats(ctx context.Context, accountID string, start time.Time, end time.Time) (*AccountUsageStats, error)
|
||||
}
|
||||
|
||||
type StoreEngine string
|
||||
|
||||
161
management/server/testdata/store_stats.json
vendored
161
management/server/testdata/store_stats.json
vendored
@@ -1,161 +0,0 @@
|
||||
{
|
||||
"Accounts": {
|
||||
"account-1": {
|
||||
"Id": "account-1",
|
||||
"Domain": "example.com",
|
||||
"Network": {
|
||||
"Id": "af1c8024-ha40-4ce2-9418-34653101fc3c",
|
||||
"Net": {
|
||||
"IP": "100.64.0.0",
|
||||
"Mask": "//8AAA=="
|
||||
},
|
||||
"Dns": null
|
||||
},
|
||||
"Users": {
|
||||
"user-1-account-1": {
|
||||
"Id": "user-1-account-1"
|
||||
},
|
||||
"user-2-account-1": {
|
||||
"Id": "user-2-account-1"
|
||||
},
|
||||
"user-3-account-1": {
|
||||
"Id": "user-3-account-1"
|
||||
},
|
||||
"user-4-account-1": {
|
||||
"Id": "user-4-account-1"
|
||||
},
|
||||
"user-5-account-1": {
|
||||
"Id": "user-5-account-1",
|
||||
"IsServiceUser": true
|
||||
}
|
||||
},
|
||||
"Peers": {
|
||||
"peer-1-account-1": {
|
||||
"ID": "peer-1-account-1",
|
||||
"UserID": "user-1-account-1",
|
||||
"Status": {
|
||||
"LastSeen": "2024-01-01T00:00:00Z"
|
||||
},
|
||||
"Name": "Peer One",
|
||||
"Meta": {
|
||||
"Hostname": "peer1-host"
|
||||
}
|
||||
},
|
||||
"peer-2-account-1": {
|
||||
"ID": "peer-2-account-1",
|
||||
"UserID": "user-2-account-1",
|
||||
"Status": {
|
||||
"LastSeen": "2024-02-29T23:59:59Z"
|
||||
},
|
||||
"Name": "Peer Two",
|
||||
"Meta": {
|
||||
"Hostname": "peer2-host"
|
||||
}
|
||||
},
|
||||
"peer-3-account-1": {
|
||||
"ID": "peer-3-account-1",
|
||||
"UserID": "user-2-account-1",
|
||||
"Status": {
|
||||
"LastSeen": "2024-02-01T12:00:00Z"
|
||||
},
|
||||
"Name": "Peer Three",
|
||||
"Meta": {
|
||||
"Hostname": "peer3-host"
|
||||
}
|
||||
},
|
||||
"peer-4-account-1": {
|
||||
"ID": "peer-4-account-1",
|
||||
"UserID": "user-3-account-1",
|
||||
"Status": {
|
||||
"LastSeen": "2024-02-08T12:00:00Z"
|
||||
},
|
||||
"Name": "Peer Four",
|
||||
"Meta": {
|
||||
"Hostname": "peer4-host"
|
||||
}
|
||||
},
|
||||
"peer-5-account-1": {
|
||||
"ID": "peer-5-account-1",
|
||||
"UserID": "user-3-account-1",
|
||||
"Status": {
|
||||
"LastSeen": "2023-06-01T12:00:00Z"
|
||||
},
|
||||
"Name": "Peer Five",
|
||||
"Meta": {
|
||||
"Hostname": "peer5-host"
|
||||
}
|
||||
},
|
||||
"peer-6-account-1": {
|
||||
"ID": "peer-6-account-1",
|
||||
"UserID": "user-4-account-1",
|
||||
"Status": {
|
||||
"LastSeen": "2024-01-31T23:59:59Z"
|
||||
},
|
||||
"Name": "Peer Six",
|
||||
"Meta": {
|
||||
"Hostname": "peer6-host"
|
||||
}
|
||||
},
|
||||
"peer-7-account-1": {
|
||||
"ID": "peer-7-account-1",
|
||||
"UserID": "user-4-account-1",
|
||||
"Status": {
|
||||
"LastSeen": "2024-03-01T00:00:00Z"
|
||||
},
|
||||
"Name": "Peer Seven",
|
||||
"Meta": {
|
||||
"Hostname": "peer7-host"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"account-2": {
|
||||
"Id": "account-2",
|
||||
"Domain": "example.org",
|
||||
"Network": {
|
||||
"Id": "af1c8024-ha40-4ce2-9418-34653101fc3c",
|
||||
"Net": {
|
||||
"IP": "100.64.0.0",
|
||||
"Mask": "//8AAA=="
|
||||
},
|
||||
"Dns": null
|
||||
},
|
||||
"Users": {
|
||||
"user-1-account-2": {
|
||||
"Id": "user-1-account-2"
|
||||
},
|
||||
"user-2-account-2": {
|
||||
"Id": "user-1-account-2"
|
||||
},
|
||||
"user-3-account-2": {
|
||||
"Id": "user-3-account-2",
|
||||
"IsServiceUser": true
|
||||
}
|
||||
},
|
||||
"Peers": {
|
||||
"peer-1-account-2": {
|
||||
"ID": "peer-1-account-2",
|
||||
"UserID": "user-1-account-2",
|
||||
"Status": {
|
||||
"LastSeen": "2023-08-30T12:00:00Z"
|
||||
},
|
||||
"Name": "Peer One",
|
||||
"Meta": {
|
||||
"Hostname": "peer1-host"
|
||||
}
|
||||
},
|
||||
"peer-2-account-2": {
|
||||
"ID": "peer-2-account-2",
|
||||
"UserID": "user-1-account-2",
|
||||
"Status": {
|
||||
"LastSeen": "2024-02-08T12:00:00Z"
|
||||
},
|
||||
"Name": "Peer Two",
|
||||
"Meta": {
|
||||
"Hostname": "peer2-host"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user