diff --git a/client/cmd/peercred_bsd.go b/client/cmd/peercred_bsd.go new file mode 100644 index 000000000..3589f8e37 --- /dev/null +++ b/client/cmd/peercred_bsd.go @@ -0,0 +1,36 @@ +//go:build darwin || freebsd + +package cmd + +import ( + "fmt" + "net" + + "golang.org/x/sys/unix" +) + +// peerUID returns the uid of the process on the other end of a unix socket +// connection, read via LOCAL_PEERCRED (xucred). Note: xucred carries the uid +// and group list but no pid, so audit on these platforms is uid-based. +func peerUID(c net.Conn) (int, error) { + uc, ok := c.(*net.UnixConn) + if !ok { + return 0, fmt.Errorf("connection is not a unix socket: %T", c) + } + raw, err := uc.SyscallConn() + if err != nil { + return 0, fmt.Errorf("raw conn: %w", err) + } + + var cred *unix.Xucred + var credErr error + if err := raw.Control(func(fd uintptr) { + cred, credErr = unix.GetsockoptXucred(int(fd), unix.SOL_LOCAL, unix.LOCAL_PEERCRED) + }); err != nil { + return 0, fmt.Errorf("getsockopt control: %w", err) + } + if credErr != nil { + return 0, fmt.Errorf("LOCAL_PEERCRED: %w", credErr) + } + return int(cred.Uid), nil +} diff --git a/client/cmd/peercred_linux.go b/client/cmd/peercred_linux.go new file mode 100644 index 000000000..992be3122 --- /dev/null +++ b/client/cmd/peercred_linux.go @@ -0,0 +1,35 @@ +//go:build linux + +package cmd + +import ( + "fmt" + "net" + + "golang.org/x/sys/unix" +) + +// peerUID returns the uid of the process on the other end of a unix socket +// connection, read from the kernel via SO_PEERCRED. +func peerUID(c net.Conn) (int, error) { + uc, ok := c.(*net.UnixConn) + if !ok { + return 0, fmt.Errorf("connection is not a unix socket: %T", c) + } + raw, err := uc.SyscallConn() + if err != nil { + return 0, fmt.Errorf("raw conn: %w", err) + } + + var cred *unix.Ucred + var credErr error + if err := raw.Control(func(fd uintptr) { + cred, credErr = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED) + }); err != nil { + return 0, fmt.Errorf("getsockopt control: %w", err) + } + if credErr != nil { + return 0, fmt.Errorf("SO_PEERCRED: %w", credErr) + } + return int(cred.Uid), nil +} diff --git a/client/cmd/peercred_unsupported.go b/client/cmd/peercred_unsupported.go new file mode 100644 index 000000000..26179cae8 --- /dev/null +++ b/client/cmd/peercred_unsupported.go @@ -0,0 +1,16 @@ +//go:build !linux && !darwin && !freebsd + +package cmd + +import ( + "fmt" + "net" + "runtime" +) + +// peerUID is unimplemented on this platform, so the trust-on-first-use socket +// migration cannot run here. Configure --socket-owner explicitly, or use +// --disable-strict-socket. (Windows uses a TCP socket and never reaches this.) +func peerUID(net.Conn) (int, error) { + return 0, fmt.Errorf("peer credential check not supported on %s", runtime.GOOS) +} diff --git a/client/cmd/service.go b/client/cmd/service.go index 8d702eb46..71bd54bae 100644 --- a/client/cmd/service.go +++ b/client/cmd/service.go @@ -57,8 +57,8 @@ func init() { installCmd.Flags().StringSliceVar(&serviceEnvVars, "service-env", nil, serviceEnvDesc) reconfigureCmd.Flags().StringSliceVar(&serviceEnvVars, "service-env", nil, serviceEnvDesc) - serviceCmd.PersistentFlags().StringVar(&socketOwner, "socket-owner", "", "Socket owner TODO") - serviceCmd.PersistentFlags().BoolVar(&strictSocketDisabled, "disable-strict-socket", false, "Restrict socket permissions TODO") + serviceCmd.PersistentFlags().StringVar(&socketOwner, "socket-owner", "", "user to own the daemon control socket; restricts it to that user plus the netbird group (0660). If unset, the first client to connect claims ownership (trust-on-first-use)") + serviceCmd.PersistentFlags().BoolVar(&strictSocketDisabled, "disable-strict-socket", false, "leave the daemon control socket world-writable (0666) instead of restricting it; set via the (root-only) service command") rootCmd.AddCommand(serviceCmd) } diff --git a/client/cmd/service_controller.go b/client/cmd/service_controller.go index efc6735b0..d7cb9b53f 100644 --- a/client/cmd/service_controller.go +++ b/client/cmd/service_controller.go @@ -12,6 +12,7 @@ import ( "os/user" "strconv" "strings" + "sync" "time" "github.com/kardianos/service" @@ -58,35 +59,41 @@ func (p *program) Start(svc service.Service) error { go func() { defer listen.Close() + srvListener := listen if split[0] == "unix" { - socketPerm := os.FileMode(0666) - if socketOwner != "" && !strictSocketDisabled { - socketPerm = 0660 - gid, err := addGroup("netbird") - if err != nil { - log.Errorf("failed setting up group (%d): %v", gid, err) - } - user, err := user.Lookup(socketOwner) - if err != nil { - log.Errorf("lookup user %q: %v", socketOwner, err) + owner := effectiveSocketOwner() + switch { + case strictSocketDisabled: + // Opt-out (root-only, via service.json): leave it world-writable. + if err := os.Chmod(split[1], 0666); err != nil { + log.Errorf("failed setting daemon permissions: %v", split[1]) return } - uid, err := strconv.ParseInt(user.Uid, 10, 64) + case owner != "": + // Seeded owner (flag, MDM, or persisted TOFU result): restrict + // before serving so there is no open window. + u, err := user.Lookup(owner) if err != nil { - log.Errorf("falied to convert uid (%d) to int: %v", uid, err) + log.Errorf("lookup socket owner %q: %v", owner, err) return } - if err = os.Chown(split[1], int(uid), int(gid)); err != nil { - log.Errorf("failed setting daemon group (%d) on socket: %v", gid, split[1]) + uid, err := strconv.Atoi(u.Uid) + if err != nil { + log.Errorf("parse uid %q for %q: %v", u.Uid, owner, err) return } - } - if socketOwner == "" && !strictSocketDisabled { - // TODO: handle TOFU - } - if err := os.Chmod(split[1], socketPerm); err != nil { - log.Errorf("failed setting daemon permissions: %v", split[1]) - return + if err := restrictSocket(split[1], uid); err != nil { + log.Errorf("restrict socket to %q: %v", owner, err) + return + } + default: + // Trust-on-first-use: open the socket now; tofuListener locks it + // to the first caller's uid on the first connection. + if err := os.Chmod(split[1], 0666); err != nil { + log.Errorf("failed setting daemon permissions: %v", split[1]) + return + } + srvListener = &tofuListener{Listener: listen, path: split[1], owner: -1} } } @@ -101,7 +108,7 @@ func (p *program) Start(svc service.Service) error { p.serverInstanceMu.Unlock() log.Printf("started daemon server: %v", split[1]) - if err := p.serv.Serve(listen); err != nil { + if err := p.serv.Serve(srvListener); err != nil { log.Errorf("failed to serve daemon requests: %v", err) } }() @@ -138,6 +145,130 @@ func addGroup(name string) (int, error) { return -1, fmt.Errorf("lookup group %q: %w", name, err) } +// restrictSocket locks the unix socket down to the owner uid plus the netbird +// group (0660). If the group cannot be created or applied, it fails closed to +// owner-only 0600 — it never leaves the socket world-writable. +func restrictSocket(path string, uid int) error { + gid, err := addGroup("netbird") + if err != nil { + log.Errorf("create netbird group, failing closed to owner-only 0600: %v", err) + return chownChmod(path, uid, -1, 0600) + } + if err := chownChmod(path, uid, gid, 0660); err != nil { + log.Errorf("apply netbird group to socket, failing closed to owner-only 0600: %v", err) + return chownChmod(path, uid, -1, 0600) + } + return nil +} + +// chownChmod sets ownership and mode on the socket. A gid of -1 leaves the +// group unchanged. +func chownChmod(path string, uid, gid int, mode os.FileMode) error { + if err := os.Chown(path, uid, gid); err != nil { + return fmt.Errorf("chown socket %s: %w", path, err) + } + if err := os.Chmod(path, mode); err != nil { + return fmt.Errorf("chmod socket %s: %w", path, err) + } + return nil +} + +// tofuListener implements trust-on-first-use for the daemon control socket. +// The socket starts world-writable; the first caller's uid (read via the +// platform peer-credential mechanism) becomes the owner. On that first +// connection the socket is restricted (see restrictSocket) and the owner is +// persisted so the open window never reopens on later starts. Connections that +// raced in during the open window and are neither the owner nor root are +// dropped. Changing the socket mode does not disturb the already-open +// connection, so the first caller's request is served normally. +type tofuListener struct { + net.Listener + path string + mu sync.Mutex + owner int // -1 until claimed +} + +func (l *tofuListener) Accept() (net.Conn, error) { + for { + c, err := l.Listener.Accept() + if err != nil { + return nil, err + } + + uid, err := peerUID(c) + if err != nil { + log.Errorf("read peer credentials, dropping connection: %v", err) + _ = c.Close() + continue + } + + l.mu.Lock() + if l.owner == -1 { + if err := restrictSocket(l.path, uid); err != nil { + l.mu.Unlock() + _ = c.Close() + // Refuse to serve on a socket we could not lock down. + return nil, fmt.Errorf("restrict socket on first connection: %w", err) + } + l.owner = uid + persistSocketOwner(uid) + log.Infof("control socket restricted to first caller (uid %d)", uid) + l.mu.Unlock() + return c, nil + } + owner := l.owner + l.mu.Unlock() + + // New connects are already gated by the 0660 perms set above; this only + // drops anything that slipped in during the brief open window. + if uid != owner && uid != 0 { + log.Warnf("dropping non-owner connection (uid %d) during socket bootstrap", uid) + _ = c.Close() + continue + } + return c, nil + } +} + +// effectiveSocketOwner returns the configured socket owner: the --socket-owner +// flag when set, otherwise the owner persisted by a previous TOFU migration. +func effectiveSocketOwner() string { + if socketOwner != "" { + return socketOwner + } + params, err := loadServiceParams() + if err != nil { + log.Errorf("load service params for socket owner: %v", err) + return "" + } + if params != nil { + return params.SocketOwner + } + return "" +} + +// persistSocketOwner records the TOFU-selected owner (by username) so the next +// daemon start restricts the socket immediately, with no open window. +func persistSocketOwner(uid int) { + u, err := user.LookupId(strconv.Itoa(uid)) + if err != nil { + log.Errorf("resolve uid %d to username for persistence: %v", uid, err) + return + } + params, err := loadServiceParams() + if err != nil { + log.Errorf("load service params to persist socket owner: %v", err) + return + } + if params == nil { + params = currentServiceParams() + } + params.SocketOwner = u.Username + if err := saveServiceParams(params); err != nil { + log.Errorf("persist socket owner: %v", err) + } +} + func (p *program) Stop(srv service.Service) error { p.serverInstanceMu.Lock() if p.serverInstance != nil { diff --git a/client/cmd/service_installer.go b/client/cmd/service_installer.go index 11acbd518..50fe1be9f 100644 --- a/client/cmd/service_installer.go +++ b/client/cmd/service_installer.go @@ -71,6 +71,10 @@ func buildServiceArguments() []string { args = append(args, "--socket-owner", socketOwner) } + if strictSocketDisabled { + args = append(args, "--disable-strict-socket") + } + return args } diff --git a/client/cmd/service_params.go b/client/cmd/service_params.go index 192e0ac60..3da57af3a 100644 --- a/client/cmd/service_params.go +++ b/client/cmd/service_params.go @@ -30,6 +30,8 @@ type serviceParams struct { DisableUpdateSettings bool `json:"disable_update_settings,omitempty"` EnableCapture bool `json:"enable_capture,omitempty"` DisableNetworks bool `json:"disable_networks,omitempty"` + SocketOwner string `json:"socket_owner,omitempty"` + DisableStrictSocket bool `json:"disable_strict_socket,omitempty"` ServiceEnvVars map[string]string `json:"service_env_vars,omitempty"` } @@ -82,6 +84,8 @@ func currentServiceParams() *serviceParams { DisableUpdateSettings: updateSettingsDisabled, EnableCapture: captureEnabled, DisableNetworks: networksDisabled, + SocketOwner: socketOwner, + DisableStrictSocket: strictSocketDisabled, } if len(serviceEnvVars) > 0 { @@ -154,6 +158,14 @@ func applyServiceParams(cmd *cobra.Command, params *serviceParams) { networksDisabled = params.DisableNetworks } + if !serviceCmd.PersistentFlags().Changed("socket-owner") { + socketOwner = params.SocketOwner + } + + if !serviceCmd.PersistentFlags().Changed("disable-strict-socket") { + strictSocketDisabled = params.DisableStrictSocket + } + applyServiceEnvParams(cmd, params) }