Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions client/ssh/server/getent_cgo_unix.go
Original file line number Diff line number Diff line change
@@ -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 changes: 74 additions & 0 deletions client/ssh/server/getent_nocgo_unix.go
Original file line number Diff line number Diff line change
@@ -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 changes: 172 additions & 0 deletions client/ssh/server/getent_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Comment thread
lixmal marked this conversation as resolved.
122 changes: 122 additions & 0 deletions client/ssh/server/getent_unix.go
Original file line number Diff line number Diff line change
@@ -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)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return strings.Fields(trimmed), nil
}
Loading
Loading