mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-14 10:49:07 +02:00
Merge branch 'main' into file-share
# Conflicts: # client/android/client.go # client/android/login.go # client/android/profile_prefs.go # client/internal/connect.go # client/internal/engine.go # client/mobile/profile_state.go # client/ui/i18n/locales/de/common.json # client/ui/i18n/locales/en/common.json # client/ui/i18n/locales/es/common.json # client/ui/i18n/locales/fr/common.json # client/ui/i18n/locales/hu/common.json # client/ui/i18n/locales/it/common.json # client/ui/i18n/locales/ja/common.json # client/ui/i18n/locales/pt/common.json # client/ui/i18n/locales/ru/common.json # client/ui/i18n/locales/zh-CN/common.json # client/ui/main.go
This commit is contained in:
@@ -123,8 +123,16 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err
|
||||
if p.PreSharedKey != "" {
|
||||
req.OptionalPreSharedKey = ptrStr(p.PreSharedKey)
|
||||
}
|
||||
if p.Hint != "" {
|
||||
req.Hint = ptrStr(p.Hint)
|
||||
hint := p.Hint
|
||||
if hint == "" && profileID != "" {
|
||||
if state, serr := profilemanager.NewProfileManager().GetProfileState(profilemanager.ID(profileID)); serr == nil {
|
||||
hint = state.Email
|
||||
} else {
|
||||
log.Debugf("failed to get profile state for login hint: %v", serr)
|
||||
}
|
||||
}
|
||||
if hint != "" {
|
||||
req.Hint = ptrStr(hint)
|
||||
}
|
||||
|
||||
resp, err := cli.Login(ctx, req)
|
||||
@@ -228,16 +236,6 @@ func (s *Connection) Logout(ctx context.Context, p LogoutParams) error {
|
||||
return s.classifyDaemonError(err)
|
||||
}
|
||||
|
||||
// The daemon runs as root and can't reach the user-owned per-profile state
|
||||
// file holding the account email (see Profiles.List), so clear the stale
|
||||
// email here; the next SSO login recreates it.
|
||||
if p.ProfileName != "" {
|
||||
if err := profilemanager.NewProfileManager().RemoveProfileState(p.ProfileName); err != nil {
|
||||
// Non-fatal: the logout itself succeeded.
|
||||
log.Warnf("failed to remove profile state for %s: %v", p.ProfileName, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -261,7 +259,7 @@ func (s *Connection) waitSSOLogin(ctx context.Context, p WaitSSOParams) (string,
|
||||
|
||||
// Persist the account email the same way the CLI does after its own
|
||||
// WaitSSOLogin: the daemon returns it but cannot store it, since it runs as
|
||||
// root and the per-profile state file is user-owned (see Logout below).
|
||||
// root and the per-profile state file is user-owned (see Profiles.List).
|
||||
// Without this the profile has no email, so Profiles.List shows no account
|
||||
// and later logins and session extends go out without a login_hint —
|
||||
// leaving the IdP to guess which account was meant.
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/elevate"
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
)
|
||||
|
||||
// The command line of the one-shot mode this binary runs itself in, elevated, to
|
||||
// apply a setting the daemon restricts to root/administrator. The setting flags
|
||||
// spell the same words as `netbird up`, so the command a user is shown and what
|
||||
// runs behind the prompt read alike. Parsed in oneshot.go.
|
||||
const (
|
||||
FlagApplyPrivilegedSettings = "apply-privileged-settings"
|
||||
FlagDaemonAddr = "daemon-addr"
|
||||
FlagProfile = "profile"
|
||||
FlagUser = "user"
|
||||
FlagLogLevel = "log-level"
|
||||
FlagManagementURL = "management-url"
|
||||
FlagAllowServerSSH = "allow-server-ssh"
|
||||
FlagEnableSSHRoot = "enable-ssh-root"
|
||||
FlagDisableSSHAuth = "disable-ssh-auth"
|
||||
)
|
||||
|
||||
// Error codes for the ways asking for privileges can fail.
|
||||
const (
|
||||
CodeElevationUnavailable = "elevation_unavailable"
|
||||
CodeElevationFailed = "elevation_failed"
|
||||
)
|
||||
|
||||
// elevationTimeout bounds the wait for a prompt and the change behind it, so a
|
||||
// dialog nobody answers does not leave its control disabled for the session. Long
|
||||
// enough to find a password manager, and no shorter than the platforms' own prompt
|
||||
// timeouts: Windows gives up on its consent dialog after two minutes by itself.
|
||||
//
|
||||
// It always ends our waiting, and not always the prompt: Security.framework offers
|
||||
// no way to withdraw a request, so on macOS the system's own timeout is what closes
|
||||
// the dialog.
|
||||
const elevationTimeout = 5 * time.Minute
|
||||
|
||||
// elevator raises the platform's privilege prompt and runs the change behind it.
|
||||
// An interface so tests can answer without a prompt.
|
||||
type elevator interface {
|
||||
// Run runs this binary again, elevated, with the given arguments.
|
||||
Run(ctx context.Context, args ...string) error
|
||||
// Available reports whether there is a prompt to raise on this host at all.
|
||||
Available() bool
|
||||
}
|
||||
|
||||
// osElevator is the real thing: see the elevate package.
|
||||
type osElevator struct{}
|
||||
|
||||
func (osElevator) Run(ctx context.Context, args ...string) error {
|
||||
return elevate.Run(ctx, args...)
|
||||
}
|
||||
|
||||
func (osElevator) Available() bool {
|
||||
return elevate.Available()
|
||||
}
|
||||
|
||||
// SaveOutcome reports what became of a change that needed authorization.
|
||||
//
|
||||
// A declined prompt is a result, not an error: the user was asked and said no, so
|
||||
// nothing was applied and nothing went wrong. Reporting it as an error would have
|
||||
// every cancelled prompt logged as one.
|
||||
type SaveOutcome struct {
|
||||
// Declined is set when the user dismissed the authorization prompt, or was
|
||||
// refused by policy. Nothing was changed.
|
||||
Declined bool `json:"declined"`
|
||||
}
|
||||
|
||||
// GuardedSettings is the subset of the config the daemon restricts to
|
||||
// root/administrator. Only the fields that are set are changed: a nil pointer, or
|
||||
// an empty management URL, leaves that setting alone.
|
||||
//
|
||||
// The management URL is in here because pointing a host with the SSH server
|
||||
// running at another management identity hands the decision of who may open a
|
||||
// shell on it to whoever runs that server, which is the same power as enabling
|
||||
// the SSH server in the first place.
|
||||
type GuardedSettings struct {
|
||||
ProfileName string `json:"profileName"`
|
||||
Username string `json:"username"`
|
||||
ManagementURL string `json:"managementUrl,omitempty"`
|
||||
ServerSSHAllowed *bool `json:"serverSshAllowed,omitempty"`
|
||||
EnableSSHRoot *bool `json:"enableSshRoot,omitempty"`
|
||||
DisableSSHAuth *bool `json:"disableSshAuth,omitempty"`
|
||||
}
|
||||
|
||||
// guardedSetting is one setting to change, in the two spellings this needs: the
|
||||
// one-shot's own flag, and the `netbird up` flag that does the same thing from a
|
||||
// terminal, for when there is no prompt to raise.
|
||||
type guardedSetting struct {
|
||||
arg string
|
||||
flag string
|
||||
}
|
||||
|
||||
// SetGuardedSettings applies settings the daemon refuses from an unprivileged
|
||||
// caller, by having the operating system run this binary again, elevated, to send
|
||||
// the same request the frontend would have sent itself.
|
||||
//
|
||||
// The user authorizes it at the platform's own prompt: the UAC consent dialog,
|
||||
// the macOS authentication dialog, or the polkit agent's. Any credentials are the
|
||||
// operating system's business; NetBird neither sees nor asks for them. Nothing
|
||||
// about the daemon's rules changes, and the elevated process is authorized like
|
||||
// any other privileged caller, from the identity the kernel reports for it.
|
||||
//
|
||||
// A declined prompt comes back as SaveOutcome.Declined with no error. When there is
|
||||
// no prompt to raise, or the elevated run failed, the error carries the command
|
||||
// that does the same thing from a terminal.
|
||||
func (s *Settings) SetGuardedSettings(ctx context.Context, p GuardedSettings) (SaveOutcome, error) {
|
||||
settings := guardedSettings(p)
|
||||
if len(settings) == 0 {
|
||||
return SaveOutcome{}, &ClientError{
|
||||
Code: CodeElevationFailed,
|
||||
Short: "no setting to apply",
|
||||
Long: "no setting to apply",
|
||||
}
|
||||
}
|
||||
|
||||
// The elevated run has no window and, on Linux, an environment pkexec has
|
||||
// cleared, so what it writes to stderr is all there is to go on. It follows
|
||||
// this process's level so that starting the app with --log-level debug says
|
||||
// something about the run behind the prompt too.
|
||||
args := append([]string{
|
||||
"--" + FlagApplyPrivilegedSettings,
|
||||
"--" + FlagDaemonAddr, s.daemonAddr,
|
||||
"--" + FlagProfile, p.ProfileName,
|
||||
"--" + FlagUser, p.Username,
|
||||
"--" + FlagLogLevel, log.GetLevel().String(),
|
||||
}, oneShotArgs(settings)...)
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, elevationTimeout)
|
||||
defer cancel()
|
||||
|
||||
// These changes hand out shells on this host, so both ends are logged: when the
|
||||
// prompt went up, and what came of it. It is also the only account of a prompt
|
||||
// that was slow to appear or never answered.
|
||||
log.Infof("asking for privileges to apply %s", guardedSummary(p))
|
||||
|
||||
if err := s.elevator.Run(ctx, args...); err != nil {
|
||||
return s.elevationOutcome(err, p)
|
||||
}
|
||||
|
||||
log.Infof("applied %s with the privileges the user authorized", guardedSummary(p))
|
||||
return SaveOutcome{}, nil
|
||||
}
|
||||
|
||||
// elevationOutcome sorts what came back into the one normal ending and the two
|
||||
// that need reporting, with the command that does the same thing by hand.
|
||||
func (s *Settings) elevationOutcome(err error, p GuardedSettings) (SaveOutcome, error) {
|
||||
switch {
|
||||
case errors.Is(err, elevate.ErrDeclined):
|
||||
// With the reason: an account that may not elevate at all lands here too,
|
||||
// and the log is the only place that says which it was.
|
||||
log.Infof("the elevation prompt for %s was declined: %v", guardedSummary(p), err)
|
||||
return SaveOutcome{Declined: true}, nil
|
||||
case errors.Is(err, elevate.ErrUnavailable):
|
||||
log.Warnf("cannot ask for privileges to apply %s: %v", guardedSummary(p), err)
|
||||
return SaveOutcome{}, &ClientError{
|
||||
Code: CodeElevationUnavailable,
|
||||
Short: s.classifier.translateShort(CodeElevationUnavailable),
|
||||
Long: err.Error(),
|
||||
Command: guardedCommand(p),
|
||||
}
|
||||
default:
|
||||
log.Errorf("applying %s with elevated privileges failed: %v", guardedSummary(p), err)
|
||||
return SaveOutcome{}, &ClientError{
|
||||
Code: CodeElevationFailed,
|
||||
Short: s.classifier.translateShort(CodeElevationFailed),
|
||||
Long: err.Error(),
|
||||
Command: guardedCommand(p),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// guardedSettings renders the settings that are actually being changed, from the
|
||||
// same table the one-shot parses them with: see oneshot.go.
|
||||
func guardedSettings(p GuardedSettings) []guardedSetting {
|
||||
var settings []guardedSetting
|
||||
for _, field := range guardedFields {
|
||||
value, ok := field.read(p)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
settings = append(settings, guardedSetting{
|
||||
arg: "--" + field.flag + "=" + value,
|
||||
flag: field.up(value),
|
||||
})
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
func oneShotArgs(settings []guardedSetting) []string {
|
||||
args := make([]string, 0, len(settings))
|
||||
for _, setting := range settings {
|
||||
args = append(args, setting.arg)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func upFlags(settings []guardedSetting) []string {
|
||||
flags := make([]string, 0, len(settings))
|
||||
for _, setting := range settings {
|
||||
flags = append(flags, setting.flag)
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
// guardedCommand is the elevated command line equivalent to the requested
|
||||
// change, the same shape the daemon names in its own refusals.
|
||||
func guardedCommand(p GuardedSettings) string {
|
||||
settings := guardedSettings(p)
|
||||
if len(settings) == 0 {
|
||||
return ""
|
||||
}
|
||||
return ipcauth.UpCommand(strings.Join(upFlags(settings), " "))
|
||||
}
|
||||
|
||||
// guardedSummary names the change for the log.
|
||||
func guardedSummary(p GuardedSettings) string {
|
||||
return fmt.Sprintf("%v for profile %q", oneShotArgs(guardedSettings(p)), p.ProfileName)
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/elevate"
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// A Unix socket, so the daemon address is one that carries a caller's identity and
|
||||
// elevation is worth offering at all: see Settings.canElevate.
|
||||
const testDaemonAddr = "unix:///var/run/netbird.sock"
|
||||
|
||||
// storedManagementURL is what the stub daemon already holds, so that a request
|
||||
// naming a different one is a change: see Settings.guardedChanges.
|
||||
const storedManagementURL = "https://stored.example.com"
|
||||
|
||||
// stubElevator stands in for the platform's prompt: it records what would have run
|
||||
// and answers with a fixed outcome.
|
||||
type stubElevator struct {
|
||||
outcome error
|
||||
available bool
|
||||
calls [][]string
|
||||
}
|
||||
|
||||
func (e *stubElevator) Run(_ context.Context, args ...string) error {
|
||||
e.calls = append(e.calls, args)
|
||||
return e.outcome
|
||||
}
|
||||
|
||||
func (e *stubElevator) Available() bool { return e.available }
|
||||
|
||||
// stubDaemon implements only the RPCs under test. The embedded interface is nil, so
|
||||
// any other call panics rather than passing quietly.
|
||||
type stubDaemon struct {
|
||||
proto.DaemonServiceClient
|
||||
setConfig func(*proto.SetConfigRequest) error
|
||||
// stored is what GetConfig reports, which is what a refused request's guarded
|
||||
// settings are compared against.
|
||||
stored *proto.GetConfigResponse
|
||||
requests []*proto.SetConfigRequest
|
||||
}
|
||||
|
||||
func (d *stubDaemon) SetConfig(_ context.Context, in *proto.SetConfigRequest, _ ...grpc.CallOption) (*proto.SetConfigResponse, error) {
|
||||
d.requests = append(d.requests, in)
|
||||
if err := d.setConfig(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &proto.SetConfigResponse{}, nil
|
||||
}
|
||||
|
||||
func (d *stubDaemon) GetConfig(_ context.Context, _ *proto.GetConfigRequest, _ ...grpc.CallOption) (*proto.GetConfigResponse, error) {
|
||||
return d.stored, nil
|
||||
}
|
||||
|
||||
type stubConn struct{ client proto.DaemonServiceClient }
|
||||
|
||||
func (c stubConn) Client() (proto.DaemonServiceClient, error) { return c.client, nil }
|
||||
|
||||
// privilegeRefusal is the error the daemon raises for a change it restricts to
|
||||
// root, detail and all: see server.privilegeError.
|
||||
func privilegeRefusal(t *testing.T) error {
|
||||
t.Helper()
|
||||
|
||||
st, err := gstatus.New(codes.PermissionDenied, "Changing the management URL requires root.").
|
||||
WithDetails(&errdetails.ErrorInfo{
|
||||
Reason: ipcauth.ErrorReasonPrivilegeRequired,
|
||||
Domain: ipcauth.ErrorDomain,
|
||||
Metadata: map[string]string{
|
||||
ipcauth.ErrorMetaSummary: "Changing the management URL requires root.",
|
||||
ipcauth.ErrorMetaCommand: "sudo netbird down; sudo netbird up -m https://mgmt.example.com",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "build the refusal detail")
|
||||
return st.Err()
|
||||
}
|
||||
|
||||
func settingsWithElevation(t *testing.T, outcome error) (*Settings, *stubElevator) {
|
||||
t.Helper()
|
||||
|
||||
elev := &stubElevator{outcome: outcome, available: true}
|
||||
return &Settings{daemonAddr: testDaemonAddr, elevator: elev}, elev
|
||||
}
|
||||
|
||||
// settingsRefusingOnce returns a Settings whose daemon refuses the first SetConfig
|
||||
// for want of privileges and accepts anything after it. Its stored config holds
|
||||
// another management server and no SSH grants, so a request naming either is a
|
||||
// change rather than a restatement.
|
||||
func settingsRefusingOnce(t *testing.T, elev *stubElevator) (*Settings, *stubDaemon) {
|
||||
t.Helper()
|
||||
|
||||
refusal := privilegeRefusal(t)
|
||||
daemon := &stubDaemon{stored: &proto.GetConfigResponse{ManagementUrl: storedManagementURL}}
|
||||
daemon.setConfig = func(*proto.SetConfigRequest) error {
|
||||
if len(daemon.requests) == 1 {
|
||||
return refusal
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return &Settings{conn: stubConn{client: daemon}, daemonAddr: testDaemonAddr, elevator: elev}, daemon
|
||||
}
|
||||
|
||||
func TestSetGuardedSettingsPassesOnlyTheChangedSettings(t *testing.T) {
|
||||
s, elev := settingsWithElevation(t, nil)
|
||||
|
||||
root := true
|
||||
outcome, err := s.SetGuardedSettings(context.Background(), GuardedSettings{
|
||||
ProfileName: "work",
|
||||
Username: "vma",
|
||||
EnableSSHRoot: &root,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, outcome.Declined, "the prompt was answered")
|
||||
|
||||
want := []string{
|
||||
"--" + FlagApplyPrivilegedSettings,
|
||||
"--" + FlagDaemonAddr, testDaemonAddr,
|
||||
"--" + FlagProfile, "work",
|
||||
"--" + FlagUser, "vma",
|
||||
"--" + FlagLogLevel, log.GetLevel().String(),
|
||||
"--" + FlagEnableSSHRoot + "=true",
|
||||
}
|
||||
require.Len(t, elev.calls, 1, "one prompt for one change")
|
||||
assert.Equal(t, want, elev.calls[0], "elevated arguments")
|
||||
|
||||
// argv[1] is what the polkit action is pinned to, so the marker has to stay
|
||||
// first however the rest of the line grows.
|
||||
assert.Equal(t, "--"+FlagApplyPrivilegedSettings, elev.calls[0][0], "the flag polkit matches on")
|
||||
}
|
||||
|
||||
// Turning a setting off has to be as explicit as turning it on: a bare flag would
|
||||
// read as "on" to the one-shot's parser.
|
||||
func TestSetGuardedSettingsSpellsOutFalse(t *testing.T) {
|
||||
s, elev := settingsWithElevation(t, nil)
|
||||
|
||||
off := false
|
||||
_, err := s.SetGuardedSettings(context.Background(), GuardedSettings{
|
||||
ProfileName: "default",
|
||||
ServerSSHAllowed: &off,
|
||||
DisableSSHAuth: &off,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
args := elev.calls[0]
|
||||
assert.Contains(t, args, "--"+FlagAllowServerSSH+"=false", "the setting being switched off")
|
||||
assert.Contains(t, args, "--"+FlagDisableSSHAuth+"=false", "the setting being switched off")
|
||||
assert.NotContains(t, args, "--"+FlagEnableSSHRoot+"=false", "no flag for a setting nobody touched")
|
||||
}
|
||||
|
||||
func TestSetGuardedSettingsPassesTheManagementURL(t *testing.T) {
|
||||
s, elev := settingsWithElevation(t, nil)
|
||||
|
||||
_, err := s.SetGuardedSettings(context.Background(), GuardedSettings{
|
||||
ProfileName: "default",
|
||||
ManagementURL: "https://mgmt.example.com:33073",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, elev.calls[0], "--"+FlagManagementURL+"=https://mgmt.example.com:33073",
|
||||
"the management URL to point the profile at")
|
||||
}
|
||||
|
||||
func TestSetGuardedSettingsWithoutASettingDoesNotElevate(t *testing.T) {
|
||||
s, elev := settingsWithElevation(t, nil)
|
||||
|
||||
_, err := s.SetGuardedSettings(context.Background(), GuardedSettings{ProfileName: "default"})
|
||||
|
||||
require.Error(t, err, "nothing to apply is not something to prompt for")
|
||||
assert.Empty(t, elev.calls, "no prompt at all")
|
||||
}
|
||||
|
||||
// A declined prompt is the one ending that is not an error: reporting it as one
|
||||
// would have every cancelled prompt logged as a failure.
|
||||
func TestSetGuardedSettingsReportsADeclinedPromptAsAnOutcome(t *testing.T) {
|
||||
s, _ := settingsWithElevation(t, elevate.ErrDeclined)
|
||||
|
||||
root := true
|
||||
outcome, err := s.SetGuardedSettings(context.Background(), GuardedSettings{
|
||||
ProfileName: "default",
|
||||
EnableSSHRoot: &root,
|
||||
})
|
||||
|
||||
require.NoError(t, err, "the user was asked and answered; nothing went wrong")
|
||||
assert.True(t, outcome.Declined, "nothing was applied")
|
||||
}
|
||||
|
||||
func TestSetGuardedSettingsMapsFailures(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
outcome error
|
||||
wantCode string
|
||||
}{
|
||||
{
|
||||
// Nothing to raise a prompt with: the user needs the command.
|
||||
name: "no mechanism falls back to the command",
|
||||
outcome: elevate.ErrUnavailable,
|
||||
wantCode: CodeElevationUnavailable,
|
||||
},
|
||||
{
|
||||
name: "a failed run falls back to the command",
|
||||
outcome: errors.New("elevated netbird exited with 1"),
|
||||
wantCode: CodeElevationFailed,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s, _ := settingsWithElevation(t, tt.outcome)
|
||||
|
||||
root := true
|
||||
_, err := s.SetGuardedSettings(context.Background(), GuardedSettings{
|
||||
ProfileName: "default",
|
||||
EnableSSHRoot: &root,
|
||||
})
|
||||
|
||||
var clientErr *ClientError
|
||||
require.ErrorAs(t, err, &clientErr, "the frontend needs a code to act on")
|
||||
assert.Equal(t, tt.wantCode, clientErr.Code, "error code")
|
||||
assert.Contains(t, clientErr.Command, "--"+FlagEnableSSHRoot+"=true",
|
||||
"the setting in the fallback command")
|
||||
assert.Contains(t, clientErr.Command, "netbird up", "the fallback command")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Changing the management URL is only privileged while the host runs the SSH
|
||||
// server, which no control can know up front, so the refusal is what triggers the
|
||||
// prompt. The original request goes again afterwards, so the fields the one-shot
|
||||
// does not understand are applied too.
|
||||
func TestSetConfigElevatesAfterARefusalAndRetries(t *testing.T) {
|
||||
elev := &stubElevator{available: true}
|
||||
s, daemon := settingsRefusingOnce(t, elev)
|
||||
|
||||
mtu := int64(1280)
|
||||
outcome, err := s.SetConfig(context.Background(), SetConfigParams{
|
||||
ProfileName: "default",
|
||||
ManagementURL: "https://mgmt.example.com",
|
||||
MTU: &mtu,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, outcome.Declined, "the prompt was answered")
|
||||
|
||||
require.Len(t, elev.calls, 1, "one prompt")
|
||||
assert.Contains(t, elev.calls[0], "--"+FlagManagementURL+"=https://mgmt.example.com",
|
||||
"the guarded part of the request")
|
||||
require.Len(t, daemon.requests, 2, "the refused request and the retry")
|
||||
assert.Equal(t, mtu, daemon.requests[1].GetMtu(),
|
||||
"the retry carries the rest of the request, which the one-shot does not understand")
|
||||
}
|
||||
|
||||
func TestSetConfigDoesNotRetryWhenTheUserDeclines(t *testing.T) {
|
||||
elev := &stubElevator{outcome: elevate.ErrDeclined, available: true}
|
||||
s, daemon := settingsRefusingOnce(t, elev)
|
||||
|
||||
outcome, err := s.SetConfig(context.Background(), SetConfigParams{
|
||||
ProfileName: "default",
|
||||
ManagementURL: "https://mgmt.example.com",
|
||||
})
|
||||
|
||||
require.NoError(t, err, "a declined prompt is not an error")
|
||||
assert.True(t, outcome.Declined, "nothing was applied")
|
||||
assert.Len(t, daemon.requests, 1, "only the refused request")
|
||||
}
|
||||
|
||||
// With no prompt to raise, the refusal is reported as the daemon wrote it, which is
|
||||
// the guidance that was there before elevation existed.
|
||||
func TestSetConfigReportsTheRefusalWhenItCannotElevate(t *testing.T) {
|
||||
elev := &stubElevator{available: false}
|
||||
s, _ := settingsRefusingOnce(t, elev)
|
||||
|
||||
_, err := s.SetConfig(context.Background(), SetConfigParams{
|
||||
ProfileName: "default",
|
||||
ManagementURL: "https://mgmt.example.com",
|
||||
})
|
||||
|
||||
var clientErr *ClientError
|
||||
require.ErrorAs(t, err, &clientErr)
|
||||
assert.Equal(t, "privilege_required", clientErr.Code, "error code")
|
||||
assert.Contains(t, clientErr.Command, "netbird up -m https://mgmt.example.com",
|
||||
"the daemon's own command")
|
||||
assert.Empty(t, elev.calls, "no prompt where there is none to raise")
|
||||
}
|
||||
|
||||
// One authorization must buy only the change the user made. A settings form
|
||||
// submits every field it holds, so most of a refused request restates what the
|
||||
// daemon already has, and elevating those too would apply a guarded setting the
|
||||
// user never touched — a value gone stale since the form loaded above all.
|
||||
func TestSetConfigElevatesOnlyTheGuardedSettingsThatChange(t *testing.T) {
|
||||
elev := &stubElevator{available: true}
|
||||
s, _ := settingsRefusingOnce(t, elev)
|
||||
|
||||
on, off := true, false
|
||||
_, err := s.SetConfig(context.Background(), SetConfigParams{
|
||||
ProfileName: "default",
|
||||
ManagementURL: storedManagementURL,
|
||||
ServerSSHAllowed: &off,
|
||||
EnableSSHRoot: &off,
|
||||
DisableSSHAuth: &on,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, elev.calls, 1, "one prompt")
|
||||
args := elev.calls[0]
|
||||
assert.Contains(t, args, "--"+FlagDisableSSHAuth+"=true", "the setting that changes")
|
||||
assert.NotContains(t, args, "--"+FlagManagementURL+"="+storedManagementURL,
|
||||
"a management URL the daemon already holds")
|
||||
assert.NotContains(t, args, "--"+FlagAllowServerSSH+"=false", "a setting already off")
|
||||
assert.NotContains(t, args, "--"+FlagEnableSSHRoot+"=false", "a setting already off")
|
||||
}
|
||||
|
||||
// A request that changes no guarded setting has nothing an elevated run could
|
||||
// apply, so the refusal must have come from somewhere a prompt cannot reach.
|
||||
func TestSetConfigDoesNotElevateWhenNoGuardedSettingChanges(t *testing.T) {
|
||||
elev := &stubElevator{available: true}
|
||||
s, _ := settingsRefusingOnce(t, elev)
|
||||
|
||||
off := false
|
||||
_, err := s.SetConfig(context.Background(), SetConfigParams{
|
||||
ProfileName: "default",
|
||||
ManagementURL: storedManagementURL,
|
||||
ServerSSHAllowed: &off,
|
||||
})
|
||||
|
||||
var clientErr *ClientError
|
||||
require.ErrorAs(t, err, &clientErr)
|
||||
assert.Equal(t, "privilege_required", clientErr.Code, "error code")
|
||||
assert.Empty(t, elev.calls, "no prompt for a change nobody made")
|
||||
}
|
||||
|
||||
// A refusal with nothing in the request the one-shot could apply: the daemon
|
||||
// cannot see who is calling, and being root would not help either.
|
||||
func TestSetConfigReportsARefusalWithNothingToElevate(t *testing.T) {
|
||||
elev := &stubElevator{available: true}
|
||||
s, _ := settingsRefusingOnce(t, elev)
|
||||
|
||||
_, err := s.SetConfig(context.Background(), SetConfigParams{ProfileName: "default"})
|
||||
|
||||
var clientErr *ClientError
|
||||
require.ErrorAs(t, err, &clientErr)
|
||||
assert.Equal(t, "privilege_required", clientErr.Code, "error code")
|
||||
assert.Empty(t, elev.calls, "no prompt")
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/elevate"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
// The other end of SetGuardedSettings: the mode this binary runs itself in,
|
||||
// elevated, to apply the settings the daemon restricts to root/administrator.
|
||||
//
|
||||
// Both ends are here on purpose. What may be changed this way is an allowlist, and
|
||||
// an allowlist declared twice is one that will eventually disagree with itself, so
|
||||
// the arguments are rendered and parsed from a single table: guardedFields. Adding
|
||||
// a setting is one row; nothing generic passes through, and no field outside the
|
||||
// table can be reached with an elevated request no matter what lands on the command
|
||||
// line.
|
||||
|
||||
// oneShotTimeout bounds the whole one-shot: connect, one RPC, exit. Generous
|
||||
// because the user has just waited for an authentication dialog, and a failure here
|
||||
// costs them the entire round trip.
|
||||
const oneShotTimeout = 30 * time.Second
|
||||
|
||||
// Exit codes the parent reads where the platform gives it one.
|
||||
const (
|
||||
exitOK = 0
|
||||
exitFailure = 1
|
||||
exitUsage = 2
|
||||
)
|
||||
|
||||
// guardedField is one setting the one-shot understands, in the two spellings it
|
||||
// needs and with the two halves of its plumbing.
|
||||
type guardedField struct {
|
||||
// flag names it on the one-shot's command line.
|
||||
flag string
|
||||
usage string
|
||||
// read returns the value to send and whether the caller asked for this setting
|
||||
// at all.
|
||||
read func(GuardedSettings) (string, bool)
|
||||
// write parses a value from the command line onto the request. It is the only
|
||||
// thing that validates the value, so it fails on anything it does not
|
||||
// recognise rather than guessing.
|
||||
write func(*proto.SetConfigRequest, string) error
|
||||
// up renders the equivalent `netbird up` flag, for the fallback command shown
|
||||
// when there is no prompt to raise.
|
||||
up func(value string) string
|
||||
}
|
||||
|
||||
var guardedFields = []guardedField{
|
||||
{
|
||||
flag: FlagManagementURL,
|
||||
usage: "Management server the profile registers with.",
|
||||
read: func(p GuardedSettings) (string, bool) { return p.ManagementURL, p.ManagementURL != "" },
|
||||
write: func(req *proto.SetConfigRequest, value string) error {
|
||||
// Parsed with the config layer's own parser, so what the elevated run
|
||||
// accepts cannot drift from what the daemon would store.
|
||||
if _, err := profilemanager.ParseServiceURL("Management URL", value); err != nil {
|
||||
return err
|
||||
}
|
||||
req.ManagementUrl = value
|
||||
return nil
|
||||
},
|
||||
// The daemon names this one as `-m <url>` in its own refusals.
|
||||
up: func(value string) string { return "-m " + value },
|
||||
},
|
||||
boolField(FlagAllowServerSSH, "Run the NetBird SSH server.",
|
||||
func(p GuardedSettings) *bool { return p.ServerSSHAllowed },
|
||||
func(req *proto.SetConfigRequest, v *bool) { req.ServerSSHAllowed = v }),
|
||||
boolField(FlagEnableSSHRoot, "Allow SSH sessions to privileged accounts.",
|
||||
func(p GuardedSettings) *bool { return p.EnableSSHRoot },
|
||||
func(req *proto.SetConfigRequest, v *bool) { req.EnableSSHRoot = v }),
|
||||
boolField(FlagDisableSSHAuth, "Accept SSH sessions without authentication.",
|
||||
func(p GuardedSettings) *bool { return p.DisableSSHAuth },
|
||||
func(req *proto.SetConfigRequest, v *bool) { req.DisableSSHAuth = v }),
|
||||
}
|
||||
|
||||
// fieldValue is a flag that remembers whether it was given, and requires a value:
|
||||
// the renderer always writes one, so a bare flag is a caller that got it wrong.
|
||||
type fieldValue struct {
|
||||
set bool
|
||||
value string
|
||||
}
|
||||
|
||||
func (v *fieldValue) String() string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return v.value
|
||||
}
|
||||
|
||||
func (v *fieldValue) Set(value string) error {
|
||||
v.set, v.value = true, value
|
||||
return nil
|
||||
}
|
||||
|
||||
// boolField describes a setting that is on or off. The value is always spelled out,
|
||||
// so that turning a setting off is as unambiguous as turning it on and a flag with
|
||||
// no value is a mistake rather than an "on".
|
||||
func boolField(
|
||||
name, usage string,
|
||||
read func(GuardedSettings) *bool,
|
||||
write func(*proto.SetConfigRequest, *bool),
|
||||
) guardedField {
|
||||
return guardedField{
|
||||
flag: name,
|
||||
usage: usage,
|
||||
read: func(p GuardedSettings) (string, bool) {
|
||||
value := read(p)
|
||||
if value == nil {
|
||||
return "", false
|
||||
}
|
||||
return strconv.FormatBool(*value), true
|
||||
},
|
||||
write: func(req *proto.SetConfigRequest, value string) error {
|
||||
parsed, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse %q as a boolean: %w", value, err)
|
||||
}
|
||||
write(req, &parsed)
|
||||
return nil
|
||||
},
|
||||
up: func(value string) string { return "--" + name + "=" + value },
|
||||
}
|
||||
}
|
||||
|
||||
// IsPrivilegedSettingsRun reports whether this process was started as the one-shot.
|
||||
// The flag is a marker rather than a value, so only the bare forms count: reading a
|
||||
// value would mean "--flag=false" started it too.
|
||||
func IsPrivilegedSettingsRun(args []string) bool {
|
||||
for _, arg := range args {
|
||||
if arg == "--"+FlagApplyPrivilegedSettings || arg == "-"+FlagApplyPrivilegedSettings {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RunPrivilegedSettings applies the requested settings and returns the process exit
|
||||
// code. connect dials the daemon, which is the caller's business because only it
|
||||
// knows how this build talks to it.
|
||||
//
|
||||
// Everything it reports goes to stderr, which is what the parent captures where the
|
||||
// platform lets it. On success it says so on standard output, because macOS gives
|
||||
// the parent no exit status to read: see elevate.AppliedMarker.
|
||||
func RunPrivilegedSettings(args []string, connect func(addr string) (proto.DaemonServiceClient, error)) int {
|
||||
fs := flag.NewFlagSet("netbird-ui --"+FlagApplyPrivilegedSettings, flag.ContinueOnError)
|
||||
fs.Bool(FlagApplyPrivilegedSettings, false, "Apply the settings the daemon restricts to root/administrator and exit.")
|
||||
daemonAddr := fs.String(FlagDaemonAddr, "", "Daemon gRPC address: unix:///path, npipe://name or tcp://host:port")
|
||||
logLevel := fs.String(FlagLogLevel, "info", "Log level: trace|debug|info|warn|error.")
|
||||
profile := fs.String(FlagProfile, "", "Profile to change.")
|
||||
username := fs.String(FlagUser, "", "Owner of the profile.")
|
||||
|
||||
values := make([]fieldValue, len(guardedFields))
|
||||
for i, field := range guardedFields {
|
||||
fs.Var(&values[i], field.flag, field.usage)
|
||||
}
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
if err := util.InitLog(*logLevel, "console"); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "init log: %v\n", err)
|
||||
return exitFailure
|
||||
}
|
||||
|
||||
req, err := privilegedRequest(*profile, *username, values)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), oneShotTimeout)
|
||||
defer cancel()
|
||||
|
||||
if err := applyPrivilegedSettings(ctx, *daemonAddr, req, connect); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "apply settings: %v\n", err)
|
||||
return exitFailure
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stdout, elevate.AppliedMarker)
|
||||
return exitOK
|
||||
}
|
||||
|
||||
// privilegedRequest builds the request from the flags that were given, and refuses
|
||||
// one that asks for nothing.
|
||||
func privilegedRequest(profile, username string, values []fieldValue) (*proto.SetConfigRequest, error) {
|
||||
req := &proto.SetConfigRequest{ProfileName: profile, Username: username}
|
||||
|
||||
given := 0
|
||||
for i, field := range guardedFields {
|
||||
if !values[i].set {
|
||||
continue
|
||||
}
|
||||
if err := field.write(req, values[i].value); err != nil {
|
||||
return nil, fmt.Errorf("--%s: %w", field.flag, err)
|
||||
}
|
||||
given++
|
||||
}
|
||||
if given == 0 {
|
||||
return nil, errors.New("no setting to apply")
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func applyPrivilegedSettings(
|
||||
ctx context.Context,
|
||||
daemonAddr string,
|
||||
req *proto.SetConfigRequest,
|
||||
connect func(addr string) (proto.DaemonServiceClient, error),
|
||||
) error {
|
||||
client, err := connect(daemonAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := client.SetConfig(ctx, req); err != nil {
|
||||
// Unwrapped: the daemon's message is written for a person, and a refusal
|
||||
// elevation cannot fix has to say so where the parent can read it off
|
||||
// stderr.
|
||||
return errors.New(gstatus.Convert(err).Message())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// interface guard: the one-shot's flags are flag.Value.
|
||||
var _ flag.Value = (*fieldValue)(nil)
|
||||
@@ -0,0 +1,151 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
func TestIsPrivilegedSettingsRun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want bool
|
||||
}{
|
||||
{name: "no arguments"},
|
||||
{name: "double dash", args: []string{"--" + FlagApplyPrivilegedSettings}, want: true},
|
||||
{name: "single dash", args: []string{"-" + FlagApplyPrivilegedSettings}, want: true},
|
||||
{
|
||||
name: "among other flags",
|
||||
args: []string{"--daemon-addr", "unix:///tmp/x.sock", "--" + FlagApplyPrivilegedSettings},
|
||||
want: true,
|
||||
},
|
||||
// A marker, not a value: the caller never passes one, and reading a value
|
||||
// would mean "--flag=false" started the one-shot too.
|
||||
{name: "with a value", args: []string{"--" + FlagApplyPrivilegedSettings + "=true"}},
|
||||
{name: "unrelated flags", args: []string{"--log-level", "debug"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, IsPrivilegedSettingsRun(tt.args), "args %v", tt.args)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// What SetGuardedSettings renders has to be what the one-shot reads back, for every
|
||||
// setting in the table. This is the property that keeps the two ends of an allowlist
|
||||
// from drifting, so it is checked field by field rather than by example.
|
||||
func TestGuardedFieldsRoundTrip(t *testing.T) {
|
||||
on, off := true, false
|
||||
tests := []struct {
|
||||
name string
|
||||
settings GuardedSettings
|
||||
want func(*testing.T, *proto.SetConfigRequest)
|
||||
}{
|
||||
{
|
||||
name: "management url",
|
||||
settings: GuardedSettings{ManagementURL: "https://mgmt.example.com:33073"},
|
||||
want: func(t *testing.T, req *proto.SetConfigRequest) {
|
||||
assert.Equal(t, "https://mgmt.example.com:33073", req.GetManagementUrl())
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ssh server on",
|
||||
settings: GuardedSettings{ServerSSHAllowed: &on},
|
||||
want: func(t *testing.T, req *proto.SetConfigRequest) {
|
||||
require.NotNil(t, req.ServerSSHAllowed)
|
||||
assert.True(t, *req.ServerSSHAllowed)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ssh root off",
|
||||
settings: GuardedSettings{EnableSSHRoot: &off},
|
||||
want: func(t *testing.T, req *proto.SetConfigRequest) {
|
||||
require.NotNil(t, req.EnableSSHRoot, "an explicit false must survive, not read as absent")
|
||||
assert.False(t, *req.EnableSSHRoot)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ssh auth off",
|
||||
settings: GuardedSettings{DisableSSHAuth: &on},
|
||||
want: func(t *testing.T, req *proto.SetConfigRequest) {
|
||||
require.NotNil(t, req.DisableSSHAuth)
|
||||
assert.True(t, *req.DisableSSHAuth)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := parseRendered(t, tt.settings)
|
||||
tt.want(t, req)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A setting nobody asked about must not arrive at the daemon at all: sending its
|
||||
// zero value would change it.
|
||||
func TestGuardedFieldsCarryOnlyWhatWasAsked(t *testing.T) {
|
||||
on := true
|
||||
req := parseRendered(t, GuardedSettings{ProfileName: "work", EnableSSHRoot: &on})
|
||||
|
||||
assert.Equal(t, "work", req.GetProfileName(), "profile")
|
||||
require.NotNil(t, req.EnableSSHRoot)
|
||||
assert.Nil(t, req.ServerSSHAllowed, "untouched setting")
|
||||
assert.Nil(t, req.DisableSSHAuth, "untouched setting")
|
||||
assert.Empty(t, req.GetManagementUrl(), "untouched setting")
|
||||
}
|
||||
|
||||
func TestPrivilegedRequestRejectsAnEmptyChange(t *testing.T) {
|
||||
_, err := privilegedRequest("default", "vma", make([]fieldValue, len(guardedFields)))
|
||||
require.Error(t, err, "nothing to apply is not a request worth sending as root")
|
||||
}
|
||||
|
||||
// A value the table cannot parse is refused rather than guessed at.
|
||||
func TestPrivilegedRequestRejectsAnUnparseableValue(t *testing.T) {
|
||||
values := make([]fieldValue, len(guardedFields))
|
||||
for i, field := range guardedFields {
|
||||
if field.flag != FlagEnableSSHRoot {
|
||||
continue
|
||||
}
|
||||
require.NoError(t, values[i].Set("perhaps"))
|
||||
}
|
||||
|
||||
_, err := privilegedRequest("default", "vma", values)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), FlagEnableSSHRoot, "which flag was wrong")
|
||||
}
|
||||
|
||||
// parseRendered puts the settings through both ends: rendered as the arguments the
|
||||
// elevated process is given, then parsed by a flag set registered from the same
|
||||
// table, which is what the one-shot itself parses them with. Anything hand-rolled
|
||||
// here would pin down a parser nothing uses.
|
||||
func parseRendered(t *testing.T, p GuardedSettings) *proto.SetConfigRequest {
|
||||
t.Helper()
|
||||
|
||||
rendered := guardedSettings(p)
|
||||
require.NotEmpty(t, rendered, "nothing rendered for %+v", p)
|
||||
|
||||
args := make([]string, 0, len(rendered))
|
||||
for _, setting := range rendered {
|
||||
args = append(args, setting.arg)
|
||||
}
|
||||
|
||||
fs := flag.NewFlagSet(t.Name(), flag.ContinueOnError)
|
||||
values := make([]fieldValue, len(guardedFields))
|
||||
for i, field := range guardedFields {
|
||||
fs.Var(&values[i], field.flag, field.usage)
|
||||
}
|
||||
require.NoError(t, fs.Parse(args), "the one-shot's own flag set must accept %v", args)
|
||||
|
||||
req, err := privilegedRequest(p.ProfileName, p.Username, values)
|
||||
require.NoError(t, err)
|
||||
return req
|
||||
}
|
||||
@@ -162,8 +162,9 @@ func (s *Profiles) Remove(ctx context.Context, p ProfileRef) error {
|
||||
}
|
||||
|
||||
// The daemon deletes what it owns but runs as root, so it leaves the
|
||||
// user-owned state file holding the account email behind (same split as
|
||||
// Connection.Logout). Legacy profiles are keyed by name rather than by a
|
||||
// user-owned state file holding the account email behind. Logout keeps the
|
||||
// email on purpose so later logins can pass it as the login_hint; profile
|
||||
// removal is what deletes it. Legacy profiles are keyed by name rather than by a
|
||||
// generated ID, so a recreated profile of the same name would inherit the
|
||||
// deleted one's email and offer it as the login_hint.
|
||||
//
|
||||
|
||||
+127
-20
@@ -44,12 +44,19 @@ type Restrictions struct {
|
||||
}
|
||||
|
||||
// Privilege tells the frontend whether this process may perform the changes the
|
||||
// daemon restricts to root/administrator, and carries the command for each so a
|
||||
// disabled control can show the way to do it.
|
||||
// daemon restricts to root/administrator, whether it can ask the operating
|
||||
// system for the privileges instead, and the command for each so a control that
|
||||
// can do neither can still show the way.
|
||||
type Privilege struct {
|
||||
Privileged bool `json:"privileged"`
|
||||
// Actor names what the operation requires ("root", "administrator privileges").
|
||||
Actor string `json:"actor"`
|
||||
// ActorKey identifies the principal the operation requires without wording it,
|
||||
// so the frontend can name it in the user's language: see
|
||||
// ipcauth.PrivilegedActorKey. The words are not sent, because English ones
|
||||
// cannot be dropped into a translated sentence.
|
||||
ActorKey string `json:"actorKey"`
|
||||
// CanElevate reports whether a guarded control can offer to authorize the
|
||||
// change through the platform's own prompt: see SetGuardedSettings.
|
||||
CanElevate bool `json:"canElevate"`
|
||||
// Commands equivalent to the settings the daemon guards, ready to copy.
|
||||
AllowSSHServer string `json:"allowSshServer"`
|
||||
EnableSSHRoot string `json:"enableSshRoot"`
|
||||
@@ -128,6 +135,9 @@ type Settings struct {
|
||||
// daemonAddr is where the daemon listens, used to tell whether it runs as
|
||||
// this user and would therefore authorize us: see Privilege.
|
||||
daemonAddr string
|
||||
// elevator raises the platform's privilege prompt when a change needs more
|
||||
// rights than this process has.
|
||||
elevator elevator
|
||||
}
|
||||
|
||||
func NewSettings(conn DaemonConn, translator ErrorTranslator, prefs LanguagePreference, daemonAddr string) *Settings {
|
||||
@@ -135,6 +145,7 @@ func NewSettings(conn DaemonConn, translator ErrorTranslator, prefs LanguagePref
|
||||
conn: conn,
|
||||
classifier: errorClassifier{translator: translator, prefs: prefs},
|
||||
daemonAddr: daemonAddr,
|
||||
elevator: osElevator{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,10 +191,10 @@ func (s *Settings) GetConfig(ctx context.Context, p ConfigParams) (Config, error
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) error {
|
||||
func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) (SaveOutcome, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
return SaveOutcome{}, err
|
||||
}
|
||||
req := &proto.SetConfigRequest{
|
||||
ProfileName: p.ProfileName,
|
||||
@@ -215,19 +226,92 @@ func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) error {
|
||||
SshJWTCacheTTL: p.SSHJWTCacheTTL,
|
||||
}
|
||||
if _, err := cli.SetConfig(ctx, req); err != nil {
|
||||
if _, refused := privilegeErrorInfo(err); refused {
|
||||
return s.setConfigElevated(ctx, p, req, err)
|
||||
}
|
||||
// Classified so the frontend gets the daemon's guidance instead of the
|
||||
// gRPC envelope, which is what a refused privileged change looks like.
|
||||
return s.classifier.classify(err)
|
||||
// gRPC envelope.
|
||||
return SaveOutcome{}, s.classifier.classify(err)
|
||||
}
|
||||
return nil
|
||||
return SaveOutcome{}, nil
|
||||
}
|
||||
|
||||
// setConfigElevated answers a request the daemon refused for want of privileges by
|
||||
// asking the user to authorize it, and sending it again if they do. It is the same
|
||||
// offer the SSH settings make up front, for the changes a control cannot know are
|
||||
// guarded until it is told: repointing a profile at another management server is
|
||||
// only privileged while that host runs the SSH server.
|
||||
//
|
||||
// Two steps, because the elevated one-shot deliberately understands only the
|
||||
// settings the daemon guards: it applies those, and the original request then goes
|
||||
// through as this user, its privileged parts now asking for nothing that is not
|
||||
// already stored. Nothing was applied by the refused attempt — the daemon decides
|
||||
// before it writes — so there is no half-applied state to undo either way.
|
||||
func (s *Settings) setConfigElevated(ctx context.Context, p SetConfigParams, req *proto.SetConfigRequest, refusal error) (SaveOutcome, error) {
|
||||
if !s.canElevate() {
|
||||
return SaveOutcome{}, s.classifier.classify(refusal)
|
||||
}
|
||||
|
||||
guarded, err := s.guardedChanges(ctx, p)
|
||||
if err != nil {
|
||||
log.Warnf("cannot tell which guarded settings this request changes: %v", err)
|
||||
return SaveOutcome{}, s.classifier.classify(refusal)
|
||||
}
|
||||
if len(guardedSettings(guarded)) == 0 {
|
||||
// Refused over something no prompt can settle, such as a control channel
|
||||
// that carries no caller identity. Report the daemon's own guidance.
|
||||
return SaveOutcome{}, s.classifier.classify(refusal)
|
||||
}
|
||||
|
||||
outcome, err := s.SetGuardedSettings(ctx, guarded)
|
||||
if err != nil || outcome.Declined {
|
||||
return outcome, err
|
||||
}
|
||||
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return SaveOutcome{}, err
|
||||
}
|
||||
if _, err := cli.SetConfig(ctx, req); err != nil {
|
||||
return SaveOutcome{}, s.classifier.classify(err)
|
||||
}
|
||||
return SaveOutcome{}, nil
|
||||
}
|
||||
|
||||
// guardedChanges is the guarded part of a request, reduced to what it actually
|
||||
// changes.
|
||||
//
|
||||
// A settings form submits every field it holds, so a request restates values the
|
||||
// daemon already has. Carrying those into the elevated run would spend one
|
||||
// authorization on more than the user asked for, and a value that has gone stale
|
||||
// since the form was loaded would spend it on something they never asked about.
|
||||
func (s *Settings) guardedChanges(ctx context.Context, p SetConfigParams) (GuardedSettings, error) {
|
||||
stored, err := s.GetConfig(ctx, ConfigParams{ProfileName: p.ProfileName, Username: p.Username})
|
||||
if err != nil {
|
||||
return GuardedSettings{}, fmt.Errorf("read the stored config: %w", err)
|
||||
}
|
||||
|
||||
guarded := GuardedSettings{
|
||||
ProfileName: p.ProfileName,
|
||||
Username: p.Username,
|
||||
ServerSSHAllowed: changedFlag(p.ServerSSHAllowed, stored.ServerSSHAllowed),
|
||||
EnableSSHRoot: changedFlag(p.EnableSSHRoot, stored.EnableSSHRoot),
|
||||
DisableSSHAuth: changedFlag(p.DisableSSHAuth, stored.DisableSSHAuth),
|
||||
}
|
||||
// An empty URL leaves the setting alone, which is the daemon's rule too.
|
||||
if p.ManagementURL != "" && p.ManagementURL != stored.ManagementURL {
|
||||
guarded.ManagementURL = p.ManagementURL
|
||||
}
|
||||
return guarded, nil
|
||||
}
|
||||
|
||||
// Privilege reports whether this UI process could carry out the changes the
|
||||
// daemon restricts to root/administrator, and the command that performs the one
|
||||
// users hit in the SSH settings. It applies the daemon's own rule to what it can
|
||||
// see locally, so the frontend can present those controls as unavailable up front
|
||||
// instead of letting a save fail. No daemon round-trip, so it also works while the
|
||||
// daemon is down.
|
||||
// daemon restricts to root/administrator, whether it can instead ask the
|
||||
// operating system for the privileges when the user wants one of them, and the
|
||||
// command that performs the ones users hit in the SSH settings. It applies the
|
||||
// daemon's own rule to what it can see locally, so the frontend can decide up
|
||||
// front how to present those controls instead of letting a save fail. No daemon
|
||||
// round-trip, so it also works while the daemon is down.
|
||||
//
|
||||
// Being root or an elevated administrator is one way. The other is running as the
|
||||
// daemon's own user while the daemon is unprivileged, which the daemon accepts
|
||||
@@ -237,26 +321,40 @@ func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) error {
|
||||
func (s *Settings) Privilege() Privilege {
|
||||
id, err := ipcauth.CurrentProcessIdentity()
|
||||
if err != nil {
|
||||
// Fail closed: report unprivileged, which only ever disables controls.
|
||||
// Fail closed: report unprivileged, which only ever asks for more.
|
||||
log.Warnf("cannot read this process's identity, treating it as unprivileged: %v", err)
|
||||
return newPrivilege(false)
|
||||
return s.newPrivilege(false)
|
||||
}
|
||||
if id.IsPrivileged() {
|
||||
return newPrivilege(true)
|
||||
return s.newPrivilege(true)
|
||||
}
|
||||
return newPrivilege(daemonaddr.DaemonRunsAsSelf(s.daemonAddr))
|
||||
return s.newPrivilege(daemonaddr.DaemonRunsAsSelf(s.daemonAddr))
|
||||
}
|
||||
|
||||
func newPrivilege(privileged bool) Privilege {
|
||||
func (s *Settings) newPrivilege(privileged bool) Privilege {
|
||||
return Privilege{
|
||||
Privileged: privileged,
|
||||
Actor: ipcauth.PrivilegedActor(),
|
||||
ActorKey: ipcauth.PrivilegedActorKey(),
|
||||
CanElevate: s.canElevate(),
|
||||
AllowSSHServer: ipcauth.UpCommand("--allow-server-ssh"),
|
||||
EnableSSHRoot: ipcauth.UpCommand("--enable-ssh-root"),
|
||||
DisableSSHAuth: ipcauth.UpCommand("--disable-ssh-auth"),
|
||||
}
|
||||
}
|
||||
|
||||
// canElevate reports whether offering the platform's elevation prompt would get
|
||||
// the user anywhere. It needs a mechanism to raise the prompt with and a control
|
||||
// channel that tells the daemon who is calling: on loopback TCP the daemon
|
||||
// refuses these changes to everybody, root included, so a prompt there would
|
||||
// only waste the user's password.
|
||||
func (s *Settings) canElevate() bool {
|
||||
if !daemonaddr.CarriesIdentity(s.daemonAddr) {
|
||||
log.Debugf("not offering elevation: the daemon address %s carries no caller identity", s.daemonAddr)
|
||||
return false
|
||||
}
|
||||
return s.elevator.Available()
|
||||
}
|
||||
|
||||
func (s *Settings) GetRestrictions(ctx context.Context) (Restrictions, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
@@ -289,6 +387,15 @@ func (s *Settings) GetRestrictions(ctx context.Context) (Restrictions, error) {
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// changedFlag returns requested only when it differs from what is stored, so a
|
||||
// setting the request merely restates is left out of the elevated run.
|
||||
func changedFlag(requested *bool, stored bool) *bool {
|
||||
if requested == nil || *requested == stored {
|
||||
return nil
|
||||
}
|
||||
return requested
|
||||
}
|
||||
|
||||
func applyMDMRestrictions(mdm *MDMFields, cfgResp *proto.GetConfigResponse) {
|
||||
managed := cfgResp.GetMDMManagedFields()
|
||||
if len(managed) == 0 {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
"github.com/wailsapp/wails/v3/pkg/events"
|
||||
|
||||
@@ -29,6 +30,12 @@ const EventBrowserLoginCancel = "browser-login:cancel"
|
||||
// EventSettingsOpen tells the mounted settings window which tab to show.
|
||||
const EventSettingsOpen = "netbird:settings:open"
|
||||
|
||||
const EventWindowPainted = "netbird:window-painted"
|
||||
|
||||
const paintedFallback = 2 * time.Second
|
||||
|
||||
const headlessTeardownDelay = 2 * time.Second
|
||||
|
||||
var WindowBackgroundColour = application.NewRGB(24, 26, 29) // bg-nb-gray-950
|
||||
|
||||
// WindowHeight is shared by the main and Settings windows.
|
||||
@@ -94,9 +101,6 @@ func DialogWindowOptions(name, title, url string, linuxIcon []byte) application.
|
||||
}
|
||||
}
|
||||
|
||||
// WindowManager owns the auxiliary windows (main is created in main.go). Settings is created
|
||||
// eagerly and hidden on close to keep React state; the rest are created on open, destroyed on
|
||||
// close, so the macOS dock-reopen handler finds no hidden window to resurrect.
|
||||
type WindowManager struct {
|
||||
app *application.App
|
||||
mainWindow *application.WebviewWindow
|
||||
@@ -112,15 +116,35 @@ type WindowManager struct {
|
||||
// hiddenForLogin holds windows hidden while the BrowserLogin popup is open, restored on close.
|
||||
hiddenForLogin []application.Window
|
||||
mu sync.Mutex
|
||||
createMu sync.Mutex
|
||||
newMain func(startURL string) *application.WebviewWindow
|
||||
ready map[uint]bool
|
||||
showPending map[uint]bool
|
||||
pendingTab map[uint]string
|
||||
pendingEmits map[uint][]string
|
||||
fallbackTimers map[uint]*time.Timer
|
||||
headlessMain bool
|
||||
headlessTimer *time.Timer
|
||||
// recenterOnShow is set only on the minimal-WM/XEmbed path, where the WM neither centers nor
|
||||
// restores position; nil on full desktops so re-centering can't fight a user-moved window.
|
||||
recenterOnShow func() bool
|
||||
}
|
||||
|
||||
// NewWindowManager wires the manager to the main app; translator/prefs may be nil (tests). The
|
||||
// Settings window is created here (hidden) so the first OpenSettings is instant.
|
||||
func NewWindowManager(app *application.App, mainWindow *application.WebviewWindow, translator ErrorTranslator, prefs LanguagePreference, linuxIcon []byte) *WindowManager {
|
||||
s := &WindowManager{app: app, mainWindow: mainWindow, translator: translator, prefs: prefs, linuxIcon: linuxIcon}
|
||||
s := &WindowManager{
|
||||
app: app,
|
||||
mainWindow: mainWindow,
|
||||
translator: translator,
|
||||
prefs: prefs,
|
||||
linuxIcon: linuxIcon,
|
||||
ready: map[uint]bool{},
|
||||
showPending: map[uint]bool{},
|
||||
pendingTab: map[uint]string{},
|
||||
pendingEmits: map[uint][]string{},
|
||||
fallbackTimers: map[uint]*time.Timer{},
|
||||
}
|
||||
s.watchPainted()
|
||||
s.watchTriggerLogin()
|
||||
// Re-title live windows on language flip. Wired internally so the binding generator
|
||||
// doesn't try to expose the interface param.
|
||||
if sub, ok := prefs.(LanguageSubscriber); ok && sub != nil {
|
||||
@@ -136,7 +160,11 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo
|
||||
}
|
||||
}()
|
||||
}
|
||||
s.settings = app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *WindowManager) newSettingsWindow() *application.WebviewWindow {
|
||||
w := s.app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||
Name: "settings",
|
||||
Title: s.title("window.title.settings"),
|
||||
Width: 900,
|
||||
@@ -150,18 +178,15 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo
|
||||
URL: "/#/settings",
|
||||
Mac: AppleMacOSAppearanceOptions(),
|
||||
Windows: MicrosoftWindowsAppearanceOptions(),
|
||||
Linux: LinuxAppearanceOptions(linuxIcon),
|
||||
Linux: LinuxAppearanceOptions(s.linuxIcon),
|
||||
})
|
||||
// Hide (not destroy) on close to keep React state; reset to General for a flash-free reopen.
|
||||
s.settings.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
|
||||
if ShuttingDown() {
|
||||
return
|
||||
}
|
||||
e.Cancel()
|
||||
s.app.Event.Emit(EventSettingsOpen, "general")
|
||||
s.settings.Hide()
|
||||
w.RegisterHook(events.Common.WindowClosing, func(_ *application.WindowEvent) {
|
||||
s.mu.Lock()
|
||||
s.settings = nil
|
||||
s.forgetWindowLocked(w)
|
||||
s.mu.Unlock()
|
||||
})
|
||||
return s
|
||||
return w
|
||||
}
|
||||
|
||||
// OpenSettings shows the settings window on tab (empty → General), switching tab via
|
||||
@@ -171,11 +196,20 @@ func (s *WindowManager) OpenSettings(tab string) {
|
||||
if target == "" {
|
||||
target = "general"
|
||||
}
|
||||
s.app.Event.Emit(EventSettingsOpen, target)
|
||||
s.settings.Show()
|
||||
s.settings.Focus()
|
||||
// Re-center (minimal-WM only; see centerWhenReady).
|
||||
s.centerWhenReady(s.settings)
|
||||
|
||||
w, _ := s.ensureWindow(&s.settings, s.newSettingsWindow)
|
||||
|
||||
s.mu.Lock()
|
||||
ready := s.ready[w.ID()]
|
||||
if !ready {
|
||||
s.pendingTab[w.ID()] = target
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if ready {
|
||||
s.app.Event.Emit(EventSettingsOpen, target)
|
||||
}
|
||||
s.showWhenReady(w)
|
||||
}
|
||||
|
||||
// OpenBrowserLogin shows the SSO popup, creating it on first use.
|
||||
@@ -440,13 +474,295 @@ func (s *WindowManager) OpenMain() {
|
||||
// ShowMain brings the main window forward (re-centering on minimal WMs). The single entry
|
||||
// point every surface (tray, SIGUSR1, welcome) should use so centering applies uniformly.
|
||||
func (s *WindowManager) ShowMain() {
|
||||
if s.mainWindow == nil {
|
||||
s.showWhenReady(s.MainWindow())
|
||||
}
|
||||
|
||||
// ShowMainAndEmit brings the main window forward and emits event once its frontend is ready.
|
||||
func (s *WindowManager) ShowMainAndEmit(event string) {
|
||||
w := s.MainWindow()
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
s.mainWindow.Show()
|
||||
s.mainWindow.Focus()
|
||||
// Re-center (minimal-WM only; see centerWhenReady).
|
||||
s.centerWhenReady(s.mainWindow)
|
||||
|
||||
id := w.ID()
|
||||
s.mu.Lock()
|
||||
ready := s.ready[id]
|
||||
if !ready {
|
||||
s.pendingEmits[id] = append(s.pendingEmits[id], event)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
s.showWhenReady(w)
|
||||
if ready {
|
||||
s.app.Event.Emit(event)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WindowManager) MainWindow() *application.WebviewWindow {
|
||||
w, _ := s.ensureMain("/")
|
||||
return w
|
||||
}
|
||||
|
||||
func (s *WindowManager) ensureMain(startURL string) (*application.WebviewWindow, bool) {
|
||||
s.mu.Lock()
|
||||
factory := s.newMain
|
||||
s.mu.Unlock()
|
||||
if factory == nil {
|
||||
return s.ensureWindow(&s.mainWindow, nil)
|
||||
}
|
||||
return s.ensureWindow(&s.mainWindow, func() *application.WebviewWindow {
|
||||
return factory(startURL)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *WindowManager) ensureWindow(slot **application.WebviewWindow, factory func() *application.WebviewWindow) (*application.WebviewWindow, bool) {
|
||||
s.createMu.Lock()
|
||||
defer s.createMu.Unlock()
|
||||
|
||||
s.mu.Lock()
|
||||
w := *slot
|
||||
s.mu.Unlock()
|
||||
if w != nil || factory == nil {
|
||||
return w, false
|
||||
}
|
||||
|
||||
w = factory()
|
||||
s.armReady(w)
|
||||
|
||||
s.mu.Lock()
|
||||
*slot = w
|
||||
s.mu.Unlock()
|
||||
return w, true
|
||||
}
|
||||
|
||||
func (s *WindowManager) armReady(w *application.WebviewWindow) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
w.RegisterHook(events.Common.WindowRuntimeReady, func(_ *application.WindowEvent) {
|
||||
timer := time.AfterFunc(paintedFallback, func() {
|
||||
log.Warnf("window %q never reported a first render, showing it anyway", w.Name())
|
||||
s.markReady(w)
|
||||
})
|
||||
s.mu.Lock()
|
||||
s.fallbackTimers[w.ID()] = timer
|
||||
s.mu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func (s *WindowManager) watchPainted() {
|
||||
s.app.Event.On(EventWindowPainted, func(e *application.CustomEvent) {
|
||||
if w := s.windowByName(e.Sender); w != nil {
|
||||
s.markReady(w)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *WindowManager) watchTriggerLogin() {
|
||||
s.app.Event.On(EventTriggerLogin, func(_ *application.CustomEvent) {
|
||||
s.mu.Lock()
|
||||
if s.headlessTimer != nil {
|
||||
s.headlessTimer.Stop()
|
||||
s.headlessTimer = nil
|
||||
}
|
||||
w := s.mainWindow
|
||||
ready := w != nil && s.ready[w.ID()]
|
||||
s.mu.Unlock()
|
||||
if ready {
|
||||
return
|
||||
}
|
||||
|
||||
w, created := s.ensureMain("/")
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if created {
|
||||
s.headlessMain = true
|
||||
}
|
||||
pending := !s.ready[w.ID()]
|
||||
if pending {
|
||||
s.pendingEmits[w.ID()] = append(s.pendingEmits[w.ID()], EventTriggerLogin)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if !pending {
|
||||
s.app.Event.Emit(EventTriggerLogin)
|
||||
}
|
||||
})
|
||||
|
||||
s.app.Event.On(EventBrowserLoginCancel, func(_ *application.CustomEvent) {
|
||||
s.scheduleHeadlessTeardown()
|
||||
})
|
||||
|
||||
s.app.Event.On(EventStatusSnapshot, func(e *application.CustomEvent) {
|
||||
st, ok := e.Data.(Status)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
switch st.Status {
|
||||
case StatusConnected, StatusLoginFailed, StatusDaemonUnavailable:
|
||||
s.scheduleHeadlessTeardown()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *WindowManager) scheduleHeadlessTeardown() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if !s.headlessMain || s.mainWindow == nil {
|
||||
return
|
||||
}
|
||||
if s.headlessTimer != nil {
|
||||
s.headlessTimer.Stop()
|
||||
}
|
||||
s.headlessTimer = time.AfterFunc(headlessTeardownDelay, s.closeHeadlessMain)
|
||||
}
|
||||
|
||||
func (s *WindowManager) closeHeadlessMain() {
|
||||
s.mu.Lock()
|
||||
w := s.mainWindow
|
||||
headless := s.headlessMain
|
||||
s.headlessTimer = nil
|
||||
s.mu.Unlock()
|
||||
if !headless || w == nil {
|
||||
return
|
||||
}
|
||||
w.Close()
|
||||
}
|
||||
|
||||
func (s *WindowManager) forgetWindowLocked(w *application.WebviewWindow) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
|
||||
id := w.ID()
|
||||
if timer := s.fallbackTimers[id]; timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
delete(s.fallbackTimers, id)
|
||||
delete(s.ready, id)
|
||||
delete(s.showPending, id)
|
||||
delete(s.pendingTab, id)
|
||||
delete(s.pendingEmits, id)
|
||||
|
||||
kept := s.hiddenForLogin[:0]
|
||||
for _, hidden := range s.hiddenForLogin {
|
||||
if hidden != application.Window(w) {
|
||||
kept = append(kept, hidden)
|
||||
}
|
||||
}
|
||||
s.hiddenForLogin = kept
|
||||
}
|
||||
|
||||
func (s *WindowManager) windowByName(name string) *application.WebviewWindow {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
switch name {
|
||||
case "main":
|
||||
return s.mainWindow
|
||||
case "settings":
|
||||
return s.settings
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WindowManager) markReady(w *application.WebviewWindow) {
|
||||
id := w.ID()
|
||||
s.mu.Lock()
|
||||
already := s.ready[id]
|
||||
s.ready[id] = true
|
||||
wanted := s.showPending[id]
|
||||
tab, hasTab := s.pendingTab[id]
|
||||
emits := s.pendingEmits[id]
|
||||
if timer := s.fallbackTimers[id]; timer != nil {
|
||||
timer.Stop()
|
||||
delete(s.fallbackTimers, id)
|
||||
}
|
||||
delete(s.showPending, id)
|
||||
delete(s.pendingTab, id)
|
||||
delete(s.pendingEmits, id)
|
||||
s.mu.Unlock()
|
||||
|
||||
if already {
|
||||
return
|
||||
}
|
||||
|
||||
if hasTab {
|
||||
s.app.Event.Emit(EventSettingsOpen, tab)
|
||||
}
|
||||
|
||||
if wanted {
|
||||
s.showNow(w)
|
||||
}
|
||||
|
||||
for _, event := range emits {
|
||||
s.app.Event.Emit(event)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WindowManager) showWhenReady(w *application.WebviewWindow) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
|
||||
id := w.ID()
|
||||
s.mu.Lock()
|
||||
ready := s.ready[id]
|
||||
if !ready {
|
||||
s.showPending[id] = true
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if ready {
|
||||
s.showNow(w)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WindowManager) showNow(w *application.WebviewWindow) {
|
||||
s.mu.Lock()
|
||||
if w == s.mainWindow {
|
||||
s.headlessMain = false
|
||||
if s.headlessTimer != nil {
|
||||
s.headlessTimer.Stop()
|
||||
s.headlessTimer = nil
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
w.Show()
|
||||
w.Focus()
|
||||
s.centerWhenReady(w)
|
||||
}
|
||||
|
||||
func (s *WindowManager) ShowMainAt(url string) {
|
||||
w, created := s.ensureMain(url)
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
if !created {
|
||||
w.SetURL(url)
|
||||
}
|
||||
s.showWhenReady(w)
|
||||
}
|
||||
|
||||
func (s *WindowManager) SetMainFactory(f func(startURL string) *application.WebviewWindow) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.newMain = f
|
||||
}
|
||||
|
||||
func (s *WindowManager) ForgetMain() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.forgetWindowLocked(s.mainWindow)
|
||||
s.mainWindow = nil
|
||||
s.headlessMain = false
|
||||
if s.headlessTimer != nil {
|
||||
s.headlessTimer.Stop()
|
||||
s.headlessTimer = nil
|
||||
}
|
||||
}
|
||||
|
||||
// SetRecenterOnShow installs the recenterOnShow predicate (see the field).
|
||||
|
||||
Reference in New Issue
Block a user