-
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
[client] Fall back to getent/id for SSH user lookup in static builds #5510
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| return strings.Fields(trimmed), nil | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.