Browse Source

ssh/tailssh,util: extract new osuser package from ssh code (#10170)

This package is a wrapper for os/user that handles non-cgo builds,
gokrazy and user shells.

Updates tailscale/corp#15405

Signed-off-by: Andrew Lytvynov <[email protected]>
Andrew Lytvynov 2 years ago
parent
commit
1fc1077052
3 changed files with 143 additions and 86 deletions
  1. 1 0
      cmd/tailscaled/depaware.txt
  2. 3 86
      ssh/tailssh/user.go
  3. 139 0
      util/osuser/user.go

+ 1 - 0
cmd/tailscaled/depaware.txt

@@ -360,6 +360,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
      💣 tailscale.com/util/osdiag                                    from tailscale.com/cmd/tailscaled+
    W 💣 tailscale.com/util/osdiag/internal/wsc                       from tailscale.com/util/osdiag
         tailscale.com/util/osshare                                   from tailscale.com/ipn/ipnlocal+
+  LD    tailscale.com/util/osuser                                    from tailscale.com/ssh/tailssh
         tailscale.com/util/race                                      from tailscale.com/net/dns/resolver
         tailscale.com/util/racebuild                                 from tailscale.com/logpolicy
         tailscale.com/util/rands                                     from tailscale.com/ipn/ipnlocal+

+ 3 - 86
ssh/tailssh/user.go

@@ -6,10 +6,7 @@
 package tailssh
 
 import (
-	"context"
-	"errors"
 	"io"
-	"log"
 	"os"
 	"os/exec"
 	"os/user"
@@ -17,13 +14,12 @@ import (
 	"runtime"
 	"strconv"
 	"strings"
-	"time"
-	"unicode/utf8"
 
 	"go4.org/mem"
 	"tailscale.com/envknob"
 	"tailscale.com/hostinfo"
 	"tailscale.com/util/lineread"
+	"tailscale.com/util/osuser"
 	"tailscale.com/version/distro"
 )
 
@@ -51,90 +47,11 @@ func (u *userMeta) GroupIds() ([]string, error) {
 // userLookup is like os/user.Lookup but it returns a *userMeta wrapper
 // around a *user.User with extra fields.
 func userLookup(username string) (*userMeta, error) {
-	if runtime.GOOS != "linux" {
-		return userLookupStd(username)
-	}
-
-	// No getent on Gokrazy. So hard-code the login shell.
-	if distro.Get() == distro.Gokrazy {
-		um, err := userLookupStd(username)
-		if err != nil {
-			um = &userMeta{
-				User: user.User{
-					Uid:      "0",
-					Gid:      "0",
-					Username: "root",
-					Name:     "Gokrazy",
-					HomeDir:  "/",
-				},
-			}
-		}
-		um.loginShellCached = "/tmp/serial-busybox/ash"
-		return um, err
-	}
-
-	// On Linux, default to using "getent" to look up users so that
-	// even with static tailscaled binaries without cgo (as we distribute),
-	// we can still look up PAM/NSS users which the standard library's
-	// os/user without cgo won't get (because of no libc hooks).
-	// But if "getent" fails, userLookupGetent falls back to the standard
-	// library anyway.
-	return userLookupGetent(username)
-}
-
-func validUsername(uid string) bool {
-	maxUid := 32
-	if runtime.GOOS == "linux" {
-		maxUid = 256
-	}
-	if len(uid) > maxUid || len(uid) == 0 {
-		return false
-	}
-	for _, r := range uid {
-		if r < ' ' || r == 0x7f || r == utf8.RuneError { // TODO(bradfitz): more?
-			return false
-		}
-	}
-	return true
-}
-
-func userLookupGetent(username string) (*userMeta, error) {
-	// Do some basic validation before passing this string to "getent", even though
-	// getent should do its own validation.
-	if !validUsername(username) {
-		return nil, errors.New("invalid username")
-	}
-	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
-	defer cancel()
-	out, err := exec.CommandContext(ctx, "getent", "passwd", username).Output()
-	if err != nil {
-		log.Printf("error calling getent for user %q: %v", username, err)
-		return userLookupStd(username)
-	}
-	// output is "alice:x:1001:1001:Alice Smith,,,:/home/alice:/bin/bash"
-	f := strings.SplitN(strings.TrimSpace(string(out)), ":", 10)
-	for len(f) < 7 {
-		f = append(f, "")
-	}
-	um := &userMeta{
-		User: user.User{
-			Username: f[0],
-			Uid:      f[2],
-			Gid:      f[3],
-			Name:     f[4],
-			HomeDir:  f[5],
-		},
-		loginShellCached: f[6],
-	}
-	return um, nil
-}
-
-func userLookupStd(username string) (*userMeta, error) {
-	u, err := user.Lookup(username)
+	u, s, err := osuser.LookupByUsernameWithShell(username)
 	if err != nil {
 		return nil, err
 	}
-	return &userMeta{User: *u}, nil
+	return &userMeta{User: *u, loginShellCached: s}, nil
 }
 
 func (u *userMeta) LoginShell() string {

+ 139 - 0
util/osuser/user.go

@@ -0,0 +1,139 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+// Package osuser implements OS user lookup. It's a wrapper around os/user that
+// works on non-cgo builds.
+package osuser
+
+import (
+	"context"
+	"errors"
+	"log"
+	"os/exec"
+	"os/user"
+	"runtime"
+	"strings"
+	"time"
+	"unicode/utf8"
+
+	"tailscale.com/version/distro"
+)
+
+// LookupByUIDWithShell is like os/user.LookupId but handles a few edge cases
+// like gokrazy and non-cgo lookups, and returns the user shell. The user shell
+// lookup is best-effort and may be empty.
+func LookupByUIDWithShell(uid string) (u *user.User, shell string, err error) {
+	return lookup(uid, user.LookupId, true)
+}
+
+// LookupByUsernameWithShell is like os/user.Lookup but handles a few edge
+// cases like gokrazy and non-cgo lookups, and returns the user shell. The user
+// shell lookup is best-effort and may be empty.
+func LookupByUsernameWithShell(username string) (u *user.User, shell string, err error) {
+	return lookup(username, user.Lookup, true)
+}
+
+// LookupByUID is like os/user.LookupId but handles a few edge cases like
+// gokrazy and non-cgo lookups.
+func LookupByUID(uid string) (*user.User, error) {
+	u, _, err := lookup(uid, user.LookupId, false)
+	return u, err
+}
+
+// LookupByUsername is like os/user.Lookup but handles a few edge cases like
+// gokrazy and non-cgo lookups.
+func LookupByUsername(username string) (*user.User, error) {
+	u, _, err := lookup(username, user.Lookup, false)
+	return u, err
+}
+
+// lookupStd is either user.Lookup or user.LookupId.
+type lookupStd func(string) (*user.User, error)
+
+func lookup(usernameOrUID string, std lookupStd, wantShell bool) (*user.User, string, error) {
+	// TODO(awly): we should use genet on more platforms, like FreeBSD.
+	if runtime.GOOS != "linux" {
+		u, err := std(usernameOrUID)
+		return u, "", err
+	}
+
+	// No getent on Gokrazy. So hard-code the login shell.
+	if distro.Get() == distro.Gokrazy {
+		var shell string
+		if wantShell {
+			shell = "/tmp/serial-busybox/ash"
+		}
+		u, err := std(usernameOrUID)
+		if err != nil {
+			return &user.User{
+				Uid:      "0",
+				Gid:      "0",
+				Username: "root",
+				Name:     "Gokrazy",
+				HomeDir:  "/",
+			}, shell, nil
+		}
+		return u, shell, nil
+	}
+
+	// Start with getent if caller wants to get the user shell.
+	if wantShell {
+		return userLookupGetent(usernameOrUID, std)
+	}
+	// If shell is not required, try os/user.Lookup* first and only use getent
+	// if that fails. This avoids spawning a child process when os/user lookup
+	// succeeds.
+	if u, err := std(usernameOrUID); err == nil {
+		return u, "", nil
+	}
+	return userLookupGetent(usernameOrUID, std)
+}
+
+func checkGetentInput(usernameOrUID string) bool {
+	maxUid := 32
+	if runtime.GOOS == "linux" {
+		maxUid = 256
+	}
+	if len(usernameOrUID) > maxUid || len(usernameOrUID) == 0 {
+		return false
+	}
+	for _, r := range usernameOrUID {
+		if r < ' ' || r == 0x7f || r == utf8.RuneError { // TODO(bradfitz): more?
+			return false
+		}
+	}
+	return true
+}
+
+// userLookupGetent uses "getent" to look up users so that even with static
+// tailscaled binaries without cgo (as we distribute), we can still look up
+// PAM/NSS users which the standard library's os/user without cgo won't get
+// (because of no libc hooks). If "getent" fails, userLookupGetent falls back
+// to the standard library.
+func userLookupGetent(usernameOrUID string, std lookupStd) (*user.User, string, error) {
+	// Do some basic validation before passing this string to "getent", even though
+	// getent should do its own validation.
+	if !checkGetentInput(usernameOrUID) {
+		return nil, "", errors.New("invalid username or UID")
+	}
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+	out, err := exec.CommandContext(ctx, "getent", "passwd", usernameOrUID).Output()
+	if err != nil {
+		log.Printf("error calling getent for user %q: %v", usernameOrUID, err)
+		u, err := std(usernameOrUID)
+		return u, "", err
+	}
+	// output is "alice:x:1001:1001:Alice Smith,,,:/home/alice:/bin/bash"
+	f := strings.SplitN(strings.TrimSpace(string(out)), ":", 10)
+	for len(f) < 7 {
+		f = append(f, "")
+	}
+	return &user.User{
+		Username: f[0],
+		Uid:      f[2],
+		Gid:      f[3],
+		Name:     f[4],
+		HomeDir:  f[5],
+	}, f[6], nil
+}