Implement API response cache (#1645)
Apply peer validator cache mechanism --------- Co-authored-by: Maycon Santos <mlsmaycon@gmail.com> Co-authored-by: Yury Gargay <yury.gargay@gmail.com> Co-authored-by: Viktor Liu <viktor@netbird.io> Co-authored-by: Bethuel Mmbaga <bethuelmbaga12@gmail.com> Co-authored-by: pascal-fischer <32096965+pascal-fischer@users.noreply.github.com> Co-authored-by: Misha Bragin <bangvalo@gmail.com>
@@ -76,10 +76,7 @@ func startManagement(t *testing.T, config *mgmt.Config) (*grpc.Server, net.Liste
|
||||
|
||||
peersUpdateManager := mgmt.NewPeersUpdateManager(nil)
|
||||
eventStore := &activity.InMemoryEventStore{}
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
iv := integrations.NewIntegratedApproval()
|
||||
iv, _ := integrations.NewIntegratedApproval(eventStore)
|
||||
accountManager, err := mgmt.BuildManager(store, peersUpdateManager, nil, "", "", eventStore, nil, false, iv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -26,7 +26,7 @@ type HTTPClient interface {
|
||||
}
|
||||
|
||||
// AuthFlowInfo holds information for the OAuth 2.0 authorization flow
|
||||
type AuthFlowInfo struct {
|
||||
type AuthFlowInfo struct { //nolint:revive
|
||||
DeviceCode string `json:"device_code"`
|
||||
UserCode string `json:"user_code"`
|
||||
VerificationURI string `json:"verification_uri"`
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
@@ -23,10 +24,16 @@ const (
|
||||
fileMaxNumberOfSearchDomains = 6
|
||||
)
|
||||
|
||||
const (
|
||||
dnsFailoverTimeout = 4 * time.Second
|
||||
dnsFailoverAttempts = 1
|
||||
)
|
||||
|
||||
type fileConfigurator struct {
|
||||
repair *repair
|
||||
|
||||
originalPerms os.FileMode
|
||||
originalPerms os.FileMode
|
||||
nbNameserverIP string
|
||||
}
|
||||
|
||||
func newFileConfigurator() (hostManager, error) {
|
||||
@@ -64,7 +71,7 @@ func (f *fileConfigurator) applyDNSConfig(config HostDNSConfig) error {
|
||||
}
|
||||
|
||||
nbSearchDomains := searchDomains(config)
|
||||
nbNameserverIP := config.ServerIP
|
||||
f.nbNameserverIP = config.ServerIP
|
||||
|
||||
resolvConf, err := parseBackupResolvConf()
|
||||
if err != nil {
|
||||
@@ -73,11 +80,11 @@ func (f *fileConfigurator) applyDNSConfig(config HostDNSConfig) error {
|
||||
|
||||
f.repair.stopWatchFileChanges()
|
||||
|
||||
err = f.updateConfig(nbSearchDomains, nbNameserverIP, resolvConf)
|
||||
err = f.updateConfig(nbSearchDomains, f.nbNameserverIP, resolvConf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f.repair.watchFileChanges(nbSearchDomains, nbNameserverIP)
|
||||
f.repair.watchFileChanges(nbSearchDomains, f.nbNameserverIP)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -85,10 +92,11 @@ func (f *fileConfigurator) updateConfig(nbSearchDomains []string, nbNameserverIP
|
||||
searchDomainList := mergeSearchDomains(nbSearchDomains, cfg.searchDomains)
|
||||
nameServers := generateNsList(nbNameserverIP, cfg)
|
||||
|
||||
options := prepareOptionsWithTimeout(cfg.others, int(dnsFailoverTimeout.Seconds()), dnsFailoverAttempts)
|
||||
buf := prepareResolvConfContent(
|
||||
searchDomainList,
|
||||
nameServers,
|
||||
cfg.others)
|
||||
options)
|
||||
|
||||
log.Debugf("creating managed file %s", defaultResolvConfPath)
|
||||
err := os.WriteFile(defaultResolvConfPath, buf.Bytes(), f.originalPerms)
|
||||
@@ -131,7 +139,12 @@ func (f *fileConfigurator) backup() error {
|
||||
}
|
||||
|
||||
func (f *fileConfigurator) restore() error {
|
||||
err := copyFile(fileDefaultResolvConfBackupLocation, defaultResolvConfPath)
|
||||
err := removeFirstNbNameserver(fileDefaultResolvConfBackupLocation, f.nbNameserverIP)
|
||||
if err != nil {
|
||||
log.Errorf("Failed to remove netbird nameserver from %s on backup restore: %s", fileDefaultResolvConfBackupLocation, err)
|
||||
}
|
||||
|
||||
err = copyFile(fileDefaultResolvConfBackupLocation, defaultResolvConfPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("restoring %s from %s: %w", defaultResolvConfPath, fileDefaultResolvConfBackupLocation, err)
|
||||
}
|
||||
@@ -157,7 +170,7 @@ func (f *fileConfigurator) restoreUncleanShutdownDNS(storedDNSAddress *netip.Add
|
||||
currentDNSAddress, err := netip.ParseAddr(resolvConf.nameServers[0])
|
||||
// not a valid first nameserver -> restore
|
||||
if err != nil {
|
||||
log.Errorf("restoring unclean shutdown: parse dns address %s failed: %s", resolvConf.nameServers[1], err)
|
||||
log.Errorf("restoring unclean shutdown: parse dns address %s failed: %s", resolvConf.nameServers[0], err)
|
||||
return restoreResolvConfFile()
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ package dns
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -14,6 +15,9 @@ const (
|
||||
defaultResolvConfPath = "/etc/resolv.conf"
|
||||
)
|
||||
|
||||
var timeoutRegex = regexp.MustCompile(`timeout:\d+`)
|
||||
var attemptsRegex = regexp.MustCompile(`attempts:\d+`)
|
||||
|
||||
type resolvConf struct {
|
||||
nameServers []string
|
||||
searchDomains []string
|
||||
@@ -103,3 +107,62 @@ func parseResolvConfFile(resolvConfFile string) (*resolvConf, error) {
|
||||
}
|
||||
return rconf, nil
|
||||
}
|
||||
|
||||
// prepareOptionsWithTimeout appends timeout to existing options if it doesn't exist,
|
||||
// otherwise it adds a new option with timeout and attempts.
|
||||
func prepareOptionsWithTimeout(input []string, timeout int, attempts int) []string {
|
||||
configs := make([]string, len(input))
|
||||
copy(configs, input)
|
||||
|
||||
for i, config := range configs {
|
||||
if strings.HasPrefix(config, "options") {
|
||||
config = strings.ReplaceAll(config, "rotate", "")
|
||||
config = strings.Join(strings.Fields(config), " ")
|
||||
|
||||
if strings.Contains(config, "timeout:") {
|
||||
config = timeoutRegex.ReplaceAllString(config, fmt.Sprintf("timeout:%d", timeout))
|
||||
} else {
|
||||
config = strings.Replace(config, "options ", fmt.Sprintf("options timeout:%d ", timeout), 1)
|
||||
}
|
||||
|
||||
if strings.Contains(config, "attempts:") {
|
||||
config = attemptsRegex.ReplaceAllString(config, fmt.Sprintf("attempts:%d", attempts))
|
||||
} else {
|
||||
config = strings.Replace(config, "options ", fmt.Sprintf("options attempts:%d ", attempts), 1)
|
||||
}
|
||||
|
||||
configs[i] = config
|
||||
return configs
|
||||
}
|
||||
}
|
||||
|
||||
return append(configs, fmt.Sprintf("options timeout:%d attempts:%d", timeout, attempts))
|
||||
}
|
||||
|
||||
// removeFirstNbNameserver removes the given nameserver from the given file if it is in the first position
|
||||
// and writes the file back to the original location
|
||||
func removeFirstNbNameserver(filename, nameserverIP string) error {
|
||||
resolvConf, err := parseResolvConfFile(filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse backup resolv.conf: %w", err)
|
||||
}
|
||||
content, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", filename, err)
|
||||
}
|
||||
|
||||
if len(resolvConf.nameServers) > 1 && resolvConf.nameServers[0] == nameserverIP {
|
||||
newContent := strings.Replace(string(content), fmt.Sprintf("nameserver %s\n", nameserverIP), "", 1)
|
||||
|
||||
stat, err := os.Stat(filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat %s: %w", filename, err)
|
||||
}
|
||||
if err := os.WriteFile(filename, []byte(newContent), stat.Mode()); err != nil {
|
||||
return fmt.Errorf("write %s: %w", filename, err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_parseResolvConf(t *testing.T) {
|
||||
@@ -172,3 +174,131 @@ nameserver 192.168.0.1
|
||||
t.Errorf("unexpected resolv.conf content: %v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareOptionsWithTimeout(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
others []string
|
||||
timeout int
|
||||
attempts int
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "Append new options with timeout and attempts",
|
||||
others: []string{"some config"},
|
||||
timeout: 2,
|
||||
attempts: 2,
|
||||
expected: []string{"some config", "options timeout:2 attempts:2"},
|
||||
},
|
||||
{
|
||||
name: "Modify existing options to exclude rotate and include timeout and attempts",
|
||||
others: []string{"some config", "options rotate someother"},
|
||||
timeout: 3,
|
||||
attempts: 2,
|
||||
expected: []string{"some config", "options attempts:2 timeout:3 someother"},
|
||||
},
|
||||
{
|
||||
name: "Existing options with timeout and attempts are updated",
|
||||
others: []string{"some config", "options timeout:4 attempts:3"},
|
||||
timeout: 5,
|
||||
attempts: 4,
|
||||
expected: []string{"some config", "options timeout:5 attempts:4"},
|
||||
},
|
||||
{
|
||||
name: "Modify existing options, add missing attempts before timeout",
|
||||
others: []string{"some config", "options timeout:4"},
|
||||
timeout: 4,
|
||||
attempts: 3,
|
||||
expected: []string{"some config", "options attempts:3 timeout:4"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := prepareOptionsWithTimeout(tc.others, tc.timeout, tc.attempts)
|
||||
assert.Equal(t, tc.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveFirstNbNameserver(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
content string
|
||||
ipToRemove string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Unrelated nameservers with comments and options",
|
||||
content: `# This is a comment
|
||||
options rotate
|
||||
nameserver 1.1.1.1
|
||||
# Another comment
|
||||
nameserver 8.8.4.4
|
||||
search example.com`,
|
||||
ipToRemove: "9.9.9.9",
|
||||
expected: `# This is a comment
|
||||
options rotate
|
||||
nameserver 1.1.1.1
|
||||
# Another comment
|
||||
nameserver 8.8.4.4
|
||||
search example.com`,
|
||||
},
|
||||
{
|
||||
name: "First nameserver matches",
|
||||
content: `search example.com
|
||||
nameserver 9.9.9.9
|
||||
# oof, a comment
|
||||
nameserver 8.8.4.4
|
||||
options attempts:5`,
|
||||
ipToRemove: "9.9.9.9",
|
||||
expected: `search example.com
|
||||
# oof, a comment
|
||||
nameserver 8.8.4.4
|
||||
options attempts:5`,
|
||||
},
|
||||
{
|
||||
name: "Target IP not the first nameserver",
|
||||
// nolint:dupword
|
||||
content: `# Comment about the first nameserver
|
||||
nameserver 8.8.4.4
|
||||
# Comment before our target
|
||||
nameserver 9.9.9.9
|
||||
options timeout:2`,
|
||||
ipToRemove: "9.9.9.9",
|
||||
// nolint:dupword
|
||||
expected: `# Comment about the first nameserver
|
||||
nameserver 8.8.4.4
|
||||
# Comment before our target
|
||||
nameserver 9.9.9.9
|
||||
options timeout:2`,
|
||||
},
|
||||
{
|
||||
name: "Only nameserver matches",
|
||||
content: `options debug
|
||||
nameserver 9.9.9.9
|
||||
search localdomain`,
|
||||
ipToRemove: "9.9.9.9",
|
||||
expected: `options debug
|
||||
nameserver 9.9.9.9
|
||||
search localdomain`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
tempFile := filepath.Join(tempDir, "resolv.conf")
|
||||
err := os.WriteFile(tempFile, []byte(tc.content), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = removeFirstNbNameserver(tempFile, tc.ipToRemove)
|
||||
assert.NoError(t, err)
|
||||
|
||||
content, err := os.ReadFile(tempFile)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tc.expected, string(content), "The resulting content should match the expected output.")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ func newHostManager(wgInterface string) (hostManager, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Debugf("discovered mode is: %s", osManager)
|
||||
log.Infof("System DNS manager discovered: %s", osManager)
|
||||
return newHostManagerFromType(wgInterface, osManager)
|
||||
}
|
||||
|
||||
|
||||
@@ -53,10 +53,12 @@ func (r *resolvconf) applyDNSConfig(config HostDNSConfig) error {
|
||||
searchDomainList := searchDomains(config)
|
||||
searchDomainList = mergeSearchDomains(searchDomainList, r.originalSearchDomains)
|
||||
|
||||
options := prepareOptionsWithTimeout(r.othersConfigs, int(dnsFailoverTimeout.Seconds()), dnsFailoverAttempts)
|
||||
|
||||
buf := prepareResolvConfContent(
|
||||
searchDomainList,
|
||||
append([]string{config.ServerIP}, r.originalNameServers...),
|
||||
r.othersConfigs)
|
||||
options)
|
||||
|
||||
// create a backup for unclean shutdown detection before the resolv.conf is changed
|
||||
if err := createUncleanShutdownIndicator(defaultResolvConfPath, resolvConfManager, config.ServerIP); err != nil {
|
||||
|
||||
@@ -71,10 +71,10 @@ func TestEngine_SSH(t *testing.T) {
|
||||
defer cancel()
|
||||
|
||||
engine := NewEngine(ctx, cancel, &signal.MockClient{}, &mgmt.MockClient{}, &EngineConfig{
|
||||
WgIfaceName: "utun101",
|
||||
WgAddr: "100.64.0.1/24",
|
||||
WgPrivateKey: key,
|
||||
WgPort: 33100,
|
||||
WgIfaceName: "utun101",
|
||||
WgAddr: "100.64.0.1/24",
|
||||
WgPrivateKey: key,
|
||||
WgPort: 33100,
|
||||
ServerSSHAllowed: true,
|
||||
}, MobileDependency{}, peer.NewRecorder("https://mgm"))
|
||||
|
||||
@@ -1048,10 +1048,8 @@ func startManagement(dataDir string) (*grpc.Server, string, error) {
|
||||
|
||||
peersUpdateManager := server.NewPeersUpdateManager(nil)
|
||||
eventStore := &activity.InMemoryEventStore{}
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
accountManager, err := server.BuildManager(store, peersUpdateManager, nil, "", "", eventStore, nil, false, integrations.NewIntegratedApproval())
|
||||
ia, _ := integrations.NewIntegratedApproval(eventStore)
|
||||
accountManager, err := server.BuildManager(store, peersUpdateManager, nil, "", "", eventStore, nil, false, ia)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ func main() {
|
||||
|
||||
flag.Parse()
|
||||
|
||||
a := app.New()
|
||||
a := app.NewWithID("NetBird")
|
||||
a.SetIcon(fyne.NewStaticResource("netbird", iconDisconnectedPNG))
|
||||
|
||||
client := newServiceClient(daemonAddr, a, showSettings)
|
||||
@@ -82,17 +82,23 @@ var iconConnectedICO []byte
|
||||
//go:embed netbird-systemtray-connected.png
|
||||
var iconConnectedPNG []byte
|
||||
|
||||
//go:embed netbird-systemtray-default.ico
|
||||
//go:embed netbird-systemtray-disconnected.ico
|
||||
var iconDisconnectedICO []byte
|
||||
|
||||
//go:embed netbird-systemtray-default.png
|
||||
//go:embed netbird-systemtray-disconnected.png
|
||||
var iconDisconnectedPNG []byte
|
||||
|
||||
//go:embed netbird-systemtray-update.ico
|
||||
var iconUpdateICO []byte
|
||||
//go:embed netbird-systemtray-update-disconnected.ico
|
||||
var iconUpdateDisconnectedICO []byte
|
||||
|
||||
//go:embed netbird-systemtray-update.png
|
||||
var iconUpdatePNG []byte
|
||||
//go:embed netbird-systemtray-update-disconnected.png
|
||||
var iconUpdateDisconnectedPNG []byte
|
||||
|
||||
//go:embed netbird-systemtray-update-connected.ico
|
||||
var iconUpdateConnectedICO []byte
|
||||
|
||||
//go:embed netbird-systemtray-update-connected.png
|
||||
var iconUpdateConnectedPNG []byte
|
||||
|
||||
//go:embed netbird-systemtray-update-cloud.ico
|
||||
var iconUpdateCloudICO []byte
|
||||
@@ -105,10 +111,11 @@ type serviceClient struct {
|
||||
addr string
|
||||
conn proto.DaemonServiceClient
|
||||
|
||||
icConnected []byte
|
||||
icDisconnected []byte
|
||||
icUpdate []byte
|
||||
icUpdateCloud []byte
|
||||
icConnected []byte
|
||||
icDisconnected []byte
|
||||
icUpdateConnected []byte
|
||||
icUpdateDisconnected []byte
|
||||
icUpdateCloud []byte
|
||||
|
||||
// systray menu items
|
||||
mStatus *systray.MenuItem
|
||||
@@ -123,9 +130,10 @@ type serviceClient struct {
|
||||
mQuit *systray.MenuItem
|
||||
|
||||
// application with main windows.
|
||||
app fyne.App
|
||||
wSettings fyne.Window
|
||||
showSettings bool
|
||||
app fyne.App
|
||||
wSettings fyne.Window
|
||||
showSettings bool
|
||||
sendNotification bool
|
||||
|
||||
// input elements for settings form
|
||||
iMngURL *widget.Entry
|
||||
@@ -139,6 +147,7 @@ type serviceClient struct {
|
||||
preSharedKey string
|
||||
adminURL string
|
||||
|
||||
connected bool
|
||||
update *version.Update
|
||||
daemonVersion string
|
||||
updateIndicationLock sync.Mutex
|
||||
@@ -150,9 +159,10 @@ type serviceClient struct {
|
||||
// This constructor also builds the UI elements for the settings window.
|
||||
func newServiceClient(addr string, a fyne.App, showSettings bool) *serviceClient {
|
||||
s := &serviceClient{
|
||||
ctx: context.Background(),
|
||||
addr: addr,
|
||||
app: a,
|
||||
ctx: context.Background(),
|
||||
addr: addr,
|
||||
app: a,
|
||||
sendNotification: false,
|
||||
|
||||
showSettings: showSettings,
|
||||
update: version.NewUpdate(),
|
||||
@@ -161,13 +171,15 @@ func newServiceClient(addr string, a fyne.App, showSettings bool) *serviceClient
|
||||
if runtime.GOOS == "windows" {
|
||||
s.icConnected = iconConnectedICO
|
||||
s.icDisconnected = iconDisconnectedICO
|
||||
s.icUpdate = iconUpdateICO
|
||||
s.icUpdateConnected = iconUpdateConnectedICO
|
||||
s.icUpdateDisconnected = iconUpdateDisconnectedICO
|
||||
s.icUpdateCloud = iconUpdateCloudICO
|
||||
|
||||
} else {
|
||||
s.icConnected = iconConnectedPNG
|
||||
s.icDisconnected = iconDisconnectedPNG
|
||||
s.icUpdate = iconUpdatePNG
|
||||
s.icUpdateConnected = iconUpdateConnectedPNG
|
||||
s.icUpdateDisconnected = iconUpdateDisconnectedPNG
|
||||
s.icUpdateCloud = iconUpdateCloudPNG
|
||||
}
|
||||
|
||||
@@ -367,9 +379,18 @@ func (s *serviceClient) updateStatus() error {
|
||||
s.updateIndicationLock.Lock()
|
||||
defer s.updateIndicationLock.Unlock()
|
||||
|
||||
// notify the user when the session has expired
|
||||
if status.Status == string(internal.StatusNeedsLogin) {
|
||||
s.onSessionExpire()
|
||||
}
|
||||
|
||||
var systrayIconState bool
|
||||
if status.Status == string(internal.StatusConnected) && !s.mUp.Disabled() {
|
||||
if !s.isUpdateIconActive {
|
||||
s.connected = true
|
||||
s.sendNotification = true
|
||||
if s.isUpdateIconActive {
|
||||
systray.SetIcon(s.icUpdateConnected)
|
||||
} else {
|
||||
systray.SetIcon(s.icConnected)
|
||||
}
|
||||
systray.SetTooltip("NetBird (Connected)")
|
||||
@@ -378,7 +399,10 @@ func (s *serviceClient) updateStatus() error {
|
||||
s.mDown.Enable()
|
||||
systrayIconState = true
|
||||
} else if status.Status != string(internal.StatusConnected) && s.mUp.Disabled() {
|
||||
if !s.isUpdateIconActive {
|
||||
s.connected = false
|
||||
if s.isUpdateIconActive {
|
||||
systray.SetIcon(s.icUpdateDisconnected)
|
||||
} else {
|
||||
systray.SetIcon(s.icDisconnected)
|
||||
}
|
||||
systray.SetTooltip("NetBird (Disconnected)")
|
||||
@@ -605,10 +629,30 @@ func (s *serviceClient) onUpdateAvailable() {
|
||||
defer s.updateIndicationLock.Unlock()
|
||||
|
||||
s.mUpdate.Show()
|
||||
s.mAbout.SetIcon(s.icUpdateCloud)
|
||||
|
||||
s.isUpdateIconActive = true
|
||||
systray.SetIcon(s.icUpdate)
|
||||
|
||||
if s.connected {
|
||||
systray.SetIcon(s.icUpdateConnected)
|
||||
} else {
|
||||
systray.SetIcon(s.icUpdateDisconnected)
|
||||
}
|
||||
}
|
||||
|
||||
// onSessionExpire sends a notification to the user when the session expires.
|
||||
func (s *serviceClient) onSessionExpire() {
|
||||
if s.sendNotification {
|
||||
title := "Connection session expired"
|
||||
if runtime.GOOS == "darwin" {
|
||||
title = "NetBird connection session expired"
|
||||
}
|
||||
s.app.SendNotification(
|
||||
fyne.NewNotification(
|
||||
title,
|
||||
"Please re-authenticate to connect to the network",
|
||||
),
|
||||
)
|
||||
s.sendNotification = false
|
||||
}
|
||||
}
|
||||
|
||||
func openURL(url string) error {
|
||||
|
||||
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |