From 1fc1077052dfa77e0efc7b5b308ead5e5e3e1965 Mon Sep 17 00:00:00 2001 From: Andrew Lytvynov Date: Thu, 9 Nov 2023 10:49:06 -0700 Subject: [PATCH] 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 --- cmd/tailscaled/depaware.txt | 1 + ssh/tailssh/user.go | 89 +---------------------- util/osuser/user.go | 139 ++++++++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 86 deletions(-) create mode 100644 util/osuser/user.go diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index c65e635c0..046d4f5c5 100644 --- a/cmd/tailscaled/depaware.txt +++ b/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+ diff --git a/ssh/tailssh/user.go b/ssh/tailssh/user.go index 623aab3ad..b27d605ed 100644 --- a/ssh/tailssh/user.go +++ b/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 { diff --git a/util/osuser/user.go b/util/osuser/user.go new file mode 100644 index 000000000..b10186c97 --- /dev/null +++ b/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 +}