mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-17 20:19:55 +00:00
Merge main into ui-refactor; port MDM support to the Wails UI
Integrates main's MDM configuration-profile feature and adapts it to the Wails UI (this branch had already replaced the Fyne UI). Conflict resolution: - go.mod/go.sum: take main's deps; howett.net/plist pinned to v1.0.2-... (tidy) - client/proto/daemon.pb.go: regenerated from the merged daemon.proto - client/internal/peer/status.go: union of ipToKey (main) + sessionExpiresAt (HEAD) - client/server/server.go: main's intent/liveness model (connectionGoroutineRunning, clientRunning no longer cleared by the goroutine) + empty-PSK guard - client/ui/client_ui.go, client/ui/profile.go: removed (dead Fyne UI) MDM port (backend + tray): - services/settings.go: expose MDMManagedFields plus a managedFields map keyed by Config field names so the settings form can gate a control without translating mdm.Key* names - tray: gate Profiles / Exit Node menus on DisableProfiles / DisableNetworks via GetFeatures, refreshed on the config_changed system event (replaces the legacy 2s poll); localized MDM policy-applied toast in all shipped locales - client/proto/metadata.go: shared constants for the config_changed / policy_applied event markers PreSharedKey: GetConfig now returns preSharedKeySet (bool) instead of the masked value; the settings form provides its own placeholder and sends a new key only when the user types one.
This commit is contained in:
@@ -3,12 +3,14 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/user"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"google.golang.org/protobuf/types/known/durationpb"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
@@ -85,6 +87,73 @@ var persistenceCmd = &cobra.Command{
|
||||
RunE: setSyncResponsePersistence,
|
||||
}
|
||||
|
||||
var debugConfigCmd = &cobra.Command{
|
||||
Use: "config",
|
||||
Example: " netbird debug config",
|
||||
Short: "Dump the effective configuration",
|
||||
Long: "Prints the daemon's resolved configuration (after applying defaults, file, env, CLI input, and MDM policy overrides) as JSON. Includes the list of MDM-managed fields.",
|
||||
RunE: debugConfigDump,
|
||||
}
|
||||
|
||||
// debugConfigDump implements `netbird debug config`. It resolves the
|
||||
// active profile, queries the daemon for the effective configuration
|
||||
// via GetConfig, and prints the resulting GetConfigResponse as JSON
|
||||
// (via protojson with EmitUnpopulated=true so the output is stable
|
||||
// across runs and includes zero-valued fields).
|
||||
//
|
||||
// Useful for verifying MDM enforcement end-to-end: the response's
|
||||
// mDMManagedFields array is the single source of truth for "which
|
||||
// fields is the daemon currently enforcing from the MDM source", and
|
||||
// every config field side-by-side with that list confirms the merge
|
||||
// result. Secrets in the response (e.g. PreSharedKey) are already
|
||||
// redacted by the daemon-side handler.
|
||||
func debugConfigDump(cmd *cobra.Command, _ []string) error {
|
||||
pm := profilemanager.NewProfileManager()
|
||||
activeProf, err := pm.GetActiveProfile()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get active profile: %v", err)
|
||||
}
|
||||
currUser, err := user.Current()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get current user: %v", err)
|
||||
}
|
||||
|
||||
conn, err := getClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err := conn.Close(); err != nil {
|
||||
log.Errorf(errCloseConnection, err)
|
||||
}
|
||||
}()
|
||||
|
||||
client := proto.NewDaemonServiceClient(conn)
|
||||
resp, err := client.GetConfig(cmd.Context(), &proto.GetConfigRequest{
|
||||
ProfileName: activeProf.Name,
|
||||
Username: currUser.Username,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get config: %v", status.Convert(err).Message())
|
||||
}
|
||||
|
||||
// Use protojson so well-known fields render correctly; emit defaults so
|
||||
// the operator sees every field even when zero/empty.
|
||||
m := protojson.MarshalOptions{Multiline: true, Indent: " ", EmitUnpopulated: true}
|
||||
out, err := m.Marshal(resp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal config: %w", err)
|
||||
}
|
||||
cmd.Println(string(out))
|
||||
return nil
|
||||
}
|
||||
|
||||
// debugBundle requests the daemon to create a debug bundle and prints
|
||||
// the resulting local file path and, if uploaded, the uploaded file
|
||||
// key. It uses the package flags (anonymize, system info, log file
|
||||
// count, CLI version, optional upload URL) to configure the bundle
|
||||
// request. Returns an error if the RPC fails or if the daemon reports
|
||||
// an upload failure reason.
|
||||
func debugBundle(cmd *cobra.Command, _ []string) error {
|
||||
conn, err := getClient(cmd)
|
||||
if err != nil {
|
||||
|
||||
@@ -95,7 +95,9 @@ var (
|
||||
}
|
||||
)
|
||||
|
||||
// Execute executes the root command.
|
||||
// Execute runs the appropriate Cobra command for the CLI.
|
||||
// If the process is the update binary it delegates to updateCmd; otherwise it runs the root command.
|
||||
// It returns any error produced during command execution.
|
||||
func Execute() error {
|
||||
if isUpdateBinary() {
|
||||
return updateCmd.Execute()
|
||||
@@ -103,6 +105,16 @@ func Execute() error {
|
||||
return rootCmd.Execute()
|
||||
}
|
||||
|
||||
// init initialises package-level defaults and configures the root
|
||||
// Cobra command tree. Sets platform-specific config / log directory
|
||||
// paths (including legacy Wiretrustee fallbacks) and a default daemon
|
||||
// address; registers persistent CLI flags (daemon address,
|
||||
// management / admin URLs, logging, setup key (file and inline,
|
||||
// mutually exclusive), preshared key, hostname, anonymise, config
|
||||
// path); attaches top-level and nested subcommands to the root
|
||||
// command; and registers `up`-specific persistent flags (external IP
|
||||
// maps, custom DNS resolver address, Rosenpass options, auto-connect
|
||||
// disabling, lazy connection).
|
||||
func init() {
|
||||
defaultConfigPathDir = "/etc/netbird/"
|
||||
defaultLogFileDir = "/var/log/netbird/"
|
||||
@@ -168,6 +180,7 @@ func init() {
|
||||
logCmd.AddCommand(logLevelCmd)
|
||||
debugCmd.AddCommand(forCmd)
|
||||
debugCmd.AddCommand(persistenceCmd)
|
||||
debugCmd.AddCommand(debugConfigCmd)
|
||||
|
||||
// kubernetes commands
|
||||
rootCmd.AddCommand(kubernetesCmd)
|
||||
|
||||
@@ -446,8 +446,8 @@ func (c *Client) Expose(ctx context.Context, req ExposeRequest) (*ExposeSession,
|
||||
|
||||
// IdentityForIP looks up a remote peer by its tunnel IP using the
|
||||
// embedded client's status recorder. Returns the peer's WireGuard public
|
||||
// key and FQDN. ok=false means the IP isn't in this client's peer
|
||||
// roster — callers should treat that as "unknown peer".
|
||||
// key and FQDN. ok=false means the IP doesn't belong to an active peer
|
||||
// — offline roster peers are treated as unknown, same as foreign IPs.
|
||||
func (c *Client) IdentityForIP(ip netip.Addr) (pubKey, fqdn string, ok bool) {
|
||||
if !ip.IsValid() || c.recorder == nil {
|
||||
return "", "", false
|
||||
|
||||
@@ -530,6 +530,14 @@ func (g *BundleGenerator) addConfig() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Surface the set of MDM-enforced keys so a support engineer reading
|
||||
// the bundle can tell which field values are user-set vs MDM-overridden.
|
||||
// Same semantics as the mDMManagedFields list returned by the
|
||||
// GetConfig RPC consumed by `netbird debug config`.
|
||||
if managed := g.internalConfig.Policy().ManagedKeys(); len(managed) > 0 {
|
||||
configContent.WriteString(fmt.Sprintf("MDMManagedFields: %v\n", managed))
|
||||
}
|
||||
|
||||
configReader := strings.NewReader(configContent.String())
|
||||
if err := g.addFileToZip(configReader, "config.txt"); err != nil {
|
||||
return fmt.Errorf("add config file to zip: %w", err)
|
||||
|
||||
@@ -843,6 +843,7 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
|
||||
"PreSharedKey": "sensitive: WireGuard pre-shared key",
|
||||
"SSHKey": "sensitive: SSH private key",
|
||||
"ClientCertKeyPair": "non-config: parsed cert pair, not serialized",
|
||||
"policy": "non-config: in-memory MDM policy snapshot, surfaced via Config.Policy() / GetConfigResponse.MDMManagedFields",
|
||||
}
|
||||
|
||||
mURL, _ := url.Parse("https://api.example.com:443")
|
||||
|
||||
@@ -482,7 +482,7 @@ func (d *Resolver) logDNSError(logger *log.Entry, hostname string, qtype uint16,
|
||||
// completely when every proxy peer is offline (the upstream may still
|
||||
// be reachable some other way, or the peerstore may be stale).
|
||||
func (d *Resolver) filterDisconnectedPeerAnswers(logger *log.Entry, question dns.Question, records []dns.RR) []dns.RR {
|
||||
if len(records) == 0 {
|
||||
if len(records) < 2 {
|
||||
return records
|
||||
}
|
||||
d.mu.RLock()
|
||||
|
||||
@@ -2738,6 +2738,17 @@ func TestLocalResolver_FilterDisconnectedPeerAnswers(t *testing.T) {
|
||||
connByIP: nil,
|
||||
wantInOrder: []string{"100.64.0.10", "100.64.0.11"},
|
||||
},
|
||||
{
|
||||
// A single answer is never filtered: dropping it would only
|
||||
// trigger the empty-answer escape hatch, so the fast path
|
||||
// returns it untouched.
|
||||
name: "single disconnected answer passes through",
|
||||
records: []nbdns.SimpleRecord{disconnectedRec},
|
||||
connByIP: map[string]ipState{
|
||||
"100.64.0.11": {known: true, connected: false},
|
||||
},
|
||||
wantInOrder: []string{"100.64.0.11"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
|
||||
@@ -26,7 +26,6 @@ type connStatusInputs struct {
|
||||
iceInProgress bool // a negotiation is currently in flight
|
||||
}
|
||||
|
||||
|
||||
// ConnStatus describe the status of a peer's connection
|
||||
type ConnStatus int32
|
||||
|
||||
|
||||
@@ -194,6 +194,7 @@ func (s *StatusChangeSubscription) Events() chan map[string]RouterState {
|
||||
type Status struct {
|
||||
mux sync.RWMutex
|
||||
peers map[string]State
|
||||
ipToKey map[string]string
|
||||
changeNotify map[string]map[string]*StatusChangeSubscription // map[peerID]map[subscriptionID]*StatusChangeSubscription
|
||||
signalState bool
|
||||
signalError error
|
||||
@@ -212,7 +213,8 @@ type Status struct {
|
||||
// expiration is disabled. Populated from management LoginResponse /
|
||||
// SyncResponse and exposed via the daemon's Status / SubscribeStatus RPC
|
||||
// so the UI can show remaining time without itself talking to mgm.
|
||||
sessionExpiresAt time.Time
|
||||
sessionExpiresAt time.Time
|
||||
|
||||
nsGroupStates []NSGroupState
|
||||
resolvedDomainsStates map[domain.Domain]ResolvedDomainInfo
|
||||
lazyConnectionEnabled bool
|
||||
@@ -253,6 +255,7 @@ type Status struct {
|
||||
func NewRecorder(mgmAddress string) *Status {
|
||||
return &Status{
|
||||
peers: make(map[string]State),
|
||||
ipToKey: make(map[string]string),
|
||||
changeNotify: make(map[string]map[string]*StatusChangeSubscription),
|
||||
eventStreams: make(map[string]chan *proto.SystemEvent),
|
||||
eventQueue: NewEventQueue(eventQueueSize),
|
||||
@@ -305,6 +308,12 @@ func (d *Status) AddPeer(peerPubKey string, fqdn string, ip string, ipv6 string)
|
||||
Mux: new(sync.RWMutex),
|
||||
}
|
||||
d.peerListChangedForNotification = true
|
||||
if ipv6 != "" {
|
||||
d.ipToKey[ipv6] = peerPubKey
|
||||
}
|
||||
if ip != "" {
|
||||
d.ipToKey[ip] = peerPubKey
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -334,28 +343,22 @@ func (d *Status) PeerByIP(ip string) (string, bool) {
|
||||
|
||||
// PeerStateByIP returns the full peer State for the given tunnel IP.
|
||||
// Matches against either the IPv4 (State.IP) or IPv6 (State.IPv6) tunnel
|
||||
// address so dual-stack peers are reachable on either family. Searches
|
||||
// both d.peers and d.offlinePeers — peers that have been moved into
|
||||
// the offline slice by ReplaceOfflinePeers are still part of the
|
||||
// account's roster and callers (DNS filter, embed.Client.IdentityForIP)
|
||||
// need to recognise them rather than treating them as unknown. Returns
|
||||
// the zero State and false when no peer matches or the input is empty.
|
||||
// address so dual-stack peers are reachable on either family. Only
|
||||
// active peers are matched; peers moved into the offline slice by
|
||||
// ReplaceOfflinePeers are intentionally treated as unknown.
|
||||
func (d *Status) PeerStateByIP(ip string) (State, bool) {
|
||||
if ip == "" {
|
||||
return State{}, false
|
||||
}
|
||||
d.mux.RLock()
|
||||
defer d.mux.RUnlock()
|
||||
|
||||
for _, state := range d.peers {
|
||||
if (state.IP != "" && state.IP == ip) || (state.IPv6 != "" && state.IPv6 == ip) {
|
||||
return state, true
|
||||
}
|
||||
key, ok := d.ipToKey[ip]
|
||||
if !ok {
|
||||
return State{}, false
|
||||
}
|
||||
for _, state := range d.offlinePeers {
|
||||
if (state.IP != "" && state.IP == ip) || (state.IPv6 != "" && state.IPv6 == ip) {
|
||||
return state, true
|
||||
}
|
||||
state, ok := d.peers[key]
|
||||
if ok {
|
||||
return state, true
|
||||
}
|
||||
return State{}, false
|
||||
}
|
||||
@@ -365,12 +368,18 @@ func (d *Status) RemovePeer(peerPubKey string) error {
|
||||
d.mux.Lock()
|
||||
defer d.mux.Unlock()
|
||||
|
||||
_, ok := d.peers[peerPubKey]
|
||||
p, ok := d.peers[peerPubKey]
|
||||
if !ok {
|
||||
return errors.New("no peer with to remove")
|
||||
}
|
||||
|
||||
delete(d.peers, peerPubKey)
|
||||
if mappedKey, exists := d.ipToKey[p.IP]; exists && mappedKey == peerPubKey {
|
||||
delete(d.ipToKey, p.IP)
|
||||
}
|
||||
if mappedKey, exists := d.ipToKey[p.IPv6]; exists && mappedKey == peerPubKey {
|
||||
delete(d.ipToKey, p.IPv6)
|
||||
}
|
||||
d.peerListChangedForNotification = true
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -90,12 +90,11 @@ func TestStatus_PeerStateByIP_MatchesIPv6(t *testing.T) {
|
||||
req.Equal("pk-1", state.PubKey, "matching state must carry the right pub key")
|
||||
}
|
||||
|
||||
// TestStatus_PeerStateByIP_MatchesOfflinePeers covers peers that have
|
||||
// been moved into the offline slice via ReplaceOfflinePeers. Callers
|
||||
// (DNS filter, embed.Client.IdentityForIP) need to treat them as known
|
||||
// rather than unknown — otherwise authentication / DNS filtering treats
|
||||
// known-but-offline peers as foreign IPs.
|
||||
func TestStatus_PeerStateByIP_MatchesOfflinePeers(t *testing.T) {
|
||||
// TestStatus_PeerStateByIP_IgnoresOfflinePeers documents that peers
|
||||
// moved into the offline slice via ReplaceOfflinePeers are intentionally
|
||||
// not resolvable by IP: only active peers can carry traffic, so callers
|
||||
// (DNS filter, embed.Client.IdentityForIP) treat them as unknown.
|
||||
func TestStatus_PeerStateByIP_IgnoresOfflinePeers(t *testing.T) {
|
||||
status := NewRecorder("https://mgm")
|
||||
req := require.New(t)
|
||||
|
||||
@@ -103,13 +102,31 @@ func TestStatus_PeerStateByIP_MatchesOfflinePeers(t *testing.T) {
|
||||
{PubKey: "pk-offline", FQDN: "offline.netbird", IP: "100.64.0.20", IPv6: "fd00::20"},
|
||||
})
|
||||
|
||||
state, ok := status.PeerStateByIP("100.64.0.20")
|
||||
req.True(ok, "offline peer must resolve by IPv4 tunnel address")
|
||||
req.Equal("pk-offline", state.PubKey, "matching state must carry the offline peer's pub key")
|
||||
_, ok := status.PeerStateByIP("100.64.0.20")
|
||||
req.False(ok, "offline peer must not resolve by IPv4 tunnel address")
|
||||
|
||||
state, ok = status.PeerStateByIP("fd00::20")
|
||||
req.True(ok, "offline peer must resolve by IPv6 tunnel address")
|
||||
req.Equal("pk-offline", state.PubKey, "IPv6 match must carry the offline peer's pub key")
|
||||
_, ok = status.PeerStateByIP("fd00::20")
|
||||
req.False(ok, "offline peer must not resolve by IPv6 tunnel address")
|
||||
}
|
||||
|
||||
// TestStatus_PeerStateByIP_RemovedPeer verifies RemovePeer drops the
|
||||
// IP index entries for both address families.
|
||||
func TestStatus_PeerStateByIP_RemovedPeer(t *testing.T) {
|
||||
status := NewRecorder("https://mgm")
|
||||
req := require.New(t)
|
||||
|
||||
req.NoError(status.AddPeer("pk-1", "peer-1.netbird", "100.64.0.10", "fd00::1"))
|
||||
|
||||
_, ok := status.PeerStateByIP("100.64.0.10")
|
||||
req.True(ok, "active peer must resolve before removal")
|
||||
|
||||
req.NoError(status.RemovePeer("pk-1"))
|
||||
|
||||
_, ok = status.PeerStateByIP("100.64.0.10")
|
||||
req.False(ok, "removed peer must not resolve by IPv4 tunnel address")
|
||||
|
||||
_, ok = status.PeerStateByIP("fd00::1")
|
||||
req.False(ok, "removed peer must not resolve by IPv6 tunnel address")
|
||||
}
|
||||
|
||||
func TestStatus_UpdatePeerFQDN(t *testing.T) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface"
|
||||
"github.com/netbirdio/netbird/client/internal/routemanager/dynamic"
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
"github.com/netbirdio/netbird/client/ssh"
|
||||
mgm "github.com/netbirdio/netbird/shared/management/client"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
@@ -57,6 +58,10 @@ var DefaultInterfaceBlacklist = []string{
|
||||
"Tailscale", "tailscale", "docker", "veth", "br-", "lo",
|
||||
}
|
||||
|
||||
// loadMDMPolicy is the package-level indirection used by apply() to read the
|
||||
// active MDM policy. Tests override this to inject a fake policy.
|
||||
var loadMDMPolicy = mdm.LoadPolicy
|
||||
|
||||
// ConfigInput carries configuration changes to the client
|
||||
type ConfigInput struct {
|
||||
ManagementURL string
|
||||
@@ -174,6 +179,23 @@ type Config struct {
|
||||
LazyConnectionEnabled bool
|
||||
|
||||
MTU uint16
|
||||
|
||||
// policy is the MDM policy that produced the currently-set values for
|
||||
// any MDM-enforced fields. Set by applyMDMPolicy at the tail of apply()
|
||||
// and reset on every apply() invocation. Never persisted to disk.
|
||||
// Callers query enforcement state via Policy() and the mdm.Policy API
|
||||
// (HasKey, ManagedKeys, IsEmpty).
|
||||
policy *mdm.Policy `json:"-"`
|
||||
}
|
||||
|
||||
// Policy returns the MDM policy applied to this Config. Returns a non-nil
|
||||
// empty Policy when MDM enforcement is inactive; callers can always invoke
|
||||
// HasKey / ManagedKeys / IsEmpty without a nil check.
|
||||
func (config *Config) Policy() *mdm.Policy {
|
||||
if config == nil || config.policy == nil {
|
||||
return mdm.NewPolicy(nil)
|
||||
}
|
||||
return config.policy
|
||||
}
|
||||
|
||||
var ConfigDirOverride string
|
||||
@@ -612,10 +634,93 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
updated = true
|
||||
}
|
||||
|
||||
// MDM is the last override layer: any key present in the policy
|
||||
// supersedes defaults, on-disk config, env vars and CLI input.
|
||||
config.applyMDMPolicy(loadMDMPolicy())
|
||||
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// parseURL parses and validates a service URL
|
||||
// applyMDMPolicy overlays MDM-supplied values on top of the resolved Config.
|
||||
// The provided Policy is also stored on the Config so callers can later query
|
||||
// which fields are enforced. Invalid values (e.g. malformed URLs) are logged
|
||||
// and skipped to avoid bricking the client; the field keeps its previous
|
||||
// resolved value but is still marked as managed (Policy.HasKey returns true
|
||||
// for the key, so per-field rejection of user writes still applies).
|
||||
func (config *Config) applyMDMPolicy(policy *mdm.Policy) {
|
||||
config.policy = policy
|
||||
if policy.IsEmpty() {
|
||||
return
|
||||
}
|
||||
|
||||
// Helper: log the application of a single MDM-managed key. Values for
|
||||
// keys in mdm.SecretKeys are redacted.
|
||||
logApplied := func(key string, displayValue any) {
|
||||
if _, secret := mdm.SecretKeys[key]; secret {
|
||||
log.Infof("MDM override %s = ********** (secret)", key)
|
||||
return
|
||||
}
|
||||
log.Infof("MDM override %s = %v", key, displayValue)
|
||||
}
|
||||
|
||||
if v, ok := policy.GetString(mdm.KeyManagementURL); ok {
|
||||
if u, err := parseURL("Management URL", v); err != nil {
|
||||
log.Warnf("MDM management URL %q invalid: %v; keeping previous value", v, err)
|
||||
} else {
|
||||
config.ManagementURL = u
|
||||
logApplied(mdm.KeyManagementURL, u.String())
|
||||
}
|
||||
}
|
||||
|
||||
if v, ok := policy.GetString(mdm.KeyPreSharedKey); ok {
|
||||
// Defensive: refuse the redaction mask in case it round-tripped
|
||||
// through a manifest by mistake.
|
||||
if !isPreSharedKeyHidden(&v) {
|
||||
config.PreSharedKey = v
|
||||
logApplied(mdm.KeyPreSharedKey, "")
|
||||
}
|
||||
}
|
||||
|
||||
// applyBool collapses the per-key "read + set + log" boilerplate
|
||||
// for every plain bool MDM key into a single helper. Keeps the
|
||||
// outer function's cognitive complexity below SonarCube's
|
||||
// threshold; functional behaviour is identical to the inlined
|
||||
// branches it replaces.
|
||||
applyBool := func(key string, setter func(bool)) {
|
||||
v, ok := policy.GetBool(key)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
setter(v)
|
||||
logApplied(key, v)
|
||||
}
|
||||
|
||||
applyBool(mdm.KeyAllowServerSSH, func(v bool) { bv := v; config.ServerSSHAllowed = &bv })
|
||||
applyBool(mdm.KeyDisableClientRoutes, func(v bool) { config.DisableClientRoutes = v })
|
||||
applyBool(mdm.KeyDisableServerRoutes, func(v bool) { config.DisableServerRoutes = v })
|
||||
applyBool(mdm.KeyBlockInbound, func(v bool) { config.BlockInbound = v })
|
||||
applyBool(mdm.KeyDisableAutoConnect, func(v bool) { config.DisableAutoConnect = v })
|
||||
applyBool(mdm.KeyRosenpassEnabled, func(v bool) { config.RosenpassEnabled = v })
|
||||
applyBool(mdm.KeyRosenpassPermissive, func(v bool) { config.RosenpassPermissive = v })
|
||||
|
||||
if v, ok := policy.GetInt(mdm.KeyWireguardPort); ok {
|
||||
// REG_DWORD is 32-bit; UDP port range is 1-65535. Clamp at the
|
||||
// upper bound and reject obviously-invalid values to avoid the
|
||||
// engine binding to an unusable port if the admin pushes garbage.
|
||||
if v >= 1 && v <= 65535 {
|
||||
config.WgPort = int(v)
|
||||
logApplied(mdm.KeyWireguardPort, v)
|
||||
} else {
|
||||
log.Warnf("MDM wireguard port %d out of range [1,65535]; keeping previous value", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseURL parses and validates the URL for the named service. The URL
|
||||
// must use the http or https scheme; if no port is present, ":443" is
|
||||
// appended for https or ":80" for http. The serviceName parameter is
|
||||
// used to contextualise error messages. On success returns the parsed
|
||||
// *url.URL; on failure returns a non-nil error.
|
||||
func parseURL(serviceName, serviceURL string) (*url.URL, error) {
|
||||
parsedMgmtURL, err := url.ParseRequestURI(serviceURL)
|
||||
if err != nil {
|
||||
|
||||
152
client/internal/profilemanager/config_mdm_test.go
Normal file
152
client/internal/profilemanager/config_mdm_test.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package profilemanager
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
)
|
||||
|
||||
// withMDMPolicy temporarily overrides the package-level loadMDMPolicy hook so
|
||||
// apply() observes the supplied Policy. The original loader is restored at
|
||||
// test cleanup.
|
||||
func withMDMPolicy(t *testing.T, policy *mdm.Policy) {
|
||||
t.Helper()
|
||||
prev := loadMDMPolicy
|
||||
loadMDMPolicy = func() *mdm.Policy { return policy }
|
||||
t.Cleanup(func() { loadMDMPolicy = prev })
|
||||
}
|
||||
|
||||
func TestApply_MDMEmpty_NoEnforcement(t *testing.T) {
|
||||
withMDMPolicy(t, mdm.NewPolicy(nil))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
assert.True(t, cfg.Policy().IsEmpty(), "no MDM source ⇒ empty Policy")
|
||||
assert.False(t, cfg.Policy().HasKey(mdm.KeyManagementURL))
|
||||
assert.Empty(t, cfg.Policy().ManagedKeys())
|
||||
|
||||
// Default management URL still resolves.
|
||||
assert.Equal(t, DefaultManagementURL, cfg.ManagementURL.String())
|
||||
}
|
||||
|
||||
func TestApply_MDMOnly_OverridesDefaults(t *testing.T) {
|
||||
const mdmURL = "https://corp.mdm.example.com:443"
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyManagementURL: mdmURL,
|
||||
mdm.KeyDisableClientRoutes: true,
|
||||
mdm.KeyBlockInbound: true,
|
||||
}))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
assert.Equal(t, mdmURL, cfg.ManagementURL.String())
|
||||
assert.True(t, cfg.DisableClientRoutes)
|
||||
assert.True(t, cfg.BlockInbound)
|
||||
|
||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyManagementURL))
|
||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyDisableClientRoutes))
|
||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyBlockInbound))
|
||||
assert.False(t, cfg.Policy().HasKey(mdm.KeyAllowServerSSH))
|
||||
}
|
||||
|
||||
func TestApply_MDMBeatsCLIInput(t *testing.T) {
|
||||
const mdmURL = "https://mdm.example.com:443"
|
||||
const cliURL = "https://cli.example.com:443"
|
||||
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyManagementURL: mdmURL,
|
||||
}))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
ManagementURL: cliURL,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
// MDM wins over CLI-supplied management URL.
|
||||
assert.Equal(t, mdmURL, cfg.ManagementURL.String())
|
||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyManagementURL))
|
||||
}
|
||||
|
||||
func TestApply_MDMInvalidURL_KeepsPreviousValue(t *testing.T) {
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyManagementURL: "not-a-url",
|
||||
}))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
// Invalid MDM URL is logged and skipped: default URL stays in place
|
||||
// to keep the client functional.
|
||||
assert.Equal(t, DefaultManagementURL, cfg.ManagementURL.String())
|
||||
|
||||
// But the key is still considered MDM-managed (admin intent is to
|
||||
// enforce, daemon rejects user writes to this field — phase-1 scaffolding
|
||||
// reflects this by keeping Policy.HasKey true even on parse failure).
|
||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyManagementURL))
|
||||
}
|
||||
|
||||
func TestApply_MDMBoolKeysOverrideOnDiskValue(t *testing.T) {
|
||||
tmp := filepath.Join(t.TempDir(), "config.json")
|
||||
|
||||
// Seed without MDM.
|
||||
withMDMPolicy(t, mdm.NewPolicy(nil))
|
||||
_, err := UpdateOrCreateConfig(ConfigInput{
|
||||
ConfigPath: tmp,
|
||||
DisableClientRoutes: boolPtr(false),
|
||||
RosenpassEnabled: boolPtr(false),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Now enable MDM enforcement for these keys.
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyDisableClientRoutes: true,
|
||||
mdm.KeyRosenpassEnabled: true,
|
||||
}))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: tmp})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
assert.True(t, cfg.DisableClientRoutes, "MDM override should flip on-disk false to true")
|
||||
assert.True(t, cfg.RosenpassEnabled)
|
||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyDisableClientRoutes))
|
||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyRosenpassEnabled))
|
||||
}
|
||||
|
||||
func TestApply_MDMPreSharedKeyRedactionSentinelRejected(t *testing.T) {
|
||||
const maskSentinel = "**********"
|
||||
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyPreSharedKey: maskSentinel,
|
||||
}))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
// Mask sentinel must not be persisted as the actual PSK.
|
||||
assert.NotEqual(t, maskSentinel, cfg.PreSharedKey)
|
||||
// Key still marked managed so user writes are still rejected.
|
||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyPreSharedKey))
|
||||
}
|
||||
|
||||
func boolPtr(b bool) *bool { return &b }
|
||||
50
client/mdm/canonical_loaders.go
Normal file
50
client/mdm/canonical_loaders.go
Normal file
@@ -0,0 +1,50 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package mdm
|
||||
|
||||
import "strings"
|
||||
|
||||
// allKeys is the set of recognised MDM keys. Unknown keys in a managed
|
||||
// configuration are ignored but logged. Lives in this build-tagged file
|
||||
// (windows || darwin) because only desktop loaders need the
|
||||
// canonicalisation table that consumes it; including it unconditionally
|
||||
// would trigger the `unused` golangci-lint check on platforms that
|
||||
// don't import canonical_loaders.go.
|
||||
var allKeys = []string{
|
||||
KeyManagementURL,
|
||||
KeyDisableUpdateSettings,
|
||||
KeyDisableProfiles,
|
||||
KeyDisableNetworks,
|
||||
KeyDisableClientRoutes,
|
||||
KeyDisableServerRoutes,
|
||||
KeyBlockInbound,
|
||||
KeyDisableMetricsCollection,
|
||||
KeyAllowServerSSH,
|
||||
KeyDisableAutoConnect,
|
||||
KeyPreSharedKey,
|
||||
KeyRosenpassEnabled,
|
||||
KeyRosenpassPermissive,
|
||||
KeyWireguardPort,
|
||||
KeySplitTunnelMode,
|
||||
KeySplitTunnelApps,
|
||||
}
|
||||
|
||||
// canonicalKey maps the lowercase form of a managed-config value name to
|
||||
// its canonical mdm.Key* form. Admins commonly write PascalCase value
|
||||
// names in ADMX / Group Policy ("ManagementURL"); the iOS/AppConfig and
|
||||
// macOS plist conventions are camelCase ("managementURL"); both must
|
||||
// resolve to the same Policy lookup.
|
||||
//
|
||||
// Lives in a desktop-loader-only file (build tag `windows || darwin`)
|
||||
// because no other build path consumes it. Linux / FreeBSD / mobile
|
||||
// builds don't ship a platform loader that reads arbitrary-case key
|
||||
// names, so they don't need the canonicalisation table — and including
|
||||
// the var unconditionally would trigger the `unused` golangci-lint
|
||||
// check on those platforms.
|
||||
var canonicalKey = func() map[string]string {
|
||||
m := make(map[string]string, len(allKeys))
|
||||
for _, k := range allKeys {
|
||||
m[strings.ToLower(k)] = k
|
||||
}
|
||||
return m
|
||||
}()
|
||||
247
client/mdm/policy.go
Normal file
247
client/mdm/policy.go
Normal file
@@ -0,0 +1,247 @@
|
||||
// Package mdm reads MDM-managed configuration from platform-native sources
|
||||
// (plist on macOS, registry on Windows, UserDefaults on iOS,
|
||||
// RestrictionsManager on Android). The returned Policy is consumed by
|
||||
// profilemanager.Config.apply() as the highest-priority override layer.
|
||||
//
|
||||
// An empty Policy (no source present, or source present with zero keys)
|
||||
// means no MDM enforcement is active and the client behaves as if the
|
||||
// feature did not exist.
|
||||
package mdm
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Well-known policy keys. Names mirror the corresponding ConfigInput Go field
|
||||
// names (lowerCamelCase) so the daemon can map a Policy key directly to a
|
||||
// configuration field.
|
||||
const (
|
||||
KeyManagementURL = "managementURL"
|
||||
KeyDisableUpdateSettings = "disableUpdateSettings"
|
||||
KeyDisableProfiles = "disableProfiles"
|
||||
KeyDisableNetworks = "disableNetworks"
|
||||
KeyDisableClientRoutes = "disableClientRoutes"
|
||||
KeyDisableServerRoutes = "disableServerRoutes"
|
||||
KeyBlockInbound = "blockInbound"
|
||||
KeyDisableMetricsCollection = "disableMetricsCollection"
|
||||
KeyAllowServerSSH = "allowServerSSH"
|
||||
KeyDisableAutoConnect = "disableAutoConnect"
|
||||
KeyPreSharedKey = "preSharedKey"
|
||||
KeyRosenpassEnabled = "rosenpassEnabled"
|
||||
KeyRosenpassPermissive = "rosenpassPermissive"
|
||||
KeyWireguardPort = "wireguardPort"
|
||||
|
||||
// Split tunnel is modeled as a single conceptual policy with two
|
||||
// registry/plist values. KeySplitTunnelMode is the discriminator
|
||||
// ("allow" or "disallow"); KeySplitTunnelApps is a comma-separated
|
||||
// list of package names. The values are mutually exclusive by
|
||||
// construction — only one mode can be set at a time.
|
||||
KeySplitTunnelMode = "splitTunnelMode"
|
||||
KeySplitTunnelApps = "splitTunnelApps"
|
||||
)
|
||||
|
||||
// Split-tunnel mode literals (KeySplitTunnelMode values).
|
||||
const (
|
||||
SplitTunnelModeAllow = "allow"
|
||||
SplitTunnelModeDisallow = "disallow"
|
||||
)
|
||||
|
||||
// SecretKeys lists keys whose values must be redacted in logs.
|
||||
var SecretKeys = map[string]struct{}{
|
||||
KeyPreSharedKey: {},
|
||||
}
|
||||
|
||||
// boolStringLiterals enumerates the textual boolean encodings the
|
||||
// platform loaders may produce (Windows REG_SZ "true", iOS / Android
|
||||
// managed-config booleans-as-strings, etc.). Lookup keeps GetBool flat
|
||||
// (no nested switch on the string case).
|
||||
var boolStringLiterals = map[string]bool{
|
||||
"true": true,
|
||||
"1": true,
|
||||
"yes": true,
|
||||
"false": false,
|
||||
"0": false,
|
||||
"no": false,
|
||||
}
|
||||
|
||||
|
||||
// Policy holds MDM-managed settings read from the platform source. A nil or
|
||||
// empty Policy means no enforcement is active.
|
||||
type Policy struct {
|
||||
values map[string]any
|
||||
}
|
||||
|
||||
// NewPolicy constructs a Policy from a key→value map. Pass nil or an
|
||||
// empty map to construct an empty (no-enforcement) Policy. The returned
|
||||
// *Policy is always non-nil.
|
||||
func NewPolicy(values map[string]any) *Policy {
|
||||
if values == nil {
|
||||
values = map[string]any{}
|
||||
}
|
||||
return &Policy{values: values}
|
||||
}
|
||||
|
||||
// LoadPolicy reads the platform-native MDM configuration. Returns an
|
||||
// empty (but non-nil) Policy when no source is present, the source is
|
||||
// empty, or the platform is unsupported.
|
||||
//
|
||||
// Diagnostic logging differentiates the three states:
|
||||
// - source absent / unsupported platform: trace log only
|
||||
// - source present, zero keys: info "MDM enrolled (no managed keys)"
|
||||
// - source present, N keys: info "MDM enrolled with N managed keys: [...]"
|
||||
func LoadPolicy() *Policy {
|
||||
values, err := loadPlatformPolicy()
|
||||
if err != nil {
|
||||
log.Tracef("MDM policy load: %v", err)
|
||||
return &Policy{values: map[string]any{}}
|
||||
}
|
||||
if values == nil {
|
||||
return &Policy{values: map[string]any{}}
|
||||
}
|
||||
if len(values) == 0 {
|
||||
log.Info("MDM enrolled (no managed keys)")
|
||||
} else {
|
||||
log.Infof("MDM enrolled with %d managed key(s): %v", len(values), sortedKeys(values))
|
||||
}
|
||||
return &Policy{values: values}
|
||||
}
|
||||
|
||||
// IsEmpty reports whether the Policy has no managed keys.
|
||||
func (p *Policy) IsEmpty() bool {
|
||||
return p == nil || len(p.values) == 0
|
||||
}
|
||||
|
||||
// HasKey reports whether the given key is MDM-managed.
|
||||
func (p *Policy) HasKey(key string) bool {
|
||||
if p == nil {
|
||||
return false
|
||||
}
|
||||
_, ok := p.values[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ManagedKeys returns the sorted list of managed key names. Returns an empty
|
||||
// slice (not nil) on an empty Policy.
|
||||
func (p *Policy) ManagedKeys() []string {
|
||||
if p == nil {
|
||||
return []string{}
|
||||
}
|
||||
return sortedKeys(p.values)
|
||||
}
|
||||
|
||||
// GetString returns the managed value for key coerced to string, and whether
|
||||
// the key was set. A non-string value returns ("", false).
|
||||
func (p *Policy) GetString(key string) (string, bool) {
|
||||
if p == nil {
|
||||
return "", false
|
||||
}
|
||||
v, ok := p.values[key]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok || s == "" {
|
||||
return "", false
|
||||
}
|
||||
return s, true
|
||||
}
|
||||
|
||||
// GetBool returns the managed value for key coerced to bool, and whether the
|
||||
// key was set. Accepts native bool and string literals "true"/"false"/"1"/"0".
|
||||
func (p *Policy) GetBool(key string) (bool, bool) {
|
||||
if p == nil {
|
||||
return false, false
|
||||
}
|
||||
v, ok := p.values[key]
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case bool:
|
||||
return t, true
|
||||
case string:
|
||||
b, known := boolStringLiterals[t]
|
||||
return b, known
|
||||
case int:
|
||||
return t != 0, true
|
||||
case int64:
|
||||
return t != 0, true
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
// GetInt returns the managed value for key as int64, and whether the key
|
||||
// was set. Accepts native int / int64 (as produced by the Windows registry
|
||||
// loader for REG_DWORD/REG_QWORD) and numeric strings (decimal).
|
||||
func (p *Policy) GetInt(key string) (int64, bool) {
|
||||
if p == nil {
|
||||
return 0, false
|
||||
}
|
||||
v, ok := p.values[key]
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case int64:
|
||||
return t, true
|
||||
case int:
|
||||
return int64(t), true
|
||||
case int32:
|
||||
return int64(t), true
|
||||
case uint64:
|
||||
return int64(t), true
|
||||
case float64:
|
||||
return int64(t), true
|
||||
case string:
|
||||
if n, err := strconv.ParseInt(t, 10, 64); err == nil {
|
||||
return n, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// GetStringSlice returns the managed value for key as []string, and whether
|
||||
// the key was set. Accepts []string, []any (of strings), and a single string
|
||||
// (treated as a one-element list).
|
||||
func (p *Policy) GetStringSlice(key string) ([]string, bool) {
|
||||
if p == nil {
|
||||
return nil, false
|
||||
}
|
||||
v, ok := p.values[key]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case []string:
|
||||
return append([]string(nil), t...), true
|
||||
case []any:
|
||||
out := make([]string, 0, len(t))
|
||||
for _, item := range t {
|
||||
s, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, true
|
||||
case string:
|
||||
return []string{t}, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// sortedKeys returns the keys of m as a deterministic, lexicographically
|
||||
// sorted slice. Used internally by Policy.ManagedKeys and LoadPolicy's
|
||||
// diagnostic log line so callers see a stable key order across runs
|
||||
// regardless of Go's randomised map iteration.
|
||||
func sortedKeys(m map[string]any) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
90
client/mdm/policy_darwin.go
Normal file
90
client/mdm/policy_darwin.go
Normal file
@@ -0,0 +1,90 @@
|
||||
//go:build darwin && !ios
|
||||
|
||||
package mdm
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"howett.net/plist"
|
||||
)
|
||||
|
||||
// policyPlistPath is the well-known location where macOS writes the
|
||||
// device-level mandatory MDM payload for NetBird. The path is fixed by
|
||||
// Apple convention: when an MDM provider (Jamf / Kandji / Mosyle /
|
||||
// Intune for Mac / Workspace ONE) pushes a Configuration Profile that
|
||||
// contains a com.apple.ManagedClient.preferences payload targeting the
|
||||
// bundle id io.netbird.client, the OS materializes the payload here.
|
||||
//
|
||||
// Read-only — only the OS (root) is supposed to write this file. The
|
||||
// loader sanity-checks the file mode and refuses to honour a world-
|
||||
// writable plist, as a defense against tampered installs.
|
||||
const policyPlistPath = "/Library/Managed Preferences/io.netbird.client.plist"
|
||||
|
||||
// loadPlatformPolicy reads the MDM-managed configuration from the macOS
|
||||
// managed-preferences plist at policyPlistPath. Returns:
|
||||
// - (nil, nil) when the plist is absent (device not MDM-enrolled for
|
||||
// NetBird, or admin has not yet pushed a payload)
|
||||
// - (map, nil) with N entries when N managed values are present
|
||||
// (N may be 0 — empty plist still signals enrollment to the caller)
|
||||
// - (nil, err) on permission / parse / safety errors (including
|
||||
// refusal to read a world-writable plist)
|
||||
//
|
||||
// Top-level plist keys are canonicalised case-insensitively to the
|
||||
// package's internal mdm.Key* names; unknown keys are logged and
|
||||
// skipped so a stray entry in the payload does not block startup.
|
||||
// Native plist value types map naturally onto the Policy accessor
|
||||
// expectations (GetString / GetBool / GetInt / GetStringSlice).
|
||||
func loadPlatformPolicy() (map[string]any, error) {
|
||||
f, err := os.Open(policyPlistPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
// Not enrolled for NetBird. Caller treats nil as
|
||||
// "no MDM source present".
|
||||
//nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy.
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("open %s: %w", policyPlistPath, err)
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := f.Close(); closeErr != nil {
|
||||
log.Warnf("MDM close plist %s: %v", policyPlistPath, closeErr)
|
||||
}
|
||||
}()
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat %s: %w", policyPlistPath, err)
|
||||
}
|
||||
// World-writable plist => tampered install. Refuse rather than
|
||||
// honour potentially attacker-controlled policy values.
|
||||
if info.Mode().Perm()&0o002 != 0 {
|
||||
return nil, fmt.Errorf("refusing to read world-writable MDM source %s (mode %o)",
|
||||
policyPlistPath, info.Mode().Perm())
|
||||
}
|
||||
|
||||
raw := make(map[string]any)
|
||||
if err := plist.NewDecoder(f).Decode(&raw); err != nil {
|
||||
return nil, fmt.Errorf("decode plist %s: %w", policyPlistPath, err)
|
||||
}
|
||||
|
||||
out := make(map[string]any, len(raw))
|
||||
for name, val := range raw {
|
||||
// macOS / AppConfig conventions both use camelCase for managed
|
||||
// preferences keys; canonicalize to the mdm.Key* form so a key
|
||||
// written as "ManagementURL" (PascalCase, rare on macOS but
|
||||
// possible if the admin reused an ADMX-style name) still
|
||||
// resolves.
|
||||
canonical, known := canonicalKey[strings.ToLower(name)]
|
||||
if !known {
|
||||
log.Warnf("MDM ignoring unknown plist key %s: %s", policyPlistPath, name)
|
||||
continue
|
||||
}
|
||||
out[canonical] = val
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
14
client/mdm/policy_mobile.go
Normal file
14
client/mdm/policy_mobile.go
Normal file
@@ -0,0 +1,14 @@
|
||||
//go:build ios || android
|
||||
|
||||
package mdm
|
||||
|
||||
// loadPlatformPolicy is unused on mobile: the native layer (Swift on iOS,
|
||||
// Kotlin/Java on Android) reads the OS managed-config store and pushes the
|
||||
// resulting dictionary in-process via a gomobile entry point that lands in
|
||||
// Phase 5 / Phase 6. The stub keeps the package compilable for mobile
|
||||
// builds and returns (nil, nil) — the platform-absent sentinel that
|
||||
// LoadPolicy in policy.go treats as "no MDM source present".
|
||||
func loadPlatformPolicy() (map[string]any, error) {
|
||||
//nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy.
|
||||
return nil, nil
|
||||
}
|
||||
14
client/mdm/policy_other.go
Normal file
14
client/mdm/policy_other.go
Normal file
@@ -0,0 +1,14 @@
|
||||
//go:build !windows && !darwin && !ios && !android
|
||||
|
||||
package mdm
|
||||
|
||||
// loadPlatformPolicy returns no policy on platforms without an MDM channel
|
||||
// (Linux, FreeBSD). MDM enforcement is off and the client behaves as if
|
||||
// the feature did not exist. Returns (nil, nil) — the platform-absent
|
||||
// sentinel the caller (LoadPolicy in policy.go) treats as "no MDM
|
||||
// source present"; an error here would just translate to the same
|
||||
// outcome with an extra log line.
|
||||
func loadPlatformPolicy() (map[string]any, error) {
|
||||
//nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy.
|
||||
return nil, nil
|
||||
}
|
||||
160
client/mdm/policy_test.go
Normal file
160
client/mdm/policy_test.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package mdm
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPolicy_NilSafe(t *testing.T) {
|
||||
var p *Policy
|
||||
assert.True(t, p.IsEmpty())
|
||||
assert.False(t, p.HasKey(KeyManagementURL))
|
||||
assert.Empty(t, p.ManagedKeys())
|
||||
|
||||
_, ok := p.GetString(KeyManagementURL)
|
||||
assert.False(t, ok)
|
||||
_, ok = p.GetBool(KeyDisableProfiles)
|
||||
assert.False(t, ok)
|
||||
_, ok = p.GetStringSlice(KeySplitTunnelApps)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestPolicy_Empty(t *testing.T) {
|
||||
p := NewPolicy(nil)
|
||||
require.NotNil(t, p)
|
||||
assert.True(t, p.IsEmpty())
|
||||
assert.False(t, p.HasKey(KeyManagementURL))
|
||||
assert.Empty(t, p.ManagedKeys())
|
||||
}
|
||||
|
||||
func TestPolicy_HasKey(t *testing.T) {
|
||||
p := NewPolicy(map[string]any{
|
||||
KeyManagementURL: "https://corp.example.com",
|
||||
KeyDisableProfiles: true,
|
||||
})
|
||||
assert.False(t, p.IsEmpty())
|
||||
assert.True(t, p.HasKey(KeyManagementURL))
|
||||
assert.True(t, p.HasKey(KeyDisableProfiles))
|
||||
assert.False(t, p.HasKey(KeyPreSharedKey))
|
||||
}
|
||||
|
||||
func TestPolicy_ManagedKeysSorted(t *testing.T) {
|
||||
p := NewPolicy(map[string]any{
|
||||
KeyDisableProfiles: true,
|
||||
KeyManagementURL: "https://x",
|
||||
KeyAllowServerSSH: false,
|
||||
})
|
||||
got := p.ManagedKeys()
|
||||
assert.Equal(t, []string{KeyAllowServerSSH, KeyDisableProfiles, KeyManagementURL}, got)
|
||||
}
|
||||
|
||||
func TestPolicy_GetString(t *testing.T) {
|
||||
p := NewPolicy(map[string]any{
|
||||
KeyManagementURL: "https://corp.example.com",
|
||||
KeyDisableProfiles: true, // wrong type for GetString
|
||||
KeyPreSharedKey: "", // empty rejected
|
||||
})
|
||||
v, ok := p.GetString(KeyManagementURL)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "https://corp.example.com", v)
|
||||
|
||||
_, ok = p.GetString(KeyDisableProfiles)
|
||||
assert.False(t, ok, "non-string value must not be reported as string")
|
||||
|
||||
_, ok = p.GetString(KeyPreSharedKey)
|
||||
assert.False(t, ok, "empty string treated as unset")
|
||||
|
||||
_, ok = p.GetString("nonexistent")
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestPolicy_GetBool(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw any
|
||||
want bool
|
||||
ok bool
|
||||
}{
|
||||
{"native true", true, true, true},
|
||||
{"native false", false, false, true},
|
||||
{"string true", "true", true, true},
|
||||
{"string false", "false", false, true},
|
||||
{"string 1", "1", true, true},
|
||||
{"string 0", "0", false, true},
|
||||
{"string yes", "yes", true, true},
|
||||
{"string no", "no", false, true},
|
||||
{"int nonzero", 1, true, true},
|
||||
{"int zero", 0, false, true},
|
||||
{"int64 nonzero", int64(2), true, true},
|
||||
{"int64 zero", int64(0), false, true},
|
||||
{"string garbage", "maybe", false, false},
|
||||
{"float unsupported", 1.0, false, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
p := NewPolicy(map[string]any{KeyDisableProfiles: c.raw})
|
||||
got, ok := p.GetBool(KeyDisableProfiles)
|
||||
assert.Equal(t, c.ok, ok)
|
||||
if c.ok {
|
||||
assert.Equal(t, c.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
_, ok := NewPolicy(nil).GetBool(KeyDisableProfiles)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestPolicy_GetStringSlice(t *testing.T) {
|
||||
t.Run("native string slice", func(t *testing.T) {
|
||||
p := NewPolicy(map[string]any{
|
||||
KeySplitTunnelApps: []string{"com.a", "com.b"},
|
||||
})
|
||||
got, ok := p.GetStringSlice(KeySplitTunnelApps)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, []string{"com.a", "com.b"}, got)
|
||||
})
|
||||
|
||||
t.Run("any slice of strings", func(t *testing.T) {
|
||||
p := NewPolicy(map[string]any{
|
||||
KeySplitTunnelApps: []any{"com.a", "com.b"},
|
||||
})
|
||||
got, ok := p.GetStringSlice(KeySplitTunnelApps)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, []string{"com.a", "com.b"}, got)
|
||||
})
|
||||
|
||||
t.Run("single string lifts to one-element slice", func(t *testing.T) {
|
||||
p := NewPolicy(map[string]any{
|
||||
KeySplitTunnelApps: "com.a",
|
||||
})
|
||||
got, ok := p.GetStringSlice(KeySplitTunnelApps)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, []string{"com.a"}, got)
|
||||
})
|
||||
|
||||
t.Run("mixed any slice rejected", func(t *testing.T) {
|
||||
p := NewPolicy(map[string]any{
|
||||
KeySplitTunnelApps: []any{"com.a", 1},
|
||||
})
|
||||
_, ok := p.GetStringSlice(KeySplitTunnelApps)
|
||||
assert.False(t, ok)
|
||||
})
|
||||
|
||||
t.Run("missing key", func(t *testing.T) {
|
||||
p := NewPolicy(nil)
|
||||
_, ok := p.GetStringSlice(KeySplitTunnelApps)
|
||||
assert.False(t, ok)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadPolicy_PlatformStubReturnsEmpty(t *testing.T) {
|
||||
// loadPlatformPolicy is a stub on every OS for Phase 1. LoadPolicy must
|
||||
// degrade gracefully and never return nil.
|
||||
p := LoadPolicy()
|
||||
require.NotNil(t, p)
|
||||
assert.True(t, p.IsEmpty())
|
||||
assert.Empty(t, p.ManagedKeys())
|
||||
}
|
||||
108
client/mdm/policy_windows.go
Normal file
108
client/mdm/policy_windows.go
Normal file
@@ -0,0 +1,108 @@
|
||||
//go:build windows
|
||||
|
||||
package mdm
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
)
|
||||
|
||||
// policyRegistryPath is the well-known MDM policy registry key for NetBird.
|
||||
// Admins push values here through Group Policy, Intune ADMX ingestion, an
|
||||
// Intune custom Registry CSP profile, or `reg add` during MSI deployment.
|
||||
// Listed in the project's docs/mdm/netbird.admx schema.
|
||||
const policyRegistryPath = `Software\Policies\NetBird`
|
||||
|
||||
// readRegistryValue reads a single value under policyRegistryPath and,
|
||||
// on success, stores the type-coerced result in out[canonical]. Type
|
||||
// coercion mirrors loadPlatformPolicy's documented mapping:
|
||||
// - REG_SZ / REG_EXPAND_SZ -> string (REG_EXPAND_SZ is expanded by the API)
|
||||
// - REG_DWORD / REG_QWORD -> int64
|
||||
// - REG_MULTI_SZ -> []string
|
||||
//
|
||||
// Unsupported value types and per-value read failures are logged at
|
||||
// warn level and skipped — one malformed value must not block the
|
||||
// surrounding loop. Extracted from loadPlatformPolicy to keep that
|
||||
// function's cognitive complexity in check.
|
||||
func readRegistryValue(k registry.Key, name, canonical string, out map[string]any) {
|
||||
_, valType, err := k.GetValue(name, nil)
|
||||
if err != nil {
|
||||
log.Warnf("MDM stat %s\\%s: %v", policyRegistryPath, name, err)
|
||||
return
|
||||
}
|
||||
switch valType {
|
||||
case registry.SZ, registry.EXPAND_SZ:
|
||||
if v, _, err := k.GetStringValue(name); err == nil {
|
||||
out[canonical] = v
|
||||
} else {
|
||||
log.Warnf("MDM read string %s\\%s: %v", policyRegistryPath, name, err)
|
||||
}
|
||||
case registry.DWORD, registry.QWORD:
|
||||
if v, _, err := k.GetIntegerValue(name); err == nil {
|
||||
// uint64 from the registry API; Policy.GetBool / GetInt
|
||||
// helpers consume int64, so narrow safely.
|
||||
out[canonical] = int64(v)
|
||||
} else {
|
||||
log.Warnf("MDM read int %s\\%s: %v", policyRegistryPath, name, err)
|
||||
}
|
||||
case registry.MULTI_SZ:
|
||||
if v, _, err := k.GetStringsValue(name); err == nil {
|
||||
out[canonical] = v
|
||||
} else {
|
||||
log.Warnf("MDM read multi-string %s\\%s: %v", policyRegistryPath, name, err)
|
||||
}
|
||||
default:
|
||||
log.Warnf("MDM ignoring unsupported registry value type %d at %s\\%s",
|
||||
valType, policyRegistryPath, name)
|
||||
}
|
||||
}
|
||||
|
||||
// loadPlatformPolicy reads the MDM-managed configuration from the
|
||||
// Windows registry under HKLM\Software\Policies\NetBird. Returns:
|
||||
// - (nil, nil) when the key is absent (device not MDM-enrolled for NetBird)
|
||||
// - (map, nil) with N entries when N managed values are set (N may be 0)
|
||||
// - (nil, err) on open / enumerate registry errors
|
||||
//
|
||||
// Per-value type coercion + skip-on-error is delegated to
|
||||
// readRegistryValue. Unknown value names are logged and skipped so a
|
||||
// malformed deployment does not block startup.
|
||||
func loadPlatformPolicy() (map[string]any, error) {
|
||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE, policyRegistryPath, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
if errors.Is(err, registry.ErrNotExist) {
|
||||
// Not enrolled. Caller treats nil as "no MDM source present".
|
||||
//nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy.
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("open %s: %w", policyRegistryPath, err)
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := k.Close(); closeErr != nil {
|
||||
log.Warnf("MDM close registry key %s: %v", policyRegistryPath, closeErr)
|
||||
}
|
||||
}()
|
||||
|
||||
names, err := k.ReadValueNames(-1)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("enumerate values of %s: %w", policyRegistryPath, err)
|
||||
}
|
||||
|
||||
out := make(map[string]any, len(names))
|
||||
for _, name := range names {
|
||||
// Canonicalize the registry value name against the known MDM key
|
||||
// set so Policy.HasKey lookups (which use the canonical names)
|
||||
// succeed regardless of the casing used by the admin's ADMX or
|
||||
// `reg add` command.
|
||||
canonical, known := canonicalKey[strings.ToLower(name)]
|
||||
if !known {
|
||||
log.Warnf("MDM ignoring unknown registry value %s\\%s", policyRegistryPath, name)
|
||||
continue
|
||||
}
|
||||
readRegistryValue(k, name, canonical, out)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
129
client/mdm/ticker.go
Normal file
129
client/mdm/ticker.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package mdm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// DefaultReloadInterval is the production cadence at which the desktop daemon
|
||||
// re-reads the OS-native MDM policy. Picked to balance responsiveness against
|
||||
// registry/plist I/O overhead. Mobile builds use OS-side notifications
|
||||
// instead, hence anticipating the ticker mechanism entirely.
|
||||
const DefaultReloadInterval = 1 * time.Minute
|
||||
|
||||
// policyLoader is the indirection through which the ticker reads the
|
||||
// OS-native policy, both for the initial observation and on every tick.
|
||||
// Production points it at LoadPolicy; tests in this package override it to
|
||||
// feed a scripted sequence of policies without touching the real OS store.
|
||||
var policyLoader = LoadPolicy
|
||||
|
||||
// Ticker periodically re-reads the OS-native MDM policy via LoadPolicy and
|
||||
// invokes the onChange callback (supplied to Run) whenever the observed
|
||||
// Policy diverges from the last observation (added / removed / changed
|
||||
// keys). Launch with Run from a goroutine; cancel the supplied context
|
||||
// to stop.
|
||||
type Ticker struct {
|
||||
interval time.Duration
|
||||
prev *Policy
|
||||
}
|
||||
|
||||
// NewTicker constructs a Ticker that will re-read the OS-native policy
|
||||
// every reloadInterval once Run is called.
|
||||
// The initial snapshot is populated by calling policyLoader at
|
||||
// construction time so the first tick only fires
|
||||
// onChange when the policy actually changed since boot — without
|
||||
// this baseline the first tick would report every currently-managed
|
||||
// key as "added" and trigger a spurious engine restart.
|
||||
func NewTicker(reloadInterval time.Duration) *Ticker {
|
||||
return &Ticker{
|
||||
interval: reloadInterval,
|
||||
prev: policyLoader(),
|
||||
}
|
||||
}
|
||||
|
||||
// Run blocks until ctx is cancelled, polling the OS-native policy store at
|
||||
// the configured cadence and emitting log lines + onChange callback on
|
||||
// every observed diff. onChange must be non-nil.
|
||||
func (t *Ticker) Run(ctx context.Context, onChange func(prev, curr *Policy) error) {
|
||||
tk := time.NewTicker(t.interval)
|
||||
defer tk.Stop()
|
||||
log.Infof("MDM policy reload ticker started (interval=%s)", t.interval)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Info("MDM policy reload ticker stopped")
|
||||
return
|
||||
case <-tk.C:
|
||||
curr := policyLoader()
|
||||
if policiesEqual(t.prev, curr) {
|
||||
continue
|
||||
}
|
||||
added, removed, changed := diffPolicies(t.prev, curr)
|
||||
log.Infof("MDM policy changed: added=%v removed=%v changed=%v",
|
||||
added, removed, changed)
|
||||
prev := t.prev
|
||||
if err := onChange(prev, curr); err != nil {
|
||||
log.Errorf("MDM policy change handler failed (retrying in 1 minute): %v", err)
|
||||
continue
|
||||
}
|
||||
t.prev = curr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// policiesEqual reports whether two Policy instances carry the same
|
||||
// managed key set with identical values. Nil and empty policies
|
||||
// compare equal; one-nil/one-non-empty compare not equal; otherwise
|
||||
// the underlying values maps are compared with reflect.DeepEqual.
|
||||
func policiesEqual(a, b *Policy) bool {
|
||||
if a.IsEmpty() && b.IsEmpty() {
|
||||
return true
|
||||
}
|
||||
if a == nil || b == nil {
|
||||
return false
|
||||
}
|
||||
return reflect.DeepEqual(a.values, b.values)
|
||||
}
|
||||
|
||||
// diffPolicies returns the keys added in curr, removed from prev, and
|
||||
// whose values changed between prev and curr. Each slice is sorted
|
||||
// lexicographically for stable log output; value differences are
|
||||
// determined with reflect.DeepEqual.
|
||||
func diffPolicies(prev, curr *Policy) (added, removed, changed []string) {
|
||||
prevKVs := mapOf(prev)
|
||||
currKVs := mapOf(curr)
|
||||
for k := range currKVs {
|
||||
if _, ok := prevKVs[k]; !ok {
|
||||
added = append(added, k)
|
||||
} else if !reflect.DeepEqual(prevKVs[k], currKVs[k]) {
|
||||
changed = append(changed, k)
|
||||
}
|
||||
}
|
||||
for k := range prevKVs {
|
||||
if _, ok := currKVs[k]; !ok {
|
||||
removed = append(removed, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(added)
|
||||
sort.Strings(removed)
|
||||
sort.Strings(changed)
|
||||
return added, removed, changed
|
||||
}
|
||||
|
||||
// mapOf returns a (possibly empty, never nil) copy of the underlying
|
||||
// values map of a Policy so callers outside this package can compare
|
||||
// keys/values across the type boundary. Returns an empty map on nil p.
|
||||
func mapOf(p *Policy) map[string]any {
|
||||
if p == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
out := make(map[string]any, len(p.values))
|
||||
for k, v := range p.values {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
100
client/mdm/ticker_test.go
Normal file
100
client/mdm/ticker_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package mdm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// testReloadInterval for speeding up the ticker cadence under `go test`
|
||||
const testReloadInterval = 1 * time.Second
|
||||
|
||||
// withPolicyLoader overrides the package-level policyLoader for the duration
|
||||
// of the test so the ticker observes a scripted policy instead of the real
|
||||
// OS-native store. The original loader is restored on cleanup.
|
||||
func withPolicyLoader(t *testing.T, fn func() *Policy) {
|
||||
t.Helper()
|
||||
prev := policyLoader
|
||||
policyLoader = fn
|
||||
t.Cleanup(func() { policyLoader = prev })
|
||||
}
|
||||
|
||||
func TestTicker_FiresOnChangeWithDelta(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
current := NewPolicy(nil) // initial observation: empty (no enforcement)
|
||||
withPolicyLoader(t, func() *Policy {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return current
|
||||
})
|
||||
|
||||
type change struct{ prev, curr *Policy }
|
||||
changes := make(chan change, 1)
|
||||
tk := NewTicker(testReloadInterval)
|
||||
require.Equal(t, testReloadInterval, tk.interval)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
tk.Run(ctx, func(prev, curr *Policy) error {
|
||||
select {
|
||||
case changes <- change{prev, curr}:
|
||||
default:
|
||||
}
|
||||
return nil
|
||||
})
|
||||
close(done)
|
||||
}()
|
||||
// Stop Run and wait for it to exit before returning, so the policyLoader
|
||||
// restore in t.Cleanup can't race the ticker goroutine still reading it.
|
||||
defer func() { cancel(); <-done }()
|
||||
|
||||
// Flip the OS-observed policy from empty to one managed key. The next
|
||||
// tick must detect the diff and invoke onChange.
|
||||
mu.Lock()
|
||||
current = NewPolicy(map[string]any{KeyManagementURL: "https://mdm.example.com:443"})
|
||||
mu.Unlock()
|
||||
|
||||
select {
|
||||
case c := <-changes:
|
||||
assert.True(t, c.prev.IsEmpty(), "prev should be the initial empty policy")
|
||||
assert.True(t, c.curr.HasKey(KeyManagementURL), "curr should carry the newly-pushed managed key")
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("onChange not invoked within 5s; ticker should fire every 1s under test")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTicker_NoCallbackWhenPolicyUnchanged(t *testing.T) {
|
||||
withPolicyLoader(t, func() *Policy {
|
||||
return NewPolicy(map[string]any{KeyBlockInbound: true})
|
||||
})
|
||||
|
||||
fired := make(chan struct{}, 1)
|
||||
tk := NewTicker(testReloadInterval)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
tk.Run(ctx, func(_, _ *Policy) error {
|
||||
select {
|
||||
case fired <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return nil
|
||||
})
|
||||
close(done)
|
||||
}()
|
||||
defer func() { cancel(); <-done }()
|
||||
|
||||
// Over ~2 ticks at the 1s test cadence the policy never changes, so the
|
||||
// diff guard must suppress the callback entirely.
|
||||
select {
|
||||
case <-fired:
|
||||
t.Fatal("onChange fired despite an unchanged policy")
|
||||
case <-time.After(2500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -354,6 +354,13 @@ message GetConfigResponse {
|
||||
int32 sshJWTCacheTTL = 26;
|
||||
|
||||
bool disable_ipv6 = 27;
|
||||
|
||||
// mDMManagedFields lists the names of configuration keys whose value is
|
||||
// currently enforced by an MDM policy. Names match mdm.Key* constants
|
||||
// (e.g. "managementURL", "disableClientRoutes"). UI/CLI clients should
|
||||
// render the corresponding inputs as read-only and display a "managed
|
||||
// by MDM" indicator.
|
||||
repeated string mDMManagedFields = 28;
|
||||
}
|
||||
|
||||
// PeerState contains the latest state of a peer
|
||||
@@ -786,6 +793,15 @@ message GetFeaturesResponse{
|
||||
bool disable_networks = 3;
|
||||
}
|
||||
|
||||
// MDMManagedFieldsViolation is attached as a gRPC error detail on a
|
||||
// FailedPrecondition status returned from SetConfig (and similar mutating
|
||||
// RPCs) when the caller tries to modify one or more MDM-enforced fields.
|
||||
// The fields list contains the offending key names; the entire request is
|
||||
// rejected (no partial apply).
|
||||
message MDMManagedFieldsViolation {
|
||||
repeated string fields = 1;
|
||||
}
|
||||
|
||||
message TriggerUpdateRequest {}
|
||||
|
||||
message TriggerUpdateResponse {
|
||||
|
||||
@@ -28,3 +28,34 @@ const (
|
||||
// MetadataKindLogLevelChanged.
|
||||
MetadataLevelKey = "level"
|
||||
)
|
||||
|
||||
// SystemEvent metadata markers for daemon config-change events. The daemon
|
||||
// publishes a SYSTEM-category event whenever its effective Config is
|
||||
// replaced (engine spawn, Up RPC, MDM policy diff); the UI re-fetches its
|
||||
// cached config/features in response and, for the MDM source, shows a
|
||||
// localised toast. Producer (client/server) and consumer (client/ui) share
|
||||
// these so neither duplicates the wire literals.
|
||||
const (
|
||||
// MetadataTypeKey is the SystemEvent.metadata key carrying the
|
||||
// config-change event type (one of the MetadataType* values below).
|
||||
MetadataTypeKey = "type"
|
||||
// MetadataTypeConfigChanged marks a config replacement that should nudge
|
||||
// UIs to re-fetch their cached config + features. UserMessage is empty so
|
||||
// the change is silent; the source is carried in MetadataSourceKey.
|
||||
MetadataTypeConfigChanged = "config_changed"
|
||||
// MetadataTypePolicyApplied marks an MDM-policy-driven config change. The
|
||||
// daemon stamps it with a (non-localised) UserMessage; the UI suppresses
|
||||
// that and builds its own localised toast off the paired config_changed
|
||||
// event instead.
|
||||
MetadataTypePolicyApplied = "policy_applied"
|
||||
|
||||
// MetadataSourceKey is the SystemEvent.metadata key carrying what
|
||||
// triggered a config_changed event (one of the MetadataSource* values).
|
||||
MetadataSourceKey = "source"
|
||||
// MetadataSourceStartup marks a config_changed from the daemon Start path.
|
||||
MetadataSourceStartup = "startup"
|
||||
// MetadataSourceUpRPC marks a config_changed from the Up RPC.
|
||||
MetadataSourceUpRPC = "up_rpc"
|
||||
// MetadataSourceMDM marks a config_changed driven by an MDM policy diff.
|
||||
MetadataSourceMDM = "mdm"
|
||||
)
|
||||
|
||||
422
client/server/mdm.go
Normal file
422
client/server/mdm.go
Normal file
@@ -0,0 +1,422 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// preSharedKeyRedactedSentinel is the value GetConfig returns in place
|
||||
// of an actual PSK, so a UI that round-trips the field back to the
|
||||
// daemon (via SetConfig / Login) can be distinguished from a deliberate
|
||||
// override. Any incoming PSK that equals this sentinel is treated as
|
||||
// a no-op echo, never as a conflict with the policy.
|
||||
const preSharedKeyRedactedSentinel = "**********"
|
||||
|
||||
// loadMDMPolicy is the indirection used by server handlers to read the
|
||||
// active MDM policy. Tests override this to inject a fake policy.
|
||||
var loadMDMPolicy = mdm.LoadPolicy
|
||||
|
||||
// conflictCheck is a value-aware comparison between a single field in
|
||||
// the incoming request and the corresponding MDM-enforced value. It
|
||||
// runs only when the field was actually set in the request (presence
|
||||
// already filtered upstream); ok=true reports the policy value, ok=false
|
||||
// means the policy is silent on the key — both are treated as conflicts
|
||||
// to be safe (an MDM key declared as managed must hold a value).
|
||||
type conflictCheck struct {
|
||||
key string
|
||||
check func(*mdm.Policy) (match bool)
|
||||
}
|
||||
|
||||
// onMDMPolicyChange is invoked by the MDM reload ticker every time the
|
||||
// OS-native managed-config store reports a diff vs the last observation.
|
||||
//
|
||||
// Restart sequence:
|
||||
// 1. Cancel the active engine context (terminates connectWithRetryRuns).
|
||||
// 2. Wait briefly for that goroutine to exit (giveUpChan is closed on exit).
|
||||
// 3. Re-resolve Config from disk + MDM policy (Config.apply re-runs
|
||||
// applyMDMPolicy with the freshly loaded Policy).
|
||||
// 4. Spawn a fresh connectWithRetryRuns with the new context and config.
|
||||
// 5. Broadcast a SystemEvent so any GUI / CLI subscriber (SubscribeEvents
|
||||
// RPC) can refresh its cached config view without polling.
|
||||
//
|
||||
// The callback runs in the ticker's own goroutine. Ticker has already
|
||||
// logged the per-key diff before invoking this hook.
|
||||
func (s *Server) onMDMPolicyChange(_, _ *mdm.Policy) error {
|
||||
log.Warn("MDM policy changed; restarting engine to apply new configuration")
|
||||
|
||||
// Hold s.mutex for the entire restart sequence (cancel + quiescence
|
||||
// wait + re-spawn). Any concurrent Up/Down/Status arriving while
|
||||
// MDM is restarting blocks on the Lock until we are done — they
|
||||
// then observe the post-restart state coherently. This is safe
|
||||
// because the connectWithRetryRuns goroutine no longer acquires
|
||||
// s.mutex in its defer (intent vs. goroutine-alive concerns are
|
||||
// fully separated; see the connectionGoroutineRunning helper).
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
if !s.clientRunning {
|
||||
// The client is not running, so there's no engine to restart.
|
||||
return nil
|
||||
}
|
||||
if s.actCancel != nil {
|
||||
s.actCancel()
|
||||
}
|
||||
|
||||
// Wait for previous connectWithRetryRuns to exit so we don't end up
|
||||
// with two goroutines fighting over the same status recorder + engine.
|
||||
// The teardown engages a fan-out of engine goroutines (peer workers,
|
||||
// signal handler, route manager, ...). close(clientGiveUpChan)
|
||||
// happens in the function-scope defer of connectWithRetryRuns, on
|
||||
// every exit path (ctx cancel, backoff exhausted, panic) — see the
|
||||
// defer in server.go.
|
||||
if s.clientGiveUpChan != nil {
|
||||
select {
|
||||
case <-s.clientGiveUpChan:
|
||||
case <-time.After(10 * time.Second):
|
||||
return fmt.Errorf("failed to restart the engine due to timeout")
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.restartEngineForMDMLocked(); err != nil {
|
||||
log.Errorf("MDM restart failed: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// publishConfigChangedEvent has already fired inside
|
||||
// restartEngineForMDMLocked with source="mdm". Emit an MDM-specific
|
||||
// user-visible toast so the operator knows their IT policy was
|
||||
// applied (UserMessage != "" triggers the GUI notifier).
|
||||
s.statusRecorder.PublishEvent(
|
||||
proto.SystemEvent_INFO,
|
||||
proto.SystemEvent_SYSTEM,
|
||||
"MDM policy applied",
|
||||
"NetBird configuration was updated by your IT policy.",
|
||||
map[string]string{
|
||||
proto.MetadataSourceKey: proto.MetadataSourceMDM,
|
||||
proto.MetadataTypeKey: proto.MetadataTypePolicyApplied,
|
||||
},
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// publishConfigChangedEvent broadcasts a SystemEvent informing any active
|
||||
// SubscribeEvents subscriber (typically the GUI tray) that the daemon's
|
||||
// effective Config has been replaced and any cached client-side view
|
||||
// should be refreshed. Callers pass a stable `source` label so the GUI
|
||||
// can distinguish a startup spawn from a user-triggered Up or an
|
||||
// MDM-driven restart. Reusing the SYSTEM category keeps the proto enum
|
||||
// stable; metadata.type="config_changed" routes to the GUI's refresh
|
||||
// handler. UserMessage is left empty so the system tray does not toast
|
||||
// for every internal restart; the MDM path emits a separate
|
||||
// "policy_applied" event (with UserMessage) for that purpose.
|
||||
func (s *Server) publishConfigChangedEvent(source string) {
|
||||
if s.statusRecorder == nil {
|
||||
return
|
||||
}
|
||||
s.statusRecorder.PublishEvent(
|
||||
proto.SystemEvent_INFO,
|
||||
proto.SystemEvent_SYSTEM,
|
||||
fmt.Sprintf("daemon config changed (source=%s)", source),
|
||||
"",
|
||||
map[string]string{
|
||||
proto.MetadataSourceKey: source,
|
||||
proto.MetadataTypeKey: proto.MetadataTypeConfigChanged,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// restartEngineForMDMLocked re-resolves the active profile config
|
||||
// (re-running applyMDMPolicy via Config.apply) and re-spawns
|
||||
// connectWithRetryRuns. Mirrors the tail of Server.Start so a runtime
|
||||
// MDM change behaves identically to a fresh boot under the new policy.
|
||||
//
|
||||
// MUST be called with s.mutex held — onMDMPolicyChange holds the lock
|
||||
// for the entire restart sequence (cancel + quiescence wait + re-spawn)
|
||||
// so concurrent Up/Down/Status RPCs observe a coherent post-restart
|
||||
// state.
|
||||
func (s *Server) restartEngineForMDMLocked() error {
|
||||
activeProf, err := s.profileManager.GetActiveProfileState()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get active profile state: %w", err)
|
||||
}
|
||||
config, _, err := s.getConfig(activeProf)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get active profile config: %w", err)
|
||||
}
|
||||
|
||||
s.config = config
|
||||
s.statusRecorder.UpdateManagementAddress(config.ManagementURL.String())
|
||||
s.statusRecorder.UpdateRosenpass(config.RosenpassEnabled, config.RosenpassPermissive)
|
||||
s.statusRecorder.UpdateLazyConnection(config.LazyConnectionEnabled)
|
||||
|
||||
ctx, cancel := context.WithCancel(s.rootCtx)
|
||||
s.actCancel = cancel
|
||||
s.clientRunning = true
|
||||
s.clientRunningChan = make(chan struct{})
|
||||
s.clientGiveUpChan = make(chan struct{})
|
||||
log.Info("MDM restart: spawning connectWithRetryRuns with re-resolved config")
|
||||
go s.connectWithRetryRuns(ctx, config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan)
|
||||
s.publishConfigChangedEvent(proto.MetadataSourceMDM)
|
||||
return nil
|
||||
}
|
||||
|
||||
// conflictBool builds a conflictCheck for a boolean MDM key. If p is nil
|
||||
// the field is treated as matching (no override requested); otherwise the
|
||||
// check returns true only when the policy contains the key and its
|
||||
// boolean value equals *p.
|
||||
func conflictBool(key string, p *bool) conflictCheck {
|
||||
return conflictCheck{
|
||||
key: key,
|
||||
check: func(pol *mdm.Policy) bool {
|
||||
if p == nil {
|
||||
return true // absent → match by definition
|
||||
}
|
||||
want, ok := pol.GetBool(key)
|
||||
return ok && want == *p
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// conflictString builds a conflictCheck for a string MDM key. An empty
|
||||
// `got` is treated as "field not set" (no override requested); otherwise
|
||||
// the check returns true only when the policy contains the key and its
|
||||
// value equals got.
|
||||
func conflictString(key, got string) conflictCheck {
|
||||
return conflictCheck{
|
||||
key: key,
|
||||
check: func(pol *mdm.Policy) bool {
|
||||
if got == "" {
|
||||
return true
|
||||
}
|
||||
want, ok := pol.GetString(key)
|
||||
return ok && want == got
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// conflictInt64 builds a conflictCheck for an integer MDM key. If p is
|
||||
// nil the field is treated as matching; otherwise the check returns
|
||||
// true only when the policy contains the key and its int value equals *p.
|
||||
func conflictInt64(key string, p *int64) conflictCheck {
|
||||
return conflictCheck{
|
||||
key: key,
|
||||
check: func(pol *mdm.Policy) bool {
|
||||
if p == nil {
|
||||
return true
|
||||
}
|
||||
want, ok := pol.GetInt(key)
|
||||
return ok && want == *p
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// resolveConflicts walks the per-field checks against the active MDM
|
||||
// policy and returns the names of keys whose requested value diverges
|
||||
// from the policy-enforced value. Keys not present in the policy are
|
||||
// skipped silently (the gate fires only for keys the admin has
|
||||
// actually pushed). Returns nil for an empty policy.
|
||||
func resolveConflicts(policy *mdm.Policy, checks []conflictCheck) []string {
|
||||
if policy.IsEmpty() {
|
||||
return nil
|
||||
}
|
||||
var conflicts []string
|
||||
for _, c := range checks {
|
||||
if !policy.HasKey(c.key) {
|
||||
continue
|
||||
}
|
||||
if !c.check(policy) {
|
||||
conflicts = append(conflicts, c.key)
|
||||
}
|
||||
}
|
||||
return conflicts
|
||||
}
|
||||
|
||||
// mdmManagedFieldConflicts returns the names of MDM-managed keys whose
|
||||
// requested value in the SetConfigRequest differs from the MDM-enforced
|
||||
// value. A field set to the same value the policy already enforces is
|
||||
// treated as a no-op echo (the GUI tray sends a full Config snapshot on
|
||||
// every toggle, so most fields in a typical request match the policy
|
||||
// exactly and must NOT be flagged as conflicts). The redacted PSK
|
||||
// sentinel ("**********") returned by GetConfig is recognised and
|
||||
// treated as no-op so the UI can safely round-trip it.
|
||||
func mdmManagedFieldConflicts(msg *proto.SetConfigRequest, policy *mdm.Policy) []string {
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// PSK round-trip echo: collapse the sentinel to empty so the
|
||||
// shared check treats it as "field not set".
|
||||
pskGot := ""
|
||||
if msg.OptionalPreSharedKey != nil && *msg.OptionalPreSharedKey != preSharedKeyRedactedSentinel {
|
||||
pskGot = *msg.OptionalPreSharedKey
|
||||
}
|
||||
|
||||
return resolveConflicts(policy, []conflictCheck{
|
||||
conflictString(mdm.KeyManagementURL, msg.ManagementUrl),
|
||||
conflictString(mdm.KeyPreSharedKey, pskGot),
|
||||
conflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled),
|
||||
conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive),
|
||||
conflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect),
|
||||
conflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed),
|
||||
conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes),
|
||||
conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
|
||||
conflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
|
||||
conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
|
||||
})
|
||||
}
|
||||
|
||||
// setConfigRequestHasConfigOverrides reports whether the SetConfigRequest
|
||||
// carries ANY field that would actually mutate the persisted config.
|
||||
// The CLI builds a SetConfigRequest unconditionally on every
|
||||
// `netbird up` (see setupSetConfigReq in cmd/up.go) — a plain
|
||||
// `netbird up` produces a request with every field at its zero value;
|
||||
// the gate must skip such no-op invocations or it would always fire
|
||||
// even when the user did not pass any --flag. Returns false on a nil
|
||||
// msg; true when any management/admin URL, PSK, DNS/NAT list+clean
|
||||
// flag, interface/port/MTU, or any optional bool/duration field is set.
|
||||
func setConfigRequestHasConfigOverrides(msg *proto.SetConfigRequest) bool {
|
||||
if msg == nil {
|
||||
return false
|
||||
}
|
||||
return msg.ManagementUrl != "" ||
|
||||
msg.AdminURL != "" ||
|
||||
msg.OptionalPreSharedKey != nil ||
|
||||
len(msg.CustomDNSAddress) > 0 ||
|
||||
len(msg.NatExternalIPs) > 0 || msg.CleanNATExternalIPs ||
|
||||
len(msg.ExtraIFaceBlacklist) > 0 ||
|
||||
len(msg.DnsLabels) > 0 || msg.CleanDNSLabels ||
|
||||
msg.DnsRouteInterval != nil ||
|
||||
msg.RosenpassEnabled != nil ||
|
||||
msg.RosenpassPermissive != nil ||
|
||||
msg.InterfaceName != nil ||
|
||||
msg.WireguardPort != nil ||
|
||||
msg.Mtu != nil ||
|
||||
msg.DisableAutoConnect != nil ||
|
||||
msg.ServerSSHAllowed != nil ||
|
||||
msg.NetworkMonitor != nil ||
|
||||
msg.DisableClientRoutes != nil ||
|
||||
msg.DisableServerRoutes != nil ||
|
||||
msg.DisableDns != nil ||
|
||||
msg.DisableFirewall != nil ||
|
||||
msg.BlockLanAccess != nil ||
|
||||
msg.DisableNotifications != nil ||
|
||||
msg.LazyConnectionEnabled != nil ||
|
||||
msg.BlockInbound != nil ||
|
||||
msg.DisableIpv6 != nil ||
|
||||
msg.EnableSSHRoot != nil ||
|
||||
msg.EnableSSHSFTP != nil ||
|
||||
msg.EnableSSHLocalPortForwarding != nil ||
|
||||
msg.EnableSSHRemotePortForwarding != nil ||
|
||||
msg.DisableSSHAuth != nil ||
|
||||
msg.SshJWTCacheTTL != nil
|
||||
}
|
||||
|
||||
// loginRequestHasConfigOverrides reports whether the LoginRequest
|
||||
// carries ANY field that would mutate persisted daemon configuration
|
||||
// (as opposed to pure-auth fields like setupKey, hostname, hint,
|
||||
// profileName, username). Used by the Login handler to decide whether
|
||||
// the `--disable-update-settings` / MDM gates must run: a re-auth that
|
||||
// changes nothing about the configuration is always allowed.
|
||||
func loginRequestHasConfigOverrides(msg *proto.LoginRequest) bool {
|
||||
if msg == nil {
|
||||
return false
|
||||
}
|
||||
return msg.ManagementUrl != "" ||
|
||||
msg.AdminURL != "" ||
|
||||
msg.PreSharedKey != "" || //nolint:staticcheck // SA1019: legacy proto field still accepted by Login
|
||||
msg.OptionalPreSharedKey != nil ||
|
||||
len(msg.CustomDNSAddress) > 0 ||
|
||||
len(msg.NatExternalIPs) > 0 || msg.CleanNATExternalIPs ||
|
||||
msg.RosenpassEnabled != nil ||
|
||||
msg.InterfaceName != nil ||
|
||||
msg.WireguardPort != nil ||
|
||||
msg.DisableAutoConnect != nil ||
|
||||
msg.ServerSSHAllowed != nil ||
|
||||
msg.RosenpassPermissive != nil ||
|
||||
len(msg.ExtraIFaceBlacklist) > 0 ||
|
||||
msg.NetworkMonitor != nil ||
|
||||
msg.DnsRouteInterval != nil ||
|
||||
msg.DisableClientRoutes != nil ||
|
||||
msg.DisableServerRoutes != nil ||
|
||||
msg.DisableDns != nil ||
|
||||
msg.DisableFirewall != nil ||
|
||||
msg.BlockLanAccess != nil ||
|
||||
msg.DisableNotifications != nil ||
|
||||
len(msg.DnsLabels) > 0 || msg.CleanDNSLabels ||
|
||||
msg.LazyConnectionEnabled != nil ||
|
||||
msg.BlockInbound != nil
|
||||
}
|
||||
|
||||
// loginRequestMDMConflicts mirrors mdmManagedFieldConflicts but for the
|
||||
// LoginRequest surface. Same value-aware semantics: a field set to the
|
||||
// MDM-enforced value is a no-op echo, not a conflict; only a divergent
|
||||
// value is flagged. PSK has two proto fields — PreSharedKey (deprecated)
|
||||
// and OptionalPreSharedKey (current); either route trips the gate if it
|
||||
// diverges from the MDM-enforced PSK. OptionalPreSharedKey wins when
|
||||
// both are set; the redaction sentinel ("**********") is accepted as
|
||||
// a no-op echo.
|
||||
func loginRequestMDMConflicts(msg *proto.LoginRequest, policy *mdm.Policy) []string {
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Collapse the two PSK fields + the redaction sentinel down to a
|
||||
// single "got" string the shared check can compare against the
|
||||
// policy: OptionalPreSharedKey wins if set; PreSharedKey (deprecated)
|
||||
// is the fallback; sentinel echo is treated as "field not set".
|
||||
pskGot := ""
|
||||
if msg.OptionalPreSharedKey != nil {
|
||||
pskGot = *msg.OptionalPreSharedKey
|
||||
} else if msg.PreSharedKey != "" { //nolint:staticcheck // SA1019: legacy proto field still accepted by Login
|
||||
pskGot = msg.PreSharedKey //nolint:staticcheck // SA1019
|
||||
}
|
||||
if pskGot == preSharedKeyRedactedSentinel {
|
||||
pskGot = ""
|
||||
}
|
||||
|
||||
return resolveConflicts(policy, []conflictCheck{
|
||||
conflictString(mdm.KeyManagementURL, msg.ManagementUrl),
|
||||
conflictString(mdm.KeyPreSharedKey, pskGot),
|
||||
conflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled),
|
||||
conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive),
|
||||
conflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect),
|
||||
conflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed),
|
||||
conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes),
|
||||
conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
|
||||
conflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
|
||||
conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
|
||||
})
|
||||
}
|
||||
|
||||
// rejectMDMManagedFieldConflicts returns a FailedPrecondition gRPC error
|
||||
// with an MDMManagedFieldsViolation detail when any of the requested
|
||||
// fields tries to change an MDM-enforced value to something else, and
|
||||
// nil otherwise. The whole request is rejected on any conflict; non-
|
||||
// conflicting fields in the same request are not applied either (no
|
||||
// partial apply).
|
||||
func rejectMDMManagedFieldConflicts(conflicts []string) error {
|
||||
if len(conflicts) == 0 {
|
||||
return nil
|
||||
}
|
||||
log.Warnf("MDM rejected request: tried to modify %d managed key(s): %v",
|
||||
len(conflicts), conflicts)
|
||||
st := gstatus.New(
|
||||
codes.FailedPrecondition,
|
||||
fmt.Sprintf("fields managed by MDM cannot be modified: %v", conflicts),
|
||||
)
|
||||
detailed, err := st.WithDetails(&proto.MDMManagedFieldsViolation{Fields: conflicts})
|
||||
if err != nil {
|
||||
// Detail attachment is best-effort; fall back to the plain status
|
||||
// so the caller still gets a usable FailedPrecondition.
|
||||
return st.Err()
|
||||
}
|
||||
return detailed.Err()
|
||||
}
|
||||
@@ -30,7 +30,7 @@ func (s *Server) ListNetworks(context.Context, *proto.ListNetworksRequest) (*pro
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
if s.networksDisabled {
|
||||
if s.checkNetworksDisabled() {
|
||||
return nil, gstatus.Errorf(codes.Unavailable, errNetworksDisabled)
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ func (s *Server) SelectNetworks(_ context.Context, req *proto.SelectNetworksRequ
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
if s.networksDisabled {
|
||||
if s.checkNetworksDisabled() {
|
||||
return nil, gstatus.Errorf(codes.Unavailable, errNetworksDisabled)
|
||||
}
|
||||
|
||||
@@ -206,7 +206,7 @@ func (s *Server) DeselectNetworks(_ context.Context, req *proto.SelectNetworksRe
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
if s.networksDisabled {
|
||||
if s.checkNetworksDisabled() {
|
||||
return nil, gstatus.Errorf(codes.Unavailable, errNetworksDisabled)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/expose"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
sleephandler "github.com/netbirdio/netbird/client/internal/sleep/handler"
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
mgm "github.com/netbirdio/netbird/shared/management/client"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
@@ -84,7 +85,13 @@ type Server struct {
|
||||
mutex sync.Mutex
|
||||
config *profilemanager.Config
|
||||
proto.UnimplementedDaemonServiceServer
|
||||
clientRunning bool // protected by mutex
|
||||
// clientRunning tracks "the daemon wants to be connected" — set true by
|
||||
// Start / Up, cleared by Down / Logout. Persists across retry
|
||||
// loops, signal disconnects, and ErrResetConnection cycles. NOT
|
||||
// changed by connectWithRetryRuns goroutine exit — for that
|
||||
// (goroutine-still-alive) check, see connectionGoroutineRunning() which
|
||||
// derives from clientGiveUpChan close state. Protected by s.mutex.
|
||||
clientRunning bool
|
||||
clientRunningChan chan struct{}
|
||||
clientGiveUpChan chan struct{} // closed when connectWithRetryRuns goroutine exits
|
||||
|
||||
@@ -111,6 +118,11 @@ type Server struct {
|
||||
|
||||
sleepHandler *sleephandler.SleepHandler
|
||||
|
||||
// mdmTicker periodically re-reads the OS-native MDM policy and triggers
|
||||
// an engine restart when the policy changes. Launched once by Start;
|
||||
// stopped by the rootCtx cancellation.
|
||||
mdmTicker *mdm.Ticker
|
||||
|
||||
updateManager *updater.Manager
|
||||
|
||||
jwtCache *jwtCache
|
||||
@@ -179,6 +191,17 @@ func (s *Server) Start() error {
|
||||
s.updateManager.CheckUpdateSuccess(s.rootCtx)
|
||||
}
|
||||
|
||||
// MDM policy reload ticker: every minute the desktop daemon re-reads
|
||||
// the OS-native managed-config store and, on diff vs the previous
|
||||
// observation, cancels the active engine context so connectWithRetry-
|
||||
// Runs re-resolves Config (re-running profilemanager.Config.apply which
|
||||
// applies the freshly-read MDM policy as the last layer) and brings
|
||||
// the engine back with the new values.
|
||||
if s.mdmTicker == nil {
|
||||
s.mdmTicker = mdm.NewTicker(mdm.DefaultReloadInterval)
|
||||
go s.mdmTicker.Run(s.rootCtx, s.onMDMPolicyChange)
|
||||
}
|
||||
|
||||
// if current state contains any error, return it
|
||||
// in all other cases we can continue execution only if status is idle and up command was
|
||||
// not in the progress or already successfully established connection.
|
||||
@@ -237,24 +260,28 @@ func (s *Server) Start() error {
|
||||
s.clientRunningChan = make(chan struct{})
|
||||
s.clientGiveUpChan = make(chan struct{})
|
||||
go s.connectWithRetryRuns(ctx, config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan)
|
||||
s.publishConfigChangedEvent(proto.MetadataSourceStartup)
|
||||
return nil
|
||||
}
|
||||
|
||||
// connectWithRetryRuns runs the client connection with a backoff strategy where we retry the operation as additional
|
||||
// mechanism to keep the client connected even when the connection is lost.
|
||||
// we cancel retry if the client receive a stop or down command, or if disable auto connect is configured.
|
||||
//
|
||||
// The goroutine's exit is signalled to the daemon via close(giveUpChan)
|
||||
// — placed in the function-scope defer so every return path (panic,
|
||||
// DisableAutoConnect early-exit, backoff exhausted, ctx cancel) closes
|
||||
// it. Callers that need to observe "is the goroutine still alive?" use
|
||||
// Server.connectionGoroutineRunning() which non-blockingly checks the close state
|
||||
// of clientGiveUpChan. The defer does NOT touch s.mutex; the daemon's
|
||||
// "intent" (clientRunning) is maintained by the RPC handlers, not by this
|
||||
// goroutine.
|
||||
func (s *Server) connectWithRetryRuns(ctx context.Context, profileConfig *profilemanager.Config, statusRecorder *peer.Status, runningChan chan struct{}, giveUpChan chan struct{}) {
|
||||
// close(giveUpChan) MUST run on every exit path (DisableAutoConnect
|
||||
// return, backoff.Retry return, panic) — Down() blocks for up to 5s
|
||||
// waiting on this signal before flipping the state to Idle, and a
|
||||
// missed close leaves Down() always hitting the timeout. The signal
|
||||
// fires AFTER clientRunning=false is committed under the mutex so a
|
||||
// Down/Up racing with the goroutine exit never observes a half-state
|
||||
// (chan closed but clientRunning still true).
|
||||
// missed close leaves Down() always hitting the timeout.
|
||||
defer func() {
|
||||
s.mutex.Lock()
|
||||
s.clientRunning = false
|
||||
s.mutex.Unlock()
|
||||
if giveUpChan != nil {
|
||||
close(giveUpChan)
|
||||
}
|
||||
@@ -312,6 +339,27 @@ func (s *Server) connectWithRetryRuns(ctx context.Context, profileConfig *profil
|
||||
if err := backoff.Retry(runOperation, backOff); err != nil {
|
||||
log.Errorf("operation failed: %v", err)
|
||||
}
|
||||
// giveUpChan is closed by the function-scope defer.
|
||||
}
|
||||
|
||||
// connectionGoroutineRunning reports whether the connectWithRetryRuns goroutine is
|
||||
// still running. Returns false when no goroutine has ever been started
|
||||
// AND when the most recent one has already closed clientGiveUpChan on
|
||||
// exit (whether due to ctx cancel, DisableAutoConnect single-shot
|
||||
// completion, or backoff retry exhaustion).
|
||||
//
|
||||
// MUST be called with s.mutex held — accesses s.clientGiveUpChan which
|
||||
// is written by Start/Up under the same lock.
|
||||
func (s *Server) connectionGoroutineRunning() bool {
|
||||
if s.clientGiveUpChan == nil {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case <-s.clientGiveUpChan:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// loginAttempt attempts to login using the provided information. it returns a status in case something fails
|
||||
@@ -343,52 +391,85 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
if s.checkUpdateSettingsDisabled() {
|
||||
return nil, gstatus.Errorf(codes.Unavailable, errUpdateSettingsDisabled)
|
||||
// Skip the update-settings gate when the request carries no actual
|
||||
// overrides: the CLI builds a SetConfigRequest unconditionally on
|
||||
// every `netbird up` (setupSetConfigReq in cmd/up.go), so a plain
|
||||
// `netbird up` would otherwise always trip the gate and surface a
|
||||
// misleading "setConfig method is not available" warning, even when
|
||||
// the user did not pass any config flag.
|
||||
if setConfigRequestHasConfigOverrides(msg) {
|
||||
if s.checkUpdateSettingsDisabled() {
|
||||
return nil, gstatus.Errorf(codes.Unavailable, errUpdateSettingsDisabled)
|
||||
}
|
||||
}
|
||||
|
||||
// MDM gate: refuse the whole request if any of its fields is enforced
|
||||
// by the active MDM policy. The error carries an MDMManagedFields-
|
||||
// Violation detail listing the offending key names. Non-conflicting
|
||||
// fields in the same request are not applied either.
|
||||
policy := loadMDMPolicy()
|
||||
if err := rejectMDMManagedFieldConflicts(mdmManagedFieldConflicts(msg, policy)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config, err := setConfigInputFromRequest(msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := profilemanager.UpdateConfig(config); err != nil {
|
||||
log.Errorf("failed to update profile config: %v", err)
|
||||
return nil, fmt.Errorf("failed to update profile config: %w", err)
|
||||
}
|
||||
|
||||
return &proto.SetConfigResponse{}, nil
|
||||
}
|
||||
|
||||
// setConfigInputFromRequest translates a SetConfigRequest into the
|
||||
// profilemanager.ConfigInput that profilemanager.UpdateConfig consumes.
|
||||
// Pure mapping with no business logic beyond presence-aware copying of
|
||||
// optional fields and the "empty / clean" semantics for the two slice
|
||||
// fields (DNS labels, NAT external IPs). Extracted from SetConfig to
|
||||
// keep the handler's cognitive complexity below the SonarCube
|
||||
// threshold; the body is intentionally linear because each proto
|
||||
// field is its own optional case. Returns the resolved ConfigInput
|
||||
// and a non-nil error only when the active profile file path cannot
|
||||
// be determined.
|
||||
func setConfigInputFromRequest(msg *proto.SetConfigRequest) (profilemanager.ConfigInput, error) {
|
||||
var config profilemanager.ConfigInput
|
||||
|
||||
profState := profilemanager.ActiveProfileState{
|
||||
Name: msg.ProfileName,
|
||||
Username: msg.Username,
|
||||
}
|
||||
|
||||
profPath, err := profState.FilePath()
|
||||
if err != nil {
|
||||
log.Errorf("failed to get active profile file path: %v", err)
|
||||
return nil, fmt.Errorf("failed to get active profile file path: %w", err)
|
||||
return config, fmt.Errorf("failed to get active profile file path: %w", err)
|
||||
}
|
||||
|
||||
var config profilemanager.ConfigInput
|
||||
|
||||
config.ConfigPath = profPath
|
||||
|
||||
if msg.ManagementUrl != "" {
|
||||
config.ManagementURL = msg.ManagementUrl
|
||||
}
|
||||
|
||||
if msg.AdminURL != "" {
|
||||
config.AdminURL = msg.AdminURL
|
||||
}
|
||||
|
||||
if msg.InterfaceName != nil {
|
||||
config.InterfaceName = msg.InterfaceName
|
||||
}
|
||||
|
||||
if msg.WireguardPort != nil {
|
||||
wgPort := int(*msg.WireguardPort)
|
||||
config.WireguardPort = &wgPort
|
||||
}
|
||||
|
||||
if msg.OptionalPreSharedKey != nil {
|
||||
if msg.OptionalPreSharedKey != nil && *msg.OptionalPreSharedKey != "" {
|
||||
config.PreSharedKey = msg.OptionalPreSharedKey
|
||||
}
|
||||
|
||||
if msg.CleanDNSLabels {
|
||||
config.DNSLabels = domain.List{}
|
||||
|
||||
} else if msg.DnsLabels != nil {
|
||||
dnsLabels := domain.FromPunycodeList(msg.DnsLabels)
|
||||
config.DNSLabels = dnsLabels
|
||||
config.DNSLabels = domain.FromPunycodeList(msg.DnsLabels)
|
||||
}
|
||||
|
||||
if msg.CleanNATExternalIPs {
|
||||
@@ -401,7 +482,6 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
|
||||
if string(msg.CustomDNSAddress) == "empty" {
|
||||
config.CustomDNSAddress = []byte{}
|
||||
}
|
||||
|
||||
config.ExtraIFaceBlackList = msg.ExtraIFaceBlacklist
|
||||
|
||||
if msg.DnsRouteInterval != nil {
|
||||
@@ -434,22 +514,31 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
|
||||
ttl := int(*msg.SshJWTCacheTTL)
|
||||
config.SSHJWTCacheTTL = &ttl
|
||||
}
|
||||
|
||||
if msg.Mtu != nil {
|
||||
mtu := uint16(*msg.Mtu)
|
||||
config.MTU = &mtu
|
||||
}
|
||||
|
||||
if _, err := profilemanager.UpdateConfig(config); err != nil {
|
||||
log.Errorf("failed to update profile config: %v", err)
|
||||
return nil, fmt.Errorf("failed to update profile config: %w", err)
|
||||
}
|
||||
|
||||
return &proto.SetConfigResponse{}, nil
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// Login uses setup key to prepare configuration for the daemon.
|
||||
func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*proto.LoginResponse, error) {
|
||||
// Config-override gates. LoginRequest carries the same surface as
|
||||
// SetConfigRequest (managementUrl, PSK, ssh/rosenpass/port toggles,
|
||||
// ...), so the same protections must apply. Without these the CLI
|
||||
// command `netbird up --management-url=X` (which falls through to
|
||||
// Login when SetConfig is rejected — see cmd/up.go) would silently
|
||||
// bypass `--disable-update-settings` and any MDM policy.
|
||||
if loginRequestHasConfigOverrides(msg) {
|
||||
if s.checkUpdateSettingsDisabled() {
|
||||
return nil, gstatus.Errorf(codes.Unavailable, errUpdateSettingsDisabled)
|
||||
}
|
||||
policy := loadMDMPolicy()
|
||||
if err := rejectMDMManagedFieldConflicts(loginRequestMDMConflicts(msg, policy)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
s.mutex.Lock()
|
||||
if s.actCancel != nil {
|
||||
s.actCancel()
|
||||
@@ -770,7 +859,13 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin
|
||||
// Up starts engine work in the daemon.
|
||||
func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpResponse, error) {
|
||||
s.mutex.Lock()
|
||||
if s.clientRunning {
|
||||
// clientRunning is the daemon-intent flag (set by previous Up/Start, cleared
|
||||
// by Down). connectionGoroutineRunning() reports whether the previous retry-loop
|
||||
// goroutine is still trying. When intent is up AND goroutine is alive,
|
||||
// the existing engine is on the job — just wait for it. When intent
|
||||
// is up but the goroutine has given up (backoff exhausted) OR when
|
||||
// intent is down, fall through to spawn a fresh retry loop.
|
||||
if s.clientRunning && s.connectionGoroutineRunning() {
|
||||
state := internal.CtxGetState(s.rootCtx)
|
||||
status, err := state.Status()
|
||||
if err != nil {
|
||||
@@ -877,6 +972,7 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
|
||||
s.clientGiveUpChan = make(chan struct{})
|
||||
|
||||
go s.connectWithRetryRuns(ctx, s.config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan)
|
||||
s.publishConfigChangedEvent(proto.MetadataSourceUpRPC)
|
||||
|
||||
s.mutex.Unlock()
|
||||
if msg.GetAsync() {
|
||||
@@ -1026,6 +1122,12 @@ func (s *Server) cleanupConnection() error {
|
||||
return ErrServiceNotUp
|
||||
}
|
||||
|
||||
// Daemon intent flips to "down" — all callers (Down RPC,
|
||||
// Logout RPC handlers) tear down the connection because the user
|
||||
// explicitly asked for it. MDM restart does NOT go through this
|
||||
// path, so its clientRunning stays true.
|
||||
s.clientRunning = false
|
||||
|
||||
// Capture the engine reference before cancelling the context.
|
||||
// After actCancel(), the connectWithRetryRuns goroutine wakes up
|
||||
// and sets connectClient.engine = nil, causing connectClient.Stop()
|
||||
@@ -1241,10 +1343,14 @@ func (s *Server) Status(
|
||||
msg *proto.StatusRequest,
|
||||
) (*proto.StatusResponse, error) {
|
||||
s.mutex.Lock()
|
||||
clientRunning := s.clientRunning
|
||||
// Only wait if the retry-loop goroutine is alive and making
|
||||
// progress. clientRunning=true with connectionGoroutineRunning=false means the
|
||||
// backoff has given up — there is nothing to wait for; let the
|
||||
// caller observe the failed status directly.
|
||||
alive := s.connectionGoroutineRunning()
|
||||
s.mutex.Unlock()
|
||||
|
||||
if msg.WaitForReady != nil && *msg.WaitForReady && clientRunning {
|
||||
if msg.WaitForReady != nil && *msg.WaitForReady && alive {
|
||||
state := internal.CtxGetState(s.rootCtx)
|
||||
status, err := state.Status()
|
||||
if err != nil {
|
||||
@@ -1865,6 +1971,7 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p
|
||||
EnableSSHRemotePortForwarding: enableSSHRemotePortForwarding,
|
||||
DisableSSHAuth: disableSSHAuth,
|
||||
SshJWTCacheTTL: sshJWTCacheTTL,
|
||||
MDMManagedFields: cfg.Policy().ManagedKeys(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -2007,7 +2114,7 @@ func (s *Server) GetFeatures(ctx context.Context, msg *proto.GetFeaturesRequest)
|
||||
features := &proto.GetFeaturesResponse{
|
||||
DisableProfiles: s.checkProfilesDisabled(),
|
||||
DisableUpdateSettings: s.checkUpdateSettingsDisabled(),
|
||||
DisableNetworks: s.networksDisabled,
|
||||
DisableNetworks: s.checkNetworksDisabled(),
|
||||
}
|
||||
|
||||
return features, nil
|
||||
@@ -2029,22 +2136,46 @@ func (s *Server) connect(ctx context.Context, config *profilemanager.Config, sta
|
||||
return nil
|
||||
}
|
||||
|
||||
// MDM authority: when the platform-native MDM source sets a kill switch
|
||||
// key (regardless of true/false value), that value wins. The CLI flag
|
||||
// supplied at service install time is the fallback used only when the
|
||||
// MDM source is silent on the key. This honors the "MDM decides
|
||||
// everything" semantic agreed for NET-1214 — an admin pushing
|
||||
// disableX=false via MDM explicitly re-enables the feature even on a
|
||||
// box installed with --disable-X.
|
||||
func (s *Server) checkProfilesDisabled() bool {
|
||||
// Check if the environment variable is set to disable profiles
|
||||
if s.profilesDisabled {
|
||||
return true
|
||||
if s.config != nil {
|
||||
if v, ok := s.config.Policy().GetBool(mdm.KeyDisableProfiles); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return s.profilesDisabled
|
||||
}
|
||||
|
||||
return false
|
||||
// checkNetworksDisabled reports whether the networks/exit-node feature
|
||||
// is disabled on this daemon instance. Resolved MDM-first: when the
|
||||
// active policy declares mdm.KeyDisableNetworks the policy value wins
|
||||
// (regardless of true/false), so an admin can re-enable the feature
|
||||
// via MDM even on a host that was installed with --disable-networks.
|
||||
// Falls back to the s.networksDisabled CLI flag when the policy is
|
||||
// silent on the key. Mirrors checkProfilesDisabled and
|
||||
// checkUpdateSettingsDisabled.
|
||||
func (s *Server) checkNetworksDisabled() bool {
|
||||
if s.config != nil {
|
||||
if v, ok := s.config.Policy().GetBool(mdm.KeyDisableNetworks); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return s.networksDisabled
|
||||
}
|
||||
|
||||
func (s *Server) checkUpdateSettingsDisabled() bool {
|
||||
// Check if the environment variable is set to disable profiles
|
||||
if s.updateSettingsDisabled {
|
||||
return true
|
||||
if s.config != nil {
|
||||
if v, ok := s.config.Policy().GetBool(mdm.KeyDisableUpdateSettings); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
return s.updateSettingsDisabled
|
||||
}
|
||||
|
||||
func (s *Server) startUpdateManagerForGUI() {
|
||||
|
||||
@@ -101,6 +101,7 @@ func TestCleanupConnection_ClearsConnectClient(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Nil(t, s.connectClient, "connectClient should be nil after cleanup")
|
||||
assert.False(t, s.clientRunning, "clientRunning should be cleared after cleanup (intent = down)")
|
||||
}
|
||||
|
||||
// TestCleanState_NilConnectClient validates that CleanState doesn't panic
|
||||
@@ -144,17 +145,20 @@ func TestDownThenUp_StaleRunningChan(t *testing.T) {
|
||||
_, cancel := context.WithCancel(context.Background())
|
||||
s.actCancel = cancel
|
||||
|
||||
// Simulate Down(): cleanupConnection sets connectClient = nil
|
||||
// Simulate Down(): cleanupConnection sets connectClient = nil and
|
||||
// flips clientRunning to false (intent = down). The connectionGoroutineRunning state
|
||||
// remains independent of intent — derived from clientGiveUpChan.
|
||||
s.mutex.Lock()
|
||||
err := s.cleanupConnection()
|
||||
s.mutex.Unlock()
|
||||
require.NoError(t, err)
|
||||
|
||||
// After cleanup: connectClient is nil, clientRunning still true
|
||||
// (goroutine hasn't exited yet)
|
||||
// After cleanup: connectClient is nil, clientRunning is false (intent
|
||||
// cleared by cleanupConnection), connectionGoroutineRunning may still be true
|
||||
// (goroutine teardown is independent of the intent flag).
|
||||
s.mutex.Lock()
|
||||
assert.Nil(t, s.connectClient, "connectClient should be nil after cleanup")
|
||||
assert.True(t, s.clientRunning, "clientRunning still true until goroutine exits")
|
||||
assert.False(t, s.clientRunning, "clientRunning should be cleared by cleanupConnection (intent = down)")
|
||||
s.mutex.Unlock()
|
||||
|
||||
// waitForUp() returns immediately due to stale closed clientRunningChan
|
||||
|
||||
198
client/server/setconfig_mdm_test.go
Normal file
198
client/server/setconfig_mdm_test.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// withMDMPolicy temporarily overrides the server-package loadMDMPolicy hook
|
||||
// so SetConfig observes the supplied Policy. Restores the original loader
|
||||
// at test cleanup.
|
||||
func withMDMPolicy(t *testing.T, policy *mdm.Policy) {
|
||||
t.Helper()
|
||||
prev := loadMDMPolicy
|
||||
loadMDMPolicy = func() *mdm.Policy { return policy }
|
||||
t.Cleanup(func() { loadMDMPolicy = prev })
|
||||
}
|
||||
|
||||
// setupServerWithProfile mirrors the boilerplate of TestSetConfig_AllFieldsSaved:
|
||||
// overrides profilemanager paths to a temp dir, seeds a profile, sets it
|
||||
// active, and constructs a Server instance. Returns the constructed server
|
||||
// plus context + profile name + username + cfgPath for the seeded profile.
|
||||
func setupServerWithProfile(t *testing.T) (s *Server, ctx context.Context, profName, username, cfgPath string) {
|
||||
t.Helper()
|
||||
tempDir := t.TempDir()
|
||||
|
||||
origDefaultProfileDir := profilemanager.DefaultConfigPathDir
|
||||
origDefaultConfigPath := profilemanager.DefaultConfigPath
|
||||
origActiveProfileStatePath := profilemanager.ActiveProfileStatePath
|
||||
profilemanager.ConfigDirOverride = tempDir
|
||||
profilemanager.DefaultConfigPathDir = tempDir
|
||||
profilemanager.ActiveProfileStatePath = tempDir + "/active_profile.json"
|
||||
profilemanager.DefaultConfigPath = filepath.Join(tempDir, "default.json")
|
||||
t.Cleanup(func() {
|
||||
profilemanager.DefaultConfigPathDir = origDefaultProfileDir
|
||||
profilemanager.ActiveProfileStatePath = origActiveProfileStatePath
|
||||
profilemanager.DefaultConfigPath = origDefaultConfigPath
|
||||
profilemanager.ConfigDirOverride = ""
|
||||
})
|
||||
|
||||
currUser, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
profName = "test-profile-mdm"
|
||||
cfgPath = filepath.Join(tempDir, profName+".json")
|
||||
|
||||
_, err = profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
|
||||
ConfigPath: cfgPath,
|
||||
ManagementURL: "https://api.netbird.io:443",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
pm := profilemanager.ServiceManager{}
|
||||
require.NoError(t, pm.SetActiveProfileState(&profilemanager.ActiveProfileState{
|
||||
Name: profName,
|
||||
Username: currUser.Username,
|
||||
}))
|
||||
|
||||
ctx = context.Background()
|
||||
s = New(ctx, "console", "", false, false, false, false)
|
||||
return s, ctx, profName, currUser.Username, cfgPath
|
||||
}
|
||||
|
||||
// extractViolation pulls the MDMManagedFieldsViolation detail from a
|
||||
// FailedPrecondition error. Fails the test if absent or malformed.
|
||||
func extractViolation(t *testing.T, err error) *proto.MDMManagedFieldsViolation {
|
||||
t.Helper()
|
||||
require.Error(t, err)
|
||||
st, ok := gstatus.FromError(err)
|
||||
require.True(t, ok, "error must be a gRPC status: %v", err)
|
||||
require.Equal(t, codes.FailedPrecondition, st.Code(), "expected FailedPrecondition, got %s", st.Code())
|
||||
for _, d := range st.Details() {
|
||||
if v, ok := d.(*proto.MDMManagedFieldsViolation); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
t.Fatalf("MDMManagedFieldsViolation detail not found on status; details: %v", st.Details())
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestSetConfig_MDMReject_SingleField(t *testing.T) {
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyManagementURL: "https://mdm.example.com:443",
|
||||
}))
|
||||
|
||||
s, ctx, profName, username, _ := setupServerWithProfile(t)
|
||||
|
||||
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
|
||||
ProfileName: profName,
|
||||
Username: username,
|
||||
ManagementUrl: "https://user.tried.this.com:443",
|
||||
})
|
||||
|
||||
v := extractViolation(t, err)
|
||||
assert.Equal(t, []string{mdm.KeyManagementURL}, v.GetFields())
|
||||
}
|
||||
|
||||
func TestSetConfig_MDMReject_MultipleFields(t *testing.T) {
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyManagementURL: "https://mdm.example.com:443",
|
||||
mdm.KeyBlockInbound: true,
|
||||
mdm.KeyRosenpassEnabled: true,
|
||||
}))
|
||||
|
||||
s, ctx, profName, username, _ := setupServerWithProfile(t)
|
||||
|
||||
blockInbound := false
|
||||
rosenpassEnabled := false
|
||||
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
|
||||
ProfileName: profName,
|
||||
Username: username,
|
||||
ManagementUrl: "https://user.tried.this.com:443",
|
||||
BlockInbound: &blockInbound,
|
||||
RosenpassEnabled: &rosenpassEnabled,
|
||||
})
|
||||
|
||||
v := extractViolation(t, err)
|
||||
assert.ElementsMatch(t, []string{
|
||||
mdm.KeyManagementURL,
|
||||
mdm.KeyBlockInbound,
|
||||
mdm.KeyRosenpassEnabled,
|
||||
}, v.GetFields())
|
||||
}
|
||||
|
||||
func TestSetConfig_MDMReject_AllOrNothing(t *testing.T) {
|
||||
// MDM enforces ManagementURL only; user request touches both the
|
||||
// enforced field AND a non-enforced field (RosenpassEnabled).
|
||||
// The whole request must be rejected — non-conflicting fields are not
|
||||
// applied either.
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyManagementURL: "https://mdm.example.com:443",
|
||||
}))
|
||||
|
||||
s, ctx, profName, username, cfgPath := setupServerWithProfile(t)
|
||||
|
||||
rosenpassEnabled := true
|
||||
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
|
||||
ProfileName: profName,
|
||||
Username: username,
|
||||
ManagementUrl: "https://user.tried.this.com:443",
|
||||
RosenpassEnabled: &rosenpassEnabled,
|
||||
})
|
||||
|
||||
v := extractViolation(t, err)
|
||||
assert.Equal(t, []string{mdm.KeyManagementURL}, v.GetFields())
|
||||
|
||||
// Confirm RosenpassEnabled was NOT applied even though it was not
|
||||
// in the conflict list: the request was rejected as a whole.
|
||||
reloaded, err := profilemanager.GetConfig(cfgPath)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, reloaded.RosenpassEnabled, "non-conflicting field must not be applied when request is rejected")
|
||||
}
|
||||
|
||||
func TestSetConfig_MDMAllow_NonManagedFields(t *testing.T) {
|
||||
// MDM enforces ManagementURL but the user only writes RosenpassEnabled.
|
||||
// Request must succeed.
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyManagementURL: "https://mdm.example.com:443",
|
||||
}))
|
||||
|
||||
s, ctx, profName, username, _ := setupServerWithProfile(t)
|
||||
|
||||
rosenpassEnabled := true
|
||||
resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{
|
||||
ProfileName: profName,
|
||||
Username: username,
|
||||
RosenpassEnabled: &rosenpassEnabled,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
}
|
||||
|
||||
func TestSetConfig_MDMEmpty_NoEnforcement(t *testing.T) {
|
||||
// No MDM policy active: any field can be written.
|
||||
withMDMPolicy(t, mdm.NewPolicy(nil))
|
||||
|
||||
s, ctx, profName, username, _ := setupServerWithProfile(t)
|
||||
|
||||
resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{
|
||||
ProfileName: profName,
|
||||
Username: username,
|
||||
ManagementUrl: "https://user.changed.url.com:443",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
}
|
||||
8
client/ui/.claude/settings.local.json
Normal file
8
client/ui/.claude/settings.local.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(gh api *)",
|
||||
"Bash(wails3 generate *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,10 @@ type SettingsContextValue = {
|
||||
guiVersion: string;
|
||||
setField: <K extends keyof Config>(k: K, v: Config[K]) => void;
|
||||
saveField: <K extends keyof Config>(k: K, v: Config[K]) => Promise<void>;
|
||||
saveFields: (partial: Partial<Config>) => Promise<void>;
|
||||
// opts.preSharedKey carries a new PSK to write. Config no longer exposes the
|
||||
// PSK value (only preSharedKeySet), so it rides alongside the Config fields
|
||||
// here and is sent only when non-empty.
|
||||
saveFields: (partial: Partial<Config>, opts?: { preSharedKey?: string }) => Promise<void>;
|
||||
saveNow: () => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -104,13 +107,14 @@ const useSettingsState = () => {
|
||||
);
|
||||
|
||||
const save = useCallback(
|
||||
async (profileName: string, next: Config) => {
|
||||
// Sending the "**********" PSK mask back corrupts the stored PSK (wgtypes.ParseKey fails next connect).
|
||||
const { preSharedKey, ...rest } = next;
|
||||
async (profileName: string, next: Config, preSharedKey?: string) => {
|
||||
try {
|
||||
await SettingsSvc.SetConfig({
|
||||
...rest,
|
||||
...(preSharedKey === "**********" ? {} : { preSharedKey }),
|
||||
...next,
|
||||
// The daemon never returns the PSK value (only preSharedKeySet),
|
||||
// so send one only when the user actually typed a new key; an
|
||||
// empty field means "leave unchanged", never "clear".
|
||||
...(preSharedKey ? { preSharedKey } : {}),
|
||||
profileName,
|
||||
username,
|
||||
});
|
||||
@@ -163,7 +167,7 @@ const useSettingsState = () => {
|
||||
);
|
||||
|
||||
const saveFields = useCallback(
|
||||
async (partial: Partial<Config>) => {
|
||||
async (partial: Partial<Config>, opts?: { preSharedKey?: string }) => {
|
||||
if (!loaded) return;
|
||||
if (saveTimer.current) {
|
||||
clearTimeout(saveTimer.current);
|
||||
@@ -171,7 +175,7 @@ const useSettingsState = () => {
|
||||
}
|
||||
const next = { ...loaded.data, ...partial };
|
||||
setLoaded({ profileName: loaded.profileName, data: next });
|
||||
await save(loaded.profileName, next);
|
||||
await save(loaded.profileName, next, opts?.preSharedKey);
|
||||
},
|
||||
[loaded, save],
|
||||
);
|
||||
|
||||
@@ -20,8 +20,6 @@ const PORT_MAX = 65535;
|
||||
// Mirrors client/iface/iface.go MinMTU / MaxMTU.
|
||||
const MTU_MIN = 576;
|
||||
const MTU_MAX = 8192;
|
||||
// GetConfig returns existing PSKs as this mask; revealing it would only show the asterisks.
|
||||
const PSK_MASK = "**********";
|
||||
|
||||
export function SettingsAdvanced() {
|
||||
const { t } = useTranslation();
|
||||
@@ -31,8 +29,11 @@ export function SettingsAdvanced() {
|
||||
interfaceName: config.interfaceName,
|
||||
wireguardPort: config.wireguardPort,
|
||||
mtu: config.mtu,
|
||||
preSharedKey: config.preSharedKey,
|
||||
});
|
||||
// PSK is write-only from the UI: the daemon returns only preSharedKeySet,
|
||||
// never the value. Empty means "leave unchanged"; a typed value is sent on
|
||||
// save. Reset on every config reload (e.g. after a successful save).
|
||||
const [psk, setPsk] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -40,9 +41,9 @@ export function SettingsAdvanced() {
|
||||
interfaceName: config.interfaceName,
|
||||
wireguardPort: config.wireguardPort,
|
||||
mtu: config.mtu,
|
||||
preSharedKey: config.preSharedKey,
|
||||
});
|
||||
}, [config.interfaceName, config.wireguardPort, config.mtu, config.preSharedKey]);
|
||||
setPsk("");
|
||||
}, [config.interfaceName, config.wireguardPort, config.mtu, config.preSharedKeySet]);
|
||||
|
||||
const errors = useMemo(() => {
|
||||
const out: { interfaceName?: string; wireguardPort?: string; mtu?: string } = {};
|
||||
@@ -70,13 +71,13 @@ export function SettingsAdvanced() {
|
||||
values.interfaceName !== config.interfaceName ||
|
||||
values.wireguardPort !== config.wireguardPort ||
|
||||
values.mtu !== config.mtu ||
|
||||
values.preSharedKey !== config.preSharedKey;
|
||||
psk !== "";
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!hasChanges || saving || hasErrors) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await saveFields(values);
|
||||
await saveFields(values, psk ? { preSharedKey: psk } : undefined);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -127,10 +128,14 @@ export function SettingsAdvanced() {
|
||||
<HelpText>{t("settings.advanced.psk.help")}</HelpText>
|
||||
<Input
|
||||
type={"password"}
|
||||
showPasswordToggle={values.preSharedKey !== PSK_MASK}
|
||||
placeholder={"kQv0qF3oQpJYdgD5mC9hL7sB2xZ8nT4eU6wY1aR3jK0="}
|
||||
value={values.preSharedKey}
|
||||
onChange={(e) => setValues((v) => ({ ...v, preSharedKey: e.target.value }))}
|
||||
showPasswordToggle={psk !== ""}
|
||||
placeholder={
|
||||
config.preSharedKeySet
|
||||
? t("settings.advanced.psk.configured")
|
||||
: "kQv0qF3oQpJYdgD5mC9hL7sB2xZ8nT4eU6wY1aR3jK0="
|
||||
}
|
||||
value={psk}
|
||||
onChange={(e) => setPsk(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</SectionGroup>
|
||||
|
||||
@@ -167,6 +167,12 @@
|
||||
"notify.sessionDeadlineRejected.body": {
|
||||
"message": "Der Server hat eine ungültige Sitzungsablaufzeit übermittelt. Bitte melden Sie sich erneut an."
|
||||
},
|
||||
"notify.mdm.policyApplied.title": {
|
||||
"message": "NetBird-Einstellungen aktualisiert"
|
||||
},
|
||||
"notify.mdm.policyApplied.body": {
|
||||
"message": "Ihre NetBird-Konfiguration wurde durch Ihre IT-Richtlinie aktualisiert."
|
||||
},
|
||||
"common.cancel": {
|
||||
"message": "Abbrechen"
|
||||
},
|
||||
@@ -695,6 +701,9 @@
|
||||
"settings.advanced.psk.help": {
|
||||
"message": "Optionaler WireGuard-PSK für zusätzliche symmetrische Verschlüsselung. Nicht identisch mit einem NetBird Setup-Key. Sie kommunizieren nur mit Peers, die denselben Pre-shared Key verwenden."
|
||||
},
|
||||
"settings.advanced.psk.configured": {
|
||||
"message": "Ein Pre-shared Key ist gesetzt – geben Sie einen neuen ein, um ihn zu ersetzen."
|
||||
},
|
||||
"settings.troubleshooting.section.title": {
|
||||
"message": "Debug-Paket"
|
||||
},
|
||||
|
||||
@@ -223,6 +223,14 @@
|
||||
"message": "The server sent an invalid session deadline. Please sign in again.",
|
||||
"description": "Body explaining the server sent an invalid session deadline and the user must sign in again."
|
||||
},
|
||||
"notify.mdm.policyApplied.title": {
|
||||
"message": "NetBird settings updated",
|
||||
"description": "Title of the desktop notification shown when an MDM (IT-managed) policy changed the daemon configuration at runtime."
|
||||
},
|
||||
"notify.mdm.policyApplied.body": {
|
||||
"message": "Your NetBird configuration was updated by your IT policy.",
|
||||
"description": "Body of the MDM policy-applied notification, telling the user their settings were changed by their organization's device-management policy."
|
||||
},
|
||||
"common.cancel": {
|
||||
"message": "Cancel",
|
||||
"description": "Generic Cancel button label, reused across dialogs. Keep short."
|
||||
@@ -927,6 +935,10 @@
|
||||
"message": "Optional WireGuard PSK for extra symmetric encryption. Not the same as a NetBird Setup Key. You will only communicate with peers that use the same pre-shared key.",
|
||||
"description": "Helper text for the WireGuard PSK. 'WireGuard', 'PSK', and 'NetBird Setup Key' are product/technical terms — keep them."
|
||||
},
|
||||
"settings.advanced.psk.configured": {
|
||||
"message": "A pre-shared key is set — enter a new one to replace it.",
|
||||
"description": "Placeholder shown in the empty PSK input when a pre-shared key is already configured; the field stays blank because the daemon never returns the value."
|
||||
},
|
||||
"settings.troubleshooting.section.title": {
|
||||
"message": "Debug bundle",
|
||||
"description": "Section heading: Debug bundle."
|
||||
|
||||
@@ -167,6 +167,12 @@
|
||||
"notify.sessionDeadlineRejected.body": {
|
||||
"message": "El servidor envió un plazo de sesión no válido. Inicie sesión de nuevo."
|
||||
},
|
||||
"notify.mdm.policyApplied.title": {
|
||||
"message": "Configuración de NetBird actualizada"
|
||||
},
|
||||
"notify.mdm.policyApplied.body": {
|
||||
"message": "Su configuración de NetBird fue actualizada por su política de TI."
|
||||
},
|
||||
"common.cancel": {
|
||||
"message": "Cancelar"
|
||||
},
|
||||
@@ -695,6 +701,9 @@
|
||||
"settings.advanced.psk.help": {
|
||||
"message": "PSK de WireGuard opcional para cifrado simétrico adicional. No es lo mismo que una clave de instalación de NetBird. Solo se comunicará con peers que usen la misma clave precompartida."
|
||||
},
|
||||
"settings.advanced.psk.configured": {
|
||||
"message": "Hay una clave precompartida configurada: introduzca una nueva para reemplazarla."
|
||||
},
|
||||
"settings.troubleshooting.section.title": {
|
||||
"message": "Paquete de diagnóstico"
|
||||
},
|
||||
|
||||
@@ -167,6 +167,12 @@
|
||||
"notify.sessionDeadlineRejected.body": {
|
||||
"message": "Le serveur a envoyé une échéance de session invalide. Veuillez vous reconnecter."
|
||||
},
|
||||
"notify.mdm.policyApplied.title": {
|
||||
"message": "Paramètres NetBird mis à jour"
|
||||
},
|
||||
"notify.mdm.policyApplied.body": {
|
||||
"message": "Votre configuration NetBird a été mise à jour par votre politique informatique."
|
||||
},
|
||||
"common.cancel": {
|
||||
"message": "Annuler"
|
||||
},
|
||||
@@ -695,6 +701,9 @@
|
||||
"settings.advanced.psk.help": {
|
||||
"message": "PSK WireGuard facultative pour un chiffrement symétrique supplémentaire. Différente d’une clé d’installation NetBird. Vous ne communiquerez qu’avec les pairs utilisant la même clé pré-partagée."
|
||||
},
|
||||
"settings.advanced.psk.configured": {
|
||||
"message": "Une clé pré-partagée est définie — saisissez-en une nouvelle pour la remplacer."
|
||||
},
|
||||
"settings.troubleshooting.section.title": {
|
||||
"message": "Lot de diagnostic"
|
||||
},
|
||||
|
||||
@@ -167,6 +167,12 @@
|
||||
"notify.sessionDeadlineRejected.body": {
|
||||
"message": "A szerver érvénytelen munkamenet-határidőt küldött. Kérjük, jelentkezzen be újra."
|
||||
},
|
||||
"notify.mdm.policyApplied.title": {
|
||||
"message": "NetBird beállítások frissítve"
|
||||
},
|
||||
"notify.mdm.policyApplied.body": {
|
||||
"message": "A NetBird konfigurációt az IT-szabályzat frissítette."
|
||||
},
|
||||
"common.cancel": {
|
||||
"message": "Mégse"
|
||||
},
|
||||
@@ -695,6 +701,9 @@
|
||||
"settings.advanced.psk.help": {
|
||||
"message": "Opcionális WireGuard PSK további szimmetrikus titkosításhoz. Nem azonos a NetBird telepítőkulccsal. Csak olyan Peerekkel kommunikál, akik ugyanazt a pre-shared kulcsot használják."
|
||||
},
|
||||
"settings.advanced.psk.configured": {
|
||||
"message": "Pre-shared kulcs be van állítva – új megadásával cserélhető."
|
||||
},
|
||||
"settings.troubleshooting.section.title": {
|
||||
"message": "Hibakeresési csomag"
|
||||
},
|
||||
|
||||
@@ -167,6 +167,12 @@
|
||||
"notify.sessionDeadlineRejected.body": {
|
||||
"message": "Il server ha inviato una scadenza di sessione non valida. Effettui di nuovo l'accesso."
|
||||
},
|
||||
"notify.mdm.policyApplied.title": {
|
||||
"message": "Impostazioni NetBird aggiornate"
|
||||
},
|
||||
"notify.mdm.policyApplied.body": {
|
||||
"message": "La configurazione di NetBird è stata aggiornata dalla policy IT."
|
||||
},
|
||||
"common.cancel": {
|
||||
"message": "Annulla"
|
||||
},
|
||||
@@ -695,6 +701,9 @@
|
||||
"settings.advanced.psk.help": {
|
||||
"message": "PSK WireGuard opzionale per una crittografia simmetrica aggiuntiva. Non è la stessa cosa di una chiave di configurazione NetBird. Comunicherà solo con i peer che usano la stessa chiave pre-condivisa."
|
||||
},
|
||||
"settings.advanced.psk.configured": {
|
||||
"message": "Una chiave pre-condivisa è impostata: ne inserisca una nuova per sostituirla."
|
||||
},
|
||||
"settings.troubleshooting.section.title": {
|
||||
"message": "Pacchetto di debug"
|
||||
},
|
||||
|
||||
@@ -167,6 +167,12 @@
|
||||
"notify.sessionDeadlineRejected.body": {
|
||||
"message": "O servidor enviou um prazo de sessão inválido. Faça login novamente."
|
||||
},
|
||||
"notify.mdm.policyApplied.title": {
|
||||
"message": "Definições do NetBird atualizadas"
|
||||
},
|
||||
"notify.mdm.policyApplied.body": {
|
||||
"message": "A sua configuração do NetBird foi atualizada pela política de TI."
|
||||
},
|
||||
"common.cancel": {
|
||||
"message": "Cancelar"
|
||||
},
|
||||
@@ -695,6 +701,9 @@
|
||||
"settings.advanced.psk.help": {
|
||||
"message": "PSK opcional do WireGuard para criptografia simétrica adicional. Não é o mesmo que uma chave de configuração do NetBird. Você só se comunicará com peers que usem a mesma chave pré-compartilhada."
|
||||
},
|
||||
"settings.advanced.psk.configured": {
|
||||
"message": "Uma chave pré-compartilhada está definida — introduza uma nova para a substituir."
|
||||
},
|
||||
"settings.troubleshooting.section.title": {
|
||||
"message": "Pacote de depuração"
|
||||
},
|
||||
|
||||
@@ -167,6 +167,12 @@
|
||||
"notify.sessionDeadlineRejected.body": {
|
||||
"message": "Сервер передал неверный срок действия сеанса. Пожалуйста, войдите снова."
|
||||
},
|
||||
"notify.mdm.policyApplied.title": {
|
||||
"message": "Настройки NetBird обновлены"
|
||||
},
|
||||
"notify.mdm.policyApplied.body": {
|
||||
"message": "Конфигурация NetBird была обновлена в соответствии с вашей ИТ-политикой."
|
||||
},
|
||||
"common.cancel": {
|
||||
"message": "Отмена"
|
||||
},
|
||||
@@ -695,6 +701,9 @@
|
||||
"settings.advanced.psk.help": {
|
||||
"message": "Необязательный PSK WireGuard для дополнительного симметричного шифрования. Это не то же самое, что ключ установки NetBird. Вы будете обмениваться данными только с пирами, использующими тот же общий ключ."
|
||||
},
|
||||
"settings.advanced.psk.configured": {
|
||||
"message": "Общий ключ установлен — введите новый, чтобы заменить его."
|
||||
},
|
||||
"settings.troubleshooting.section.title": {
|
||||
"message": "Отладочный пакет"
|
||||
},
|
||||
|
||||
@@ -167,6 +167,12 @@
|
||||
"notify.sessionDeadlineRejected.body": {
|
||||
"message": "服务器发送了无效的会话截止时间。请重新登录。"
|
||||
},
|
||||
"notify.mdm.policyApplied.title": {
|
||||
"message": "NetBird 设置已更新"
|
||||
},
|
||||
"notify.mdm.policyApplied.body": {
|
||||
"message": "您的 NetBird 配置已根据 IT 策略更新。"
|
||||
},
|
||||
"common.cancel": {
|
||||
"message": "取消"
|
||||
},
|
||||
@@ -695,6 +701,9 @@
|
||||
"settings.advanced.psk.help": {
|
||||
"message": "可选的 WireGuard PSK,用于额外的对称加密。它与 NetBird 设置密钥不同。您将只能与使用相同预共享密钥的对等节点通信。"
|
||||
},
|
||||
"settings.advanced.psk.configured": {
|
||||
"message": "已设置预共享密钥,输入新密钥即可替换。"
|
||||
},
|
||||
"settings.troubleshooting.section.title": {
|
||||
"message": "调试包"
|
||||
},
|
||||
|
||||
@@ -5,9 +5,28 @@ package services
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// mdmKeyToConfigField maps an MDM policy key (mdm.Key*) to the JSON field name
|
||||
// of the matching Config field, so GetConfig can translate the daemon's key
|
||||
// names to the frontend's field names in exactly one place. Mirrors the
|
||||
// conflict set the daemon enforces on SetConfig/Login (mdmManagedFieldConflicts);
|
||||
// keys with no settings field are absent.
|
||||
var mdmKeyToConfigField = map[string]string{
|
||||
mdm.KeyManagementURL: "managementUrl",
|
||||
mdm.KeyPreSharedKey: "preSharedKey",
|
||||
mdm.KeyWireguardPort: "wireguardPort",
|
||||
mdm.KeyRosenpassEnabled: "rosenpassEnabled",
|
||||
mdm.KeyRosenpassPermissive: "rosenpassPermissive",
|
||||
mdm.KeyDisableClientRoutes: "disableClientRoutes",
|
||||
mdm.KeyDisableServerRoutes: "disableServerRoutes",
|
||||
mdm.KeyAllowServerSSH: "serverSshAllowed",
|
||||
mdm.KeyDisableAutoConnect: "disableAutoConnect",
|
||||
mdm.KeyBlockInbound: "blockInbound",
|
||||
}
|
||||
|
||||
// ConfigParams selects which profile/user to read or write config for.
|
||||
type ConfigParams struct {
|
||||
ProfileName string `json:"profileName"`
|
||||
@@ -18,33 +37,51 @@ type ConfigParams struct {
|
||||
// Pointer fields mark "set" vs "unset" so the UI can omit a value to keep the
|
||||
// daemon's current setting (matching SetConfigRequest's optional semantics).
|
||||
type Config struct {
|
||||
ManagementURL string `json:"managementUrl"`
|
||||
AdminURL string `json:"adminUrl"`
|
||||
ConfigFile string `json:"configFile"`
|
||||
LogFile string `json:"logFile"`
|
||||
PreSharedKey string `json:"preSharedKey"`
|
||||
InterfaceName string `json:"interfaceName"`
|
||||
WireguardPort int64 `json:"wireguardPort"`
|
||||
MTU int64 `json:"mtu"`
|
||||
DisableAutoConnect bool `json:"disableAutoConnect"`
|
||||
ServerSSHAllowed bool `json:"serverSshAllowed"`
|
||||
RosenpassEnabled bool `json:"rosenpassEnabled"`
|
||||
RosenpassPermissive bool `json:"rosenpassPermissive"`
|
||||
DisableNotifications bool `json:"disableNotifications"`
|
||||
LazyConnectionEnabled bool `json:"lazyConnectionEnabled"`
|
||||
BlockInbound bool `json:"blockInbound"`
|
||||
NetworkMonitor bool `json:"networkMonitor"`
|
||||
DisableClientRoutes bool `json:"disableClientRoutes"`
|
||||
DisableServerRoutes bool `json:"disableServerRoutes"`
|
||||
DisableDNS bool `json:"disableDns"`
|
||||
DisableIPv6 bool `json:"disableIpv6"`
|
||||
BlockLANAccess bool `json:"blockLanAccess"`
|
||||
EnableSSHRoot bool `json:"enableSshRoot"`
|
||||
EnableSSHSFTP bool `json:"enableSshSftp"`
|
||||
EnableSSHLocalPortForwarding bool `json:"enableSshLocalPortForwarding"`
|
||||
EnableSSHRemotePortForwarding bool `json:"enableSshRemotePortForwarding"`
|
||||
DisableSSHAuth bool `json:"disableSshAuth"`
|
||||
SSHJWTCacheTTL int32 `json:"sshJwtCacheTtl"`
|
||||
ManagementURL string `json:"managementUrl"`
|
||||
AdminURL string `json:"adminUrl"`
|
||||
ConfigFile string `json:"configFile"`
|
||||
LogFile string `json:"logFile"`
|
||||
// PreSharedKeySet reports whether a pre-shared key is configured, without
|
||||
// exposing its value (the daemon redacts the PSK). The settings form shows
|
||||
// its own "configured" / "managed by MDM" placeholder when true and sends a
|
||||
// new PSK only when the user actually types one — the redaction sentinel
|
||||
// never crosses to the UI.
|
||||
PreSharedKeySet bool `json:"preSharedKeySet"`
|
||||
InterfaceName string `json:"interfaceName"`
|
||||
WireguardPort int64 `json:"wireguardPort"`
|
||||
MTU int64 `json:"mtu"`
|
||||
DisableAutoConnect bool `json:"disableAutoConnect"`
|
||||
ServerSSHAllowed bool `json:"serverSshAllowed"`
|
||||
RosenpassEnabled bool `json:"rosenpassEnabled"`
|
||||
RosenpassPermissive bool `json:"rosenpassPermissive"`
|
||||
DisableNotifications bool `json:"disableNotifications"`
|
||||
LazyConnectionEnabled bool `json:"lazyConnectionEnabled"`
|
||||
BlockInbound bool `json:"blockInbound"`
|
||||
NetworkMonitor bool `json:"networkMonitor"`
|
||||
DisableClientRoutes bool `json:"disableClientRoutes"`
|
||||
DisableServerRoutes bool `json:"disableServerRoutes"`
|
||||
DisableDNS bool `json:"disableDns"`
|
||||
DisableIPv6 bool `json:"disableIpv6"`
|
||||
BlockLANAccess bool `json:"blockLanAccess"`
|
||||
EnableSSHRoot bool `json:"enableSshRoot"`
|
||||
EnableSSHSFTP bool `json:"enableSshSftp"`
|
||||
EnableSSHLocalPortForwarding bool `json:"enableSshLocalPortForwarding"`
|
||||
EnableSSHRemotePortForwarding bool `json:"enableSshRemotePortForwarding"`
|
||||
DisableSSHAuth bool `json:"disableSshAuth"`
|
||||
SSHJWTCacheTTL int32 `json:"sshJwtCacheTtl"`
|
||||
// MDMManagedFields is the raw list of MDM-managed policy keys exactly as
|
||||
// the daemon reports them (mdm.Key* names, e.g. "managementURL",
|
||||
// "preSharedKey", "splitTunnelMode"). Includes keys with no settings
|
||||
// field (split-tunnel, metrics, the Disable* feature flags). The faithful
|
||||
// full set; prefer ManagedFields for per-field gating.
|
||||
MDMManagedFields []string `json:"mdmManagedFields"`
|
||||
// ManagedFields is the MDM-managed set normalised to Config JSON field
|
||||
// names (e.g. "managementUrl", "serverSshAllowed", "preSharedKey"), so the
|
||||
// settings form can gate a control with managedFields[fieldName] without
|
||||
// translating the daemon's mdm.Key* names. Only managed fields are present
|
||||
// (value true); keys with no settings field are omitted (the Disable*
|
||||
// feature flags come via GetFeatures instead).
|
||||
ManagedFields map[string]bool `json:"managedFields"`
|
||||
}
|
||||
|
||||
// SetConfigParams is a partial update — only fields with non-nil pointers
|
||||
@@ -114,7 +151,7 @@ func (s *Settings) GetConfig(ctx context.Context, p ConfigParams) (Config, error
|
||||
AdminURL: resp.GetAdminURL(),
|
||||
ConfigFile: resp.GetConfigFile(),
|
||||
LogFile: resp.GetLogFile(),
|
||||
PreSharedKey: resp.GetPreSharedKey(),
|
||||
PreSharedKeySet: resp.GetPreSharedKey() != "",
|
||||
InterfaceName: resp.GetInterfaceName(),
|
||||
WireguardPort: resp.GetWireguardPort(),
|
||||
MTU: resp.GetMtu(),
|
||||
@@ -137,6 +174,8 @@ func (s *Settings) GetConfig(ctx context.Context, p ConfigParams) (Config, error
|
||||
EnableSSHRemotePortForwarding: resp.GetEnableSSHRemotePortForwarding(),
|
||||
DisableSSHAuth: resp.GetDisableSSHAuth(),
|
||||
SSHJWTCacheTTL: resp.GetSshJWTCacheTTL(),
|
||||
MDMManagedFields: resp.GetMDMManagedFields(),
|
||||
ManagedFields: configManagedFields(resp.GetMDMManagedFields()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -194,3 +233,17 @@ func (s *Settings) GetFeatures(ctx context.Context) (Features, error) {
|
||||
DisableNetworks: resp.GetDisableNetworks(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// configManagedFields normalises the daemon's MDM-managed key list (mdm.Key*
|
||||
// names) to a set keyed by Config JSON field names, so the settings form can
|
||||
// look up a field's locked state directly. Returns a non-nil (possibly empty)
|
||||
// map so it marshals to {} rather than null.
|
||||
func configManagedFields(managed []string) map[string]bool {
|
||||
out := make(map[string]bool, len(managed))
|
||||
for _, k := range managed {
|
||||
if field, ok := mdmKeyToConfigField[k]; ok {
|
||||
out[field] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ const (
|
||||
notifyIDUpdatePrefix = "netbird-update-"
|
||||
notifyIDEvent = "netbird-event-"
|
||||
notifyIDTrayError = "netbird-tray-error"
|
||||
notifyIDMDMPolicy = "netbird-mdm-policy"
|
||||
|
||||
statusError = "Error"
|
||||
|
||||
@@ -199,6 +200,16 @@ type Tray struct {
|
||||
// succession and each may kick a refresh, but the ListNetworks fetch +
|
||||
// submenu rebuild + SetMenu must not run concurrently with itself.
|
||||
exitNodesRebuildMu sync.Mutex
|
||||
|
||||
// featureMu guards the daemon feature kill switches mirrored on the
|
||||
// tray. Fetched once at startup and refreshed on every config_changed
|
||||
// system event (the daemon re-applies MDM policy on each engine spawn
|
||||
// and signals it via that event). Folded into the Profiles and Exit
|
||||
// Node menu enablement by featuresDisabled so an operator- or
|
||||
// MDM-disabled surface greys out without a periodic GetFeatures poll.
|
||||
featureMu sync.Mutex
|
||||
disableProfiles bool
|
||||
disableNetworks bool
|
||||
}
|
||||
|
||||
func NewTray(app *application.App, window *application.WebviewWindow, svc TrayServices) *Tray {
|
||||
@@ -261,6 +272,10 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe
|
||||
// nil-deref).
|
||||
app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) {
|
||||
go t.loadProfiles()
|
||||
// Seed the feature kill switches so a DisableProfiles / DisableNetworks
|
||||
// policy already greys out the matching menus on the first paint
|
||||
// (config_changed events refresh them afterwards).
|
||||
go t.refreshFeatures()
|
||||
go t.runSessionExpiryTicker()
|
||||
// Notification-category registration must run after the Wails
|
||||
// notifications service Startup has populated wn.appName /
|
||||
@@ -376,6 +391,8 @@ func (t *Tray) relayoutMenu() {
|
||||
exitNodeEntries := append([]exitNodeEntry(nil), t.exitNodes...)
|
||||
t.exitNodesMu.Unlock()
|
||||
|
||||
disableProfiles, disableNetworks := t.featuresDisabled()
|
||||
|
||||
daemonUnavailable := strings.EqualFold(lastStatus, services.StatusDaemonUnavailable)
|
||||
connecting := strings.EqualFold(lastStatus, services.StatusConnecting)
|
||||
|
||||
@@ -402,13 +419,13 @@ func (t *Tray) relayoutMenu() {
|
||||
t.downItem.SetEnabled(connected || connecting)
|
||||
}
|
||||
if t.exitNodeItem != nil {
|
||||
t.exitNodeItem.SetEnabled(connected && len(exitNodeEntries) > 0)
|
||||
t.exitNodeItem.SetEnabled(connected && len(exitNodeEntries) > 0 && !disableNetworks)
|
||||
}
|
||||
if t.settingsItem != nil {
|
||||
t.settingsItem.SetEnabled(!daemonUnavailable)
|
||||
}
|
||||
if t.profileSubmenuItem != nil {
|
||||
t.profileSubmenuItem.SetEnabled(!daemonUnavailable)
|
||||
t.profileSubmenuItem.SetEnabled(!daemonUnavailable && !disableProfiles)
|
||||
}
|
||||
if daemonVersion != "" && t.daemonVersionItem != nil {
|
||||
t.daemonVersionItem.SetLabel(t.loc.T("tray.menu.daemonVersion", "version", daemonVersion))
|
||||
|
||||
@@ -6,8 +6,10 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/ui/authsession"
|
||||
"github.com/netbirdio/netbird/client/ui/services"
|
||||
)
|
||||
@@ -22,6 +24,39 @@ func (t *Tray) onSystemEvent(ev *application.CustomEvent) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// config_changed: the daemon re-applied its effective config (engine
|
||||
// spawn, Up, or MDM policy diff) and signals the UI to re-sync. It
|
||||
// carries no UserMessage, so it must be handled before the user-facing
|
||||
// message gate below. Re-fetch the feature kill switches (DisableProfiles
|
||||
// / DisableNetworks) and the notifications gate so CLI- or MDM-driven
|
||||
// changes reflect in the tray without a periodic poll. This replaces the
|
||||
// legacy Fyne UI's 2s GetFeatures poll.
|
||||
if se.Category == "system" && se.Metadata[proto.MetadataTypeKey] == proto.MetadataTypeConfigChanged {
|
||||
log.Infof("config_changed event received (source=%s); refreshing tray features", se.Metadata[proto.MetadataSourceKey])
|
||||
go t.refreshFeatures()
|
||||
go t.loadConfig()
|
||||
// An MDM-driven config change gets a user-facing toast so the
|
||||
// operator knows their IT policy was applied. The daemon also
|
||||
// emits a separate "policy_applied" event carrying an English
|
||||
// UserMessage, but that text has no locale context — it's
|
||||
// suppressed in shouldSkipSystemEvent and the tray builds the
|
||||
// localised toast here instead. Other sources (startup, up_rpc)
|
||||
// stay silent, matching the daemon's empty-UserMessage intent.
|
||||
// Gated by the notifications toggle like every other INFO event.
|
||||
if se.Metadata[proto.MetadataSourceKey] == proto.MetadataSourceMDM {
|
||||
t.profileMu.Lock()
|
||||
enabled := t.notificationsEnabled
|
||||
t.profileMu.Unlock()
|
||||
if enabled {
|
||||
t.notify(
|
||||
t.loc.T("notify.mdm.policyApplied.title"),
|
||||
t.loc.T("notify.mdm.policyApplied.body"),
|
||||
notifyIDMDMPolicy,
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
// Session-warning and deadline-rejected events carry no UserMessage —
|
||||
// the tray builds the localised notification body locally from metadata.
|
||||
// Every other event needs a non-empty UserMessage to show anything meaningful.
|
||||
@@ -112,6 +147,13 @@ func titleCase(s string) string {
|
||||
// partner already drove the user-facing toast, so the v6 row is
|
||||
// suppressed to avoid a duplicate notification)
|
||||
func shouldSkipSystemEvent(se services.SystemEvent) bool {
|
||||
// The daemon's MDM "policy_applied" event carries a hardcoded English
|
||||
// UserMessage. The tray shows its own localised toast on the paired
|
||||
// config_changed (source=mdm) event instead, so drop this one to avoid
|
||||
// a duplicate, non-localised notification.
|
||||
if se.Metadata[proto.MetadataTypeKey] == proto.MetadataTypePolicyApplied {
|
||||
return true
|
||||
}
|
||||
if _, isUpdate := se.Metadata["new_version_available"]; isUpdate {
|
||||
return true
|
||||
}
|
||||
|
||||
47
client/ui/tray_features.go
Normal file
47
client/ui/tray_features.go
Normal file
@@ -0,0 +1,47 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// refreshFeatures pulls the daemon's operator-disabled UI surfaces
|
||||
// (DisableProfiles / DisableNetworks) and re-applies the tray menu gating.
|
||||
// Called once at startup (ApplicationStarted) and on every config_changed
|
||||
// system event — the daemon re-applies its MDM policy on each engine spawn
|
||||
// and emits that event, so this is the tray's signal to re-sync the kill
|
||||
// switches. It replaces the legacy Fyne UI's 2s GetFeatures poll.
|
||||
func (t *Tray) refreshFeatures() {
|
||||
features, err := t.svc.Settings.GetFeatures(context.Background())
|
||||
if err != nil {
|
||||
log.Debugf("get features: %v", err)
|
||||
return
|
||||
}
|
||||
t.featureMu.Lock()
|
||||
changed := t.disableProfiles != features.DisableProfiles ||
|
||||
t.disableNetworks != features.DisableNetworks
|
||||
t.disableProfiles = features.DisableProfiles
|
||||
t.disableNetworks = features.DisableNetworks
|
||||
t.featureMu.Unlock()
|
||||
// Repaint only when a flag actually flipped: relayoutMenu rebuilds the
|
||||
// whole menu tree, so a no-op refresh (the common case) must not churn
|
||||
// it. relayoutMenu and fillProfileSubmenu read the cached flags via
|
||||
// featuresDisabled, so the new state applies regardless of which relayout
|
||||
// (this one, a status push, or a profile reload) runs last.
|
||||
if changed {
|
||||
t.relayoutMenu()
|
||||
}
|
||||
}
|
||||
|
||||
// featuresDisabled returns the cached DisableProfiles / DisableNetworks kill
|
||||
// switches under featureMu. Read by relayoutMenu, refreshMenuItemsForStatus,
|
||||
// and fillProfileSubmenu to grey out the Profiles and Exit Node menus when
|
||||
// the operator (or an MDM policy) disabled those surfaces server-side.
|
||||
func (t *Tray) featuresDisabled() (profiles, networks bool) {
|
||||
t.featureMu.Lock()
|
||||
defer t.featureMu.Unlock()
|
||||
return t.disableProfiles, t.disableNetworks
|
||||
}
|
||||
@@ -96,6 +96,13 @@ func (t *Tray) fillProfileSubmenu() {
|
||||
|
||||
sort.Slice(profiles, func(i, j int) bool { return profiles[i].Name < profiles[j].Name })
|
||||
|
||||
// When the daemon (or an MDM policy) disables profiles, the parent menu
|
||||
// is greyed out by relayoutMenu/refreshMenuItemsForStatus, but Wails'
|
||||
// systray does not reliably propagate a disabled parent to its children
|
||||
// on every platform — so disable each row and "Manage Profiles" too,
|
||||
// mirroring the legacy Fyne UI's profile.setEnabled lock.
|
||||
disableProfiles, _ := t.featuresDisabled()
|
||||
|
||||
t.profileSubmenu.Clear()
|
||||
var activeName, activeEmail string
|
||||
for _, p := range profiles {
|
||||
@@ -118,15 +125,18 @@ func (t *Tray) fillProfileSubmenu() {
|
||||
}
|
||||
t.switchProfile(name)
|
||||
})
|
||||
item.SetEnabled(!disableProfiles)
|
||||
if active {
|
||||
activeName = name
|
||||
activeEmail = p.Email
|
||||
}
|
||||
}
|
||||
t.profileSubmenu.AddSeparator()
|
||||
t.profileSubmenu.Add(t.loc.T("tray.menu.manageProfiles")).OnClick(func(*application.Context) {
|
||||
manageProfiles := t.profileSubmenu.Add(t.loc.T("tray.menu.manageProfiles"))
|
||||
manageProfiles.OnClick(func(*application.Context) {
|
||||
t.svc.WindowManager.OpenSettings("profiles")
|
||||
})
|
||||
manageProfiles.SetEnabled(!disableProfiles)
|
||||
log.Infof("tray fillProfileSubmenu: %d profile(s) for user %q, active=%q", len(profiles), username, activeName)
|
||||
if t.profileSubmenuItem != nil && activeName != "" {
|
||||
t.profileSubmenuItem.SetLabel(activeName)
|
||||
|
||||
@@ -153,8 +153,9 @@ func (t *Tray) refreshMenuItemsForStatus(st services.Status, connected bool) {
|
||||
if t.settingsItem != nil {
|
||||
t.settingsItem.SetEnabled(!daemonUnavailable)
|
||||
}
|
||||
disableProfiles, _ := t.featuresDisabled()
|
||||
if t.profileSubmenuItem != nil {
|
||||
t.profileSubmenuItem.SetEnabled(!daemonUnavailable)
|
||||
t.profileSubmenuItem.SetEnabled(!daemonUnavailable && !disableProfiles)
|
||||
}
|
||||
// Refresh the Profiles submenu on every status-text transition: the
|
||||
// daemon does not emit an active-profile event, so the startup race
|
||||
|
||||
126
docs/io.netbird.client.plist
Normal file
126
docs/io.netbird.client.plist
Normal file
@@ -0,0 +1,126 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<!--
|
||||
NetBird MDM preferences (macOS) — bare plist for MDM platforms that
|
||||
accept a managed-preferences plist tied to a bundle identifier
|
||||
(e.g. JumpCloud "Mac Application Custom Settings", Mosyle "Custom
|
||||
Settings", Jamf "Application & Custom Settings" → External
|
||||
Application).
|
||||
|
||||
Bundle identifier (preference domain): io.netbird.client
|
||||
|
||||
The MDM provider will wrap this plist into a Configuration Profile
|
||||
payload of type com.apple.ManagedClient.preferences and push it to
|
||||
target devices via the Apple MDM protocol. The OS materializes the
|
||||
final file at:
|
||||
/Library/Managed Preferences/io.netbird.client.plist
|
||||
which is what the NetBird daemon's client/mdm/policy_darwin.go
|
||||
loader reads on every 1-minute MDM reload tick.
|
||||
|
||||
For MDM platforms that expect a full Configuration Profile instead
|
||||
of a bare plist (Custom Configuration Profile / .mobileconfig upload),
|
||||
use docs/netbird-macos.mobileconfig — same keys, additional Payload*
|
||||
envelope.
|
||||
|
||||
Editing this file:
|
||||
- Remove or comment out any key you do NOT want to enforce. The
|
||||
daemon treats an absent key as "no enforcement" for that field.
|
||||
- Keep the document well-formed XML. Validate locally with:
|
||||
plutil -lint docs/io.netbird.client.plist
|
||||
- Keys are camelCase; values are typed (<string>, <true/>, <false/>,
|
||||
<integer>). See docs/src/pages/client/mdm-integration.mdx (the
|
||||
public docs page) for the full reference.
|
||||
|
||||
Persistence caveat:
|
||||
macOS wipes /Library/Managed Preferences/ at every boot on
|
||||
devices that are NOT MDM-enrolled. This plist only sticks across
|
||||
reboots when delivered through a real MDM channel. For local
|
||||
testing on an un-enrolled host, write the file manually as root
|
||||
and accept it will not survive the next boot.
|
||||
-->
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
|
||||
<!-- ===== Identity / auth ===== -->
|
||||
<key>managementURL</key>
|
||||
<string>https://api.netbird.io:443</string>
|
||||
|
||||
<!--
|
||||
Pre-shared key: secret. Remove the entry entirely when not used;
|
||||
do NOT leave an empty <string></string>, which the daemon would
|
||||
otherwise treat as a deliberate empty-PSK enforcement.
|
||||
-->
|
||||
<!--
|
||||
<key>preSharedKey</key>
|
||||
<string>REPLACE_ME</string>
|
||||
-->
|
||||
|
||||
<!-- ===== Engine / runtime behavior =====
|
||||
Each key is optional. Remove or comment out to leave the
|
||||
field unmanaged on the client. -->
|
||||
|
||||
<key>allowServerSSH</key>
|
||||
<true/>
|
||||
|
||||
<!--
|
||||
<key>disableAutoConnect</key>
|
||||
<false/>
|
||||
|
||||
<key>disableClientRoutes</key>
|
||||
<false/>
|
||||
|
||||
<key>disableServerRoutes</key>
|
||||
<false/>
|
||||
|
||||
<key>blockInbound</key>
|
||||
<false/>
|
||||
|
||||
<key>rosenpassEnabled</key>
|
||||
<true/>
|
||||
|
||||
<key>rosenpassPermissive</key>
|
||||
<false/>
|
||||
-->
|
||||
|
||||
<!-- ===== WireGuard UDP port =====
|
||||
Range 1-65535. Omit to keep the daemon default. -->
|
||||
<!--
|
||||
<key>wireguardPort</key>
|
||||
<integer>51820</integer>
|
||||
-->
|
||||
|
||||
<!-- ===== UI / lockdown kill switches =====
|
||||
disableUpdateSettings : block every config change from UI and CLI
|
||||
on this device (Settings view stays
|
||||
readable but read-only).
|
||||
disableProfiles : hide the profile menu, reject profile CRUD.
|
||||
disableNetworks : hide the Networks / Exit Node menus,
|
||||
reject the related RPCs.
|
||||
disableMetricsCollection: opt out of anonymous usage telemetry. -->
|
||||
<!--
|
||||
<key>disableUpdateSettings</key>
|
||||
<true/>
|
||||
|
||||
<key>disableProfiles</key>
|
||||
<true/>
|
||||
|
||||
<key>disableNetworks</key>
|
||||
<true/>
|
||||
|
||||
<key>disableMetricsCollection</key>
|
||||
<false/>
|
||||
-->
|
||||
|
||||
<!-- ===== Split tunnel =====
|
||||
Android-only at the client level. Safe to ship on macOS for
|
||||
mixed-platform fleets; the macOS daemon parses and ignores. -->
|
||||
<!--
|
||||
<key>splitTunnelMode</key>
|
||||
<string>allow</string>
|
||||
|
||||
<key>splitTunnelApps</key>
|
||||
<string>com.acme.app1,com.acme.app2</string>
|
||||
-->
|
||||
|
||||
</dict>
|
||||
</plist>
|
||||
159
docs/netbird-macos.mobileconfig
Normal file
159
docs/netbird-macos.mobileconfig
Normal file
@@ -0,0 +1,159 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<!--
|
||||
NetBird MDM configuration profile (macOS).
|
||||
|
||||
Wraps a `com.apple.ManagedClient.preferences` payload that pushes the
|
||||
NetBird MDM policy into:
|
||||
/Library/Managed Preferences/io.netbird.client.plist
|
||||
|
||||
Read at runtime by the netbird daemon's macOS loader
|
||||
(client/mdm/policy_darwin.go — Phase 2). Key names match the canonical
|
||||
lowerCamelCase form used in docs/netbird.admx and the mdm.Key*
|
||||
constants in client/mdm/policy.go.
|
||||
|
||||
Bundle identifier: io.netbird.client
|
||||
(confirm against the signed pkg before fleet roll-out)
|
||||
|
||||
Distribution:
|
||||
- sign with `productsign --sign "Developer ID Installer: ..." ...`
|
||||
before fleet roll-out (Apple-Configurator-2 won't install an
|
||||
unsigned profile on Sonoma+ without user override).
|
||||
- For local dev install: `sudo profiles install -path netbird-macos.mobileconfig`.
|
||||
- For MDM (Jamf/Kandji/Mosyle/Intune): upload as a Custom Profile.
|
||||
|
||||
Editing:
|
||||
- Replace UUID placeholders below with fresh UUIDs (`uuidgen` on
|
||||
macOS) when forking this template for a real fleet — each
|
||||
deployment should have unique UUIDs so the OS treats it as a
|
||||
distinct profile.
|
||||
- Tune the PayloadContent values to the policy you want to enforce.
|
||||
- Remove any key you do NOT want to enforce (the daemon treats an
|
||||
absent key as "no enforcement" for that field).
|
||||
|
||||
iOS note:
|
||||
This file is macOS-specific. iOS uses managed app config via
|
||||
UserDefaults[com.apple.configuration.managed] under a different
|
||||
payload type (com.apple.app.configuration.managed); the wrapper
|
||||
structure is the same but the inner payload dictionary differs.
|
||||
See docs/netbird-ios.mobileconfig (Phase 5) when shipped.
|
||||
-->
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Outer profile envelope -->
|
||||
<key>PayloadType</key>
|
||||
<string>Configuration</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>io.netbird.client.mdm</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>11111111-1111-1111-1111-111111111111</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>NetBird MDM Policy</string>
|
||||
<key>PayloadDescription</key>
|
||||
<string>Enforces NetBird client configuration. Values written here override any local user / CLI / on-disk setting and are re-applied at every daemon boot and on every 1-minute MDM reload tick.</string>
|
||||
<key>PayloadOrganization</key>
|
||||
<string>NetBird</string>
|
||||
<key>PayloadScope</key>
|
||||
<string>System</string>
|
||||
<key>PayloadRemovalDisallowed</key>
|
||||
<false/>
|
||||
|
||||
<key>PayloadContent</key>
|
||||
<array>
|
||||
<dict>
|
||||
<!-- Managed preferences payload: writes /Library/Managed Preferences/io.netbird.client.plist -->
|
||||
<key>PayloadType</key>
|
||||
<string>com.apple.ManagedClient.preferences</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>io.netbird.client.mdm.preferences</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>22222222-2222-2222-2222-222222222222</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>NetBird Managed Preferences</string>
|
||||
<key>PayloadEnabled</key>
|
||||
<true/>
|
||||
|
||||
<key>PayloadContent</key>
|
||||
<dict>
|
||||
<key>io.netbird.client</key>
|
||||
<dict>
|
||||
<key>Forced</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>mcx_preference_settings</key>
|
||||
<dict>
|
||||
|
||||
<!-- ===== Identity / auth (strings) ===== -->
|
||||
<key>managementURL</key>
|
||||
<string>https://api.netbird.io:443</string>
|
||||
|
||||
<!-- Pre-shared key: secret. Remove the entry entirely
|
||||
when not used; do NOT leave an empty string. -->
|
||||
<!--
|
||||
<key>preSharedKey</key>
|
||||
<string>REPLACE_ME</string>
|
||||
-->
|
||||
|
||||
<!-- ===== Engine / runtime behavior (bool) =====
|
||||
Remove any key to leave the field unmanaged. -->
|
||||
<!--
|
||||
<key>disableAutoConnect</key>
|
||||
<false/>
|
||||
<key>disableClientRoutes</key>
|
||||
<false/>
|
||||
<key>disableServerRoutes</key>
|
||||
<false/>
|
||||
<key>blockInbound</key>
|
||||
<false/>
|
||||
-->
|
||||
<key>allowServerSSH</key>
|
||||
<true/>
|
||||
<!--
|
||||
<key>rosenpassEnabled</key>
|
||||
<true/>
|
||||
<key>rosenpassPermissive</key>
|
||||
<false/>
|
||||
-->
|
||||
|
||||
<!-- ===== WireGuard UDP port (int) =====
|
||||
Range 1-65535. Omit to keep the default. -->
|
||||
<!--
|
||||
<key>wireguardPort</key>
|
||||
<integer>51820</integer>
|
||||
-->
|
||||
|
||||
<!-- ===== Split tunnel (Android-only at the daemon level)
|
||||
Pushed harmlessly on macOS for fleets with mixed
|
||||
desktop+mobile devices; the macOS daemon ignores it. -->
|
||||
<!--
|
||||
<key>splitTunnelMode</key>
|
||||
<string>allow</string>
|
||||
<key>splitTunnelApps</key>
|
||||
<string>com.acme.app1,com.acme.app2</string>
|
||||
-->
|
||||
|
||||
<!-- ===== UI / kill switches (bool) ===== -->
|
||||
<!--
|
||||
<key>disableUpdateSettings</key>
|
||||
<true/>
|
||||
<key>disableProfiles</key>
|
||||
<true/>
|
||||
<key>disableNetworks</key>
|
||||
<true/>
|
||||
<key>disableMetricsCollection</key>
|
||||
<false/>
|
||||
-->
|
||||
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
189
docs/netbird-macos.sh
Normal file
189
docs/netbird-macos.sh
Normal file
@@ -0,0 +1,189 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# SYNOPSIS
|
||||
# Push the NetBird MDM policy to a macOS device via JumpCloud Commands.
|
||||
#
|
||||
# DESCRIPTION
|
||||
# This is the macOS counterpart of docs/netbird-policy.reg.ps1.
|
||||
# It writes the values declared in the "POLICY VALUES" block below to
|
||||
# the managed-preferences plist that the NetBird daemon's
|
||||
# client/mdm/policy_darwin.go loader reads on every 1-minute MDM
|
||||
# reload tick:
|
||||
#
|
||||
# /Library/Managed Preferences/io.netbird.client.plist
|
||||
#
|
||||
# Once the plist lands, the daemon picks up the new values without
|
||||
# restart (the ticker calls Config.apply() → applyMDMPolicy() and
|
||||
# restarts the engine on diff).
|
||||
#
|
||||
# DEPLOYMENT (JumpCloud)
|
||||
# 1. Admin Console -> Device Management -> Commands -> +.
|
||||
# 2. Type: Mac, Shell, Run as: root.
|
||||
# 3. Paste this file verbatim into the command body.
|
||||
# 4. Bind to the target system group, save, run.
|
||||
#
|
||||
# IMPORTANT: PERSISTENCE
|
||||
# macOS wipes /Library/Managed Preferences/ at every boot on devices
|
||||
# that are NOT MDM-enrolled. For a persistent fleet rollout, push the
|
||||
# companion docs/netbird-macos.mobileconfig as a Custom Configuration
|
||||
# Profile (Admin Console -> MDM -> Mac Custom Configuration Profiles)
|
||||
# instead of this script. Use this script when:
|
||||
# - the device is MDM-enrolled (file survives reboots), or
|
||||
# - you need a one-shot test push before reboot, or
|
||||
# - you orchestrate via JumpCloud Commands and want the same
|
||||
# variable-driven workflow as the Windows .ps1 sibling.
|
||||
#
|
||||
# IDEMPOTENCY: re-running with the same values is a no-op from the
|
||||
# daemon's point of view (the 1-minute reload ticker diff returns empty).
|
||||
#
|
||||
# SECURITY: PreSharedKey is redacted in this script's log output.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
### POLICY VALUES — EDIT THIS BLOCK ###########################################
|
||||
#
|
||||
# Set each variable below to the desired value. Set to empty string ""
|
||||
# or to NULL to omit a key entirely (the daemon treats an absent key
|
||||
# as "no enforcement" for that field). Booleans use "true"/"false"
|
||||
# (lowercase). Integers as decimal.
|
||||
#
|
||||
# Reference for key names + accepted values:
|
||||
# client/mdm/policy.go (Key* constants)
|
||||
# docs/netbird-macos.mobileconfig (sample profile)
|
||||
# docs/netbird.admx + .adml (Windows ADMX schema)
|
||||
#
|
||||
NULL='__UNSET__'
|
||||
managementURL='https://api.netbird.io:443'
|
||||
preSharedKey="$NULL" # secret; redacted in log
|
||||
allowServerSSH='true'
|
||||
blockInbound="$NULL"
|
||||
disableAutoConnect="$NULL"
|
||||
disableClientRoutes="$NULL"
|
||||
disableServerRoutes="$NULL"
|
||||
disableMetricsCollection="$NULL"
|
||||
disableUpdateSettings="$NULL"
|
||||
disableProfiles="$NULL"
|
||||
disableNetworks="$NULL"
|
||||
rosenpassEnabled="$NULL"
|
||||
rosenpassPermissive="$NULL"
|
||||
wireguardPort='51820'
|
||||
splitTunnelMode="$NULL" # "allow" or "disallow", Android-only at the daemon level
|
||||
splitTunnelApps="$NULL" # comma-separated app IDs, Android-only
|
||||
##############################################################################
|
||||
|
||||
readonly PLIST_DIR='/Library/Managed Preferences'
|
||||
readonly PLIST_PATH="$PLIST_DIR/io.netbird.client.plist"
|
||||
readonly LOG_TAG='netbird-mdm'
|
||||
|
||||
# log sends a message to the system logger using the configured tag and echoes the message to stdout prefixed by an ISO 8601 UTC timestamp and the tag.
|
||||
log() {
|
||||
/usr/bin/logger -t "$LOG_TAG" "$*"
|
||||
printf '%s [%s] %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$LOG_TAG" "$*"
|
||||
}
|
||||
|
||||
# is_set returns success if the provided value is non-empty and is not equal to the special NULL marker.
|
||||
is_set() {
|
||||
local value="$1"
|
||||
[[ -n "$value" && "$value" != "$NULL" ]]
|
||||
}
|
||||
|
||||
# start_plist creates the temporary plist file at "$PLIST_PATH.tmp" containing the XML plist header and opening `<dict>` for the policy plist.
|
||||
start_plist() {
|
||||
cat > "$PLIST_PATH.tmp" <<'EOF'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
EOF
|
||||
}
|
||||
|
||||
# end_plist appends the closing `</dict>` and `</plist>` tags to the temporary plist file.
|
||||
end_plist() {
|
||||
cat >> "$PLIST_PATH.tmp" <<'EOF'
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
}
|
||||
|
||||
# emit_string appends a plist `<key>`/`<string>` entry for the given key and value to "$PLIST_PATH.tmp", XML-escaping `&`, `<`, and `>`, and logs the assignment (masking the logged value as `********** (secret)` when the key is `preSharedKey`).
|
||||
emit_string() {
|
||||
local key="$1" value="$2" log_value="$2"
|
||||
# Escape XML entities in the value
|
||||
local escaped
|
||||
escaped="$(printf '%s' "$value" | sed -e 's/&/\&/g' -e 's/</\</g' -e 's/>/\>/g')"
|
||||
printf ' <key>%s</key>\n <string>%s</string>\n' "$key" "$escaped" >> "$PLIST_PATH.tmp"
|
||||
if [[ "$key" == "preSharedKey" ]]; then
|
||||
log_value='********** (secret)'
|
||||
fi
|
||||
log "set $key = $log_value"
|
||||
}
|
||||
|
||||
# emit_bool writes a boolean plist entry for a given key into the temporary plist file.
|
||||
# emit_bool writes a boolean plist entry for a key when the provided value matches an accepted boolean token; logs an error and skips the key on invalid input.
|
||||
emit_bool() {
|
||||
local key="$1" value="$2"
|
||||
local xml_bool
|
||||
case "$value" in
|
||||
true|True|TRUE|1|yes) xml_bool='<true/>' ; value='true' ;;
|
||||
false|False|FALSE|0|no) xml_bool='<false/>' ; value='false' ;;
|
||||
*) log "invalid boolean for $key: $value (must be true/false); skipping"; return ;;
|
||||
esac
|
||||
printf ' <key>%s</key>\n %s\n' "$key" "$xml_bool" >> "$PLIST_PATH.tmp"
|
||||
log "set $key = $value"
|
||||
}
|
||||
|
||||
# emit_int validates that VALUE contains only decimal digits and, if valid, appends an `<integer>` plist entry for KEY to the temporary plist (`$PLIST_PATH.tmp`) and logs the assignment; on invalid input it logs a skip and does not emit the key.
|
||||
emit_int() {
|
||||
local key="$1" value="$2"
|
||||
if ! [[ "$value" =~ ^[0-9]+$ ]]; then
|
||||
log "invalid integer for $key: $value (must be decimal); skipping"
|
||||
return
|
||||
fi
|
||||
printf ' <key>%s</key>\n <integer>%s</integer>\n' "$key" "$value" >> "$PLIST_PATH.tmp"
|
||||
log "set $key = $value"
|
||||
}
|
||||
|
||||
# main builds the NetBird MDM plist from configured policy variables, validates and installs it to /Library/Managed Preferences/io.netbird.client.plist (root:wheel, 644) and optionally triggers the NetBird daemon to reload.
|
||||
main() {
|
||||
log "applying NetBird MDM policy to $PLIST_PATH"
|
||||
/bin/mkdir -p "$PLIST_DIR"
|
||||
start_plist
|
||||
|
||||
is_set "$managementURL" && emit_string managementURL "$managementURL"
|
||||
is_set "$preSharedKey" && emit_string preSharedKey "$preSharedKey"
|
||||
is_set "$allowServerSSH" && emit_bool allowServerSSH "$allowServerSSH"
|
||||
is_set "$blockInbound" && emit_bool blockInbound "$blockInbound"
|
||||
is_set "$disableAutoConnect" && emit_bool disableAutoConnect "$disableAutoConnect"
|
||||
is_set "$disableClientRoutes" && emit_bool disableClientRoutes "$disableClientRoutes"
|
||||
is_set "$disableServerRoutes" && emit_bool disableServerRoutes "$disableServerRoutes"
|
||||
is_set "$disableMetricsCollection" && emit_bool disableMetricsCollection "$disableMetricsCollection"
|
||||
is_set "$disableUpdateSettings" && emit_bool disableUpdateSettings "$disableUpdateSettings"
|
||||
is_set "$disableProfiles" && emit_bool disableProfiles "$disableProfiles"
|
||||
is_set "$disableNetworks" && emit_bool disableNetworks "$disableNetworks"
|
||||
is_set "$rosenpassEnabled" && emit_bool rosenpassEnabled "$rosenpassEnabled"
|
||||
is_set "$rosenpassPermissive" && emit_bool rosenpassPermissive "$rosenpassPermissive"
|
||||
is_set "$wireguardPort" && emit_int wireguardPort "$wireguardPort"
|
||||
is_set "$splitTunnelMode" && emit_string splitTunnelMode "$splitTunnelMode"
|
||||
is_set "$splitTunnelApps" && emit_string splitTunnelApps "$splitTunnelApps"
|
||||
|
||||
end_plist
|
||||
|
||||
if ! /usr/bin/plutil -lint "$PLIST_PATH.tmp" >/dev/null 2>&1; then
|
||||
log "ERROR: generated plist failed plutil lint; not installing"
|
||||
/usr/bin/plutil -lint "$PLIST_PATH.tmp" >&2 || true
|
||||
/bin/rm -f "$PLIST_PATH.tmp"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
/bin/mv -f "$PLIST_PATH.tmp" "$PLIST_PATH"
|
||||
/usr/sbin/chown root:wheel "$PLIST_PATH"
|
||||
/bin/chmod 644 "$PLIST_PATH"
|
||||
|
||||
log "policy installed; NetBird daemon will pick it up within the next 1-minute reload tick"
|
||||
|
||||
# Optional: kick the daemon for an immediate apply. Safe — does
|
||||
# nothing on a host where NetBird is not yet installed.
|
||||
/bin/launchctl kickstart -k system/io.netbird.client 2>/dev/null || true
|
||||
}
|
||||
|
||||
main "$@"
|
||||
BIN
docs/netbird-policy.reg
Normal file
BIN
docs/netbird-policy.reg
Normal file
Binary file not shown.
94
docs/netbird-policy.reg.ps1
Normal file
94
docs/netbird-policy.reg.ps1
Normal file
@@ -0,0 +1,94 @@
|
||||
#requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Push the NetBird MDM policy to a Windows device via JumpCloud Commands
|
||||
by importing a sidecar netbird-policy.reg file.
|
||||
|
||||
.DESCRIPTION
|
||||
Windows counterpart of docs/netbird-macos.sh. Outcome:
|
||||
HKLM\Software\Policies\NetBird populated from the attached
|
||||
netbird-policy.reg file, daemon picks up the change via the
|
||||
1-minute MDM reload ticker.
|
||||
|
||||
Deployment:
|
||||
1. Admin Console -> Device Management -> Commands -> +.
|
||||
2. Type: Windows PowerShell. Run as: SYSTEM.
|
||||
3. Paste this file verbatim into the command body.
|
||||
4. In the same command, attach `netbird-policy.reg` as a file.
|
||||
JumpCloud copies attached files into the command's working
|
||||
directory before invoking the script, so `$PSScriptRoot` or
|
||||
Get-Location resolves to where the .reg lives.
|
||||
5. Bind to the target system group, save, run.
|
||||
|
||||
Producing the .reg file:
|
||||
On a reference machine, after configuring the policy values either
|
||||
via gpedit (GPO) or manual `reg add`, export with:
|
||||
|
||||
reg export "HKLM\Software\Policies\NetBird" netbird-policy.reg /y
|
||||
|
||||
Then attach the resulting file to the JumpCloud command.
|
||||
|
||||
Semantics:
|
||||
- The script nukes the existing HKLM\Software\Policies\NetBird key
|
||||
before importing the .reg, so the .reg is the SINGLE SOURCE OF
|
||||
TRUTH. Any value present in the registry but absent from the .reg
|
||||
is removed. This is what an MDM admin almost always wants.
|
||||
- Setting the .reg to an empty (header-only) file effectively unsets
|
||||
the policy.
|
||||
|
||||
Idempotency: re-running the script with the same .reg is a no-op from
|
||||
the daemon's perspective (values identical → 1-min ticker sees no
|
||||
diff → engine not restarted).
|
||||
|
||||
Exit codes: 0 = success; 1 = .reg missing or reg.exe error.
|
||||
#>
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$RegFileName = "netbird-policy.reg"
|
||||
$RegKey = "HKLM\Software\Policies\NetBird"
|
||||
|
||||
# Resolve the attached .reg file: JumpCloud copies command attachments
|
||||
# into C:\Windows\Temp\ before invoking the script. Cwd / $PSScriptRoot
|
||||
# fallbacks cover the local-dev case where you might dot-source this
|
||||
# from elsewhere.
|
||||
$candidates = @(
|
||||
(Join-Path "$env:WINDIR\Temp" $RegFileName)
|
||||
(Join-Path (Get-Location) $RegFileName)
|
||||
(Join-Path $PSScriptRoot $RegFileName)
|
||||
) | Where-Object { Test-Path $_ }
|
||||
|
||||
if ($candidates.Count -eq 0) {
|
||||
Write-Error "[netbird-mdm] $RegFileName not found in working directory or `$PSScriptRoot. Attach the file to the JumpCloud command."
|
||||
exit 1
|
||||
}
|
||||
$regFile = $candidates[0]
|
||||
Write-Host "[netbird-mdm] using $regFile"
|
||||
|
||||
# Wipe the existing policy key so the .reg is authoritative.
|
||||
$existed = Test-Path "Registry::HKEY_LOCAL_MACHINE\Software\Policies\NetBird"
|
||||
if ($existed) {
|
||||
& reg.exe delete $RegKey /f | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "[netbird-mdm] failed to clear $RegKey before import (exit $LASTEXITCODE)"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "[netbird-mdm] cleared previous values under $RegKey"
|
||||
}
|
||||
|
||||
# Import. reg.exe writes both data and (re-)creates the key if needed.
|
||||
& reg.exe import $regFile
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "[netbird-mdm] reg import failed (exit $LASTEXITCODE)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Audit dump so the JumpCloud per-execution log captures the applied state.
|
||||
Write-Host "[netbird-mdm] final policy state under $RegKey :"
|
||||
& reg.exe query $RegKey /s
|
||||
|
||||
# Daemon's 1-min reload ticker picks up the change automatically.
|
||||
# Uncomment to force immediate convergence (skips the ticker wait):
|
||||
# Restart-Service netbird -Force -ErrorAction SilentlyContinue
|
||||
|
||||
exit 0
|
||||
95
docs/netbird.adml
Normal file
95
docs/netbird.adml
Normal file
@@ -0,0 +1,95 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<policyDefinitionResources xmlns:xsd="http://www.w3.org/2001/XMLSchema"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
revision="1.0"
|
||||
schemaVersion="1.0"
|
||||
xmlns="http://schemas.microsoft.com/GroupPolicy/2006/07/PolicyDefinitions">
|
||||
<displayName>NetBird Client Policies</displayName>
|
||||
<description>Group Policy template for NetBird client MDM-managed settings. Values are written under HKLM\Software\Policies\NetBird and consumed by the netbird daemon at startup and every 1-minute reload tick.</description>
|
||||
<resources>
|
||||
<stringTable>
|
||||
|
||||
<!-- Categories -->
|
||||
<string id="NetBird_Category">NetBird</string>
|
||||
<string id="SUPPORTED_NetBird_All">NetBird Client 0.40+</string>
|
||||
|
||||
<!-- Identity / auth -->
|
||||
<string id="ManagementURL_Name">Management URL</string>
|
||||
<string id="ManagementURL_Help">URL of the NetBird management server. Format: https://host[:port]. When set, users cannot override this value via UI or CLI.</string>
|
||||
|
||||
<string id="PreSharedKey_Name">Pre-shared key</string>
|
||||
<string id="PreSharedKey_Help">WireGuard pre-shared key used as an additional symmetric secret on every peer-to-peer tunnel. Secret value.</string>
|
||||
|
||||
<!-- Settings: engine / runtime behavior -->
|
||||
<string id="DisableAutoConnect_Name">Disable auto-connect</string>
|
||||
<string id="DisableAutoConnect_Help">When enabled, the NetBird tunnel does not auto-connect at daemon startup. Equivalent to --disable-auto-connect.</string>
|
||||
|
||||
<string id="DisableClientRoutes_Name">Disable client routes</string>
|
||||
<string id="DisableClientRoutes_Help">When enabled, this client will not consume routes advertised by routing peers. Equivalent to --disable-client-routes.</string>
|
||||
|
||||
<string id="DisableServerRoutes_Name">Disable server routes</string>
|
||||
<string id="DisableServerRoutes_Help">When enabled, this client will not act as a routing peer for other clients. Equivalent to --disable-server-routes.</string>
|
||||
|
||||
<string id="BlockInbound_Name">Block inbound</string>
|
||||
<string id="BlockInbound_Help">When enabled, the client firewall blocks all inbound peer traffic on the WireGuard interface. Equivalent to --block-inbound.</string>
|
||||
|
||||
<string id="AllowServerSSH_Name">Allow server SSH</string>
|
||||
<string id="AllowServerSSH_Help">When enabled, this client accepts incoming SSH sessions via NetBird SSH. Equivalent to --allow-server-ssh.</string>
|
||||
|
||||
<string id="RosenpassEnabled_Name">Enable Rosenpass</string>
|
||||
<string id="RosenpassEnabled_Help">Enables Rosenpass post-quantum key exchange on WireGuard tunnels. Both peers must support it.</string>
|
||||
|
||||
<string id="RosenpassPermissive_Name">Rosenpass permissive</string>
|
||||
<string id="RosenpassPermissive_Help">When enabled, the client falls back to plain WireGuard if a peer does not support Rosenpass; otherwise it refuses the connection.</string>
|
||||
|
||||
<string id="WireguardPort_Name">WireGuard port</string>
|
||||
<string id="WireguardPort_Help">UDP port used by the local WireGuard interface. Allowed range: 1-65535.</string>
|
||||
|
||||
<string id="SplitTunnel_Name">Split tunnel</string>
|
||||
<string id="SplitTunnel_Help">Restrict the NetBird tunnel to or from a chosen list of application package names. Choose either the allow mode (only the listed apps route through NetBird) or the disallow mode (the listed apps bypass NetBird; everything else routes through). The mode is mutually exclusive — only one can be active at a time. Android-only at the daemon level; Windows/macOS/iOS clients ignore this policy.</string>
|
||||
<string id="SplitTunnel_Allow">Allow only listed apps (everything else bypasses)</string>
|
||||
<string id="SplitTunnel_Disallow">Disallow listed apps (everything else routes)</string>
|
||||
|
||||
<!-- UI -->
|
||||
<string id="DisableUpdateSettings_Name">Disable update settings</string>
|
||||
<string id="DisableUpdateSettings_Help">When enabled, blocks every configuration change from the client UI and from the CLI (netbird up / login / setconfig). The Settings view stays viewable but read-only. Equivalent to --disable-update-settings.</string>
|
||||
|
||||
<string id="DisableProfiles_Name">Disable profiles</string>
|
||||
<string id="DisableProfiles_Help">When enabled, the client UI/CLI cannot list, create, switch or remove NetBird connection profiles. Equivalent to --disable-profiles.</string>
|
||||
|
||||
<string id="DisableNetworks_Name">Disable networks</string>
|
||||
<string id="DisableNetworks_Help">When enabled, the client UI/CLI cannot list, select or deselect NetBird networks (the corresponding daemon RPCs return Unavailable). Equivalent to --disable-networks.</string>
|
||||
|
||||
<string id="DisableMetricsCollection_Name">Disable metrics collection</string>
|
||||
<string id="DisableMetricsCollection_Help">When enabled, the client does not collect or report local usage metrics.</string>
|
||||
|
||||
</stringTable>
|
||||
<presentationTable>
|
||||
|
||||
<presentation id="ManagementURL_Pres">
|
||||
<textBox refId="ManagementURL_Text">
|
||||
<label>Management URL:</label>
|
||||
<defaultValue>https://api.netbird.io:443</defaultValue>
|
||||
</textBox>
|
||||
</presentation>
|
||||
|
||||
<presentation id="PreSharedKey_Pres">
|
||||
<textBox refId="PreSharedKey_Text">
|
||||
<label>Pre-shared key:</label>
|
||||
</textBox>
|
||||
</presentation>
|
||||
|
||||
<presentation id="WireguardPort_Pres">
|
||||
<decimalTextBox refId="WireguardPort_Decimal" defaultValue="51820">WireGuard UDP port:</decimalTextBox>
|
||||
</presentation>
|
||||
|
||||
<presentation id="SplitTunnel_Pres">
|
||||
<dropdownList refId="SplitTunnel_Mode" defaultItem="0">Mode:</dropdownList>
|
||||
<textBox refId="SplitTunnel_Apps">
|
||||
<label>Package names (comma-separated):</label>
|
||||
</textBox>
|
||||
</presentation>
|
||||
|
||||
</presentationTable>
|
||||
</resources>
|
||||
</policyDefinitionResources>
|
||||
223
docs/netbird.admx
Normal file
223
docs/netbird.admx
Normal file
@@ -0,0 +1,223 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<policyDefinitions xmlns:xsd="http://www.w3.org/2001/XMLSchema"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
revision="1.0"
|
||||
schemaVersion="1.0"
|
||||
xmlns="http://schemas.microsoft.com/GroupPolicy/2006/07/PolicyDefinitions">
|
||||
<policyNamespaces>
|
||||
<target prefix="netbird" namespace="NetBird.Policies.Client" />
|
||||
</policyNamespaces>
|
||||
<resources minRequiredRevision="1.0" />
|
||||
<supportedOn>
|
||||
<definitions>
|
||||
<definition name="SUPPORTED_NetBird_All" displayName="$(string.SUPPORTED_NetBird_All)" />
|
||||
</definitions>
|
||||
</supportedOn>
|
||||
<categories>
|
||||
<category name="NetBird" displayName="$(string.NetBird_Category)" />
|
||||
</categories>
|
||||
<policies>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- TOP-LEVEL: foundational identity / authentication -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<policy name="ManagementURL"
|
||||
class="Machine"
|
||||
displayName="$(string.ManagementURL_Name)"
|
||||
explainText="$(string.ManagementURL_Help)"
|
||||
key="Software\Policies\NetBird"
|
||||
presentation="$(presentation.ManagementURL_Pres)">
|
||||
<parentCategory ref="NetBird" />
|
||||
<supportedOn ref="SUPPORTED_NetBird_All" />
|
||||
<elements>
|
||||
<text id="ManagementURL_Text" valueName="ManagementURL" required="true" />
|
||||
</elements>
|
||||
</policy>
|
||||
|
||||
<policy name="PreSharedKey"
|
||||
class="Machine"
|
||||
displayName="$(string.PreSharedKey_Name)"
|
||||
explainText="$(string.PreSharedKey_Help)"
|
||||
key="Software\Policies\NetBird"
|
||||
presentation="$(presentation.PreSharedKey_Pres)">
|
||||
<parentCategory ref="NetBird" />
|
||||
<supportedOn ref="SUPPORTED_NetBird_All" />
|
||||
<elements>
|
||||
<text id="PreSharedKey_Text" valueName="PreSharedKey" />
|
||||
</elements>
|
||||
</policy>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- SETTINGS: engine / runtime / connection behavior -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<policy name="DisableAutoConnect"
|
||||
class="Machine"
|
||||
displayName="$(string.DisableAutoConnect_Name)"
|
||||
explainText="$(string.DisableAutoConnect_Help)"
|
||||
key="Software\Policies\NetBird"
|
||||
valueName="DisableAutoConnect">
|
||||
<parentCategory ref="NetBird" />
|
||||
<supportedOn ref="SUPPORTED_NetBird_All" />
|
||||
<enabledValue><decimal value="1" /></enabledValue>
|
||||
<disabledValue><decimal value="0" /></disabledValue>
|
||||
</policy>
|
||||
|
||||
<policy name="DisableClientRoutes"
|
||||
class="Machine"
|
||||
displayName="$(string.DisableClientRoutes_Name)"
|
||||
explainText="$(string.DisableClientRoutes_Help)"
|
||||
key="Software\Policies\NetBird"
|
||||
valueName="DisableClientRoutes">
|
||||
<parentCategory ref="NetBird" />
|
||||
<supportedOn ref="SUPPORTED_NetBird_All" />
|
||||
<enabledValue><decimal value="1" /></enabledValue>
|
||||
<disabledValue><decimal value="0" /></disabledValue>
|
||||
</policy>
|
||||
|
||||
<policy name="DisableServerRoutes"
|
||||
class="Machine"
|
||||
displayName="$(string.DisableServerRoutes_Name)"
|
||||
explainText="$(string.DisableServerRoutes_Help)"
|
||||
key="Software\Policies\NetBird"
|
||||
valueName="DisableServerRoutes">
|
||||
<parentCategory ref="NetBird" />
|
||||
<supportedOn ref="SUPPORTED_NetBird_All" />
|
||||
<enabledValue><decimal value="1" /></enabledValue>
|
||||
<disabledValue><decimal value="0" /></disabledValue>
|
||||
</policy>
|
||||
|
||||
<policy name="BlockInbound"
|
||||
class="Machine"
|
||||
displayName="$(string.BlockInbound_Name)"
|
||||
explainText="$(string.BlockInbound_Help)"
|
||||
key="Software\Policies\NetBird"
|
||||
valueName="BlockInbound">
|
||||
<parentCategory ref="NetBird" />
|
||||
<supportedOn ref="SUPPORTED_NetBird_All" />
|
||||
<enabledValue><decimal value="1" /></enabledValue>
|
||||
<disabledValue><decimal value="0" /></disabledValue>
|
||||
</policy>
|
||||
|
||||
<policy name="AllowServerSSH"
|
||||
class="Machine"
|
||||
displayName="$(string.AllowServerSSH_Name)"
|
||||
explainText="$(string.AllowServerSSH_Help)"
|
||||
key="Software\Policies\NetBird"
|
||||
valueName="AllowServerSSH">
|
||||
<parentCategory ref="NetBird" />
|
||||
<supportedOn ref="SUPPORTED_NetBird_All" />
|
||||
<enabledValue><decimal value="1" /></enabledValue>
|
||||
<disabledValue><decimal value="0" /></disabledValue>
|
||||
</policy>
|
||||
|
||||
<policy name="RosenpassEnabled"
|
||||
class="Machine"
|
||||
displayName="$(string.RosenpassEnabled_Name)"
|
||||
explainText="$(string.RosenpassEnabled_Help)"
|
||||
key="Software\Policies\NetBird"
|
||||
valueName="RosenpassEnabled">
|
||||
<parentCategory ref="NetBird" />
|
||||
<supportedOn ref="SUPPORTED_NetBird_All" />
|
||||
<enabledValue><decimal value="1" /></enabledValue>
|
||||
<disabledValue><decimal value="0" /></disabledValue>
|
||||
</policy>
|
||||
|
||||
<policy name="RosenpassPermissive"
|
||||
class="Machine"
|
||||
displayName="$(string.RosenpassPermissive_Name)"
|
||||
explainText="$(string.RosenpassPermissive_Help)"
|
||||
key="Software\Policies\NetBird"
|
||||
valueName="RosenpassPermissive">
|
||||
<parentCategory ref="NetBird" />
|
||||
<supportedOn ref="SUPPORTED_NetBird_All" />
|
||||
<enabledValue><decimal value="1" /></enabledValue>
|
||||
<disabledValue><decimal value="0" /></disabledValue>
|
||||
</policy>
|
||||
|
||||
<policy name="WireguardPort"
|
||||
class="Machine"
|
||||
displayName="$(string.WireguardPort_Name)"
|
||||
explainText="$(string.WireguardPort_Help)"
|
||||
key="Software\Policies\NetBird"
|
||||
presentation="$(presentation.WireguardPort_Pres)">
|
||||
<parentCategory ref="NetBird" />
|
||||
<supportedOn ref="SUPPORTED_NetBird_All" />
|
||||
<elements>
|
||||
<decimal id="WireguardPort_Decimal" valueName="WireguardPort"
|
||||
minValue="1" maxValue="65535" required="true" />
|
||||
</elements>
|
||||
</policy>
|
||||
|
||||
<policy name="SplitTunnel"
|
||||
class="Machine"
|
||||
displayName="$(string.SplitTunnel_Name)"
|
||||
explainText="$(string.SplitTunnel_Help)"
|
||||
key="Software\Policies\NetBird"
|
||||
presentation="$(presentation.SplitTunnel_Pres)">
|
||||
<parentCategory ref="NetBird" />
|
||||
<supportedOn ref="SUPPORTED_NetBird_All" />
|
||||
<elements>
|
||||
<enum id="SplitTunnel_Mode" valueName="SplitTunnelMode" required="true">
|
||||
<item displayName="$(string.SplitTunnel_Allow)"><value><string>allow</string></value></item>
|
||||
<item displayName="$(string.SplitTunnel_Disallow)"><value><string>disallow</string></value></item>
|
||||
</enum>
|
||||
<text id="SplitTunnel_Apps" valueName="SplitTunnelApps" required="true" />
|
||||
</elements>
|
||||
</policy>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- UI: visibility / UX kill switches -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<policy name="DisableUpdateSettings"
|
||||
class="Machine"
|
||||
displayName="$(string.DisableUpdateSettings_Name)"
|
||||
explainText="$(string.DisableUpdateSettings_Help)"
|
||||
key="Software\Policies\NetBird"
|
||||
valueName="DisableUpdateSettings">
|
||||
<parentCategory ref="NetBird" />
|
||||
<supportedOn ref="SUPPORTED_NetBird_All" />
|
||||
<enabledValue><decimal value="1" /></enabledValue>
|
||||
<disabledValue><decimal value="0" /></disabledValue>
|
||||
</policy>
|
||||
|
||||
<policy name="DisableProfiles"
|
||||
class="Machine"
|
||||
displayName="$(string.DisableProfiles_Name)"
|
||||
explainText="$(string.DisableProfiles_Help)"
|
||||
key="Software\Policies\NetBird"
|
||||
valueName="DisableProfiles">
|
||||
<parentCategory ref="NetBird" />
|
||||
<supportedOn ref="SUPPORTED_NetBird_All" />
|
||||
<enabledValue><decimal value="1" /></enabledValue>
|
||||
<disabledValue><decimal value="0" /></disabledValue>
|
||||
</policy>
|
||||
|
||||
<policy name="DisableNetworks"
|
||||
class="Machine"
|
||||
displayName="$(string.DisableNetworks_Name)"
|
||||
explainText="$(string.DisableNetworks_Help)"
|
||||
key="Software\Policies\NetBird"
|
||||
valueName="DisableNetworks">
|
||||
<parentCategory ref="NetBird" />
|
||||
<supportedOn ref="SUPPORTED_NetBird_All" />
|
||||
<enabledValue><decimal value="1" /></enabledValue>
|
||||
<disabledValue><decimal value="0" /></disabledValue>
|
||||
</policy>
|
||||
|
||||
<policy name="DisableMetricsCollection"
|
||||
class="Machine"
|
||||
displayName="$(string.DisableMetricsCollection_Name)"
|
||||
explainText="$(string.DisableMetricsCollection_Help)"
|
||||
key="Software\Policies\NetBird"
|
||||
valueName="DisableMetricsCollection">
|
||||
<parentCategory ref="NetBird" />
|
||||
<supportedOn ref="SUPPORTED_NetBird_All" />
|
||||
<enabledValue><decimal value="1" /></enabledValue>
|
||||
<disabledValue><decimal value="0" /></disabledValue>
|
||||
</policy>
|
||||
|
||||
</policies>
|
||||
</policyDefinitions>
|
||||
1
go.mod
1
go.mod
@@ -132,6 +132,7 @@ require (
|
||||
gorm.io/driver/sqlite v1.5.7
|
||||
gorm.io/gorm v1.25.12
|
||||
gvisor.dev/gvisor v0.0.0-20260219192049-0f2374377e89
|
||||
howett.net/plist v1.0.2-0.20250314012144-ee69052608d9
|
||||
)
|
||||
|
||||
require (
|
||||
|
||||
3
go.sum
3
go.sum
@@ -378,6 +378,7 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
@@ -978,5 +979,7 @@ gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
|
||||
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
|
||||
gvisor.dev/gvisor v0.0.0-20260219192049-0f2374377e89 h1:mGJaeA61P8dEHTqdvAgc70ZIV3QoUoJcXCRyyjO26OA=
|
||||
gvisor.dev/gvisor v0.0.0-20260219192049-0f2374377e89/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q=
|
||||
howett.net/plist v1.0.2-0.20250314012144-ee69052608d9 h1:eeH1AIcPvSc0Z25ThsYF+Xoqbn0CI/YnXVYoTLFdGQw=
|
||||
howett.net/plist v1.0.2-0.20250314012144-ee69052608d9/go.mod h1:fyFX5Hj5tP1Mpk8obqA9MZgXT416Q5711SDT7dQLTLk=
|
||||
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
||||
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
||||
|
||||
Reference in New Issue
Block a user