mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-05 21:51:43 -04:00
* Initial scaffolding * Applies MDM override * Unit tests * Helpers business logic * Return error if trying to modify any config that is gated by MDM * Add ManagedFields to returned config over GetConfig * Adds initial 101 MDM policy business logic testing * gRPC MDM changes * MDM Name scoping for clarity * Implements windows loading of MDM policy * Adds missing WGPort config * Cleanup setupKey to align to linear * Align split tunnel code * Adds some log * Prefix every log with MDM * Adds debug config cobra command This can be useful for troubleshooting and checking config now that its resolution is not trivial defaults > config > env cars > CLI/UI > MDM * Adds MDM 1m diff checker & reloader * Adds also up/start after cancel * Publishes event for UI to sync upon MDM changes * Add events to resync UI to actual config This also provide fixup for UI no aligning to changed config when coming from cli up with config flags. * UI behavior conflicts relaxation UI sends full config snapshot with all values. It doesn't make sense to block it if the values are aligned with the values constrained by the MDM policy. It's just simplier to allow values that are compliant. (this goes for the CLI as well at this point) * Lock toggle Settngs * Advanced Settings locking * Fixup presharedkey * Apply MDM locks * Toggle gray in/out for Advanced Settings * Adds support for disabling of Profiles and UpdateSettings feature flags * Adds Gate Login as well when --disable-update-settings=true is given to service This commit tries to settle things with an old PR-4237 which had relaxed the case where the SetConfig returned an `Unavailable` code error. Under this circumnstance the PR allowed the upFunc to just emit a warning and progress further with the login gRPC. Since the login call is consuming the --management-url coming from the `up` command, it might be possible to abuse the "Unavailable" code to inject a management URL that is different from the configured one even though the --disable-update-settings is set to true (?) * Evaluate disable-update-settings errors only when there's an actual override * [UI] Fixup advanced Settings * [UI] Fixup for preshared key * [UI] Fixup for profile enable/disable toggle We need to align the initial state to evaluate the delta in case. The initial state has to be "true" since the profile starts visible. Then we receive MDM and transition the cache bool value to the actual MDM imposed state * Enforces disable networks * [UI] Aligns to "enable/disable once on change only" * Fixup: MDM wins. always * Removes --disable-advanced-settings It was a typo in our meetings. the actual thing is --disable-update-settings * [PROTO] Removes --disable-advanced-settings * [UI] Removes --disable-advanced-settings * Pins feat profile retrieval to notif event * [UI] Fix for "hide" not working when propagating to parent with children * Adds dep for reading plist files * Introduces support for darwing plist loading * Tests MDM config reload via ticker * [PROVISIONING] ADMX/ADML/PS/bash scripts/templates * CI fixes - Add docstrings to `mdm_integration` - refactor for cognitive complexity - mod tidy * Linting * Add docstrings to `mdm_integration` * nil,nil is no policy and no error. Allow it * nil,nil is no policy and no error. Allow it * exclude MDM profile adminstrated keys data from debug bundle * Fixes Rosenpass left disable after MDM unlock * Partial revert coderabbit added docstrings * Renaming fix * Avoid locking on clientRunning bool when the connection is aborted for whatever reason We want to just signal this through the giveUpChan, we will manage the signal from the waiter side and in case set it to false there. THis way we avoid locking, which should allow the MDM down+wait_for_term_chan_signal_+up procedure clientRunning is used to signal two different conditions here: 1. the initialization procedure is over (we have an engine) 2. the connection being up (or being attempted) Probably these two functionalities should not alias, and the failure of the second condition (because of any error) should just drive a reconnection (currently it's not happening, and we silently go idle). OR, mor probably, the two things are the SAME and there should not exist a case where we did the "Up" initialization and connection attempt but we are not still attempting it. * Moves test helper at te very bottom * Addresses github comments * No lock no copy * Prevents engine not stopping within 10 secs from being paired by another instance We instead juts SKIP updating the policy, so 1. the MDM ticker will kick in 1 minute time, 2. find the policy misaligned, 3. enter the onMDMPolicyChange, 4. find the s.clientRunning == true (because it is set to false only in server cleanupConnection, and not by s.actCancel()) 5. call s.actCancel() again if not nil 6. immediately return from <-s.clientGiveUpChan 7. finally call s.restartEngineForMDMLocked() * Since we ARE running there should be a config If the config was cancelled midflight, connect will abort later on * DisableAutoConnect should not stop a running connection. DisableAutoConnect should just avoid the connection attempts *when the service starts*. If we are started and we are up and running, DisableAutoConnect should not kick in. Another PR will follow about this topic * Removes unused vars * Moves callback into Run method arg * align comment to removal of DisableAutoConnect DisableAutoConnect should just avoid the connection attempts *when the service starts*. If we are started and we are up and running, DisableAutoConnect should not kick in * Removes unused managed_fields data. This was initially used to drive the UI but approach changed to reload config/features upon notifications which makes this data redundant. * Reorder stuff * Unexport unrequired vars/functions PoliciesEqual → policiesEqual AllKeys → allKeys * Adds list of MDM managed fields in the debug bundle
1985 lines
61 KiB
Go
1985 lines
61 KiB
Go
//go:build !(linux && 386)
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
_ "embed"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"os/user"
|
|
"path"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode"
|
|
|
|
"fyne.io/fyne/v2"
|
|
"fyne.io/fyne/v2/app"
|
|
"fyne.io/fyne/v2/canvas"
|
|
"fyne.io/fyne/v2/container"
|
|
"fyne.io/fyne/v2/dialog"
|
|
"fyne.io/fyne/v2/layout"
|
|
"fyne.io/fyne/v2/theme"
|
|
"fyne.io/fyne/v2/widget"
|
|
"fyne.io/systray"
|
|
"github.com/cenkalti/backoff/v4"
|
|
log "github.com/sirupsen/logrus"
|
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/credentials/insecure"
|
|
|
|
"github.com/netbirdio/netbird/client/iface"
|
|
"github.com/netbirdio/netbird/client/internal"
|
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
|
"github.com/netbirdio/netbird/client/mdm"
|
|
"github.com/netbirdio/netbird/client/proto"
|
|
"github.com/netbirdio/netbird/client/ui/desktop"
|
|
"github.com/netbirdio/netbird/client/ui/event"
|
|
"github.com/netbirdio/netbird/client/ui/notifier"
|
|
"github.com/netbirdio/netbird/client/ui/process"
|
|
"github.com/netbirdio/netbird/util"
|
|
|
|
"github.com/netbirdio/netbird/version"
|
|
)
|
|
|
|
const (
|
|
defaultFailTimeout = 3 * time.Second
|
|
failFastTimeout = time.Second
|
|
)
|
|
|
|
const (
|
|
censoredPreSharedKey = "**********"
|
|
maxSSHJWTCacheTTL = 86_400 // 24 hours in seconds
|
|
// mdmFieldSuffix is appended to plain-text Entry widgets in the
|
|
// advanced Settings window when the underlying field is enforced
|
|
// by MDM, so the user sees the lock indicator inline next to the
|
|
// value. Stripped before any read site that feeds the value back
|
|
// into a SetConfig request (saveSettings / parseNumericSettings).
|
|
mdmFieldSuffix = " (MDM)"
|
|
)
|
|
|
|
// main is the entry point for the UI tray/client binary. Parses CLI
|
|
// flags, initialises logging, builds the Fyne application and tray
|
|
// icons, and constructs the service client (which may open a
|
|
// requested UI window). When a window-mode flag is set the Fyne event
|
|
// loop runs and main returns; otherwise main enforces single-instance
|
|
// behaviour (signalling an existing instance to show its window when
|
|
// present), sets up signal handling + default fonts, and runs the
|
|
// system tray loop.
|
|
func main() {
|
|
flags := parseFlags()
|
|
|
|
// Initialize file logging if needed.
|
|
var logFile string
|
|
if flags.saveLogsInFile {
|
|
file, err := initLogFile()
|
|
if err != nil {
|
|
log.Errorf("error while initializing log: %v", err)
|
|
return
|
|
}
|
|
logFile = file
|
|
} else {
|
|
_ = util.InitLog("trace", util.LogConsole)
|
|
}
|
|
|
|
// Create the Fyne application.
|
|
a := app.NewWithID("NetBird")
|
|
a.SetIcon(fyne.NewStaticResource("netbird", iconDisconnected))
|
|
|
|
// Show error message window if needed.
|
|
if flags.errorMsg != "" {
|
|
showErrorMessage(flags.errorMsg)
|
|
return
|
|
}
|
|
|
|
// Create the service client (this also builds the settings or networks UI if requested).
|
|
client := newServiceClient(&newServiceClientArgs{
|
|
addr: flags.daemonAddr,
|
|
logFile: logFile,
|
|
app: a,
|
|
showSettings: flags.showSettings,
|
|
showNetworks: flags.showNetworks,
|
|
showLoginURL: flags.showLoginURL,
|
|
showDebug: flags.showDebug,
|
|
showProfiles: flags.showProfiles,
|
|
showQuickActions: flags.showQuickActions,
|
|
showUpdate: flags.showUpdate,
|
|
showUpdateVersion: flags.showUpdateVersion,
|
|
})
|
|
|
|
// Watch for theme/settings changes to update the icon.
|
|
go watchSettingsChanges(a, client)
|
|
|
|
// Run in window mode if any UI flag was set.
|
|
if flags.showSettings || flags.showNetworks || flags.showDebug || flags.showLoginURL || flags.showProfiles || flags.showQuickActions || flags.showUpdate {
|
|
a.Run()
|
|
return
|
|
}
|
|
|
|
// Check for another running process.
|
|
pid, running, err := process.IsAnotherProcessRunning()
|
|
if err != nil {
|
|
log.Errorf("error while checking process: %v", err)
|
|
return
|
|
}
|
|
if running {
|
|
log.Infof("another process is running with pid %d, sending signal to show window", pid)
|
|
if err := sendShowWindowSignal(pid); err != nil {
|
|
log.Errorf("send signal to running instance: %v", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
client.setupSignalHandler(client.ctx)
|
|
|
|
client.setDefaultFonts()
|
|
systray.Run(client.onTrayReady, client.onTrayExit)
|
|
}
|
|
|
|
type cliFlags struct {
|
|
daemonAddr string
|
|
showSettings bool
|
|
showNetworks bool
|
|
showProfiles bool
|
|
showDebug bool
|
|
showLoginURL bool
|
|
showQuickActions bool
|
|
errorMsg string
|
|
saveLogsInFile bool
|
|
showUpdate bool
|
|
showUpdateVersion string
|
|
}
|
|
|
|
// parseFlags reads and returns all needed command-line flags.
|
|
func parseFlags() *cliFlags {
|
|
var flags cliFlags
|
|
|
|
defaultDaemonAddr := "unix:///var/run/netbird.sock"
|
|
if runtime.GOOS == "windows" {
|
|
defaultDaemonAddr = "tcp://127.0.0.1:41731"
|
|
}
|
|
flag.StringVar(&flags.daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp]://[path|host:port]")
|
|
flag.BoolVar(&flags.showSettings, "settings", false, "run settings window")
|
|
flag.BoolVar(&flags.showNetworks, "networks", false, "run networks window")
|
|
flag.BoolVar(&flags.showProfiles, "profiles", false, "run profiles window")
|
|
flag.BoolVar(&flags.showDebug, "debug", false, "run debug window")
|
|
flag.BoolVar(&flags.showQuickActions, "quick-actions", false, "run quick actions window")
|
|
flag.StringVar(&flags.errorMsg, "error-msg", "", "displays an error message window")
|
|
flag.BoolVar(&flags.saveLogsInFile, "use-log-file", false, fmt.Sprintf("save logs in a file: %s/netbird-ui-PID.log", os.TempDir()))
|
|
flag.BoolVar(&flags.showLoginURL, "login-url", false, "show login URL in a popup window")
|
|
flag.BoolVar(&flags.showUpdate, "update", false, "show update progress window")
|
|
flag.StringVar(&flags.showUpdateVersion, "update-version", "", "version to update to")
|
|
flag.Parse()
|
|
return &flags
|
|
}
|
|
|
|
// initLogFile initializes logging into a file.
|
|
func initLogFile() (string, error) {
|
|
logFile := path.Join(os.TempDir(), fmt.Sprintf("netbird-ui-%d.log", os.Getpid()))
|
|
return logFile, util.InitLog("trace", logFile)
|
|
}
|
|
|
|
// watchSettingsChanges listens for Fyne theme/settings changes and updates the client icon.
|
|
func watchSettingsChanges(a fyne.App, client *serviceClient) {
|
|
a.Settings().AddListener(func(settings fyne.Settings) {
|
|
client.updateIcon()
|
|
})
|
|
}
|
|
|
|
// showErrorMessage displays an error message in a simple window.
|
|
func showErrorMessage(msg string) {
|
|
a := app.New()
|
|
w := a.NewWindow("NetBird Error")
|
|
label := widget.NewLabel(msg)
|
|
label.Wrapping = fyne.TextWrapWord
|
|
w.SetContent(label)
|
|
w.Resize(fyne.NewSize(400, 100))
|
|
w.Show()
|
|
a.Run()
|
|
}
|
|
|
|
//go:embed assets/netbird-systemtray-connected-macos.png
|
|
var iconConnectedMacOS []byte
|
|
|
|
//go:embed assets/netbird-systemtray-disconnected-macos.png
|
|
var iconDisconnectedMacOS []byte
|
|
|
|
//go:embed assets/netbird-systemtray-update-disconnected-macos.png
|
|
var iconUpdateDisconnectedMacOS []byte
|
|
|
|
//go:embed assets/netbird-systemtray-update-connected-macos.png
|
|
var iconUpdateConnectedMacOS []byte
|
|
|
|
//go:embed assets/netbird-systemtray-connecting-macos.png
|
|
var iconConnectingMacOS []byte
|
|
|
|
//go:embed assets/netbird-systemtray-error-macos.png
|
|
var iconErrorMacOS []byte
|
|
|
|
//go:embed assets/connected.png
|
|
var iconConnectedDot []byte
|
|
|
|
//go:embed assets/disconnected.png
|
|
var iconDisconnectedDot []byte
|
|
|
|
type serviceClient struct {
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
addr string
|
|
conn proto.DaemonServiceClient
|
|
connLock sync.Mutex
|
|
|
|
eventHandler *eventHandler
|
|
|
|
profileManager *profilemanager.ProfileManager
|
|
|
|
icAbout []byte
|
|
icConnected []byte
|
|
icConnectedDot []byte
|
|
icDisconnected []byte
|
|
icDisconnectedDot []byte
|
|
icUpdateConnected []byte
|
|
icUpdateDisconnected []byte
|
|
icConnecting []byte
|
|
icError []byte
|
|
|
|
// systray menu items
|
|
mStatus *systray.MenuItem
|
|
mUp *systray.MenuItem
|
|
mDown *systray.MenuItem
|
|
mSettings *systray.MenuItem
|
|
mProfile *profileMenu
|
|
mAbout *systray.MenuItem
|
|
mGitHub *systray.MenuItem
|
|
mVersionUI *systray.MenuItem
|
|
mVersionDaemon *systray.MenuItem
|
|
mUpdate *systray.MenuItem
|
|
mQuit *systray.MenuItem
|
|
mNetworks *systray.MenuItem
|
|
mAllowSSH *systray.MenuItem
|
|
mAutoConnect *systray.MenuItem
|
|
mEnableRosenpass *systray.MenuItem
|
|
mLazyConnEnabled *systray.MenuItem
|
|
mBlockInbound *systray.MenuItem
|
|
mNotifications *systray.MenuItem
|
|
mAdvancedSettings *systray.MenuItem
|
|
mCreateDebugBundle *systray.MenuItem
|
|
mExitNode *systray.MenuItem
|
|
|
|
// application with main windows.
|
|
app fyne.App
|
|
notifier notifier.Notifier
|
|
wSettings fyne.Window
|
|
showAdvancedSettings bool
|
|
sendNotification bool
|
|
|
|
// input elements for settings form
|
|
iMngURL *widget.Entry
|
|
iLogFile *widget.Entry
|
|
iPreSharedKey *widget.Entry
|
|
iInterfaceName *widget.Entry
|
|
iInterfacePort *widget.Entry
|
|
iMTU *widget.Entry
|
|
|
|
// switch elements for settings form
|
|
sRosenpassPermissive *widget.Check
|
|
sNetworkMonitor *widget.Check
|
|
sDisableDNS *widget.Check
|
|
sDisableClientRoutes *widget.Check
|
|
sDisableServerRoutes *widget.Check
|
|
sDisableIPv6 *widget.Check
|
|
sBlockLANAccess *widget.Check
|
|
sEnableSSHRoot *widget.Check
|
|
sEnableSSHSFTP *widget.Check
|
|
sEnableSSHLocalPortForward *widget.Check
|
|
sEnableSSHRemotePortForward *widget.Check
|
|
sDisableSSHAuth *widget.Check
|
|
iSSHJWTCacheTTL *widget.Entry
|
|
|
|
// observable settings over corresponding iMngURL and iPreSharedKey values.
|
|
managementURL string
|
|
preSharedKey string
|
|
|
|
RosenpassPermissive bool
|
|
interfaceName string
|
|
interfacePort int
|
|
mtu uint16
|
|
networkMonitor bool
|
|
disableDNS bool
|
|
disableClientRoutes bool
|
|
disableServerRoutes bool
|
|
disableIPv6 bool
|
|
blockLANAccess bool
|
|
enableSSHRoot bool
|
|
enableSSHSFTP bool
|
|
enableSSHLocalPortForward bool
|
|
enableSSHRemotePortForward bool
|
|
disableSSHAuth bool
|
|
sshJWTCacheTTL int
|
|
|
|
connected bool
|
|
daemonVersion string
|
|
updateIndicationLock sync.Mutex
|
|
isUpdateIconActive bool
|
|
isEnforcedUpdate bool
|
|
lastNotifiedVersion string
|
|
profilesEnabled bool
|
|
networksEnabled bool
|
|
// networksMenuEnabled caches the last applied enabled-state of the
|
|
// mNetworks + mExitNode submenu items. Combines features.DisableNetworks
|
|
// AND s.connected — both must be true for the menus to be active.
|
|
// Zero value (false) matches the Disable() call at AddMenuItem time.
|
|
networksMenuEnabled bool
|
|
showNetworks bool
|
|
wNetworks fyne.Window
|
|
wProfiles fyne.Window
|
|
wQuickActions fyne.Window
|
|
|
|
eventManager *event.Manager
|
|
|
|
exitNodeMu sync.Mutex
|
|
mExitNodeItems []menuHandler
|
|
exitNodeRetryCancel context.CancelFunc
|
|
mExitNodeSeparator *systray.MenuItem
|
|
mExitNodeDeselectAll *systray.MenuItem
|
|
logFile string
|
|
wLoginURL fyne.Window
|
|
wUpdateProgress fyne.Window
|
|
updateContextCancel context.CancelFunc
|
|
|
|
connectCancel context.CancelFunc
|
|
|
|
// mdmManagedFields caches the names of MDM-enforced policy keys
|
|
// surfaced by the daemon in GetConfigResponse. Each refresh of
|
|
// daemon config (loadSettings, getSrvConfig, config_changed event)
|
|
// updates this set and re-applies the lock/badge to the affected
|
|
// menu items and settings-form widgets.
|
|
mdmManagedFields map[string]bool
|
|
}
|
|
|
|
type menuHandler struct {
|
|
*systray.MenuItem
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
type newServiceClientArgs struct {
|
|
addr string
|
|
logFile string
|
|
app fyne.App
|
|
showSettings bool
|
|
showNetworks bool
|
|
showDebug bool
|
|
showLoginURL bool
|
|
showProfiles bool
|
|
showQuickActions bool
|
|
showUpdate bool
|
|
showUpdateVersion string
|
|
}
|
|
|
|
// newServiceClient instance constructor
|
|
//
|
|
// This constructor also builds the UI elements for the settings window.
|
|
func newServiceClient(args *newServiceClientArgs) *serviceClient {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
s := &serviceClient{
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
addr: args.addr,
|
|
app: args.app,
|
|
notifier: notifier.New(args.app),
|
|
logFile: args.logFile,
|
|
sendNotification: false,
|
|
|
|
showAdvancedSettings: args.showSettings,
|
|
showNetworks: args.showNetworks,
|
|
networksEnabled: true,
|
|
}
|
|
|
|
s.eventHandler = newEventHandler(s)
|
|
s.profileManager = profilemanager.NewProfileManager()
|
|
s.setNewIcons()
|
|
|
|
switch {
|
|
case args.showSettings:
|
|
s.showSettingsUI()
|
|
case args.showNetworks:
|
|
s.showNetworksUI()
|
|
case args.showLoginURL:
|
|
s.showLoginURL()
|
|
case args.showDebug:
|
|
s.showDebugUI()
|
|
case args.showProfiles:
|
|
s.showProfilesUI()
|
|
case args.showQuickActions:
|
|
s.showQuickActionsUI()
|
|
case args.showUpdate:
|
|
s.showUpdateProgress(ctx, args.showUpdateVersion)
|
|
}
|
|
|
|
return s
|
|
}
|
|
|
|
func (s *serviceClient) setNewIcons() {
|
|
s.icAbout = iconAbout
|
|
s.icConnectedDot = iconConnectedDot
|
|
s.icDisconnectedDot = iconDisconnectedDot
|
|
if s.app.Settings().ThemeVariant() == theme.VariantDark {
|
|
s.icConnected = iconConnectedDark
|
|
s.icDisconnected = iconDisconnected
|
|
s.icUpdateConnected = iconUpdateConnectedDark
|
|
s.icUpdateDisconnected = iconUpdateDisconnectedDark
|
|
s.icConnecting = iconConnectingDark
|
|
s.icError = iconErrorDark
|
|
} else {
|
|
s.icConnected = iconConnected
|
|
s.icDisconnected = iconDisconnected
|
|
s.icUpdateConnected = iconUpdateConnected
|
|
s.icUpdateDisconnected = iconUpdateDisconnected
|
|
s.icConnecting = iconConnecting
|
|
s.icError = iconError
|
|
}
|
|
}
|
|
|
|
func (s *serviceClient) updateIcon() {
|
|
s.setNewIcons()
|
|
s.updateIndicationLock.Lock()
|
|
if s.connected {
|
|
if s.isUpdateIconActive {
|
|
systray.SetTemplateIcon(iconUpdateConnectedMacOS, s.icUpdateConnected)
|
|
} else {
|
|
systray.SetTemplateIcon(iconConnectedMacOS, s.icConnected)
|
|
}
|
|
} else {
|
|
if s.isUpdateIconActive {
|
|
systray.SetTemplateIcon(iconUpdateDisconnectedMacOS, s.icUpdateDisconnected)
|
|
} else {
|
|
systray.SetTemplateIcon(iconDisconnectedMacOS, s.icDisconnected)
|
|
}
|
|
}
|
|
s.updateIndicationLock.Unlock()
|
|
}
|
|
|
|
func (s *serviceClient) showSettingsUI() {
|
|
// DisableUpdateSettings no longer gates the window from opening:
|
|
// the daemon blocks every actual mutation at SetConfig / Login,
|
|
// so the window is safe to show as a read-only view. The previous
|
|
// early-return also blocked Advanced Settings whenever update
|
|
// editing was off, which conflated two distinct kill switches
|
|
// (see comment in checkAndUpdateFeatures).
|
|
|
|
// add settings window UI elements.
|
|
s.wSettings = s.app.NewWindow("NetBird Settings")
|
|
s.wSettings.SetOnClosed(s.cancel)
|
|
|
|
s.iMngURL = widget.NewEntry()
|
|
|
|
s.iLogFile = widget.NewEntry()
|
|
s.iLogFile.Disable()
|
|
s.iPreSharedKey = widget.NewPasswordEntry()
|
|
s.iInterfaceName = widget.NewEntry()
|
|
s.iInterfacePort = widget.NewEntry()
|
|
s.iMTU = widget.NewEntry()
|
|
|
|
s.sRosenpassPermissive = widget.NewCheck("Enable Rosenpass permissive mode", nil)
|
|
|
|
s.sNetworkMonitor = widget.NewCheck("Restarts NetBird when the network changes", nil)
|
|
s.sDisableDNS = widget.NewCheck("Keeps system DNS settings unchanged", nil)
|
|
s.sDisableClientRoutes = widget.NewCheck("This peer won't route traffic to other peers", nil)
|
|
s.sDisableServerRoutes = widget.NewCheck("This peer won't act as router for others", nil)
|
|
s.sDisableIPv6 = widget.NewCheck("Disable IPv6 overlay addressing", nil)
|
|
s.sBlockLANAccess = widget.NewCheck("Blocks local network access when used as exit node", nil)
|
|
s.sEnableSSHRoot = widget.NewCheck("Enable SSH Root Login", nil)
|
|
s.sEnableSSHSFTP = widget.NewCheck("Enable SSH SFTP", nil)
|
|
s.sEnableSSHLocalPortForward = widget.NewCheck("Enable SSH Local Port Forwarding", nil)
|
|
s.sEnableSSHRemotePortForward = widget.NewCheck("Enable SSH Remote Port Forwarding", nil)
|
|
s.sDisableSSHAuth = widget.NewCheck("Disable SSH Authentication", nil)
|
|
s.iSSHJWTCacheTTL = widget.NewEntry()
|
|
|
|
s.wSettings.SetContent(s.getSettingsForm())
|
|
s.wSettings.Resize(fyne.NewSize(600, 400))
|
|
s.wSettings.SetFixedSize(true)
|
|
|
|
s.getSrvConfig()
|
|
s.wSettings.Show()
|
|
}
|
|
|
|
func (s *serviceClient) getConnectionForm() *widget.Form {
|
|
var activeProfName string
|
|
activeProf, err := s.profileManager.GetActiveProfile()
|
|
if err != nil {
|
|
log.Errorf("get active profile: %v", err)
|
|
} else {
|
|
activeProfName = activeProf.Name
|
|
}
|
|
return &widget.Form{
|
|
Items: []*widget.FormItem{
|
|
{Text: "Profile", Widget: widget.NewLabel(activeProfName)},
|
|
{Text: "Management URL", Widget: s.iMngURL},
|
|
{Text: "Pre-shared Key", Widget: s.iPreSharedKey},
|
|
{Text: "Quantum-Resistance", Widget: s.sRosenpassPermissive},
|
|
{Text: "Interface Name", Widget: s.iInterfaceName},
|
|
{Text: "Interface Port", Widget: s.iInterfacePort, HintText: "If set to 0, a random free port will be used"},
|
|
{Text: "MTU", Widget: s.iMTU},
|
|
{Text: "Log File", Widget: s.iLogFile},
|
|
},
|
|
}
|
|
}
|
|
|
|
func (s *serviceClient) saveSettings() {
|
|
// Check if update settings are disabled by daemon
|
|
features, err := s.getFeatures()
|
|
if err != nil {
|
|
log.Errorf("failed to get features from daemon: %v", err)
|
|
// Continue with default behavior if features can't be retrieved
|
|
} else if features != nil && features.DisableUpdateSettings {
|
|
log.Warn("Configuration updates are disabled by daemon")
|
|
dialog.ShowError(fmt.Errorf("configuration updates are disabled by daemon"), s.wSettings)
|
|
return
|
|
}
|
|
|
|
if err := s.validateSettings(); err != nil {
|
|
dialog.ShowError(err, s.wSettings)
|
|
return
|
|
}
|
|
|
|
port, mtu, err := s.parseNumericSettings()
|
|
if err != nil {
|
|
dialog.ShowError(err, s.wSettings)
|
|
return
|
|
}
|
|
|
|
iMngURL := strings.TrimSpace(strings.TrimSuffix(s.iMngURL.Text, mdmFieldSuffix))
|
|
|
|
if s.hasSettingsChanged(iMngURL, port, mtu) {
|
|
if err := s.applySettingsChanges(iMngURL, port, mtu); err != nil {
|
|
dialog.ShowError(err, s.wSettings)
|
|
return
|
|
}
|
|
}
|
|
|
|
s.wSettings.Close()
|
|
}
|
|
|
|
func (s *serviceClient) validateSettings() error {
|
|
if s.iPreSharedKey.Text != "" && s.iPreSharedKey.Text != censoredPreSharedKey {
|
|
if _, err := wgtypes.ParseKey(s.iPreSharedKey.Text); err != nil {
|
|
return fmt.Errorf("invalid pre-shared key value")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *serviceClient) parseNumericSettings() (int64, int64, error) {
|
|
port, err := strconv.ParseInt(strings.TrimSpace(strings.TrimSuffix(s.iInterfacePort.Text, mdmFieldSuffix)), 10, 64)
|
|
if err != nil {
|
|
return 0, 0, errors.New("invalid interface port")
|
|
}
|
|
if port < 0 || port > 65535 {
|
|
return 0, 0, errors.New("invalid interface port: out of range 0-65535")
|
|
}
|
|
|
|
var mtu int64
|
|
mtuText := strings.TrimSpace(s.iMTU.Text)
|
|
if mtuText != "" {
|
|
mtu, err = strconv.ParseInt(mtuText, 10, 64)
|
|
if err != nil {
|
|
return 0, 0, errors.New("invalid MTU value")
|
|
}
|
|
if mtu < iface.MinMTU || mtu > iface.MaxMTU {
|
|
return 0, 0, fmt.Errorf("MTU must be between %d and %d bytes", iface.MinMTU, iface.MaxMTU)
|
|
}
|
|
}
|
|
|
|
return port, mtu, nil
|
|
}
|
|
|
|
func (s *serviceClient) hasSettingsChanged(iMngURL string, port, mtu int64) bool {
|
|
return s.managementURL != iMngURL ||
|
|
s.preSharedKey != s.iPreSharedKey.Text ||
|
|
s.RosenpassPermissive != s.sRosenpassPermissive.Checked ||
|
|
s.interfaceName != s.iInterfaceName.Text ||
|
|
s.interfacePort != int(port) ||
|
|
s.mtu != uint16(mtu) ||
|
|
s.networkMonitor != s.sNetworkMonitor.Checked ||
|
|
s.disableDNS != s.sDisableDNS.Checked ||
|
|
s.disableClientRoutes != s.sDisableClientRoutes.Checked ||
|
|
s.disableServerRoutes != s.sDisableServerRoutes.Checked ||
|
|
s.disableIPv6 != s.sDisableIPv6.Checked ||
|
|
s.blockLANAccess != s.sBlockLANAccess.Checked ||
|
|
s.hasSSHChanges()
|
|
}
|
|
|
|
func (s *serviceClient) applySettingsChanges(iMngURL string, port, mtu int64) error {
|
|
s.managementURL = iMngURL
|
|
s.preSharedKey = s.iPreSharedKey.Text
|
|
s.mtu = uint16(mtu)
|
|
|
|
req, err := s.buildSetConfigRequest(iMngURL, port, mtu)
|
|
if err != nil {
|
|
return fmt.Errorf("build config request: %w", err)
|
|
}
|
|
|
|
if err := s.sendConfigUpdate(req); err != nil {
|
|
return fmt.Errorf("set configuration: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *serviceClient) buildSetConfigRequest(iMngURL string, port, mtu int64) (*proto.SetConfigRequest, error) {
|
|
currUser, err := user.Current()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get current user: %w", err)
|
|
}
|
|
|
|
activeProf, err := s.profileManager.GetActiveProfile()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get active profile: %w", err)
|
|
}
|
|
|
|
req := &proto.SetConfigRequest{
|
|
ProfileName: activeProf.Name,
|
|
Username: currUser.Username,
|
|
}
|
|
|
|
if iMngURL != "" {
|
|
req.ManagementUrl = iMngURL
|
|
}
|
|
|
|
req.RosenpassPermissive = &s.sRosenpassPermissive.Checked
|
|
req.InterfaceName = &s.iInterfaceName.Text
|
|
req.WireguardPort = &port
|
|
if mtu > 0 {
|
|
req.Mtu = &mtu
|
|
}
|
|
|
|
req.NetworkMonitor = &s.sNetworkMonitor.Checked
|
|
req.DisableDns = &s.sDisableDNS.Checked
|
|
req.DisableClientRoutes = &s.sDisableClientRoutes.Checked
|
|
req.DisableServerRoutes = &s.sDisableServerRoutes.Checked
|
|
req.DisableIpv6 = &s.sDisableIPv6.Checked
|
|
req.BlockLanAccess = &s.sBlockLANAccess.Checked
|
|
|
|
req.EnableSSHRoot = &s.sEnableSSHRoot.Checked
|
|
req.EnableSSHSFTP = &s.sEnableSSHSFTP.Checked
|
|
req.EnableSSHLocalPortForwarding = &s.sEnableSSHLocalPortForward.Checked
|
|
req.EnableSSHRemotePortForwarding = &s.sEnableSSHRemotePortForward.Checked
|
|
req.DisableSSHAuth = &s.sDisableSSHAuth.Checked
|
|
|
|
sshJWTCacheTTLText := strings.TrimSpace(s.iSSHJWTCacheTTL.Text)
|
|
if sshJWTCacheTTLText != "" {
|
|
sshJWTCacheTTL, err := strconv.ParseInt(sshJWTCacheTTLText, 10, 32)
|
|
if err != nil {
|
|
return nil, errors.New("invalid SSH JWT Cache TTL value")
|
|
}
|
|
if sshJWTCacheTTL < 0 || sshJWTCacheTTL > maxSSHJWTCacheTTL {
|
|
return nil, fmt.Errorf("SSH JWT Cache TTL must be between 0 and %d seconds", maxSSHJWTCacheTTL)
|
|
}
|
|
sshJWTCacheTTL32 := int32(sshJWTCacheTTL)
|
|
req.SshJWTCacheTTL = &sshJWTCacheTTL32
|
|
}
|
|
|
|
// Only attach the PSK when the user actually typed something:
|
|
// - "" means the field was left untouched (we deliberately render
|
|
// an empty Text + placeholder hint to avoid leaking the daemon's
|
|
// "**********" redaction through the password reveal toggle);
|
|
// sending an empty pointer would tell the daemon to clear / overwrite
|
|
// the on-disk or MDM-enforced PSK, which then trips the MDM
|
|
// conflict gate when PSK is policy-managed.
|
|
// - "**********" is the redacted echo (legacy non-MDM path); also a no-op.
|
|
if s.iPreSharedKey.Text != "" && s.iPreSharedKey.Text != censoredPreSharedKey {
|
|
req.OptionalPreSharedKey = &s.iPreSharedKey.Text
|
|
}
|
|
|
|
return req, nil
|
|
}
|
|
|
|
func (s *serviceClient) sendConfigUpdate(req *proto.SetConfigRequest) error {
|
|
conn, err := s.getSrvClient(failFastTimeout)
|
|
if err != nil {
|
|
return fmt.Errorf("get client: %w", err)
|
|
}
|
|
|
|
_, err = conn.SetConfig(s.ctx, req)
|
|
if err != nil {
|
|
return fmt.Errorf("set config: %w", err)
|
|
}
|
|
|
|
// Reconnect if connected to apply the new settings.
|
|
// Use a background context so the reconnect outlives the settings window.
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
status, err := conn.Status(ctx, &proto.StatusRequest{})
|
|
if err != nil {
|
|
log.Errorf("failed to get service status: %v", err)
|
|
return
|
|
}
|
|
if status.Status == string(internal.StatusConnected) {
|
|
if _, err = conn.Down(ctx, &proto.DownRequest{}); err != nil {
|
|
log.Errorf("failed to stop service: %v", err)
|
|
}
|
|
// TODO: wait for the service to be idle before calling Up, or use a fresh connection
|
|
if _, err = conn.Up(ctx, &proto.UpRequest{}); err != nil {
|
|
log.Errorf("failed to start service: %v", err)
|
|
}
|
|
}
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *serviceClient) getSettingsForm() fyne.CanvasObject {
|
|
connectionForm := s.getConnectionForm()
|
|
networkForm := s.getNetworkForm()
|
|
sshForm := s.getSSHForm()
|
|
tabs := container.NewAppTabs(
|
|
container.NewTabItem("Connection", connectionForm),
|
|
container.NewTabItem("Network", networkForm),
|
|
container.NewTabItem("SSH", sshForm),
|
|
)
|
|
saveButton := widget.NewButtonWithIcon("Save", theme.ConfirmIcon(), s.saveSettings)
|
|
saveButton.Importance = widget.HighImportance
|
|
cancelButton := widget.NewButtonWithIcon("Cancel", theme.CancelIcon(), func() {
|
|
s.wSettings.Close()
|
|
})
|
|
buttonContainer := container.NewHBox(
|
|
layout.NewSpacer(),
|
|
cancelButton,
|
|
saveButton,
|
|
)
|
|
return container.NewBorder(nil, buttonContainer, nil, nil, tabs)
|
|
}
|
|
|
|
func (s *serviceClient) getNetworkForm() *widget.Form {
|
|
return &widget.Form{
|
|
Items: []*widget.FormItem{
|
|
{Text: "Network Monitor", Widget: s.sNetworkMonitor},
|
|
{Text: "Disable DNS", Widget: s.sDisableDNS},
|
|
{Text: "Disable Client Routes", Widget: s.sDisableClientRoutes},
|
|
{Text: "Disable Server Routes", Widget: s.sDisableServerRoutes},
|
|
{Text: "Disable IPv6", Widget: s.sDisableIPv6},
|
|
{Text: "Disable LAN Access", Widget: s.sBlockLANAccess},
|
|
},
|
|
}
|
|
}
|
|
|
|
func (s *serviceClient) getSSHForm() *widget.Form {
|
|
return &widget.Form{
|
|
Items: []*widget.FormItem{
|
|
{Text: "Enable SSH Root Login", Widget: s.sEnableSSHRoot},
|
|
{Text: "Enable SSH SFTP", Widget: s.sEnableSSHSFTP},
|
|
{Text: "Enable SSH Local Port Forwarding", Widget: s.sEnableSSHLocalPortForward},
|
|
{Text: "Enable SSH Remote Port Forwarding", Widget: s.sEnableSSHRemotePortForward},
|
|
{Text: "Disable SSH Authentication", Widget: s.sDisableSSHAuth},
|
|
{Text: "JWT Cache TTL (seconds, 0=disabled)", Widget: s.iSSHJWTCacheTTL},
|
|
},
|
|
}
|
|
}
|
|
|
|
func (s *serviceClient) hasSSHChanges() bool {
|
|
currentSSHJWTCacheTTL := s.sshJWTCacheTTL
|
|
if text := strings.TrimSpace(s.iSSHJWTCacheTTL.Text); text != "" {
|
|
val, err := strconv.Atoi(text)
|
|
if err != nil {
|
|
return true
|
|
}
|
|
currentSSHJWTCacheTTL = val
|
|
}
|
|
|
|
return s.enableSSHRoot != s.sEnableSSHRoot.Checked ||
|
|
s.enableSSHSFTP != s.sEnableSSHSFTP.Checked ||
|
|
s.enableSSHLocalPortForward != s.sEnableSSHLocalPortForward.Checked ||
|
|
s.enableSSHRemotePortForward != s.sEnableSSHRemotePortForward.Checked ||
|
|
s.disableSSHAuth != s.sDisableSSHAuth.Checked ||
|
|
s.sshJWTCacheTTL != currentSSHJWTCacheTTL
|
|
}
|
|
|
|
func (s *serviceClient) login(ctx context.Context, openURL bool) (*proto.LoginResponse, error) {
|
|
conn, err := s.getSrvClient(defaultFailTimeout)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get daemon client: %w", err)
|
|
}
|
|
|
|
activeProf, err := s.profileManager.GetActiveProfile()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get active profile: %w", err)
|
|
}
|
|
|
|
currUser, err := user.Current()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get current user: %w", err)
|
|
}
|
|
|
|
loginReq := &proto.LoginRequest{
|
|
IsUnixDesktopClient: runtime.GOOS == "linux" || runtime.GOOS == "freebsd",
|
|
ProfileName: &activeProf.Name,
|
|
Username: &currUser.Username,
|
|
}
|
|
|
|
profileState, err := s.profileManager.GetProfileState(activeProf.Name)
|
|
if err != nil {
|
|
log.Debugf("failed to get profile state for login hint: %v", err)
|
|
} else if profileState.Email != "" {
|
|
loginReq.Hint = &profileState.Email
|
|
}
|
|
|
|
loginResp, err := conn.Login(ctx, loginReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("login to management: %w", err)
|
|
}
|
|
|
|
if loginResp.NeedsSSOLogin && openURL {
|
|
if err = s.handleSSOLogin(ctx, loginResp, conn); err != nil {
|
|
return nil, fmt.Errorf("SSO login: %w", err)
|
|
}
|
|
}
|
|
|
|
return loginResp, nil
|
|
}
|
|
|
|
func (s *serviceClient) handleSSOLogin(ctx context.Context, loginResp *proto.LoginResponse, conn proto.DaemonServiceClient) error {
|
|
if err := openURL(loginResp.VerificationURIComplete); err != nil {
|
|
return fmt.Errorf("open browser: %w", err)
|
|
}
|
|
|
|
resp, err := conn.WaitSSOLogin(ctx, &proto.WaitSSOLoginRequest{UserCode: loginResp.UserCode})
|
|
if err != nil {
|
|
return fmt.Errorf("wait for SSO login: %w", err)
|
|
}
|
|
|
|
if resp.Email != "" {
|
|
if err := s.profileManager.SetActiveProfileState(&profilemanager.ProfileState{
|
|
Email: resp.Email,
|
|
}); err != nil {
|
|
log.Debugf("failed to set profile state: %v", err)
|
|
} else {
|
|
s.mProfile.refresh()
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *serviceClient) menuUpClick(ctx context.Context) error {
|
|
systray.SetTemplateIcon(iconConnectingMacOS, s.icConnecting)
|
|
conn, err := s.getSrvClient(defaultFailTimeout)
|
|
if err != nil {
|
|
systray.SetTemplateIcon(iconErrorMacOS, s.icError)
|
|
return fmt.Errorf("get daemon client: %w", err)
|
|
}
|
|
|
|
_, err = s.login(ctx, true)
|
|
if err != nil {
|
|
return fmt.Errorf("login: %w", err)
|
|
}
|
|
|
|
status, err := conn.Status(ctx, &proto.StatusRequest{})
|
|
if err != nil {
|
|
return fmt.Errorf("get status: %w", err)
|
|
}
|
|
|
|
if status.Status == string(internal.StatusConnected) {
|
|
return nil
|
|
}
|
|
|
|
if _, err := s.conn.Up(s.ctx, &proto.UpRequest{}); err != nil {
|
|
return fmt.Errorf("start connection: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *serviceClient) menuDownClick() error {
|
|
systray.SetTemplateIcon(iconConnectingMacOS, s.icConnecting)
|
|
conn, err := s.getSrvClient(defaultFailTimeout)
|
|
if err != nil {
|
|
return fmt.Errorf("get daemon client: %w", err)
|
|
}
|
|
|
|
status, err := conn.Status(s.ctx, &proto.StatusRequest{})
|
|
if err != nil {
|
|
return fmt.Errorf("get status: %w", err)
|
|
}
|
|
|
|
if status.Status != string(internal.StatusConnected) && status.Status != string(internal.StatusConnecting) {
|
|
return nil
|
|
}
|
|
|
|
if _, err := conn.Down(s.ctx, &proto.DownRequest{}); err != nil {
|
|
return fmt.Errorf("stop connection: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *serviceClient) updateStatus() error {
|
|
conn, err := s.getSrvClient(defaultFailTimeout)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = backoff.Retry(func() error {
|
|
status, err := conn.Status(s.ctx, &proto.StatusRequest{})
|
|
if err != nil {
|
|
log.Errorf("get service status: %v", err)
|
|
if s.connected {
|
|
s.notifier.Send("Error", "Connection to service lost")
|
|
}
|
|
s.setDisconnectedStatus()
|
|
return err
|
|
}
|
|
|
|
s.updateIndicationLock.Lock()
|
|
defer s.updateIndicationLock.Unlock()
|
|
|
|
// notify the user when the session has expired
|
|
if status.Status == string(internal.StatusSessionExpired) {
|
|
s.onSessionExpire()
|
|
}
|
|
|
|
var systrayIconState bool
|
|
|
|
switch {
|
|
case status.Status == string(internal.StatusConnected) && !s.connected:
|
|
s.connected = true
|
|
s.sendNotification = true
|
|
if s.isUpdateIconActive {
|
|
systray.SetTemplateIcon(iconUpdateConnectedMacOS, s.icUpdateConnected)
|
|
} else {
|
|
systray.SetTemplateIcon(iconConnectedMacOS, s.icConnected)
|
|
}
|
|
systray.SetTooltip("NetBird (Connected)")
|
|
s.mStatus.SetTitle("Connected")
|
|
s.mStatus.SetIcon(s.icConnectedDot)
|
|
s.mUp.Disable()
|
|
s.mDown.Enable()
|
|
if s.networksEnabled {
|
|
s.mNetworks.Enable()
|
|
s.mExitNode.Enable()
|
|
}
|
|
s.startExitNodeRefresh()
|
|
systrayIconState = true
|
|
case status.Status == string(internal.StatusConnecting):
|
|
s.setConnectingStatus()
|
|
case status.Status != string(internal.StatusConnected) && s.mUp.Disabled():
|
|
s.setDisconnectedStatus()
|
|
systrayIconState = false
|
|
}
|
|
|
|
// if the daemon version changed (e.g. after a successful update), reset the update indication
|
|
if s.daemonVersion != status.DaemonVersion {
|
|
if s.daemonVersion != "" {
|
|
s.mUpdate.Hide()
|
|
s.isUpdateIconActive = false
|
|
}
|
|
s.daemonVersion = status.DaemonVersion
|
|
if !s.isUpdateIconActive {
|
|
if systrayIconState {
|
|
systray.SetTemplateIcon(iconConnectedMacOS, s.icConnected)
|
|
} else {
|
|
systray.SetTemplateIcon(iconDisconnectedMacOS, 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,
|
|
RandomizationFactor: backoff.DefaultRandomizationFactor,
|
|
Multiplier: backoff.DefaultMultiplier,
|
|
MaxInterval: 300 * time.Millisecond,
|
|
MaxElapsedTime: 2 * time.Second,
|
|
Stop: backoff.Stop,
|
|
Clock: backoff.SystemClock,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *serviceClient) setDisconnectedStatus() {
|
|
s.connected = false
|
|
if s.isUpdateIconActive {
|
|
systray.SetTemplateIcon(iconUpdateDisconnectedMacOS, s.icUpdateDisconnected)
|
|
} else {
|
|
systray.SetTemplateIcon(iconDisconnectedMacOS, s.icDisconnected)
|
|
}
|
|
systray.SetTooltip("NetBird (Disconnected)")
|
|
s.mStatus.SetTitle("Disconnected")
|
|
s.mStatus.SetIcon(s.icDisconnectedDot)
|
|
s.mDown.Disable()
|
|
s.mUp.Enable()
|
|
s.mNetworks.Disable()
|
|
s.mExitNode.Disable()
|
|
s.cancelExitNodeRetry()
|
|
go s.updateExitNodes()
|
|
}
|
|
|
|
func (s *serviceClient) setConnectingStatus() {
|
|
s.connected = false
|
|
systray.SetTemplateIcon(iconConnectingMacOS, s.icConnecting)
|
|
systray.SetTooltip("NetBird (Connecting)")
|
|
s.mStatus.SetTitle("Connecting")
|
|
s.mUp.Disable()
|
|
s.mDown.Enable()
|
|
s.mNetworks.Disable()
|
|
s.mExitNode.Disable()
|
|
}
|
|
|
|
func (s *serviceClient) onTrayReady() {
|
|
systray.SetTemplateIcon(iconDisconnectedMacOS, s.icDisconnected)
|
|
systray.SetTooltip("NetBird")
|
|
|
|
// setup systray menu items
|
|
s.mStatus = systray.AddMenuItem("Disconnected", "Disconnected")
|
|
s.mStatus.SetIcon(s.icDisconnectedDot)
|
|
s.mStatus.Disable()
|
|
|
|
profileMenuItem := systray.AddMenuItem("", "")
|
|
emailMenuItem := systray.AddMenuItem("", "")
|
|
|
|
newProfileMenuArgs := &newProfileMenuArgs{
|
|
ctx: s.ctx,
|
|
serviceClient: s,
|
|
profileManager: s.profileManager,
|
|
eventHandler: s.eventHandler,
|
|
profileMenuItem: profileMenuItem,
|
|
emailMenuItem: emailMenuItem,
|
|
downClickCallback: s.menuDownClick,
|
|
upClickCallback: s.menuUpClick,
|
|
getSrvClientCallback: s.getSrvClient,
|
|
loadSettingsCallback: s.loadSettings,
|
|
app: s.app,
|
|
}
|
|
|
|
s.mProfile = newProfileMenu(*newProfileMenuArgs)
|
|
// Seed the transition cache to match the actual default menu
|
|
// state (visible / enabled). Without this, the first
|
|
// checkAndUpdateFeatures tick that observes DisableProfiles=true
|
|
// is a no-op (cache zero-value == desired-false) and the menu
|
|
// never gets hidden — symptom: MDM enforces the kill switch but
|
|
// the profile menu stays clickable.
|
|
s.profilesEnabled = true
|
|
|
|
systray.AddSeparator()
|
|
s.mUp = systray.AddMenuItem("Connect", "Connect")
|
|
s.mDown = systray.AddMenuItem("Disconnect", "Disconnect")
|
|
s.mDown.Disable()
|
|
systray.AddSeparator()
|
|
|
|
s.mSettings = systray.AddMenuItem("Settings", disabledMenuDescr)
|
|
s.mAllowSSH = s.mSettings.AddSubMenuItemCheckbox("Allow SSH", allowSSHMenuDescr, false)
|
|
s.mAutoConnect = s.mSettings.AddSubMenuItemCheckbox("Connect on Startup", autoConnectMenuDescr, false)
|
|
s.mEnableRosenpass = s.mSettings.AddSubMenuItemCheckbox("Enable Quantum-Resistance", quantumResistanceMenuDescr, false)
|
|
s.mLazyConnEnabled = s.mSettings.AddSubMenuItemCheckbox("Enable Lazy Connections", lazyConnMenuDescr, false)
|
|
s.mBlockInbound = s.mSettings.AddSubMenuItemCheckbox("Block Inbound Connections", blockInboundMenuDescr, false)
|
|
s.mNotifications = s.mSettings.AddSubMenuItemCheckbox("Notifications", notificationsMenuDescr, false)
|
|
s.mSettings.AddSeparator()
|
|
s.mAdvancedSettings = s.mSettings.AddSubMenuItem("Advanced Settings", advancedSettingsMenuDescr)
|
|
s.mCreateDebugBundle = s.mSettings.AddSubMenuItem("Create Debug Bundle", debugBundleMenuDescr)
|
|
s.loadSettings()
|
|
|
|
// Disable profile menu if profiles are disabled by daemon.
|
|
// DisableUpdateSettings is enforced at the daemon's SetConfig /
|
|
// Login gates, not by hiding the UI — so the Settings menu (and
|
|
// its Advanced Settings submenu, which has its own kill switch)
|
|
// stays visible and the user can still inspect current values.
|
|
features, err := s.getFeatures()
|
|
if err != nil {
|
|
log.Errorf("failed to get features from daemon: %v", err)
|
|
// Continue with default behavior if features can't be retrieved
|
|
} else if features != nil && features.DisableProfiles {
|
|
s.mProfile.setEnabled(false)
|
|
s.profilesEnabled = false
|
|
}
|
|
|
|
s.exitNodeMu.Lock()
|
|
s.mExitNode = systray.AddMenuItem("Exit Node", disabledMenuDescr)
|
|
s.mExitNode.Disable()
|
|
s.exitNodeMu.Unlock()
|
|
|
|
s.mNetworks = systray.AddMenuItem("Networks", networksMenuDescr)
|
|
s.mNetworks.Disable()
|
|
systray.AddSeparator()
|
|
|
|
s.mAbout = systray.AddMenuItem("About", "About")
|
|
s.mAbout.SetIcon(s.icAbout)
|
|
|
|
s.mGitHub = s.mAbout.AddSubMenuItem("GitHub", "GitHub")
|
|
|
|
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", latestVersionMenuDescr)
|
|
s.mUpdate.Hide()
|
|
|
|
systray.AddSeparator()
|
|
s.mQuit = systray.AddMenuItem("Quit", quitMenuDescr)
|
|
|
|
// update exit node menu in case service is already connected
|
|
go s.updateExitNodes()
|
|
|
|
// Features (DisableProfiles, DisableUpdateSettings, DisableNetworks,
|
|
// ...) only change in two ways: at service install time (CLI flag,
|
|
// static) and at MDM ticker diff time. The daemon already publishes
|
|
// a SystemEvent{type=config_changed} on every MDM-driven engine
|
|
// restart, so the UI no longer needs to poll GetFeatures every 2 s.
|
|
// A single fetch at startup covers the static CLI-flag case; the
|
|
// event handler below covers MDM transitions. updateStatus stays in
|
|
// the 2 s loop because connection / peer state genuinely change
|
|
// continuously and have no event yet.
|
|
s.checkAndUpdateFeatures()
|
|
go func() {
|
|
s.getSrvConfig()
|
|
time.Sleep(100 * time.Millisecond) // To prevent race condition caused by systray not being fully initialized and ignoring setIcon
|
|
for {
|
|
err := s.updateStatus()
|
|
if err != nil {
|
|
log.Errorf("error while updating status: %v", err)
|
|
}
|
|
|
|
time.Sleep(2 * time.Second)
|
|
}
|
|
}()
|
|
|
|
s.eventManager = event.NewManager(s.notifier, s.addr)
|
|
s.eventManager.SetNotificationsEnabled(s.mNotifications.Checked())
|
|
s.eventManager.AddHandler(func(event *proto.SystemEvent) {
|
|
if event.Category == proto.SystemEvent_SYSTEM {
|
|
s.updateExitNodes()
|
|
}
|
|
})
|
|
s.eventManager.AddHandler(func(event *proto.SystemEvent) {
|
|
// todo use new Category
|
|
if windowAction, ok := event.Metadata["progress_window"]; ok {
|
|
targetVersion, ok := event.Metadata["version"]
|
|
if !ok {
|
|
targetVersion = "unknown"
|
|
}
|
|
log.Debugf("window action: %v", windowAction)
|
|
if windowAction == "show" {
|
|
if s.updateContextCancel != nil {
|
|
s.updateContextCancel()
|
|
s.updateContextCancel = nil
|
|
}
|
|
|
|
subCtx, cancel := context.WithCancel(s.ctx)
|
|
go s.eventHandler.runSelfCommand(subCtx, "update", "--update-version", targetVersion)
|
|
s.updateContextCancel = cancel
|
|
}
|
|
}
|
|
})
|
|
s.eventManager.AddHandler(func(event *proto.SystemEvent) {
|
|
if newVersion, ok := event.Metadata["new_version_available"]; ok {
|
|
_, enforced := event.Metadata["enforced"]
|
|
log.Infof("received new_version_available event: version=%s enforced=%v", newVersion, enforced)
|
|
s.onUpdateAvailable(newVersion, enforced)
|
|
}
|
|
})
|
|
s.eventManager.AddHandler(func(event *proto.SystemEvent) {
|
|
// Daemon emits a config_changed event after every engine spawn
|
|
// (Server.Start, Server.Up, MDM ticker restart). Re-sync the
|
|
// tray submenu checkboxes from the fresh daemon-side config so
|
|
// the user does not have to restart the tray to see CLI- or
|
|
// MDM-driven changes.
|
|
if event.Category == proto.SystemEvent_SYSTEM && event.Metadata["type"] == "config_changed" {
|
|
log.Infof("config_changed event received (source=%s); refreshing settings + features", event.Metadata["source"])
|
|
s.loadSettings()
|
|
// MDM-driven feature kill switches (DisableProfiles /
|
|
// DisableUpdateSettings / DisableNetworks) ride the same
|
|
// config_changed signal because the daemon re-applies its
|
|
// MDM policy on every engine spawn. Pull them in here so
|
|
// the UI is up to date without a periodic GetFeatures poll.
|
|
s.checkAndUpdateFeatures()
|
|
}
|
|
})
|
|
|
|
go s.eventManager.Start(s.ctx)
|
|
go s.eventHandler.listen(s.ctx)
|
|
}
|
|
|
|
func (s *serviceClient) attachOutput(cmd *exec.Cmd) *os.File {
|
|
if s.logFile == "" {
|
|
// attach child's streams to parent's streams
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
|
|
return nil
|
|
}
|
|
|
|
out, err := os.OpenFile(s.logFile, os.O_WRONLY|os.O_APPEND, 0)
|
|
if err != nil {
|
|
log.Errorf("Failed to open log file %s: %v", s.logFile, err)
|
|
return nil
|
|
}
|
|
cmd.Stdout = out
|
|
cmd.Stderr = out
|
|
return out
|
|
}
|
|
|
|
func normalizedVersion(version string) string {
|
|
versionString := version
|
|
if unicode.IsDigit(rune(versionString[0])) {
|
|
versionString = fmt.Sprintf("v%s", versionString)
|
|
}
|
|
return versionString
|
|
}
|
|
|
|
// onTrayExit is called when the tray icon is closed.
|
|
func (s *serviceClient) onTrayExit() {
|
|
s.cancel()
|
|
}
|
|
|
|
// getSrvClient connection to the service.
|
|
func (s *serviceClient) getSrvClient(timeout time.Duration) (proto.DaemonServiceClient, error) {
|
|
s.connLock.Lock()
|
|
defer s.connLock.Unlock()
|
|
if s.conn != nil {
|
|
return s.conn, nil
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(s.ctx, timeout)
|
|
defer cancel()
|
|
|
|
conn, err := grpc.DialContext(
|
|
ctx,
|
|
strings.TrimPrefix(s.addr, "tcp://"),
|
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
|
grpc.WithBlock(),
|
|
grpc.WithUserAgent(desktop.GetUIUserAgent()),
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("dial service: %w", err)
|
|
}
|
|
|
|
s.conn = proto.NewDaemonServiceClient(conn)
|
|
return s.conn, nil
|
|
}
|
|
|
|
// checkAndUpdateFeatures checks the current features and updates the UI accordingly
|
|
func (s *serviceClient) checkAndUpdateFeatures() {
|
|
features, err := s.getFeatures()
|
|
if err != nil {
|
|
log.Errorf("failed to get features from daemon: %v", err)
|
|
return
|
|
}
|
|
|
|
s.updateIndicationLock.Lock()
|
|
defer s.updateIndicationLock.Unlock()
|
|
|
|
// DisableUpdateSettings is enforced server-side by the daemon gates
|
|
// on SetConfig + Login: any attempt to mutate config from UI or
|
|
// CLI is rejected at that layer. The UI deliberately keeps the
|
|
// Settings menu visible so the user can still inspect current
|
|
// values — read-only by virtue of the daemon refusing edits.
|
|
|
|
// Update profile menu based on current features
|
|
if s.mProfile != nil {
|
|
profilesEnabled := features == nil || !features.DisableProfiles
|
|
if s.profilesEnabled != profilesEnabled {
|
|
s.profilesEnabled = profilesEnabled
|
|
s.mProfile.setEnabled(profilesEnabled)
|
|
}
|
|
}
|
|
|
|
// Update networks and exit node menus based on current features.
|
|
// `networksEnabled` is the bare feature flag (read elsewhere, e.g. at
|
|
// connection-status transitions). `networksMenuEnabled` is the
|
|
// transition-cached state actually applied to the menu items —
|
|
// it folds in the connection state so a Connected client with the
|
|
// kill switch off shows the menus active, and only flips on diff.
|
|
s.networksEnabled = features == nil || !features.DisableNetworks
|
|
desiredNetworksMenu := s.networksEnabled && s.connected
|
|
if desiredNetworksMenu != s.networksMenuEnabled {
|
|
s.networksMenuEnabled = desiredNetworksMenu
|
|
if desiredNetworksMenu {
|
|
s.mNetworks.Enable()
|
|
s.mExitNode.Enable()
|
|
} else {
|
|
s.mNetworks.Disable()
|
|
s.mExitNode.Disable()
|
|
}
|
|
}
|
|
}
|
|
|
|
// getFeatures from the daemon to determine which features are enabled/disabled.
|
|
func (s *serviceClient) getFeatures() (*proto.GetFeaturesResponse, error) {
|
|
conn, err := s.getSrvClient(failFastTimeout)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get client for features: %w", err)
|
|
}
|
|
|
|
features, err := conn.GetFeatures(s.ctx, &proto.GetFeaturesRequest{})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get features from daemon: %w", err)
|
|
}
|
|
|
|
return features, nil
|
|
}
|
|
|
|
// getSrvConfig from the service to show it in the settings window.
|
|
func (s *serviceClient) getSrvConfig() {
|
|
s.managementURL = profilemanager.DefaultManagementURL
|
|
|
|
_, err := s.profileManager.GetActiveProfile()
|
|
if err != nil {
|
|
log.Errorf("get active profile: %v", err)
|
|
return
|
|
}
|
|
|
|
var cfg *profilemanager.Config
|
|
|
|
conn, err := s.getSrvClient(failFastTimeout)
|
|
if err != nil {
|
|
log.Errorf("get client: %v", err)
|
|
return
|
|
}
|
|
|
|
currUser, err := user.Current()
|
|
if err != nil {
|
|
log.Errorf("get current user: %v", err)
|
|
return
|
|
}
|
|
|
|
activeProf, err := s.profileManager.GetActiveProfile()
|
|
if err != nil {
|
|
log.Errorf("get active profile: %v", err)
|
|
return
|
|
}
|
|
|
|
srvCfg, err := conn.GetConfig(s.ctx, &proto.GetConfigRequest{
|
|
ProfileName: activeProf.Name,
|
|
Username: currUser.Username,
|
|
})
|
|
if err != nil {
|
|
log.Errorf("get config settings from server: %v", err)
|
|
return
|
|
}
|
|
|
|
cfg = protoConfigToConfig(srvCfg)
|
|
|
|
if cfg.ManagementURL.String() != "" {
|
|
s.managementURL = cfg.ManagementURL.String()
|
|
}
|
|
s.preSharedKey = cfg.PreSharedKey
|
|
s.RosenpassPermissive = cfg.RosenpassPermissive
|
|
s.interfaceName = cfg.WgIface
|
|
s.interfacePort = cfg.WgPort
|
|
s.mtu = cfg.MTU
|
|
|
|
s.networkMonitor = *cfg.NetworkMonitor
|
|
s.disableDNS = cfg.DisableDNS
|
|
s.disableClientRoutes = cfg.DisableClientRoutes
|
|
s.disableServerRoutes = cfg.DisableServerRoutes
|
|
s.disableIPv6 = cfg.DisableIPv6
|
|
s.blockLANAccess = cfg.BlockLANAccess
|
|
|
|
if cfg.EnableSSHRoot != nil {
|
|
s.enableSSHRoot = *cfg.EnableSSHRoot
|
|
}
|
|
if cfg.EnableSSHSFTP != nil {
|
|
s.enableSSHSFTP = *cfg.EnableSSHSFTP
|
|
}
|
|
if cfg.EnableSSHLocalPortForwarding != nil {
|
|
s.enableSSHLocalPortForward = *cfg.EnableSSHLocalPortForwarding
|
|
}
|
|
if cfg.EnableSSHRemotePortForwarding != nil {
|
|
s.enableSSHRemotePortForward = *cfg.EnableSSHRemotePortForwarding
|
|
}
|
|
if cfg.DisableSSHAuth != nil {
|
|
s.disableSSHAuth = *cfg.DisableSSHAuth
|
|
}
|
|
if cfg.SSHJWTCacheTTL != nil {
|
|
s.sshJWTCacheTTL = *cfg.SSHJWTCacheTTL
|
|
}
|
|
|
|
if s.showAdvancedSettings {
|
|
s.iMngURL.SetText(s.managementURL)
|
|
// PSK is rendered with an empty Text and a hint via the
|
|
// placeholder so the eye toggle never reveals literal asterisks
|
|
// (the daemon returns the "**********" sentinel — writing that
|
|
// into a PasswordEntry would surface the literal sentinel when
|
|
// the user unmasks the field). The placeholder communicates the
|
|
// configured / MDM-managed state without exposing any value.
|
|
s.iPreSharedKey.SetText("")
|
|
s.iPreSharedKey.SetPlaceHolder(preSharedKeyPlaceholder(srvCfg))
|
|
s.iInterfaceName.SetText(cfg.WgIface)
|
|
s.iInterfacePort.SetText(strconv.Itoa(cfg.WgPort))
|
|
if cfg.MTU != 0 {
|
|
s.iMTU.SetText(strconv.Itoa(int(cfg.MTU)))
|
|
} else {
|
|
s.iMTU.SetText("")
|
|
s.iMTU.SetPlaceHolder(strconv.Itoa(int(iface.DefaultMTU)))
|
|
}
|
|
s.sRosenpassPermissive.SetChecked(cfg.RosenpassPermissive)
|
|
// Re-baseline the enabled state on every refresh: when Rosenpass
|
|
// is on the checkbox is editable, when it's off the field is
|
|
// inert. Without an explicit Enable() here the control stays
|
|
// stuck disabled after a previous refresh (or an MDM unlock) had
|
|
// turned it off — applyMDMLocksToSettingsForm below adds the
|
|
// MDM lock on top of this baseline.
|
|
if cfg.RosenpassEnabled {
|
|
s.sRosenpassPermissive.Enable()
|
|
} else {
|
|
s.sRosenpassPermissive.Disable()
|
|
}
|
|
s.sNetworkMonitor.SetChecked(*cfg.NetworkMonitor)
|
|
s.sDisableDNS.SetChecked(cfg.DisableDNS)
|
|
s.sDisableClientRoutes.SetChecked(cfg.DisableClientRoutes)
|
|
s.sDisableServerRoutes.SetChecked(cfg.DisableServerRoutes)
|
|
s.sDisableIPv6.SetChecked(cfg.DisableIPv6)
|
|
s.sBlockLANAccess.SetChecked(cfg.BlockLANAccess)
|
|
if cfg.EnableSSHRoot != nil {
|
|
s.sEnableSSHRoot.SetChecked(*cfg.EnableSSHRoot)
|
|
}
|
|
if cfg.EnableSSHSFTP != nil {
|
|
s.sEnableSSHSFTP.SetChecked(*cfg.EnableSSHSFTP)
|
|
}
|
|
if cfg.EnableSSHLocalPortForwarding != nil {
|
|
s.sEnableSSHLocalPortForward.SetChecked(*cfg.EnableSSHLocalPortForwarding)
|
|
}
|
|
if cfg.EnableSSHRemotePortForwarding != nil {
|
|
s.sEnableSSHRemotePortForward.SetChecked(*cfg.EnableSSHRemotePortForwarding)
|
|
}
|
|
if cfg.DisableSSHAuth != nil {
|
|
s.sDisableSSHAuth.SetChecked(*cfg.DisableSSHAuth)
|
|
}
|
|
if cfg.SSHJWTCacheTTL != nil {
|
|
s.iSSHJWTCacheTTL.SetText(strconv.Itoa(*cfg.SSHJWTCacheTTL))
|
|
}
|
|
}
|
|
|
|
// MDM locks must run before the mNotifications-nil early return:
|
|
// the Settings window is rendered by a separate UI process launched
|
|
// with --settings (see handleAdvancedSettingsClick), and that child
|
|
// process does NOT run onReady — so its mNotifications is nil and
|
|
// the early return below skipped the lock pass entirely.
|
|
s.applyMDMLocks(srvCfg.MDMManagedFields)
|
|
|
|
if s.mNotifications == nil {
|
|
return
|
|
}
|
|
if cfg.DisableNotifications != nil && *cfg.DisableNotifications {
|
|
s.mNotifications.Uncheck()
|
|
} else {
|
|
s.mNotifications.Check()
|
|
}
|
|
if s.eventManager != nil {
|
|
s.eventManager.SetNotificationsEnabled(s.mNotifications.Checked())
|
|
}
|
|
}
|
|
|
|
func protoConfigToConfig(cfg *proto.GetConfigResponse) *profilemanager.Config {
|
|
|
|
var config profilemanager.Config
|
|
|
|
if cfg.ManagementUrl != "" {
|
|
parsed, err := url.Parse(cfg.ManagementUrl)
|
|
if err != nil {
|
|
log.Errorf("parse management URL: %v", err)
|
|
} else {
|
|
config.ManagementURL = parsed
|
|
}
|
|
}
|
|
|
|
if cfg.PreSharedKey != "" {
|
|
if cfg.PreSharedKey != censoredPreSharedKey {
|
|
config.PreSharedKey = cfg.PreSharedKey
|
|
} else {
|
|
config.PreSharedKey = ""
|
|
}
|
|
}
|
|
if cfg.AdminURL != "" {
|
|
parsed, err := url.Parse(cfg.AdminURL)
|
|
if err != nil {
|
|
log.Errorf("parse admin URL: %v", err)
|
|
} else {
|
|
config.AdminURL = parsed
|
|
}
|
|
}
|
|
|
|
config.WgIface = cfg.InterfaceName
|
|
if cfg.WireguardPort >= 0 && cfg.WireguardPort <= 65535 {
|
|
config.WgPort = int(cfg.WireguardPort)
|
|
} else {
|
|
config.WgPort = iface.DefaultWgPort
|
|
}
|
|
|
|
if cfg.Mtu != 0 {
|
|
config.MTU = uint16(cfg.Mtu)
|
|
} else {
|
|
config.MTU = iface.DefaultMTU
|
|
}
|
|
|
|
config.DisableAutoConnect = cfg.DisableAutoConnect
|
|
config.ServerSSHAllowed = &cfg.ServerSSHAllowed
|
|
config.RosenpassEnabled = cfg.RosenpassEnabled
|
|
config.RosenpassPermissive = cfg.RosenpassPermissive
|
|
config.DisableNotifications = &cfg.DisableNotifications
|
|
config.LazyConnectionEnabled = cfg.LazyConnectionEnabled
|
|
config.BlockInbound = cfg.BlockInbound
|
|
config.NetworkMonitor = &cfg.NetworkMonitor
|
|
config.DisableDNS = cfg.DisableDns
|
|
config.DisableClientRoutes = cfg.DisableClientRoutes
|
|
config.DisableServerRoutes = cfg.DisableServerRoutes
|
|
config.DisableIPv6 = cfg.DisableIpv6
|
|
config.BlockLANAccess = cfg.BlockLanAccess
|
|
|
|
config.EnableSSHRoot = &cfg.EnableSSHRoot
|
|
config.EnableSSHSFTP = &cfg.EnableSSHSFTP
|
|
config.EnableSSHLocalPortForwarding = &cfg.EnableSSHLocalPortForwarding
|
|
config.EnableSSHRemotePortForwarding = &cfg.EnableSSHRemotePortForwarding
|
|
config.DisableSSHAuth = &cfg.DisableSSHAuth
|
|
|
|
ttl := int(cfg.SshJWTCacheTTL)
|
|
config.SSHJWTCacheTTL = &ttl
|
|
|
|
return &config
|
|
}
|
|
|
|
func (s *serviceClient) onUpdateAvailable(newVersion string, enforced bool) {
|
|
s.updateIndicationLock.Lock()
|
|
defer s.updateIndicationLock.Unlock()
|
|
|
|
s.isEnforcedUpdate = enforced
|
|
if enforced {
|
|
s.mUpdate.SetTitle("Install version " + newVersion)
|
|
} else {
|
|
s.lastNotifiedVersion = ""
|
|
s.mUpdate.SetTitle("Download latest version")
|
|
}
|
|
|
|
s.mUpdate.Show()
|
|
s.isUpdateIconActive = true
|
|
|
|
if s.connected {
|
|
systray.SetTemplateIcon(iconUpdateConnectedMacOS, s.icUpdateConnected)
|
|
} else {
|
|
systray.SetTemplateIcon(iconUpdateDisconnectedMacOS, s.icUpdateDisconnected)
|
|
}
|
|
|
|
if enforced && s.lastNotifiedVersion != newVersion {
|
|
s.lastNotifiedVersion = newVersion
|
|
s.notifier.Send("Update available", "A new version "+newVersion+" is ready to install")
|
|
}
|
|
}
|
|
|
|
// onSessionExpire sends a notification to the user when the session expires.
|
|
func (s *serviceClient) onSessionExpire() {
|
|
s.sendNotification = true
|
|
if s.sendNotification {
|
|
go s.eventHandler.runSelfCommand(s.ctx, "login-url", "true")
|
|
s.sendNotification = false
|
|
}
|
|
}
|
|
|
|
// loadSettings loads the settings from the config file and updates the UI elements accordingly.
|
|
func (s *serviceClient) loadSettings() {
|
|
conn, err := s.getSrvClient(failFastTimeout)
|
|
if err != nil {
|
|
log.Errorf("get client: %v", err)
|
|
return
|
|
}
|
|
|
|
currUser, err := user.Current()
|
|
if err != nil {
|
|
log.Errorf("get current user: %v", err)
|
|
return
|
|
}
|
|
|
|
activeProf, err := s.profileManager.GetActiveProfile()
|
|
if err != nil {
|
|
log.Errorf("get active profile: %v", err)
|
|
return
|
|
}
|
|
|
|
cfg, err := conn.GetConfig(s.ctx, &proto.GetConfigRequest{
|
|
ProfileName: activeProf.Name,
|
|
Username: currUser.Username,
|
|
})
|
|
if err != nil {
|
|
log.Errorf("get config settings from server: %v", err)
|
|
return
|
|
}
|
|
|
|
if cfg.ServerSSHAllowed {
|
|
s.mAllowSSH.Check()
|
|
} else {
|
|
s.mAllowSSH.Uncheck()
|
|
}
|
|
|
|
if cfg.DisableAutoConnect {
|
|
s.mAutoConnect.Uncheck()
|
|
} else {
|
|
s.mAutoConnect.Check()
|
|
}
|
|
|
|
if cfg.RosenpassEnabled {
|
|
s.mEnableRosenpass.Check()
|
|
} else {
|
|
s.mEnableRosenpass.Uncheck()
|
|
}
|
|
|
|
if cfg.LazyConnectionEnabled {
|
|
s.mLazyConnEnabled.Check()
|
|
} else {
|
|
s.mLazyConnEnabled.Uncheck()
|
|
}
|
|
|
|
if cfg.BlockInbound {
|
|
s.mBlockInbound.Check()
|
|
} else {
|
|
s.mBlockInbound.Uncheck()
|
|
}
|
|
|
|
if cfg.DisableNotifications {
|
|
s.mNotifications.Uncheck()
|
|
} else {
|
|
s.mNotifications.Check()
|
|
}
|
|
if s.eventManager != nil {
|
|
s.eventManager.SetNotificationsEnabled(s.mNotifications.Checked())
|
|
}
|
|
s.applyMDMLocks(cfg.MDMManagedFields)
|
|
}
|
|
|
|
// applyMDMLocks disables and badges any tray submenu item or settings-
|
|
// form widget whose underlying field is enforced by the active MDM
|
|
// policy. Called from loadSettings (submenu refresh) and from
|
|
// getSrvConfig (settings-window refresh). Locked items keep their value
|
|
// already set by the surrounding refresh code — this routine only
|
|
// flips the enabled state and the title suffix, never the value.
|
|
func (s *serviceClient) applyMDMLocks(managed []string) {
|
|
set := make(map[string]bool, len(managed))
|
|
for _, k := range managed {
|
|
set[k] = true
|
|
}
|
|
s.mdmManagedFields = set
|
|
if len(managed) > 0 {
|
|
log.Infof("MDM-managed UI fields: %v", managed)
|
|
}
|
|
|
|
type submenuTarget struct {
|
|
item *systray.MenuItem
|
|
title string
|
|
key string
|
|
}
|
|
for _, t := range []submenuTarget{
|
|
{s.mAllowSSH, "Allow SSH", mdm.KeyAllowServerSSH},
|
|
{s.mAutoConnect, "Connect on Startup", mdm.KeyDisableAutoConnect},
|
|
{s.mEnableRosenpass, "Enable Quantum-Resistance", mdm.KeyRosenpassEnabled},
|
|
{s.mBlockInbound, "Block Inbound Connections", mdm.KeyBlockInbound},
|
|
} {
|
|
if t.item == nil {
|
|
continue
|
|
}
|
|
if set[t.key] {
|
|
t.item.SetTitle(t.title + " (MDM)")
|
|
t.item.Disable()
|
|
} else {
|
|
t.item.SetTitle(t.title)
|
|
t.item.Enable()
|
|
}
|
|
}
|
|
|
|
s.applyMDMLocksToSettingsForm(set)
|
|
}
|
|
|
|
// preSharedKeyPlaceholder returns the hint string shown in the PSK
|
|
// Entry's placeholder slot. The placeholder is the only signal the
|
|
// user gets that a PSK is configured, because the entry's Text is
|
|
// forced to empty to keep the password reveal toggle from leaking
|
|
// the daemon-returned "**********" redaction sentinel. Returns "" if
|
|
// no PSK is present, "MDM-managed" if the key is enforced by MDM,
|
|
// and "configured" otherwise.
|
|
func preSharedKeyPlaceholder(cfg *proto.GetConfigResponse) string {
|
|
if cfg == nil || cfg.PreSharedKey == "" {
|
|
return ""
|
|
}
|
|
for _, k := range cfg.MDMManagedFields {
|
|
if k == mdm.KeyPreSharedKey {
|
|
return "MDM-managed"
|
|
}
|
|
}
|
|
return "configured"
|
|
}
|
|
|
|
// applyMDMLocksToSettingsForm disables the per-field input widgets in
|
|
// the advanced Settings window when the corresponding MDM key is set.
|
|
// For plain-text entries (Management URL, Interface Port) the visible
|
|
// value is suffixed with " (MDM)" so the user sees the lock indicator
|
|
// inline; for the password entry the suffix is skipped (a password
|
|
// widget renders every char as a dot and the indicator would not be
|
|
// readable). The widgets are created lazily by showSettingsUI, so
|
|
// guard each ref against nil.
|
|
func (s *serviceClient) applyMDMLocksToSettingsForm(set map[string]bool) {
|
|
type entryTarget struct {
|
|
entry *widget.Entry
|
|
key string
|
|
inlineTag bool
|
|
}
|
|
for _, t := range []entryTarget{
|
|
{s.iMngURL, mdm.KeyManagementURL, true},
|
|
{s.iPreSharedKey, mdm.KeyPreSharedKey, false},
|
|
{s.iInterfacePort, mdm.KeyWireguardPort, true},
|
|
} {
|
|
if t.entry == nil {
|
|
continue
|
|
}
|
|
if set[t.key] {
|
|
if t.inlineTag && t.entry.Text != "" && !strings.HasSuffix(t.entry.Text, mdmFieldSuffix) {
|
|
t.entry.SetText(t.entry.Text + mdmFieldSuffix)
|
|
}
|
|
t.entry.Disable()
|
|
} else {
|
|
if t.inlineTag {
|
|
t.entry.SetText(strings.TrimSuffix(t.entry.Text, mdmFieldSuffix))
|
|
}
|
|
t.entry.Enable()
|
|
}
|
|
}
|
|
type checkTarget struct {
|
|
check *widget.Check
|
|
key string
|
|
}
|
|
for _, t := range []checkTarget{
|
|
{s.sDisableClientRoutes, mdm.KeyDisableClientRoutes},
|
|
{s.sDisableServerRoutes, mdm.KeyDisableServerRoutes},
|
|
} {
|
|
if t.check == nil {
|
|
continue
|
|
}
|
|
if set[t.key] {
|
|
t.check.Disable()
|
|
} else {
|
|
t.check.Enable()
|
|
}
|
|
}
|
|
if s.sRosenpassPermissive != nil && set[mdm.KeyRosenpassPermissive] {
|
|
// MDM lock layered on top of the Rosenpass-on/off baseline
|
|
// applied by getSrvConfig. No Enable() branch here: when the
|
|
// MDM key is removed, the next getSrvConfig refresh re-baselines
|
|
// the control on cfg.RosenpassEnabled and brings it back if
|
|
// Rosenpass is on.
|
|
s.sRosenpassPermissive.Disable()
|
|
}
|
|
}
|
|
|
|
// updateConfig updates the configuration parameters
|
|
// based on the values selected in the settings window.
|
|
func (s *serviceClient) updateConfig() error {
|
|
disableAutoStart := !s.mAutoConnect.Checked()
|
|
sshAllowed := s.mAllowSSH.Checked()
|
|
rosenpassEnabled := s.mEnableRosenpass.Checked()
|
|
lazyConnectionEnabled := s.mLazyConnEnabled.Checked()
|
|
blockInbound := s.mBlockInbound.Checked()
|
|
notificationsDisabled := !s.mNotifications.Checked()
|
|
|
|
activeProf, err := s.profileManager.GetActiveProfile()
|
|
if err != nil {
|
|
log.Errorf("get active profile: %v", err)
|
|
return err
|
|
}
|
|
|
|
currUser, err := user.Current()
|
|
if err != nil {
|
|
log.Errorf("get current user: %v", err)
|
|
return err
|
|
}
|
|
|
|
conn, err := s.getSrvClient(failFastTimeout)
|
|
if err != nil {
|
|
log.Errorf("get client: %v", err)
|
|
return err
|
|
}
|
|
|
|
req := proto.SetConfigRequest{
|
|
ProfileName: activeProf.Name,
|
|
Username: currUser.Username,
|
|
DisableAutoConnect: &disableAutoStart,
|
|
ServerSSHAllowed: &sshAllowed,
|
|
RosenpassEnabled: &rosenpassEnabled,
|
|
LazyConnectionEnabled: &lazyConnectionEnabled,
|
|
BlockInbound: &blockInbound,
|
|
DisableNotifications: ¬ificationsDisabled,
|
|
}
|
|
|
|
if _, err := conn.SetConfig(s.ctx, &req); err != nil {
|
|
log.Errorf("set config settings on server: %v", err)
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// showLoginURL creates a borderless window styled like a pop-up in the top-right corner using s.wLoginURL.
|
|
// It also starts a background goroutine that periodically checks if the client is already connected
|
|
// and closes the window if so. The goroutine can be cancelled by the returned CancelFunc, and it is
|
|
// also cancelled when the window is closed.
|
|
func (s *serviceClient) showLoginURL() context.CancelFunc {
|
|
|
|
// create a cancellable context for the background check goroutine
|
|
ctx, cancel := context.WithCancel(s.ctx)
|
|
|
|
resIcon := fyne.NewStaticResource("netbird.png", iconAbout)
|
|
|
|
if s.wLoginURL == nil {
|
|
s.wLoginURL = s.app.NewWindow("NetBird Session Expired")
|
|
s.wLoginURL.Resize(fyne.NewSize(400, 200))
|
|
s.wLoginURL.SetIcon(resIcon)
|
|
}
|
|
// ensure goroutine is cancelled when the window is closed
|
|
s.wLoginURL.SetOnClosed(func() { cancel() })
|
|
// add a description label
|
|
label := widget.NewLabel("Your NetBird session has expired.\nPlease re-authenticate to continue using NetBird.")
|
|
|
|
btn := widget.NewButtonWithIcon("Re-authenticate", theme.ViewRefreshIcon(), func() {
|
|
|
|
conn, err := s.getSrvClient(defaultFailTimeout)
|
|
if err != nil {
|
|
log.Errorf("get client: %v", err)
|
|
return
|
|
}
|
|
|
|
resp, err := s.login(ctx, false)
|
|
if err != nil {
|
|
log.Errorf("failed to fetch login URL: %v", err)
|
|
return
|
|
}
|
|
verificationURL := resp.VerificationURIComplete
|
|
if verificationURL == "" {
|
|
verificationURL = resp.VerificationURI
|
|
}
|
|
|
|
if verificationURL == "" {
|
|
log.Error("no verification URL provided in the login response")
|
|
return
|
|
}
|
|
|
|
if err := openURL(verificationURL); err != nil {
|
|
log.Errorf("failed to open login URL: %v", err)
|
|
return
|
|
}
|
|
|
|
_, err = conn.WaitSSOLogin(ctx, &proto.WaitSSOLoginRequest{UserCode: resp.UserCode})
|
|
if err != nil {
|
|
log.Errorf("Waiting sso login failed with: %v", err)
|
|
label.SetText("Waiting login failed, please create \na debug bundle in the settings and contact support.")
|
|
return
|
|
}
|
|
|
|
label.SetText("Re-authentication successful.\nReconnecting")
|
|
status, err := conn.Status(ctx, &proto.StatusRequest{})
|
|
if err != nil {
|
|
log.Errorf("get service status: %v", err)
|
|
return
|
|
}
|
|
|
|
if status.Status == string(internal.StatusConnected) {
|
|
label.SetText("Already connected.\nClosing this window.")
|
|
time.Sleep(2 * time.Second)
|
|
s.wLoginURL.Close()
|
|
return
|
|
}
|
|
|
|
_, err = conn.Up(ctx, &proto.UpRequest{})
|
|
if err != nil {
|
|
label.SetText("Reconnecting failed, please create \na debug bundle in the settings and contact support.")
|
|
log.Errorf("Reconnecting failed with: %v", err)
|
|
return
|
|
}
|
|
|
|
label.SetText("Connection successful.\nClosing this window.")
|
|
time.Sleep(time.Second)
|
|
|
|
s.wLoginURL.Close()
|
|
})
|
|
|
|
img := canvas.NewImageFromResource(resIcon)
|
|
img.FillMode = canvas.ImageFillContain
|
|
img.SetMinSize(fyne.NewSize(64, 64))
|
|
img.Resize(fyne.NewSize(64, 64))
|
|
|
|
// center the content vertically
|
|
content := container.NewVBox(
|
|
layout.NewSpacer(),
|
|
img,
|
|
label,
|
|
btn,
|
|
layout.NewSpacer(),
|
|
)
|
|
s.wLoginURL.SetContent(container.NewCenter(content))
|
|
|
|
// start a goroutine to check connection status and close the window if connected
|
|
go func() {
|
|
ticker := time.NewTicker(5 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
conn, err := s.getSrvClient(failFastTimeout)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
status, err := conn.Status(s.ctx, &proto.StatusRequest{})
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if status.Status == string(internal.StatusConnected) {
|
|
if s.wLoginURL != nil {
|
|
s.wLoginURL.Close()
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
s.wLoginURL.Show()
|
|
|
|
// return cancel func so callers can stop the background goroutine if desired
|
|
return cancel
|
|
}
|
|
|
|
func openURL(url string) error {
|
|
if browser := os.Getenv("BROWSER"); browser != "" {
|
|
return exec.Command(browser, url).Start()
|
|
}
|
|
|
|
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", "freebsd":
|
|
err = exec.Command("xdg-open", url).Start()
|
|
default:
|
|
err = fmt.Errorf("unsupported platform")
|
|
}
|
|
return err
|
|
}
|