Refactor shell package and use getent for user/group lookup

This commit is contained in:
Theodor S. Midtlien
2026-06-11 17:40:45 +02:00
parent 064f7bf0fd
commit d65927275d
14 changed files with 206 additions and 123 deletions
+19 -10
View File
@@ -21,6 +21,7 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"google.golang.org/grpc" "google.golang.org/grpc"
"github.com/netbirdio/netbird/client/internal/shell"
"github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/server" "github.com/netbirdio/netbird/client/server"
"github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/client/system"
@@ -72,16 +73,11 @@ func (p *program) Start(svc service.Service) error {
case owner != "": case owner != "":
// Seeded owner (flag, MDM, or persisted TOFU result): restrict // Seeded owner (flag, MDM, or persisted TOFU result): restrict
// before serving so there is no open window. // before serving so there is no open window.
u, err := user.Lookup(owner) uid, err := lookupUser(owner)
if err != nil { if err != nil {
log.Errorf("lookup socket owner %q: %v", owner, err) log.Errorf("lookup socket owner %q: %v", owner, err)
return return
} }
uid, err := strconv.Atoi(u.Uid)
if err != nil {
log.Errorf("parse uid %q for %q: %v", u.Uid, owner, err)
return
}
if err := restrictSocket(split[1], uid); err != nil { if err := restrictSocket(split[1], uid); err != nil {
log.Errorf("restrict socket to %q: %v", owner, err) log.Errorf("restrict socket to %q: %v", owner, err)
return return
@@ -115,16 +111,28 @@ func (p *program) Start(svc service.Service) error {
return nil return nil
} }
func lookupUser(username string) (int, error) {
u, err := shell.LookupWithGetent(username)
if err != nil {
return -1, fmt.Errorf("lookup user %s: %w", username, err)
}
uid, err := strconv.Atoi(u.Uid)
if err != nil {
return -1, fmt.Errorf("parse uid %s: %w", u.Uid, err)
}
return uid, nil
}
// addGroup creates a system group if it doesn't already exist and returns the gid. // addGroup creates a system group if it doesn't already exist and returns the gid.
// Must run as root. // Must run as root.
func addGroup(name string) (int, error) { func addGroup(name string) (int, error) {
if group, err := user.LookupGroup(name); err == nil { group, err := shell.LookupGroupWithGetent(name)
if err == nil {
gid, err := strconv.ParseInt(group.Gid, 10, 64) gid, err := strconv.ParseInt(group.Gid, 10, 64)
return int(gid), err return int(gid), err
} else if _, ok := err.(user.UnknownGroupError); !ok {
return -1, fmt.Errorf("lookup group %q: %w", name, err)
} }
// looup failed, create the group
groupadd, err := exec.LookPath("groupadd") groupadd, err := exec.LookPath("groupadd")
if err != nil { if err != nil {
// Fallback for Alpine/BusyBox systems. // Fallback for Alpine/BusyBox systems.
@@ -138,7 +146,7 @@ func addGroup(name string) (int, error) {
if err != nil { if err != nil {
return -1, fmt.Errorf("create group %q: %w: %s", name, err, out) return -1, fmt.Errorf("create group %q: %w: %s", name, err, out)
} }
if group, err := user.LookupGroup(name); err == nil { if group, err := shell.LookupWithGetent(name); err == nil {
gid, err := strconv.ParseInt(group.Gid, 10, 64) gid, err := strconv.ParseInt(group.Gid, 10, 64)
return int(gid), err return int(gid), err
} }
@@ -149,6 +157,7 @@ func addGroup(name string) (int, error) {
// group (0660). If the group cannot be created or applied, it fails closed to // group (0660). If the group cannot be created or applied, it fails closed to
// owner-only 0600 — it never leaves the socket world-writable. // owner-only 0600 — it never leaves the socket world-writable.
func restrictSocket(path string, uid int) error { func restrictSocket(path string, uid int) error {
// TODO: introduce flag to configure this (LDAP/AD usecase)
gid, err := addGroup("netbird") gid, err := addGroup("netbird")
if err != nil { if err != nil {
log.Errorf("create netbird group, failing closed to owner-only 0600: %v", err) log.Errorf("create netbird group, failing closed to owner-only 0600: %v", err)
@@ -1,6 +1,6 @@
//go:build cgo && !osusergo && !windows //go:build cgo && !osusergo && !windows
package server package shell
import "os/user" import "os/user"
@@ -8,17 +8,22 @@ import "os/user"
// When CGO is enabled, os/user uses libc (getpwnam_r) which goes through // 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 // the NSS stack natively. If it fails, the user truly doesn't exist and
// getent would also fail. // getent would also fail.
func lookupWithGetent(username string) (*user.User, error) { func LookupWithGetent(username string) (*user.User, error) {
return user.Lookup(username) return user.Lookup(username)
} }
// currentUserWithGetent with CGO delegates directly to os/user.Current. // currentUserWithGetent with CGO delegates directly to os/user.Current.
func currentUserWithGetent() (*user.User, error) { func CurrentUserWithGetent() (*user.User, error) {
return user.Current() 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. // groupIdsWithFallback with CGO delegates directly to user.GroupIds.
// libc's getgrouplist handles NSS groups natively. // libc's getgrouplist handles NSS groups natively.
func groupIdsWithFallback(u *user.User) ([]string, error) { func GroupIdsWithFallback(u *user.User) ([]string, error) {
return u.GroupIds() return u.GroupIds()
} }
@@ -1,6 +1,6 @@
//go:build (!cgo || osusergo) && !windows //go:build (!cgo || osusergo) && !windows
package server package shell
import ( import (
"os" "os"
@@ -13,7 +13,7 @@ import (
// 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. // Without CGO, os/user only reads /etc/passwd and misses NSS-provided users.
// getent goes through the host's NSS stack. // 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) u, err := user.Lookup(username)
if err == nil { if err == nil {
return u, nil return u, nil
@@ -22,7 +22,7 @@ func lookupWithGetent(username string) (*user.User, error) {
stdErr := err stdErr := err
log.Debugf("os/user.Lookup(%q) failed, trying getent: %v", username, err) log.Debugf("os/user.Lookup(%q) failed, trying getent: %v", username, err)
u, _, getentErr := runGetent(username) u, _, getentErr := runGetentPasswd(username)
if getentErr != nil { if getentErr != nil {
log.Debugf("getent fallback for %q also failed: %v", username, getentErr) log.Debugf("getent fallback for %q also failed: %v", username, getentErr)
return nil, stdErr return nil, stdErr
@@ -31,8 +31,25 @@ func lookupWithGetent(username string) (*user.User, error) {
return u, nil return u, nil
} }
// LookupGroupWithGetent returns the resolved group from either a gid or groupname
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. // currentUserWithGetent gets the current user, falling back to getent if os/user fails.
func currentUserWithGetent() (*user.User, error) { func CurrentUserWithGetent() (*user.User, error) {
u, err := user.Current() u, err := user.Current()
if err == nil { if err == nil {
return u, nil return u, nil
@@ -42,7 +59,7 @@ func currentUserWithGetent() (*user.User, error) {
uid := strconv.Itoa(os.Getuid()) uid := strconv.Itoa(os.Getuid())
log.Debugf("os/user.Current() failed, trying getent with UID %s: %v", uid, err) 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 { if getentErr != nil {
return nil, stdErr return nil, stdErr
} }
@@ -57,7 +74,7 @@ func currentUserWithGetent() (*user.User, error) {
// only reads /etc/group and silently returns incomplete results for NSS users // 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 // (no error, just missing groups). The id command goes through NSS and returns
// the full set. // the full set.
func groupIdsWithFallback(u *user.User) ([]string, error) { func GroupIdsWithFallback(u *user.User) ([]string, error) {
ids, err := runIdGroups(u.Username) ids, err := runIdGroups(u.Username)
if err == nil { if err == nil {
return ids, nil return ids, nil
@@ -1,4 +1,4 @@
package server package shell
import ( import (
"os/user" "os/user"
@@ -15,7 +15,7 @@ func TestLookupWithGetent_CurrentUser(t *testing.T) {
current, err := user.Current() current, err := user.Current()
require.NoError(t, err) require.NoError(t, err)
u, err := lookupWithGetent(current.Username) u, err := LookupWithGetent(current.Username)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, current.Username, u.Username) assert.Equal(t, current.Username, u.Username)
assert.Equal(t, current.Uid, u.Uid) assert.Equal(t, current.Uid, u.Uid)
@@ -23,7 +23,7 @@ func TestLookupWithGetent_CurrentUser(t *testing.T) {
} }
func TestLookupWithGetent_NonexistentUser(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") require.Error(t, err, "should fail for nonexistent user")
} }
@@ -31,7 +31,7 @@ func TestCurrentUserWithGetent(t *testing.T) {
stdUser, err := user.Current() stdUser, err := user.Current()
require.NoError(t, err) require.NoError(t, err)
u, err := currentUserWithGetent() u, err := CurrentUserWithGetent()
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, stdUser.Uid, u.Uid) assert.Equal(t, stdUser.Uid, u.Uid)
assert.Equal(t, stdUser.Username, u.Username) assert.Equal(t, stdUser.Username, u.Username)
@@ -41,7 +41,7 @@ func TestGroupIdsWithFallback_CurrentUser(t *testing.T) {
current, err := user.Current() current, err := user.Current()
require.NoError(t, err) require.NoError(t, err)
groups, err := groupIdsWithFallback(current) groups, err := GroupIdsWithFallback(current)
require.NoError(t, err) require.NoError(t, err)
require.NotEmpty(t, groups, "current user should have at least one group") 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) { func TestGetShellFromGetent_CurrentUser(t *testing.T) {
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
// Windows stub always returns empty, which is correct // Windows stub always returns empty, which is correct
shell := getShellFromGetent("1000") shell := GetShellFromGetent("1000")
assert.Empty(t, shell, "Windows stub should return empty") assert.Empty(t, shell, "Windows stub should return empty")
return return
} }
@@ -65,7 +65,7 @@ func TestGetShellFromGetent_CurrentUser(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// getent may not be available on all systems (e.g., macOS without Homebrew getent) // getent may not be available on all systems (e.g., macOS without Homebrew getent)
shell := getShellFromGetent(current.Uid) shell := GetShellFromGetent(current.Uid)
if shell == "" { if shell == "" {
t.Log("getShellFromGetent returned empty, getent may not be available") t.Log("getShellFromGetent returned empty, getent may not be available")
return return
@@ -78,7 +78,7 @@ func TestLookupWithGetent_RootUser(t *testing.T) {
t.Skip("no root user on Windows") t.Skip("no root user on Windows")
} }
u, err := lookupWithGetent("root") u, err := LookupWithGetent("root")
if err != nil { if err != nil {
t.Skip("root user not available on this system") t.Skip("root user not available on this system")
} }
@@ -91,20 +91,20 @@ func TestLookupWithGetent_RootUser(t *testing.T) {
// consistent and correct results when composed together. // consistent and correct results when composed together.
func TestIntegration_FullLookupChain(t *testing.T) { func TestIntegration_FullLookupChain(t *testing.T) {
// Step 1: currentUserWithGetent must resolve the running user. // Step 1: currentUserWithGetent must resolve the running user.
current, err := currentUserWithGetent() current, err := CurrentUserWithGetent()
require.NoError(t, err, "currentUserWithGetent must resolve the running user") require.NoError(t, err, "currentUserWithGetent must resolve the running user")
require.NotEmpty(t, current.Uid) require.NotEmpty(t, current.Uid)
require.NotEmpty(t, current.Username) require.NotEmpty(t, current.Username)
// Step 2: lookupWithGetent by the same username must return matching identity. // Step 2: lookupWithGetent by the same username must return matching identity.
byName, err := lookupWithGetent(current.Username) byName, err := LookupWithGetent(current.Username)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, current.Uid, byName.Uid, "lookup by name should return same UID") 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.Gid, byName.Gid, "lookup by name should return same GID")
assert.Equal(t, current.HomeDir, byName.HomeDir, "lookup by name should return same home") assert.Equal(t, current.HomeDir, byName.HomeDir, "lookup by name should return same home")
// Step 3: groupIdsWithFallback must return at least the primary GID. // Step 3: groupIdsWithFallback must return at least the primary GID.
groups, err := groupIdsWithFallback(current) groups, err := GroupIdsWithFallback(current)
require.NoError(t, err) require.NoError(t, err)
require.NotEmpty(t, groups, "user must have at least one group") require.NotEmpty(t, groups, "user must have at least one group")
@@ -123,7 +123,7 @@ func TestIntegration_FullLookupChain(t *testing.T) {
// 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). // (empty is OK when getent is not available, e.g. macOS without Homebrew getent).
if runtime.GOOS != "windows" { if runtime.GOOS != "windows" {
shell := getShellFromGetent(current.Uid) shell := GetShellFromGetent(current.Uid)
if shell != "" { if shell != "" {
assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell) assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell)
} }
@@ -138,10 +138,10 @@ func TestIntegration_LookupAndGroupsConsistency(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Simulate the SSH server flow: lookup user, then get their groups. // 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) require.NoError(t, err)
groups, err := groupIdsWithFallback(resolved) groups, err := GroupIdsWithFallback(resolved)
require.NoError(t, err) require.NoError(t, err)
require.NotEmpty(t, groups, "resolved user must have groups") require.NotEmpty(t, groups, "resolved user must have groups")
@@ -154,19 +154,3 @@ func TestIntegration_LookupAndGroupsConsistency(t *testing.T) {
} }
} }
} }
// TestIntegration_ShellLookupChain tests the full shell resolution chain
// (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")
}
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")
assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell)
}
@@ -1,6 +1,6 @@
//go:build !windows //go:build !windows
package server package shell
import ( import (
"context" "context"
@@ -14,19 +14,25 @@ import (
const getentTimeout = 5 * time.Second 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 // This is needed even with CGO because getShellFromPasswd reads /etc/passwd
// directly and won't find NSS-provided users there. // directly and won't find NSS-provided users there.
func getShellFromGetent(userID string) string { func GetShellFromGetent(userID string) string {
_, shell, err := runGetent(userID) _, shell, err := runGetentPasswd(userID)
if err != nil { if err != nil {
return "" return ""
} }
return shell return shell
} }
// runGetent executes `getent passwd <query>` and returns the user and login shell. // GetUserFromGetent returns the resolved group from either a uid or username
func runGetent(query string) (*user.User, string, error) { func GetUserFromGetent(user string) (*user.User, error) {
u, _, err := runGetentPasswd(user)
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) { if !validateGetentInput(query) {
return nil, "", fmt.Errorf("invalid getent input: %q", query) return nil, "", fmt.Errorf("invalid getent input: %q", query)
} }
@@ -42,6 +48,23 @@ func runGetent(query string) (*user.User, string, error) {
return parseGetentPasswd(string(out)) return parseGetentPasswd(string(out))
} }
// 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 passwd%s: %w", query, err)
}
return parseGetentGroup(string(out))
}
// parseGetentPasswd parses getent passwd output: "name:x:uid:gid:gecos:home:shell" // parseGetentPasswd parses getent passwd output: "name:x:uid:gid:gecos:home:shell"
func parseGetentPasswd(output string) (*user.User, string, error) { func parseGetentPasswd(output string) (*user.User, string, error) {
fields := strings.SplitN(strings.TrimSpace(output), ":", 8) fields := strings.SplitN(strings.TrimSpace(output), ":", 8)
@@ -67,6 +90,20 @@ func parseGetentPasswd(output string) (*user.User, string, error) {
}, shell, nil }, shell, nil
} }
// parseGetentGroup parses getent group output: "group:x:gid:user"
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. // validateGetentInput checks that the input is safe to pass to getent or id.
// Allows POSIX usernames, numeric UIDs, and common NSS extensions // Allows POSIX usernames, numeric UIDs, and common NSS extensions
// (@ for Kerberos, $ for Samba, + for NIS compat). // (@ for Kerberos, $ for Samba, + for NIS compat).
@@ -1,6 +1,6 @@
//go:build !windows //go:build !windows
package server package shell
import ( import (
"os/exec" "os/exec"
@@ -195,7 +195,7 @@ func TestRunGetent_RootUser(t *testing.T) {
t.Skip("getent not available on this system") t.Skip("getent not available on this system")
} }
u, shell, err := runGetent("root") u, shell, err := runGetentPasswd("root")
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "root", u.Username) assert.Equal(t, "root", u.Username)
assert.Equal(t, "0", u.Uid) assert.Equal(t, "0", u.Uid)
@@ -208,7 +208,7 @@ func TestRunGetent_ByUID(t *testing.T) {
t.Skip("getent not available on this system") t.Skip("getent not available on this system")
} }
u, _, err := runGetent("0") u, _, err := runGetentPasswd("0")
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "root", u.Username) assert.Equal(t, "root", u.Username)
assert.Equal(t, "0", u.Uid) assert.Equal(t, "0", u.Uid)
@@ -219,15 +219,15 @@ func TestRunGetent_NonexistentUser(t *testing.T) {
t.Skip("getent not available on this system") t.Skip("getent not available on this system")
} }
_, _, err := runGetent("nonexistent_user_xyzzy_12345") _, _, err := runGetentPasswd("nonexistent_user_xyzzy_12345")
assert.Error(t, err) assert.Error(t, err)
} }
func TestRunGetent_InvalidInput(t *testing.T) { func TestRunGetent_InvalidInput(t *testing.T) {
_, _, err := runGetent("") _, _, err := runGetentPasswd("")
assert.Error(t, err) assert.Error(t, err)
_, _, err = runGetent("user\x00name") _, _, err = runGetentPasswd("user\x00name")
assert.Error(t, err) assert.Error(t, err)
} }
@@ -236,7 +236,7 @@ func TestRunGetent_NotAvailable(t *testing.T) {
t.Skip("getent is available, can't test missing case") 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") assert.Error(t, err, "should fail when getent is not installed")
} }
@@ -283,7 +283,7 @@ func TestGetentResultsMatchStdlib(t *testing.T) {
current, err := user.Current() current, err := user.Current()
require.NoError(t, err) require.NoError(t, err)
getentUser, _, err := runGetent(current.Username) getentUser, _, err := runGetentPasswd(current.Username)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, current.Username, getentUser.Username, "username should match") assert.Equal(t, current.Username, getentUser.Username, "username should match")
@@ -300,7 +300,7 @@ func TestGetentResultsMatchStdlib_ByUID(t *testing.T) {
current, err := user.Current() current, err := user.Current()
require.NoError(t, err) require.NoError(t, err)
getentUser, _, err := runGetent(current.Uid) getentUser, _, err := runGetentPasswd(current.Uid)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, current.Username, getentUser.Username, "username should match when looked up by UID") assert.Equal(t, current.Username, getentUser.Username, "username should match when looked up by UID")
@@ -356,7 +356,7 @@ func TestGetShellFromPasswd_CurrentUser(t *testing.T) {
assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell) assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell)
if _, err := exec.LookPath("getent"); err == nil { if _, err := exec.LookPath("getent"); err == nil {
_, getentShell, getentErr := runGetent(current.Uid) _, getentShell, getentErr := runGetentPasswd(current.Uid)
if getentErr == nil && getentShell != "" { if getentErr == nil && getentShell != "" {
assert.Equal(t, getentShell, shell, "shell from /etc/passwd should match getent") assert.Equal(t, getentShell, shell, "shell from /etc/passwd should match getent")
} }
@@ -400,7 +400,7 @@ func TestGetShellFromPasswd_MatchesGetentForKnownUsers(t *testing.T) {
continue continue
} }
_, getentShell, err := runGetent(uid) _, getentShell, err := runGetentPasswd(uid)
if err != nil { if err != nil {
continue continue
} }
@@ -1,26 +1,26 @@
//go:build windows //go:build windows
package server package shell
import "os/user" import "os/user"
// lookupWithGetent on Windows just delegates to os/user.Lookup. // lookupWithGetent on Windows just delegates to os/user.Lookup.
// Windows does not use NSS/getent; its user lookup works without CGO. // Windows does not use NSS/getent; its user lookup works without CGO.
func lookupWithGetent(username string) (*user.User, error) { func LookupWithGetent(username string) (*user.User, error) {
return user.Lookup(username) return user.Lookup(username)
} }
// currentUserWithGetent on Windows just delegates to os/user.Current. // currentUserWithGetent on Windows just delegates to os/user.Current.
func currentUserWithGetent() (*user.User, error) { func CurrentUserWithGetent() (*user.User, error) {
return user.Current() return user.Current()
} }
// getShellFromGetent is a no-op on Windows; shell resolution uses PowerShell detection. // getShellFromGetent is a no-op on Windows; shell resolution uses PowerShell detection.
func getShellFromGetent(_ string) string { func GetShellFromGetent(_ string) string {
return "" return ""
} }
// groupIdsWithFallback on Windows just delegates to u.GroupIds(). // groupIdsWithFallback on Windows just delegates to u.GroupIds().
func groupIdsWithFallback(u *user.User) ([]string, error) { func GroupIdsWithFallback(u *user.User) ([]string, error) {
return u.GroupIds() return u.GroupIds()
} }
@@ -1,17 +1,14 @@
package server package shell
import ( import (
"bufio" "bufio"
"fmt" "fmt"
"net"
"os" "os"
"os/exec" "os/exec"
"os/user" "os/user"
"runtime" "runtime"
"strconv"
"strings" "strings"
"github.com/gliderlabs/ssh"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
@@ -24,7 +21,7 @@ const (
// getUserShell returns the appropriate shell for the given user ID // getUserShell returns the appropriate shell for the given user ID
// Handles all platform-specific logic and fallbacks consistently // Handles all platform-specific logic and fallbacks consistently
func getUserShell(userID string) string { func GetUserShell(userID string) string {
switch runtime.GOOS { switch runtime.GOOS {
case "windows": case "windows":
return getWindowsUserShell() return getWindowsUserShell()
@@ -56,7 +53,7 @@ func getUnixUserShell(userID string) string {
return shell return shell
} }
if shell := getShellFromGetent(userID); shell != "" { if shell := GetShellFromGetent(userID); shell != "" {
return shell return shell
} }
@@ -101,8 +98,8 @@ func getShellFromPasswd(userID string) string {
return "" return ""
} }
// prepareUserEnv prepares environment variables for user execution // PrepareUserEnv prepares environment variables for user execution
func prepareUserEnv(user *user.User, shell string) []string { func PrepareUserEnv(user *user.User, shell string) []string {
pathValue := "/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games" pathValue := "/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games"
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
pathValue = `C:\Windows\System32;C:\Windows;C:\Windows\System32\Wbem;C:\Windows\System32\WindowsPowerShell\v1.0` pathValue = `C:\Windows\System32;C:\Windows;C:\Windows\System32\Wbem;C:\Windows\System32\WindowsPowerShell\v1.0`
@@ -119,7 +116,7 @@ func prepareUserEnv(user *user.User, shell string) []string {
// acceptEnv checks if environment variable from SSH client should be accepted // 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 // This is a whitelist of variables that SSH clients can send to the server
func acceptEnv(envVar string) bool { func AcceptEnv(envVar string) bool {
varName := envVar varName := envVar
if idx := strings.Index(envVar, "="); idx != -1 { if idx := strings.Index(envVar, "="); idx != -1 {
varName = envVar[:idx] varName = envVar[:idx]
@@ -156,29 +153,3 @@ func acceptEnv(envVar string) bool {
return false 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),
}
}
+26
View File
@@ -0,0 +1,26 @@
package shell
import (
"os/user"
"runtime"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestIntegration_ShellLookupChain tests the full shell resolution chain
// (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")
}
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")
assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell)
}
+3 -2
View File
@@ -19,6 +19,7 @@ import (
"github.com/creack/pty" "github.com/creack/pty"
"github.com/gliderlabs/ssh" "github.com/gliderlabs/ssh"
"github.com/netbirdio/netbird/client/internal/shell"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
@@ -146,10 +147,10 @@ func (s *Server) createShellCommand(ctx context.Context, shell string, args []st
// prepareCommandEnv prepares environment variables for command execution on Unix // prepareCommandEnv prepares environment variables for command execution on Unix
func (s *Server) prepareCommandEnv(_ *log.Entry, localUser *user.User, session ssh.Session) []string { func (s *Server) prepareCommandEnv(_ *log.Entry, localUser *user.User, session ssh.Session) []string {
env := prepareUserEnv(localUser, getUserShell(localUser.Uid)) env := shell.PrepareUserEnv(localUser, shell.GetUserShell(localUser.Uid))
env = append(env, prepareSSHEnv(session)...) env = append(env, prepareSSHEnv(session)...)
for _, v := range session.Environ() { for _, v := range session.Environ() {
if acceptEnv(v) { if shell.AcceptEnv(v) {
env = append(env, v) env = append(env, v)
} }
} }
@@ -247,10 +247,10 @@ func (s *Server) prepareCommandEnv(logger *log.Entry, localUser *user.User, sess
userEnv, err := s.getUserEnvironment(logger, username, domain) userEnv, err := s.getUserEnvironment(logger, username, domain)
if err != nil { if err != nil {
log.Debugf("failed to get user environment for %s\\%s, using fallback: %v", domain, username, err) log.Debugf("failed to get user environment for %s\\%s, using fallback: %v", domain, username, err)
env := prepareUserEnv(localUser, getUserShell(localUser.Uid)) env := shell.PrepareUserEnv(localUser, shell.GetUserShell(localUser.Uid))
env = append(env, prepareSSHEnv(session)...) env = append(env, prepareSSHEnv(session)...)
for _, v := range session.Environ() { for _, v := range session.Environ() {
if acceptEnv(v) { if shell.AcceptEnv(v) {
env = append(env, v) env = append(env, v)
} }
} }
@@ -260,7 +260,7 @@ func (s *Server) prepareCommandEnv(logger *log.Entry, localUser *user.User, sess
env := userEnv env := userEnv
env = append(env, prepareSSHEnv(session)...) env = append(env, prepareSSHEnv(session)...)
for _, v := range session.Environ() { for _, v := range session.Environ() {
if acceptEnv(v) { if shell.AcceptEnv(v) {
env = append(env, v) env = append(env, v)
} }
} }
@@ -273,7 +273,7 @@ func (s *Server) handlePtyLogin(logger *log.Entry, session ssh.Session, privileg
return false return false
} }
shell := getUserShell(privilegeResult.User.Uid) shell := shell.GetUserShell(privilegeResult.User.Uid)
logger.Infof("starting interactive shell: %s", shell) logger.Infof("starting interactive shell: %s", shell)
s.executeCommandWithPty(logger, session, nil, privilegeResult, ptyReq, nil) s.executeCommandWithPty(logger, session, nil, privilegeResult, ptyReq, nil)
@@ -384,7 +384,7 @@ func (s *Server) executeCommandWithPty(logger *log.Entry, session ssh.Session, _
} }
username, domain := s.parseUsername(localUser.Username) username, domain := s.parseUsername(localUser.Username)
shell := getUserShell(localUser.Uid) shell := shell.GetUserShell(localUser.Uid)
req := PtyExecutionRequest{ req := PtyExecutionRequest{
Shell: shell, Shell: shell,
+32 -2
View File
@@ -3,11 +3,15 @@ package server
import ( import (
"errors" "errors"
"fmt" "fmt"
"net"
"os" "os"
"os/user" "os/user"
"runtime" "runtime"
"strconv"
"strings" "strings"
"github.com/gliderlabs/ssh"
"github.com/netbirdio/netbird/client/internal/shell"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
@@ -23,8 +27,8 @@ func isPlatformUnix() bool {
// Dependency injection variables for testing - allows mocking dynamic runtime checks // Dependency injection variables for testing - allows mocking dynamic runtime checks
var ( var (
getCurrentUser = currentUserWithGetent getCurrentUser = shell.CurrentUserWithGetent
lookupUser = lookupWithGetent lookupUser = shell.LookupWithGetent
getCurrentOS = func() string { return runtime.GOOS } getCurrentOS = func() string { return runtime.GOOS }
getIsProcessPrivileged = isCurrentProcessPrivileged getIsProcessPrivileged = isCurrentProcessPrivileged
@@ -409,3 +413,29 @@ func isWindowsElevated() bool {
log.Debugf("Windows user switching not supported: not running as privileged user (current: %s)", currentUser.Uid) log.Debugf("Windows user switching not supported: not running as privileged user (current: %s)", currentUser.Uid)
return false 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),
}
}
+6 -5
View File
@@ -15,6 +15,7 @@ import (
"strconv" "strconv"
"github.com/gliderlabs/ssh" "github.com/gliderlabs/ssh"
"github.com/netbirdio/netbird/client/internal/shell"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
@@ -160,7 +161,7 @@ func (s *Server) parseUserCredentials(localUser *user.User) (uint32, uint32, []u
// getSupplementaryGroups retrieves supplementary group IDs for a user. // getSupplementaryGroups retrieves supplementary group IDs for a user.
// Uses id/getent fallback for NSS users in CGO_ENABLED=0 builds. // Uses id/getent fallback for NSS users in CGO_ENABLED=0 builds.
func (s *Server) getSupplementaryGroups(u *user.User) ([]uint32, error) { func (s *Server) getSupplementaryGroups(u *user.User) ([]uint32, error) {
groupIDStrings, err := groupIdsWithFallback(u) groupIDStrings, err := shell.GroupIdsWithFallback(u)
if err != nil { if err != nil {
return nil, fmt.Errorf("get group IDs for user %s: %w", u.Username, err) return nil, fmt.Errorf("get group IDs for user %s: %w", u.Username, err)
} }
@@ -196,7 +197,7 @@ func (s *Server) createExecutorCommand(logger *log.Entry, session ssh.Session, l
GID: gid, GID: gid,
Groups: groups, Groups: groups,
WorkingDir: localUser.HomeDir, WorkingDir: localUser.HomeDir,
Shell: getUserShell(localUser.Uid), Shell: shell.GetUserShell(localUser.Uid),
Command: session.RawCommand(), Command: session.RawCommand(),
PTY: hasPty, PTY: hasPty,
} }
@@ -228,7 +229,7 @@ func (s *Server) createPtyCommand(privilegeResult PrivilegeCheckResult, ptyReq s
func (s *Server) createDirectPtyCommand(session ssh.Session, localUser *user.User, ptyReq ssh.Pty) *exec.Cmd { 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) log.Debugf("creating direct Pty command for user %s (no user switching needed)", localUser.Username)
shell := getUserShell(localUser.Uid) shell := shell.GetUserShell(localUser.Uid)
args := s.getShellCommandArgs(shell, session.RawCommand()) args := s.getShellCommandArgs(shell, session.RawCommand())
cmd := s.createShellCommand(session.Context(), shell, args) cmd := s.createShellCommand(session.Context(), shell, args)
@@ -245,12 +246,12 @@ func (s *Server) preparePtyEnv(localUser *user.User, ptyReq ssh.Pty, session ssh
termType = "xterm-256color" termType = "xterm-256color"
} }
env := prepareUserEnv(localUser, getUserShell(localUser.Uid)) env := shell.PrepareUserEnv(localUser, shell.GetUserShell(localUser.Uid))
env = append(env, prepareSSHEnv(session)...) env = append(env, prepareSSHEnv(session)...)
env = append(env, fmt.Sprintf("TERM=%s", termType)) env = append(env, fmt.Sprintf("TERM=%s", termType))
for _, v := range session.Environ() { for _, v := range session.Environ() {
if acceptEnv(v) { if shell.AcceptEnv(v) {
env = append(env, v) env = append(env, v)
} }
} }
+4 -2
View File
@@ -13,6 +13,8 @@ import (
"github.com/gliderlabs/ssh" "github.com/gliderlabs/ssh"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows" "golang.org/x/sys/windows"
"github.com/netbirdio/netbird/client/internal/shell"
) )
// validateUsername validates Windows usernames according to SAM Account Name rules // 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) { func (s *Server) createUserSwitchCommand(logger *log.Entry, session ssh.Session, localUser *user.User) (*exec.Cmd, func(), error) {
username, domain := s.parseUsername(localUser.Username) username, domain := s.parseUsername(localUser.Username)
shell := getUserShell(localUser.Uid) sh := shell.GetUserShell(localUser.Uid)
rawCmd := session.RawCommand() rawCmd := session.RawCommand()
var command string var command string
@@ -116,7 +118,7 @@ func (s *Server) createUserSwitchCommand(logger *log.Entry, session ssh.Session,
Username: username, Username: username,
Domain: domain, Domain: domain,
WorkingDir: localUser.HomeDir, WorkingDir: localUser.HomeDir,
Shell: shell, Shell: sh,
Command: command, Command: command,
} }