mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-25 15:32:34 -04:00
Compare commits
26 Commits
daemon-ipc
...
grpc-acl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbf326e22c | ||
|
|
11642aeb44 | ||
|
|
14917dbc22 | ||
|
|
ae9ee15501 | ||
|
|
cd98648a67 | ||
|
|
0bb73b3730 | ||
|
|
5e07a0c27b | ||
|
|
0137876618 | ||
|
|
57f9cbe5ff | ||
|
|
f60ac9e746 | ||
|
|
2f84aa3d20 | ||
|
|
4de39a80f8 | ||
|
|
5d9ef0123f | ||
|
|
27afbd7952 | ||
|
|
a12a3e4603 | ||
|
|
dc89b471fa | ||
|
|
0e520ee9f5 | ||
|
|
9620890b65 | ||
|
|
69c35e31b4 | ||
|
|
b6cd8944b1 | ||
|
|
6fc05efa6c | ||
|
|
3cda14d7f2 | ||
|
|
d9392fdbb8 | ||
|
|
82fdfa84b8 | ||
|
|
ca80e49aa0 | ||
|
|
51f17bf919 |
@@ -237,7 +237,7 @@ task dev
|
||||
Pass daemon flags after `--`:
|
||||
|
||||
```
|
||||
task dev -- --daemon-addr=tcp://127.0.0.1:41731
|
||||
task dev -- --daemon-addr=npipe://netbird
|
||||
```
|
||||
|
||||
Production build (frontend assets embedded into the binary, output in `client/ui/bin/`):
|
||||
|
||||
@@ -247,6 +247,9 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin
|
||||
deps.SyncResponse = resp
|
||||
|
||||
if e := cc.Engine(); e != nil {
|
||||
deps.RefreshStatus = func() {
|
||||
e.RunHealthProbes(context.Background(), true)
|
||||
}
|
||||
if cm := e.GetClientMetrics(); cm != nil {
|
||||
deps.ClientMetrics = cm
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ func (pm *ProfileManager) SwitchProfile(id string) error {
|
||||
// AddProfile creates a new profile
|
||||
func (pm *ProfileManager) AddProfile(profileName string) error {
|
||||
// Use ServiceManager (creates profile in profiles/ directory)
|
||||
profile, err := pm.serviceMgr.AddProfile(profileName, androidUsername)
|
||||
profile, err := pm.serviceMgr.AddProfile(profileName, androidUsername, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add profile: %w", err)
|
||||
}
|
||||
|
||||
58
client/cmd/elevate.go
Normal file
58
client/cmd/elevate.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// SetSSHConfigCmdName is the hidden subcommand an elevated process runs to apply
|
||||
// the privileged SSH settings.
|
||||
const SetSSHConfigCmdName = "set-ssh-config"
|
||||
|
||||
// sshConfigElevated is set by runInDaemonMode once the dangerous SSH settings
|
||||
// have been applied by an elevated helper, so the (unprivileged) up flow does
|
||||
// not re-send them and re-trip the daemon gate.
|
||||
var sshConfigElevated bool
|
||||
|
||||
// wantsDangerousSSH reports whether this invocation is trying to ENABLE SSH root
|
||||
// login or DISABLE SSH authentication.
|
||||
func wantsDangerousSSH(cmd *cobra.Command) bool {
|
||||
return (cmd.Flags().Changed(enableSSHRootFlag) && enableSSHRoot) ||
|
||||
(cmd.Flags().Changed(disableSSHAuthFlag) && disableSSHAuth)
|
||||
}
|
||||
|
||||
// buildSetSSHConfigArgs builds the argument list for an elevated
|
||||
// `netbird set-ssh-config` invocation.
|
||||
func buildSetSSHConfigArgs(profileName, username string, enableSSHRoot, disableSSHAuth *bool, daemonAddr string) []string {
|
||||
args := []string{SetSSHConfigCmdName}
|
||||
if profileName != "" {
|
||||
args = append(args, "--profile", profileName)
|
||||
}
|
||||
if username != "" {
|
||||
args = append(args, "--username", username)
|
||||
}
|
||||
if enableSSHRoot != nil && *enableSSHRoot {
|
||||
args = append(args, "--"+enableSSHRootFlag)
|
||||
}
|
||||
if disableSSHAuth != nil && *disableSSHAuth {
|
||||
args = append(args, "--"+disableSSHAuthFlag)
|
||||
}
|
||||
if daemonAddr != "" {
|
||||
args = append(args, "--daemon-addr", daemonAddr)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// ElevateSSHConfig re-runs the current executable's `set-ssh-config` command with
|
||||
// root/administrator privileges via the platform's prompt (pkexec/UAC/osascript),
|
||||
// so the elevated process connects to the daemon with a privileged identity and
|
||||
// the daemon's requirePrivilegedForDangerousSSH gate passes.
|
||||
func ElevateSSHConfig(profileName, username string, enableSSHRoot, disableSSHAuth *bool) error {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("locate executable for elevation: %w", err)
|
||||
}
|
||||
return runElevated(exe, buildSetSSHConfigArgs(profileName, username, enableSSHRoot, disableSSHAuth, daemonAddr))
|
||||
}
|
||||
16
client/cmd/elevate_darwin.go
Normal file
16
client/cmd/elevate_darwin.go
Normal file
@@ -0,0 +1,16 @@
|
||||
//go:build darwin
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// isProcessPrivileged reports whether the current process runs as root.
|
||||
func isProcessPrivileged() bool { return os.Geteuid() == 0 }
|
||||
|
||||
// TODO(ssh-elevation): implement the osascript admin prompt.
|
||||
func runElevated(_ string, _ []string) error {
|
||||
return fmt.Errorf("automatic privilege elevation is not yet implemented on macOS, re-run with sudo")
|
||||
}
|
||||
38
client/cmd/elevate_linux.go
Normal file
38
client/cmd/elevate_linux.go
Normal file
@@ -0,0 +1,38 @@
|
||||
//go:build linux
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// isProcessPrivileged reports whether the current process runs as root.
|
||||
func isProcessPrivileged() bool { return os.Geteuid() == 0 }
|
||||
|
||||
// runElevated re-runs exe with args as root. On a graphical session it uses
|
||||
// pkexec, which drives the desktop's polkit authentication agent (GUI prompt).
|
||||
func runElevated(exe string, args []string) error {
|
||||
if !hasGraphicalSession() {
|
||||
return fmt.Errorf("cannot request privilege elevation without a graphical session. re-run as root: sudo %s %s", exe, strings.Join(args, " "))
|
||||
}
|
||||
pkexec, err := exec.LookPath("pkexec")
|
||||
if err != nil {
|
||||
return fmt.Errorf("pkexec not found for privilege elevation, re-run as root: sudo %s %s", exe, strings.Join(args, " "))
|
||||
}
|
||||
|
||||
c := exec.Command(pkexec, append([]string{exe}, args...)...)
|
||||
c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr
|
||||
if err := c.Run(); err != nil {
|
||||
return fmt.Errorf("elevated %s failed (elevation cancelled or denied?): %w", SetSSHConfigCmdName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasGraphicalSession heuristically reports whether a desktop session is present
|
||||
// that polkit can prompt in.
|
||||
func hasGraphicalSession() bool {
|
||||
return os.Getenv("DISPLAY") != "" || os.Getenv("WAYLAND_DISPLAY") != ""
|
||||
}
|
||||
17
client/cmd/elevate_other.go
Normal file
17
client/cmd/elevate_other.go
Normal file
@@ -0,0 +1,17 @@
|
||||
//go:build !linux && !darwin && !windows
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// isProcessPrivileged reports whether the current process runs as root.
|
||||
func isProcessPrivileged() bool { return os.Geteuid() == 0 }
|
||||
|
||||
// runElevated has no automatic elevation mechanism on these platforms
|
||||
func runElevated(exe string, args []string) error {
|
||||
return fmt.Errorf("automatic privilege elevation is not supported on this platform, re-run as root: sudo %s %s", exe, strings.Join(args, " "))
|
||||
}
|
||||
99
client/cmd/elevate_test.go
Normal file
99
client/cmd/elevate_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
//go:build !ios && !android
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBuildSetSSHConfigArgs(t *testing.T) {
|
||||
tr, fa := true, false
|
||||
|
||||
t.Run("enable root only, with daemon-addr", func(t *testing.T) {
|
||||
got := buildSetSSHConfigArgs("prof", "alice", &tr, nil, "unix:///x.sock")
|
||||
assert.Equal(t, []string{
|
||||
SetSSHConfigCmdName, "--profile", "prof", "--username", "alice",
|
||||
"--" + enableSSHRootFlag, "--daemon-addr", "unix:///x.sock",
|
||||
}, got)
|
||||
})
|
||||
|
||||
t.Run("disable auth only, no daemon-addr", func(t *testing.T) {
|
||||
got := buildSetSSHConfigArgs("prof", "alice", nil, &tr, "")
|
||||
assert.Equal(t, []string{
|
||||
SetSSHConfigCmdName, "--profile", "prof", "--username", "alice",
|
||||
"--" + disableSSHAuthFlag,
|
||||
}, got)
|
||||
})
|
||||
|
||||
t.Run("false pointers omit the flags", func(t *testing.T) {
|
||||
got := buildSetSSHConfigArgs("", "", &fa, &fa, "")
|
||||
assert.Equal(t, []string{SetSSHConfigCmdName}, got)
|
||||
})
|
||||
|
||||
t.Run("both enabled", func(t *testing.T) {
|
||||
got := buildSetSSHConfigArgs("p", "u", &tr, &tr, "")
|
||||
assert.Equal(t, []string{
|
||||
SetSSHConfigCmdName, "--profile", "p", "--username", "u",
|
||||
"--" + enableSSHRootFlag, "--" + disableSSHAuthFlag,
|
||||
}, got)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildSetSSHConfigRequest(t *testing.T) {
|
||||
tr := true
|
||||
|
||||
req := buildSetSSHConfigRequest("p", "u", &tr, nil)
|
||||
assert.Equal(t, "p", req.ProfileName)
|
||||
assert.Equal(t, "u", req.Username)
|
||||
if assert.NotNil(t, req.EnableSSHRoot) {
|
||||
assert.True(t, *req.EnableSSHRoot)
|
||||
}
|
||||
assert.Nil(t, req.DisableSSHAuth, "an unset flag must leave the daemon value untouched")
|
||||
}
|
||||
|
||||
func TestWantsDangerousSSH(t *testing.T) {
|
||||
origRoot, origAuth := enableSSHRoot, disableSSHAuth
|
||||
t.Cleanup(func() { enableSSHRoot, disableSSHAuth = origRoot, origAuth })
|
||||
|
||||
newCmd := func() *cobra.Command {
|
||||
enableSSHRoot, disableSSHAuth = false, false
|
||||
c := &cobra.Command{Use: "x"}
|
||||
c.Flags().BoolVar(&enableSSHRoot, enableSSHRootFlag, false, "")
|
||||
c.Flags().BoolVar(&disableSSHAuth, disableSSHAuthFlag, false, "")
|
||||
return c
|
||||
}
|
||||
|
||||
// wantsDangerousSSH fires only in the privileged direction.
|
||||
t.Run("enable root true is dangerous", func(t *testing.T) {
|
||||
c := newCmd()
|
||||
require.NoError(t, c.Flags().Set(enableSSHRootFlag, "true"))
|
||||
assert.True(t, wantsDangerousSSH(c))
|
||||
})
|
||||
|
||||
t.Run("enable root false is not dangerous", func(t *testing.T) {
|
||||
c := newCmd()
|
||||
require.NoError(t, c.Flags().Set(enableSSHRootFlag, "false"))
|
||||
assert.False(t, wantsDangerousSSH(c))
|
||||
})
|
||||
|
||||
t.Run("disable auth true is dangerous", func(t *testing.T) {
|
||||
c := newCmd()
|
||||
require.NoError(t, c.Flags().Set(disableSSHAuthFlag, "true"))
|
||||
assert.True(t, wantsDangerousSSH(c))
|
||||
})
|
||||
|
||||
t.Run("disable auth false is not dangerous", func(t *testing.T) {
|
||||
c := newCmd()
|
||||
require.NoError(t, c.Flags().Set(disableSSHAuthFlag, "false"))
|
||||
assert.False(t, wantsDangerousSSH(c))
|
||||
})
|
||||
|
||||
t.Run("nothing changed is not dangerous", func(t *testing.T) {
|
||||
c := newCmd()
|
||||
assert.False(t, wantsDangerousSSH(c))
|
||||
})
|
||||
}
|
||||
23
client/cmd/elevate_windows.go
Normal file
23
client/cmd/elevate_windows.go
Normal file
@@ -0,0 +1,23 @@
|
||||
//go:build windows
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// isProcessPrivileged reports whether the current process token is elevated
|
||||
// (running as administrator / LocalSystem).
|
||||
func isProcessPrivileged() bool {
|
||||
return windows.GetCurrentProcessToken().IsElevated()
|
||||
}
|
||||
|
||||
// runElevated should re-run exe with args elevated via a UAC prompt
|
||||
// (ShellExecuteEx with the "runas" verb).
|
||||
//
|
||||
// TODO(ssh-elevation): implement ShellExecuteEx("runas") + wait for the child.
|
||||
func runElevated(_ string, _ []string) error {
|
||||
return fmt.Errorf("automatic privilege elevation is not yet implemented on Windows, re-run netbird as administrator")
|
||||
}
|
||||
116
client/cmd/owner.go
Normal file
116
client/cmd/owner.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
var ownerCmd = &cobra.Command{
|
||||
Use: "owner",
|
||||
Short: "Manage who may control the NetBird daemon",
|
||||
Long: `Manage the daemon-wide owners.
|
||||
|
||||
Owners are enforced on the daemon and stored in the service parameters. All owners
|
||||
may control the daemon and use the shared default profile (plus root/administrator),
|
||||
every other profile stays isolated to the user that created it. An unowned daemon
|
||||
is claimed by the first caller (trust-on-first-use).`,
|
||||
}
|
||||
|
||||
var ownerAddCmd = &cobra.Command{
|
||||
Use: "add <principal>",
|
||||
Short: "Add a daemon owner principal",
|
||||
Long: `Add a daemon-wide owner principal. Principals are typed:
|
||||
uid:1000 a Unix user ID
|
||||
gid:1000 a Unix group ID
|
||||
group:netbird-admins a Unix group name (resolved via NSS/getent)
|
||||
sid:S-1-5-21-... a Windows user or group SID
|
||||
|
||||
Requires root/administrator or an existing owner.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return withDaemon(cmd, func(ctx context.Context, c proto.DaemonServiceClient) error {
|
||||
if _, err := c.AddOwner(ctx, &proto.AddOwnerRequest{Principal: args[0]}); err != nil {
|
||||
return err
|
||||
}
|
||||
cmd.Printf("Added daemon owner %q\n", args[0])
|
||||
return nil
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
var ownerResetCmd = &cobra.Command{
|
||||
Use: "reset",
|
||||
Short: "Clear the daemon owner list (root/administrator only)",
|
||||
Long: `Clear the daemon-wide owner list, returning the daemon to the unowned
|
||||
state. The next caller then claims ownership (trust-on-first-use). Requires
|
||||
root/administrator.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return withDaemon(cmd, func(ctx context.Context, c proto.DaemonServiceClient) error {
|
||||
if _, err := c.ResetOwner(ctx, &proto.ResetOwnerRequest{}); err != nil {
|
||||
return err
|
||||
}
|
||||
cmd.Println("Daemon owner list cleared, the next caller will claim ownership")
|
||||
return nil
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
var ownerShareCmd = &cobra.Command{
|
||||
Use: "share",
|
||||
Short: "Mark the daemon shared (any local user may control it)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return withDaemon(cmd, func(ctx context.Context, c proto.DaemonServiceClient) error {
|
||||
if _, err := c.ShareProfile(ctx, &proto.ShareProfileRequest{Shared: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
cmd.Println("Daemon is now shared with all local users")
|
||||
return nil
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
var ownerUnshareCmd = &cobra.Command{
|
||||
Use: "unshare",
|
||||
Short: "Stop sharing the daemon (restrict to its owners)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return withDaemon(cmd, func(ctx context.Context, c proto.DaemonServiceClient) error {
|
||||
if _, err := c.ShareProfile(ctx, &proto.ShareProfileRequest{Shared: false}); err != nil {
|
||||
return err
|
||||
}
|
||||
cmd.Println("Daemon is no longer shared")
|
||||
return nil
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// withDaemon runs fn with a connected daemon client, handling setup and teardown.
|
||||
func withDaemon(cmd *cobra.Command, fn func(context.Context, proto.DaemonServiceClient) error) error {
|
||||
SetFlagsFromEnvVars(rootCmd)
|
||||
cmd.SetOut(cmd.OutOrStdout())
|
||||
if err := util.InitLog(logLevel, util.LogConsole); err != nil {
|
||||
log.Errorf("failed initializing log %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
||||
if err != nil {
|
||||
log.Errorf("failed to connect to service CLI interface %v", err)
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if cerr := conn.Close(); cerr != nil {
|
||||
log.Debugf("close daemon connection: %v", cerr)
|
||||
}
|
||||
}()
|
||||
|
||||
return fn(ctx, proto.NewDaemonServiceClient(conn))
|
||||
}
|
||||
@@ -117,10 +117,12 @@ func listProfilesFunc(cmd *cobra.Command, _ []string) error {
|
||||
} else {
|
||||
fmt.Fprintln(tw, "NAME\tACTIVE")
|
||||
}
|
||||
anyActive := false
|
||||
for _, profile := range resp.Profiles {
|
||||
marker := ""
|
||||
if profile.IsActive {
|
||||
marker = "✓"
|
||||
anyActive = true
|
||||
}
|
||||
name := profilemanager.StripCtrlChars(profile.Name)
|
||||
id := profilemanager.ID(profile.Id)
|
||||
@@ -130,7 +132,19 @@ func listProfilesFunc(cmd *cobra.Command, _ []string) error {
|
||||
fmt.Fprintf(tw, "%s\t%s\n", name, marker)
|
||||
}
|
||||
}
|
||||
return tw.Flush()
|
||||
if err := tw.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// None of the caller's profiles is active: another user may hold the daemon.
|
||||
// Surface it so the empty ACTIVE column is not mistaken for "nothing active".
|
||||
if !anyActive {
|
||||
if active, aerr := daemonClient.GetActiveProfile(cmd.Context(), &proto.GetActiveProfileRequest{}); aerr == nil && active.GetUsername() != "" && !usernamesMatch(active.GetUsername(), currUser.Username) {
|
||||
cmd.Printf("\nActive profile belongs to another user: %s (user %s)\n",
|
||||
profilemanager.StripCtrlChars(active.GetProfileName()), active.GetUsername())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func addProfileFunc(cmd *cobra.Command, args []string) error {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path"
|
||||
@@ -143,10 +144,10 @@ func init() {
|
||||
|
||||
defaultDaemonAddr := "unix:///var/run/netbird.sock"
|
||||
if runtime.GOOS == "windows" {
|
||||
defaultDaemonAddr = "tcp://127.0.0.1:41731"
|
||||
defaultDaemonAddr = windowsPipeDaemonAddr
|
||||
}
|
||||
|
||||
rootCmd.PersistentFlags().StringVar(&daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp]://[path|host:port]")
|
||||
rootCmd.PersistentFlags().StringVar(&daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp|npipe]://[path|host:port|name]")
|
||||
rootCmd.PersistentFlags().StringVarP(&managementURL, "management-url", "m", "", fmt.Sprintf("Management Service URL [http|https]://[host]:[port] (default \"%s\")", profilemanager.DefaultManagementURL))
|
||||
rootCmd.PersistentFlags().StringVar(&adminURL, "admin-url", "", fmt.Sprintf("Admin Panel URL [http|https]://[host]:[port] (default \"%s\")", profilemanager.DefaultAdminURL))
|
||||
rootCmd.PersistentFlags().StringVarP(&logLevel, "log-level", "l", "info", "sets NetBird log level")
|
||||
@@ -172,6 +173,11 @@ func init() {
|
||||
rootCmd.AddCommand(profileCmd)
|
||||
rootCmd.AddCommand(exposeCmd)
|
||||
|
||||
rootCmd.AddCommand(ownerCmd)
|
||||
ownerCmd.AddCommand(ownerAddCmd, ownerResetCmd, ownerShareCmd, ownerUnshareCmd)
|
||||
|
||||
rootCmd.AddCommand(setSSHConfigCmd)
|
||||
|
||||
networksCMD.AddCommand(routesListCmd)
|
||||
networksCMD.AddCommand(routesSelectCmd, routesDeselectCmd)
|
||||
|
||||
@@ -264,17 +270,32 @@ func FlagNameToEnvVar(cmdFlag string, prefix string) string {
|
||||
return prefix + upper
|
||||
}
|
||||
|
||||
// daemonDialTarget returns the gRPC dial target and base options for the daemon
|
||||
// address, handling the npipe scheme (Windows named pipe, via a context dialer)
|
||||
// and unix/tcp.
|
||||
func daemonDialTarget(addr string) (string, []grpc.DialOption) {
|
||||
opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
|
||||
target := strings.TrimPrefix(addr, "tcp://")
|
||||
if strings.HasPrefix(addr, "npipe://") {
|
||||
path := pipePath(strings.TrimPrefix(addr, "npipe://"))
|
||||
opts = append(opts, grpc.WithContextDialer(func(dialCtx context.Context, _ string) (net.Conn, error) {
|
||||
return dialNamedPipe(dialCtx, path)
|
||||
}))
|
||||
target = "passthrough:///netbird-daemon-pipe"
|
||||
}
|
||||
return target, opts
|
||||
}
|
||||
|
||||
// DialClientGRPCServer returns client connection to the daemon server.
|
||||
func DialClientGRPCServer(ctx context.Context, addr string) (*grpc.ClientConn, error) {
|
||||
func DialClientGRPCServer(ctx context.Context, addr string, opts ...grpc.DialOption) (*grpc.ClientConn, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, time.Second*10)
|
||||
defer cancel()
|
||||
|
||||
return grpc.DialContext(
|
||||
ctx,
|
||||
strings.TrimPrefix(addr, "tcp://"),
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithBlock(),
|
||||
)
|
||||
target, dialOpts := daemonDialTarget(addr)
|
||||
dialOpts = append(dialOpts, grpc.WithBlock())
|
||||
dialOpts = append(dialOpts, opts...)
|
||||
|
||||
return grpc.DialContext(ctx, target, dialOpts...)
|
||||
}
|
||||
|
||||
// WithBackOff execute function in backoff cycle.
|
||||
|
||||
@@ -30,6 +30,12 @@ var (
|
||||
serviceEnvVars []string
|
||||
jsonSocket string
|
||||
enableJSONSocket bool
|
||||
// owners seeds the daemon-wide owner set at install time (--owner). At runtime
|
||||
// the daemon reads and writes owners in service.json directly.
|
||||
owners []string
|
||||
// daemonShared carries the persisted daemon shared flag across
|
||||
// install/reconfigure round-trips (set at runtime via `netbird owner share`).
|
||||
daemonShared bool
|
||||
)
|
||||
|
||||
type program struct {
|
||||
@@ -54,7 +60,8 @@ func init() {
|
||||
serviceCmd.PersistentFlags().BoolVar(&captureEnabled, "enable-capture", false, "Enables packet capture via 'netbird debug capture'. To persist, use: netbird service install --enable-capture")
|
||||
serviceCmd.PersistentFlags().BoolVar(&networksDisabled, "disable-networks", false, "Disables network selection. If enabled, the client will not allow listing, selecting, or deselecting networks. To persist, use: netbird service install --disable-networks")
|
||||
serviceCmd.PersistentFlags().BoolVar(&enableJSONSocket, "enable-json-socket", false, "Enables the HTTP/JSON API socket served by grpc-gateway. To persist, use: netbird service install --enable-json-socket")
|
||||
serviceCmd.PersistentFlags().StringVar(&jsonSocket, "json-socket", defaultJSONSocket, "HTTP/JSON API socket address [unix|tcp]://[path|host:port]. Requires --enable-json-socket to serve. To persist, use: netbird service install --enable-json-socket --json-socket")
|
||||
serviceCmd.PersistentFlags().StringVar(&jsonSocket, "json-socket", defaultJSONSocket, "HTTP/JSON API socket address [unix|tcp|npipe]://[path|host:port|name]. Requires --enable-json-socket to serve. To persist, use: netbird service install --enable-json-socket --json-socket")
|
||||
serviceCmd.PersistentFlags().StringSliceVar(&owners, "owner", nil, "Principal(s) allowed to control the daemon and its default profile: uid:1000, gid:1000, group:netbird-admins (NSS), or sid:S-1-5-... (Windows). Repeatable. Other profiles stay isolated per user. To persist: netbird service install --owner uid:1000")
|
||||
|
||||
rootCmd.PersistentFlags().StringVarP(&serviceName, "service", "s", defaultServiceName, "Netbird system service name")
|
||||
serviceEnvDesc := `Sets extra environment variables for the service. ` +
|
||||
|
||||
@@ -5,6 +5,7 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/kardianos/service"
|
||||
@@ -13,12 +14,32 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/server"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
// daemonServerOptions installs peer-identity transport credentials and the
|
||||
// authorization interceptor on the daemon ipc if supported.
|
||||
func daemonServerOptions(network string, interceptor *ipcauth.Interceptor) []grpc.ServerOption {
|
||||
creds := ipcauth.NewTransportCredentials()
|
||||
if creds == nil {
|
||||
log.Warnf("daemon ipc has no peer-identity primitive on %s, per-caller authorization is disabled", runtime.GOOS)
|
||||
return nil
|
||||
}
|
||||
if network == "tcp" {
|
||||
log.Warnf("daemon is listening on TCP (%s), peer identity cannot be authenticated over TCP, per-caller authorization is disabled", daemonAddr)
|
||||
return nil
|
||||
}
|
||||
return []grpc.ServerOption{
|
||||
grpc.Creds(creds),
|
||||
grpc.ChainUnaryInterceptor(interceptor.UnaryServerInterceptor()),
|
||||
grpc.ChainStreamInterceptor(interceptor.StreamServerInterceptor()),
|
||||
}
|
||||
}
|
||||
|
||||
func validateJSONSocketFlags() error {
|
||||
if serviceCmd.PersistentFlags().Changed("json-socket") && !enableJSONSocket {
|
||||
return fmt.Errorf("--json-socket requires --enable-json-socket to configure the daemon JSON gateway")
|
||||
@@ -37,8 +58,28 @@ func (p *program) Start(svc service.Service) error {
|
||||
// Collect static system and platform information
|
||||
system.UpdateStaticInfoAsync()
|
||||
|
||||
// in any case, even if configuration does not exists we run daemon to serve CLI gRPC API.
|
||||
p.serv = grpc.NewServer()
|
||||
// A daemon installed before named-pipe support uses the old loopback-TCP
|
||||
// address as the daemon address. We migrate to a named pipe so an
|
||||
// upgraded daemon enforces per-caller authorization instead of silently
|
||||
// running on identity-less TCP.
|
||||
if migrated, ok := migrateLegacyDaemonAddr(daemonAddr); ok {
|
||||
log.Infof("legacy daemon address %q predates named-pipe support. listening on %q so per-caller authorization is enforced", daemonAddr, migrated)
|
||||
daemonAddr = migrated
|
||||
}
|
||||
|
||||
network, _, err := parseListenAddress(daemonAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse daemon address: %w", err)
|
||||
}
|
||||
|
||||
// Owner-authorization interceptor. The ConfigAdapter is a lazy bridge: the
|
||||
// gRPC server is built before the daemon server instance exists, so we set
|
||||
// the real policy backend below once serverInstance is created.
|
||||
ownerAdapter := &ipcauth.ConfigAdapter{}
|
||||
authInterceptor := ipcauth.NewInterceptor(ownerAdapter, ipcauth.NewDefaultGroupResolver())
|
||||
|
||||
// in any case, even if configuration does not exist we run daemon to serve the CLI gRPC API.
|
||||
p.serv = grpc.NewServer(daemonServerOptions(network, authInterceptor)...)
|
||||
|
||||
daemonListener, err := listenOnAddress(daemonAddr)
|
||||
if err != nil {
|
||||
@@ -74,9 +115,13 @@ func (p *program) Start(svc service.Service) error {
|
||||
}
|
||||
|
||||
serverInstance := server.New(p.ctx, util.FindFirstLogPath(logFiles), configPath, profilesDisabled, updateSettingsDisabled, captureEnabled, networksDisabled)
|
||||
// Daemon-wide owners live in service.json (governs the default profile and
|
||||
// daemon access), wire persistence before serving so owner add / TOFU work.
|
||||
serverInstance.SetDaemonOwnerStore(daemonOwnerStore{})
|
||||
if err := serverInstance.Start(); err != nil {
|
||||
log.Fatalf("failed to start daemon: %v", err)
|
||||
}
|
||||
ownerAdapter.SetBackend(serverInstance)
|
||||
proto.RegisterDaemonServiceServer(p.serv, serverInstance)
|
||||
|
||||
p.serverInstanceMu.Lock()
|
||||
@@ -84,6 +129,7 @@ func (p *program) Start(svc service.Service) error {
|
||||
p.serverInstanceMu.Unlock()
|
||||
|
||||
if jsonListener != nil {
|
||||
log.Warnf("JSON gateway (--enable-json-socket) re-dials the daemon locally. The HTTP client's identity is forwarded so per-caller authorization still applies, but restrict access to %s appropriately", jsonSocket)
|
||||
if err := p.startJSONGateway(jsonListener, daemonAddr); err != nil {
|
||||
log.Fatalf("failed to start daemon JSON server: %v", err)
|
||||
}
|
||||
|
||||
@@ -5,27 +5,62 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/metadata"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
func grpcGatewayEndpoint(addr string) string {
|
||||
return strings.TrimPrefix(addr, "tcp://")
|
||||
// jsonPeerCtxKey keys the HTTP client's kernel identity in the request context.
|
||||
type jsonPeerCtxKey struct{}
|
||||
|
||||
// jsonConnContext reads the connecting HTTP client's identity from the JSON
|
||||
// socket and stashes it so it can be forwarded to the daemon. The gateway
|
||||
// re-dials the daemon as the daemon's own identity, so without this the
|
||||
// daemon would see every JSON request as privileged.
|
||||
func jsonConnContext(ctx context.Context, c net.Conn) context.Context {
|
||||
id, err := ipcauth.ConnIdentity(c)
|
||||
if err != nil {
|
||||
log.Warnf("json gateway: cannot read HTTP client identity, requests won't carry it: %v", err)
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, jsonPeerCtxKey{}, id)
|
||||
}
|
||||
|
||||
// jsonForwardIdentity injects the stashed HTTP client identity as gRPC metadata
|
||||
// on the gateway's re-dial to the daemon. The daemon trusts it only because the
|
||||
// dial arrives as the daemon's own (self/privileged) identity.
|
||||
func jsonForwardIdentity(ctx context.Context, _ *http.Request) metadata.MD {
|
||||
id, ok := ctx.Value(jsonPeerCtxKey{}).(ipcauth.Identity)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return ipcauth.ForwardIdentityMetadata(id)
|
||||
}
|
||||
|
||||
func (p *program) startJSONGateway(jsonListener *socketListener, daemonEndpoint string) error {
|
||||
mux := runtime.NewServeMux()
|
||||
opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
|
||||
if err := proto.RegisterDaemonServiceHandlerFromEndpoint(p.ctx, mux, grpcGatewayEndpoint(daemonEndpoint), opts); err != nil {
|
||||
if jsonListener.network == "tcp" {
|
||||
log.Warnf("json daemon is listening on TCP (%s), peer identity cannot be authenticated over TCP, per-caller authorization is disabled", daemonAddr)
|
||||
}
|
||||
|
||||
mux := runtime.NewServeMux(runtime.WithMetadata(jsonForwardIdentity))
|
||||
|
||||
// Lazy client to the daemon, npipe-aware (grpc.NewClient does not connect
|
||||
// until the first request, so this does not block startup before Serve).
|
||||
target, opts := daemonDialTarget(daemonEndpoint)
|
||||
conn, err := grpc.NewClient(target, opts...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create daemon client for JSON gateway: %w", err)
|
||||
}
|
||||
if err := proto.RegisterDaemonServiceHandler(p.ctx, mux, conn); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -35,6 +70,7 @@ func (p *program) startJSONGateway(jsonListener *socketListener, daemonEndpoint
|
||||
BaseContext: func(net.Listener) context.Context {
|
||||
return p.ctx
|
||||
},
|
||||
ConnContext: jsonConnContext,
|
||||
}
|
||||
|
||||
p.jsonServMu.Lock()
|
||||
|
||||
@@ -33,6 +33,15 @@ type serviceParams struct {
|
||||
DisableNetworks bool `json:"disable_networks,omitempty"`
|
||||
EnableJSONSocket bool `json:"enable_json_socket,omitempty"`
|
||||
ServiceEnvVars map[string]string `json:"service_env_vars,omitempty"`
|
||||
// Owners lists the principals allowed to control this profile over the local
|
||||
// IPC, as typed strings: "uid:1000", "gid:1000", "group:netbird-admins"
|
||||
// (Unix, NSS-resolved) or "sid:S-1-5-..." (Windows user or group SID). Empty
|
||||
// with Shared=false means the profile is owned by nobody yet, until claimed
|
||||
Owners []string `json:"owners,omitempty"`
|
||||
|
||||
// Shared, when true, lets any authenticated local caller control this profile
|
||||
// (opt-in). Takes precedence over Owners.
|
||||
Shared bool `json:"shared,omitempty"`
|
||||
}
|
||||
|
||||
// serviceParamsPath returns the path to the service params file.
|
||||
@@ -40,6 +49,38 @@ func serviceParamsPath() string {
|
||||
return filepath.Join(configs.StateDir, serviceParamsFile)
|
||||
}
|
||||
|
||||
// daemonOwnerStore persists the daemon-wide owner set in service.json. It
|
||||
// implements server.DaemonOwnerStore so the daemon can read owners at startup and
|
||||
// mutate them at runtime (owner add, reset, share, TOFU claim) without server
|
||||
// importing cmd. Load-modify-write preserves the other service.json fields.
|
||||
type daemonOwnerStore struct{}
|
||||
|
||||
func (daemonOwnerStore) Load() ([]string, bool, error) {
|
||||
params, err := loadServiceParams()
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if params == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
return params.Owners, params.Shared, nil
|
||||
}
|
||||
|
||||
func (daemonOwnerStore) Save(owners []string, shared bool) error {
|
||||
params, err := loadServiceParams()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if params == nil {
|
||||
// No service.json yet (daemon started without `service install`). Seed it
|
||||
// from the running daemon's current parameters so the file stays complete.
|
||||
params = currentServiceParams()
|
||||
}
|
||||
params.Owners = owners
|
||||
params.Shared = shared
|
||||
return saveServiceParams(params)
|
||||
}
|
||||
|
||||
// loadServiceParams reads saved service parameters from disk.
|
||||
// Returns nil with no error if the file does not exist.
|
||||
func loadServiceParams() (*serviceParams, error) {
|
||||
@@ -86,6 +127,8 @@ func currentServiceParams() *serviceParams {
|
||||
EnableCapture: captureEnabled,
|
||||
DisableNetworks: networksDisabled,
|
||||
EnableJSONSocket: enableJSONSocket,
|
||||
Owners: owners,
|
||||
Shared: daemonShared,
|
||||
}
|
||||
|
||||
if len(serviceEnvVars) > 0 {
|
||||
@@ -125,6 +168,10 @@ func applyServiceParams(cmd *cobra.Command, params *serviceParams) {
|
||||
|
||||
if !rootCmd.PersistentFlags().Changed("daemon-addr") && params.DaemonAddr != "" {
|
||||
daemonAddr = params.DaemonAddr
|
||||
if migrated, ok := migrateLegacyDaemonAddr(daemonAddr); ok {
|
||||
cmd.Printf("Migrating saved daemon address %q to %q so per-caller authorization can be enforced\n", daemonAddr, migrated)
|
||||
daemonAddr = migrated
|
||||
}
|
||||
}
|
||||
|
||||
if !serviceCmd.PersistentFlags().Changed("json-socket") && params.JSONSocket != "" {
|
||||
@@ -165,6 +212,14 @@ func applyServiceParams(cmd *cobra.Command, params *serviceParams) {
|
||||
networksDisabled = params.DisableNetworks
|
||||
}
|
||||
|
||||
// Carry the daemon-wide owner set forward across install/reconfigure so a
|
||||
// runtime owner add or TOFU claim in service.json is not clobbered. --owner
|
||||
// overrides.
|
||||
if !serviceCmd.PersistentFlags().Changed("owner") && len(params.Owners) > 0 {
|
||||
owners = params.Owners
|
||||
}
|
||||
daemonShared = params.Shared
|
||||
|
||||
applyServiceEnvParams(cmd, params)
|
||||
}
|
||||
|
||||
|
||||
@@ -431,9 +431,15 @@ func TestServiceParams_BuildArgsCoversAllFlags(t *testing.T) {
|
||||
installerFile, err := parser.ParseFile(fset, "service_installer.go", nil, 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Fields that are handled outside of buildServiceArguments (env vars go through newSVCConfig).
|
||||
// Fields that are handled outside of buildServiceArguments.
|
||||
// - ServiceEnvVars flows through newSVCConfig() EnvVars, not CLI args.
|
||||
// - Owners/Shared are daemon-wide ownership persisted in service.json and
|
||||
// read+mutated by the daemon at runtime (owner add / TOFU claim); they are
|
||||
// deliberately NOT baked into the run args so runtime changes are not lost.
|
||||
fieldsNotInArgs := map[string]bool{
|
||||
"ServiceEnvVars": true,
|
||||
"Owners": true,
|
||||
"Shared": true,
|
||||
}
|
||||
|
||||
buildFields := extractFuncGlobalRefs(t, installerFile, "buildServiceArguments")
|
||||
|
||||
20
client/cmd/service_pipe_other.go
Normal file
20
client/cmd/service_pipe_other.go
Normal file
@@ -0,0 +1,20 @@
|
||||
//go:build !windows
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// listenNamedPipe is unsupported off Windows; named pipes are a Windows-only transport.
|
||||
func listenNamedPipe(string) (net.Listener, error) {
|
||||
return nil, fmt.Errorf("named pipe daemon socket is only supported on Windows, not %s", runtime.GOOS)
|
||||
}
|
||||
|
||||
// dialNamedPipe is unsupported off Windows.
|
||||
func dialNamedPipe(context.Context, string) (net.Conn, error) {
|
||||
return nil, fmt.Errorf("named pipe daemon socket is only supported on Windows, not %s", runtime.GOOS)
|
||||
}
|
||||
30
client/cmd/service_pipe_windows.go
Normal file
30
client/cmd/service_pipe_windows.go
Normal file
@@ -0,0 +1,30 @@
|
||||
//go:build windows
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
|
||||
"github.com/Microsoft/go-winio"
|
||||
"golang.org/x/sys/windows"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
)
|
||||
|
||||
// listenNamedPipe creates the daemon control named pipe with a permissive,
|
||||
// local-only SDDL. Any local caller may connect, like Unix socket with 0666.
|
||||
func listenNamedPipe(path string) (net.Listener, error) {
|
||||
return winio.ListenPipe(path, &winio.PipeConfig{
|
||||
SecurityDescriptor: ipcauth.DefaultPipeSDDL(),
|
||||
})
|
||||
}
|
||||
|
||||
// dialNamedPipe connects to the daemon ipc named pipe at SECURITY_IDENTIFICATION.
|
||||
func dialNamedPipe(ctx context.Context, path string) (net.Conn, error) {
|
||||
access := uint32(windows.GENERIC_READ | windows.GENERIC_WRITE)
|
||||
// winio's plain DialPipe connects at SECURITY_ANONYMOUS, under which the
|
||||
// daemon cannot read the caller's token. Identification lets the daemon
|
||||
// read its SID/groups without granting it the ability to act as the caller.
|
||||
return winio.DialPipeAccessImpLevel(ctx, path, access, winio.PipeImpLevelIdentification)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -14,6 +15,30 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
windowsPipeDaemonAddr = "npipe://netbird"
|
||||
|
||||
// legacyWindowsDaemonAddr is the loopback-TCP address the Windows daemon used
|
||||
// before named-pipe support.
|
||||
legacyWindowsDaemonAddr = "tcp://127.0.0.1:41731"
|
||||
)
|
||||
|
||||
// migrateLegacyDaemonAddr upgrades the pre-named-pipe Windows daemon address to
|
||||
// the pipe. Existing installs persist daemon addr, so on upgrade the daemon
|
||||
// would otherwise keep listening on TCP and silently run without IPC
|
||||
// authorization. Only the exact legacy default is rewritten, while a
|
||||
// deliberately-chosen custom TCP address is left alone.
|
||||
func migrateLegacyDaemonAddr(addr string) (string, bool) {
|
||||
return migrateLegacyDaemonAddrForOS(runtime.GOOS, addr)
|
||||
}
|
||||
|
||||
func migrateLegacyDaemonAddrForOS(goos, addr string) (string, bool) {
|
||||
if goos == "windows" && addr == legacyWindowsDaemonAddr {
|
||||
return windowsPipeDaemonAddr, true
|
||||
}
|
||||
return addr, false
|
||||
}
|
||||
|
||||
type socketListener struct {
|
||||
net.Listener
|
||||
network string
|
||||
@@ -26,6 +51,15 @@ func listenOnAddress(addr string) (*socketListener, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if network == "npipe" {
|
||||
path := pipePath(address)
|
||||
listener, err := listenNamedPipe(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &socketListener{Listener: listener, network: network, address: path}, nil
|
||||
}
|
||||
|
||||
if network == "unix" {
|
||||
removeStaleUnixSocket(address)
|
||||
}
|
||||
@@ -41,17 +75,26 @@ func listenOnAddress(addr string) (*socketListener, error) {
|
||||
func parseListenAddress(addr string) (string, string, error) {
|
||||
network, address, ok := strings.Cut(addr, "://")
|
||||
if !ok || network == "" || address == "" {
|
||||
return "", "", fmt.Errorf("address must be in [unix|tcp]://[path|host:port] format: %q", addr)
|
||||
return "", "", fmt.Errorf("address must be in [unix|tcp|npipe]://[path|host:port|name] format: %q", addr)
|
||||
}
|
||||
|
||||
switch network {
|
||||
case "unix", "tcp":
|
||||
case "unix", "tcp", "npipe":
|
||||
return network, address, nil
|
||||
default:
|
||||
return "", "", fmt.Errorf("unsupported daemon address protocol: %v", network)
|
||||
}
|
||||
}
|
||||
|
||||
// pipePath maps a daemon-addr npipe name ("npipe://netbird") to a Windows
|
||||
// named-pipe path (\\.\pipe\netbird).
|
||||
func pipePath(name string) string {
|
||||
if strings.HasPrefix(name, `\\`) {
|
||||
return name
|
||||
}
|
||||
return `\\.\pipe\` + name
|
||||
}
|
||||
|
||||
func removeStaleUnixSocket(path string) {
|
||||
stat, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
|
||||
63
client/cmd/service_socket_test.go
Normal file
63
client/cmd/service_socket_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
//go:build !ios && !android
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMigrateLegacyDaemonAddrForOS(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
goos string
|
||||
addr string
|
||||
want string
|
||||
migrate bool
|
||||
}{
|
||||
{
|
||||
name: "windows legacy tcp migrates to pipe",
|
||||
goos: "windows",
|
||||
addr: legacyWindowsDaemonAddr,
|
||||
want: windowsPipeDaemonAddr,
|
||||
migrate: true,
|
||||
},
|
||||
{
|
||||
name: "windows pipe already migrated stays",
|
||||
goos: "windows",
|
||||
addr: windowsPipeDaemonAddr,
|
||||
want: windowsPipeDaemonAddr,
|
||||
migrate: false,
|
||||
},
|
||||
{
|
||||
name: "windows custom tcp left alone",
|
||||
goos: "windows",
|
||||
addr: "tcp://127.0.0.1:9999",
|
||||
want: "tcp://127.0.0.1:9999",
|
||||
migrate: false,
|
||||
},
|
||||
{
|
||||
name: "linux legacy-looking tcp not migrated",
|
||||
goos: "linux",
|
||||
addr: legacyWindowsDaemonAddr,
|
||||
want: legacyWindowsDaemonAddr,
|
||||
migrate: false,
|
||||
},
|
||||
{
|
||||
name: "linux unix socket untouched",
|
||||
goos: "linux",
|
||||
addr: "unix:///var/run/netbird.sock",
|
||||
want: "unix:///var/run/netbird.sock",
|
||||
migrate: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, ok := migrateLegacyDaemonAddrForOS(tc.goos, tc.addr)
|
||||
assert.Equal(t, tc.want, got)
|
||||
assert.Equal(t, tc.migrate, ok)
|
||||
})
|
||||
}
|
||||
}
|
||||
73
client/cmd/ssh_config_cmd.go
Normal file
73
client/cmd/ssh_config_cmd.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
var (
|
||||
setSSHConfigProfile string
|
||||
setSSHConfigUsername string
|
||||
setSSHConfigEnableRoot bool
|
||||
setSSHConfigDisableAuth bool
|
||||
)
|
||||
|
||||
// setSSHConfigCmd applies the privileged SSH settings (enable root login /
|
||||
// disable auth) to a profile over the daemon IPC. Hidden because users
|
||||
// interact via `netbird up` / the UI, not directly.
|
||||
var setSSHConfigCmd = &cobra.Command{
|
||||
Use: SetSSHConfigCmdName,
|
||||
Short: "Apply privileged SSH settings to a profile (internal elevation target)",
|
||||
Hidden: true,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
ctx := internal.CtxInitState(cmd.Context())
|
||||
|
||||
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to daemon: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if cerr := conn.Close(); cerr != nil {
|
||||
log.Warnf("failed closing daemon gRPC client connection: %v", cerr)
|
||||
}
|
||||
}()
|
||||
|
||||
var enableRoot, disableAuth *bool
|
||||
if cmd.Flags().Changed(enableSSHRootFlag) {
|
||||
enableRoot = &setSSHConfigEnableRoot
|
||||
}
|
||||
if cmd.Flags().Changed(disableSSHAuthFlag) {
|
||||
disableAuth = &setSSHConfigDisableAuth
|
||||
}
|
||||
|
||||
req := buildSetSSHConfigRequest(setSSHConfigProfile, setSSHConfigUsername, enableRoot, disableAuth)
|
||||
if _, err := proto.NewDaemonServiceClient(conn).SetConfig(ctx, req); err != nil {
|
||||
return fmt.Errorf("apply SSH config: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// buildSetSSHConfigRequest builds a minimal SetConfigRequest that touches only
|
||||
// the SSH fields that were provided (nil pointers leave the daemon's stored
|
||||
// value untouched, matching setupSetConfigReq's pointer semantics).
|
||||
func buildSetSSHConfigRequest(profileName, username string, enableSSHRoot, disableSSHAuth *bool) *proto.SetConfigRequest {
|
||||
return &proto.SetConfigRequest{
|
||||
ProfileName: profileName,
|
||||
Username: username,
|
||||
EnableSSHRoot: enableSSHRoot,
|
||||
DisableSSHAuth: disableSSHAuth,
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
setSSHConfigCmd.Flags().StringVar(&setSSHConfigProfile, "profile", "", "profile name to apply the SSH settings to")
|
||||
setSSHConfigCmd.Flags().StringVar(&setSSHConfigUsername, "username", "", "owning username of the profile")
|
||||
setSSHConfigCmd.Flags().BoolVar(&setSSHConfigEnableRoot, enableSSHRootFlag, false, "enable root login for the SSH server")
|
||||
setSSHConfigCmd.Flags().BoolVar(&setSSHConfigDisableAuth, disableSSHAuthFlag, false, "disable SSH authentication")
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os/user"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -172,10 +174,9 @@ func getStatus(ctx context.Context, fullPeerStatus bool, shouldRunProbes bool) (
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// getActiveProfileName asks the daemon for the active profile's display
|
||||
// name. The daemon runs as root and can read the per-user profile files to
|
||||
// resolve the ID to its human-readable name. Returns an empty string on any
|
||||
// error so status output degrades gracefully.
|
||||
// getActiveProfileName asks the daemon for the active profile's display name,
|
||||
// annotated with the owning user when the active profile belongs to someone else.
|
||||
// Returns an empty string on any error so status output degrades gracefully.
|
||||
func getActiveProfileName(ctx context.Context) string {
|
||||
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
||||
if err != nil {
|
||||
@@ -188,7 +189,22 @@ func getActiveProfileName(ctx context.Context) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
return resp.GetProfileName()
|
||||
name := resp.GetProfileName()
|
||||
if owner := resp.GetUsername(); owner != "" {
|
||||
if curr, uerr := user.Current(); uerr != nil || !usernamesMatch(owner, curr.Username) {
|
||||
name = fmt.Sprintf("%s (user %s)", name, owner)
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// usernamesMatch compares usernames case-insensitively on Windows (domain
|
||||
// accounts) and exactly elsewhere.
|
||||
func usernamesMatch(a, b string) bool {
|
||||
if runtime.GOOS == "windows" {
|
||||
return strings.EqualFold(a, b)
|
||||
}
|
||||
return a == b
|
||||
}
|
||||
|
||||
func parseFilters() error {
|
||||
|
||||
@@ -21,8 +21,8 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
nbnet "github.com/netbirdio/netbird/client/net"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/server"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
@@ -56,6 +56,7 @@ var (
|
||||
showQR bool
|
||||
profileName string
|
||||
configPath string
|
||||
claimOwner bool
|
||||
|
||||
upCmd = &cobra.Command{
|
||||
Use: "up",
|
||||
@@ -67,6 +68,7 @@ var (
|
||||
|
||||
func init() {
|
||||
upCmd.PersistentFlags().BoolVarP(&foregroundMode, "foreground-mode", "F", false, "start service in foreground")
|
||||
upCmd.PersistentFlags().BoolVar(&claimOwner, "owner", false, "claim ownership of this profile for the current user, restricting daemon control of it to you and root/administrator")
|
||||
upCmd.PersistentFlags().StringVar(&interfaceName, interfaceNameFlag, iface.WgInterfaceDefault, "WireGuard interface name")
|
||||
upCmd.PersistentFlags().Uint16Var(&wireguardPort, wireguardPortFlag, iface.DefaultWgPort, "WireGuard interface listening port")
|
||||
upCmd.PersistentFlags().Uint16Var(&mtu, mtuFlag, iface.DefaultMTU, "Set MTU (Maximum Transmission Unit) for the WireGuard interface")
|
||||
@@ -319,6 +321,24 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager
|
||||
return fmt.Errorf("get current user: %v", err)
|
||||
}
|
||||
|
||||
// Enabling SSH root login / disabling SSH auth is a privileged change the
|
||||
// daemon rejects from a non-privileged caller. Offer to elevate (polkit/UAC/
|
||||
// osascript) so an elevated helper applies just those settings.
|
||||
if wantsDangerousSSH(cmd) && !isProcessPrivileged() {
|
||||
cmd.Println("Enabling SSH root login / disabling SSH authentication requires administrator privileges, requesting elevation...")
|
||||
var enableRoot, disableAuth *bool
|
||||
if cmd.Flag(enableSSHRootFlag).Changed {
|
||||
enableRoot = &enableSSHRoot
|
||||
}
|
||||
if cmd.Flag(disableSSHAuthFlag).Changed {
|
||||
disableAuth = &disableSSHAuth
|
||||
}
|
||||
if err := ElevateSSHConfig(activeProf.ID.String(), username.Username, enableRoot, disableAuth); err != nil {
|
||||
return fmt.Errorf("apply privileged SSH settings: %w", err)
|
||||
}
|
||||
sshConfigElevated = true
|
||||
}
|
||||
|
||||
// set the new config
|
||||
req := setupSetConfigReq(customDNSAddressConverted, cmd, activeProf.ID.String(), username.Username)
|
||||
if _, err := client.SetConfig(ctx, req); err != nil {
|
||||
@@ -391,6 +411,7 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ
|
||||
if _, err := client.Up(ctx, &proto.UpRequest{
|
||||
ProfileName: &profileID,
|
||||
Username: &username,
|
||||
ClaimOwner: claimOwner,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("call service up method: %v", err)
|
||||
}
|
||||
@@ -421,7 +442,7 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro
|
||||
if cmd.Flag(serverSSHAllowedFlag).Changed {
|
||||
req.ServerSSHAllowed = &serverSSHAllowed
|
||||
}
|
||||
if cmd.Flag(enableSSHRootFlag).Changed {
|
||||
if cmd.Flag(enableSSHRootFlag).Changed && !sshConfigElevated {
|
||||
req.EnableSSHRoot = &enableSSHRoot
|
||||
}
|
||||
if cmd.Flag(enableSSHSFTPFlag).Changed {
|
||||
@@ -433,7 +454,7 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro
|
||||
if cmd.Flag(enableSSHRemotePortForwardFlag).Changed {
|
||||
req.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward
|
||||
}
|
||||
if cmd.Flag(disableSSHAuthFlag).Changed {
|
||||
if cmd.Flag(disableSSHAuthFlag).Changed && !sshConfigElevated {
|
||||
req.DisableSSHAuth = &disableSSHAuth
|
||||
}
|
||||
if cmd.Flag(sshJWTCacheTTLFlag).Changed {
|
||||
@@ -649,7 +670,7 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte
|
||||
loginRequest.ServerSSHAllowed = &serverSSHAllowed
|
||||
}
|
||||
|
||||
if cmd.Flag(enableSSHRootFlag).Changed {
|
||||
if cmd.Flag(enableSSHRootFlag).Changed && !sshConfigElevated {
|
||||
loginRequest.EnableSSHRoot = &enableSSHRoot
|
||||
}
|
||||
|
||||
@@ -665,7 +686,7 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte
|
||||
loginRequest.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward
|
||||
}
|
||||
|
||||
if cmd.Flag(disableSSHAuthFlag).Changed {
|
||||
if cmd.Flag(disableSSHAuthFlag).Changed && !sshConfigElevated {
|
||||
loginRequest.DisableSSHAuth = &disableSSHAuth
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestUpDaemon(t *testing.T) {
|
||||
}
|
||||
|
||||
sm := profilemanager.ServiceManager{}
|
||||
created, err := sm.AddProfile("test1", currUser.Username)
|
||||
created, err := sm.AddProfile("test1", currUser.Username, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to add profile: %v", err)
|
||||
return
|
||||
|
||||
@@ -24,11 +24,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// Skew tolerates a small clock difference between the management
|
||||
// server and this peer before treating a deadline as "in the past".
|
||||
// Slightly above typical NTP drift; tight enough that the UI doesn't
|
||||
// paint a stale expiry as if it were valid.
|
||||
Skew = 30 * time.Second
|
||||
maxPastHorizon = 30 * 24 * time.Hour
|
||||
|
||||
// maxDeadlineHorizon caps how far in the future an accepted deadline
|
||||
// can sit. A timestamp beyond this is almost certainly a protocol
|
||||
@@ -57,7 +53,7 @@ var (
|
||||
ErrDeadlineTooFarFuture = errors.New("session deadline too far in the future")
|
||||
|
||||
// ErrDeadlineInPast is returned by Update when the supplied deadline
|
||||
// is more than Skew in the past.
|
||||
// is more than maxPastHorizon in the past.
|
||||
ErrDeadlineInPast = errors.New("session deadline in the past")
|
||||
)
|
||||
|
||||
@@ -66,15 +62,14 @@ var (
|
||||
// for deadline change/clear, PublishEvent for the two warnings); tests pass
|
||||
// a fake recorder so the same surface is observable without an engine.
|
||||
//
|
||||
// The watcher is the single owner of the deadline propagated to the
|
||||
// recorder: every set, clear, sanity-check rejection and Close routes the
|
||||
// value through SetSessionExpiresAt, so the SubscribeStatus snapshot the UI
|
||||
// reads can never drift from the watcher's timer state. (SetSessionExpiresAt
|
||||
// fans out its own state-change notification, so no separate notify is
|
||||
// needed.) The recorder is server-scoped and outlives this engine-scoped
|
||||
// watcher — without the Close-time clear a teardown (Down, or the Down+Up of
|
||||
// a profile switch) would leave the next session showing the previous one's
|
||||
// stale "expires in" value.
|
||||
// While the watcher runs, it owns the deadline propagated to the recorder:
|
||||
// every set, clear and sanity-check rejection routes the value through
|
||||
// SetSessionExpiresAt, so the SubscribeStatus snapshot the UI reads can
|
||||
// never drift from the watcher's timer state. (SetSessionExpiresAt fans
|
||||
// out its own state-change notification, so no separate notify is needed.)
|
||||
// The recorder is server-scoped and outlives this engine-scoped watcher;
|
||||
// Close deliberately leaves the recorder value in place so transient engine
|
||||
// restarts don't blank it — the client run loop clears it on real teardown.
|
||||
//
|
||||
// PublishEvent's signature mirrors peer.Status.PublishEvent: the watcher
|
||||
// composes the metadata internally so the wire format (MetaSession*) is
|
||||
@@ -135,10 +130,13 @@ func NewWithLeads(lead, final time.Duration, recorder StatusRecorder) *Watcher {
|
||||
// was disabled).
|
||||
//
|
||||
// Same-value updates are no-ops. A different non-zero value cancels any
|
||||
// pending timer, resets the "already fired" guard, and arms a new one.
|
||||
// pending timer, resets the "already fired" guards, and — when the
|
||||
// deadline lies in the future — arms fresh warning timers. A deadline
|
||||
// already in the past (within maxPastHorizon) is recorded as-is with no
|
||||
// timers: the session has expired and consumers render it that way.
|
||||
//
|
||||
// Returns one of the sentinel Err* values when the deadline fails the
|
||||
// sanity checks (pre-epoch, far future, or in the past beyond Skew).
|
||||
// sanity checks (pre-epoch, far future, or past beyond maxPastHorizon).
|
||||
// In every error case the watcher first clears its state so it stays
|
||||
// consistent with what the caller will push into its other sinks (e.g.
|
||||
// applySessionDeadline forces a zero deadline into the status recorder
|
||||
@@ -163,7 +161,7 @@ func (w *Watcher) Update(deadline time.Time) error {
|
||||
case deadline.After(now.Add(maxDeadlineHorizon)):
|
||||
w.clearLocked()
|
||||
return fmt.Errorf("%w: %v", ErrDeadlineTooFarFuture, deadline)
|
||||
case deadline.Before(now.Add(-Skew)):
|
||||
case deadline.Before(now.Add(-maxPastHorizon)):
|
||||
w.clearLocked()
|
||||
return fmt.Errorf("%w: %v (now=%v)", ErrDeadlineInPast, deadline, now)
|
||||
}
|
||||
@@ -183,7 +181,9 @@ func (w *Watcher) Update(deadline time.Time) error {
|
||||
w.finalFiredAt = time.Time{}
|
||||
w.dismissedAt = time.Time{}
|
||||
|
||||
w.armTimerLocked(deadline)
|
||||
if deadline.After(now) {
|
||||
w.armTimerLocked(deadline)
|
||||
}
|
||||
recorder := w.recorder
|
||||
w.mu.Unlock()
|
||||
if recorder != nil {
|
||||
@@ -227,30 +227,25 @@ func (w *Watcher) Dismiss() {
|
||||
log.Infof("auth session final-warning dismissed for deadline %s", w.current.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
// Close stops any pending timer and drops the deadline on the status
|
||||
// recorder. Update calls after Close are ignored. Clearing the recorder
|
||||
// here is what keeps a teardown (Down, or the Down+Up of a profile switch)
|
||||
// from leaving the next session showing this one's stale "expires in"
|
||||
// value — the recorder is server-scoped and outlives this engine-scoped
|
||||
// watcher, so nothing else drops the anchor on teardown.
|
||||
// Close stops any pending timer. Update calls after Close are ignored.
|
||||
// The recorder keeps its deadline: the watcher is engine-scoped and closes
|
||||
// on every engine restart (network change, sleep/wake, stream errors)
|
||||
// while the SSO deadline stays valid across those, so clearing here would
|
||||
// blank the UI's "expires in" row on every transient reconnect. The
|
||||
// client run loop clears the server-scoped recorder when it exits for
|
||||
// real (Down, profile switch, permanent login failure).
|
||||
func (w *Watcher) Close() {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.closed {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
w.closed = true
|
||||
w.stopTimerLocked()
|
||||
hadDeadline := !w.current.IsZero()
|
||||
w.current = time.Time{}
|
||||
w.firedAt = time.Time{}
|
||||
w.finalFiredAt = time.Time{}
|
||||
w.dismissedAt = time.Time{}
|
||||
recorder := w.recorder
|
||||
w.mu.Unlock()
|
||||
if recorder != nil && hadDeadline {
|
||||
recorder.SetSessionExpiresAt(time.Time{})
|
||||
}
|
||||
}
|
||||
|
||||
// clearLocked drops the tracked deadline and notifies the recorder so
|
||||
|
||||
@@ -224,11 +224,13 @@ func TestNewDeadlineCancelsPriorTimer(t *testing.T) {
|
||||
|
||||
func TestRefreshAfterFireArmsNewWarning(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
lead := 30 * time.Millisecond
|
||||
lead := 150 * time.Millisecond
|
||||
w := newWatcher(lead, r)
|
||||
defer w.Close()
|
||||
|
||||
first := time.Now().Add(50 * time.Millisecond)
|
||||
// Warning fires ~20ms in; the deadline itself stays 150ms away so the
|
||||
// replacement below lands well before it.
|
||||
first := time.Now().Add(170 * time.Millisecond)
|
||||
_ = w.Update(first)
|
||||
|
||||
// Wait for stateChange + warning of the first cycle.
|
||||
@@ -306,7 +308,29 @@ func TestUpdateRejectsTooFarFuture(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateInPastClearsDeadline(t *testing.T) {
|
||||
func TestUpdateRecentPastRecordedAsExpired(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(50*time.Millisecond, r)
|
||||
defer w.Close()
|
||||
|
||||
d := time.Now().Add(-1 * time.Hour)
|
||||
if err := w.Update(d); err != nil {
|
||||
t.Fatalf("recent-past Update should succeed, got %v", err)
|
||||
}
|
||||
if !w.Deadline().Equal(d) {
|
||||
t.Fatalf("expected deadline to be recorded, got %v want %v", w.Deadline(), d)
|
||||
}
|
||||
if got := r.deadline(); !got.Equal(d) {
|
||||
t.Fatalf("recorder deadline = %v, want %v", got, d)
|
||||
}
|
||||
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
if n := countWhere(r.snapshot(), func(e event) bool { return e.kind == publish }); n != 0 {
|
||||
t.Fatalf("no warning events may fire for an already-past deadline, got %+v", r.snapshot())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAncientPastRejected(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(50*time.Millisecond, r)
|
||||
defer w.Close()
|
||||
@@ -318,12 +342,12 @@ func TestUpdateInPastClearsDeadline(t *testing.T) {
|
||||
// Drain the stateChange from the seed.
|
||||
waitForEvents(t, r, 1)
|
||||
|
||||
err := w.Update(time.Now().Add(-1 * time.Hour))
|
||||
err := w.Update(time.Now().Add(-31 * 24 * time.Hour))
|
||||
if !errors.Is(err, ErrDeadlineInPast) {
|
||||
t.Fatalf("want ErrDeadlineInPast, got %v", err)
|
||||
}
|
||||
if !w.Deadline().IsZero() {
|
||||
t.Fatalf("in-past update must clear the deadline, got %v", w.Deadline())
|
||||
t.Fatalf("rejected ancient-past update must clear the deadline, got %v", w.Deadline())
|
||||
}
|
||||
events := waitForEvents(t, r, 2)
|
||||
if events[1].kind != stateChange {
|
||||
@@ -331,39 +355,25 @@ func TestUpdateInPastClearsDeadline(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateWithinSkewAccepted(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(50*time.Millisecond, r)
|
||||
defer w.Close()
|
||||
|
||||
// 5 seconds in the past is within the 30s Skew tolerance — accept it.
|
||||
d := time.Now().Add(-5 * time.Second)
|
||||
if err := w.Update(d); err != nil {
|
||||
t.Fatalf("within-skew Update should succeed, got %v", err)
|
||||
}
|
||||
if !w.Deadline().Equal(d) {
|
||||
t.Fatalf("expected deadline to be applied, got %v want %v", w.Deadline(), d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseSilencesUpdates(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(50*time.Millisecond, r)
|
||||
w.Close()
|
||||
|
||||
_ = w.Update(time.Now().Add(time.Hour))
|
||||
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
if err := w.Update(time.Now().Add(time.Hour)); err != nil {
|
||||
t.Fatalf("Update after Close: want nil, got %v", err)
|
||||
}
|
||||
if got := r.snapshot(); len(got) != 0 {
|
||||
t.Fatalf("expected no events after Close, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCloseClearsRecorderDeadline pins the profile-switch fix: a watcher
|
||||
// holding a live deadline must zero the recorder on Close so the next
|
||||
// engine's watcher (and the UI reading the shared server-scoped recorder)
|
||||
// doesn't start out showing the previous session's stale "expires in".
|
||||
func TestCloseClearsRecorderDeadline(t *testing.T) {
|
||||
// TestCloseKeepsRecorderDeadline pins the reconnect-flap fix: the watcher
|
||||
// closes on every engine restart (network change, sleep/wake) while the
|
||||
// SSO deadline stays valid across those, so Close must leave the
|
||||
// server-scoped recorder's value in place. The client run loop clears the
|
||||
// recorder when it exits for real.
|
||||
func TestCloseKeepsRecorderDeadline(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(time.Hour, r)
|
||||
|
||||
@@ -377,8 +387,8 @@ func TestCloseClearsRecorderDeadline(t *testing.T) {
|
||||
|
||||
w.Close()
|
||||
|
||||
if got := r.deadline(); !got.IsZero() {
|
||||
t.Fatalf("recorder deadline after Close = %v, want zero", got)
|
||||
if got := r.deadline(); !got.Equal(d) {
|
||||
t.Fatalf("recorder deadline after Close = %v, want %v", got, d)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -257,7 +257,10 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
log.Errorf("failed to clean up temporary installer file: %v", err)
|
||||
}
|
||||
|
||||
defer c.statusRecorder.ClientStop()
|
||||
defer func() {
|
||||
c.statusRecorder.SetSessionExpiresAt(time.Time{})
|
||||
c.statusRecorder.ClientStop()
|
||||
}()
|
||||
operation := func() error {
|
||||
// if context cancelled we not start new backoff cycle
|
||||
if c.ctx.Err() != nil {
|
||||
|
||||
@@ -75,4 +75,14 @@ func TestApplySessionDeadline_ThreeState(t *testing.T) {
|
||||
require.True(t, e.statusRecorder.GetSessionExpiresAt().IsZero(),
|
||||
"invalid timestamp must clear the deadline")
|
||||
})
|
||||
|
||||
t.Run("recently expired timestamp stays visible as expired", func(t *testing.T) {
|
||||
e := newEngine()
|
||||
expired := time.Now().Add(-5 * time.Minute).UTC().Truncate(time.Second)
|
||||
|
||||
e.ApplySessionDeadline(timestamppb.New(expired))
|
||||
|
||||
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(expired),
|
||||
"recently-expired deadline must stay on the recorder so consumers render it as expired")
|
||||
})
|
||||
}
|
||||
|
||||
84
client/internal/ipcauth/authorize.go
Normal file
84
client/internal/ipcauth/authorize.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Ownership is a profile's access policy: the typed owner principals plus the
|
||||
// opt-in shared flag.
|
||||
type Ownership struct {
|
||||
Owners []string
|
||||
Shared bool
|
||||
}
|
||||
|
||||
// GroupResolver resolves a Unix caller's effective group IDs and owner group
|
||||
// names to GIDs. A nil resolver disables group matching.
|
||||
type GroupResolver interface {
|
||||
// CallerGIDs returns the set of group IDs the caller belongs to.
|
||||
CallerGIDs(id Identity) map[uint32]struct{}
|
||||
// GroupNameGID resolves a group name to its GID.
|
||||
GroupNameGID(name string) (uint32, bool)
|
||||
}
|
||||
|
||||
// Authorize reports whether the identity may control a profile with the given
|
||||
// ownership. Privileged callers and shared profiles are always allowed.
|
||||
func Authorize(o Ownership, id Identity, r GroupResolver) bool {
|
||||
if id.IsPrivileged() {
|
||||
return true
|
||||
}
|
||||
if o.Shared {
|
||||
return true
|
||||
}
|
||||
for _, raw := range o.Owners {
|
||||
p, ok := ParsePrincipal(raw)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if principalMatches(p, id, r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func principalMatches(p Principal, id Identity, r GroupResolver) bool {
|
||||
switch p.Kind {
|
||||
case KindUID:
|
||||
if id.IsWindows() {
|
||||
return false
|
||||
}
|
||||
uid, err := strconv.ParseUint(p.Value, 10, 32)
|
||||
return err == nil && uint32(uid) == id.UID
|
||||
case KindGID:
|
||||
if id.IsWindows() {
|
||||
return false
|
||||
}
|
||||
gid, err := strconv.ParseUint(p.Value, 10, 32)
|
||||
return err == nil && callerHasGID(uint32(gid), id, r)
|
||||
case KindGroup:
|
||||
if id.IsWindows() || r == nil {
|
||||
return false
|
||||
}
|
||||
gid, ok := r.GroupNameGID(p.Value)
|
||||
return ok && callerHasGID(gid, id, r)
|
||||
case KindSID:
|
||||
if !id.IsWindows() {
|
||||
return false
|
||||
}
|
||||
return id.SID == p.Value || slices.Contains(id.Groups, p.Value)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func callerHasGID(gid uint32, id Identity, r GroupResolver) bool {
|
||||
if id.GID == gid {
|
||||
return true
|
||||
}
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
_, ok := r.CallerGIDs(id)[gid]
|
||||
return ok
|
||||
}
|
||||
22
client/internal/ipcauth/creds_stub.go
Normal file
22
client/internal/ipcauth/creds_stub.go
Normal file
@@ -0,0 +1,22 @@
|
||||
//go:build !linux && !darwin && !freebsd && !windows
|
||||
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"runtime"
|
||||
|
||||
"google.golang.org/grpc/credentials"
|
||||
)
|
||||
|
||||
// NewTransportCredentials returns nil on platforms without a peer-identity
|
||||
// primitive.
|
||||
func NewTransportCredentials() credentials.TransportCredentials {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConnIdentity is unsupported on platforms without a peer-identity primitive.
|
||||
func ConnIdentity(net.Conn) (Identity, error) {
|
||||
return Identity{}, fmt.Errorf("peer identity not supported on %s", runtime.GOOS)
|
||||
}
|
||||
51
client/internal/ipcauth/creds_unix.go
Normal file
51
client/internal/ipcauth/creds_unix.go
Normal file
@@ -0,0 +1,51 @@
|
||||
//go:build linux || darwin || freebsd
|
||||
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
|
||||
"google.golang.org/grpc/credentials"
|
||||
)
|
||||
|
||||
// NewTransportCredentials returns gRPC transport credentials that extract the
|
||||
// caller's kernel-authenticated identity from a Unix-socket connection and
|
||||
// expose it via IdentityFromContext. Non-nil on platforms with a
|
||||
// peer-credential primitive.
|
||||
func NewTransportCredentials() credentials.TransportCredentials {
|
||||
return unixCreds{}
|
||||
}
|
||||
|
||||
type unixCreds struct{}
|
||||
|
||||
func (unixCreds) ClientHandshake(_ context.Context, _ string, conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
||||
return conn, AuthInfo{}, nil
|
||||
}
|
||||
|
||||
// ConnIdentity extracts the caller's identity from an accepted local IPC
|
||||
// connection. On Unix it reads peer credentials from the socket. It is shared
|
||||
// by the gRPC transport credentials and the JSON gateway (which forwards it).
|
||||
func ConnIdentity(conn net.Conn) (Identity, error) {
|
||||
return PeerIdentity(conn)
|
||||
}
|
||||
|
||||
// ServerHandshake extracts the peer identity and fails closed if it cannot be read.
|
||||
func (unixCreds) ServerHandshake(conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
||||
id, err := ConnIdentity(conn)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return conn, AuthInfo{
|
||||
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
|
||||
Identity: id,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (unixCreds) Info() credentials.ProtocolInfo {
|
||||
return credentials.ProtocolInfo{SecurityProtocol: AuthInfo{}.AuthType()}
|
||||
}
|
||||
|
||||
func (unixCreds) Clone() credentials.TransportCredentials { return unixCreds{} }
|
||||
|
||||
func (unixCreds) OverrideServerName(string) error { return nil }
|
||||
135
client/internal/ipcauth/creds_windows.go
Normal file
135
client/internal/ipcauth/creds_windows.go
Normal file
@@ -0,0 +1,135 @@
|
||||
//go:build windows
|
||||
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"runtime"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"google.golang.org/grpc/credentials"
|
||||
)
|
||||
|
||||
var (
|
||||
modadvapi32 = windows.NewLazySystemDLL("advapi32.dll")
|
||||
procImpersonateNamedPipeClient = modadvapi32.NewProc("ImpersonateNamedPipeClient")
|
||||
)
|
||||
|
||||
// DefaultPipeSDDL keeps the daemon control pipe open to any LOCAL caller,
|
||||
// like Unix socket with 0666 permissions.
|
||||
//
|
||||
// D:P protected DACL, no inheritance
|
||||
// (D;;GA;;;NU) deny GENERIC_ALL to NETWORK (remote/SMB)
|
||||
// (A;;GA;;;SY) allow GENERIC_ALL to LocalSystem (the daemon itself)
|
||||
// (A;;GA;;;WD) allow GENERIC_ALL to Everyone (local, per-RPC ACL gates)
|
||||
func DefaultPipeSDDL() string {
|
||||
return "D:P(D;;GA;;;NU)(A;;GA;;;SY)(A;;GA;;;WD)"
|
||||
}
|
||||
|
||||
// NewTransportCredentials returns gRPC transport credentials that derive the
|
||||
// caller's identity from the named-pipe client token.
|
||||
//
|
||||
// This requires the client to dial at SECURITY_IDENTIFICATION (see dialNamedPipe).
|
||||
func NewTransportCredentials() credentials.TransportCredentials {
|
||||
return winpipeCreds{}
|
||||
}
|
||||
|
||||
type winpipeCreds struct{}
|
||||
|
||||
func (winpipeCreds) ClientHandshake(_ context.Context, _ string, conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
||||
return conn, AuthInfo{}, nil
|
||||
}
|
||||
|
||||
// ConnIdentity extracts the caller's identity from an accepted named-pipe
|
||||
// connection by impersonating the pipe client and reading its token. It is
|
||||
// shared by the gRPC transport credentials and the JSON gateway (which forwards
|
||||
// it). Requires the client to have connected at SECURITY_IDENTIFICATION.
|
||||
func ConnIdentity(conn net.Conn) (Identity, error) {
|
||||
// go-winio's pipe connection embeds *win32File, which exposes Fd().
|
||||
fdConn, ok := conn.(interface{ Fd() uintptr })
|
||||
if !ok {
|
||||
return Identity{}, fmt.Errorf("connection %T does not expose a pipe handle", conn)
|
||||
}
|
||||
return pipeClientIdentity(windows.Handle(fdConn.Fd()))
|
||||
}
|
||||
|
||||
// ServerHandshake extracts the connecting client's identity from the pipe. Fails
|
||||
// closed if the handle or token cannot be read.
|
||||
func (winpipeCreds) ServerHandshake(conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
||||
id, err := ConnIdentity(conn)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return conn, AuthInfo{
|
||||
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
|
||||
Identity: id,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (winpipeCreds) Info() credentials.ProtocolInfo {
|
||||
return credentials.ProtocolInfo{SecurityProtocol: AuthInfo{}.AuthType()}
|
||||
}
|
||||
|
||||
func (winpipeCreds) Clone() credentials.TransportCredentials { return winpipeCreds{} }
|
||||
|
||||
func (winpipeCreds) OverrideServerName(string) error { return nil }
|
||||
|
||||
// pipeClientIdentity reads the connecting client's user SID, enabled group SIDs,
|
||||
// and elevation by impersonating the pipe client on this thread and reading the
|
||||
// impersonation token.
|
||||
func pipeClientIdentity(handle windows.Handle) (id Identity, err error) {
|
||||
runtime.LockOSThread()
|
||||
defer runtime.UnlockOSThread()
|
||||
|
||||
if err = impersonateNamedPipeClient(handle); err != nil {
|
||||
return Identity{}, fmt.Errorf("impersonate named pipe client: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
// Surface revert error if there are no other errors.
|
||||
revErr := windows.RevertToSelf()
|
||||
if err == nil {
|
||||
err = revErr
|
||||
}
|
||||
}()
|
||||
|
||||
// openAsSelf=true: the token is opened using the daemon's process context
|
||||
// (LocalSystem), not the impersonated client's, so the open always succeeds.
|
||||
var token windows.Token
|
||||
if err = windows.OpenThreadToken(windows.CurrentThread(), windows.TOKEN_QUERY, true, &token); err != nil {
|
||||
return Identity{}, fmt.Errorf("open thread token: %w", err)
|
||||
}
|
||||
defer token.Close()
|
||||
|
||||
tu, err := token.GetTokenUser()
|
||||
if err != nil {
|
||||
return Identity{}, fmt.Errorf("get token user: %w", err)
|
||||
}
|
||||
|
||||
tg, err := token.GetTokenGroups()
|
||||
if err != nil {
|
||||
return Identity{}, fmt.Errorf("get token groups: %w", err)
|
||||
}
|
||||
var groups []string
|
||||
for _, g := range tg.AllGroups() {
|
||||
if g.Attributes&windows.SE_GROUP_ENABLED == 0 || g.Attributes&windows.SE_GROUP_USE_FOR_DENY_ONLY != 0 {
|
||||
continue
|
||||
}
|
||||
groups = append(groups, g.Sid.String())
|
||||
}
|
||||
|
||||
return Identity{
|
||||
SID: tu.User.Sid.String(),
|
||||
Groups: groups,
|
||||
Elevated: token.IsElevated(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func impersonateNamedPipeClient(h windows.Handle) error {
|
||||
r, _, e := procImpersonateNamedPipeClient.Call(uintptr(h))
|
||||
if r == 0 {
|
||||
return e
|
||||
}
|
||||
return nil
|
||||
}
|
||||
77
client/internal/ipcauth/forward.go
Normal file
77
client/internal/ipcauth/forward.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
// Metadata keys used by the local JSON gateway to forward the HTTP client's
|
||||
// identity to the daemon.
|
||||
const (
|
||||
mdFwdUID = "x-netbird-fwd-uid" // Unix
|
||||
mdFwdGID = "x-netbird-fwd-gid" // Unix
|
||||
mdFwdSID = "x-netbird-fwd-sid" // Windows user SID
|
||||
mdFwdGroup = "x-netbird-fwd-group" // Windows group SID (repeated)
|
||||
mdFwdElevated = "x-netbird-fwd-elevated" // Windows, "1" if elevated
|
||||
)
|
||||
|
||||
// ForwardIdentityMetadata encodes an identity for the gateway to forward to the
|
||||
// daemon.
|
||||
func ForwardIdentityMetadata(id Identity) metadata.MD {
|
||||
if id.IsWindows() {
|
||||
md := metadata.MD{}
|
||||
md.Set(mdFwdSID, id.SID)
|
||||
if len(id.Groups) > 0 {
|
||||
md.Set(mdFwdGroup, id.Groups...)
|
||||
}
|
||||
if id.Elevated {
|
||||
md.Set(mdFwdElevated, "1")
|
||||
}
|
||||
return md
|
||||
}
|
||||
return metadata.Pairs(
|
||||
mdFwdUID, strconv.FormatUint(uint64(id.UID), 10),
|
||||
mdFwdGID, strconv.FormatUint(uint64(id.GID), 10),
|
||||
)
|
||||
}
|
||||
|
||||
// forwardedIdentity extracts a forwarded identity from incoming gRPC metadata
|
||||
func forwardedIdentity(ctx context.Context) (Identity, bool) {
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
return Identity{}, false
|
||||
}
|
||||
|
||||
if sid := mdFirst(md, mdFwdSID); sid != "" {
|
||||
return Identity{
|
||||
SID: sid,
|
||||
Groups: md.Get(mdFwdGroup),
|
||||
Elevated: mdFirst(md, mdFwdElevated) == "1",
|
||||
}, true
|
||||
}
|
||||
|
||||
uidStr := mdFirst(md, mdFwdUID)
|
||||
if uidStr == "" {
|
||||
return Identity{}, false
|
||||
}
|
||||
uid, err := strconv.ParseUint(uidStr, 10, 32)
|
||||
if err != nil {
|
||||
return Identity{}, false
|
||||
}
|
||||
id := Identity{UID: uint32(uid)}
|
||||
if g := mdFirst(md, mdFwdGID); g != "" {
|
||||
if v, err := strconv.ParseUint(g, 10, 32); err == nil {
|
||||
id.GID = uint32(v)
|
||||
}
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func mdFirst(md metadata.MD, key string) string {
|
||||
if v := md.Get(key); len(v) > 0 {
|
||||
return v[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
41
client/internal/ipcauth/forward_test.go
Normal file
41
client/internal/ipcauth/forward_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
func TestForwardIdentityRoundTrip(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
id Identity
|
||||
}{
|
||||
{"unix uid/gid", Identity{UID: 1000, GID: 1000}},
|
||||
{"windows sid+groups+elevated", Identity{
|
||||
SID: "S-1-5-21-1-2-3-1001",
|
||||
Groups: []string{"S-1-5-32-544", "S-1-1-0"},
|
||||
Elevated: true,
|
||||
}},
|
||||
{"windows sid only", Identity{SID: "S-1-5-21-9"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx := metadata.NewIncomingContext(context.Background(), ForwardIdentityMetadata(tc.id))
|
||||
got, ok := forwardedIdentity(ctx)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, tc.id, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardedIdentity_None(t *testing.T) {
|
||||
_, ok := forwardedIdentity(context.Background())
|
||||
assert.False(t, ok, "no metadata, no forwarded identity")
|
||||
|
||||
ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("other", "x"))
|
||||
_, ok = forwardedIdentity(ctx)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
89
client/internal/ipcauth/identity.go
Normal file
89
client/internal/ipcauth/identity.go
Normal file
@@ -0,0 +1,89 @@
|
||||
// Package ipcauth provides the kernel-authenticated identity of a local IPC
|
||||
// (gRPC) caller and the transport credentials that surface it into the gRPC
|
||||
// context, so the daemon can authorize each RPC by caller identity.
|
||||
//
|
||||
// On Unix the identity is read from the kernel via SO_PEERCRED (Linux) or
|
||||
// LOCAL_PEERCRED (Darwin/FreeBSD). On Windows it is derived from the named-pipe
|
||||
// client token. Platforms without a peer-identity primitive get no credentials
|
||||
// and therefore no enforcement.
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/peer"
|
||||
)
|
||||
|
||||
// sidLocalSystem is the well-known Windows SID for the LocalSystem account.
|
||||
const sidLocalSystem = "S-1-5-18"
|
||||
|
||||
// Identity is the kernel-authenticated identity of a local IPC caller. The zero
|
||||
// value is not a valid identity.
|
||||
type Identity struct {
|
||||
// UID and GID are the caller's Unix user ID and primary group ID.
|
||||
// Zero on Windows, where SID is authoritative instead.
|
||||
UID uint32
|
||||
GID uint32
|
||||
|
||||
// SID is the caller's Windows security identifier (empty on Unix).
|
||||
SID string
|
||||
|
||||
// Groups holds the caller's Windows group SIDs, captured from the client
|
||||
// token at handshake (empty on Unix, where supplementary group membership is
|
||||
// resolved on demand via NSS/getent by the authorizer).
|
||||
Groups []string
|
||||
|
||||
// Elevated reports whether the Windows client token is elevated (run as
|
||||
// administrator). Always false on Unix, where privilege is uid==0.
|
||||
Elevated bool
|
||||
}
|
||||
|
||||
// IsWindows reports whether this identity is a Windows principal (SID-based)
|
||||
// rather than a Unix uid/gid principal.
|
||||
func (i Identity) IsWindows() bool {
|
||||
return i.SID != ""
|
||||
}
|
||||
|
||||
// IsPrivileged reports whether the caller is the platform's administrative
|
||||
// principal.
|
||||
func (i Identity) IsPrivileged() bool {
|
||||
if i.IsWindows() {
|
||||
return i.Elevated || i.SID == sidLocalSystem
|
||||
}
|
||||
return i.UID == 0
|
||||
}
|
||||
|
||||
// String renders the identity for audit logs.
|
||||
func (i Identity) String() string {
|
||||
if i.IsWindows() {
|
||||
return fmt.Sprintf("sid=%s elevated=%t", i.SID, i.Elevated)
|
||||
}
|
||||
return fmt.Sprintf("uid=%d gid=%d", i.UID, i.GID)
|
||||
}
|
||||
|
||||
// AuthInfo carries the peer Identity as a gRPC credentials.AuthInfo so the
|
||||
// interceptor can retrieve it from the request context via IdentityFromContext.
|
||||
type AuthInfo struct {
|
||||
credentials.CommonAuthInfo
|
||||
Identity Identity
|
||||
}
|
||||
|
||||
// AuthType identifies the authentication scheme.
|
||||
func (AuthInfo) AuthType() string { return "netbird-ipc-peercred" }
|
||||
|
||||
// IdentityFromContext extracts the caller's kernel-authenticated identity from
|
||||
// the gRPC peer context. The second return value is false when no IPC transport
|
||||
// credentials were negotiated, callers MUST fail closed in that case.
|
||||
func IdentityFromContext(ctx context.Context) (Identity, bool) {
|
||||
p, ok := peer.FromContext(ctx)
|
||||
if !ok {
|
||||
return Identity{}, false
|
||||
}
|
||||
info, ok := p.AuthInfo.(AuthInfo)
|
||||
if !ok {
|
||||
return Identity{}, false
|
||||
}
|
||||
return info.Identity, true
|
||||
}
|
||||
50
client/internal/ipcauth/identity_test.go
Normal file
50
client/internal/ipcauth/identity_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/peer"
|
||||
)
|
||||
|
||||
func TestIdentityFromContext_NoPeer(t *testing.T) {
|
||||
_, ok := IdentityFromContext(context.Background())
|
||||
assert.False(t, ok, "bare context must report no identity (fail closed)")
|
||||
}
|
||||
|
||||
func TestIdentityFromContext_WrongAuthInfo(t *testing.T) {
|
||||
ctx := peer.NewContext(context.Background(), &peer.Peer{})
|
||||
_, ok := IdentityFromContext(ctx)
|
||||
assert.False(t, ok, "peer without our AuthInfo must report no identity")
|
||||
}
|
||||
|
||||
func TestIdentityFromContext_Present(t *testing.T) {
|
||||
want := Identity{UID: 1000, GID: 1000}
|
||||
ctx := peer.NewContext(context.Background(), &peer.Peer{
|
||||
AuthInfo: AuthInfo{
|
||||
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
|
||||
Identity: want,
|
||||
},
|
||||
})
|
||||
|
||||
got, ok := IdentityFromContext(ctx)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, want, got)
|
||||
}
|
||||
|
||||
func TestIdentity_IsPrivileged(t *testing.T) {
|
||||
// Unix
|
||||
assert.True(t, Identity{UID: 0}.IsPrivileged(), "root is privileged")
|
||||
assert.False(t, Identity{UID: 1000}.IsPrivileged(), "non-root is not privileged")
|
||||
// Windows
|
||||
assert.True(t, Identity{SID: "S-1-5-21-1-2-3-1001", Elevated: true}.IsPrivileged(), "elevated admin is privileged")
|
||||
assert.True(t, Identity{SID: "S-1-5-18"}.IsPrivileged(), "LocalSystem is privileged")
|
||||
assert.False(t, Identity{SID: "S-1-5-21-1-2-3-1001"}.IsPrivileged(), "non-elevated admin is NOT privileged")
|
||||
}
|
||||
|
||||
func TestIdentity_IsWindows(t *testing.T) {
|
||||
assert.True(t, Identity{SID: "S-1-5-18"}.IsWindows())
|
||||
assert.False(t, Identity{UID: 0}.IsWindows())
|
||||
}
|
||||
152
client/internal/ipcauth/interceptor.go
Normal file
152
client/internal/ipcauth/interceptor.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// Interceptor enforces per-RPC authorization on the daemon IPC, keyed to
|
||||
// the caller's kernel-authenticated identity. It is safe-by-default:
|
||||
// any RPC without a matching bypass is gated by the active profile's ownership,
|
||||
// and a caller without a readable identity is denied.
|
||||
type Interceptor struct {
|
||||
policy ProfilePolicy
|
||||
resolver GroupResolver
|
||||
// selfUID is the daemon's own effective UID. -1 on Windows.
|
||||
selfUID int
|
||||
}
|
||||
|
||||
// NewInterceptor builds an interceptor over the given policy and group resolver.
|
||||
func NewInterceptor(policy ProfilePolicy, resolver GroupResolver) *Interceptor {
|
||||
return &Interceptor{policy: policy, resolver: resolver, selfUID: os.Geteuid()}
|
||||
}
|
||||
|
||||
// UnaryServerInterceptor authorizes each unary RPC before the handler runs.
|
||||
func (i *Interceptor) UnaryServerInterceptor() grpc.UnaryServerInterceptor {
|
||||
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
||||
if err := i.authorize(ctx, info.FullMethod); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return handler(ctx, req)
|
||||
}
|
||||
}
|
||||
|
||||
// StreamServerInterceptor authorizes each streaming RPC before the handler runs.
|
||||
func (i *Interceptor) StreamServerInterceptor() grpc.StreamServerInterceptor {
|
||||
return func(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
|
||||
if err := i.authorize(ss.Context(), info.FullMethod); err != nil {
|
||||
return err
|
||||
}
|
||||
return handler(srv, ss)
|
||||
}
|
||||
}
|
||||
|
||||
// authorize decides each RPC from the caller's identity, first match wins:
|
||||
//
|
||||
// 0. no identity DENY
|
||||
// 1. self / privileged / forwarded ALLOW (root, elevated, daemon-self, JSON gateway)
|
||||
// 2. in ownersAuthorizedMethods owner tier (gate on DaemonOwnership)
|
||||
// 3. in handlerAuthorizedMethods profile tier (bypass, handler decides)
|
||||
// 4. everything else default tier (gate on ActiveProfileOwnership)
|
||||
func (i *Interceptor) authorize(ctx context.Context, fullMethod string) error {
|
||||
id, ok := IdentityFromContext(ctx)
|
||||
if !ok {
|
||||
log.Warnf("ipc authz: DENY %s. caller identity unavailable", fullMethod)
|
||||
return status.Error(codes.PermissionDenied, "caller identity could not be verified on the daemon control channel")
|
||||
}
|
||||
|
||||
if i.isSelfOrPrivileged(id) {
|
||||
// The local JSON gateway connects as the daemon itself (self/privileged)
|
||||
// and forwards the real HTTP client's identity. Trust it here, where
|
||||
// the transport peer is already the daemon, then authorize as the
|
||||
// forwarded client. A direct non-privileged caller never reaches this
|
||||
// branch, so it cannot forge the forwarding metadata.
|
||||
fwd, hasFwd := forwardedIdentity(ctx)
|
||||
if !hasFwd {
|
||||
i.auditAllow(id, fullMethod)
|
||||
return nil
|
||||
}
|
||||
log.Infof("ipc authz: gateway-forwarded identity %s", fwd)
|
||||
id = fwd
|
||||
if i.isSelfOrPrivileged(id) {
|
||||
i.auditAllow(id, fullMethod)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Owner tier: daemon-level RPCs (Add, Down, Status) require a daemon-wide owner.
|
||||
if ownersAuthorizedMethods[fullMethod] {
|
||||
allowed, err := i.authorizeOwnership(id, i.policy.DaemonOwnership, i.policy.ClaimDaemonOwnerIfUnowned)
|
||||
if err != nil {
|
||||
log.Errorf("ipc authz: claim daemon owner for %s: %v", id, err)
|
||||
return status.Error(codes.Internal, "failed to claim daemon ownership")
|
||||
}
|
||||
if allowed {
|
||||
i.auditAllow(id, fullMethod)
|
||||
return nil
|
||||
}
|
||||
log.Warnf("ipc authz: DENY %s for %s. not a daemon owner", fullMethod, id)
|
||||
return status.Errorf(codes.PermissionDenied,
|
||||
"not authorized (caller %s is not a daemon owner). ask an owner or run as root/administrator", id)
|
||||
}
|
||||
|
||||
// Profile tier: the handler self-authorizes against the target profile.
|
||||
if handlerAuthorizedMethods[fullMethod] {
|
||||
i.auditAllow(id, fullMethod)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Default: gated on the active profile's ownership.
|
||||
allowed, err := i.authorizeOwnership(id, i.policy.ActiveProfileOwnership, i.policy.ClaimActiveProfileOwnerIfUnowned)
|
||||
if err != nil {
|
||||
log.Errorf("ipc authz: claim active profile for %s: %v", id, err)
|
||||
return status.Error(codes.Internal, "failed to claim profile ownership")
|
||||
}
|
||||
if allowed {
|
||||
i.auditAllow(id, fullMethod)
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Warnf("ipc authz: DENY %s for %s. active profile owned by another principal", fullMethod, id)
|
||||
return status.Errorf(codes.PermissionDenied,
|
||||
"not authorized to control the active profile (caller %s). ask an owner or run as root/administrator", id)
|
||||
}
|
||||
|
||||
// authorizeOwnership authorizes id against an ownership set, claiming it via
|
||||
// trust-on-first-use when it is unowned and unshared. The claim is atomic, so on
|
||||
// a lost race it re-reads and authorizes normally.
|
||||
func (i *Interceptor) authorizeOwnership(id Identity, get func() Ownership, claim func(Identity) (bool, error)) (bool, error) {
|
||||
o := get()
|
||||
if len(o.Owners) == 0 && !o.Shared {
|
||||
claimed, err := claim(id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if claimed {
|
||||
return true, nil
|
||||
}
|
||||
o = get()
|
||||
}
|
||||
return Authorize(o, id, i.resolver), nil
|
||||
}
|
||||
|
||||
// isSelfOrPrivileged reports whether the caller is the platform administrator
|
||||
// (root / elevated-admin / LocalSystem) or the daemon's own user.
|
||||
func (i *Interceptor) isSelfOrPrivileged(id Identity) bool {
|
||||
if id.IsPrivileged() {
|
||||
return true
|
||||
}
|
||||
// Daemon-self: only meaningful on Unix (Windows privilege is covered above).
|
||||
return !id.IsWindows() && i.selfUID >= 0 && int(id.UID) == i.selfUID
|
||||
}
|
||||
|
||||
func (i *Interceptor) auditAllow(id Identity, fullMethod string) {
|
||||
if auditMethods[fullMethod] {
|
||||
log.Infof("ipc authz: allow %s for %s", fullMethod, id)
|
||||
}
|
||||
}
|
||||
176
client/internal/ipcauth/interceptor_test.go
Normal file
176
client/internal/ipcauth/interceptor_test.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/peer"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
type mockPolicy struct {
|
||||
o Ownership // active profile ownership
|
||||
daemon Ownership // daemon-wide ownership
|
||||
claimed bool
|
||||
daemonClaimed bool
|
||||
}
|
||||
|
||||
func (m *mockPolicy) ActiveProfileOwnership() Ownership { return m.o }
|
||||
|
||||
// ClaimActiveProfileOwnerIfUnowned records a claim and marks the profile owned.
|
||||
func (m *mockPolicy) ClaimActiveProfileOwnerIfUnowned(id Identity) (bool, error) {
|
||||
if len(m.o.Owners) == 0 && !m.o.Shared {
|
||||
m.o.Owners = []string{OwnerPrincipalForIdentity(id)}
|
||||
m.claimed = true
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (m *mockPolicy) DaemonOwnership() Ownership { return m.daemon }
|
||||
|
||||
// ClaimDaemonOwnerIfUnowned records a daemon claim and marks the daemon owned.
|
||||
func (m *mockPolicy) ClaimDaemonOwnerIfUnowned(id Identity) (bool, error) {
|
||||
if len(m.daemon.Owners) == 0 && !m.daemon.Shared {
|
||||
m.daemon.Owners = []string{OwnerPrincipalForIdentity(id)}
|
||||
m.daemonClaimed = true
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
type mockResolver struct {
|
||||
gids map[uint32]struct{}
|
||||
names map[string]uint32
|
||||
}
|
||||
|
||||
func (m mockResolver) CallerGIDs(Identity) map[uint32]struct{} { return m.gids }
|
||||
func (m mockResolver) GroupNameGID(n string) (uint32, bool) { g, ok := m.names[n]; return g, ok }
|
||||
|
||||
func ctxWith(id Identity) context.Context {
|
||||
return peer.NewContext(context.Background(), &peer.Peer{AuthInfo: AuthInfo{Identity: id}})
|
||||
}
|
||||
|
||||
const (
|
||||
up = servicePath + "Up"
|
||||
list = servicePath + "ListProfiles"
|
||||
unkwn = servicePath + "SomeFutureMethod"
|
||||
down = servicePath + "Down"
|
||||
statusm = servicePath + "Status"
|
||||
addp = servicePath + "AddProfile"
|
||||
switchp = servicePath + "SwitchProfile"
|
||||
addowner = servicePath + "AddOwner"
|
||||
sharep = servicePath + "ShareProfile"
|
||||
)
|
||||
|
||||
func TestInterceptorAuthorize(t *testing.T) {
|
||||
const selfUID = 4000
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
own Ownership // active profile ownership
|
||||
daemon Ownership // daemon-wide ownership
|
||||
resolver GroupResolver
|
||||
ctx context.Context
|
||||
method string
|
||||
wantErr bool
|
||||
}{
|
||||
// Default gate (active profile ownership).
|
||||
{"no identity denies", Ownership{}, Ownership{}, nil, context.Background(), up, true},
|
||||
{"root allowed", Ownership{}, Ownership{}, nil, ctxWith(Identity{UID: 0}), up, false},
|
||||
{"daemon-self allowed", Ownership{}, Ownership{}, nil, ctxWith(Identity{UID: selfUID}), up, false},
|
||||
{"shared allows any", Ownership{Shared: true}, Ownership{}, nil, ctxWith(Identity{UID: 1234}), up, false},
|
||||
{"uid owner allowed", Ownership{Owners: []string{"uid:1000"}}, Ownership{}, nil, ctxWith(Identity{UID: 1000}), up, false},
|
||||
{"non-owner denied", Ownership{Owners: []string{"uid:1000"}}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), up, true},
|
||||
{"unknown method gated", Ownership{Owners: []string{"uid:1000"}}, Ownership{}, nil, ctxWith(Identity{UID: 2000}), unkwn, true},
|
||||
{"primary gid owner", Ownership{Owners: []string{"gid:5000"}}, Ownership{}, nil, ctxWith(Identity{UID: 2000, GID: 5000}), up, false},
|
||||
{"group-name owner via resolver", Ownership{Owners: []string{"group:admins"}}, Ownership{},
|
||||
mockResolver{names: map[string]uint32{"admins": 5000}, gids: map[uint32]struct{}{5000: {}}},
|
||||
ctxWith(Identity{UID: 2000, GID: 42}), up, false},
|
||||
{"windows sid owner", Ownership{Owners: []string{"sid:S-1-5-21-9"}}, Ownership{}, nil,
|
||||
ctxWith(Identity{SID: "S-1-5-21-9"}), up, false},
|
||||
{"windows group-sid owner", Ownership{Owners: []string{"sid:S-1-5-32-544"}}, Ownership{}, nil,
|
||||
ctxWith(Identity{SID: "S-1-5-21-1", Groups: []string{"S-1-5-32-544"}}), up, false},
|
||||
{"windows elevated privileged", Ownership{}, Ownership{}, nil,
|
||||
ctxWith(Identity{SID: "S-1-5-21-1", Elevated: true}), up, false},
|
||||
|
||||
// Profile tier (handler self-authorizes, bypass).
|
||||
{"list bypasses gate", Ownership{Owners: []string{"uid:1000"}}, Ownership{}, nil, ctxWith(Identity{UID: 2000}), list, false},
|
||||
{"switch-profile bypasses gate", Ownership{Owners: []string{"uid:1000"}}, Ownership{}, nil, ctxWith(Identity{UID: 2000}), switchp, false},
|
||||
|
||||
// Owner tier (daemon-wide ownership), independent of the active profile.
|
||||
{"down by daemon owner allowed", Ownership{Owners: []string{"uid:9"}}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 1000}), down, false},
|
||||
{"down by non-owner denied", Ownership{}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), down, true},
|
||||
{"status by non-owner denied", Ownership{}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), statusm, true},
|
||||
{"add by daemon owner allowed", Ownership{}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 1000}), addp, false},
|
||||
{"add by non-owner denied", Ownership{}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), addp, true},
|
||||
{"owner-tier TOFU claims unowned daemon", Ownership{}, Ownership{}, nil, ctxWith(Identity{UID: 2000}), down, false},
|
||||
|
||||
// Owner-set mutations gate on daemon ownership, not the active profile.
|
||||
{"add-owner by daemon owner allowed", Ownership{Owners: []string{"uid:9"}}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 1000}), addowner, false},
|
||||
{"add-owner by active-profile owner (non daemon owner) denied", Ownership{Owners: []string{"uid:2000"}}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), addowner, true},
|
||||
{"share by active-profile owner (non daemon owner) denied", Ownership{Owners: []string{"uid:2000"}}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), sharep, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
i := &Interceptor{policy: &mockPolicy{o: tt.own, daemon: tt.daemon}, resolver: tt.resolver, selfUID: selfUID}
|
||||
err := i.authorize(tt.ctx, tt.method)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, codes.PermissionDenied, status.Code(err))
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestInterceptorForwardedIdentity verifies the JSON-gateway trust model: a
|
||||
// self/privileged transport peer (the loopback gateway) may forward a real
|
||||
// client identity, but a non-privileged caller cannot forge it.
|
||||
func TestInterceptorForwardedIdentity(t *testing.T) {
|
||||
const selfUID = 4000
|
||||
owners := Ownership{Owners: []string{"uid:1000"}}
|
||||
|
||||
withFwd := func(peerUID, fwdUID uint32) context.Context {
|
||||
ctx := ctxWith(Identity{UID: peerUID})
|
||||
return metadata.NewIncomingContext(ctx, metadata.Pairs(mdFwdUID, itoa(fwdUID)))
|
||||
}
|
||||
|
||||
// Gateway forwards a non-owner client: denied as that client.
|
||||
i := &Interceptor{policy: &mockPolicy{o: owners}, selfUID: selfUID}
|
||||
assert.Error(t, i.authorize(withFwd(selfUID, 2000), up))
|
||||
|
||||
// Gateway forwards the owner: allowed.
|
||||
assert.NoError(t, i.authorize(withFwd(selfUID, 1000), up))
|
||||
|
||||
// A non-privileged direct caller's forwarded metadata: denied
|
||||
assert.Error(t, i.authorize(withFwd(2000, 1000), up))
|
||||
}
|
||||
|
||||
func itoa(u uint32) string {
|
||||
return strconv.FormatUint(uint64(u), 10)
|
||||
}
|
||||
|
||||
// TestInterceptorTOFU verifies an unowned, non-shared profile is claimed by the
|
||||
// first non-privileged caller, and a different caller is then denied.
|
||||
func TestInterceptorTOFU(t *testing.T) {
|
||||
policy := &mockPolicy{o: Ownership{}} // unowned
|
||||
i := &Interceptor{policy: policy, resolver: nil, selfUID: 4000}
|
||||
|
||||
// First caller (uid 1000) claims via TOFU.
|
||||
err := i.authorize(ctxWith(Identity{UID: 1000}), up)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, policy.claimed, "first caller should claim ownership")
|
||||
assert.Equal(t, []string{"uid:1000"}, policy.o.Owners)
|
||||
|
||||
// A different caller is now denied (profile owned by uid 1000).
|
||||
err = i.authorize(ctxWith(Identity{UID: 2000}), up)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, codes.PermissionDenied, status.Code(err))
|
||||
}
|
||||
42
client/internal/ipcauth/peercred_bsd.go
Normal file
42
client/internal/ipcauth/peercred_bsd.go
Normal file
@@ -0,0 +1,42 @@
|
||||
//go:build darwin || freebsd
|
||||
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// PeerIdentity reads the kernel-authenticated identity of the process on the
|
||||
// other end of a Unix socket connection via LOCAL_PEERCRED (xucred). xucred
|
||||
// carries the uid and primary group.
|
||||
func PeerIdentity(c net.Conn) (Identity, error) {
|
||||
uc, ok := c.(*net.UnixConn)
|
||||
if !ok {
|
||||
return Identity{}, fmt.Errorf("connection is not a unix socket: %T", c)
|
||||
}
|
||||
raw, err := uc.SyscallConn()
|
||||
if err != nil {
|
||||
return Identity{}, 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 Identity{}, fmt.Errorf("getsockopt control: %w", err)
|
||||
}
|
||||
if credErr != nil {
|
||||
return Identity{}, fmt.Errorf("LOCAL_PEERCRED: %w", credErr)
|
||||
}
|
||||
|
||||
id := Identity{UID: cred.Uid}
|
||||
// Groups[0] is the effective (primary) GID; guard against an empty list.
|
||||
if cred.Ngroups > 0 {
|
||||
id.GID = cred.Groups[0]
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
41
client/internal/ipcauth/peercred_linux.go
Normal file
41
client/internal/ipcauth/peercred_linux.go
Normal file
@@ -0,0 +1,41 @@
|
||||
//go:build linux
|
||||
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// PeerIdentity reads the kernel-authenticated identity of the process on the
|
||||
// other end of a Unix socket connection via SO_PEERCRED. The credentials are
|
||||
// captured by the kernel at connect() time and cannot be spoofed or changed for
|
||||
// the life of the connection.
|
||||
func PeerIdentity(c net.Conn) (Identity, error) {
|
||||
uc, ok := c.(*net.UnixConn)
|
||||
if !ok {
|
||||
return Identity{}, fmt.Errorf("connection is not a unix socket: %T", c)
|
||||
}
|
||||
raw, err := uc.SyscallConn()
|
||||
if err != nil {
|
||||
return Identity{}, 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 Identity{}, fmt.Errorf("getsockopt control: %w", err)
|
||||
}
|
||||
if credErr != nil {
|
||||
return Identity{}, fmt.Errorf("SO_PEERCRED: %w", credErr)
|
||||
}
|
||||
|
||||
return Identity{
|
||||
UID: cred.Uid,
|
||||
GID: cred.Gid,
|
||||
}, nil
|
||||
}
|
||||
16
client/internal/ipcauth/peercred_stub.go
Normal file
16
client/internal/ipcauth/peercred_stub.go
Normal file
@@ -0,0 +1,16 @@
|
||||
//go:build !linux && !darwin && !freebsd
|
||||
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// PeerIdentity is unimplemented on platforms without a Unix-socket peer-credential
|
||||
// primitive. Windows derives identity from the named-pipe client token instead
|
||||
// (see the Windows transport credentials), so it never calls this.
|
||||
func PeerIdentity(net.Conn) (Identity, error) {
|
||||
return Identity{}, fmt.Errorf("peer credential check not supported on %s", runtime.GOOS)
|
||||
}
|
||||
114
client/internal/ipcauth/peercred_unix_test.go
Normal file
114
client/internal/ipcauth/peercred_unix_test.go
Normal file
@@ -0,0 +1,114 @@
|
||||
//go:build linux || darwin || freebsd
|
||||
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/health"
|
||||
healthpb "google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
// TestPeerIdentity_MatchesCurrentProcess connects to a real Unix socket and
|
||||
// verifies the extracted UID/GID match the running process (both ends are us).
|
||||
func TestPeerIdentity_MatchesCurrentProcess(t *testing.T) {
|
||||
sock := filepath.Join(t.TempDir(), "peer.sock")
|
||||
ln, err := net.Listen("unix", sock)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
|
||||
type result struct {
|
||||
id Identity
|
||||
err error
|
||||
}
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
c, aerr := ln.Accept()
|
||||
if aerr != nil {
|
||||
done <- result{err: aerr}
|
||||
return
|
||||
}
|
||||
defer func() { _ = c.Close() }()
|
||||
id, ierr := PeerIdentity(c)
|
||||
done <- result{id: id, err: ierr}
|
||||
}()
|
||||
|
||||
client, err := net.Dial("unix", sock)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
|
||||
res := <-done
|
||||
require.NoError(t, res.err)
|
||||
assert.Equal(t, uint32(os.Getuid()), res.id.UID, "UID should match current process")
|
||||
assert.Equal(t, uint32(os.Getgid()), res.id.GID, "primary GID should match current process")
|
||||
}
|
||||
|
||||
// TestPeerIdentity_NonUnixConn rejects non-Unix connections (fail closed).
|
||||
func TestPeerIdentity_NonUnixConn(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
c, aerr := ln.Accept()
|
||||
if aerr != nil {
|
||||
done <- aerr
|
||||
return
|
||||
}
|
||||
defer func() { _ = c.Close() }()
|
||||
_, ierr := PeerIdentity(c)
|
||||
done <- ierr
|
||||
}()
|
||||
|
||||
client, err := net.Dial("tcp", ln.Addr().String())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
|
||||
assert.Error(t, <-done, "PeerIdentity must reject a non-Unix connection")
|
||||
}
|
||||
|
||||
// TestGRPCRoundTrip_ServerCredsClientInsecure proves the transport contract end
|
||||
// to end: a gRPC server using the peercred transport credentials still serves a
|
||||
// plain insecure client (the CLI never changed), and the caller's kernel
|
||||
// identity reaches the handler via IdentityFromContext.
|
||||
func TestGRPCRoundTrip_ServerCredsClientInsecure(t *testing.T) {
|
||||
sock := filepath.Join(t.TempDir(), "rt.sock")
|
||||
ln, err := net.Listen("unix", sock)
|
||||
require.NoError(t, err)
|
||||
|
||||
var gotUID uint32
|
||||
var gotOK bool
|
||||
srv := grpc.NewServer(
|
||||
grpc.Creds(NewTransportCredentials()),
|
||||
grpc.UnaryInterceptor(func(ctx context.Context, req any, _ *grpc.UnaryServerInfo, h grpc.UnaryHandler) (any, error) {
|
||||
id, ok := IdentityFromContext(ctx)
|
||||
gotUID, gotOK = id.UID, ok
|
||||
return h(ctx, req)
|
||||
}),
|
||||
)
|
||||
healthpb.RegisterHealthServer(srv, health.NewServer())
|
||||
go func() { _ = srv.Serve(ln) }()
|
||||
t.Cleanup(srv.Stop)
|
||||
|
||||
conn, err := grpc.NewClient("unix://"+sock, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_, err = healthpb.NewHealthClient(conn).Check(ctx, &healthpb.HealthCheckRequest{})
|
||||
require.NoError(t, err, "insecure client must reach the peercred server")
|
||||
|
||||
assert.True(t, gotOK, "handler must see a peer identity")
|
||||
assert.Equal(t, uint32(os.Getuid()), gotUID, "handler must see the caller's UID")
|
||||
}
|
||||
135
client/internal/ipcauth/policy.go
Normal file
135
client/internal/ipcauth/policy.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package ipcauth
|
||||
|
||||
import "sync"
|
||||
|
||||
const servicePath = "/daemon.DaemonService/"
|
||||
|
||||
// ProfilePolicy exposes ownership to the interceptor. The daemon server
|
||||
// implements it. ConfigAdapter bridges the gap because the gRPC server (and its
|
||||
// interceptor) is constructed before the server instance exists.
|
||||
type ProfilePolicy interface {
|
||||
// ActiveProfileOwnership returns the active profile's ownership policy.
|
||||
ActiveProfileOwnership() Ownership
|
||||
|
||||
// ClaimActiveProfileOwnerIfUnowned atomically claims the active profile for
|
||||
// id when it has no owners and is not shared (trust-on-first-use), and
|
||||
// reports whether id is now an owner. A false return means the profile was
|
||||
// already owned or shared or another caller won the claim.
|
||||
ClaimActiveProfileOwnerIfUnowned(id Identity) (bool, error)
|
||||
|
||||
// DaemonOwnership returns the daemon-wide ownership policy that governs the
|
||||
// owner-tier RPCs and the default profile.
|
||||
DaemonOwnership() Ownership
|
||||
|
||||
// ClaimDaemonOwnerIfUnowned atomically claims daemon-wide ownership for id
|
||||
// when the daemon is unowned and not shared (trust-on-first-use).
|
||||
ClaimDaemonOwnerIfUnowned(id Identity) (bool, error)
|
||||
}
|
||||
|
||||
// ownersAuthorizedMethods gate on the daemon-wide owner set (or root): daemon-level
|
||||
// ops independent of any profile. The owner-set mutations (AddOwner, ShareProfile,
|
||||
// ResetOwner) must gate here, not on the active profile, else a per-profile owner
|
||||
// could escalate via `owner add`. ResetOwner also requires root in its handler.
|
||||
var ownersAuthorizedMethods = map[string]bool{
|
||||
servicePath + "AddProfile": true,
|
||||
servicePath + "Down": true,
|
||||
servicePath + "Status": true,
|
||||
servicePath + "AddOwner": true,
|
||||
servicePath + "ShareProfile": true,
|
||||
servicePath + "ResetOwner": true,
|
||||
}
|
||||
|
||||
// handlerAuthorizedMethods bypass the ownership gate (identity still required)
|
||||
// and let the handler authorize. GetActiveProfile bypasses only to return
|
||||
// public metadata any local user may read.
|
||||
var handlerAuthorizedMethods = map[string]bool{
|
||||
servicePath + "ListProfiles": true,
|
||||
servicePath + "RemoveProfile": true,
|
||||
servicePath + "RenameProfile": true,
|
||||
servicePath + "SwitchProfile": true,
|
||||
servicePath + "GetActiveProfile": true,
|
||||
}
|
||||
|
||||
// auditMethods are worth an audit log line. Denials are always logged.
|
||||
var auditMethods = map[string]bool{
|
||||
servicePath + "GetConfig": true,
|
||||
servicePath + "SetConfig": true,
|
||||
servicePath + "Login": true,
|
||||
servicePath + "WaitSSOLogin": true,
|
||||
servicePath + "RequestJWTAuth": true,
|
||||
servicePath + "WaitJWTToken": true,
|
||||
servicePath + "StartCapture": true,
|
||||
servicePath + "StartBundleCapture": true,
|
||||
servicePath + "DebugBundle": true,
|
||||
servicePath + "ExposeService": true,
|
||||
servicePath + "Up": true,
|
||||
servicePath + "Down": true,
|
||||
servicePath + "SelectNetworks": true,
|
||||
servicePath + "DeselectNetworks": true,
|
||||
servicePath + "SwitchProfile": true,
|
||||
servicePath + "TriggerUpdate": true,
|
||||
servicePath + "Logout": true,
|
||||
servicePath + "CleanState": true,
|
||||
servicePath + "DeleteState": true,
|
||||
servicePath + "AddOwner": true,
|
||||
servicePath + "ResetOwner": true,
|
||||
servicePath + "ShareProfile": true,
|
||||
}
|
||||
|
||||
// ConfigAdapter is a ProfilePolicy whose backend is set lazily, once the daemon
|
||||
// server instance is created. Until then it reports an unowned profile
|
||||
// (Ownership zero value), so non-privileged callers are denied.
|
||||
type ConfigAdapter struct {
|
||||
mu sync.RWMutex
|
||||
backend ProfilePolicy
|
||||
}
|
||||
|
||||
// SetBackend installs the real policy. Must be called before serving RPCs.
|
||||
func (a *ConfigAdapter) SetBackend(backend ProfilePolicy) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.backend = backend
|
||||
}
|
||||
|
||||
// ActiveProfileOwnership delegates to the backend, or reports an unowned profile
|
||||
// when no backend is set yet.
|
||||
func (a *ConfigAdapter) ActiveProfileOwnership() Ownership {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
if a.backend == nil {
|
||||
return Ownership{}
|
||||
}
|
||||
return a.backend.ActiveProfileOwnership()
|
||||
}
|
||||
|
||||
// ClaimActiveProfileOwnerIfUnowned delegates to the backend. Before the backend
|
||||
// is set it cannot claim, so it reports not-owned (fail closed).
|
||||
func (a *ConfigAdapter) ClaimActiveProfileOwnerIfUnowned(id Identity) (bool, error) {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
if a.backend == nil {
|
||||
return false, nil
|
||||
}
|
||||
return a.backend.ClaimActiveProfileOwnerIfUnowned(id)
|
||||
}
|
||||
|
||||
// DaemonOwnership delegates to the backend, reporting unowned when none is set.
|
||||
func (a *ConfigAdapter) DaemonOwnership() Ownership {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
if a.backend == nil {
|
||||
return Ownership{}
|
||||
}
|
||||
return a.backend.DaemonOwnership()
|
||||
}
|
||||
|
||||
// ClaimDaemonOwnerIfUnowned delegates to the backend. Before the backend is set
|
||||
// it cannot claim, so it reports not-owned (fail closed).
|
||||
func (a *ConfigAdapter) ClaimDaemonOwnerIfUnowned(id Identity) (bool, error) {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
if a.backend == nil {
|
||||
return false, nil
|
||||
}
|
||||
return a.backend.ClaimDaemonOwnerIfUnowned(id)
|
||||
}
|
||||
54
client/internal/ipcauth/principal.go
Normal file
54
client/internal/ipcauth/principal.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PrincipalKind is the type of an owner principal.
|
||||
type PrincipalKind string
|
||||
|
||||
const (
|
||||
KindUID PrincipalKind = "uid" // Unix user ID
|
||||
KindGID PrincipalKind = "gid" // Unix group ID
|
||||
KindGroup PrincipalKind = "group" // Unix group name (NSS-resolved)
|
||||
KindSID PrincipalKind = "sid" // Windows user or group SID
|
||||
)
|
||||
|
||||
// Principal is a parsed owner entry from a profile's Owners list.
|
||||
type Principal struct {
|
||||
Kind PrincipalKind
|
||||
Value string
|
||||
}
|
||||
|
||||
// ParsePrincipal parses a "kind:value" owner string. Returns false for empty
|
||||
// values or unknown kinds so malformed entries are ignored rather than trusted.
|
||||
func ParsePrincipal(s string) (Principal, bool) {
|
||||
kind, value, ok := strings.Cut(s, ":")
|
||||
if !ok || value == "" {
|
||||
return Principal{}, false
|
||||
}
|
||||
switch PrincipalKind(kind) {
|
||||
case KindUID, KindGID, KindGroup, KindSID:
|
||||
return Principal{Kind: PrincipalKind(kind), Value: value}, true
|
||||
default:
|
||||
return Principal{}, false
|
||||
}
|
||||
}
|
||||
|
||||
// UIDPrincipal builds the owner string for a Unix user ID.
|
||||
func UIDPrincipal(uid uint32) string {
|
||||
return string(KindUID) + ":" + strconv.FormatUint(uint64(uid), 10)
|
||||
}
|
||||
|
||||
// SIDPrincipal builds the owner string for a Windows SID.
|
||||
func SIDPrincipal(sid string) string { return string(KindSID) + ":" + sid }
|
||||
|
||||
// OwnerPrincipalForIdentity returns the self-ownership principal for an identity:
|
||||
// the user's UID on Unix, or the user's SID on Windows.
|
||||
func OwnerPrincipalForIdentity(id Identity) string {
|
||||
if id.IsWindows() {
|
||||
return SIDPrincipal(id.SID)
|
||||
}
|
||||
return UIDPrincipal(id.UID)
|
||||
}
|
||||
71
client/internal/ipcauth/resolver_unix.go
Normal file
71
client/internal/ipcauth/resolver_unix.go
Normal file
@@ -0,0 +1,71 @@
|
||||
//go:build !windows
|
||||
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/shell"
|
||||
)
|
||||
|
||||
const groupCacheTTL = 30 * time.Second
|
||||
|
||||
// NewDefaultGroupResolver returns an NSS-aware group resolver, owners resolve
|
||||
// correctly for LDAP/AD users under CGO_ENABLED=0. Results are cached briefly.
|
||||
func NewDefaultGroupResolver() GroupResolver {
|
||||
return &nssResolver{byUID: make(map[uint32]gidCacheEntry)}
|
||||
}
|
||||
|
||||
type gidCacheEntry struct {
|
||||
gids map[uint32]struct{}
|
||||
at time.Time
|
||||
}
|
||||
|
||||
type nssResolver struct {
|
||||
mu sync.Mutex
|
||||
byUID map[uint32]gidCacheEntry
|
||||
}
|
||||
|
||||
func (r *nssResolver) CallerGIDs(id Identity) map[uint32]struct{} {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if e, ok := r.byUID[id.UID]; ok && time.Since(e.at) < groupCacheTTL {
|
||||
return e.gids
|
||||
}
|
||||
gids := resolveGIDs(id.UID)
|
||||
r.byUID[id.UID] = gidCacheEntry{gids: gids, at: time.Now()}
|
||||
return gids
|
||||
}
|
||||
|
||||
func resolveGIDs(uid uint32) map[uint32]struct{} {
|
||||
out := make(map[uint32]struct{})
|
||||
u, err := shell.GetUserFromGetent(strconv.FormatUint(uint64(uid), 10))
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
ids, err := shell.GroupIdsWithFallback(u)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
for _, s := range ids {
|
||||
if g, err := strconv.ParseUint(s, 10, 32); err == nil {
|
||||
out[uint32(g)] = struct{}{}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *nssResolver) GroupNameGID(name string) (uint32, bool) {
|
||||
g, err := shell.LookupGroupWithGetent(name)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
gid, err := strconv.ParseUint(g.Gid, 10, 32)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return uint32(gid), true
|
||||
}
|
||||
10
client/internal/ipcauth/resolver_windows.go
Normal file
10
client/internal/ipcauth/resolver_windows.go
Normal file
@@ -0,0 +1,10 @@
|
||||
//go:build windows
|
||||
|
||||
package ipcauth
|
||||
|
||||
// NewDefaultGroupResolver returns nil on Windows: group authorization uses the
|
||||
// group SIDs carried in the client token (see the Windows transport
|
||||
// credentials).
|
||||
func NewDefaultGroupResolver() GroupResolver {
|
||||
return nil
|
||||
}
|
||||
@@ -813,19 +813,14 @@ func (d *Status) SetSessionExpiresAt(deadline time.Time) {
|
||||
}
|
||||
|
||||
// GetSessionExpiresAt returns the most recently recorded SSO session deadline,
|
||||
// or the zero value when no deadline is tracked. A deadline that has already
|
||||
// slipped into the past reports as "none": once the session has expired it is
|
||||
// no longer a meaningful countdown, and the sessionwatch.Watcher does not
|
||||
// arm a timer at the deadline itself to clear it (only the two pre-expiry
|
||||
// warnings). Without this guard the UI would keep painting a stale
|
||||
// "expires in …" against a moment that has passed until the next login,
|
||||
// extend, or teardown rewrote the value.
|
||||
// or the zero value when no deadline is tracked. A deadline in the past is
|
||||
// returned as-is: it means the session has expired, and consumers (tray row,
|
||||
// CLI status) render it as "expired" rather than hiding it — masking it as
|
||||
// "none" would blank the UI at the exact moment it should say the session
|
||||
// ended.
|
||||
func (d *Status) GetSessionExpiresAt() time.Time {
|
||||
d.mux.Lock()
|
||||
defer d.mux.Unlock()
|
||||
if !d.sessionExpiresAt.IsZero() && d.sessionExpiresAt.Before(time.Now()) {
|
||||
return time.Time{}
|
||||
}
|
||||
return d.sessionExpiresAt
|
||||
}
|
||||
|
||||
|
||||
@@ -102,6 +102,11 @@ type ConfigInput struct {
|
||||
DNSLabels domain.List
|
||||
|
||||
MTU *uint16
|
||||
|
||||
// Owners replaces the profile's owner principal list when non-nil.
|
||||
// Shared replaces the profile's shared flag when non-nil.
|
||||
Owners []string
|
||||
Shared *bool
|
||||
}
|
||||
|
||||
// Config Configuration type
|
||||
@@ -184,6 +189,16 @@ type Config struct {
|
||||
|
||||
MTU uint16
|
||||
|
||||
// Owners lists the principals allowed to control this profile over the local
|
||||
// IPC, as typed strings: "uid:1000", "gid:1000", "group:netbird-admins"
|
||||
// (Unix, NSS-resolved) or "sid:S-1-5-..." (Windows user or group SID). Empty
|
||||
// with Shared=false means the profile is owned by nobody yet, until claimed
|
||||
Owners []string `json:"Owners,omitempty"`
|
||||
|
||||
// Shared, when true, lets any authenticated local caller control this profile
|
||||
// (opt-in). Takes precedence over Owners.
|
||||
Shared bool `json:"Shared,omitempty"`
|
||||
|
||||
// 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.
|
||||
@@ -642,6 +657,18 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
updated = true
|
||||
}
|
||||
|
||||
if input.Owners != nil && !slices.Equal(config.Owners, input.Owners) {
|
||||
log.Infof("updating profile owners to %v", input.Owners)
|
||||
config.Owners = input.Owners
|
||||
updated = true
|
||||
}
|
||||
|
||||
if input.Shared != nil && *input.Shared != config.Shared {
|
||||
log.Infof("updating profile shared flag to %t", *input.Shared)
|
||||
config.Shared = *input.Shared
|
||||
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())
|
||||
|
||||
@@ -296,7 +296,7 @@ func (s *ServiceManager) DefaultProfilePath() string {
|
||||
// The returned Profile carries the freshly-generated ID so callers can
|
||||
// show it to the user (and so the gRPC AddProfileResponse can include
|
||||
// it).
|
||||
func (s *ServiceManager) AddProfile(displayName, username string) (*Profile, error) {
|
||||
func (s *ServiceManager) AddProfile(displayName, username string, owners []string) (*Profile, error) {
|
||||
configDir, err := s.getConfigDir(username)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get config directory: %w", err)
|
||||
@@ -318,6 +318,9 @@ func (s *ServiceManager) AddProfile(displayName, username string) (*Profile, err
|
||||
return nil, fmt.Errorf("failed to create new config: %w", err)
|
||||
}
|
||||
cfg.Name = displayName
|
||||
// owners auto-isolates the new profile to its creator (nil = unowned, so the
|
||||
// eventual first caller claims it via trust-on-first-use).
|
||||
cfg.Owners = owners
|
||||
|
||||
if err := util.WriteJson(context.Background(), profPath, cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to write profile config: %w", err)
|
||||
|
||||
@@ -32,7 +32,7 @@ func withTestSM(t *testing.T, fn func(sm *ServiceManager, username string)) {
|
||||
|
||||
func TestServiceProfile_ExactID(t *testing.T) {
|
||||
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||
created, err := sm.AddProfile("work", username)
|
||||
created, err := sm.AddProfile("work", username, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := sm.ResolveProfile(created.ID.String(), username)
|
||||
@@ -44,7 +44,7 @@ func TestServiceProfile_ExactID(t *testing.T) {
|
||||
|
||||
func TestServiceProfile_IDPrefix(t *testing.T) {
|
||||
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||
created, err := sm.AddProfile("work", username)
|
||||
created, err := sm.AddProfile("work", username, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
prefix := created.ID[:4]
|
||||
@@ -75,7 +75,7 @@ func TestServiceProfile_AmbiguousPrefix(t *testing.T) {
|
||||
|
||||
func TestServiceProfile_ExactNameUnique(t *testing.T) {
|
||||
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||
_, err := sm.AddProfile("work", username)
|
||||
_, err := sm.AddProfile("work", username, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := sm.ResolveProfile("work", username)
|
||||
@@ -86,9 +86,9 @@ func TestServiceProfile_ExactNameUnique(t *testing.T) {
|
||||
|
||||
func TestServiceProfile_AmbiguousName(t *testing.T) {
|
||||
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||
_, err := sm.AddProfile("work", username)
|
||||
_, err := sm.AddProfile("work", username, nil)
|
||||
require.NoError(t, err)
|
||||
_, err = sm.AddProfile("work", username)
|
||||
_, err = sm.AddProfile("work", username, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = sm.ResolveProfile("work", username)
|
||||
@@ -133,10 +133,10 @@ func TestServiceProfile_LegacyFilenameCoexists(t *testing.T) {
|
||||
|
||||
func TestAddProfile_AllowsDuplicateWithFlag(t *testing.T) {
|
||||
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||
first, err := sm.AddProfile("work", username)
|
||||
first, err := sm.AddProfile("work", username, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
second, err := sm.AddProfile("work", username)
|
||||
second, err := sm.AddProfile("work", username, nil)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, first.ID, second.ID)
|
||||
assert.Equal(t, "work", second.Name)
|
||||
@@ -151,7 +151,7 @@ func TestAddProfile_RejectsInvalidNames(t *testing.T) {
|
||||
strings.Repeat("a", maxProfileNameLen+1), // too long
|
||||
}
|
||||
for _, name := range cases {
|
||||
_, err := sm.AddProfile(name, username)
|
||||
_, err := sm.AddProfile(name, username, nil)
|
||||
assert.Error(t, err, "expected error for %q", name)
|
||||
}
|
||||
})
|
||||
@@ -215,7 +215,7 @@ func TestIsValidProfileFilenameStem(t *testing.T) {
|
||||
|
||||
func TestRemoveProfile_DeletesStateFile(t *testing.T) {
|
||||
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||
created, err := sm.AddProfile("work", username)
|
||||
created, err := sm.AddProfile("work", username, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
configDir, err := sm.getConfigDir(username)
|
||||
|
||||
@@ -185,7 +185,7 @@ func (r *Route) startResolver(ctx context.Context) {
|
||||
}
|
||||
|
||||
func (r *Route) update(ctx context.Context) error {
|
||||
resolved, err := r.resolveDomains()
|
||||
resolved, err := r.resolveDomains(ctx)
|
||||
if err != nil {
|
||||
if len(resolved) == 0 {
|
||||
return fmt.Errorf("resolve domains: %w", err)
|
||||
@@ -199,9 +199,9 @@ func (r *Route) update(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Route) resolveDomains() (domainMap, error) {
|
||||
func (r *Route) resolveDomains(ctx context.Context) (domainMap, error) {
|
||||
results := make(chan resolveResult)
|
||||
go r.resolve(results)
|
||||
go r.resolve(ctx, results)
|
||||
|
||||
resolved := domainMap{}
|
||||
var merr *multierror.Error
|
||||
@@ -217,7 +217,7 @@ func (r *Route) resolveDomains() (domainMap, error) {
|
||||
return resolved, nberrors.FormatErrorOrNil(merr)
|
||||
}
|
||||
|
||||
func (r *Route) resolve(results chan resolveResult) {
|
||||
func (r *Route) resolve(ctx context.Context, results chan resolveResult) {
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, d := range r.route.Domains {
|
||||
@@ -225,10 +225,10 @@ func (r *Route) resolve(results chan resolveResult) {
|
||||
go func(domain domain.Domain) {
|
||||
defer wg.Done()
|
||||
|
||||
ips, err := r.getIPsFromResolver(domain)
|
||||
ips, err := r.getIPsFromResolver(ctx, domain)
|
||||
if err != nil {
|
||||
log.Tracef("Failed to resolve domain %s with private resolver: %v", domain.SafeString(), err)
|
||||
ips, err = net.LookupIP(domain.PunycodeString())
|
||||
ips, err = lookupHostIPs(ctx, domain)
|
||||
if err != nil {
|
||||
results <- resolveResult{domain: domain, err: fmt.Errorf("resolve d %s: %w", domain.SafeString(), err)}
|
||||
return
|
||||
@@ -364,6 +364,20 @@ func determinePrefixChanges(oldPrefixes, newPrefixes []netip.Prefix) (toAdd, toR
|
||||
return
|
||||
}
|
||||
|
||||
// lookupHostIPs resolves d via the system resolver, honoring ctx cancellation.
|
||||
func lookupHostIPs(ctx context.Context, d domain.Domain) ([]net.IP, error) {
|
||||
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, d.PunycodeString())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ips := make([]net.IP, 0, len(addrs))
|
||||
for _, addr := range addrs {
|
||||
ips = append(ips, addr.IP)
|
||||
}
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
func combinePrefixes(oldPrefixes, removedPrefixes, addedPrefixes []netip.Prefix) []netip.Prefix {
|
||||
prefixSet := make(map[netip.Prefix]struct{})
|
||||
for _, prefix := range oldPrefixes {
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
package dynamic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
)
|
||||
|
||||
func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) {
|
||||
return net.LookupIP(domain.PunycodeString())
|
||||
func (r *Route) getIPsFromResolver(ctx context.Context, domain domain.Domain) ([]net.IP, error) {
|
||||
return lookupHostIPs(ctx, domain)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package dynamic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
@@ -16,7 +17,7 @@ import (
|
||||
|
||||
const dialTimeout = 10 * time.Second
|
||||
|
||||
func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) {
|
||||
func (r *Route) getIPsFromResolver(ctx context.Context, domain domain.Domain) ([]net.IP, error) {
|
||||
privateClient, err := nbdns.GetClientPrivate(r.wgInterface, r.resolverAddr.Addr(), dialTimeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error while creating private client: %s", err)
|
||||
@@ -32,7 +33,7 @@ func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) {
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion(fqdn, qtype)
|
||||
|
||||
response, _, err := nbdns.ExchangeWithFallback(nil, privateClient, msg, r.resolverAddr.String())
|
||||
response, _, err := nbdns.ExchangeWithFallback(ctx, privateClient, msg, r.resolverAddr.String())
|
||||
if err != nil {
|
||||
if queryErr == nil {
|
||||
queryErr = fmt.Errorf("DNS query for %s (type %d) after %s: %w", domain.SafeString(), qtype, time.Since(startTime), err)
|
||||
|
||||
29
client/internal/shell/getent_cgo_unix.go
Normal file
29
client/internal/shell/getent_cgo_unix.go
Normal file
@@ -0,0 +1,29 @@
|
||||
//go:build cgo && !osusergo && !windows
|
||||
|
||||
package shell
|
||||
|
||||
import "os/user"
|
||||
|
||||
// LookupWithGetent with CGO delegates directly to os/user.Lookup.
|
||||
// When CGO is enabled, os/user uses libc (getpwnam_r) which goes through
|
||||
// the NSS stack natively. If it fails, the user truly doesn't exist and
|
||||
// getent would also fail.
|
||||
func LookupWithGetent(username string) (*user.User, error) {
|
||||
return user.Lookup(username)
|
||||
}
|
||||
|
||||
// CurrentUserWithGetent with CGO delegates directly to os/user.Current.
|
||||
func CurrentUserWithGetent() (*user.User, error) {
|
||||
return user.Current()
|
||||
}
|
||||
|
||||
// LookupGroupWithGetent returns the resolved group from either a gid or groupname.
|
||||
func LookupGroupWithGetent(name string) (*user.Group, error) {
|
||||
return user.LookupGroup(name)
|
||||
}
|
||||
|
||||
// GroupIdsWithFallback with CGO delegates directly to user.GroupIds.
|
||||
// libc's getgrouplist handles NSS groups natively.
|
||||
func GroupIdsWithFallback(u *user.User) ([]string, error) {
|
||||
return u.GroupIds()
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//go:build (!cgo || osusergo) && !windows
|
||||
|
||||
package server
|
||||
package shell
|
||||
|
||||
import (
|
||||
"os"
|
||||
@@ -10,10 +10,10 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// lookupWithGetent looks up a user by name, falling back to getent if os/user fails.
|
||||
// LookupWithGetent looks up a user by name, falling back to getent if os/user fails.
|
||||
// Without CGO, os/user only reads /etc/passwd and misses NSS-provided users.
|
||||
// getent goes through the host's NSS stack.
|
||||
func lookupWithGetent(username string) (*user.User, error) {
|
||||
func LookupWithGetent(username string) (*user.User, error) {
|
||||
u, err := user.Lookup(username)
|
||||
if err == nil {
|
||||
return u, nil
|
||||
@@ -22,7 +22,7 @@ func lookupWithGetent(username string) (*user.User, error) {
|
||||
stdErr := err
|
||||
log.Debugf("os/user.Lookup(%q) failed, trying getent: %v", username, err)
|
||||
|
||||
u, _, getentErr := runGetent(username)
|
||||
u, _, getentErr := runGetentPasswd(username)
|
||||
if getentErr != nil {
|
||||
log.Debugf("getent fallback for %q also failed: %v", username, getentErr)
|
||||
return nil, stdErr
|
||||
@@ -31,8 +31,26 @@ func lookupWithGetent(username string) (*user.User, error) {
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// currentUserWithGetent gets the current user, falling back to getent if os/user fails.
|
||||
func currentUserWithGetent() (*user.User, error) {
|
||||
// LookupGroupWithGetent returns the resolved group from either a gid or groupname,
|
||||
// falling back to getent if os/user fails (NSS groups under nocgo).
|
||||
func LookupGroupWithGetent(name string) (*user.Group, error) {
|
||||
g, err := user.LookupGroup(name)
|
||||
if err == nil {
|
||||
return g, nil
|
||||
}
|
||||
|
||||
stdErr := err
|
||||
log.Debugf("os/user.LookupGroup(%q) failed, trying getent: %v", name, err)
|
||||
g, getentErr := runGetentGroup(name)
|
||||
if getentErr != nil {
|
||||
log.Debugf("getent fallback for %q also failed: %v", name, getentErr)
|
||||
return nil, stdErr
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// CurrentUserWithGetent gets the current user, falling back to getent if os/user fails.
|
||||
func CurrentUserWithGetent() (*user.User, error) {
|
||||
u, err := user.Current()
|
||||
if err == nil {
|
||||
return u, nil
|
||||
@@ -42,7 +60,7 @@ func currentUserWithGetent() (*user.User, error) {
|
||||
uid := strconv.Itoa(os.Getuid())
|
||||
log.Debugf("os/user.Current() failed, trying getent with UID %s: %v", uid, err)
|
||||
|
||||
u, _, getentErr := runGetent(uid)
|
||||
u, _, getentErr := runGetentPasswd(uid)
|
||||
if getentErr != nil {
|
||||
return nil, stdErr
|
||||
}
|
||||
@@ -50,14 +68,14 @@ func currentUserWithGetent() (*user.User, error) {
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// groupIdsWithFallback gets group IDs for a user via the id command first,
|
||||
// GroupIdsWithFallback gets group IDs for a user via the id command first,
|
||||
// falling back to user.GroupIds().
|
||||
// NOTE: unlike lookupWithGetent/currentUserWithGetent which try stdlib first,
|
||||
// NOTE: unlike LookupWithGetent/CurrentUserWithGetent which try stdlib first,
|
||||
// this intentionally tries `id -G` first because without CGO, user.GroupIds()
|
||||
// only reads /etc/group and silently returns incomplete results for NSS users
|
||||
// (no error, just missing groups). The id command goes through NSS and returns
|
||||
// the full set.
|
||||
func groupIdsWithFallback(u *user.User) ([]string, error) {
|
||||
func GroupIdsWithFallback(u *user.User) ([]string, error) {
|
||||
ids, err := runIdGroups(u.Username)
|
||||
if err == nil {
|
||||
return ids, nil
|
||||
@@ -1,4 +1,4 @@
|
||||
package server
|
||||
package shell
|
||||
|
||||
import (
|
||||
"os/user"
|
||||
@@ -15,7 +15,7 @@ func TestLookupWithGetent_CurrentUser(t *testing.T) {
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
u, err := lookupWithGetent(current.Username)
|
||||
u, err := LookupWithGetent(current.Username)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, current.Username, u.Username)
|
||||
assert.Equal(t, current.Uid, u.Uid)
|
||||
@@ -23,7 +23,7 @@ func TestLookupWithGetent_CurrentUser(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLookupWithGetent_NonexistentUser(t *testing.T) {
|
||||
_, err := lookupWithGetent("nonexistent_user_xyzzy_12345")
|
||||
_, err := LookupWithGetent("nonexistent_user_xyzzy_12345")
|
||||
require.Error(t, err, "should fail for nonexistent user")
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ func TestCurrentUserWithGetent(t *testing.T) {
|
||||
stdUser, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
u, err := currentUserWithGetent()
|
||||
u, err := CurrentUserWithGetent()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, stdUser.Uid, u.Uid)
|
||||
assert.Equal(t, stdUser.Username, u.Username)
|
||||
@@ -41,7 +41,7 @@ func TestGroupIdsWithFallback_CurrentUser(t *testing.T) {
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
groups, err := groupIdsWithFallback(current)
|
||||
groups, err := GroupIdsWithFallback(current)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, groups, "current user should have at least one group")
|
||||
|
||||
@@ -56,7 +56,7 @@ func TestGroupIdsWithFallback_CurrentUser(t *testing.T) {
|
||||
func TestGetShellFromGetent_CurrentUser(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
// Windows stub always returns empty, which is correct
|
||||
shell := getShellFromGetent("1000")
|
||||
shell := GetShellFromGetent("1000")
|
||||
assert.Empty(t, shell, "Windows stub should return empty")
|
||||
return
|
||||
}
|
||||
@@ -65,9 +65,9 @@ func TestGetShellFromGetent_CurrentUser(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// getent may not be available on all systems (e.g., macOS without Homebrew getent)
|
||||
shell := getShellFromGetent(current.Uid)
|
||||
shell := GetShellFromGetent(current.Uid)
|
||||
if shell == "" {
|
||||
t.Log("getShellFromGetent returned empty, getent may not be available")
|
||||
t.Log("GetShellFromGetent returned empty, getent may not be available")
|
||||
return
|
||||
}
|
||||
assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell)
|
||||
@@ -78,7 +78,7 @@ func TestLookupWithGetent_RootUser(t *testing.T) {
|
||||
t.Skip("no root user on Windows")
|
||||
}
|
||||
|
||||
u, err := lookupWithGetent("root")
|
||||
u, err := LookupWithGetent("root")
|
||||
if err != nil {
|
||||
t.Skip("root user not available on this system")
|
||||
}
|
||||
@@ -86,25 +86,25 @@ func TestLookupWithGetent_RootUser(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestIntegration_FullLookupChain exercises the complete user lookup chain
|
||||
// against the real system, testing that all wrappers (lookupWithGetent,
|
||||
// currentUserWithGetent, groupIdsWithFallback, getShellFromGetent) produce
|
||||
// against the real system, testing that all wrappers (LookupWithGetent,
|
||||
// CurrentUserWithGetent, GroupIdsWithFallback, GetShellFromGetent) produce
|
||||
// consistent and correct results when composed together.
|
||||
func TestIntegration_FullLookupChain(t *testing.T) {
|
||||
// Step 1: currentUserWithGetent must resolve the running user.
|
||||
current, err := currentUserWithGetent()
|
||||
require.NoError(t, err, "currentUserWithGetent must resolve the running user")
|
||||
// Step 1: CurrentUserWithGetent must resolve the running user.
|
||||
current, err := CurrentUserWithGetent()
|
||||
require.NoError(t, err, "CurrentUserWithGetent must resolve the running user")
|
||||
require.NotEmpty(t, current.Uid)
|
||||
require.NotEmpty(t, current.Username)
|
||||
|
||||
// Step 2: lookupWithGetent by the same username must return matching identity.
|
||||
byName, err := lookupWithGetent(current.Username)
|
||||
// Step 2: LookupWithGetent by the same username must return matching identity.
|
||||
byName, err := LookupWithGetent(current.Username)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, current.Uid, byName.Uid, "lookup by name should return same UID")
|
||||
assert.Equal(t, current.Gid, byName.Gid, "lookup by name should return same GID")
|
||||
assert.Equal(t, current.HomeDir, byName.HomeDir, "lookup by name should return same home")
|
||||
|
||||
// Step 3: groupIdsWithFallback must return at least the primary GID.
|
||||
groups, err := groupIdsWithFallback(current)
|
||||
// Step 3: GroupIdsWithFallback must return at least the primary GID.
|
||||
groups, err := GroupIdsWithFallback(current)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, groups, "user must have at least one group")
|
||||
|
||||
@@ -120,10 +120,10 @@ func TestIntegration_FullLookupChain(t *testing.T) {
|
||||
}
|
||||
assert.True(t, foundPrimary, "primary GID %s should appear in supplementary groups", current.Gid)
|
||||
|
||||
// Step 4: getShellFromGetent should either return a valid shell path or empty
|
||||
// Step 4: GetShellFromGetent should either return a valid shell path or empty
|
||||
// (empty is OK when getent is not available, e.g. macOS without Homebrew getent).
|
||||
if runtime.GOOS != "windows" {
|
||||
shell := getShellFromGetent(current.Uid)
|
||||
shell := GetShellFromGetent(current.Uid)
|
||||
if shell != "" {
|
||||
assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell)
|
||||
}
|
||||
@@ -131,17 +131,17 @@ func TestIntegration_FullLookupChain(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestIntegration_LookupAndGroupsConsistency verifies that a user resolved via
|
||||
// lookupWithGetent can have their groups resolved via groupIdsWithFallback,
|
||||
// LookupWithGetent can have their groups resolved via GroupIdsWithFallback,
|
||||
// testing the handoff between the two functions as used by the SSH server.
|
||||
func TestIntegration_LookupAndGroupsConsistency(t *testing.T) {
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Simulate the SSH server flow: lookup user, then get their groups.
|
||||
resolved, err := lookupWithGetent(current.Username)
|
||||
resolved, err := LookupWithGetent(current.Username)
|
||||
require.NoError(t, err)
|
||||
|
||||
groups, err := groupIdsWithFallback(resolved)
|
||||
groups, err := GroupIdsWithFallback(resolved)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, groups, "resolved user must have groups")
|
||||
|
||||
@@ -156,7 +156,7 @@ func TestIntegration_LookupAndGroupsConsistency(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestIntegration_ShellLookupChain tests the full shell resolution chain
|
||||
// (getShellFromPasswd -> getShellFromGetent -> $SHELL -> default) on Unix.
|
||||
// (getShellFromPasswd -> GetShellFromGetent -> $SHELL -> default) on Unix.
|
||||
func TestIntegration_ShellLookupChain(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Unix shell lookup not applicable on Windows")
|
||||
@@ -165,8 +165,8 @@ func TestIntegration_ShellLookupChain(t *testing.T) {
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
// getUserShell is the top-level function used by the SSH server.
|
||||
shell := getUserShell(current.Uid)
|
||||
require.NotEmpty(t, shell, "getUserShell must always return a shell")
|
||||
// GetUserShell is the top-level function used by the SSH server.
|
||||
shell := GetUserShell(current.Uid)
|
||||
require.NotEmpty(t, shell, "GetUserShell must always return a shell")
|
||||
assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//go:build !windows
|
||||
|
||||
package server
|
||||
package shell
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -14,19 +14,26 @@ import (
|
||||
|
||||
const getentTimeout = 5 * time.Second
|
||||
|
||||
// getShellFromGetent gets a user's login shell via getent by UID.
|
||||
// GetShellFromGetent gets a user's login shell via getent by UID.
|
||||
// This is needed even with CGO because getShellFromPasswd reads /etc/passwd
|
||||
// directly and won't find NSS-provided users there.
|
||||
func getShellFromGetent(userID string) string {
|
||||
_, shell, err := runGetent(userID)
|
||||
func GetShellFromGetent(userID string) string {
|
||||
_, shell, err := runGetentPasswd(userID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return shell
|
||||
}
|
||||
|
||||
// runGetent executes `getent passwd <query>` and returns the user and login shell.
|
||||
func runGetent(query string) (*user.User, string, error) {
|
||||
// GetUserFromGetent returns the resolved user from either a uid or username,
|
||||
// going through the host's NSS stack.
|
||||
func GetUserFromGetent(query string) (*user.User, error) {
|
||||
u, _, err := runGetentPasswd(query)
|
||||
return u, err
|
||||
}
|
||||
|
||||
// runGetentPasswd executes `getent passwd <query>` and returns the user and login shell.
|
||||
func runGetentPasswd(query string) (*user.User, string, error) {
|
||||
if !validateGetentInput(query) {
|
||||
return nil, "", fmt.Errorf("invalid getent input: %q", query)
|
||||
}
|
||||
@@ -42,7 +49,24 @@ func runGetent(query string) (*user.User, string, error) {
|
||||
return parseGetentPasswd(string(out))
|
||||
}
|
||||
|
||||
// parseGetentPasswd parses getent passwd output: "name:x:uid:gid:gecos:home:shell"
|
||||
// runGetentGroup executes `getent group <query>` and returns the group.
|
||||
func runGetentGroup(query string) (*user.Group, error) {
|
||||
if !validateGetentInput(query) {
|
||||
return nil, fmt.Errorf("invalid getent input: %q", query)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), getentTimeout)
|
||||
defer cancel()
|
||||
|
||||
out, err := exec.CommandContext(ctx, "getent", "group", query).Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getent group %s: %w", query, err)
|
||||
}
|
||||
|
||||
return parseGetentGroup(string(out))
|
||||
}
|
||||
|
||||
// parseGetentPasswd parses getent passwd output: "name:x:uid:gid:gecos:home:shell".
|
||||
func parseGetentPasswd(output string) (*user.User, string, error) {
|
||||
fields := strings.SplitN(strings.TrimSpace(output), ":", 8)
|
||||
if len(fields) < 6 {
|
||||
@@ -67,6 +91,20 @@ func parseGetentPasswd(output string) (*user.User, string, error) {
|
||||
}, shell, nil
|
||||
}
|
||||
|
||||
// parseGetentGroup parses getent group output: "group:x:gid:members".
|
||||
func parseGetentGroup(output string) (*user.Group, error) {
|
||||
fields := strings.SplitN(strings.TrimSpace(output), ":", 8)
|
||||
if len(fields) < 4 {
|
||||
return nil, fmt.Errorf("unexpected getent output (need 4+ fields): %q", output)
|
||||
}
|
||||
|
||||
if fields[0] == "" || fields[2] == "" {
|
||||
return nil, fmt.Errorf("missing required fields in getent output: %q", output)
|
||||
}
|
||||
|
||||
return &user.Group{Gid: fields[2], Name: fields[0]}, nil
|
||||
}
|
||||
|
||||
// validateGetentInput checks that the input is safe to pass to getent or id.
|
||||
// Allows POSIX usernames, numeric UIDs, and common NSS extensions
|
||||
// (@ for Kerberos, $ for Samba, + for NIS compat). A leading hyphen is
|
||||
@@ -1,6 +1,6 @@
|
||||
//go:build !windows
|
||||
|
||||
package server
|
||||
package shell
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
@@ -198,7 +198,7 @@ func TestRunGetent_RootUser(t *testing.T) {
|
||||
t.Skip("getent not available on this system")
|
||||
}
|
||||
|
||||
u, shell, err := runGetent("root")
|
||||
u, shell, err := runGetentPasswd("root")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "root", u.Username)
|
||||
assert.Equal(t, "0", u.Uid)
|
||||
@@ -211,7 +211,7 @@ func TestRunGetent_ByUID(t *testing.T) {
|
||||
t.Skip("getent not available on this system")
|
||||
}
|
||||
|
||||
u, _, err := runGetent("0")
|
||||
u, _, err := runGetentPasswd("0")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "root", u.Username)
|
||||
assert.Equal(t, "0", u.Uid)
|
||||
@@ -222,15 +222,15 @@ func TestRunGetent_NonexistentUser(t *testing.T) {
|
||||
t.Skip("getent not available on this system")
|
||||
}
|
||||
|
||||
_, _, err := runGetent("nonexistent_user_xyzzy_12345")
|
||||
_, _, err := runGetentPasswd("nonexistent_user_xyzzy_12345")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRunGetent_InvalidInput(t *testing.T) {
|
||||
_, _, err := runGetent("")
|
||||
_, _, err := runGetentPasswd("")
|
||||
assert.Error(t, err)
|
||||
|
||||
_, _, err = runGetent("user\x00name")
|
||||
_, _, err = runGetentPasswd("user\x00name")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ func TestRunGetent_NotAvailable(t *testing.T) {
|
||||
t.Skip("getent is available, can't test missing case")
|
||||
}
|
||||
|
||||
_, _, err := runGetent("root")
|
||||
_, _, err := runGetentPasswd("root")
|
||||
assert.Error(t, err, "should fail when getent is not installed")
|
||||
}
|
||||
|
||||
@@ -286,7 +286,7 @@ func TestGetentResultsMatchStdlib(t *testing.T) {
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
getentUser, _, err := runGetent(current.Username)
|
||||
getentUser, _, err := runGetentPasswd(current.Username)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, current.Username, getentUser.Username, "username should match")
|
||||
@@ -303,7 +303,7 @@ func TestGetentResultsMatchStdlib_ByUID(t *testing.T) {
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
getentUser, _, err := runGetent(current.Uid)
|
||||
getentUser, _, err := runGetentPasswd(current.Uid)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, current.Username, getentUser.Username, "username should match when looked up by UID")
|
||||
@@ -359,7 +359,7 @@ func TestGetShellFromPasswd_CurrentUser(t *testing.T) {
|
||||
assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell)
|
||||
|
||||
if _, err := exec.LookPath("getent"); err == nil {
|
||||
_, getentShell, getentErr := runGetent(current.Uid)
|
||||
_, getentShell, getentErr := runGetentPasswd(current.Uid)
|
||||
if getentErr == nil && getentShell != "" {
|
||||
assert.Equal(t, getentShell, shell, "shell from /etc/passwd should match getent")
|
||||
}
|
||||
@@ -403,7 +403,7 @@ func TestGetShellFromPasswd_MatchesGetentForKnownUsers(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
|
||||
_, getentShell, err := runGetent(uid)
|
||||
_, getentShell, err := runGetentPasswd(uid)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
30
client/internal/shell/getent_windows.go
Normal file
30
client/internal/shell/getent_windows.go
Normal file
@@ -0,0 +1,30 @@
|
||||
//go:build windows
|
||||
|
||||
package shell
|
||||
|
||||
import "os/user"
|
||||
|
||||
// LookupWithGetent on Windows just delegates to os/user.Lookup.
|
||||
func LookupWithGetent(username string) (*user.User, error) {
|
||||
return user.Lookup(username)
|
||||
}
|
||||
|
||||
// CurrentUserWithGetent on Windows just delegates to os/user.Current.
|
||||
func CurrentUserWithGetent() (*user.User, error) {
|
||||
return user.Current()
|
||||
}
|
||||
|
||||
// LookupGroupWithGetent on Windows just delegates to os/user.LookupGroup.
|
||||
func LookupGroupWithGetent(name string) (*user.Group, error) {
|
||||
return user.LookupGroup(name)
|
||||
}
|
||||
|
||||
// GetShellFromGetent is a no-op on Windows.
|
||||
func GetShellFromGetent(_ string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// GroupIdsWithFallback on Windows just delegates to u.GroupIds().
|
||||
func GroupIdsWithFallback(u *user.User) ([]string, error) {
|
||||
return u.GroupIds()
|
||||
}
|
||||
@@ -1,17 +1,14 @@
|
||||
package server
|
||||
package shell
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gliderlabs/ssh"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
@@ -22,9 +19,9 @@ const (
|
||||
powershellExe = "powershell.exe"
|
||||
)
|
||||
|
||||
// getUserShell returns the appropriate shell for the given user ID
|
||||
// Handles all platform-specific logic and fallbacks consistently
|
||||
func getUserShell(userID string) string {
|
||||
// GetUserShell returns the appropriate shell for the given user ID.
|
||||
// Handles all platform-specific logic and fallbacks consistently.
|
||||
func GetUserShell(userID string) string {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
return getWindowsUserShell()
|
||||
@@ -56,7 +53,7 @@ func getUnixUserShell(userID string) string {
|
||||
return shell
|
||||
}
|
||||
|
||||
if shell := getShellFromGetent(userID); shell != "" {
|
||||
if shell := GetShellFromGetent(userID); shell != "" {
|
||||
return shell
|
||||
}
|
||||
|
||||
@@ -67,7 +64,7 @@ func getUnixUserShell(userID string) string {
|
||||
return defaultUnixShell
|
||||
}
|
||||
|
||||
// getShellFromPasswd reads the shell from /etc/passwd for the given user ID
|
||||
// getShellFromPasswd reads the shell from /etc/passwd for the given user ID.
|
||||
func getShellFromPasswd(userID string) string {
|
||||
file, err := os.Open("/etc/passwd")
|
||||
if err != nil {
|
||||
@@ -101,8 +98,8 @@ func getShellFromPasswd(userID string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// prepareUserEnv prepares environment variables for user execution
|
||||
func prepareUserEnv(user *user.User, shell string) []string {
|
||||
// PrepareUserEnv prepares environment variables for user execution.
|
||||
func PrepareUserEnv(user *user.User, shell string) []string {
|
||||
pathValue := "/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games"
|
||||
if runtime.GOOS == "windows" {
|
||||
pathValue = `C:\Windows\System32;C:\Windows;C:\Windows\System32\Wbem;C:\Windows\System32\WindowsPowerShell\v1.0`
|
||||
@@ -117,9 +114,9 @@ func prepareUserEnv(user *user.User, shell string) []string {
|
||||
}
|
||||
}
|
||||
|
||||
// acceptEnv checks if environment variable from SSH client should be accepted
|
||||
// This is a whitelist of variables that SSH clients can send to the server
|
||||
func acceptEnv(envVar string) bool {
|
||||
// AcceptEnv checks if an environment variable from an SSH client should be accepted.
|
||||
// This is a whitelist of variables that SSH clients can send to the server.
|
||||
func AcceptEnv(envVar string) bool {
|
||||
varName := envVar
|
||||
if idx := strings.Index(envVar, "="); idx != -1 {
|
||||
varName = envVar[:idx]
|
||||
@@ -156,29 +153,3 @@ func acceptEnv(envVar string) bool {
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// prepareSSHEnv prepares SSH protocol-specific environment variables
|
||||
// These variables provide information about the SSH connection itself
|
||||
func prepareSSHEnv(session ssh.Session) []string {
|
||||
remoteAddr := session.RemoteAddr()
|
||||
localAddr := session.LocalAddr()
|
||||
|
||||
remoteHost, remotePort, err := net.SplitHostPort(remoteAddr.String())
|
||||
if err != nil {
|
||||
remoteHost = remoteAddr.String()
|
||||
remotePort = "0"
|
||||
}
|
||||
|
||||
localHost, localPort, err := net.SplitHostPort(localAddr.String())
|
||||
if err != nil {
|
||||
localHost = localAddr.String()
|
||||
localPort = strconv.Itoa(InternalSSHPort)
|
||||
}
|
||||
|
||||
return []string{
|
||||
// SSH_CLIENT format: "client_ip client_port server_port"
|
||||
fmt.Sprintf("SSH_CLIENT=%s %s %s", remoteHost, remotePort, localPort),
|
||||
// SSH_CONNECTION format: "client_ip client_port server_ip server_port"
|
||||
fmt.Sprintf("SSH_CONNECTION=%s %s %s %s", remoteHost, remotePort, localHost, localPort),
|
||||
}
|
||||
}
|
||||
@@ -233,6 +233,9 @@ func (c *Client) DebugBundle(anonymize bool) (string, error) {
|
||||
deps.SyncResponse = resp
|
||||
|
||||
if e := cc.Engine(); e != nil {
|
||||
deps.RefreshStatus = func() {
|
||||
e.RunHealthProbes(context.Background(), true)
|
||||
}
|
||||
if cm := e.GetClientMetrics(); cm != nil {
|
||||
deps.ClientMetrics = cm
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -791,6 +791,78 @@ func local_request_DaemonService_GetActiveProfile_0(ctx context.Context, marshal
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func request_DaemonService_AddOwner_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq AddOwnerRequest
|
||||
metadata runtime.ServerMetadata
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
msg, err := client.AddOwner(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func local_request_DaemonService_AddOwner_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq AddOwnerRequest
|
||||
metadata runtime.ServerMetadata
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
msg, err := server.AddOwner(ctx, &protoReq)
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func request_DaemonService_ResetOwner_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq ResetOwnerRequest
|
||||
metadata runtime.ServerMetadata
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
msg, err := client.ResetOwner(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func local_request_DaemonService_ResetOwner_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq ResetOwnerRequest
|
||||
metadata runtime.ServerMetadata
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
msg, err := server.ResetOwner(ctx, &protoReq)
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func request_DaemonService_ShareProfile_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq ShareProfileRequest
|
||||
metadata runtime.ServerMetadata
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
msg, err := client.ShareProfile(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func local_request_DaemonService_ShareProfile_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq ShareProfileRequest
|
||||
metadata runtime.ServerMetadata
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
msg, err := server.ShareProfile(ctx, &protoReq)
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func request_DaemonService_Logout_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq LogoutRequest
|
||||
@@ -1730,6 +1802,66 @@ func RegisterDaemonServiceHandlerServer(ctx context.Context, mux *runtime.ServeM
|
||||
}
|
||||
forward_DaemonService_GetActiveProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_DaemonService_AddOwner_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
var stream runtime.ServerTransportStream
|
||||
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/AddOwner", runtime.WithHTTPPathPattern("/daemon.DaemonService/AddOwner"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := local_request_DaemonService_AddOwner_0(annotatedContext, inboundMarshaler, server, req, pathParams)
|
||||
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_DaemonService_AddOwner_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_DaemonService_ResetOwner_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
var stream runtime.ServerTransportStream
|
||||
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/ResetOwner", runtime.WithHTTPPathPattern("/daemon.DaemonService/ResetOwner"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := local_request_DaemonService_ResetOwner_0(annotatedContext, inboundMarshaler, server, req, pathParams)
|
||||
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_DaemonService_ResetOwner_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_DaemonService_ShareProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
var stream runtime.ServerTransportStream
|
||||
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/ShareProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/ShareProfile"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := local_request_DaemonService_ShareProfile_0(annotatedContext, inboundMarshaler, server, req, pathParams)
|
||||
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_DaemonService_ShareProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_DaemonService_Logout_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
@@ -2581,6 +2713,57 @@ func RegisterDaemonServiceHandlerClient(ctx context.Context, mux *runtime.ServeM
|
||||
}
|
||||
forward_DaemonService_GetActiveProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_DaemonService_AddOwner_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/AddOwner", runtime.WithHTTPPathPattern("/daemon.DaemonService/AddOwner"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := request_DaemonService_AddOwner_0(annotatedContext, inboundMarshaler, client, req, pathParams)
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_DaemonService_AddOwner_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_DaemonService_ResetOwner_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/ResetOwner", runtime.WithHTTPPathPattern("/daemon.DaemonService/ResetOwner"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := request_DaemonService_ResetOwner_0(annotatedContext, inboundMarshaler, client, req, pathParams)
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_DaemonService_ResetOwner_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_DaemonService_ShareProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/ShareProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/ShareProfile"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := request_DaemonService_ShareProfile_0(annotatedContext, inboundMarshaler, client, req, pathParams)
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_DaemonService_ShareProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_DaemonService_Logout_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
@@ -2855,6 +3038,9 @@ var (
|
||||
pattern_DaemonService_RemoveProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "RemoveProfile"}, ""))
|
||||
pattern_DaemonService_ListProfiles_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "ListProfiles"}, ""))
|
||||
pattern_DaemonService_GetActiveProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetActiveProfile"}, ""))
|
||||
pattern_DaemonService_AddOwner_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "AddOwner"}, ""))
|
||||
pattern_DaemonService_ResetOwner_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "ResetOwner"}, ""))
|
||||
pattern_DaemonService_ShareProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "ShareProfile"}, ""))
|
||||
pattern_DaemonService_Logout_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "Logout"}, ""))
|
||||
pattern_DaemonService_GetFeatures_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetFeatures"}, ""))
|
||||
pattern_DaemonService_TriggerUpdate_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "TriggerUpdate"}, ""))
|
||||
@@ -2904,6 +3090,9 @@ var (
|
||||
forward_DaemonService_RemoveProfile_0 = runtime.ForwardResponseMessage
|
||||
forward_DaemonService_ListProfiles_0 = runtime.ForwardResponseMessage
|
||||
forward_DaemonService_GetActiveProfile_0 = runtime.ForwardResponseMessage
|
||||
forward_DaemonService_AddOwner_0 = runtime.ForwardResponseMessage
|
||||
forward_DaemonService_ResetOwner_0 = runtime.ForwardResponseMessage
|
||||
forward_DaemonService_ShareProfile_0 = runtime.ForwardResponseMessage
|
||||
forward_DaemonService_Logout_0 = runtime.ForwardResponseMessage
|
||||
forward_DaemonService_GetFeatures_0 = runtime.ForwardResponseMessage
|
||||
forward_DaemonService_TriggerUpdate_0 = runtime.ForwardResponseMessage
|
||||
|
||||
@@ -104,6 +104,18 @@ service DaemonService {
|
||||
|
||||
rpc GetActiveProfile(GetActiveProfileRequest) returns (GetActiveProfileResponse) {}
|
||||
|
||||
// AddOwner adds a principal to the active profile's owner list. Requires the
|
||||
// caller to be privileged (root/administrator) or an existing owner.
|
||||
rpc AddOwner(AddOwnerRequest) returns (AddOwnerResponse) {}
|
||||
|
||||
// ResetOwner clears the active profile's owner list, returning it to the
|
||||
// unowned state (next caller re-claims via trust-on-first-use). Privileged only.
|
||||
rpc ResetOwner(ResetOwnerRequest) returns (ResetOwnerResponse) {}
|
||||
|
||||
// ShareProfile marks the active profile shared (any local caller) or unshared.
|
||||
// Requires the caller to be an owner or privileged.
|
||||
rpc ShareProfile(ShareProfileRequest) returns (ShareProfileResponse) {}
|
||||
|
||||
// Logout disconnects from the network and deletes the peer from the management server
|
||||
rpc Logout(LogoutRequest) returns (LogoutResponse) {}
|
||||
|
||||
@@ -270,6 +282,10 @@ message UpRequest {
|
||||
// RPC blocks until the engine is running or gives up, which is the behaviour
|
||||
// needed by the CLI.
|
||||
bool async = 4;
|
||||
|
||||
// claimOwner, when true, claims ownership of the active profile for the
|
||||
// calling user (adds their kernel principal to the profile's owner list).
|
||||
bool claimOwner = 5;
|
||||
}
|
||||
|
||||
message UpResponse {}
|
||||
@@ -774,6 +790,24 @@ message AddProfileResponse {
|
||||
string id = 1;
|
||||
}
|
||||
|
||||
message AddOwnerRequest {
|
||||
// principal is a typed owner string: "uid:1000", "gid:1000",
|
||||
// "group:netbird-admins" (Unix, NSS-resolved), or "sid:S-1-5-..." (Windows).
|
||||
string principal = 1;
|
||||
}
|
||||
|
||||
message AddOwnerResponse {}
|
||||
|
||||
message ResetOwnerRequest {}
|
||||
|
||||
message ResetOwnerResponse {}
|
||||
|
||||
message ShareProfileRequest {
|
||||
bool shared = 1;
|
||||
}
|
||||
|
||||
message ShareProfileResponse {}
|
||||
|
||||
message RenameProfileRequest {
|
||||
string username = 1;
|
||||
// handle: an exact ID, a unique ID prefix, or a unique display name.
|
||||
|
||||
@@ -51,6 +51,9 @@ const (
|
||||
DaemonService_RemoveProfile_FullMethodName = "/daemon.DaemonService/RemoveProfile"
|
||||
DaemonService_ListProfiles_FullMethodName = "/daemon.DaemonService/ListProfiles"
|
||||
DaemonService_GetActiveProfile_FullMethodName = "/daemon.DaemonService/GetActiveProfile"
|
||||
DaemonService_AddOwner_FullMethodName = "/daemon.DaemonService/AddOwner"
|
||||
DaemonService_ResetOwner_FullMethodName = "/daemon.DaemonService/ResetOwner"
|
||||
DaemonService_ShareProfile_FullMethodName = "/daemon.DaemonService/ShareProfile"
|
||||
DaemonService_Logout_FullMethodName = "/daemon.DaemonService/Logout"
|
||||
DaemonService_GetFeatures_FullMethodName = "/daemon.DaemonService/GetFeatures"
|
||||
DaemonService_TriggerUpdate_FullMethodName = "/daemon.DaemonService/TriggerUpdate"
|
||||
@@ -132,6 +135,15 @@ type DaemonServiceClient interface {
|
||||
RemoveProfile(ctx context.Context, in *RemoveProfileRequest, opts ...grpc.CallOption) (*RemoveProfileResponse, error)
|
||||
ListProfiles(ctx context.Context, in *ListProfilesRequest, opts ...grpc.CallOption) (*ListProfilesResponse, error)
|
||||
GetActiveProfile(ctx context.Context, in *GetActiveProfileRequest, opts ...grpc.CallOption) (*GetActiveProfileResponse, error)
|
||||
// AddOwner adds a principal to the active profile's owner list. Requires the
|
||||
// caller to be privileged (root/administrator) or an existing owner.
|
||||
AddOwner(ctx context.Context, in *AddOwnerRequest, opts ...grpc.CallOption) (*AddOwnerResponse, error)
|
||||
// ResetOwner clears the active profile's owner list, returning it to the
|
||||
// unowned state (next caller re-claims via trust-on-first-use). Privileged only.
|
||||
ResetOwner(ctx context.Context, in *ResetOwnerRequest, opts ...grpc.CallOption) (*ResetOwnerResponse, error)
|
||||
// ShareProfile marks the active profile shared (any local caller) or unshared.
|
||||
// Requires the caller to be an owner or privileged.
|
||||
ShareProfile(ctx context.Context, in *ShareProfileRequest, opts ...grpc.CallOption) (*ShareProfileResponse, error)
|
||||
// Logout disconnects from the network and deletes the peer from the management server
|
||||
Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*LogoutResponse, error)
|
||||
GetFeatures(ctx context.Context, in *GetFeaturesRequest, opts ...grpc.CallOption) (*GetFeaturesResponse, error)
|
||||
@@ -528,6 +540,36 @@ func (c *daemonServiceClient) GetActiveProfile(ctx context.Context, in *GetActiv
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *daemonServiceClient) AddOwner(ctx context.Context, in *AddOwnerRequest, opts ...grpc.CallOption) (*AddOwnerResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(AddOwnerResponse)
|
||||
err := c.cc.Invoke(ctx, DaemonService_AddOwner_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *daemonServiceClient) ResetOwner(ctx context.Context, in *ResetOwnerRequest, opts ...grpc.CallOption) (*ResetOwnerResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ResetOwnerResponse)
|
||||
err := c.cc.Invoke(ctx, DaemonService_ResetOwner_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *daemonServiceClient) ShareProfile(ctx context.Context, in *ShareProfileRequest, opts ...grpc.CallOption) (*ShareProfileResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ShareProfileResponse)
|
||||
err := c.cc.Invoke(ctx, DaemonService_ShareProfile_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *daemonServiceClient) Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*LogoutResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(LogoutResponse)
|
||||
@@ -742,6 +784,15 @@ type DaemonServiceServer interface {
|
||||
RemoveProfile(context.Context, *RemoveProfileRequest) (*RemoveProfileResponse, error)
|
||||
ListProfiles(context.Context, *ListProfilesRequest) (*ListProfilesResponse, error)
|
||||
GetActiveProfile(context.Context, *GetActiveProfileRequest) (*GetActiveProfileResponse, error)
|
||||
// AddOwner adds a principal to the active profile's owner list. Requires the
|
||||
// caller to be privileged (root/administrator) or an existing owner.
|
||||
AddOwner(context.Context, *AddOwnerRequest) (*AddOwnerResponse, error)
|
||||
// ResetOwner clears the active profile's owner list, returning it to the
|
||||
// unowned state (next caller re-claims via trust-on-first-use). Privileged only.
|
||||
ResetOwner(context.Context, *ResetOwnerRequest) (*ResetOwnerResponse, error)
|
||||
// ShareProfile marks the active profile shared (any local caller) or unshared.
|
||||
// Requires the caller to be an owner or privileged.
|
||||
ShareProfile(context.Context, *ShareProfileRequest) (*ShareProfileResponse, error)
|
||||
// Logout disconnects from the network and deletes the peer from the management server
|
||||
Logout(context.Context, *LogoutRequest) (*LogoutResponse, error)
|
||||
GetFeatures(context.Context, *GetFeaturesRequest) (*GetFeaturesResponse, error)
|
||||
@@ -887,6 +938,15 @@ func (UnimplementedDaemonServiceServer) ListProfiles(context.Context, *ListProfi
|
||||
func (UnimplementedDaemonServiceServer) GetActiveProfile(context.Context, *GetActiveProfileRequest) (*GetActiveProfileResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetActiveProfile not implemented")
|
||||
}
|
||||
func (UnimplementedDaemonServiceServer) AddOwner(context.Context, *AddOwnerRequest) (*AddOwnerResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method AddOwner not implemented")
|
||||
}
|
||||
func (UnimplementedDaemonServiceServer) ResetOwner(context.Context, *ResetOwnerRequest) (*ResetOwnerResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ResetOwner not implemented")
|
||||
}
|
||||
func (UnimplementedDaemonServiceServer) ShareProfile(context.Context, *ShareProfileRequest) (*ShareProfileResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ShareProfile not implemented")
|
||||
}
|
||||
func (UnimplementedDaemonServiceServer) Logout(context.Context, *LogoutRequest) (*LogoutResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method Logout not implemented")
|
||||
}
|
||||
@@ -1505,6 +1565,60 @@ func _DaemonService_GetActiveProfile_Handler(srv interface{}, ctx context.Contex
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _DaemonService_AddOwner_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(AddOwnerRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DaemonServiceServer).AddOwner(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: DaemonService_AddOwner_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DaemonServiceServer).AddOwner(ctx, req.(*AddOwnerRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _DaemonService_ResetOwner_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ResetOwnerRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DaemonServiceServer).ResetOwner(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: DaemonService_ResetOwner_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DaemonServiceServer).ResetOwner(ctx, req.(*ResetOwnerRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _DaemonService_ShareProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ShareProfileRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DaemonServiceServer).ShareProfile(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: DaemonService_ShareProfile_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DaemonServiceServer).ShareProfile(ctx, req.(*ShareProfileRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _DaemonService_Logout_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(LogoutRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@@ -1873,6 +1987,18 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "GetActiveProfile",
|
||||
Handler: _DaemonService_GetActiveProfile_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "AddOwner",
|
||||
Handler: _DaemonService_AddOwner_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ResetOwner",
|
||||
Handler: _DaemonService_ResetOwner_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ShareProfile",
|
||||
Handler: _DaemonService_ShareProfile_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "Logout",
|
||||
Handler: _DaemonService_Logout_Handler,
|
||||
|
||||
393
client/server/ownership.go
Normal file
393
client/server/ownership.go
Normal file
@@ -0,0 +1,393 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
// Verify that the daemon Server implements ipcauth.ProfilePolicy.
|
||||
var _ ipcauth.ProfilePolicy = (*Server)(nil)
|
||||
|
||||
// DaemonOwnerStore persists the daemon-wide owner set. The cmd layer implements
|
||||
// it over service.json. The interface lives here to avoid an import cycle. A nil
|
||||
// store means the daemon is unowned, so non-privileged callers are denied on the
|
||||
// default profile.
|
||||
type DaemonOwnerStore interface {
|
||||
// Load returns the persisted daemon owner principals and shared flag.
|
||||
Load() (owners []string, shared bool, err error)
|
||||
// Save persists the daemon owner principals and shared flag.
|
||||
Save(owners []string, shared bool) error
|
||||
}
|
||||
|
||||
// SetDaemonOwnerStore installs the owner persistence backend and loads the
|
||||
// current owner set into memory. Called once by cmd before serving RPCs.
|
||||
func (s *Server) SetDaemonOwnerStore(store DaemonOwnerStore) {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
s.daemonOwnerStore = store
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
owners, shared, err := store.Load()
|
||||
if err != nil {
|
||||
log.Warnf("ownership: cannot load daemon owners, treating as unowned: %v", err)
|
||||
return
|
||||
}
|
||||
s.owners = ipcauth.Ownership{Owners: owners, Shared: shared}
|
||||
log.Infof("daemon owners loaded: %d principal(s), shared=%t", len(owners), shared)
|
||||
}
|
||||
|
||||
// activeIsDefaultLocked reports whether the active profile is the shared default.
|
||||
// The default is owned daemon-wide, every other profile by its own per-profile
|
||||
// owner. Caller must hold s.mutex.
|
||||
func (s *Server) activeIsDefaultLocked() (bool, error) {
|
||||
active, err := s.profileManager.GetActiveProfileState()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("get active profile: %w", err)
|
||||
}
|
||||
return active.ID == profilemanager.DefaultProfileName, nil
|
||||
}
|
||||
|
||||
// ActiveProfileOwnership returns the ownership the interceptor gates the active
|
||||
// profile against. The default profile uses the daemon-wide owner set, every
|
||||
// other profile uses its own collision-free owner (isolated per user).
|
||||
func (s *Server) ActiveProfileOwnership() ipcauth.Ownership {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
isDefault, err := s.activeIsDefaultLocked()
|
||||
if err != nil {
|
||||
log.Warnf("ownership: cannot determine active profile, treating as unowned: %v", err)
|
||||
return ipcauth.Ownership{}
|
||||
}
|
||||
if isDefault {
|
||||
return s.owners
|
||||
}
|
||||
|
||||
cfg := s.config
|
||||
if cfg == nil {
|
||||
loaded, err := s.loadActiveProfileConfigLocked()
|
||||
if err != nil {
|
||||
log.Warnf("ownership: cannot load active profile config, treating as unowned: %v", err)
|
||||
return ipcauth.Ownership{}
|
||||
}
|
||||
cfg = loaded
|
||||
}
|
||||
return ipcauth.Ownership{Owners: cfg.Owners, Shared: cfg.Shared}
|
||||
}
|
||||
|
||||
// ClaimActiveProfileOwnerIfUnowned atomically claims the active profile for id
|
||||
// when it has no owners and is not shared (trust-on-first-use). The default
|
||||
// profile claims daemon-wide ownership via the owner store, every other profile
|
||||
// claims its own per-profile owner. Returns whether id is now an owner. Concurrent
|
||||
// first-callers are serialized by s.mutex.
|
||||
func (s *Server) ClaimActiveProfileOwnerIfUnowned(id ipcauth.Identity) (bool, error) {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
isDefault, err := s.activeIsDefaultLocked()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("determine active profile: %w", err)
|
||||
}
|
||||
if isDefault {
|
||||
return s.claimDaemonOwnerLocked(id)
|
||||
}
|
||||
|
||||
cfg := s.config
|
||||
if cfg == nil {
|
||||
loaded, err := s.loadActiveProfileConfigLocked()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("load active profile config: %w", err)
|
||||
}
|
||||
cfg = loaded
|
||||
}
|
||||
|
||||
if len(cfg.Owners) > 0 || cfg.Shared {
|
||||
return false, nil // already owned or shared
|
||||
}
|
||||
|
||||
cfg.Owners = []string{ipcauth.OwnerPrincipalForIdentity(id)}
|
||||
if err := s.persistActiveProfileConfigLocked(cfg); err != nil {
|
||||
cfg.Owners = nil // revert in-memory on persistence failure
|
||||
return false, fmt.Errorf("persist claimed ownership: %w", err)
|
||||
}
|
||||
s.config = cfg
|
||||
log.Infof("profile ownership claimed by %s (trust-on-first-use)", id)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// DaemonOwnership returns the daemon-wide owner set. It governs the owner-tier
|
||||
// RPCs (Add, Down, Status) and the default profile.
|
||||
func (s *Server) DaemonOwnership() ipcauth.Ownership {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
return s.owners
|
||||
}
|
||||
|
||||
// ClaimDaemonOwnerIfUnowned claims daemon-wide ownership for id when the daemon
|
||||
// is unowned and unshared (trust-on-first-use).
|
||||
func (s *Server) ClaimDaemonOwnerIfUnowned(id ipcauth.Identity) (bool, error) {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
return s.claimDaemonOwnerLocked(id)
|
||||
}
|
||||
|
||||
// claimDaemonOwnerLocked claims daemon-wide ownership for id when the daemon is
|
||||
// unowned and unshared, persisting via the owner store. Caller must hold s.mutex.
|
||||
func (s *Server) claimDaemonOwnerLocked(id ipcauth.Identity) (bool, error) {
|
||||
if s.daemonOwnerStore == nil {
|
||||
return false, nil // no store, cannot claim, fail closed
|
||||
}
|
||||
if len(s.owners.Owners) > 0 || s.owners.Shared {
|
||||
return false, nil // already owned or shared
|
||||
}
|
||||
principal := ipcauth.OwnerPrincipalForIdentity(id)
|
||||
if err := s.daemonOwnerStore.Save([]string{principal}, false); err != nil {
|
||||
return false, fmt.Errorf("persist daemon owner claim: %w", err)
|
||||
}
|
||||
s.owners = ipcauth.Ownership{Owners: []string{principal}}
|
||||
log.Infof("daemon ownership claimed by %s (trust-on-first-use)", id)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// addDaemonOwnerLocked adds id's principal to the daemon owner set and persists.
|
||||
// Idempotent, and a no-op for privileged callers. Caller must hold s.mutex.
|
||||
func (s *Server) addDaemonOwnerLocked(id ipcauth.Identity) error {
|
||||
if id.IsPrivileged() {
|
||||
return nil
|
||||
}
|
||||
if s.daemonOwnerStore == nil {
|
||||
return fmt.Errorf("daemon owner store unavailable")
|
||||
}
|
||||
principal := ipcauth.OwnerPrincipalForIdentity(id)
|
||||
if slices.Contains(s.owners.Owners, principal) {
|
||||
return nil
|
||||
}
|
||||
next := append(slices.Clone(s.owners.Owners), principal)
|
||||
if err := s.daemonOwnerStore.Save(next, s.owners.Shared); err != nil {
|
||||
return err
|
||||
}
|
||||
s.owners.Owners = next
|
||||
return nil
|
||||
}
|
||||
|
||||
// activeProfileConfigPathLocked resolves the active profile's config file path.
|
||||
func (s *Server) activeProfileConfigPathLocked() (string, error) {
|
||||
activeProf, err := s.profileManager.GetActiveProfileState()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get active profile: %w", err)
|
||||
}
|
||||
path, err := activeProf.FilePath()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve active profile path: %w", err)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// loadActiveProfileConfigLocked reads the active profile's config from disk.
|
||||
func (s *Server) loadActiveProfileConfigLocked() (*profilemanager.Config, error) {
|
||||
path, err := s.activeProfileConfigPathLocked()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return profilemanager.GetConfig(path)
|
||||
}
|
||||
|
||||
// persistActiveProfileConfigLocked writes cfg to the active profile's config file.
|
||||
func (s *Server) persistActiveProfileConfigLocked(cfg *profilemanager.Config) error {
|
||||
path, err := s.activeProfileConfigPathLocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return util.WriteJson(context.Background(), path, cfg)
|
||||
}
|
||||
|
||||
// claimForCallerLocked adds the caller's principal to the active profile's owner
|
||||
// set (if absent) and persists. No-op for privileged callers. For the default
|
||||
// profile it adds to the daemon-wide owners, for any other profile it adds to
|
||||
// that profile's per-profile owners. Caller must hold s.mutex.
|
||||
func (s *Server) claimForCallerLocked(id ipcauth.Identity, cfg *profilemanager.Config) error {
|
||||
if id.IsPrivileged() {
|
||||
return nil
|
||||
}
|
||||
isDefault, err := s.activeIsDefaultLocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isDefault {
|
||||
return s.addDaemonOwnerLocked(id)
|
||||
}
|
||||
principal := ipcauth.OwnerPrincipalForIdentity(id)
|
||||
if slices.Contains(cfg.Owners, principal) {
|
||||
return nil
|
||||
}
|
||||
cfg.Owners = append(cfg.Owners, principal)
|
||||
if err := s.persistActiveProfileConfigLocked(cfg); err != nil {
|
||||
cfg.Owners = cfg.Owners[:len(cfg.Owners)-1] // revert on failure
|
||||
return err
|
||||
}
|
||||
s.config = cfg
|
||||
return nil
|
||||
}
|
||||
|
||||
// authorizeTargetProfile authorizes a caller to operate on a specific target
|
||||
// profile. It MUST be called after bindCallerUsername, which enforces the legacy
|
||||
// per-username-directory guard. this layers the collision-free Owners field on
|
||||
// top of it:
|
||||
//
|
||||
// - Privileged callers (root / elevated-admin) may operate on any profile.
|
||||
// - If the target has Owners (or is Shared), they are authoritative. This
|
||||
// disambiguates users whose sanitized usernames collide.
|
||||
// - If the target is unowned (a legacy profile predating ownership), passing
|
||||
// the username guard is sufficient and then the profile is claimed.
|
||||
//
|
||||
// Caller must hold s.mutex (it may persist an ownership claim).
|
||||
func (s *Server) authorizeTargetProfile(ctx context.Context, target *profilemanager.Profile, claim bool) error {
|
||||
id, ok := ipcauth.IdentityFromContext(ctx)
|
||||
if !ok {
|
||||
return gstatus.Error(codes.PermissionDenied, "caller identity could not be verified")
|
||||
}
|
||||
if id.IsPrivileged() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// The default profile is governed by the daemon-wide owners (all owners may
|
||||
// use it), not a per-profile owner. Authorize against s.owners and never stamp.
|
||||
if target.ID == profilemanager.DefaultProfileName {
|
||||
if ipcauth.Authorize(s.owners, id, s.groupResolver) {
|
||||
return nil
|
||||
}
|
||||
return gstatus.Errorf(codes.PermissionDenied,
|
||||
"not authorized to use the default profile (caller %s is not a daemon owner)", id)
|
||||
}
|
||||
|
||||
path, err := target.FilePath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve target profile path: %w", err)
|
||||
}
|
||||
cfg, err := profilemanager.GetConfig(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load target profile config: %w", err)
|
||||
}
|
||||
|
||||
ownership := ipcauth.Ownership{Owners: cfg.Owners, Shared: cfg.Shared}
|
||||
|
||||
// Owned or shared: the Owners field is authoritative (collision-free).
|
||||
if len(ownership.Owners) > 0 || ownership.Shared {
|
||||
if ipcauth.Authorize(ownership, id, s.groupResolver) {
|
||||
return nil
|
||||
}
|
||||
return gstatus.Errorf(codes.PermissionDenied,
|
||||
"not authorized to operate on profile %q (owned by another principal)", target.Name)
|
||||
}
|
||||
|
||||
// Unowned legacy profile: the username guard authorizes. Stamp the caller
|
||||
// as owner so future access is collision-free.
|
||||
if claim {
|
||||
principal := ipcauth.OwnerPrincipalForIdentity(id)
|
||||
cfg.Owners = []string{principal}
|
||||
if err := util.WriteJson(context.Background(), path, cfg); err != nil {
|
||||
return fmt.Errorf("persist profile ownership claim: %w", err)
|
||||
}
|
||||
log.Infof("profile %q (%s) claimed by %s on first access (trust-on-first-use)", target.Name, target.ID, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// requireDaemonOwnerLocked fails closed unless the caller is a daemon owner or
|
||||
// privileged. Defense in depth for the owner-set mutations, which the interceptor
|
||||
// owner tier already gates. Caller must hold s.mutex.
|
||||
func (s *Server) requireDaemonOwnerLocked(ctx context.Context) error {
|
||||
id, ok := ipcauth.IdentityFromContext(ctx)
|
||||
if !ok {
|
||||
return gstatus.Error(codes.PermissionDenied, "caller identity could not be verified")
|
||||
}
|
||||
if id.IsPrivileged() || ipcauth.Authorize(s.owners, id, s.groupResolver) {
|
||||
return nil
|
||||
}
|
||||
return gstatus.Error(codes.PermissionDenied, "not authorized: managing daemon owners requires a daemon owner or root/administrator")
|
||||
}
|
||||
|
||||
// AddOwner adds a principal to the daemon-wide owner set. The owner tier gates
|
||||
// this, the handler re-checks (defense in depth), validates, and persists.
|
||||
func (s *Server) AddOwner(ctx context.Context, msg *proto.AddOwnerRequest) (*proto.AddOwnerResponse, error) {
|
||||
principal := msg.GetPrincipal()
|
||||
if _, ok := ipcauth.ParsePrincipal(principal); !ok {
|
||||
return nil, gstatus.Errorf(codes.InvalidArgument, "invalid owner principal %q (expected uid:/gid:/group:/sid:)", principal)
|
||||
}
|
||||
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
if err := s.requireDaemonOwnerLocked(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.daemonOwnerStore == nil {
|
||||
return nil, gstatus.Error(codes.Unavailable, "daemon owner store unavailable")
|
||||
}
|
||||
if slices.Contains(s.owners.Owners, principal) {
|
||||
return &proto.AddOwnerResponse{}, nil
|
||||
}
|
||||
next := append(slices.Clone(s.owners.Owners), principal)
|
||||
if err := s.daemonOwnerStore.Save(next, s.owners.Shared); err != nil {
|
||||
return nil, fmt.Errorf("persist owner: %w", err)
|
||||
}
|
||||
s.owners.Owners = next
|
||||
log.Infof("added daemon owner %q", principal)
|
||||
return &proto.AddOwnerResponse{}, nil
|
||||
}
|
||||
|
||||
// ResetOwner clears the daemon-wide owner set (and shared flag), returning the
|
||||
// daemon to the unowned state so the next caller re-claims via trust-on-first-use.
|
||||
// Privileged-only, so co-owners cannot evict each other.
|
||||
func (s *Server) ResetOwner(ctx context.Context, _ *proto.ResetOwnerRequest) (*proto.ResetOwnerResponse, error) {
|
||||
id, ok := ipcauth.IdentityFromContext(ctx)
|
||||
if !ok || !id.IsPrivileged() {
|
||||
return nil, gstatus.Error(codes.PermissionDenied, "reset-owner requires root/administrator")
|
||||
}
|
||||
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
if s.daemonOwnerStore == nil {
|
||||
return nil, gstatus.Error(codes.Unavailable, "daemon owner store unavailable")
|
||||
}
|
||||
if err := s.daemonOwnerStore.Save(nil, false); err != nil {
|
||||
return nil, fmt.Errorf("persist owner reset: %w", err)
|
||||
}
|
||||
s.owners = ipcauth.Ownership{}
|
||||
log.Infof("daemon owner list reset, next caller will re-claim (trust-on-first-use)")
|
||||
return &proto.ResetOwnerResponse{}, nil
|
||||
}
|
||||
|
||||
// ShareProfile marks the daemon shared or unshared. When shared, any authenticated
|
||||
// local caller may control the daemon and its default profile. The owner tier
|
||||
// gates this, the handler re-checks (defense in depth).
|
||||
func (s *Server) ShareProfile(ctx context.Context, msg *proto.ShareProfileRequest) (*proto.ShareProfileResponse, error) {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
if err := s.requireDaemonOwnerLocked(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.daemonOwnerStore == nil {
|
||||
return nil, gstatus.Error(codes.Unavailable, "daemon owner store unavailable")
|
||||
}
|
||||
if err := s.daemonOwnerStore.Save(s.owners.Owners, msg.GetShared()); err != nil {
|
||||
return nil, fmt.Errorf("persist shared flag: %w", err)
|
||||
}
|
||||
s.owners.Shared = msg.GetShared()
|
||||
log.Infof("daemon shared flag set to %t", msg.GetShared())
|
||||
return &proto.ShareProfileResponse{}, nil
|
||||
}
|
||||
210
client/server/ownership_test.go
Normal file
210
client/server/ownership_test.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
// fakeOwnerStore is an in-memory server.DaemonOwnerStore for tests.
|
||||
type fakeOwnerStore struct {
|
||||
owners []string
|
||||
shared bool
|
||||
}
|
||||
|
||||
func (f *fakeOwnerStore) Load() ([]string, bool, error) { return f.owners, f.shared, nil }
|
||||
func (f *fakeOwnerStore) Save(o []string, s bool) error { f.owners, f.shared = o, s; return nil }
|
||||
|
||||
// useTempProfileDirs points the profilemanager globals at a temp dir so
|
||||
// GetActiveProfileState resolves to the default profile without touching /etc.
|
||||
func useTempProfileDirs(t *testing.T) {
|
||||
t.Helper()
|
||||
tempDir := t.TempDir()
|
||||
origDir := profilemanager.DefaultConfigPathDir
|
||||
origActive := profilemanager.ActiveProfileStatePath
|
||||
origDefault := profilemanager.DefaultConfigPath
|
||||
profilemanager.ConfigDirOverride = tempDir
|
||||
profilemanager.DefaultConfigPathDir = tempDir
|
||||
profilemanager.ActiveProfileStatePath = filepath.Join(tempDir, "active_profile.json")
|
||||
profilemanager.DefaultConfigPath = filepath.Join(tempDir, "default.json")
|
||||
t.Cleanup(func() {
|
||||
profilemanager.DefaultConfigPathDir = origDir
|
||||
profilemanager.ActiveProfileStatePath = origActive
|
||||
profilemanager.DefaultConfigPath = origDefault
|
||||
profilemanager.ConfigDirOverride = ""
|
||||
})
|
||||
}
|
||||
|
||||
// TestDaemonOwnerPolicyDefaultProfile exercises the daemon-wide owner branch that
|
||||
// governs the default profile: TOFU claim, add and reset, all via the store.
|
||||
func TestDaemonOwnerPolicyDefaultProfile(t *testing.T) {
|
||||
useTempProfileDirs(t)
|
||||
|
||||
store := &fakeOwnerStore{}
|
||||
s := &Server{profileManager: &profilemanager.ServiceManager{}, groupResolver: ipcauth.NewDefaultGroupResolver()}
|
||||
s.SetDaemonOwnerStore(store)
|
||||
|
||||
// Active profile is the default, daemon is unowned to start.
|
||||
o := s.ActiveProfileOwnership()
|
||||
assert.Empty(t, o.Owners)
|
||||
assert.False(t, o.Shared)
|
||||
|
||||
// Trust-on-first-use: the first caller claims daemon ownership, persisted.
|
||||
claimed, err := s.ClaimActiveProfileOwnerIfUnowned(ipcauth.Identity{UID: 1000})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, claimed)
|
||||
assert.Equal(t, []string{"uid:1000"}, store.owners)
|
||||
assert.Equal(t, []string{"uid:1000"}, s.ActiveProfileOwnership().Owners)
|
||||
|
||||
// A second, different caller does not re-claim an owned daemon.
|
||||
claimed, err = s.ClaimActiveProfileOwnerIfUnowned(ipcauth.Identity{UID: 1001})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, claimed)
|
||||
|
||||
// AddOwner appends a daemon-wide principal (persisted). The caller must be a
|
||||
// daemon owner: uid:1000 claimed ownership above.
|
||||
_, err = s.AddOwner(ctxWithIdentity(ipcauth.Identity{UID: 1000}), &proto.AddOwnerRequest{Principal: "uid:1001"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"uid:1000", "uid:1001"}, store.owners)
|
||||
|
||||
// ResetOwner (privileged) clears the daemon owner set.
|
||||
_, err = s.ResetOwner(ctxWithIdentity(ipcauth.Identity{UID: 0}), &proto.ResetOwnerRequest{})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, store.owners)
|
||||
assert.False(t, store.shared)
|
||||
}
|
||||
|
||||
// TestOwnerMutationsRequireDaemonOwner checks the handler defense-in-depth: a
|
||||
// caller who is neither a daemon owner nor privileged is denied at the handler.
|
||||
func TestOwnerMutationsRequireDaemonOwner(t *testing.T) {
|
||||
store := &fakeOwnerStore{owners: []string{"uid:1000"}}
|
||||
s := &Server{groupResolver: ipcauth.NewDefaultGroupResolver()}
|
||||
s.SetDaemonOwnerStore(store) // loads {uid:1000} into s.owners
|
||||
|
||||
owner := ctxWithIdentity(ipcauth.Identity{UID: 1000})
|
||||
nonOwner := ctxWithIdentity(ipcauth.Identity{UID: 2000})
|
||||
root := ctxWithIdentity(ipcauth.Identity{UID: 0})
|
||||
|
||||
t.Run("AddOwner denied for non-daemon-owner", func(t *testing.T) {
|
||||
_, err := s.AddOwner(nonOwner, &proto.AddOwnerRequest{Principal: "uid:2000"})
|
||||
assert.Equal(t, codes.PermissionDenied, gstatus.Code(err))
|
||||
assert.Equal(t, []string{"uid:1000"}, store.owners, "owner set must be unchanged")
|
||||
})
|
||||
|
||||
t.Run("ShareProfile denied for non-daemon-owner", func(t *testing.T) {
|
||||
_, err := s.ShareProfile(nonOwner, &proto.ShareProfileRequest{Shared: true})
|
||||
assert.Equal(t, codes.PermissionDenied, gstatus.Code(err))
|
||||
assert.False(t, store.shared, "shared flag must be unchanged")
|
||||
})
|
||||
|
||||
t.Run("AddOwner allowed for daemon owner", func(t *testing.T) {
|
||||
_, err := s.AddOwner(owner, &proto.AddOwnerRequest{Principal: "uid:2000"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"uid:1000", "uid:2000"}, store.owners)
|
||||
})
|
||||
|
||||
t.Run("ShareProfile allowed for root", func(t *testing.T) {
|
||||
_, err := s.ShareProfile(root, &proto.ShareProfileRequest{Shared: true})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, store.shared)
|
||||
})
|
||||
}
|
||||
|
||||
// TestDaemonOwnerAllOwnersUseDefault verifies every daemon owner is authorized
|
||||
// for the default profile, while a non-owner is denied.
|
||||
func TestDaemonOwnerAllOwnersUseDefault(t *testing.T) {
|
||||
s := &Server{groupResolver: ipcauth.NewDefaultGroupResolver()}
|
||||
s.owners = ipcauth.Ownership{Owners: []string{"uid:1000", "uid:1001"}}
|
||||
|
||||
deflt := &profilemanager.Profile{ID: profilemanager.ID(profilemanager.DefaultProfileName), Name: "default"}
|
||||
|
||||
// Both owners may use the default profile.
|
||||
require.NoError(t, s.authorizeTargetProfile(ctxWithIdentity(ipcauth.Identity{UID: 1000}), deflt, true))
|
||||
require.NoError(t, s.authorizeTargetProfile(ctxWithIdentity(ipcauth.Identity{UID: 1001}), deflt, true))
|
||||
|
||||
// A non-owner is denied the default profile.
|
||||
err := s.authorizeTargetProfile(ctxWithIdentity(ipcauth.Identity{UID: 2000}), deflt, true)
|
||||
assert.Equal(t, codes.PermissionDenied, gstatus.Code(err))
|
||||
}
|
||||
|
||||
// writeTargetProfile writes a profile JSON with the given ownership and returns
|
||||
// a Profile handle pointing at it (Path set, so FilePath() resolves directly).
|
||||
func writeTargetProfile(t *testing.T, dir, id string, owners []string, shared bool) *profilemanager.Profile {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, id+".json")
|
||||
cfg := &profilemanager.Config{Owners: owners, Shared: shared}
|
||||
require.NoError(t, util.WriteJson(context.Background(), path, cfg))
|
||||
return &profilemanager.Profile{ID: profilemanager.ID(id), Name: id, Path: path}
|
||||
}
|
||||
|
||||
func readOwners(t *testing.T, path string) ([]string, bool) {
|
||||
t.Helper()
|
||||
cfg, err := profilemanager.GetConfig(path)
|
||||
require.NoError(t, err)
|
||||
return cfg.Owners, cfg.Shared
|
||||
}
|
||||
|
||||
func TestAuthorizeTargetProfile(t *testing.T) {
|
||||
s := &Server{groupResolver: ipcauth.NewDefaultGroupResolver()}
|
||||
owner := ipcauth.Identity{UID: 1000}
|
||||
other := ipcauth.Identity{UID: 1001}
|
||||
root := ipcauth.Identity{UID: 0}
|
||||
|
||||
t.Run("no identity denies", func(t *testing.T) {
|
||||
p := writeTargetProfile(t, t.TempDir(), "p", []string{"uid:1000"}, false)
|
||||
err := s.authorizeTargetProfile(context.Background(), p, true)
|
||||
assert.Equal(t, codes.PermissionDenied, gstatus.Code(err))
|
||||
})
|
||||
|
||||
t.Run("privileged allowed on another's profile", func(t *testing.T) {
|
||||
p := writeTargetProfile(t, t.TempDir(), "p", []string{"uid:1000"}, false)
|
||||
assert.NoError(t, s.authorizeTargetProfile(ctxWithIdentity(root), p, true))
|
||||
})
|
||||
|
||||
t.Run("owner allowed", func(t *testing.T) {
|
||||
p := writeTargetProfile(t, t.TempDir(), "p", []string{"uid:1000"}, false)
|
||||
assert.NoError(t, s.authorizeTargetProfile(ctxWithIdentity(owner), p, true))
|
||||
})
|
||||
|
||||
t.Run("non-owner denied", func(t *testing.T) {
|
||||
p := writeTargetProfile(t, t.TempDir(), "p", []string{"uid:1000"}, false)
|
||||
err := s.authorizeTargetProfile(ctxWithIdentity(other), p, true)
|
||||
assert.Equal(t, codes.PermissionDenied, gstatus.Code(err))
|
||||
})
|
||||
|
||||
t.Run("shared allows any caller", func(t *testing.T) {
|
||||
p := writeTargetProfile(t, t.TempDir(), "p", nil, true)
|
||||
assert.NoError(t, s.authorizeTargetProfile(ctxWithIdentity(other), p, true))
|
||||
})
|
||||
|
||||
t.Run("unowned claim stamps owner", func(t *testing.T) {
|
||||
p := writeTargetProfile(t, t.TempDir(), "p", nil, false)
|
||||
require.NoError(t, s.authorizeTargetProfile(ctxWithIdentity(other), p, true))
|
||||
|
||||
owners, shared := readOwners(t, p.Path)
|
||||
assert.Equal(t, []string{"uid:1001"}, owners)
|
||||
assert.False(t, shared)
|
||||
|
||||
// A different caller is now locked out of the claimed profile.
|
||||
err := s.authorizeTargetProfile(ctxWithIdentity(owner), p, true)
|
||||
assert.Equal(t, codes.PermissionDenied, gstatus.Code(err))
|
||||
})
|
||||
|
||||
t.Run("unowned without claim leaves profile unowned", func(t *testing.T) {
|
||||
p := writeTargetProfile(t, t.TempDir(), "p", nil, false)
|
||||
require.NoError(t, s.authorizeTargetProfile(ctxWithIdentity(other), p, false))
|
||||
|
||||
owners, _ := readOwners(t, p.Path)
|
||||
assert.Empty(t, owners)
|
||||
})
|
||||
}
|
||||
51
client/server/profile_authz.go
Normal file
51
client/server/profile_authz.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
)
|
||||
|
||||
// bindCallerUsername enforces that a non-privileged caller may only operate on
|
||||
// its OWN user's profiles. This binds the client-supplied gRPC field to the
|
||||
// caller's kernel identity.
|
||||
// Privileged callers (root / elevated-admin) may manage any user's profiles.
|
||||
func (s *Server) bindCallerUsername(ctx context.Context, requested string) error {
|
||||
if requested == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
id, ok := ipcauth.IdentityFromContext(ctx)
|
||||
if !ok {
|
||||
return gstatus.Error(codes.PermissionDenied, "caller identity could not be verified")
|
||||
}
|
||||
if id.IsPrivileged() {
|
||||
return nil
|
||||
}
|
||||
|
||||
caller, err := usernameForIdentity(id)
|
||||
if err != nil {
|
||||
log.Warnf("profile authz: resolve caller username for %s: %v", id, err)
|
||||
return gstatus.Error(codes.PermissionDenied, "could not resolve caller identity to a username")
|
||||
}
|
||||
if !usernamesEqual(requested, caller) {
|
||||
return gstatus.Errorf(codes.PermissionDenied,
|
||||
"not authorized to operate on another user's profiles (caller %q requested %q)", caller, requested)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// usernamesEqual compares usernames case-insensitively on Windows (domain
|
||||
// accounts) and exactly on Unix.
|
||||
func usernamesEqual(a, b string) bool {
|
||||
if runtime.GOOS == "windows" {
|
||||
return strings.EqualFold(a, b)
|
||||
}
|
||||
return a == b
|
||||
}
|
||||
20
client/server/profile_authz_unix.go
Normal file
20
client/server/profile_authz_unix.go
Normal file
@@ -0,0 +1,20 @@
|
||||
//go:build !windows
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/internal/shell"
|
||||
)
|
||||
|
||||
// usernameForIdentity resolves a Unix caller's UID to its username via NSS
|
||||
// (getent), so LDAP/AD users resolve correctly under CGO_ENABLED=0.
|
||||
func usernameForIdentity(id ipcauth.Identity) (string, error) {
|
||||
u, err := shell.GetUserFromGetent(strconv.FormatUint(uint64(id.UID), 10))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return u.Username, nil
|
||||
}
|
||||
18
client/server/profile_authz_windows.go
Normal file
18
client/server/profile_authz_windows.go
Normal file
@@ -0,0 +1,18 @@
|
||||
//go:build windows
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"os/user"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
)
|
||||
|
||||
// usernameForIdentity resolves a Windows caller's SID to its account name.
|
||||
func usernameForIdentity(id ipcauth.Identity) (string, error) {
|
||||
u, err := user.LookupId(id.SID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return u.Username, nil
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/auth"
|
||||
"github.com/netbirdio/netbird/client/internal/expose"
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
sleephandler "github.com/netbirdio/netbird/client/internal/sleep/handler"
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
@@ -126,6 +127,21 @@ type Server struct {
|
||||
updateManager *updater.Manager
|
||||
|
||||
jwtCache *jwtCache
|
||||
|
||||
// groupResolver resolves a Unix caller's supplementary group membership
|
||||
// (NSS/getent) so gid:/group: owner principals authorize correctly. Nil on
|
||||
// Windows, where SID group membership travels in the identity itself. ipcauth
|
||||
// treats a nil resolver as "no group matching".
|
||||
groupResolver ipcauth.GroupResolver
|
||||
|
||||
// owners is the in-memory daemon-wide owner set, loaded from daemonOwnerStore.
|
||||
// It governs the default profile and daemon-wide access. Non-default profiles
|
||||
// stay isolated per user via their own per-profile owner. Guarded by s.mutex.
|
||||
owners ipcauth.Ownership
|
||||
// daemonOwnerStore persists owners to service.json, injected by cmd via
|
||||
// SetDaemonOwnerStore. Nil before injection or in tests, where the daemon is
|
||||
// treated as unowned and non-privileged callers are denied on the default.
|
||||
daemonOwnerStore DaemonOwnerStore
|
||||
}
|
||||
|
||||
type oauthAuthFlow struct {
|
||||
@@ -150,6 +166,7 @@ func New(ctx context.Context, logFile string, configFile string, profilesDisable
|
||||
jwtCache: newJWTCache(),
|
||||
extendAuthSessionFlow: auth.NewPendingFlow(),
|
||||
probeThrottle: newProbeThrottle(probeThreshold),
|
||||
groupResolver: ipcauth.NewDefaultGroupResolver(),
|
||||
}
|
||||
agent := &serverAgent{s}
|
||||
s.sleepHandler = sleephandler.New(agent)
|
||||
@@ -402,6 +419,11 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
|
||||
}
|
||||
}
|
||||
|
||||
// SSH root login / disabling SSH auth requires a privileged caller.
|
||||
if err := requirePrivilegedForDangerousSSH(callerCtx, msg.EnableSSHRoot, msg.DisableSSHAuth); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -537,6 +559,11 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
||||
}
|
||||
}
|
||||
|
||||
// SSH root login / disabling SSH auth requires a privileged caller.
|
||||
if err := requirePrivilegedForDangerousSSH(callerCtx, msg.EnableSSHRoot, msg.DisableSSHAuth); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mutex.Lock()
|
||||
if s.actCancel != nil {
|
||||
s.actCancel()
|
||||
@@ -943,6 +970,17 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
|
||||
}
|
||||
s.config = config
|
||||
|
||||
// --owner: explicitly claim ownership of the active profile for the caller.
|
||||
if msg != nil && msg.GetClaimOwner() {
|
||||
if id, ok := ipcauth.IdentityFromContext(callerCtx); ok {
|
||||
if err := s.claimForCallerLocked(id, s.config); err != nil {
|
||||
s.mutex.Unlock()
|
||||
log.Errorf("failed to claim profile ownership: %v", err)
|
||||
return nil, fmt.Errorf("claim owner: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.statusRecorder.UpdateManagementAddress(s.config.ManagementURL.String())
|
||||
s.statusRecorder.UpdateRosenpass(s.config.RosenpassEnabled, s.config.RosenpassPermissive)
|
||||
|
||||
@@ -1048,6 +1086,29 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi
|
||||
}
|
||||
|
||||
if msg != nil && msg.ProfileName != nil {
|
||||
targetUsername := ""
|
||||
if msg.Username != nil {
|
||||
targetUsername = *msg.Username
|
||||
}
|
||||
// The interceptor already gated this against the CURRENT active profile;
|
||||
// also bind the TARGET profile's username so a caller can't switch into
|
||||
// another user's profile.
|
||||
if err := s.bindCallerUsername(callerCtx, targetUsername); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Authorize against the target profile's owners, claiming an unowned
|
||||
// legacy target for the caller.
|
||||
resolveUsername := targetUsername
|
||||
if *msg.ProfileName == profilemanager.DefaultProfileName {
|
||||
resolveUsername = ""
|
||||
}
|
||||
resolvedTarget, err := s.resolveProfileHandle(*msg.ProfileName, resolveUsername)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.authorizeTargetProfile(callerCtx, resolvedTarget, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil {
|
||||
log.Errorf("failed to switch profile: %v", err)
|
||||
return nil, err
|
||||
@@ -1081,7 +1142,10 @@ func (s *Server) Down(ctx context.Context, _ *proto.DownRequest) (*proto.DownRes
|
||||
|
||||
if err := s.cleanupConnection(); err != nil {
|
||||
s.mutex.Unlock()
|
||||
// todo review to update the status in case any type of error
|
||||
if errors.Is(err, ErrServiceNotUp) {
|
||||
log.Debugf("Down called while service not up: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
log.Errorf("failed to shut down properly: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
@@ -1154,7 +1218,7 @@ func (s *Server) cleanupConnection() error {
|
||||
// making the run loop the sole owner of engine shutdown.
|
||||
if engine != nil {
|
||||
if err := engine.Stop(); err != nil {
|
||||
return err
|
||||
log.Errorf("failed to stop engine during cleanup: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2002,7 +2066,18 @@ func (s *Server) AddProfile(ctx context.Context, msg *proto.AddProfileRequest) (
|
||||
return nil, gstatus.Errorf(codes.InvalidArgument, "profile name and username must be provided")
|
||||
}
|
||||
|
||||
created, err := s.profileManager.AddProfile(msg.ProfileName, msg.Username)
|
||||
if err := s.bindCallerUsername(ctx, msg.Username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Auto-isolate the new profile to its creator. When root/admin creates it we
|
||||
// leave it unowned so the intended user claims it via trust-on-first-use.
|
||||
var initialOwners []string
|
||||
if id, ok := ipcauth.IdentityFromContext(ctx); ok && !id.IsPrivileged() {
|
||||
initialOwners = []string{ipcauth.OwnerPrincipalForIdentity(id)}
|
||||
}
|
||||
|
||||
created, err := s.profileManager.AddProfile(msg.ProfileName, msg.Username, initialOwners)
|
||||
if err != nil {
|
||||
log.Errorf("failed to create profile: %v", err)
|
||||
return nil, fmt.Errorf("failed to create profile: %w", err)
|
||||
@@ -2025,11 +2100,19 @@ func (s *Server) RenameProfile(ctx context.Context, msg *proto.RenameProfileRequ
|
||||
return nil, gstatus.Errorf(codes.InvalidArgument, "profile name, username and new profile name must be provided")
|
||||
}
|
||||
|
||||
if err := s.bindCallerUsername(ctx, msg.Username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resolved, err := s.resolveProfileHandle(msg.Handle, msg.Username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.authorizeTargetProfile(ctx, resolved, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = s.profileManager.RenameProfile(resolved.ID, msg.Username, msg.NewProfileName)
|
||||
if err != nil {
|
||||
log.Errorf("failed to rename profile: %v", err)
|
||||
@@ -2054,11 +2137,20 @@ func (s *Server) RemoveProfile(ctx context.Context, msg *proto.RemoveProfileRequ
|
||||
return nil, gstatus.Errorf(codes.InvalidArgument, "profile name must be provided")
|
||||
}
|
||||
|
||||
if err := s.bindCallerUsername(ctx, msg.Username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resolved, err := s.resolveProfileHandle(msg.ProfileName, msg.Username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// claim=false: don't stamp ownership on a profile we're about to delete.
|
||||
if err := s.authorizeTargetProfile(ctx, resolved, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.logoutFromProfile(ctx, resolved); err != nil {
|
||||
log.Warnf("failed to logout from profile %s before removal: %v", resolved.ID, err)
|
||||
}
|
||||
@@ -2122,6 +2214,10 @@ func (s *Server) ListProfiles(ctx context.Context, msg *proto.ListProfilesReques
|
||||
return nil, gstatus.Errorf(codes.InvalidArgument, "username must be provided")
|
||||
}
|
||||
|
||||
if err := s.bindCallerUsername(ctx, msg.Username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
profiles, err := s.profileManager.ListProfiles(msg.Username)
|
||||
if err != nil {
|
||||
log.Errorf("failed to list profiles: %v", err)
|
||||
|
||||
36
client/server/ssh_gate.go
Normal file
36
client/server/ssh_gate.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
)
|
||||
|
||||
// requirePrivilegedForDangerousSSH enforces admin permissions for SSH config.
|
||||
// Enabling SSH root login or disabling SSH authentication turns the
|
||||
// root/LocalSystem daemon's SSH server into an unauthenticated root shell
|
||||
// (local-to-remote-root escalation), so only a privileged caller may set
|
||||
// these flags to true over the local IPC.
|
||||
func requirePrivilegedForDangerousSSH(ctx context.Context, enableSSHRoot, disableSSHAuth *bool) error {
|
||||
dangerous := (enableSSHRoot != nil && *enableSSHRoot) || (disableSSHAuth != nil && *disableSSHAuth)
|
||||
if !dangerous {
|
||||
return nil
|
||||
}
|
||||
|
||||
id, ok := ipcauth.IdentityFromContext(ctx)
|
||||
if !ok {
|
||||
log.Warnf("denying SSH root/no-auth config change: caller identity unavailable on this control channel")
|
||||
return gstatus.Error(codes.PermissionDenied,
|
||||
"enabling SSH root login or disabling SSH authentication requires root/administrator, but the caller identity could not be verified on this daemon control channel")
|
||||
}
|
||||
if !id.IsPrivileged() {
|
||||
log.Warnf("denying SSH root/no-auth config change from non-privileged caller %s", id)
|
||||
return gstatus.Errorf(codes.PermissionDenied,
|
||||
"enabling SSH root login or disabling SSH authentication requires root/administrator (caller %s is not privileged); rerun as root/administrator", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
52
client/server/ssh_gate_test.go
Normal file
52
client/server/ssh_gate_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/peer"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
)
|
||||
|
||||
func ctxWithIdentity(id ipcauth.Identity) context.Context {
|
||||
return peer.NewContext(context.Background(), &peer.Peer{AuthInfo: ipcauth.AuthInfo{Identity: id}})
|
||||
}
|
||||
|
||||
func boolPtr(b bool) *bool { return &b }
|
||||
|
||||
func TestRequirePrivilegedForDangerousSSH(t *testing.T) {
|
||||
root := ipcauth.Identity{UID: 0}
|
||||
user := ipcauth.Identity{UID: 1000}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
enableSSHRoot *bool
|
||||
disableSSHAuth *bool
|
||||
wantDenied bool
|
||||
}{
|
||||
{"no flags, no identity", context.Background(), nil, nil, false},
|
||||
{"flags false, non-priv", ctxWithIdentity(user), boolPtr(false), boolPtr(false), false},
|
||||
{"enableSSHRoot by root", ctxWithIdentity(root), boolPtr(true), nil, false},
|
||||
{"enableSSHRoot by non-priv", ctxWithIdentity(user), boolPtr(true), nil, true},
|
||||
{"disableSSHAuth by non-priv", ctxWithIdentity(user), nil, boolPtr(true), true},
|
||||
{"enableSSHRoot no identity (fail closed)", context.Background(), boolPtr(true), nil, true},
|
||||
{"both by root", ctxWithIdentity(root), boolPtr(true), boolPtr(true), false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := requirePrivilegedForDangerousSSH(tt.ctx, tt.enableSSHRoot, tt.disableSSHAuth)
|
||||
if tt.wantDenied {
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, codes.PermissionDenied, gstatus.Code(err))
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ import (
|
||||
"github.com/creack/pty"
|
||||
"github.com/gliderlabs/ssh"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
shellutil "github.com/netbirdio/netbird/client/internal/shell"
|
||||
)
|
||||
|
||||
// ptyManager manages Pty file operations with thread safety
|
||||
@@ -146,10 +148,10 @@ func (s *Server) createShellCommand(ctx context.Context, shell string, args []st
|
||||
|
||||
// prepareCommandEnv prepares environment variables for command execution on Unix
|
||||
func (s *Server) prepareCommandEnv(_ *log.Entry, localUser *user.User, session ssh.Session) []string {
|
||||
env := prepareUserEnv(localUser, getUserShell(localUser.Uid))
|
||||
env := shellutil.PrepareUserEnv(localUser, shellutil.GetUserShell(localUser.Uid))
|
||||
env = append(env, prepareSSHEnv(session)...)
|
||||
for _, v := range session.Environ() {
|
||||
if acceptEnv(v) {
|
||||
if shellutil.AcceptEnv(v) {
|
||||
env = append(env, v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
|
||||
shellutil "github.com/netbirdio/netbird/client/internal/shell"
|
||||
"github.com/netbirdio/netbird/client/ssh/server/winpty"
|
||||
)
|
||||
|
||||
@@ -247,10 +248,10 @@ func (s *Server) prepareCommandEnv(logger *log.Entry, localUser *user.User, sess
|
||||
userEnv, err := s.getUserEnvironment(logger, username, domain)
|
||||
if err != nil {
|
||||
log.Debugf("failed to get user environment for %s\\%s, using fallback: %v", domain, username, err)
|
||||
env := prepareUserEnv(localUser, getUserShell(localUser.Uid))
|
||||
env := shellutil.PrepareUserEnv(localUser, shellutil.GetUserShell(localUser.Uid))
|
||||
env = append(env, prepareSSHEnv(session)...)
|
||||
for _, v := range session.Environ() {
|
||||
if acceptEnv(v) {
|
||||
if shellutil.AcceptEnv(v) {
|
||||
env = append(env, v)
|
||||
}
|
||||
}
|
||||
@@ -260,7 +261,7 @@ func (s *Server) prepareCommandEnv(logger *log.Entry, localUser *user.User, sess
|
||||
env := userEnv
|
||||
env = append(env, prepareSSHEnv(session)...)
|
||||
for _, v := range session.Environ() {
|
||||
if acceptEnv(v) {
|
||||
if shellutil.AcceptEnv(v) {
|
||||
env = append(env, v)
|
||||
}
|
||||
}
|
||||
@@ -273,7 +274,7 @@ func (s *Server) handlePtyLogin(logger *log.Entry, session ssh.Session, privileg
|
||||
return false
|
||||
}
|
||||
|
||||
shell := getUserShell(privilegeResult.User.Uid)
|
||||
shell := shellutil.GetUserShell(privilegeResult.User.Uid)
|
||||
logger.Infof("starting interactive shell: %s", shell)
|
||||
|
||||
s.executeCommandWithPty(logger, session, nil, privilegeResult, ptyReq, nil)
|
||||
@@ -384,7 +385,7 @@ func (s *Server) executeCommandWithPty(logger *log.Entry, session ssh.Session, _
|
||||
}
|
||||
|
||||
username, domain := s.parseUsername(localUser.Username)
|
||||
shell := getUserShell(localUser.Uid)
|
||||
shell := shellutil.GetUserShell(localUser.Uid)
|
||||
|
||||
req := PtyExecutionRequest{
|
||||
Shell: shell,
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
//go:build cgo && !osusergo && !windows
|
||||
|
||||
package server
|
||||
|
||||
import "os/user"
|
||||
|
||||
// lookupWithGetent with CGO delegates directly to os/user.Lookup.
|
||||
// When CGO is enabled, os/user uses libc (getpwnam_r) which goes through
|
||||
// the NSS stack natively. If it fails, the user truly doesn't exist and
|
||||
// getent would also fail.
|
||||
func lookupWithGetent(username string) (*user.User, error) {
|
||||
return user.Lookup(username)
|
||||
}
|
||||
|
||||
// currentUserWithGetent with CGO delegates directly to os/user.Current.
|
||||
func currentUserWithGetent() (*user.User, error) {
|
||||
return user.Current()
|
||||
}
|
||||
|
||||
// groupIdsWithFallback with CGO delegates directly to user.GroupIds.
|
||||
// libc's getgrouplist handles NSS groups natively.
|
||||
func groupIdsWithFallback(u *user.User) ([]string, error) {
|
||||
return u.GroupIds()
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package server
|
||||
|
||||
import "os/user"
|
||||
|
||||
// lookupWithGetent on Windows just delegates to os/user.Lookup.
|
||||
// Windows does not use NSS/getent; its user lookup works without CGO.
|
||||
func lookupWithGetent(username string) (*user.User, error) {
|
||||
return user.Lookup(username)
|
||||
}
|
||||
|
||||
// currentUserWithGetent on Windows just delegates to os/user.Current.
|
||||
func currentUserWithGetent() (*user.User, error) {
|
||||
return user.Current()
|
||||
}
|
||||
|
||||
// getShellFromGetent is a no-op on Windows; shell resolution uses PowerShell detection.
|
||||
func getShellFromGetent(_ string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// groupIdsWithFallback on Windows just delegates to u.GroupIds().
|
||||
func groupIdsWithFallback(u *user.User) ([]string, error) {
|
||||
return u.GroupIds()
|
||||
}
|
||||
35
client/ssh/server/ssh_env.go
Normal file
35
client/ssh/server/ssh_env.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
|
||||
"github.com/gliderlabs/ssh"
|
||||
)
|
||||
|
||||
// prepareSSHEnv prepares SSH protocol-specific environment variables
|
||||
// These variables provide information about the SSH connection itself
|
||||
func prepareSSHEnv(session ssh.Session) []string {
|
||||
remoteAddr := session.RemoteAddr()
|
||||
localAddr := session.LocalAddr()
|
||||
|
||||
remoteHost, remotePort, err := net.SplitHostPort(remoteAddr.String())
|
||||
if err != nil {
|
||||
remoteHost = remoteAddr.String()
|
||||
remotePort = "0"
|
||||
}
|
||||
|
||||
localHost, localPort, err := net.SplitHostPort(localAddr.String())
|
||||
if err != nil {
|
||||
localHost = localAddr.String()
|
||||
localPort = strconv.Itoa(InternalSSHPort)
|
||||
}
|
||||
|
||||
return []string{
|
||||
// SSH_CLIENT format: "client_ip client_port server_port"
|
||||
fmt.Sprintf("SSH_CLIENT=%s %s %s", remoteHost, remotePort, localPort),
|
||||
// SSH_CONNECTION format: "client_ip client_port server_ip server_port"
|
||||
fmt.Sprintf("SSH_CONNECTION=%s %s %s %s", remoteHost, remotePort, localHost, localPort),
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
shellutil "github.com/netbirdio/netbird/client/internal/shell"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -23,8 +25,8 @@ func isPlatformUnix() bool {
|
||||
|
||||
// Dependency injection variables for testing - allows mocking dynamic runtime checks
|
||||
var (
|
||||
getCurrentUser = currentUserWithGetent
|
||||
lookupUser = lookupWithGetent
|
||||
getCurrentUser = shellutil.CurrentUserWithGetent
|
||||
lookupUser = shellutil.LookupWithGetent
|
||||
getCurrentOS = func() string { return runtime.GOOS }
|
||||
getIsProcessPrivileged = isCurrentProcessPrivileged
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
|
||||
"github.com/gliderlabs/ssh"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
shellutil "github.com/netbirdio/netbird/client/internal/shell"
|
||||
)
|
||||
|
||||
// POSIX portable filename character set regex: [a-zA-Z0-9._-]
|
||||
@@ -160,7 +162,7 @@ func (s *Server) parseUserCredentials(localUser *user.User) (uint32, uint32, []u
|
||||
// getSupplementaryGroups retrieves supplementary group IDs for a user.
|
||||
// Uses id/getent fallback for NSS users in CGO_ENABLED=0 builds.
|
||||
func (s *Server) getSupplementaryGroups(u *user.User) ([]uint32, error) {
|
||||
groupIDStrings, err := groupIdsWithFallback(u)
|
||||
groupIDStrings, err := shellutil.GroupIdsWithFallback(u)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get group IDs for user %s: %w", u.Username, err)
|
||||
}
|
||||
@@ -196,7 +198,7 @@ func (s *Server) createExecutorCommand(logger *log.Entry, session ssh.Session, l
|
||||
GID: gid,
|
||||
Groups: groups,
|
||||
WorkingDir: localUser.HomeDir,
|
||||
Shell: getUserShell(localUser.Uid),
|
||||
Shell: shellutil.GetUserShell(localUser.Uid),
|
||||
Command: session.RawCommand(),
|
||||
PTY: hasPty,
|
||||
}
|
||||
@@ -228,7 +230,7 @@ func (s *Server) createPtyCommand(privilegeResult PrivilegeCheckResult, ptyReq s
|
||||
func (s *Server) createDirectPtyCommand(session ssh.Session, localUser *user.User, ptyReq ssh.Pty) *exec.Cmd {
|
||||
log.Debugf("creating direct Pty command for user %s (no user switching needed)", localUser.Username)
|
||||
|
||||
shell := getUserShell(localUser.Uid)
|
||||
shell := shellutil.GetUserShell(localUser.Uid)
|
||||
args := s.getShellCommandArgs(shell, session.RawCommand())
|
||||
|
||||
cmd := s.createShellCommand(session.Context(), shell, args)
|
||||
@@ -245,12 +247,12 @@ func (s *Server) preparePtyEnv(localUser *user.User, ptyReq ssh.Pty, session ssh
|
||||
termType = "xterm-256color"
|
||||
}
|
||||
|
||||
env := prepareUserEnv(localUser, getUserShell(localUser.Uid))
|
||||
env := shellutil.PrepareUserEnv(localUser, shellutil.GetUserShell(localUser.Uid))
|
||||
env = append(env, prepareSSHEnv(session)...)
|
||||
env = append(env, fmt.Sprintf("TERM=%s", termType))
|
||||
|
||||
for _, v := range session.Environ() {
|
||||
if acceptEnv(v) {
|
||||
if shellutil.AcceptEnv(v) {
|
||||
env = append(env, v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"github.com/gliderlabs/ssh"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/windows"
|
||||
|
||||
shellutil "github.com/netbirdio/netbird/client/internal/shell"
|
||||
)
|
||||
|
||||
// validateUsername validates Windows usernames according to SAM Account Name rules
|
||||
@@ -104,7 +106,7 @@ func (s *Server) createExecutorCommand(logger *log.Entry, session ssh.Session, l
|
||||
func (s *Server) createUserSwitchCommand(logger *log.Entry, session ssh.Session, localUser *user.User) (*exec.Cmd, func(), error) {
|
||||
username, domain := s.parseUsername(localUser.Username)
|
||||
|
||||
shell := getUserShell(localUser.Uid)
|
||||
shell := shellutil.GetUserShell(localUser.Uid)
|
||||
|
||||
rawCmd := session.RawCommand()
|
||||
var command string
|
||||
|
||||
1
client/ui/.gitignore
vendored
1
client/ui/.gitignore
vendored
@@ -6,3 +6,4 @@ frontend/bindings
|
||||
frontend/.vite
|
||||
build/linux/appimage/build
|
||||
build/windows/nsis/MicrosoftEdgeWebview2Setup.exe
|
||||
build/windows/frontend
|
||||
|
||||
@@ -51,7 +51,7 @@ func autostartDisabledByMDM(policy *mdm.Policy) bool {
|
||||
// netbirdFootprintExists reports whether the machine already carries NetBird
|
||||
// daemon config or state, meaning this is not a genuinely fresh install. It is
|
||||
// the update-safety gate for the autostart default: upgrading users always
|
||||
// have a footprint, so an update can never trigger a login-item write.
|
||||
// have a footprint, so an update can never trigger a autostart entry write.
|
||||
func netbirdFootprintExists() bool {
|
||||
candidates := []string{
|
||||
profilemanager.DefaultConfigPath,
|
||||
@@ -69,9 +69,23 @@ func netbirdFootprintExists() bool {
|
||||
// applyAutostartDefault runs the one-time launch-on-login default for genuinely
|
||||
// fresh installs. The autostartInitialized marker is persisted before any
|
||||
// enable attempt so a crash mid-flow degrades to "never enabled" instead of
|
||||
// retrying login-item writes on every launch. A user's later disable in
|
||||
// retrying autostart entry writes on every launch. A user's later disable in
|
||||
// Settings is never overridden: the marker guarantees at-most-once, ever.
|
||||
func applyAutostartDefault(ctx context.Context, autostart *services.Autostart, prefs *preferences.Store, prefsFileExisted bool) {
|
||||
mdmDisabled := autostartDisabledByMDM(mdm.LoadPolicy())
|
||||
|
||||
if mdmDisabled {
|
||||
if enabled, err := autostart.IsEnabled(ctx); err != nil {
|
||||
log.Warnf("MDM disableAutostart: read autostart state: %v", err)
|
||||
} else if enabled {
|
||||
if err := autostart.SetEnabled(ctx, false); err != nil {
|
||||
log.Warnf("MDM disableAutostart: force off failed: %v", err)
|
||||
} else {
|
||||
log.Info("MDM disableAutostart enforced: autostart turned off")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
priorFootprint := netbirdFootprintExists() || prefsFileExisted
|
||||
|
||||
if prefs.Get().AutostartInitialized {
|
||||
@@ -84,7 +98,7 @@ func applyAutostartDefault(ctx context.Context, autostart *services.Autostart, p
|
||||
|
||||
state := autostartDefaultState{
|
||||
supported: autostart.Supported(ctx),
|
||||
mdmDisabled: autostartDisabledByMDM(mdm.LoadPolicy()),
|
||||
mdmDisabled: mdmDisabled,
|
||||
priorInstall: priorFootprint,
|
||||
}
|
||||
enable, reason := shouldEnableAutostartDefault(state)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangleIcon, DownloadIcon } from "lucide-react";
|
||||
import { Browser } from "@wailsio/runtime";
|
||||
import { Version } from "@bindings/services";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { useStatus } from "@/contexts/StatusContext.tsx";
|
||||
|
||||
const RELEASES_URL = "https://github.com/netbirdio/netbird/releases/latest";
|
||||
const RC_RELEASES_URL = "https://pkgs.netbird.io/releases/rc";
|
||||
|
||||
function openUrl(url: string) {
|
||||
Browser.OpenURL(url).catch(() => globalThis.open(url, "_blank"));
|
||||
@@ -12,7 +15,26 @@ function openUrl(url: string) {
|
||||
|
||||
export const DaemonOutdatedOverlay = () => {
|
||||
const { t } = useTranslation();
|
||||
const { isDaemonOutdated } = useStatus();
|
||||
const { status, isDaemonOutdated } = useStatus();
|
||||
|
||||
const [guiVersion, setGuiVersion] = useState<string>("-");
|
||||
const clientVersion = status?.daemonVersion ?? "—";
|
||||
|
||||
const isRc = /-rc/i.test(guiVersion) || /-rc/i.test(clientVersion);
|
||||
const downloadUrl = isRc ? RC_RELEASES_URL : RELEASES_URL;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDaemonOutdated) return;
|
||||
let cancelled = false;
|
||||
Version.GUI()
|
||||
.then((v) => {
|
||||
if (!cancelled) setGuiVersion(v);
|
||||
})
|
||||
.catch((err) => console.error("[DaemonOutdatedOverlay] GUI version error", err));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isDaemonOutdated]);
|
||||
|
||||
if (!isDaemonOutdated) return null;
|
||||
|
||||
@@ -38,10 +60,37 @@ export const DaemonOutdatedOverlay = () => {
|
||||
<p className={"text-sm text-nb-gray-300"}>{t("daemon.outdated.description")}</p>
|
||||
</div>
|
||||
|
||||
<div className={"flex flex-col items-center gap-0.5 text-center"}>
|
||||
<p className={"text-sm font-semibold text-nb-gray-100"}>
|
||||
{clientVersion === "development" ? (
|
||||
<span>
|
||||
{t("settings.about.clientName")}{" "}
|
||||
<span className={"font-mono text-yellow-400"}>
|
||||
{t("settings.about.development")}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
t("settings.about.client", { version: clientVersion })
|
||||
)}
|
||||
</p>
|
||||
<p className={"text-sm font-medium text-nb-gray-250"}>
|
||||
{guiVersion === "development" ? (
|
||||
<span>
|
||||
{t("settings.about.guiName")}{" "}
|
||||
<span className={"font-mono text-yellow-400"}>
|
||||
{t("settings.about.development")}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
t("settings.about.gui", { version: guiVersion })
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={"wails-no-draggable"}>
|
||||
<Button variant={"primary"} size={"xs"} onClick={() => openUrl(RELEASES_URL)}>
|
||||
<Button variant={"primary"} size={"xs"} onClick={() => openUrl(downloadUrl)}>
|
||||
<DownloadIcon size={14} />
|
||||
{t("update.card.getInstaller")}
|
||||
{t("daemon.outdated.download")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,7 @@ type ProfileContextValue = {
|
||||
loaded: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
switchProfile: (id: string) => Promise<void>;
|
||||
switchProfileNoConnect: (id: string) => Promise<void>;
|
||||
addProfile: (name: string) => Promise<string>;
|
||||
removeProfile: (id: string) => Promise<void>;
|
||||
renameProfile: (id: string, newName: string) => Promise<void>;
|
||||
@@ -112,6 +113,16 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
|
||||
[username, refresh],
|
||||
);
|
||||
|
||||
// Manage-profiles variant: switches without connecting, so the user can
|
||||
// still adjust the management URL before bringing the connection up.
|
||||
const switchProfileNoConnect = useCallback(
|
||||
async (id: string) => {
|
||||
await ProfileSwitcher.SwitchActiveNoConnect({ profileName: id, username });
|
||||
await refresh();
|
||||
},
|
||||
[username, refresh],
|
||||
);
|
||||
|
||||
// addProfile creates a profile by display name and returns the
|
||||
// daemon-generated ID, so the caller can immediately address it by ID.
|
||||
const addProfile = useCallback(
|
||||
@@ -158,6 +169,7 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
|
||||
loaded,
|
||||
refresh,
|
||||
switchProfile,
|
||||
switchProfileNoConnect,
|
||||
addProfile,
|
||||
removeProfile,
|
||||
renameProfile,
|
||||
@@ -171,6 +183,7 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
|
||||
loaded,
|
||||
refresh,
|
||||
switchProfile,
|
||||
switchProfileNoConnect,
|
||||
addProfile,
|
||||
removeProfile,
|
||||
renameProfile,
|
||||
|
||||
@@ -45,7 +45,7 @@ export function ProfilesTab() {
|
||||
activeProfileId,
|
||||
loaded,
|
||||
username,
|
||||
switchProfile,
|
||||
switchProfileNoConnect,
|
||||
addProfile,
|
||||
removeProfile,
|
||||
renameProfile,
|
||||
@@ -100,7 +100,7 @@ export function ProfilesTab() {
|
||||
confirmLabel: t("profile.switch.confirm"),
|
||||
});
|
||||
if (!ok) return;
|
||||
await guarded(i18next.t("profile.error.switchTitle"), () => switchProfile(id));
|
||||
await guarded(i18next.t("profile.error.switchTitle"), () => switchProfileNoConnect(id));
|
||||
};
|
||||
|
||||
const handleDeregister = async (id: string, name: string) => {
|
||||
@@ -129,14 +129,13 @@ export function ProfilesTab() {
|
||||
await guarded(i18next.t("profile.error.createTitle"), async () => {
|
||||
const id = await addProfile(name);
|
||||
// SetConfig is keyed by the new profile's ID, so it writes the
|
||||
// not-yet-active profile. Write before switching so any reconnect
|
||||
// targets the right deployment.
|
||||
// not-yet-active profile before the switch makes it current.
|
||||
if (!isNetbirdCloud(managementUrl)) {
|
||||
await SettingsSvc.SetConfig(
|
||||
new SetConfigParams({ profileName: id, username, managementUrl }),
|
||||
);
|
||||
}
|
||||
await switchProfile(id);
|
||||
await switchProfileNoConnect(id);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -73,6 +73,13 @@ export default function SessionExpirationDialog() {
|
||||
|
||||
let offCancel: (() => void) | undefined;
|
||||
|
||||
// Return the dialog to its interactive state and dismiss the browser popup
|
||||
const resetDialog = () => {
|
||||
offCancel?.();
|
||||
WindowManager.CloseBrowserLogin().catch(console.error);
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
try {
|
||||
const start = await Session.RequestExtend({ hint: "" });
|
||||
const uri = start.verificationUriComplete || start.verificationUri;
|
||||
@@ -105,25 +112,22 @@ export default function SessionExpirationDialog() {
|
||||
if (outcome.kind === "cancel") {
|
||||
waitPromise.cancel?.();
|
||||
waitPromise.catch(() => {});
|
||||
resetDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
// Another surface owns this flow; keep the dialog open to retry.
|
||||
if (outcome.result.preempted) {
|
||||
resetDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
// Close before the popup so the restore can't flash this window back.
|
||||
WindowManager.CloseSessionExpiration().catch(console.error);
|
||||
WindowManager.CloseRenewFlow().catch(console.error);
|
||||
} catch (e) {
|
||||
resetDialog();
|
||||
await errorDialog({
|
||||
Title: t("sessionExpiration.extendFailedTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
} finally {
|
||||
offCancel?.();
|
||||
WindowManager.CloseBrowserLogin().catch(console.error);
|
||||
setBusy(false);
|
||||
}
|
||||
}, [busy, t]);
|
||||
|
||||
@@ -139,12 +143,11 @@ export default function SessionExpirationDialog() {
|
||||
});
|
||||
WindowManager.CloseSessionExpiration().catch(console.error);
|
||||
} catch (e) {
|
||||
setBusy(false);
|
||||
await errorDialog({
|
||||
Title: t("sessionExpiration.logoutFailedTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [busy, t]);
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ type WelcomeStepTrayProps = {
|
||||
export function WelcomeStepTray({ onContinue }: Readonly<WelcomeStepTrayProps>) {
|
||||
const { t } = useTranslation();
|
||||
const trayScreenshot = trayScreenshotForOS();
|
||||
// macOS has no tray — the icon sits in the menu bar, so the copy says so.
|
||||
const titleKey = isMacOS() ? "welcome.titleMac" : "welcome.title";
|
||||
const descriptionKey = isMacOS() ? "welcome.descriptionMac" : "welcome.description";
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -36,9 +39,9 @@ export function WelcomeStepTray({ onContinue }: Readonly<WelcomeStepTrayProps>)
|
||||
|
||||
<div className={"flex w-full flex-col gap-1"}>
|
||||
<DialogHeading id={"nb-welcome-title"} align={"left"}>
|
||||
{t("welcome.title")}
|
||||
{t(titleKey)}
|
||||
</DialogHeading>
|
||||
<DialogDescription align={"left"}>{t("welcome.description")}</DialogDescription>
|
||||
<DialogDescription align={"left"}>{t(descriptionKey)}</DialogDescription>
|
||||
</div>
|
||||
|
||||
<DialogActions>
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/backoff"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
"github.com/netbirdio/netbird/client/cmd"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/ui/desktop"
|
||||
)
|
||||
@@ -36,9 +36,7 @@ func (c *Conn) Client() (proto.DaemonServiceClient, error) {
|
||||
return c.client, nil
|
||||
}
|
||||
|
||||
cc, err := grpc.NewClient(
|
||||
strings.TrimPrefix(c.addr, "tcp://"),
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
opts := []grpc.DialOption{
|
||||
grpc.WithUserAgent(desktop.GetUIUserAgent()),
|
||||
// Cap reconnect backoff at 5s; gRPC's default 120s MaxDelay would
|
||||
// leave the UI waiting 30-60s to notice a freshly-started daemon.
|
||||
@@ -50,6 +48,12 @@ func (c *Conn) Client() (proto.DaemonServiceClient, error) {
|
||||
MaxDelay: 5 * time.Second,
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
cc, err := cmd.DialClientGRPCServer(
|
||||
context.Background(),
|
||||
c.addr,
|
||||
opts...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dial daemon: %w", err)
|
||||
@@ -61,7 +65,7 @@ func (c *Conn) Client() (proto.DaemonServiceClient, error) {
|
||||
// DaemonAddr returns the default daemon gRPC address: a Unix socket on Linux/macOS, TCP loopback on Windows.
|
||||
func DaemonAddr() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "tcp://127.0.0.1:41731"
|
||||
return "npipe://netbird"
|
||||
}
|
||||
return "unix:///var/run/netbird.sock"
|
||||
}
|
||||
|
||||
@@ -1034,9 +1034,15 @@
|
||||
"welcome.title": {
|
||||
"message": "Suchen Sie NetBird in der Taskleiste"
|
||||
},
|
||||
"welcome.titleMac": {
|
||||
"message": "Suchen Sie NetBird in der Menüleiste"
|
||||
},
|
||||
"welcome.description": {
|
||||
"message": "NetBird läuft in Ihrer Taskleiste. Klicken Sie auf das Symbol, um sich zu verbinden, Profile zu wechseln oder die Einstellungen zu öffnen."
|
||||
},
|
||||
"welcome.descriptionMac": {
|
||||
"message": "NetBird läuft in Ihrer Menüleiste. Klicken Sie auf das Symbol, um sich zu verbinden, Profile zu wechseln oder die Einstellungen zu öffnen."
|
||||
},
|
||||
"welcome.continue": {
|
||||
"message": "Weiter"
|
||||
},
|
||||
@@ -1293,10 +1299,13 @@
|
||||
"message": "Dokumentation"
|
||||
},
|
||||
"daemon.outdated.title": {
|
||||
"message": "NetBird-Dienst ist veraltet"
|
||||
"message": "NetBird Client ist veraltet"
|
||||
},
|
||||
"daemon.outdated.description": {
|
||||
"message": "Aktualisieren Sie den NetBird-Dienst, um diese App zu verwenden."
|
||||
"message": "Die neue GUI ist nicht mit Ihrem älteren Client kompatibel. Aktualisieren Sie Ihren Client, um die neue Anwendung zu verwenden."
|
||||
},
|
||||
"daemon.outdated.download": {
|
||||
"message": "Neueste Version herunterladen"
|
||||
},
|
||||
"error.jwt_clock_skew": {
|
||||
"message": "Anmeldung fehlgeschlagen: Die Uhr dieses Geräts ist nicht mit dem Server synchron. Bitte synchronisieren Sie die Systemuhr und versuchen Sie es erneut."
|
||||
|
||||
@@ -1377,11 +1377,19 @@
|
||||
},
|
||||
"welcome.title": {
|
||||
"message": "Look for NetBird in your tray",
|
||||
"description": "Heading on the first onboarding step, pointing the user to the tray icon. 'tray' = system tray / menu bar."
|
||||
"description": "Heading on the first onboarding step, pointing the user to the tray icon. Shown on Windows and Linux; macOS uses welcome.titleMac."
|
||||
},
|
||||
"welcome.titleMac": {
|
||||
"message": "Look for NetBird in your menu bar",
|
||||
"description": "Heading on the first onboarding step on macOS, pointing the user to the menu bar icon. Use your language's Apple term for the macOS menu bar."
|
||||
},
|
||||
"welcome.description": {
|
||||
"message": "NetBird lives in your tray. Click the icon to connect, switch profiles, or open settings.",
|
||||
"description": "Body of the first onboarding step explaining the tray icon."
|
||||
"description": "Body of the first onboarding step explaining the tray icon. Shown on Windows and Linux; macOS uses welcome.descriptionMac."
|
||||
},
|
||||
"welcome.descriptionMac": {
|
||||
"message": "NetBird lives in your menu bar. Click the icon to connect, switch profiles, or open settings.",
|
||||
"description": "Body of the first onboarding step on macOS explaining the menu bar icon. Use your language's Apple term for the macOS menu bar."
|
||||
},
|
||||
"welcome.continue": {
|
||||
"message": "Continue",
|
||||
@@ -1724,12 +1732,16 @@
|
||||
"description": "Documentation link on the daemon-unavailable overlay."
|
||||
},
|
||||
"daemon.outdated.title": {
|
||||
"message": "NetBird Service Is Outdated",
|
||||
"description": "Title of the overlay shown when the NetBird background service is too old to drive this UI."
|
||||
"message": "NetBird Client Is Outdated",
|
||||
"description": "Title of the overlay shown when the NetBird client (daemon) is too old to drive this UI."
|
||||
},
|
||||
"daemon.outdated.description": {
|
||||
"message": "Update the NetBird service to use this app.",
|
||||
"description": "Body of the daemon-outdated overlay telling the user to upgrade the service."
|
||||
"message": "The new GUI isn't compatible with the older NetBird client. Update your client to use the new application.",
|
||||
"description": "Body of the daemon-outdated overlay explaining that the GUI is newer than the client and the client must be updated."
|
||||
},
|
||||
"daemon.outdated.download": {
|
||||
"message": "Download Latest",
|
||||
"description": "Button on the daemon-outdated overlay that opens the download page for the latest release."
|
||||
},
|
||||
"error.jwt_clock_skew": {
|
||||
"message": "Sign-in failed: this device's clock is out of sync with the server. Please sync your system clock and try again.",
|
||||
|
||||
@@ -1034,9 +1034,15 @@
|
||||
"welcome.title": {
|
||||
"message": "Busque NetBird en su bandeja del sistema"
|
||||
},
|
||||
"welcome.titleMac": {
|
||||
"message": "Busque NetBird en su barra de menús"
|
||||
},
|
||||
"welcome.description": {
|
||||
"message": "NetBird reside en su bandeja del sistema. Haga clic en el icono para conectarse, cambiar de perfil o abrir la configuración."
|
||||
},
|
||||
"welcome.descriptionMac": {
|
||||
"message": "NetBird reside en su barra de menús. Haga clic en el icono para conectarse, cambiar de perfil o abrir la configuración."
|
||||
},
|
||||
"welcome.continue": {
|
||||
"message": "Continuar"
|
||||
},
|
||||
@@ -1293,10 +1299,13 @@
|
||||
"message": "Documentación"
|
||||
},
|
||||
"daemon.outdated.title": {
|
||||
"message": "El servicio de NetBird está desactualizado"
|
||||
"message": "NetBird Client está desactualizado"
|
||||
},
|
||||
"daemon.outdated.description": {
|
||||
"message": "Actualice el servicio de NetBird para usar esta aplicación."
|
||||
"message": "La nueva GUI no es compatible con su cliente anterior. Actualice su cliente para usar la nueva aplicación."
|
||||
},
|
||||
"daemon.outdated.download": {
|
||||
"message": "Descargar la última versión"
|
||||
},
|
||||
"error.jwt_clock_skew": {
|
||||
"message": "Error al iniciar sesión: el reloj de este dispositivo no está sincronizado con el servidor. Sincronice el reloj del sistema e inténtelo de nuevo."
|
||||
|
||||
@@ -1034,9 +1034,15 @@
|
||||
"welcome.title": {
|
||||
"message": "Cherchez NetBird dans votre barre d’état système"
|
||||
},
|
||||
"welcome.titleMac": {
|
||||
"message": "Cherchez NetBird dans votre barre des menus"
|
||||
},
|
||||
"welcome.description": {
|
||||
"message": "NetBird se trouve dans votre barre d’état système. Cliquez sur l’icône pour vous connecter, changer de profil ou ouvrir les paramètres."
|
||||
},
|
||||
"welcome.descriptionMac": {
|
||||
"message": "NetBird se trouve dans votre barre des menus. Cliquez sur l’icône pour vous connecter, changer de profil ou ouvrir les paramètres."
|
||||
},
|
||||
"welcome.continue": {
|
||||
"message": "Continuer"
|
||||
},
|
||||
@@ -1293,10 +1299,13 @@
|
||||
"message": "Documentation"
|
||||
},
|
||||
"daemon.outdated.title": {
|
||||
"message": "Le service NetBird est obsolète"
|
||||
"message": "Le Client NetBird est obsolète"
|
||||
},
|
||||
"daemon.outdated.description": {
|
||||
"message": "Mettez à jour le service NetBird pour utiliser cette application."
|
||||
"message": "La nouvelle GUI n'est pas compatible avec votre ancien client. Mettez à jour votre client pour utiliser la nouvelle application."
|
||||
},
|
||||
"daemon.outdated.download": {
|
||||
"message": "Télécharger la dernière version"
|
||||
},
|
||||
"error.jwt_clock_skew": {
|
||||
"message": "Échec de la connexion : l’horloge de cet appareil n’est pas synchronisée avec le serveur. Veuillez synchroniser l’horloge de votre système et réessayer."
|
||||
|
||||
@@ -1034,9 +1034,15 @@
|
||||
"welcome.title": {
|
||||
"message": "Keresse a NetBirdöt a tálcán"
|
||||
},
|
||||
"welcome.titleMac": {
|
||||
"message": "Keresse a NetBirdöt a menüsorban"
|
||||
},
|
||||
"welcome.description": {
|
||||
"message": "A NetBird a tálcán fut. Kattintson az ikonra a csatlakozáshoz, profilváltáshoz vagy a beállítások megnyitásához."
|
||||
},
|
||||
"welcome.descriptionMac": {
|
||||
"message": "A NetBird a menüsorban fut. Kattintson az ikonra a csatlakozáshoz, profilváltáshoz vagy a beállítások megnyitásához."
|
||||
},
|
||||
"welcome.continue": {
|
||||
"message": "Folytatás"
|
||||
},
|
||||
@@ -1293,10 +1299,13 @@
|
||||
"message": "Dokumentáció"
|
||||
},
|
||||
"daemon.outdated.title": {
|
||||
"message": "A NetBird szolgáltatás elavult"
|
||||
"message": "A NetBird Kliens elavult"
|
||||
},
|
||||
"daemon.outdated.description": {
|
||||
"message": "Frissítsd a NetBird szolgáltatást az alkalmazás használatához."
|
||||
"message": "Az új GUI nem kompatibilis a régebbi klienseddel. Frissítsd a klienst az új alkalmazás használatához."
|
||||
},
|
||||
"daemon.outdated.download": {
|
||||
"message": "Legújabb letöltése"
|
||||
},
|
||||
"error.jwt_clock_skew": {
|
||||
"message": "A bejelentkezés sikertelen: az eszköz órája eltér a szerverétől. Kérjük, szinkronizálja a rendszer óráját, majd próbálja újra."
|
||||
|
||||
@@ -1034,9 +1034,15 @@
|
||||
"welcome.title": {
|
||||
"message": "Cerchi NetBird nella tray"
|
||||
},
|
||||
"welcome.titleMac": {
|
||||
"message": "Cerchi NetBird nella barra dei menu"
|
||||
},
|
||||
"welcome.description": {
|
||||
"message": "NetBird risiede nella tray. Clicchi sull'icona per connettersi, cambiare profilo o aprire le impostazioni."
|
||||
},
|
||||
"welcome.descriptionMac": {
|
||||
"message": "NetBird risiede nella barra dei menu. Clicchi sull'icona per connettersi, cambiare profilo o aprire le impostazioni."
|
||||
},
|
||||
"welcome.continue": {
|
||||
"message": "Continua"
|
||||
},
|
||||
@@ -1293,10 +1299,13 @@
|
||||
"message": "Documentazione"
|
||||
},
|
||||
"daemon.outdated.title": {
|
||||
"message": "Il servizio NetBird è obsoleto"
|
||||
"message": "NetBird Client è obsoleto"
|
||||
},
|
||||
"daemon.outdated.description": {
|
||||
"message": "Aggiorna il servizio NetBird per usare questa app."
|
||||
"message": "La nuova GUI non è compatibile con il tuo client precedente. Aggiorna il client per usare la nuova applicazione."
|
||||
},
|
||||
"daemon.outdated.download": {
|
||||
"message": "Scarica l'ultima versione"
|
||||
},
|
||||
"error.jwt_clock_skew": {
|
||||
"message": "Accesso non riuscito: l'orologio di questo dispositivo non è sincronizzato con il server. Sincronizzi l'orologio di sistema e riprovi."
|
||||
|
||||
@@ -1034,9 +1034,15 @@
|
||||
"welcome.title": {
|
||||
"message": "トレイの NetBird を確認してください"
|
||||
},
|
||||
"welcome.titleMac": {
|
||||
"message": "メニューバーの NetBird を確認してください"
|
||||
},
|
||||
"welcome.description": {
|
||||
"message": "NetBird はトレイに常駐します。アイコンをクリックして、接続、プロファイルの切り替え、設定を開くことができます。"
|
||||
},
|
||||
"welcome.descriptionMac": {
|
||||
"message": "NetBird はメニューバーに常駐します。アイコンをクリックして、接続、プロファイルの切り替え、設定を開くことができます。"
|
||||
},
|
||||
"welcome.continue": {
|
||||
"message": "続ける"
|
||||
},
|
||||
|
||||
@@ -1034,9 +1034,15 @@
|
||||
"welcome.title": {
|
||||
"message": "Procure o NetBird na sua bandeja"
|
||||
},
|
||||
"welcome.titleMac": {
|
||||
"message": "Procure o NetBird na sua barra de menus"
|
||||
},
|
||||
"welcome.description": {
|
||||
"message": "O NetBird fica na sua bandeja. Clique no ícone para conectar, alternar perfis ou abrir as configurações."
|
||||
},
|
||||
"welcome.descriptionMac": {
|
||||
"message": "O NetBird fica na sua barra de menus. Clique no ícone para conectar, alternar perfis ou abrir as configurações."
|
||||
},
|
||||
"welcome.continue": {
|
||||
"message": "Continuar"
|
||||
},
|
||||
@@ -1293,10 +1299,13 @@
|
||||
"message": "Documentação"
|
||||
},
|
||||
"daemon.outdated.title": {
|
||||
"message": "O serviço NetBird está desatualizado"
|
||||
"message": "O NetBird Client está desatualizado"
|
||||
},
|
||||
"daemon.outdated.description": {
|
||||
"message": "Atualize o serviço NetBird para usar este aplicativo."
|
||||
"message": "A nova GUI não é compatível com o seu cliente mais antigo. Atualize o seu cliente para usar o novo aplicativo."
|
||||
},
|
||||
"daemon.outdated.download": {
|
||||
"message": "Baixar a versão mais recente"
|
||||
},
|
||||
"error.jwt_clock_skew": {
|
||||
"message": "Falha no login: o relógio deste dispositivo está fora de sincronia com o servidor. Sincronize o relógio do sistema e tente novamente."
|
||||
|
||||
@@ -1034,9 +1034,15 @@
|
||||
"welcome.title": {
|
||||
"message": "Найдите NetBird в системном трее"
|
||||
},
|
||||
"welcome.titleMac": {
|
||||
"message": "Найдите NetBird в строке меню"
|
||||
},
|
||||
"welcome.description": {
|
||||
"message": "NetBird находится в системном трее. Нажмите на значок, чтобы подключиться, переключить профиль или открыть настройки."
|
||||
},
|
||||
"welcome.descriptionMac": {
|
||||
"message": "NetBird находится в строке меню. Нажмите на значок, чтобы подключиться, переключить профиль или открыть настройки."
|
||||
},
|
||||
"welcome.continue": {
|
||||
"message": "Продолжить"
|
||||
},
|
||||
@@ -1293,10 +1299,13 @@
|
||||
"message": "Документация"
|
||||
},
|
||||
"daemon.outdated.title": {
|
||||
"message": "Служба NetBird устарела"
|
||||
"message": "Клиент NetBird устарел"
|
||||
},
|
||||
"daemon.outdated.description": {
|
||||
"message": "Обновите службу NetBird, чтобы использовать это приложение."
|
||||
"message": "Новый GUI несовместим с вашим более старым клиентом. Обновите клиент, чтобы использовать новое приложение."
|
||||
},
|
||||
"daemon.outdated.download": {
|
||||
"message": "Скачать последнюю версию"
|
||||
},
|
||||
"error.jwt_clock_skew": {
|
||||
"message": "Не удалось войти: часы этого устройства рассинхронизированы с сервером. Синхронизируйте системные часы и повторите попытку."
|
||||
|
||||
@@ -1034,9 +1034,15 @@
|
||||
"welcome.title": {
|
||||
"message": "在托盘中查找 NetBird"
|
||||
},
|
||||
"welcome.titleMac": {
|
||||
"message": "在菜单栏中查找 NetBird"
|
||||
},
|
||||
"welcome.description": {
|
||||
"message": "NetBird 驻留在您的托盘中。点击图标即可连接、切换配置文件或打开设置。"
|
||||
},
|
||||
"welcome.descriptionMac": {
|
||||
"message": "NetBird 驻留在您的菜单栏中。点击图标即可连接、切换配置文件或打开设置。"
|
||||
},
|
||||
"welcome.continue": {
|
||||
"message": "继续"
|
||||
},
|
||||
@@ -1293,10 +1299,13 @@
|
||||
"message": "文档"
|
||||
},
|
||||
"daemon.outdated.title": {
|
||||
"message": "NetBird 服务版本过旧"
|
||||
"message": "NetBird 客户端版本过旧"
|
||||
},
|
||||
"daemon.outdated.description": {
|
||||
"message": "请更新 NetBird 服务以使用此应用。"
|
||||
"message": "新版 GUI 与您较旧的客户端不兼容。请更新客户端以使用新应用。"
|
||||
},
|
||||
"daemon.outdated.download": {
|
||||
"message": "下载最新版本"
|
||||
},
|
||||
"error.jwt_clock_skew": {
|
||||
"message": "登录失败:此设备的时钟与服务器不同步。请同步您的系统时钟后重试。"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user