mirror of
https://github.com/netbirdio/netbird.git
synced 2026-03-31 06:34:19 -04:00
[client] Fall back to getent/id for SSH user lookup in static builds (#5510)
This commit is contained in:
24
client/ssh/server/getent_cgo_unix.go
Normal file
24
client/ssh/server/getent_cgo_unix.go
Normal file
@@ -0,0 +1,24 @@
|
||||
//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()
|
||||
}
|
||||
74
client/ssh/server/getent_nocgo_unix.go
Normal file
74
client/ssh/server/getent_nocgo_unix.go
Normal file
@@ -0,0 +1,74 @@
|
||||
//go:build (!cgo || osusergo) && !windows
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/user"
|
||||
"strconv"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
u, err := user.Lookup(username)
|
||||
if err == nil {
|
||||
return u, nil
|
||||
}
|
||||
|
||||
stdErr := err
|
||||
log.Debugf("os/user.Lookup(%q) failed, trying getent: %v", username, err)
|
||||
|
||||
u, _, getentErr := runGetent(username)
|
||||
if getentErr != nil {
|
||||
log.Debugf("getent fallback for %q also failed: %v", username, getentErr)
|
||||
return nil, stdErr
|
||||
}
|
||||
|
||||
return u, 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
|
||||
}
|
||||
|
||||
stdErr := err
|
||||
uid := strconv.Itoa(os.Getuid())
|
||||
log.Debugf("os/user.Current() failed, trying getent with UID %s: %v", uid, err)
|
||||
|
||||
u, _, getentErr := runGetent(uid)
|
||||
if getentErr != nil {
|
||||
return nil, stdErr
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// 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,
|
||||
// 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) {
|
||||
ids, err := runIdGroups(u.Username)
|
||||
if err == nil {
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
log.Debugf("id -G %q failed, falling back to user.GroupIds(): %v", u.Username, err)
|
||||
|
||||
ids, stdErr := u.GroupIds()
|
||||
if stdErr != nil {
|
||||
return nil, stdErr
|
||||
}
|
||||
|
||||
return ids, nil
|
||||
}
|
||||
172
client/ssh/server/getent_test.go
Normal file
172
client/ssh/server/getent_test.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"os/user"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLookupWithGetent_CurrentUser(t *testing.T) {
|
||||
// The current user should always be resolvable on any platform
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
u, err := lookupWithGetent(current.Username)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, current.Username, u.Username)
|
||||
assert.Equal(t, current.Uid, u.Uid)
|
||||
assert.Equal(t, current.Gid, u.Gid)
|
||||
}
|
||||
|
||||
func TestLookupWithGetent_NonexistentUser(t *testing.T) {
|
||||
_, err := lookupWithGetent("nonexistent_user_xyzzy_12345")
|
||||
require.Error(t, err, "should fail for nonexistent user")
|
||||
}
|
||||
|
||||
func TestCurrentUserWithGetent(t *testing.T) {
|
||||
stdUser, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
u, err := currentUserWithGetent()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, stdUser.Uid, u.Uid)
|
||||
assert.Equal(t, stdUser.Username, u.Username)
|
||||
}
|
||||
|
||||
func TestGroupIdsWithFallback_CurrentUser(t *testing.T) {
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
groups, err := groupIdsWithFallback(current)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, groups, "current user should have at least one group")
|
||||
|
||||
if runtime.GOOS != "windows" {
|
||||
for _, gid := range groups {
|
||||
_, err := strconv.ParseUint(gid, 10, 32)
|
||||
assert.NoError(t, err, "group ID %q should be a valid uint32", gid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetShellFromGetent_CurrentUser(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
// Windows stub always returns empty, which is correct
|
||||
shell := getShellFromGetent("1000")
|
||||
assert.Empty(t, shell, "Windows stub should return empty")
|
||||
return
|
||||
}
|
||||
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
// getent may not be available on all systems (e.g., macOS without Homebrew getent)
|
||||
shell := getShellFromGetent(current.Uid)
|
||||
if shell == "" {
|
||||
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)
|
||||
}
|
||||
|
||||
func TestLookupWithGetent_RootUser(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("no root user on Windows")
|
||||
}
|
||||
|
||||
u, err := lookupWithGetent("root")
|
||||
if err != nil {
|
||||
t.Skip("root user not available on this system")
|
||||
}
|
||||
assert.Equal(t, "0", u.Uid, "root should have UID 0")
|
||||
}
|
||||
|
||||
// TestIntegration_FullLookupChain exercises the complete user lookup chain
|
||||
// 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")
|
||||
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)
|
||||
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)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, groups, "user must have at least one group")
|
||||
|
||||
foundPrimary := false
|
||||
for _, gid := range groups {
|
||||
if runtime.GOOS != "windows" {
|
||||
_, err := strconv.ParseUint(gid, 10, 32)
|
||||
require.NoError(t, err, "group ID %q must be a valid uint32", gid)
|
||||
}
|
||||
if gid == current.Gid {
|
||||
foundPrimary = true
|
||||
}
|
||||
}
|
||||
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
|
||||
// (empty is OK when getent is not available, e.g. macOS without Homebrew getent).
|
||||
if runtime.GOOS != "windows" {
|
||||
shell := getShellFromGetent(current.Uid)
|
||||
if shell != "" {
|
||||
assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegration_LookupAndGroupsConsistency verifies that a user resolved via
|
||||
// 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)
|
||||
require.NoError(t, err)
|
||||
|
||||
groups, err := groupIdsWithFallback(resolved)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, groups, "resolved user must have groups")
|
||||
|
||||
// On Unix, all returned GIDs must be valid numeric values.
|
||||
// On Windows, group IDs are SIDs (e.g., "S-1-5-32-544").
|
||||
if runtime.GOOS != "windows" {
|
||||
for _, gid := range groups {
|
||||
_, err := strconv.ParseUint(gid, 10, 32)
|
||||
assert.NoError(t, err, "group ID %q should be numeric", gid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
122
client/ssh/server/getent_unix.go
Normal file
122
client/ssh/server/getent_unix.go
Normal file
@@ -0,0 +1,122 @@
|
||||
//go:build !windows
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const getentTimeout = 5 * time.Second
|
||||
|
||||
// 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)
|
||||
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) {
|
||||
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", "passwd", query).Output()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("getent passwd %s: %w", query, err)
|
||||
}
|
||||
|
||||
return parseGetentPasswd(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 {
|
||||
return nil, "", fmt.Errorf("unexpected getent output (need 6+ fields): %q", output)
|
||||
}
|
||||
|
||||
if fields[0] == "" || fields[2] == "" || fields[3] == "" {
|
||||
return nil, "", fmt.Errorf("missing required fields in getent output: %q", output)
|
||||
}
|
||||
|
||||
var shell string
|
||||
if len(fields) >= 7 {
|
||||
shell = fields[6]
|
||||
}
|
||||
|
||||
return &user.User{
|
||||
Username: fields[0],
|
||||
Uid: fields[2],
|
||||
Gid: fields[3],
|
||||
Name: fields[4],
|
||||
HomeDir: fields[5],
|
||||
}, shell, 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).
|
||||
func validateGetentInput(input string) bool {
|
||||
maxLen := 32
|
||||
if runtime.GOOS == "linux" {
|
||||
maxLen = 256
|
||||
}
|
||||
|
||||
if len(input) == 0 || len(input) > maxLen {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, r := range input {
|
||||
if isAllowedGetentChar(r) {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isAllowedGetentChar(r rune) bool {
|
||||
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' {
|
||||
return true
|
||||
}
|
||||
switch r {
|
||||
case '.', '_', '-', '@', '+', '$':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// runIdGroups runs `id -G <username>` and returns the space-separated group IDs.
|
||||
func runIdGroups(username string) ([]string, error) {
|
||||
if !validateGetentInput(username) {
|
||||
return nil, fmt.Errorf("invalid username for id command: %q", username)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), getentTimeout)
|
||||
defer cancel()
|
||||
|
||||
out, err := exec.CommandContext(ctx, "id", "-G", username).Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("id -G %s: %w", username, err)
|
||||
}
|
||||
|
||||
trimmed := strings.TrimSpace(string(out))
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("id -G %s: empty output", username)
|
||||
}
|
||||
return strings.Fields(trimmed), nil
|
||||
}
|
||||
410
client/ssh/server/getent_unix_test.go
Normal file
410
client/ssh/server/getent_unix_test.go
Normal file
@@ -0,0 +1,410 @@
|
||||
//go:build !windows
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseGetentPasswd(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantUser *user.User
|
||||
wantShell string
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "standard entry",
|
||||
input: "alice:x:1001:1001:Alice Smith:/home/alice:/bin/bash\n",
|
||||
wantUser: &user.User{
|
||||
Username: "alice",
|
||||
Uid: "1001",
|
||||
Gid: "1001",
|
||||
Name: "Alice Smith",
|
||||
HomeDir: "/home/alice",
|
||||
},
|
||||
wantShell: "/bin/bash",
|
||||
},
|
||||
{
|
||||
name: "root entry",
|
||||
input: "root:x:0:0:root:/root:/bin/bash",
|
||||
wantUser: &user.User{
|
||||
Username: "root",
|
||||
Uid: "0",
|
||||
Gid: "0",
|
||||
Name: "root",
|
||||
HomeDir: "/root",
|
||||
},
|
||||
wantShell: "/bin/bash",
|
||||
},
|
||||
{
|
||||
name: "empty gecos field",
|
||||
input: "svc:x:999:999::/var/lib/svc:/usr/sbin/nologin",
|
||||
wantUser: &user.User{
|
||||
Username: "svc",
|
||||
Uid: "999",
|
||||
Gid: "999",
|
||||
Name: "",
|
||||
HomeDir: "/var/lib/svc",
|
||||
},
|
||||
wantShell: "/usr/sbin/nologin",
|
||||
},
|
||||
{
|
||||
name: "gecos with commas",
|
||||
input: "john:x:1002:1002:John Doe,Room 101,555-1234,555-4321:/home/john:/bin/zsh",
|
||||
wantUser: &user.User{
|
||||
Username: "john",
|
||||
Uid: "1002",
|
||||
Gid: "1002",
|
||||
Name: "John Doe,Room 101,555-1234,555-4321",
|
||||
HomeDir: "/home/john",
|
||||
},
|
||||
wantShell: "/bin/zsh",
|
||||
},
|
||||
{
|
||||
name: "remote user with large UID",
|
||||
input: "remoteuser:*:50001:50001:Remote User:/home/remoteuser:/bin/bash\n",
|
||||
wantUser: &user.User{
|
||||
Username: "remoteuser",
|
||||
Uid: "50001",
|
||||
Gid: "50001",
|
||||
Name: "Remote User",
|
||||
HomeDir: "/home/remoteuser",
|
||||
},
|
||||
wantShell: "/bin/bash",
|
||||
},
|
||||
{
|
||||
name: "no shell field (only 6 fields)",
|
||||
input: "minimal:x:1000:1000::/home/minimal",
|
||||
wantUser: &user.User{
|
||||
Username: "minimal",
|
||||
Uid: "1000",
|
||||
Gid: "1000",
|
||||
Name: "",
|
||||
HomeDir: "/home/minimal",
|
||||
},
|
||||
wantShell: "",
|
||||
},
|
||||
{
|
||||
name: "too few fields",
|
||||
input: "bad:x:1000",
|
||||
wantErr: true,
|
||||
errContains: "need 6+ fields",
|
||||
},
|
||||
{
|
||||
name: "empty username",
|
||||
input: ":x:1000:1000::/home/test:/bin/bash",
|
||||
wantErr: true,
|
||||
errContains: "missing required fields",
|
||||
},
|
||||
{
|
||||
name: "empty UID",
|
||||
input: "test:x::1000::/home/test:/bin/bash",
|
||||
wantErr: true,
|
||||
errContains: "missing required fields",
|
||||
},
|
||||
{
|
||||
name: "empty GID",
|
||||
input: "test:x:1000:::/home/test:/bin/bash",
|
||||
wantErr: true,
|
||||
errContains: "missing required fields",
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
input: "",
|
||||
wantErr: true,
|
||||
errContains: "need 6+ fields",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
u, shell, err := parseGetentPasswd(tt.input)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
if tt.errContains != "" {
|
||||
assert.Contains(t, err.Error(), tt.errContains)
|
||||
}
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantUser.Username, u.Username, "username")
|
||||
assert.Equal(t, tt.wantUser.Uid, u.Uid, "UID")
|
||||
assert.Equal(t, tt.wantUser.Gid, u.Gid, "GID")
|
||||
assert.Equal(t, tt.wantUser.Name, u.Name, "name/gecos")
|
||||
assert.Equal(t, tt.wantUser.HomeDir, u.HomeDir, "home directory")
|
||||
assert.Equal(t, tt.wantShell, shell, "shell")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGetentInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
{"normal username", "alice", true},
|
||||
{"numeric UID", "1001", true},
|
||||
{"dots and underscores", "alice.bob_test", true},
|
||||
{"hyphen", "alice-bob", true},
|
||||
{"kerberos principal", "user@REALM", true},
|
||||
{"samba machine account", "MACHINE$", true},
|
||||
{"NIS compat", "+user", true},
|
||||
{"empty", "", false},
|
||||
{"null byte", "alice\x00bob", false},
|
||||
{"newline", "alice\nbob", false},
|
||||
{"tab", "alice\tbob", false},
|
||||
{"control char", "alice\x01bob", false},
|
||||
{"DEL char", "alice\x7fbob", false},
|
||||
{"space rejected", "alice bob", false},
|
||||
{"semicolon rejected", "alice;bob", false},
|
||||
{"backtick rejected", "alice`bob", false},
|
||||
{"pipe rejected", "alice|bob", false},
|
||||
{"33 chars exceeds non-linux max", makeLongString(33), runtime.GOOS == "linux"},
|
||||
{"256 chars at linux max", makeLongString(256), runtime.GOOS == "linux"},
|
||||
{"257 chars exceeds all limits", makeLongString(257), false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, validateGetentInput(tt.input))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func makeLongString(n int) string {
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = 'a'
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestRunGetent_RootUser(t *testing.T) {
|
||||
if _, err := exec.LookPath("getent"); err != nil {
|
||||
t.Skip("getent not available on this system")
|
||||
}
|
||||
|
||||
u, shell, err := runGetent("root")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "root", u.Username)
|
||||
assert.Equal(t, "0", u.Uid)
|
||||
assert.Equal(t, "0", u.Gid)
|
||||
assert.NotEmpty(t, shell, "root should have a shell")
|
||||
}
|
||||
|
||||
func TestRunGetent_ByUID(t *testing.T) {
|
||||
if _, err := exec.LookPath("getent"); err != nil {
|
||||
t.Skip("getent not available on this system")
|
||||
}
|
||||
|
||||
u, _, err := runGetent("0")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "root", u.Username)
|
||||
assert.Equal(t, "0", u.Uid)
|
||||
}
|
||||
|
||||
func TestRunGetent_NonexistentUser(t *testing.T) {
|
||||
if _, err := exec.LookPath("getent"); err != nil {
|
||||
t.Skip("getent not available on this system")
|
||||
}
|
||||
|
||||
_, _, err := runGetent("nonexistent_user_xyzzy_12345")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRunGetent_InvalidInput(t *testing.T) {
|
||||
_, _, err := runGetent("")
|
||||
assert.Error(t, err)
|
||||
|
||||
_, _, err = runGetent("user\x00name")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRunGetent_NotAvailable(t *testing.T) {
|
||||
if _, err := exec.LookPath("getent"); err == nil {
|
||||
t.Skip("getent is available, can't test missing case")
|
||||
}
|
||||
|
||||
_, _, err := runGetent("root")
|
||||
assert.Error(t, err, "should fail when getent is not installed")
|
||||
}
|
||||
|
||||
func TestRunIdGroups_CurrentUser(t *testing.T) {
|
||||
if _, err := exec.LookPath("id"); err != nil {
|
||||
t.Skip("id not available on this system")
|
||||
}
|
||||
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
groups, err := runIdGroups(current.Username)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, groups, "current user should have at least one group")
|
||||
|
||||
for _, gid := range groups {
|
||||
_, err := strconv.ParseUint(gid, 10, 32)
|
||||
assert.NoError(t, err, "group ID %q should be a valid uint32", gid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunIdGroups_NonexistentUser(t *testing.T) {
|
||||
if _, err := exec.LookPath("id"); err != nil {
|
||||
t.Skip("id not available on this system")
|
||||
}
|
||||
|
||||
_, err := runIdGroups("nonexistent_user_xyzzy_12345")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRunIdGroups_InvalidInput(t *testing.T) {
|
||||
_, err := runIdGroups("")
|
||||
assert.Error(t, err)
|
||||
|
||||
_, err = runIdGroups("user\x00name")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestGetentResultsMatchStdlib(t *testing.T) {
|
||||
if _, err := exec.LookPath("getent"); err != nil {
|
||||
t.Skip("getent not available on this system")
|
||||
}
|
||||
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
getentUser, _, err := runGetent(current.Username)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, current.Username, getentUser.Username, "username should match")
|
||||
assert.Equal(t, current.Uid, getentUser.Uid, "UID should match")
|
||||
assert.Equal(t, current.Gid, getentUser.Gid, "GID should match")
|
||||
assert.Equal(t, current.HomeDir, getentUser.HomeDir, "home directory should match")
|
||||
}
|
||||
|
||||
func TestGetentResultsMatchStdlib_ByUID(t *testing.T) {
|
||||
if _, err := exec.LookPath("getent"); err != nil {
|
||||
t.Skip("getent not available on this system")
|
||||
}
|
||||
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
getentUser, _, err := runGetent(current.Uid)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, current.Username, getentUser.Username, "username should match when looked up by UID")
|
||||
assert.Equal(t, current.Uid, getentUser.Uid, "UID should match")
|
||||
}
|
||||
|
||||
func TestIdGroupsMatchStdlib(t *testing.T) {
|
||||
if _, err := exec.LookPath("id"); err != nil {
|
||||
t.Skip("id not available on this system")
|
||||
}
|
||||
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
stdGroups, err := current.GroupIds()
|
||||
if err != nil {
|
||||
t.Skip("os/user.GroupIds() not working, likely CGO_ENABLED=0")
|
||||
}
|
||||
|
||||
idGroups, err := runIdGroups(current.Username)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Deduplicate both lists: id -G can return duplicates (e.g., root in Docker)
|
||||
// and ElementsMatch treats duplicates as distinct.
|
||||
assert.ElementsMatch(t, uniqueStrings(stdGroups), uniqueStrings(idGroups), "id -G should return same groups as os/user")
|
||||
}
|
||||
|
||||
func uniqueStrings(ss []string) []string {
|
||||
seen := make(map[string]struct{}, len(ss))
|
||||
out := make([]string, 0, len(ss))
|
||||
for _, s := range ss {
|
||||
if _, ok := seen[s]; ok {
|
||||
continue
|
||||
}
|
||||
seen[s] = struct{}{}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestGetShellFromPasswd_CurrentUser verifies that getShellFromPasswd correctly
|
||||
// reads the current user's shell from /etc/passwd by comparing it against what
|
||||
// getent reports (which goes through NSS).
|
||||
func TestGetShellFromPasswd_CurrentUser(t *testing.T) {
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
shell := getShellFromPasswd(current.Uid)
|
||||
if shell == "" {
|
||||
t.Skip("current user not found in /etc/passwd (may be an NSS-only user)")
|
||||
}
|
||||
|
||||
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)
|
||||
if getentErr == nil && getentShell != "" {
|
||||
assert.Equal(t, getentShell, shell, "shell from /etc/passwd should match getent")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetShellFromPasswd_RootUser verifies that getShellFromPasswd can read
|
||||
// root's shell from /etc/passwd. Root is guaranteed to be in /etc/passwd on
|
||||
// any standard Unix system.
|
||||
func TestGetShellFromPasswd_RootUser(t *testing.T) {
|
||||
shell := getShellFromPasswd("0")
|
||||
require.NotEmpty(t, shell, "root (UID 0) must be in /etc/passwd")
|
||||
assert.True(t, shell[0] == '/', "root shell should be an absolute path, got %q", shell)
|
||||
}
|
||||
|
||||
// TestGetShellFromPasswd_NonexistentUID verifies that getShellFromPasswd
|
||||
// returns empty for a UID that doesn't exist in /etc/passwd.
|
||||
func TestGetShellFromPasswd_NonexistentUID(t *testing.T) {
|
||||
shell := getShellFromPasswd("4294967294")
|
||||
assert.Empty(t, shell, "nonexistent UID should return empty shell")
|
||||
}
|
||||
|
||||
// TestGetShellFromPasswd_MatchesGetentForKnownUsers reads /etc/passwd directly
|
||||
// and cross-validates every entry against getent to ensure parseGetentPasswd
|
||||
// and getShellFromPasswd agree on shell values.
|
||||
func TestGetShellFromPasswd_MatchesGetentForKnownUsers(t *testing.T) {
|
||||
if _, err := exec.LookPath("getent"); err != nil {
|
||||
t.Skip("getent not available")
|
||||
}
|
||||
|
||||
// Pick a few well-known system UIDs that are virtually always in /etc/passwd.
|
||||
uids := []string{"0"} // root
|
||||
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
uids = append(uids, current.Uid)
|
||||
|
||||
for _, uid := range uids {
|
||||
passwdShell := getShellFromPasswd(uid)
|
||||
if passwdShell == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
_, getentShell, err := runGetent(uid)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
assert.Equal(t, getentShell, passwdShell, "shell mismatch for UID %s", uid)
|
||||
}
|
||||
}
|
||||
26
client/ssh/server/getent_windows.go
Normal file
26
client/ssh/server/getent_windows.go
Normal file
@@ -0,0 +1,26 @@
|
||||
//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()
|
||||
}
|
||||
@@ -49,10 +49,14 @@ func getWindowsUserShell() string {
|
||||
return `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`
|
||||
}
|
||||
|
||||
// getUnixUserShell returns the shell for Unix-like systems
|
||||
// getUnixUserShell returns the shell for Unix-like systems.
|
||||
// Tries /etc/passwd first (fast, no subprocess), falls back to getent for NSS users.
|
||||
func getUnixUserShell(userID string) string {
|
||||
shell := getShellFromPasswd(userID)
|
||||
if shell != "" {
|
||||
if shell := getShellFromPasswd(userID); shell != "" {
|
||||
return shell
|
||||
}
|
||||
|
||||
if shell := getShellFromGetent(userID); shell != "" {
|
||||
return shell
|
||||
}
|
||||
|
||||
|
||||
@@ -23,8 +23,8 @@ func isPlatformUnix() bool {
|
||||
|
||||
// Dependency injection variables for testing - allows mocking dynamic runtime checks
|
||||
var (
|
||||
getCurrentUser = user.Current
|
||||
lookupUser = user.Lookup
|
||||
getCurrentUser = currentUserWithGetent
|
||||
lookupUser = lookupWithGetent
|
||||
getCurrentOS = func() string { return runtime.GOOS }
|
||||
getIsProcessPrivileged = isCurrentProcessPrivileged
|
||||
|
||||
|
||||
@@ -146,32 +146,30 @@ func (s *Server) parseUserCredentials(localUser *user.User) (uint32, uint32, []u
|
||||
}
|
||||
gid := uint32(gid64)
|
||||
|
||||
groups, err := s.getSupplementaryGroups(localUser.Username)
|
||||
if err != nil {
|
||||
log.Warnf("failed to get supplementary groups for user %s: %v", localUser.Username, err)
|
||||
groups, err := s.getSupplementaryGroups(localUser)
|
||||
if err != nil || len(groups) == 0 {
|
||||
if err != nil {
|
||||
log.Warnf("failed to get supplementary groups for user %s: %v", localUser.Username, err)
|
||||
}
|
||||
groups = []uint32{gid}
|
||||
}
|
||||
|
||||
return uid, gid, groups, nil
|
||||
}
|
||||
|
||||
// getSupplementaryGroups retrieves supplementary group IDs for a user
|
||||
func (s *Server) getSupplementaryGroups(username string) ([]uint32, error) {
|
||||
u, err := user.Lookup(username)
|
||||
// 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)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lookup user %s: %w", username, err)
|
||||
}
|
||||
|
||||
groupIDStrings, err := u.GroupIds()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get group IDs for user %s: %w", username, err)
|
||||
return nil, fmt.Errorf("get group IDs for user %s: %w", u.Username, err)
|
||||
}
|
||||
|
||||
groups := make([]uint32, len(groupIDStrings))
|
||||
for i, gidStr := range groupIDStrings {
|
||||
gid64, err := strconv.ParseUint(gidStr, 10, 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid group ID %s for user %s: %w", gidStr, username, err)
|
||||
return nil, fmt.Errorf("invalid group ID %s for user %s: %w", gidStr, u.Username, err)
|
||||
}
|
||||
groups[i] = uint32(gid64)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user