mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-10 07:29:06 +02: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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os/user"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
|
"google.golang.org/protobuf/encoding/protojson"
|
||||||
"google.golang.org/protobuf/types/known/durationpb"
|
"google.golang.org/protobuf/types/known/durationpb"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal"
|
"github.com/netbirdio/netbird/client/internal"
|
||||||
@@ -85,6 +87,73 @@ var persistenceCmd = &cobra.Command{
|
|||||||
RunE: setSyncResponsePersistence,
|
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 {
|
func debugBundle(cmd *cobra.Command, _ []string) error {
|
||||||
conn, err := getClient(cmd)
|
conn, err := getClient(cmd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+14
-1
@@ -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 {
|
func Execute() error {
|
||||||
if isUpdateBinary() {
|
if isUpdateBinary() {
|
||||||
return updateCmd.Execute()
|
return updateCmd.Execute()
|
||||||
@@ -103,6 +105,16 @@ func Execute() error {
|
|||||||
return rootCmd.Execute()
|
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() {
|
func init() {
|
||||||
defaultConfigPathDir = "/etc/netbird/"
|
defaultConfigPathDir = "/etc/netbird/"
|
||||||
defaultLogFileDir = "/var/log/netbird/"
|
defaultLogFileDir = "/var/log/netbird/"
|
||||||
@@ -168,6 +180,7 @@ func init() {
|
|||||||
logCmd.AddCommand(logLevelCmd)
|
logCmd.AddCommand(logLevelCmd)
|
||||||
debugCmd.AddCommand(forCmd)
|
debugCmd.AddCommand(forCmd)
|
||||||
debugCmd.AddCommand(persistenceCmd)
|
debugCmd.AddCommand(persistenceCmd)
|
||||||
|
debugCmd.AddCommand(debugConfigCmd)
|
||||||
|
|
||||||
// kubernetes commands
|
// kubernetes commands
|
||||||
rootCmd.AddCommand(kubernetesCmd)
|
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
|
// IdentityForIP looks up a remote peer by its tunnel IP using the
|
||||||
// embedded client's status recorder. Returns the peer's WireGuard public
|
// 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
|
// key and FQDN. ok=false means the IP doesn't belong to an active peer
|
||||||
// roster — callers should treat that as "unknown peer".
|
// — offline roster peers are treated as unknown, same as foreign IPs.
|
||||||
func (c *Client) IdentityForIP(ip netip.Addr) (pubKey, fqdn string, ok bool) {
|
func (c *Client) IdentityForIP(ip netip.Addr) (pubKey, fqdn string, ok bool) {
|
||||||
if !ip.IsValid() || c.recorder == nil {
|
if !ip.IsValid() || c.recorder == nil {
|
||||||
return "", "", false
|
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())
|
configReader := strings.NewReader(configContent.String())
|
||||||
if err := g.addFileToZip(configReader, "config.txt"); err != nil {
|
if err := g.addFileToZip(configReader, "config.txt"); err != nil {
|
||||||
return fmt.Errorf("add config file to zip: %w", err)
|
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",
|
"PreSharedKey": "sensitive: WireGuard pre-shared key",
|
||||||
"SSHKey": "sensitive: SSH private key",
|
"SSHKey": "sensitive: SSH private key",
|
||||||
"ClientCertKeyPair": "non-config: parsed cert pair, not serialized",
|
"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")
|
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
|
// completely when every proxy peer is offline (the upstream may still
|
||||||
// be reachable some other way, or the peerstore may be stale).
|
// 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 {
|
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
|
return records
|
||||||
}
|
}
|
||||||
d.mu.RLock()
|
d.mu.RLock()
|
||||||
|
|||||||
@@ -2738,6 +2738,17 @@ func TestLocalResolver_FilterDisconnectedPeerAnswers(t *testing.T) {
|
|||||||
connByIP: nil,
|
connByIP: nil,
|
||||||
wantInOrder: []string{"100.64.0.10", "100.64.0.11"},
|
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 {
|
for _, tc := range tests {
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ type connStatusInputs struct {
|
|||||||
iceInProgress bool // a negotiation is currently in flight
|
iceInProgress bool // a negotiation is currently in flight
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ConnStatus describe the status of a peer's connection
|
// ConnStatus describe the status of a peer's connection
|
||||||
type ConnStatus int32
|
type ConnStatus int32
|
||||||
|
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ func (s *StatusChangeSubscription) Events() chan map[string]RouterState {
|
|||||||
type Status struct {
|
type Status struct {
|
||||||
mux sync.RWMutex
|
mux sync.RWMutex
|
||||||
peers map[string]State
|
peers map[string]State
|
||||||
|
ipToKey map[string]string
|
||||||
changeNotify map[string]map[string]*StatusChangeSubscription // map[peerID]map[subscriptionID]*StatusChangeSubscription
|
changeNotify map[string]map[string]*StatusChangeSubscription // map[peerID]map[subscriptionID]*StatusChangeSubscription
|
||||||
signalState bool
|
signalState bool
|
||||||
signalError error
|
signalError error
|
||||||
@@ -212,7 +213,8 @@ type Status struct {
|
|||||||
// expiration is disabled. Populated from management LoginResponse /
|
// expiration is disabled. Populated from management LoginResponse /
|
||||||
// SyncResponse and exposed via the daemon's Status / SubscribeStatus RPC
|
// SyncResponse and exposed via the daemon's Status / SubscribeStatus RPC
|
||||||
// so the UI can show remaining time without itself talking to mgm.
|
// so the UI can show remaining time without itself talking to mgm.
|
||||||
sessionExpiresAt time.Time
|
sessionExpiresAt time.Time
|
||||||
|
|
||||||
nsGroupStates []NSGroupState
|
nsGroupStates []NSGroupState
|
||||||
resolvedDomainsStates map[domain.Domain]ResolvedDomainInfo
|
resolvedDomainsStates map[domain.Domain]ResolvedDomainInfo
|
||||||
lazyConnectionEnabled bool
|
lazyConnectionEnabled bool
|
||||||
@@ -253,6 +255,7 @@ type Status struct {
|
|||||||
func NewRecorder(mgmAddress string) *Status {
|
func NewRecorder(mgmAddress string) *Status {
|
||||||
return &Status{
|
return &Status{
|
||||||
peers: make(map[string]State),
|
peers: make(map[string]State),
|
||||||
|
ipToKey: make(map[string]string),
|
||||||
changeNotify: make(map[string]map[string]*StatusChangeSubscription),
|
changeNotify: make(map[string]map[string]*StatusChangeSubscription),
|
||||||
eventStreams: make(map[string]chan *proto.SystemEvent),
|
eventStreams: make(map[string]chan *proto.SystemEvent),
|
||||||
eventQueue: NewEventQueue(eventQueueSize),
|
eventQueue: NewEventQueue(eventQueueSize),
|
||||||
@@ -305,6 +308,12 @@ func (d *Status) AddPeer(peerPubKey string, fqdn string, ip string, ipv6 string)
|
|||||||
Mux: new(sync.RWMutex),
|
Mux: new(sync.RWMutex),
|
||||||
}
|
}
|
||||||
d.peerListChangedForNotification = true
|
d.peerListChangedForNotification = true
|
||||||
|
if ipv6 != "" {
|
||||||
|
d.ipToKey[ipv6] = peerPubKey
|
||||||
|
}
|
||||||
|
if ip != "" {
|
||||||
|
d.ipToKey[ip] = peerPubKey
|
||||||
|
}
|
||||||
return nil
|
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.
|
// PeerStateByIP returns the full peer State for the given tunnel IP.
|
||||||
// Matches against either the IPv4 (State.IP) or IPv6 (State.IPv6) tunnel
|
// Matches against either the IPv4 (State.IP) or IPv6 (State.IPv6) tunnel
|
||||||
// address so dual-stack peers are reachable on either family. Searches
|
// address so dual-stack peers are reachable on either family. Only
|
||||||
// both d.peers and d.offlinePeers — peers that have been moved into
|
// active peers are matched; peers moved into the offline slice by
|
||||||
// the offline slice by ReplaceOfflinePeers are still part of the
|
// ReplaceOfflinePeers are intentionally treated as unknown.
|
||||||
// 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.
|
|
||||||
func (d *Status) PeerStateByIP(ip string) (State, bool) {
|
func (d *Status) PeerStateByIP(ip string) (State, bool) {
|
||||||
if ip == "" {
|
if ip == "" {
|
||||||
return State{}, false
|
return State{}, false
|
||||||
}
|
}
|
||||||
d.mux.RLock()
|
d.mux.RLock()
|
||||||
defer d.mux.RUnlock()
|
defer d.mux.RUnlock()
|
||||||
|
key, ok := d.ipToKey[ip]
|
||||||
for _, state := range d.peers {
|
if !ok {
|
||||||
if (state.IP != "" && state.IP == ip) || (state.IPv6 != "" && state.IPv6 == ip) {
|
return State{}, false
|
||||||
return state, true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
for _, state := range d.offlinePeers {
|
state, ok := d.peers[key]
|
||||||
if (state.IP != "" && state.IP == ip) || (state.IPv6 != "" && state.IPv6 == ip) {
|
if ok {
|
||||||
return state, true
|
return state, true
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return State{}, false
|
return State{}, false
|
||||||
}
|
}
|
||||||
@@ -365,12 +368,18 @@ func (d *Status) RemovePeer(peerPubKey string) error {
|
|||||||
d.mux.Lock()
|
d.mux.Lock()
|
||||||
defer d.mux.Unlock()
|
defer d.mux.Unlock()
|
||||||
|
|
||||||
_, ok := d.peers[peerPubKey]
|
p, ok := d.peers[peerPubKey]
|
||||||
if !ok {
|
if !ok {
|
||||||
return errors.New("no peer with to remove")
|
return errors.New("no peer with to remove")
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(d.peers, peerPubKey)
|
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
|
d.peerListChangedForNotification = true
|
||||||
return nil
|
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")
|
req.Equal("pk-1", state.PubKey, "matching state must carry the right pub key")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestStatus_PeerStateByIP_MatchesOfflinePeers covers peers that have
|
// TestStatus_PeerStateByIP_IgnoresOfflinePeers documents that peers
|
||||||
// been moved into the offline slice via ReplaceOfflinePeers. Callers
|
// moved into the offline slice via ReplaceOfflinePeers are intentionally
|
||||||
// (DNS filter, embed.Client.IdentityForIP) need to treat them as known
|
// not resolvable by IP: only active peers can carry traffic, so callers
|
||||||
// rather than unknown — otherwise authentication / DNS filtering treats
|
// (DNS filter, embed.Client.IdentityForIP) treat them as unknown.
|
||||||
// known-but-offline peers as foreign IPs.
|
func TestStatus_PeerStateByIP_IgnoresOfflinePeers(t *testing.T) {
|
||||||
func TestStatus_PeerStateByIP_MatchesOfflinePeers(t *testing.T) {
|
|
||||||
status := NewRecorder("https://mgm")
|
status := NewRecorder("https://mgm")
|
||||||
req := require.New(t)
|
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"},
|
{PubKey: "pk-offline", FQDN: "offline.netbird", IP: "100.64.0.20", IPv6: "fd00::20"},
|
||||||
})
|
})
|
||||||
|
|
||||||
state, ok := status.PeerStateByIP("100.64.0.20")
|
_, ok := status.PeerStateByIP("100.64.0.20")
|
||||||
req.True(ok, "offline peer must resolve by IPv4 tunnel address")
|
req.False(ok, "offline peer must not resolve by IPv4 tunnel address")
|
||||||
req.Equal("pk-offline", state.PubKey, "matching state must carry the offline peer's pub key")
|
|
||||||
|
|
||||||
state, ok = status.PeerStateByIP("fd00::20")
|
_, ok = status.PeerStateByIP("fd00::20")
|
||||||
req.True(ok, "offline peer must resolve by IPv6 tunnel address")
|
req.False(ok, "offline peer must not resolve by IPv6 tunnel address")
|
||||||
req.Equal("pk-offline", state.PubKey, "IPv6 match must carry the offline peer's pub key")
|
}
|
||||||
|
|
||||||
|
// 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) {
|
func TestStatus_UpdatePeerFQDN(t *testing.T) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
|
|
||||||
"github.com/netbirdio/netbird/client/iface"
|
"github.com/netbirdio/netbird/client/iface"
|
||||||
"github.com/netbirdio/netbird/client/internal/routemanager/dynamic"
|
"github.com/netbirdio/netbird/client/internal/routemanager/dynamic"
|
||||||
|
"github.com/netbirdio/netbird/client/mdm"
|
||||||
"github.com/netbirdio/netbird/client/ssh"
|
"github.com/netbirdio/netbird/client/ssh"
|
||||||
mgm "github.com/netbirdio/netbird/shared/management/client"
|
mgm "github.com/netbirdio/netbird/shared/management/client"
|
||||||
"github.com/netbirdio/netbird/shared/management/domain"
|
"github.com/netbirdio/netbird/shared/management/domain"
|
||||||
@@ -57,6 +58,10 @@ var DefaultInterfaceBlacklist = []string{
|
|||||||
"Tailscale", "tailscale", "docker", "veth", "br-", "lo",
|
"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
|
// ConfigInput carries configuration changes to the client
|
||||||
type ConfigInput struct {
|
type ConfigInput struct {
|
||||||
ManagementURL string
|
ManagementURL string
|
||||||
@@ -174,6 +179,23 @@ type Config struct {
|
|||||||
LazyConnectionEnabled bool
|
LazyConnectionEnabled bool
|
||||||
|
|
||||||
MTU uint16
|
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
|
var ConfigDirOverride string
|
||||||
@@ -612,10 +634,93 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
|||||||
updated = true
|
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
|
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) {
|
func parseURL(serviceName, serviceURL string) (*url.URL, error) {
|
||||||
parsedMgmtURL, err := url.ParseRequestURI(serviceURL)
|
parsedMgmtURL, err := url.ParseRequestURI(serviceURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
|
}()
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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):
|
||||||
|
}
|
||||||
|
}
|
||||||
+242
-176
File diff suppressed because it is too large
Load Diff
@@ -354,6 +354,13 @@ message GetConfigResponse {
|
|||||||
int32 sshJWTCacheTTL = 26;
|
int32 sshJWTCacheTTL = 26;
|
||||||
|
|
||||||
bool disable_ipv6 = 27;
|
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
|
// PeerState contains the latest state of a peer
|
||||||
@@ -786,6 +793,15 @@ message GetFeaturesResponse{
|
|||||||
bool disable_networks = 3;
|
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 TriggerUpdateRequest {}
|
||||||
|
|
||||||
message TriggerUpdateResponse {
|
message TriggerUpdateResponse {
|
||||||
|
|||||||
@@ -28,3 +28,34 @@ const (
|
|||||||
// MetadataKindLogLevelChanged.
|
// MetadataKindLogLevelChanged.
|
||||||
MetadataLevelKey = "level"
|
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"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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()
|
s.mutex.Lock()
|
||||||
defer s.mutex.Unlock()
|
defer s.mutex.Unlock()
|
||||||
|
|
||||||
if s.networksDisabled {
|
if s.checkNetworksDisabled() {
|
||||||
return nil, gstatus.Errorf(codes.Unavailable, errNetworksDisabled)
|
return nil, gstatus.Errorf(codes.Unavailable, errNetworksDisabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +143,7 @@ func (s *Server) SelectNetworks(_ context.Context, req *proto.SelectNetworksRequ
|
|||||||
s.mutex.Lock()
|
s.mutex.Lock()
|
||||||
defer s.mutex.Unlock()
|
defer s.mutex.Unlock()
|
||||||
|
|
||||||
if s.networksDisabled {
|
if s.checkNetworksDisabled() {
|
||||||
return nil, gstatus.Errorf(codes.Unavailable, errNetworksDisabled)
|
return nil, gstatus.Errorf(codes.Unavailable, errNetworksDisabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,7 +206,7 @@ func (s *Server) DeselectNetworks(_ context.Context, req *proto.SelectNetworksRe
|
|||||||
s.mutex.Lock()
|
s.mutex.Lock()
|
||||||
defer s.mutex.Unlock()
|
defer s.mutex.Unlock()
|
||||||
|
|
||||||
if s.networksDisabled {
|
if s.checkNetworksDisabled() {
|
||||||
return nil, gstatus.Errorf(codes.Unavailable, errNetworksDisabled)
|
return nil, gstatus.Errorf(codes.Unavailable, errNetworksDisabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+176
-45
@@ -25,6 +25,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/internal/expose"
|
"github.com/netbirdio/netbird/client/internal/expose"
|
||||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
sleephandler "github.com/netbirdio/netbird/client/internal/sleep/handler"
|
sleephandler "github.com/netbirdio/netbird/client/internal/sleep/handler"
|
||||||
|
"github.com/netbirdio/netbird/client/mdm"
|
||||||
"github.com/netbirdio/netbird/client/system"
|
"github.com/netbirdio/netbird/client/system"
|
||||||
mgm "github.com/netbirdio/netbird/shared/management/client"
|
mgm "github.com/netbirdio/netbird/shared/management/client"
|
||||||
"github.com/netbirdio/netbird/shared/management/domain"
|
"github.com/netbirdio/netbird/shared/management/domain"
|
||||||
@@ -84,7 +85,13 @@ type Server struct {
|
|||||||
mutex sync.Mutex
|
mutex sync.Mutex
|
||||||
config *profilemanager.Config
|
config *profilemanager.Config
|
||||||
proto.UnimplementedDaemonServiceServer
|
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{}
|
clientRunningChan chan struct{}
|
||||||
clientGiveUpChan chan struct{} // closed when connectWithRetryRuns goroutine exits
|
clientGiveUpChan chan struct{} // closed when connectWithRetryRuns goroutine exits
|
||||||
|
|
||||||
@@ -111,6 +118,11 @@ type Server struct {
|
|||||||
|
|
||||||
sleepHandler *sleephandler.SleepHandler
|
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
|
updateManager *updater.Manager
|
||||||
|
|
||||||
jwtCache *jwtCache
|
jwtCache *jwtCache
|
||||||
@@ -179,6 +191,17 @@ func (s *Server) Start() error {
|
|||||||
s.updateManager.CheckUpdateSuccess(s.rootCtx)
|
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
|
// 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
|
// 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.
|
// not in the progress or already successfully established connection.
|
||||||
@@ -237,24 +260,28 @@ func (s *Server) Start() error {
|
|||||||
s.clientRunningChan = make(chan struct{})
|
s.clientRunningChan = make(chan struct{})
|
||||||
s.clientGiveUpChan = make(chan struct{})
|
s.clientGiveUpChan = make(chan struct{})
|
||||||
go s.connectWithRetryRuns(ctx, config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan)
|
go s.connectWithRetryRuns(ctx, config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan)
|
||||||
|
s.publishConfigChangedEvent(proto.MetadataSourceStartup)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// connectWithRetryRuns runs the client connection with a backoff strategy where we retry the operation as additional
|
// 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.
|
// 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.
|
// 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{}) {
|
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
|
// close(giveUpChan) MUST run on every exit path (DisableAutoConnect
|
||||||
// return, backoff.Retry return, panic) — Down() blocks for up to 5s
|
// return, backoff.Retry return, panic) — Down() blocks for up to 5s
|
||||||
// waiting on this signal before flipping the state to Idle, and a
|
// waiting on this signal before flipping the state to Idle, and a
|
||||||
// missed close leaves Down() always hitting the timeout. The signal
|
// missed close leaves Down() always hitting the timeout.
|
||||||
// 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).
|
|
||||||
defer func() {
|
defer func() {
|
||||||
s.mutex.Lock()
|
|
||||||
s.clientRunning = false
|
|
||||||
s.mutex.Unlock()
|
|
||||||
if giveUpChan != nil {
|
if giveUpChan != nil {
|
||||||
close(giveUpChan)
|
close(giveUpChan)
|
||||||
}
|
}
|
||||||
@@ -312,6 +339,27 @@ func (s *Server) connectWithRetryRuns(ctx context.Context, profileConfig *profil
|
|||||||
if err := backoff.Retry(runOperation, backOff); err != nil {
|
if err := backoff.Retry(runOperation, backOff); err != nil {
|
||||||
log.Errorf("operation failed: %v", err)
|
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
|
// 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()
|
s.mutex.Lock()
|
||||||
defer s.mutex.Unlock()
|
defer s.mutex.Unlock()
|
||||||
|
|
||||||
if s.checkUpdateSettingsDisabled() {
|
// Skip the update-settings gate when the request carries no actual
|
||||||
return nil, gstatus.Errorf(codes.Unavailable, errUpdateSettingsDisabled)
|
// 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{
|
profState := profilemanager.ActiveProfileState{
|
||||||
Name: msg.ProfileName,
|
Name: msg.ProfileName,
|
||||||
Username: msg.Username,
|
Username: msg.Username,
|
||||||
}
|
}
|
||||||
|
|
||||||
profPath, err := profState.FilePath()
|
profPath, err := profState.FilePath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("failed to get active profile file path: %v", err)
|
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
|
config.ConfigPath = profPath
|
||||||
|
|
||||||
if msg.ManagementUrl != "" {
|
if msg.ManagementUrl != "" {
|
||||||
config.ManagementURL = msg.ManagementUrl
|
config.ManagementURL = msg.ManagementUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
if msg.AdminURL != "" {
|
if msg.AdminURL != "" {
|
||||||
config.AdminURL = msg.AdminURL
|
config.AdminURL = msg.AdminURL
|
||||||
}
|
}
|
||||||
|
|
||||||
if msg.InterfaceName != nil {
|
if msg.InterfaceName != nil {
|
||||||
config.InterfaceName = msg.InterfaceName
|
config.InterfaceName = msg.InterfaceName
|
||||||
}
|
}
|
||||||
|
|
||||||
if msg.WireguardPort != nil {
|
if msg.WireguardPort != nil {
|
||||||
wgPort := int(*msg.WireguardPort)
|
wgPort := int(*msg.WireguardPort)
|
||||||
config.WireguardPort = &wgPort
|
config.WireguardPort = &wgPort
|
||||||
}
|
}
|
||||||
|
if msg.OptionalPreSharedKey != nil && *msg.OptionalPreSharedKey != "" {
|
||||||
if msg.OptionalPreSharedKey != nil {
|
|
||||||
config.PreSharedKey = msg.OptionalPreSharedKey
|
config.PreSharedKey = msg.OptionalPreSharedKey
|
||||||
}
|
}
|
||||||
|
|
||||||
if msg.CleanDNSLabels {
|
if msg.CleanDNSLabels {
|
||||||
config.DNSLabels = domain.List{}
|
config.DNSLabels = domain.List{}
|
||||||
|
|
||||||
} else if msg.DnsLabels != nil {
|
} else if msg.DnsLabels != nil {
|
||||||
dnsLabels := domain.FromPunycodeList(msg.DnsLabels)
|
config.DNSLabels = domain.FromPunycodeList(msg.DnsLabels)
|
||||||
config.DNSLabels = dnsLabels
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if msg.CleanNATExternalIPs {
|
if msg.CleanNATExternalIPs {
|
||||||
@@ -401,7 +482,6 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
|
|||||||
if string(msg.CustomDNSAddress) == "empty" {
|
if string(msg.CustomDNSAddress) == "empty" {
|
||||||
config.CustomDNSAddress = []byte{}
|
config.CustomDNSAddress = []byte{}
|
||||||
}
|
}
|
||||||
|
|
||||||
config.ExtraIFaceBlackList = msg.ExtraIFaceBlacklist
|
config.ExtraIFaceBlackList = msg.ExtraIFaceBlacklist
|
||||||
|
|
||||||
if msg.DnsRouteInterval != nil {
|
if msg.DnsRouteInterval != nil {
|
||||||
@@ -434,22 +514,31 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
|
|||||||
ttl := int(*msg.SshJWTCacheTTL)
|
ttl := int(*msg.SshJWTCacheTTL)
|
||||||
config.SSHJWTCacheTTL = &ttl
|
config.SSHJWTCacheTTL = &ttl
|
||||||
}
|
}
|
||||||
|
|
||||||
if msg.Mtu != nil {
|
if msg.Mtu != nil {
|
||||||
mtu := uint16(*msg.Mtu)
|
mtu := uint16(*msg.Mtu)
|
||||||
config.MTU = &mtu
|
config.MTU = &mtu
|
||||||
}
|
}
|
||||||
|
return config, nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login uses setup key to prepare configuration for the daemon.
|
// Login uses setup key to prepare configuration for the daemon.
|
||||||
func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*proto.LoginResponse, error) {
|
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()
|
s.mutex.Lock()
|
||||||
if s.actCancel != nil {
|
if s.actCancel != nil {
|
||||||
s.actCancel()
|
s.actCancel()
|
||||||
@@ -770,7 +859,13 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin
|
|||||||
// Up starts engine work in the daemon.
|
// Up starts engine work in the daemon.
|
||||||
func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpResponse, error) {
|
func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpResponse, error) {
|
||||||
s.mutex.Lock()
|
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)
|
state := internal.CtxGetState(s.rootCtx)
|
||||||
status, err := state.Status()
|
status, err := state.Status()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -877,6 +972,7 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
|
|||||||
s.clientGiveUpChan = make(chan struct{})
|
s.clientGiveUpChan = make(chan struct{})
|
||||||
|
|
||||||
go s.connectWithRetryRuns(ctx, s.config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan)
|
go s.connectWithRetryRuns(ctx, s.config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan)
|
||||||
|
s.publishConfigChangedEvent(proto.MetadataSourceUpRPC)
|
||||||
|
|
||||||
s.mutex.Unlock()
|
s.mutex.Unlock()
|
||||||
if msg.GetAsync() {
|
if msg.GetAsync() {
|
||||||
@@ -1026,6 +1122,12 @@ func (s *Server) cleanupConnection() error {
|
|||||||
return ErrServiceNotUp
|
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.
|
// Capture the engine reference before cancelling the context.
|
||||||
// After actCancel(), the connectWithRetryRuns goroutine wakes up
|
// After actCancel(), the connectWithRetryRuns goroutine wakes up
|
||||||
// and sets connectClient.engine = nil, causing connectClient.Stop()
|
// and sets connectClient.engine = nil, causing connectClient.Stop()
|
||||||
@@ -1241,10 +1343,14 @@ func (s *Server) Status(
|
|||||||
msg *proto.StatusRequest,
|
msg *proto.StatusRequest,
|
||||||
) (*proto.StatusResponse, error) {
|
) (*proto.StatusResponse, error) {
|
||||||
s.mutex.Lock()
|
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()
|
s.mutex.Unlock()
|
||||||
|
|
||||||
if msg.WaitForReady != nil && *msg.WaitForReady && clientRunning {
|
if msg.WaitForReady != nil && *msg.WaitForReady && alive {
|
||||||
state := internal.CtxGetState(s.rootCtx)
|
state := internal.CtxGetState(s.rootCtx)
|
||||||
status, err := state.Status()
|
status, err := state.Status()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1865,6 +1971,7 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p
|
|||||||
EnableSSHRemotePortForwarding: enableSSHRemotePortForwarding,
|
EnableSSHRemotePortForwarding: enableSSHRemotePortForwarding,
|
||||||
DisableSSHAuth: disableSSHAuth,
|
DisableSSHAuth: disableSSHAuth,
|
||||||
SshJWTCacheTTL: sshJWTCacheTTL,
|
SshJWTCacheTTL: sshJWTCacheTTL,
|
||||||
|
MDMManagedFields: cfg.Policy().ManagedKeys(),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2007,7 +2114,7 @@ func (s *Server) GetFeatures(ctx context.Context, msg *proto.GetFeaturesRequest)
|
|||||||
features := &proto.GetFeaturesResponse{
|
features := &proto.GetFeaturesResponse{
|
||||||
DisableProfiles: s.checkProfilesDisabled(),
|
DisableProfiles: s.checkProfilesDisabled(),
|
||||||
DisableUpdateSettings: s.checkUpdateSettingsDisabled(),
|
DisableUpdateSettings: s.checkUpdateSettingsDisabled(),
|
||||||
DisableNetworks: s.networksDisabled,
|
DisableNetworks: s.checkNetworksDisabled(),
|
||||||
}
|
}
|
||||||
|
|
||||||
return features, nil
|
return features, nil
|
||||||
@@ -2029,22 +2136,46 @@ func (s *Server) connect(ctx context.Context, config *profilemanager.Config, sta
|
|||||||
return nil
|
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 {
|
func (s *Server) checkProfilesDisabled() bool {
|
||||||
// Check if the environment variable is set to disable profiles
|
if s.config != nil {
|
||||||
if s.profilesDisabled {
|
if v, ok := s.config.Policy().GetBool(mdm.KeyDisableProfiles); ok {
|
||||||
return true
|
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 {
|
func (s *Server) checkUpdateSettingsDisabled() bool {
|
||||||
// Check if the environment variable is set to disable profiles
|
if s.config != nil {
|
||||||
if s.updateSettingsDisabled {
|
if v, ok := s.config.Policy().GetBool(mdm.KeyDisableUpdateSettings); ok {
|
||||||
return true
|
return v
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return s.updateSettingsDisabled
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) startUpdateManagerForGUI() {
|
func (s *Server) startUpdateManagerForGUI() {
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ func TestCleanupConnection_ClearsConnectClient(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Nil(t, s.connectClient, "connectClient should be nil after cleanup")
|
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
|
// TestCleanState_NilConnectClient validates that CleanState doesn't panic
|
||||||
@@ -144,17 +145,20 @@ func TestDownThenUp_StaleRunningChan(t *testing.T) {
|
|||||||
_, cancel := context.WithCancel(context.Background())
|
_, cancel := context.WithCancel(context.Background())
|
||||||
s.actCancel = cancel
|
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()
|
s.mutex.Lock()
|
||||||
err := s.cleanupConnection()
|
err := s.cleanupConnection()
|
||||||
s.mutex.Unlock()
|
s.mutex.Unlock()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// After cleanup: connectClient is nil, clientRunning still true
|
// After cleanup: connectClient is nil, clientRunning is false (intent
|
||||||
// (goroutine hasn't exited yet)
|
// cleared by cleanupConnection), connectionGoroutineRunning may still be true
|
||||||
|
// (goroutine teardown is independent of the intent flag).
|
||||||
s.mutex.Lock()
|
s.mutex.Lock()
|
||||||
assert.Nil(t, s.connectClient, "connectClient should be nil after cleanup")
|
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()
|
s.mutex.Unlock()
|
||||||
|
|
||||||
// waitForUp() returns immediately due to stale closed clientRunningChan
|
// waitForUp() returns immediately due to stale closed clientRunningChan
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(gh api *)",
|
||||||
|
"Bash(wails3 generate *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,10 @@ type SettingsContextValue = {
|
|||||||
guiVersion: string;
|
guiVersion: string;
|
||||||
setField: <K extends keyof Config>(k: K, v: Config[K]) => void;
|
setField: <K extends keyof Config>(k: K, v: Config[K]) => void;
|
||||||
saveField: <K extends keyof Config>(k: K, v: Config[K]) => Promise<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>;
|
saveNow: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -104,13 +107,14 @@ const useSettingsState = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const save = useCallback(
|
const save = useCallback(
|
||||||
async (profileName: string, next: Config) => {
|
async (profileName: string, next: Config, preSharedKey?: string) => {
|
||||||
// Sending the "**********" PSK mask back corrupts the stored PSK (wgtypes.ParseKey fails next connect).
|
|
||||||
const { preSharedKey, ...rest } = next;
|
|
||||||
try {
|
try {
|
||||||
await SettingsSvc.SetConfig({
|
await SettingsSvc.SetConfig({
|
||||||
...rest,
|
...next,
|
||||||
...(preSharedKey === "**********" ? {} : { preSharedKey }),
|
// 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,
|
profileName,
|
||||||
username,
|
username,
|
||||||
});
|
});
|
||||||
@@ -163,7 +167,7 @@ const useSettingsState = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const saveFields = useCallback(
|
const saveFields = useCallback(
|
||||||
async (partial: Partial<Config>) => {
|
async (partial: Partial<Config>, opts?: { preSharedKey?: string }) => {
|
||||||
if (!loaded) return;
|
if (!loaded) return;
|
||||||
if (saveTimer.current) {
|
if (saveTimer.current) {
|
||||||
clearTimeout(saveTimer.current);
|
clearTimeout(saveTimer.current);
|
||||||
@@ -171,7 +175,7 @@ const useSettingsState = () => {
|
|||||||
}
|
}
|
||||||
const next = { ...loaded.data, ...partial };
|
const next = { ...loaded.data, ...partial };
|
||||||
setLoaded({ profileName: loaded.profileName, data: next });
|
setLoaded({ profileName: loaded.profileName, data: next });
|
||||||
await save(loaded.profileName, next);
|
await save(loaded.profileName, next, opts?.preSharedKey);
|
||||||
},
|
},
|
||||||
[loaded, save],
|
[loaded, save],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ const PORT_MAX = 65535;
|
|||||||
// Mirrors client/iface/iface.go MinMTU / MaxMTU.
|
// Mirrors client/iface/iface.go MinMTU / MaxMTU.
|
||||||
const MTU_MIN = 576;
|
const MTU_MIN = 576;
|
||||||
const MTU_MAX = 8192;
|
const MTU_MAX = 8192;
|
||||||
// GetConfig returns existing PSKs as this mask; revealing it would only show the asterisks.
|
|
||||||
const PSK_MASK = "**********";
|
|
||||||
|
|
||||||
export function SettingsAdvanced() {
|
export function SettingsAdvanced() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -31,8 +29,11 @@ export function SettingsAdvanced() {
|
|||||||
interfaceName: config.interfaceName,
|
interfaceName: config.interfaceName,
|
||||||
wireguardPort: config.wireguardPort,
|
wireguardPort: config.wireguardPort,
|
||||||
mtu: config.mtu,
|
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);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -40,9 +41,9 @@ export function SettingsAdvanced() {
|
|||||||
interfaceName: config.interfaceName,
|
interfaceName: config.interfaceName,
|
||||||
wireguardPort: config.wireguardPort,
|
wireguardPort: config.wireguardPort,
|
||||||
mtu: config.mtu,
|
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 errors = useMemo(() => {
|
||||||
const out: { interfaceName?: string; wireguardPort?: string; mtu?: string } = {};
|
const out: { interfaceName?: string; wireguardPort?: string; mtu?: string } = {};
|
||||||
@@ -70,13 +71,13 @@ export function SettingsAdvanced() {
|
|||||||
values.interfaceName !== config.interfaceName ||
|
values.interfaceName !== config.interfaceName ||
|
||||||
values.wireguardPort !== config.wireguardPort ||
|
values.wireguardPort !== config.wireguardPort ||
|
||||||
values.mtu !== config.mtu ||
|
values.mtu !== config.mtu ||
|
||||||
values.preSharedKey !== config.preSharedKey;
|
psk !== "";
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!hasChanges || saving || hasErrors) return;
|
if (!hasChanges || saving || hasErrors) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await saveFields(values);
|
await saveFields(values, psk ? { preSharedKey: psk } : undefined);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -127,10 +128,14 @@ export function SettingsAdvanced() {
|
|||||||
<HelpText>{t("settings.advanced.psk.help")}</HelpText>
|
<HelpText>{t("settings.advanced.psk.help")}</HelpText>
|
||||||
<Input
|
<Input
|
||||||
type={"password"}
|
type={"password"}
|
||||||
showPasswordToggle={values.preSharedKey !== PSK_MASK}
|
showPasswordToggle={psk !== ""}
|
||||||
placeholder={"kQv0qF3oQpJYdgD5mC9hL7sB2xZ8nT4eU6wY1aR3jK0="}
|
placeholder={
|
||||||
value={values.preSharedKey}
|
config.preSharedKeySet
|
||||||
onChange={(e) => setValues((v) => ({ ...v, preSharedKey: e.target.value }))}
|
? t("settings.advanced.psk.configured")
|
||||||
|
: "kQv0qF3oQpJYdgD5mC9hL7sB2xZ8nT4eU6wY1aR3jK0="
|
||||||
|
}
|
||||||
|
value={psk}
|
||||||
|
onChange={(e) => setPsk(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</SectionGroup>
|
</SectionGroup>
|
||||||
|
|||||||
@@ -167,6 +167,12 @@
|
|||||||
"notify.sessionDeadlineRejected.body": {
|
"notify.sessionDeadlineRejected.body": {
|
||||||
"message": "Der Server hat eine ungültige Sitzungsablaufzeit übermittelt. Bitte melden Sie sich erneut an."
|
"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": {
|
"common.cancel": {
|
||||||
"message": "Abbrechen"
|
"message": "Abbrechen"
|
||||||
},
|
},
|
||||||
@@ -695,6 +701,9 @@
|
|||||||
"settings.advanced.psk.help": {
|
"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."
|
"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": {
|
"settings.troubleshooting.section.title": {
|
||||||
"message": "Debug-Paket"
|
"message": "Debug-Paket"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -223,6 +223,14 @@
|
|||||||
"message": "The server sent an invalid session deadline. Please sign in again.",
|
"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."
|
"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": {
|
"common.cancel": {
|
||||||
"message": "Cancel",
|
"message": "Cancel",
|
||||||
"description": "Generic Cancel button label, reused across dialogs. Keep short."
|
"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.",
|
"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."
|
"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": {
|
"settings.troubleshooting.section.title": {
|
||||||
"message": "Debug bundle",
|
"message": "Debug bundle",
|
||||||
"description": "Section heading: Debug bundle."
|
"description": "Section heading: Debug bundle."
|
||||||
|
|||||||
@@ -167,6 +167,12 @@
|
|||||||
"notify.sessionDeadlineRejected.body": {
|
"notify.sessionDeadlineRejected.body": {
|
||||||
"message": "El servidor envió un plazo de sesión no válido. Inicie sesión de nuevo."
|
"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": {
|
"common.cancel": {
|
||||||
"message": "Cancelar"
|
"message": "Cancelar"
|
||||||
},
|
},
|
||||||
@@ -695,6 +701,9 @@
|
|||||||
"settings.advanced.psk.help": {
|
"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."
|
"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": {
|
"settings.troubleshooting.section.title": {
|
||||||
"message": "Paquete de diagnóstico"
|
"message": "Paquete de diagnóstico"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -167,6 +167,12 @@
|
|||||||
"notify.sessionDeadlineRejected.body": {
|
"notify.sessionDeadlineRejected.body": {
|
||||||
"message": "Le serveur a envoyé une échéance de session invalide. Veuillez vous reconnecter."
|
"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": {
|
"common.cancel": {
|
||||||
"message": "Annuler"
|
"message": "Annuler"
|
||||||
},
|
},
|
||||||
@@ -695,6 +701,9 @@
|
|||||||
"settings.advanced.psk.help": {
|
"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."
|
"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": {
|
"settings.troubleshooting.section.title": {
|
||||||
"message": "Lot de diagnostic"
|
"message": "Lot de diagnostic"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -167,6 +167,12 @@
|
|||||||
"notify.sessionDeadlineRejected.body": {
|
"notify.sessionDeadlineRejected.body": {
|
||||||
"message": "A szerver érvénytelen munkamenet-határidőt küldött. Kérjük, jelentkezzen be újra."
|
"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": {
|
"common.cancel": {
|
||||||
"message": "Mégse"
|
"message": "Mégse"
|
||||||
},
|
},
|
||||||
@@ -695,6 +701,9 @@
|
|||||||
"settings.advanced.psk.help": {
|
"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."
|
"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": {
|
"settings.troubleshooting.section.title": {
|
||||||
"message": "Hibakeresési csomag"
|
"message": "Hibakeresési csomag"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -167,6 +167,12 @@
|
|||||||
"notify.sessionDeadlineRejected.body": {
|
"notify.sessionDeadlineRejected.body": {
|
||||||
"message": "Il server ha inviato una scadenza di sessione non valida. Effettui di nuovo l'accesso."
|
"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": {
|
"common.cancel": {
|
||||||
"message": "Annulla"
|
"message": "Annulla"
|
||||||
},
|
},
|
||||||
@@ -695,6 +701,9 @@
|
|||||||
"settings.advanced.psk.help": {
|
"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."
|
"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": {
|
"settings.troubleshooting.section.title": {
|
||||||
"message": "Pacchetto di debug"
|
"message": "Pacchetto di debug"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -167,6 +167,12 @@
|
|||||||
"notify.sessionDeadlineRejected.body": {
|
"notify.sessionDeadlineRejected.body": {
|
||||||
"message": "O servidor enviou um prazo de sessão inválido. Faça login novamente."
|
"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": {
|
"common.cancel": {
|
||||||
"message": "Cancelar"
|
"message": "Cancelar"
|
||||||
},
|
},
|
||||||
@@ -695,6 +701,9 @@
|
|||||||
"settings.advanced.psk.help": {
|
"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."
|
"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": {
|
"settings.troubleshooting.section.title": {
|
||||||
"message": "Pacote de depuração"
|
"message": "Pacote de depuração"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -167,6 +167,12 @@
|
|||||||
"notify.sessionDeadlineRejected.body": {
|
"notify.sessionDeadlineRejected.body": {
|
||||||
"message": "Сервер передал неверный срок действия сеанса. Пожалуйста, войдите снова."
|
"message": "Сервер передал неверный срок действия сеанса. Пожалуйста, войдите снова."
|
||||||
},
|
},
|
||||||
|
"notify.mdm.policyApplied.title": {
|
||||||
|
"message": "Настройки NetBird обновлены"
|
||||||
|
},
|
||||||
|
"notify.mdm.policyApplied.body": {
|
||||||
|
"message": "Конфигурация NetBird была обновлена в соответствии с вашей ИТ-политикой."
|
||||||
|
},
|
||||||
"common.cancel": {
|
"common.cancel": {
|
||||||
"message": "Отмена"
|
"message": "Отмена"
|
||||||
},
|
},
|
||||||
@@ -695,6 +701,9 @@
|
|||||||
"settings.advanced.psk.help": {
|
"settings.advanced.psk.help": {
|
||||||
"message": "Необязательный PSK WireGuard для дополнительного симметричного шифрования. Это не то же самое, что ключ установки NetBird. Вы будете обмениваться данными только с пирами, использующими тот же общий ключ."
|
"message": "Необязательный PSK WireGuard для дополнительного симметричного шифрования. Это не то же самое, что ключ установки NetBird. Вы будете обмениваться данными только с пирами, использующими тот же общий ключ."
|
||||||
},
|
},
|
||||||
|
"settings.advanced.psk.configured": {
|
||||||
|
"message": "Общий ключ установлен — введите новый, чтобы заменить его."
|
||||||
|
},
|
||||||
"settings.troubleshooting.section.title": {
|
"settings.troubleshooting.section.title": {
|
||||||
"message": "Отладочный пакет"
|
"message": "Отладочный пакет"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -167,6 +167,12 @@
|
|||||||
"notify.sessionDeadlineRejected.body": {
|
"notify.sessionDeadlineRejected.body": {
|
||||||
"message": "服务器发送了无效的会话截止时间。请重新登录。"
|
"message": "服务器发送了无效的会话截止时间。请重新登录。"
|
||||||
},
|
},
|
||||||
|
"notify.mdm.policyApplied.title": {
|
||||||
|
"message": "NetBird 设置已更新"
|
||||||
|
},
|
||||||
|
"notify.mdm.policyApplied.body": {
|
||||||
|
"message": "您的 NetBird 配置已根据 IT 策略更新。"
|
||||||
|
},
|
||||||
"common.cancel": {
|
"common.cancel": {
|
||||||
"message": "取消"
|
"message": "取消"
|
||||||
},
|
},
|
||||||
@@ -695,6 +701,9 @@
|
|||||||
"settings.advanced.psk.help": {
|
"settings.advanced.psk.help": {
|
||||||
"message": "可选的 WireGuard PSK,用于额外的对称加密。它与 NetBird 设置密钥不同。您将只能与使用相同预共享密钥的对等节点通信。"
|
"message": "可选的 WireGuard PSK,用于额外的对称加密。它与 NetBird 设置密钥不同。您将只能与使用相同预共享密钥的对等节点通信。"
|
||||||
},
|
},
|
||||||
|
"settings.advanced.psk.configured": {
|
||||||
|
"message": "已设置预共享密钥,输入新密钥即可替换。"
|
||||||
|
},
|
||||||
"settings.troubleshooting.section.title": {
|
"settings.troubleshooting.section.title": {
|
||||||
"message": "调试包"
|
"message": "调试包"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,9 +5,28 @@ package services
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/mdm"
|
||||||
"github.com/netbirdio/netbird/client/proto"
|
"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.
|
// ConfigParams selects which profile/user to read or write config for.
|
||||||
type ConfigParams struct {
|
type ConfigParams struct {
|
||||||
ProfileName string `json:"profileName"`
|
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
|
// 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).
|
// daemon's current setting (matching SetConfigRequest's optional semantics).
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ManagementURL string `json:"managementUrl"`
|
ManagementURL string `json:"managementUrl"`
|
||||||
AdminURL string `json:"adminUrl"`
|
AdminURL string `json:"adminUrl"`
|
||||||
ConfigFile string `json:"configFile"`
|
ConfigFile string `json:"configFile"`
|
||||||
LogFile string `json:"logFile"`
|
LogFile string `json:"logFile"`
|
||||||
PreSharedKey string `json:"preSharedKey"`
|
// PreSharedKeySet reports whether a pre-shared key is configured, without
|
||||||
InterfaceName string `json:"interfaceName"`
|
// exposing its value (the daemon redacts the PSK). The settings form shows
|
||||||
WireguardPort int64 `json:"wireguardPort"`
|
// its own "configured" / "managed by MDM" placeholder when true and sends a
|
||||||
MTU int64 `json:"mtu"`
|
// new PSK only when the user actually types one — the redaction sentinel
|
||||||
DisableAutoConnect bool `json:"disableAutoConnect"`
|
// never crosses to the UI.
|
||||||
ServerSSHAllowed bool `json:"serverSshAllowed"`
|
PreSharedKeySet bool `json:"preSharedKeySet"`
|
||||||
RosenpassEnabled bool `json:"rosenpassEnabled"`
|
InterfaceName string `json:"interfaceName"`
|
||||||
RosenpassPermissive bool `json:"rosenpassPermissive"`
|
WireguardPort int64 `json:"wireguardPort"`
|
||||||
DisableNotifications bool `json:"disableNotifications"`
|
MTU int64 `json:"mtu"`
|
||||||
LazyConnectionEnabled bool `json:"lazyConnectionEnabled"`
|
DisableAutoConnect bool `json:"disableAutoConnect"`
|
||||||
BlockInbound bool `json:"blockInbound"`
|
ServerSSHAllowed bool `json:"serverSshAllowed"`
|
||||||
NetworkMonitor bool `json:"networkMonitor"`
|
RosenpassEnabled bool `json:"rosenpassEnabled"`
|
||||||
DisableClientRoutes bool `json:"disableClientRoutes"`
|
RosenpassPermissive bool `json:"rosenpassPermissive"`
|
||||||
DisableServerRoutes bool `json:"disableServerRoutes"`
|
DisableNotifications bool `json:"disableNotifications"`
|
||||||
DisableDNS bool `json:"disableDns"`
|
LazyConnectionEnabled bool `json:"lazyConnectionEnabled"`
|
||||||
DisableIPv6 bool `json:"disableIpv6"`
|
BlockInbound bool `json:"blockInbound"`
|
||||||
BlockLANAccess bool `json:"blockLanAccess"`
|
NetworkMonitor bool `json:"networkMonitor"`
|
||||||
EnableSSHRoot bool `json:"enableSshRoot"`
|
DisableClientRoutes bool `json:"disableClientRoutes"`
|
||||||
EnableSSHSFTP bool `json:"enableSshSftp"`
|
DisableServerRoutes bool `json:"disableServerRoutes"`
|
||||||
EnableSSHLocalPortForwarding bool `json:"enableSshLocalPortForwarding"`
|
DisableDNS bool `json:"disableDns"`
|
||||||
EnableSSHRemotePortForwarding bool `json:"enableSshRemotePortForwarding"`
|
DisableIPv6 bool `json:"disableIpv6"`
|
||||||
DisableSSHAuth bool `json:"disableSshAuth"`
|
BlockLANAccess bool `json:"blockLanAccess"`
|
||||||
SSHJWTCacheTTL int32 `json:"sshJwtCacheTtl"`
|
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
|
// 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(),
|
AdminURL: resp.GetAdminURL(),
|
||||||
ConfigFile: resp.GetConfigFile(),
|
ConfigFile: resp.GetConfigFile(),
|
||||||
LogFile: resp.GetLogFile(),
|
LogFile: resp.GetLogFile(),
|
||||||
PreSharedKey: resp.GetPreSharedKey(),
|
PreSharedKeySet: resp.GetPreSharedKey() != "",
|
||||||
InterfaceName: resp.GetInterfaceName(),
|
InterfaceName: resp.GetInterfaceName(),
|
||||||
WireguardPort: resp.GetWireguardPort(),
|
WireguardPort: resp.GetWireguardPort(),
|
||||||
MTU: resp.GetMtu(),
|
MTU: resp.GetMtu(),
|
||||||
@@ -137,6 +174,8 @@ func (s *Settings) GetConfig(ctx context.Context, p ConfigParams) (Config, error
|
|||||||
EnableSSHRemotePortForwarding: resp.GetEnableSSHRemotePortForwarding(),
|
EnableSSHRemotePortForwarding: resp.GetEnableSSHRemotePortForwarding(),
|
||||||
DisableSSHAuth: resp.GetDisableSSHAuth(),
|
DisableSSHAuth: resp.GetDisableSSHAuth(),
|
||||||
SSHJWTCacheTTL: resp.GetSshJWTCacheTTL(),
|
SSHJWTCacheTTL: resp.GetSshJWTCacheTTL(),
|
||||||
|
MDMManagedFields: resp.GetMDMManagedFields(),
|
||||||
|
ManagedFields: configManagedFields(resp.GetMDMManagedFields()),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,3 +233,17 @@ func (s *Settings) GetFeatures(ctx context.Context) (Features, error) {
|
|||||||
DisableNetworks: resp.GetDisableNetworks(),
|
DisableNetworks: resp.GetDisableNetworks(),
|
||||||
}, nil
|
}, 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
|
||||||
|
}
|
||||||
|
|||||||
+19
-2
@@ -33,6 +33,7 @@ const (
|
|||||||
notifyIDUpdatePrefix = "netbird-update-"
|
notifyIDUpdatePrefix = "netbird-update-"
|
||||||
notifyIDEvent = "netbird-event-"
|
notifyIDEvent = "netbird-event-"
|
||||||
notifyIDTrayError = "netbird-tray-error"
|
notifyIDTrayError = "netbird-tray-error"
|
||||||
|
notifyIDMDMPolicy = "netbird-mdm-policy"
|
||||||
|
|
||||||
statusError = "Error"
|
statusError = "Error"
|
||||||
|
|
||||||
@@ -199,6 +200,16 @@ type Tray struct {
|
|||||||
// succession and each may kick a refresh, but the ListNetworks fetch +
|
// succession and each may kick a refresh, but the ListNetworks fetch +
|
||||||
// submenu rebuild + SetMenu must not run concurrently with itself.
|
// submenu rebuild + SetMenu must not run concurrently with itself.
|
||||||
exitNodesRebuildMu sync.Mutex
|
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 {
|
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).
|
// nil-deref).
|
||||||
app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) {
|
app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) {
|
||||||
go t.loadProfiles()
|
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()
|
go t.runSessionExpiryTicker()
|
||||||
// Notification-category registration must run after the Wails
|
// Notification-category registration must run after the Wails
|
||||||
// notifications service Startup has populated wn.appName /
|
// notifications service Startup has populated wn.appName /
|
||||||
@@ -376,6 +391,8 @@ func (t *Tray) relayoutMenu() {
|
|||||||
exitNodeEntries := append([]exitNodeEntry(nil), t.exitNodes...)
|
exitNodeEntries := append([]exitNodeEntry(nil), t.exitNodes...)
|
||||||
t.exitNodesMu.Unlock()
|
t.exitNodesMu.Unlock()
|
||||||
|
|
||||||
|
disableProfiles, disableNetworks := t.featuresDisabled()
|
||||||
|
|
||||||
daemonUnavailable := strings.EqualFold(lastStatus, services.StatusDaemonUnavailable)
|
daemonUnavailable := strings.EqualFold(lastStatus, services.StatusDaemonUnavailable)
|
||||||
connecting := strings.EqualFold(lastStatus, services.StatusConnecting)
|
connecting := strings.EqualFold(lastStatus, services.StatusConnecting)
|
||||||
|
|
||||||
@@ -402,13 +419,13 @@ func (t *Tray) relayoutMenu() {
|
|||||||
t.downItem.SetEnabled(connected || connecting)
|
t.downItem.SetEnabled(connected || connecting)
|
||||||
}
|
}
|
||||||
if t.exitNodeItem != nil {
|
if t.exitNodeItem != nil {
|
||||||
t.exitNodeItem.SetEnabled(connected && len(exitNodeEntries) > 0)
|
t.exitNodeItem.SetEnabled(connected && len(exitNodeEntries) > 0 && !disableNetworks)
|
||||||
}
|
}
|
||||||
if t.settingsItem != nil {
|
if t.settingsItem != nil {
|
||||||
t.settingsItem.SetEnabled(!daemonUnavailable)
|
t.settingsItem.SetEnabled(!daemonUnavailable)
|
||||||
}
|
}
|
||||||
if t.profileSubmenuItem != nil {
|
if t.profileSubmenuItem != nil {
|
||||||
t.profileSubmenuItem.SetEnabled(!daemonUnavailable)
|
t.profileSubmenuItem.SetEnabled(!daemonUnavailable && !disableProfiles)
|
||||||
}
|
}
|
||||||
if daemonVersion != "" && t.daemonVersionItem != nil {
|
if daemonVersion != "" && t.daemonVersionItem != nil {
|
||||||
t.daemonVersionItem.SetLabel(t.loc.T("tray.menu.daemonVersion", "version", daemonVersion))
|
t.daemonVersionItem.SetLabel(t.loc.T("tray.menu.daemonVersion", "version", daemonVersion))
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/wailsapp/wails/v3/pkg/application"
|
"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/authsession"
|
||||||
"github.com/netbirdio/netbird/client/ui/services"
|
"github.com/netbirdio/netbird/client/ui/services"
|
||||||
)
|
)
|
||||||
@@ -22,6 +24,39 @@ func (t *Tray) onSystemEvent(ev *application.CustomEvent) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
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 —
|
// Session-warning and deadline-rejected events carry no UserMessage —
|
||||||
// the tray builds the localised notification body locally from metadata.
|
// the tray builds the localised notification body locally from metadata.
|
||||||
// Every other event needs a non-empty UserMessage to show anything meaningful.
|
// 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
|
// partner already drove the user-facing toast, so the v6 row is
|
||||||
// suppressed to avoid a duplicate notification)
|
// suppressed to avoid a duplicate notification)
|
||||||
func shouldSkipSystemEvent(se services.SystemEvent) bool {
|
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 {
|
if _, isUpdate := se.Metadata["new_version_available"]; isUpdate {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })
|
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()
|
t.profileSubmenu.Clear()
|
||||||
var activeName, activeEmail string
|
var activeName, activeEmail string
|
||||||
for _, p := range profiles {
|
for _, p := range profiles {
|
||||||
@@ -118,15 +125,18 @@ func (t *Tray) fillProfileSubmenu() {
|
|||||||
}
|
}
|
||||||
t.switchProfile(name)
|
t.switchProfile(name)
|
||||||
})
|
})
|
||||||
|
item.SetEnabled(!disableProfiles)
|
||||||
if active {
|
if active {
|
||||||
activeName = name
|
activeName = name
|
||||||
activeEmail = p.Email
|
activeEmail = p.Email
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
t.profileSubmenu.AddSeparator()
|
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")
|
t.svc.WindowManager.OpenSettings("profiles")
|
||||||
})
|
})
|
||||||
|
manageProfiles.SetEnabled(!disableProfiles)
|
||||||
log.Infof("tray fillProfileSubmenu: %d profile(s) for user %q, active=%q", len(profiles), username, activeName)
|
log.Infof("tray fillProfileSubmenu: %d profile(s) for user %q, active=%q", len(profiles), username, activeName)
|
||||||
if t.profileSubmenuItem != nil && activeName != "" {
|
if t.profileSubmenuItem != nil && activeName != "" {
|
||||||
t.profileSubmenuItem.SetLabel(activeName)
|
t.profileSubmenuItem.SetLabel(activeName)
|
||||||
|
|||||||
@@ -153,8 +153,9 @@ func (t *Tray) refreshMenuItemsForStatus(st services.Status, connected bool) {
|
|||||||
if t.settingsItem != nil {
|
if t.settingsItem != nil {
|
||||||
t.settingsItem.SetEnabled(!daemonUnavailable)
|
t.settingsItem.SetEnabled(!daemonUnavailable)
|
||||||
}
|
}
|
||||||
|
disableProfiles, _ := t.featuresDisabled()
|
||||||
if t.profileSubmenuItem != nil {
|
if t.profileSubmenuItem != nil {
|
||||||
t.profileSubmenuItem.SetEnabled(!daemonUnavailable)
|
t.profileSubmenuItem.SetEnabled(!daemonUnavailable && !disableProfiles)
|
||||||
}
|
}
|
||||||
// Refresh the Profiles submenu on every status-text transition: the
|
// Refresh the Profiles submenu on every status-text transition: the
|
||||||
// daemon does not emit an active-profile event, so the startup race
|
// daemon does not emit an active-profile event, so the startup race
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 "$@"
|
||||||
Binary file not shown.
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -132,6 +132,7 @@ require (
|
|||||||
gorm.io/driver/sqlite v1.5.7
|
gorm.io/driver/sqlite v1.5.7
|
||||||
gorm.io/gorm v1.25.12
|
gorm.io/gorm v1.25.12
|
||||||
gvisor.dev/gvisor v0.0.0-20260219192049-0f2374377e89
|
gvisor.dev/gvisor v0.0.0-20260219192049-0f2374377e89
|
||||||
|
howett.net/plist v1.0.2-0.20250314012144-ee69052608d9
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
|||||||
@@ -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/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 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
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 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
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=
|
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 h1:mGJaeA61P8dEHTqdvAgc70ZIV3QoUoJcXCRyyjO26OA=
|
||||||
gvisor.dev/gvisor v0.0.0-20260219192049-0f2374377e89/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q=
|
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 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
||||||
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
||||||
|
|||||||
Reference in New Issue
Block a user