diff --git a/ipn/ipnlocal/c2n.go b/ipn/ipnlocal/c2n.go index 00657371f..83e91883a 100644 --- a/ipn/ipnlocal/c2n.go +++ b/ipn/ipnlocal/c2n.go @@ -5,8 +5,11 @@ package ipnlocal import ( + "encoding/json" "io" "net/http" + + "tailscale.com/tailcfg" ) func (b *LocalBackend) handleC2N(w http.ResponseWriter, r *http.Request) { @@ -15,6 +18,21 @@ func (b *LocalBackend) handleC2N(w http.ResponseWriter, r *http.Request) { // Test handler. body, _ := io.ReadAll(r.Body) w.Write(body) + case "/ssh/usernames": + var req tailcfg.C2NSSHUsernamesRequest + if r.Method == "POST" { + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + } + res, err := b.getSSHUsernames(&req) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(res) default: http.Error(w, "unknown c2n path", http.StatusBadRequest) } diff --git a/ipn/ipnlocal/ssh.go b/ipn/ipnlocal/ssh.go index 58cc5d5ff..1409cdcf4 100644 --- a/ipn/ipnlocal/ssh.go +++ b/ipn/ipnlocal/ssh.go @@ -19,11 +19,17 @@ import ( "errors" "fmt" "os" + "os/exec" "path/filepath" + "runtime" "strings" "sync" "github.com/tailscale/golang-x-crypto/ssh" + "go4.org/mem" + "golang.org/x/exp/slices" + "tailscale.com/tailcfg" + "tailscale.com/util/lineread" "tailscale.com/util/mak" ) @@ -32,6 +38,77 @@ import ( // running as root. var keyTypes = []string{"rsa", "ecdsa", "ed25519"} +func (b *LocalBackend) getSSHUsernames(req *tailcfg.C2NSSHUsernamesRequest) (*tailcfg.C2NSSHUsernamesResponse, error) { + res := new(tailcfg.C2NSSHUsernamesResponse) + + b.mu.Lock() + defer b.mu.Unlock() + + if b.sshServer == nil { + return res, nil + } + max := 10 + if req != nil && req.Max != 0 { + max = req.Max + } + + add := func(u string) { + if req != nil && req.Exclude[u] { + return + } + switch u { + case "nobody", "daemon", "sync": + return + } + if slices.Contains(res.Usernames, u) { + return + } + if len(res.Usernames) > max { + // Enough for a hint. + return + } + res.Usernames = append(res.Usernames, u) + } + + if b.prefs != nil && b.prefs.OperatorUser != "" { + add(b.prefs.OperatorUser) + } + + // Check popular usernames and see if they exist with a real shell. + switch runtime.GOOS { + case "darwin": + out, err := exec.Command("dscl", ".", "list", "/Users").Output() + if err != nil { + return nil, err + } + lineread.Reader(bytes.NewReader(out), func(line []byte) error { + line = bytes.TrimSpace(line) + if len(line) == 0 || line[0] == '_' { + return nil + } + add(string(line)) + return nil + }) + default: + lineread.File("/etc/passwd", func(line []byte) error { + line = bytes.TrimSpace(line) + if len(line) == 0 || line[0] == '#' || line[0] == '_' { + return nil + } + if mem.HasSuffix(mem.B(line), mem.S("/nologin")) || + mem.HasSuffix(mem.B(line), mem.S("/false")) { + return nil + } + colon := bytes.IndexByte(line, ':') + if colon != -1 { + add(string(line[:colon])) + } + return nil + }) + } + return res, nil +} + func (b *LocalBackend) GetSSH_HostKeys() (keys []ssh.Signer, err error) { var existing map[string]ssh.Signer if os.Geteuid() == 0 { diff --git a/ipn/ipnlocal/ssh_stub.go b/ipn/ipnlocal/ssh_stub.go index 00d6c2c70..16ef98f32 100644 --- a/ipn/ipnlocal/ssh_stub.go +++ b/ipn/ipnlocal/ssh_stub.go @@ -6,6 +6,16 @@ package ipnlocal +import ( + "errors" + + "tailscale.com/tailcfg" +) + func (b *LocalBackend) getSSHHostKeyPublicStrings() []string { return nil } + +func (b *LocalBackend) getSSHUsernames(*tailcfg.C2NSSHUsernamesRequest) (*tailcfg.C2NSSHUsernamesResponse, error) { + return nil, errors.New("not implemented") +} diff --git a/ipn/ipnlocal/ssh_test.go b/ipn/ipnlocal/ssh_test.go index 42c99755a..14dd3f6a3 100644 --- a/ipn/ipnlocal/ssh_test.go +++ b/ipn/ipnlocal/ssh_test.go @@ -2,14 +2,18 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -//go:build linux -// +build linux +//go:build linux || (darwin && !ios) +// +build linux darwin,!ios package ipnlocal import ( + "encoding/json" "reflect" "testing" + + "tailscale.com/tailcfg" + "tailscale.com/util/must" ) func TestSSHKeyGen(t *testing.T) { @@ -40,3 +44,17 @@ func TestSSHKeyGen(t *testing.T) { t.Errorf("got different keys on second call") } } + +type fakeSSHServer struct { + SSHServer +} + +func TestGetSSHUsernames(t *testing.T) { + b := new(LocalBackend) + b.sshServer = fakeSSHServer{} + res, err := b.getSSHUsernames(new(tailcfg.C2NSSHUsernamesRequest)) + if err != nil { + t.Fatal(err) + } + t.Logf("Got: %s", must.Get(json.Marshal(res))) +} diff --git a/tailcfg/c2ntypes.go b/tailcfg/c2ntypes.go new file mode 100644 index 000000000..3212eaa5f --- /dev/null +++ b/tailcfg/c2ntypes.go @@ -0,0 +1,36 @@ +// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// c2n (control-to-node) API types. + +package tailcfg + +// C2NSSHUsernamesRequest is the request for the /ssh/usernames. +// A GET request without a request body is equivalent to the zero value of this type. +// Otherwise, a POST request with a JSON-encoded request body is expected. +type C2NSSHUsernamesRequest struct { + // Exclude optionally specifies usernames to exclude + // from the response. + Exclude map[string]bool `json:",omitempty"` + + // Max is the maximum number of usernames to return. + // If zero, a default limit is used. + Max int `json:",omitempty"` +} + +// C2NSSHUsernamesResponse is the response (from node to control) from the +// /ssh/usernames handler. +// +// It returns username auto-complete suggestions for a user to SSH to this node. +// It's only shown to people who already have SSH access to the node. If this +// returns multiple usernames, only the usernames that would have access per the +// tailnet's ACLs are shown to the user so as to not leak the existence of +// usernames. +type C2NSSHUsernamesResponse struct { + // Usernames is the list of usernames to suggest. If the machine has many + // users, this list may be truncated. If getting the list of usernames might + // be too slow or unavailable, this list might be empty. This is effectively + // just a best effort set of hints. + Usernames []string +}