diff --git a/cmd/k8s-operator/depaware.txt b/cmd/k8s-operator/depaware.txt index 436202216..cbb4738d7 100644 --- a/cmd/k8s-operator/depaware.txt +++ b/cmd/k8s-operator/depaware.txt @@ -1000,10 +1000,9 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/ golang.org/x/crypto/argon2 from tailscale.com/tka golang.org/x/crypto/blake2b from golang.org/x/crypto/argon2+ golang.org/x/crypto/blake2s from github.com/tailscale/wireguard-go/device+ - LD golang.org/x/crypto/blowfish from golang.org/x/crypto/ssh/internal/bcrypt_pbkdf - golang.org/x/crypto/chacha20 from golang.org/x/crypto/ssh+ + golang.org/x/crypto/chacha20 from golang.org/x/crypto/chacha20poly1305 golang.org/x/crypto/chacha20poly1305 from github.com/tailscale/wireguard-go/device+ - golang.org/x/crypto/curve25519 from golang.org/x/crypto/ssh+ + golang.org/x/crypto/curve25519 from github.com/tailscale/wireguard-go/device+ golang.org/x/crypto/hkdf from tailscale.com/control/controlbase golang.org/x/crypto/internal/alias from golang.org/x/crypto/chacha20+ golang.org/x/crypto/internal/poly1305 from golang.org/x/crypto/chacha20poly1305+ @@ -1011,8 +1010,6 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/ golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box golang.org/x/crypto/poly1305 from github.com/tailscale/wireguard-go/device golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+ - LD golang.org/x/crypto/ssh from tailscale.com/ipn/ipnlocal - LD golang.org/x/crypto/ssh/internal/bcrypt_pbkdf from golang.org/x/crypto/ssh golang.org/x/exp/constraints from tailscale.com/tsweb/varz+ golang.org/x/exp/maps from sigs.k8s.io/controller-runtime/pkg/cache+ golang.org/x/exp/slices from tailscale.com/cmd/k8s-operator+ @@ -1078,7 +1075,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/ crypto/aes from crypto/tls+ crypto/cipher from crypto/aes+ crypto/des from crypto/tls+ - crypto/dsa from crypto/x509+ + crypto/dsa from crypto/x509 crypto/ecdh from crypto/ecdsa+ crypto/ecdsa from crypto/tls+ crypto/ed25519 from crypto/tls+ @@ -1127,9 +1124,9 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/ crypto/internal/randutil from crypto/internal/rand crypto/internal/sysrand from crypto/internal/fips140/drbg crypto/md5 from crypto/tls+ - crypto/mlkem from golang.org/x/crypto/ssh+ + crypto/mlkem from crypto/hpke+ crypto/rand from crypto/ed25519+ - crypto/rc4 from crypto/tls+ + crypto/rc4 from crypto/tls crypto/rsa from crypto/tls+ crypto/sha1 from crypto/tls+ crypto/sha256 from crypto/tls+ diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index e36c975e5..c34bd490a 100644 --- a/cmd/tailscaled/depaware.txt +++ b/cmd/tailscaled/depaware.txt @@ -303,6 +303,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de tailscale.com/feature/posture from tailscale.com/feature/condregister tailscale.com/feature/relayserver from tailscale.com/feature/condregister L tailscale.com/feature/sdnotify from tailscale.com/feature/condregister + LD tailscale.com/feature/ssh from tailscale.com/cmd/tailscaled tailscale.com/feature/syspolicy from tailscale.com/feature/condregister+ tailscale.com/feature/taildrop from tailscale.com/feature/condregister L tailscale.com/feature/tap from tailscale.com/feature/condregister @@ -387,7 +388,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de tailscale.com/proxymap from tailscale.com/tsd+ 💣 tailscale.com/safesocket from tailscale.com/client/local+ LD tailscale.com/sessionrecording from tailscale.com/ssh/tailssh - LD 💣 tailscale.com/ssh/tailssh from tailscale.com/cmd/tailscaled + LD 💣 tailscale.com/ssh/tailssh from tailscale.com/feature/ssh tailscale.com/syncs from tailscale.com/cmd/tailscaled+ tailscale.com/tailcfg from tailscale.com/client/local+ tailscale.com/tempfork/acme from tailscale.com/ipn/ipnlocal diff --git a/cmd/tailscaled/ssh.go b/cmd/tailscaled/ssh.go index e69cbd5dc..8de311794 100644 --- a/cmd/tailscaled/ssh.go +++ b/cmd/tailscaled/ssh.go @@ -5,5 +5,5 @@ package main -// Force registration of tailssh with LocalBackend. -import _ "tailscale.com/ssh/tailssh" +// Register implementations of various SSH hooks. +import _ "tailscale.com/feature/ssh" diff --git a/cmd/tsidp/depaware.txt b/cmd/tsidp/depaware.txt index 14239cfa2..2f1190393 100644 --- a/cmd/tsidp/depaware.txt +++ b/cmd/tsidp/depaware.txt @@ -401,8 +401,7 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar golang.org/x/crypto/argon2 from tailscale.com/tka golang.org/x/crypto/blake2b from golang.org/x/crypto/argon2+ golang.org/x/crypto/blake2s from github.com/tailscale/wireguard-go/device+ - LD golang.org/x/crypto/blowfish from golang.org/x/crypto/ssh/internal/bcrypt_pbkdf - golang.org/x/crypto/chacha20 from golang.org/x/crypto/chacha20poly1305+ + golang.org/x/crypto/chacha20 from golang.org/x/crypto/chacha20poly1305 golang.org/x/crypto/chacha20poly1305 from github.com/tailscale/wireguard-go/device+ golang.org/x/crypto/curve25519 from github.com/tailscale/wireguard-go/device+ golang.org/x/crypto/ed25519 from gopkg.in/square/go-jose.v2 @@ -414,8 +413,6 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar golang.org/x/crypto/pbkdf2 from gopkg.in/square/go-jose.v2 golang.org/x/crypto/poly1305 from github.com/tailscale/wireguard-go/device golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+ - LD golang.org/x/crypto/ssh from tailscale.com/ipn/ipnlocal - LD golang.org/x/crypto/ssh/internal/bcrypt_pbkdf from golang.org/x/crypto/ssh golang.org/x/exp/constraints from tailscale.com/tsweb/varz+ golang.org/x/exp/maps from tailscale.com/ipn/store/mem+ golang.org/x/net/bpf from github.com/mdlayher/netlink+ @@ -476,7 +473,7 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar crypto/aes from crypto/tls+ crypto/cipher from crypto/aes+ crypto/des from crypto/tls+ - crypto/dsa from crypto/x509+ + crypto/dsa from crypto/x509 crypto/ecdh from crypto/ecdsa+ crypto/ecdsa from crypto/tls+ crypto/ed25519 from crypto/tls+ @@ -525,9 +522,9 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar crypto/internal/randutil from crypto/internal/rand crypto/internal/sysrand from crypto/internal/fips140/drbg crypto/md5 from crypto/tls+ - crypto/mlkem from golang.org/x/crypto/ssh+ + crypto/mlkem from crypto/hpke+ crypto/rand from crypto/ed25519+ - crypto/rc4 from crypto/tls+ + crypto/rc4 from crypto/tls crypto/rsa from crypto/tls+ crypto/sha1 from crypto/tls+ crypto/sha256 from crypto/tls+ diff --git a/feature/hooks.go b/feature/hooks.go index 5cd3c0d81..7611499a1 100644 --- a/feature/hooks.go +++ b/feature/hooks.go @@ -67,6 +67,11 @@ func TPMAvailable() bool { return false } +// HookGetSSHHostKeyPublicStrings is a hook for the ssh/hostkeys package to +// provide SSH host key public strings to ipn/ipnlocal without ipnlocal needing +// to import golang.org/x/crypto/ssh. +var HookGetSSHHostKeyPublicStrings Hook[func(varRoot string, logf logger.Logf) ([]string, error)] + // HookHardwareAttestationAvailable is a hook that reports whether hardware // attestation is supported and available. var HookHardwareAttestationAvailable Hook[func() bool] diff --git a/feature/ssh/ssh.go b/feature/ssh/ssh.go new file mode 100644 index 000000000..bd2200591 --- /dev/null +++ b/feature/ssh/ssh.go @@ -0,0 +1,11 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build ((linux && !android) || (darwin && !ios) || freebsd || openbsd || plan9) && !ts_omit_ssh + +// Package ssh registers the Tailscale SSH feature, including host key +// management and the SSH server. +package ssh + +// Register implementations of various SSH hooks. +import _ "tailscale.com/ssh/tailssh" diff --git a/ipn/ipnlocal/c2n.go b/ipn/ipnlocal/c2n.go index ccce2a65d..8284872b9 100644 --- a/ipn/ipnlocal/c2n.go +++ b/ipn/ipnlocal/c2n.go @@ -44,9 +44,6 @@ func init() { // several candidate nodes is reachable and actually alive. RegisterC2N("/echo", handleC2NEcho) } - if buildfeatures.HasSSH { - RegisterC2N("/ssh/usernames", handleC2NSSHUsernames) - } if buildfeatures.HasLogTail { RegisterC2N("POST /logtail/flush", handleC2NLogtailFlush) } @@ -290,26 +287,6 @@ func handleC2NPprof(b *LocalBackend, w http.ResponseWriter, r *http.Request) { c2nPprof(w, r, profile) } -func handleC2NSSHUsernames(b *LocalBackend, w http.ResponseWriter, r *http.Request) { - if !buildfeatures.HasSSH { - http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented) - return - } - 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 - } - writeJSON(w, res) -} - func handleC2NSockStats(b *LocalBackend, w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain") if b.sockstatLogger == nil { diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 77bb14f36..ea5af0897 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -5646,10 +5646,12 @@ func (b *LocalBackend) applyPrefsToHostinfoLocked(hi *tailcfg.Hostinfo, prefs ip // TODO(bradfitz): this is called with b.mu held. Not ideal. // If the filesystem gets wedged or something we could block for // a long time. But probably fine. - var err error - sshHostKeys, err = b.getSSHHostKeyPublicStrings() - if err != nil { - b.logf("warning: unable to get SSH host keys, SSH will appear as disabled for this node: %v", err) + if f, ok := feature.HookGetSSHHostKeyPublicStrings.GetOk(); ok { + var err error + sshHostKeys, err = f(b.TailscaleVarRoot(), b.logf) + if err != nil { + b.logf("warning: unable to get SSH host keys, SSH will appear as disabled for this node: %v", err) + } } } hi.SSH_HostKeys = sshHostKeys @@ -6439,9 +6441,9 @@ func (b *LocalBackend) maybeSentHostinfoIfChangedLocked(prefs ipn.PrefsView) { } } -// operatorUserName returns the current pref's OperatorUser's name, or the +// OperatorUserName returns the current pref's OperatorUser's name, or the // empty string if none. -func (b *LocalBackend) operatorUserName() string { +func (b *LocalBackend) OperatorUserName() string { b.mu.Lock() defer b.mu.Unlock() prefs := b.pm.CurrentPrefs() @@ -6454,7 +6456,7 @@ func (b *LocalBackend) operatorUserName() string { // OperatorUserID returns the current pref's OperatorUser's ID (in // os/user.User.Uid string form), or the empty string if none. func (b *LocalBackend) OperatorUserID() string { - opUserName := b.operatorUserName() + opUserName := b.OperatorUserName() if opUserName == "" { return "" } diff --git a/ipn/ipnlocal/ssh.go b/ipn/ipnlocal/ssh.go deleted file mode 100644 index 56a6d60cc..000000000 --- a/ipn/ipnlocal/ssh.go +++ /dev/null @@ -1,234 +0,0 @@ -// Copyright (c) Tailscale Inc & contributors -// SPDX-License-Identifier: BSD-3-Clause - -//go:build ((linux && !android) || (darwin && !ios) || freebsd || openbsd || plan9) && !ts_omit_ssh - -package ipnlocal - -import ( - "bytes" - "crypto/ecdsa" - "crypto/ed25519" - "crypto/elliptic" - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "encoding/pem" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" - "slices" - "strings" - "sync" - - "go4.org/mem" - "golang.org/x/crypto/ssh" - "tailscale.com/tailcfg" - "tailscale.com/util/lineiter" - "tailscale.com/util/mak" -) - -// keyTypes are the SSH key types that we either try to read from the -// system's OpenSSH keys or try to generate for ourselves when not -// running as root. -var keyTypes = []string{"rsa", "ecdsa", "ed25519"} - -// getSSHUsernames discovers and returns the list of usernames that are -// potential Tailscale SSH user targets. -// -// Invariant: must not be called with b.mu held. -func (b *LocalBackend) getSSHUsernames(req *tailcfg.C2NSSHUsernamesRequest) (*tailcfg.C2NSSHUsernamesResponse, error) { - res := new(tailcfg.C2NSSHUsernamesResponse) - if !b.tailscaleSSHEnabled() { - 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 opUser := b.operatorUserName(); opUser != "" { - add(opUser) - } - - // 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 - } - for line := range lineiter.Bytes(out) { - line = bytes.TrimSpace(line) - if len(line) == 0 || line[0] == '_' { - continue - } - add(string(line)) - } - default: - for lr := range lineiter.File("/etc/passwd") { - line, err := lr.Value() - if err != nil { - break - } - line = bytes.TrimSpace(line) - if len(line) == 0 || line[0] == '#' || line[0] == '_' { - continue - } - if mem.HasSuffix(mem.B(line), mem.S("/nologin")) || - mem.HasSuffix(mem.B(line), mem.S("/false")) { - continue - } - before, _, ok := bytes.Cut(line, []byte{':'}) - if ok { - add(string(before)) - } - } - } - return res, nil -} - -func (b *LocalBackend) GetSSH_HostKeys() (keys []ssh.Signer, err error) { - var existing map[string]ssh.Signer - if os.Geteuid() == 0 { - existing = b.getSystemSSH_HostKeys() - } - return b.getTailscaleSSH_HostKeys(existing) -} - -// getTailscaleSSH_HostKeys returns the three (rsa, ecdsa, ed25519) SSH host -// keys, reusing the provided ones in existing if present in the map. -func (b *LocalBackend) getTailscaleSSH_HostKeys(existing map[string]ssh.Signer) (keys []ssh.Signer, err error) { - var keyDir string // lazily initialized $TAILSCALE_VAR/ssh dir. - for _, typ := range keyTypes { - if s, ok := existing[typ]; ok { - keys = append(keys, s) - continue - } - if keyDir == "" { - root := b.TailscaleVarRoot() - if root == "" { - return nil, errors.New("no var root for ssh keys") - } - keyDir = filepath.Join(root, "ssh") - if err := os.MkdirAll(keyDir, 0700); err != nil { - return nil, err - } - } - hostKey, err := b.hostKeyFileOrCreate(keyDir, typ) - if err != nil { - return nil, fmt.Errorf("error creating SSH host key type %q in %q: %w", typ, keyDir, err) - } - signer, err := ssh.ParsePrivateKey(hostKey) - if err != nil { - return nil, fmt.Errorf("error parsing SSH host key type %q from %q: %w", typ, keyDir, err) - } - keys = append(keys, signer) - } - return keys, nil -} - -var keyGenMu sync.Mutex - -func (b *LocalBackend) hostKeyFileOrCreate(keyDir, typ string) ([]byte, error) { - keyGenMu.Lock() - defer keyGenMu.Unlock() - - path := filepath.Join(keyDir, "ssh_host_"+typ+"_key") - v, err := os.ReadFile(path) - if err == nil { - return v, nil - } - if !os.IsNotExist(err) { - return nil, err - } - var priv any - switch typ { - default: - return nil, fmt.Errorf("unsupported key type %q", typ) - case "ed25519": - _, priv, err = ed25519.GenerateKey(rand.Reader) - case "ecdsa": - // curve is arbitrary. We pick whatever will at - // least pacify clients as the actual encryption - // doesn't matter: it's all over WireGuard anyway. - curve := elliptic.P256() - priv, err = ecdsa.GenerateKey(curve, rand.Reader) - case "rsa": - // keySize is arbitrary. We pick whatever will at - // least pacify clients as the actual encryption - // doesn't matter: it's all over WireGuard anyway. - const keySize = 2048 - priv, err = rsa.GenerateKey(rand.Reader, keySize) - } - if err != nil { - return nil, err - } - mk, err := x509.MarshalPKCS8PrivateKey(priv) - if err != nil { - return nil, err - } - pemGen := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: mk}) - err = os.WriteFile(path, pemGen, 0700) - return pemGen, err -} - -func (b *LocalBackend) getSystemSSH_HostKeys() (ret map[string]ssh.Signer) { - for _, typ := range keyTypes { - filename := "/etc/ssh/ssh_host_" + typ + "_key" - hostKey, err := os.ReadFile(filename) - if err != nil || len(bytes.TrimSpace(hostKey)) == 0 { - continue - } - signer, err := ssh.ParsePrivateKey(hostKey) - if err != nil { - b.logf("warning: error reading host key %s: %v (generating one instead)", filename, err) - continue - } - mak.Set(&ret, typ, signer) - } - return ret -} - -func (b *LocalBackend) getSSHHostKeyPublicStrings() ([]string, error) { - signers, err := b.GetSSH_HostKeys() - if err != nil { - return nil, err - } - var keyStrings []string - for _, signer := range signers { - keyStrings = append(keyStrings, strings.TrimSpace(string(ssh.MarshalAuthorizedKey(signer.PublicKey())))) - } - return keyStrings, nil -} - -// tailscaleSSHEnabled reports whether Tailscale SSH is currently enabled based -// on prefs. It returns false if there are no prefs set. -func (b *LocalBackend) tailscaleSSHEnabled() bool { - b.mu.Lock() - defer b.mu.Unlock() - p := b.pm.CurrentPrefs() - return p.Valid() && p.RunSSH() -} diff --git a/ipn/ipnlocal/ssh_stub.go b/ipn/ipnlocal/ssh_stub.go deleted file mode 100644 index 9a997c914..000000000 --- a/ipn/ipnlocal/ssh_stub.go +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) Tailscale Inc & contributors -// SPDX-License-Identifier: BSD-3-Clause - -//go:build ts_omit_ssh || ios || android || (!linux && !darwin && !freebsd && !openbsd && !plan9) - -package ipnlocal - -import ( - "errors" - - "tailscale.com/tailcfg" -) - -func (b *LocalBackend) getSSHHostKeyPublicStrings() ([]string, error) { - return nil, 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 deleted file mode 100644 index bb293d10a..000000000 --- a/ipn/ipnlocal/ssh_test.go +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright (c) Tailscale Inc & contributors -// SPDX-License-Identifier: BSD-3-Clause - -//go:build linux || (darwin && !ios) - -package ipnlocal - -import ( - "encoding/json" - "reflect" - "testing" - - "tailscale.com/health" - "tailscale.com/ipn/store/mem" - "tailscale.com/tailcfg" - "tailscale.com/util/eventbus/eventbustest" - "tailscale.com/util/must" -) - -func TestSSHKeyGen(t *testing.T) { - dir := t.TempDir() - lb := &LocalBackend{varRoot: dir} - keys, err := lb.getTailscaleSSH_HostKeys(nil) - if err != nil { - t.Fatal(err) - } - got := map[string]bool{} - for _, k := range keys { - got[k.PublicKey().Type()] = true - } - want := map[string]bool{ - "ssh-rsa": true, - "ecdsa-sha2-nistp256": true, - "ssh-ed25519": true, - } - if !reflect.DeepEqual(got, want) { - t.Fatalf("keys = %v; want %v", got, want) - } - - keys2, err := lb.getTailscaleSSH_HostKeys(nil) - if err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(keys, keys2) { - t.Errorf("got different keys on second call") - } -} - -type fakeSSHServer struct { - SSHServer -} - -func TestGetSSHUsernames(t *testing.T) { - pm := must.Get(newProfileManager(new(mem.Store), t.Logf, health.NewTracker(eventbustest.NewBus(t)))) - b := &LocalBackend{pm: pm, store: pm.Store()} - 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/ssh/tailssh/c2n.go b/ssh/tailssh/c2n.go new file mode 100644 index 000000000..621be74d4 --- /dev/null +++ b/ssh/tailssh/c2n.go @@ -0,0 +1,109 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build (linux && !android) || (darwin && !ios) || freebsd || openbsd || plan9 + +package tailssh + +import ( + "bytes" + "encoding/json" + "net/http" + "os/exec" + "runtime" + "slices" + + "go4.org/mem" + "tailscale.com/ipn/ipnlocal" + "tailscale.com/tailcfg" + "tailscale.com/util/lineiter" +) + +func handleC2NSSHUsernames(b *ipnlocal.LocalBackend, w http.ResponseWriter, r *http.Request) { + 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 := getSSHUsernames(b, &req) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(res) +} + +// getSSHUsernames discovers and returns the list of usernames that are +// potential Tailscale SSH user targets. +func getSSHUsernames(b *ipnlocal.LocalBackend, req *tailcfg.C2NSSHUsernamesRequest) (*tailcfg.C2NSSHUsernamesResponse, error) { + res := new(tailcfg.C2NSSHUsernamesResponse) + if b == nil || !b.ShouldRunSSH() { + 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 opUser := b.OperatorUserName(); opUser != "" { + add(opUser) + } + + // 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 + } + for line := range lineiter.Bytes(out) { + line = bytes.TrimSpace(line) + if len(line) == 0 || line[0] == '_' { + continue + } + add(string(line)) + } + default: + for lr := range lineiter.File("/etc/passwd") { + line, err := lr.Value() + if err != nil { + break + } + line = bytes.TrimSpace(line) + if len(line) == 0 || line[0] == '#' || line[0] == '_' { + continue + } + if mem.HasSuffix(mem.B(line), mem.S("/nologin")) || + mem.HasSuffix(mem.B(line), mem.S("/false")) { + continue + } + before, _, ok := bytes.Cut(line, []byte{':'}) + if ok { + add(string(before)) + } + } + } + return res, nil +} diff --git a/ssh/tailssh/hostkeys.go b/ssh/tailssh/hostkeys.go new file mode 100644 index 000000000..f14d99c46 --- /dev/null +++ b/ssh/tailssh/hostkeys.go @@ -0,0 +1,155 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build (linux && !android) || (darwin && !ios) || freebsd || openbsd || plan9 + +package tailssh + +import ( + "bytes" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + + gossh "golang.org/x/crypto/ssh" + "tailscale.com/types/logger" + "tailscale.com/util/mak" +) + +// keyTypes are the SSH key types that we either try to read from the +// system's OpenSSH keys or try to generate for ourselves when not +// running as root. +var keyTypes = []string{"rsa", "ecdsa", "ed25519"} + +// getHostKeys returns the SSH host keys, using system keys when running as root +// and generating Tailscale-specific keys as needed. +func getHostKeys(varRoot string, logf logger.Logf) ([]gossh.Signer, error) { + var existing map[string]gossh.Signer + if os.Geteuid() == 0 { + existing = getSystemHostKeys(logf) + } + return getTailscaleHostKeys(varRoot, existing) +} + +// getHostKeyPublicStrings returns the SSH host key public key strings. +func getHostKeyPublicStrings(varRoot string, logf logger.Logf) ([]string, error) { + signers, err := getHostKeys(varRoot, logf) + if err != nil { + return nil, err + } + var keyStrings []string + for _, signer := range signers { + keyStrings = append(keyStrings, strings.TrimSpace(string(gossh.MarshalAuthorizedKey(signer.PublicKey())))) + } + return keyStrings, nil +} + +// getTailscaleHostKeys returns the three (rsa, ecdsa, ed25519) SSH host +// keys, reusing the provided ones in existing if present in the map. +func getTailscaleHostKeys(varRoot string, existing map[string]gossh.Signer) (keys []gossh.Signer, err error) { + var keyDir string // lazily initialized $TAILSCALE_VAR/ssh dir. + for _, typ := range keyTypes { + if s, ok := existing[typ]; ok { + keys = append(keys, s) + continue + } + if keyDir == "" { + if varRoot == "" { + return nil, errors.New("no var root for ssh keys") + } + keyDir = filepath.Join(varRoot, "ssh") + if err := os.MkdirAll(keyDir, 0700); err != nil { + return nil, err + } + } + hostKey, err := hostKeyFileOrCreate(keyDir, typ) + if err != nil { + return nil, fmt.Errorf("error creating SSH host key type %q in %q: %w", typ, keyDir, err) + } + signer, err := gossh.ParsePrivateKey(hostKey) + if err != nil { + return nil, fmt.Errorf("error parsing SSH host key type %q from %q: %w", typ, keyDir, err) + } + keys = append(keys, signer) + } + return keys, nil +} + +// keyGenMu protects concurrent generation of host keys with +// [hostKeyFileOrCreate], making sure two callers don't try to concurrently find +// a missing key and generate it at the same time, returning different keys to +// their callers. +// +// Technically we actually want to have a mutex per directory (the keyDir +// passed), but that's overkill for how rarely keys are loaded or generated. +var keyGenMu sync.Mutex + +func hostKeyFileOrCreate(keyDir, typ string) ([]byte, error) { + keyGenMu.Lock() + defer keyGenMu.Unlock() + + path := filepath.Join(keyDir, "ssh_host_"+typ+"_key") + v, err := os.ReadFile(path) + if err == nil { + return v, nil + } + if !os.IsNotExist(err) { + return nil, err + } + var priv any + switch typ { + default: + return nil, fmt.Errorf("unsupported key type %q", typ) + case "ed25519": + _, priv, err = ed25519.GenerateKey(rand.Reader) + case "ecdsa": + // curve is arbitrary. We pick whatever will at + // least pacify clients as the actual encryption + // doesn't matter: it's all over WireGuard anyway. + curve := elliptic.P256() + priv, err = ecdsa.GenerateKey(curve, rand.Reader) + case "rsa": + // keySize is arbitrary. We pick whatever will at + // least pacify clients as the actual encryption + // doesn't matter: it's all over WireGuard anyway. + const keySize = 2048 + priv, err = rsa.GenerateKey(rand.Reader, keySize) + } + if err != nil { + return nil, err + } + mk, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + return nil, err + } + pemGen := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: mk}) + err = os.WriteFile(path, pemGen, 0700) + return pemGen, err +} + +func getSystemHostKeys(logf logger.Logf) (ret map[string]gossh.Signer) { + for _, typ := range keyTypes { + filename := "/etc/ssh/ssh_host_" + typ + "_key" + hostKey, err := os.ReadFile(filename) + if err != nil || len(bytes.TrimSpace(hostKey)) == 0 { + continue + } + signer, err := gossh.ParsePrivateKey(hostKey) + if err != nil { + logf("warning: error reading host key %s: %v (generating one instead)", filename, err) + continue + } + mak.Set(&ret, typ, signer) + } + return ret +} diff --git a/ssh/tailssh/hostkeys_test.go b/ssh/tailssh/hostkeys_test.go new file mode 100644 index 000000000..24a876454 --- /dev/null +++ b/ssh/tailssh/hostkeys_test.go @@ -0,0 +1,39 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build linux || (darwin && !ios) + +package tailssh + +import ( + "reflect" + "testing" +) + +func TestSSHKeyGen(t *testing.T) { + dir := t.TempDir() + keys, err := getTailscaleHostKeys(dir, nil) + if err != nil { + t.Fatal(err) + } + got := map[string]bool{} + for _, k := range keys { + got[k.PublicKey().Type()] = true + } + want := map[string]bool{ + "ssh-rsa": true, + "ecdsa-sha2-nistp256": true, + "ssh-ed25519": true, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("keys = %v; want %v", got, want) + } + + keys2, err := getTailscaleHostKeys(dir, nil) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(keys, keys2) { + t.Errorf("got different keys on second call") + } +} diff --git a/ssh/tailssh/tailssh.go b/ssh/tailssh/tailssh.go index 96f9c826c..2be133267 100644 --- a/ssh/tailssh/tailssh.go +++ b/ssh/tailssh/tailssh.go @@ -74,7 +74,6 @@ // ipnLocalBackend is the subset of ipnlocal.LocalBackend that we use. // It is used for testing. type ipnLocalBackend interface { - GetSSH_HostKeys() ([]gossh.Signer, error) ShouldRunSSH() bool NetMap() *netmap.NetworkMap WhoIs(proto string, ipp netip.AddrPort) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool) @@ -107,6 +106,8 @@ func (srv *server) now() time.Time { } func init() { + feature.HookGetSSHHostKeyPublicStrings.Set(getHostKeyPublicStrings) + ipnlocal.RegisterC2N("/ssh/usernames", handleC2NSSHUsernames) ipnlocal.RegisterNewSSHServer(func(logf logger.Logf, lb *ipnlocal.LocalBackend) (ipnlocal.SSHServer, error) { tsd, err := os.Executable() if err != nil { @@ -504,7 +505,7 @@ func (srv *server) newConn() (*conn, error) { maps.Copy(ss.RequestHandlers, ssh.DefaultRequestHandlers) maps.Copy(ss.ChannelHandlers, ssh.DefaultChannelHandlers) maps.Copy(ss.SubsystemHandlers, ssh.DefaultSubsystemHandlers) - keys, err := srv.lb.GetSSH_HostKeys() + keys, err := getHostKeys(srv.lb.TailscaleVarRoot(), srv.logf) if err != nil { return nil, err } diff --git a/ssh/tailssh/tailssh_integration_test.go b/ssh/tailssh/tailssh_integration_test.go index 1135bebbc..ea2fe8577 100644 --- a/ssh/tailssh/tailssh_integration_test.go +++ b/ssh/tailssh/tailssh_integration_test.go @@ -32,7 +32,6 @@ "github.com/google/go-cmp/cmp" "github.com/pkg/sftp" "golang.org/x/crypto/ssh" - gossh "golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh/agent" "tailscale.com/net/tsdial" "tailscale.com/tailcfg" @@ -631,28 +630,6 @@ type testBackend struct { allowSendEnv bool } -func (tb *testBackend) GetSSH_HostKeys() ([]gossh.Signer, error) { - var result []gossh.Signer - var priv any - var err error - const keySize = 2048 - priv, err = rsa.GenerateKey(rand.Reader, keySize) - if err != nil { - return nil, err - } - mk, err := x509.MarshalPKCS8PrivateKey(priv) - if err != nil { - return nil, err - } - hostKey := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: mk}) - signer, err := gossh.ParsePrivateKey(hostKey) - if err != nil { - return nil, err - } - result = append(result, signer) - return result, nil -} - func (tb *testBackend) ShouldRunSSH() bool { return true } diff --git a/ssh/tailssh/tailssh_test.go b/ssh/tailssh/tailssh_test.go index 4ef3cbd46..3bf6a72c3 100644 --- a/ssh/tailssh/tailssh_test.go +++ b/ssh/tailssh/tailssh_test.go @@ -9,7 +9,6 @@ "bytes" "context" "crypto/ecdsa" - "crypto/ed25519" "crypto/elliptic" "crypto/rand" "encoding/json" @@ -34,7 +33,6 @@ "testing/synctest" "time" - gossh "golang.org/x/crypto/ssh" "golang.org/x/net/http2" "golang.org/x/net/http2/h2c" "tailscale.com/cmd/testwrapper/flakytest" @@ -381,6 +379,7 @@ func TestEvalSSHPolicy(t *testing.T) { type localState struct { sshEnabled bool matchingRule *tailcfg.SSHRule + varRoot string // if empty, TailscaleVarRoot returns "" // serverActions is a map of the action name to the action. // It is served for paths like https://unused/ssh-action/. @@ -388,31 +387,12 @@ type localState struct { serverActions map[string]*tailcfg.SSHAction } -var ( - currentUser = os.Getenv("USER") // Use the current user for the test. - testSigner gossh.Signer - testSignerOnce sync.Once -) +var currentUser = os.Getenv("USER") // Use the current user for the test. func (ts *localState) Dialer() *tsdial.Dialer { return &tsdial.Dialer{} } -func (ts *localState) GetSSH_HostKeys() ([]gossh.Signer, error) { - testSignerOnce.Do(func() { - _, priv, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - panic(err) - } - s, err := gossh.NewSignerFromSigner(priv) - if err != nil { - panic(err) - } - testSigner = s - }) - return []gossh.Signer{testSigner}, nil -} - func (ts *localState) ShouldRunSSH() bool { return ts.sshEnabled } @@ -468,7 +448,7 @@ func (ts *localState) DoNoiseRequest(req *http.Request) (*http.Response, error) } func (ts *localState) TailscaleVarRoot() string { - return "" + return ts.varRoot } func (ts *localState) NodeKey() key.NodePublic { @@ -505,6 +485,7 @@ func TestSSHRecordingCancelsSessionsOnUploadFailure(t *testing.T) { logf: tstest.WhileTestRunningLogger(t), lb: &localState{ sshEnabled: true, + varRoot: t.TempDir(), matchingRule: newSSHRule( &tailcfg.SSHAction{ Accept: true, @@ -633,6 +614,7 @@ func TestMultipleRecorders(t *testing.T) { logf: tstest.WhileTestRunningLogger(t), lb: &localState{ sshEnabled: true, + varRoot: t.TempDir(), matchingRule: newSSHRule( &tailcfg.SSHAction{ Accept: true, @@ -724,6 +706,7 @@ func TestSSHRecordingNonInteractive(t *testing.T) { logf: tstest.WhileTestRunningLogger(t), lb: &localState{ sshEnabled: true, + varRoot: t.TempDir(), matchingRule: newSSHRule( &tailcfg.SSHAction{ Accept: true, @@ -792,6 +775,7 @@ func TestSSHAuthFlow(t *testing.T) { if runtime.GOOS != "linux" && runtime.GOOS != "darwin" { t.Skipf("skipping on %q; only runs on linux and darwin", runtime.GOOS) } + varRoot := t.TempDir() acceptRule := newSSHRule(&tailcfg.SSHAction{ Accept: true, Message: "Welcome to Tailscale SSH!", @@ -818,6 +802,7 @@ func TestSSHAuthFlow(t *testing.T) { name: "no-policy", state: &localState{ sshEnabled: true, + varRoot: varRoot, }, authErr: true, wantBanners: []string{"tailscale: tailnet policy does not permit you to SSH to this node\n"}, @@ -826,6 +811,7 @@ func TestSSHAuthFlow(t *testing.T) { name: "user-mismatch", state: &localState{ sshEnabled: true, + varRoot: varRoot, matchingRule: bobRule, }, authErr: true, @@ -835,6 +821,7 @@ func TestSSHAuthFlow(t *testing.T) { name: "accept", state: &localState{ sshEnabled: true, + varRoot: varRoot, matchingRule: acceptRule, }, wantBanners: []string{"Welcome to Tailscale SSH!"}, @@ -843,6 +830,7 @@ func TestSSHAuthFlow(t *testing.T) { name: "reject", state: &localState{ sshEnabled: true, + varRoot: varRoot, matchingRule: rejectRule, }, wantBanners: []string{"Go Away!"}, @@ -852,6 +840,7 @@ func TestSSHAuthFlow(t *testing.T) { name: "simple-check", state: &localState{ sshEnabled: true, + varRoot: varRoot, matchingRule: newSSHRule(&tailcfg.SSHAction{ HoldAndDelegate: "https://unused/ssh-action/accept", }), @@ -865,6 +854,7 @@ func TestSSHAuthFlow(t *testing.T) { name: "multi-check", state: &localState{ sshEnabled: true, + varRoot: varRoot, matchingRule: newSSHRule(&tailcfg.SSHAction{ Message: "First", HoldAndDelegate: "https://unused/ssh-action/check1", @@ -883,6 +873,7 @@ func TestSSHAuthFlow(t *testing.T) { name: "check-reject", state: &localState{ sshEnabled: true, + varRoot: varRoot, matchingRule: newSSHRule(&tailcfg.SSHAction{ Message: "First", HoldAndDelegate: "https://unused/ssh-action/reject", @@ -899,6 +890,7 @@ func TestSSHAuthFlow(t *testing.T) { sshUser: "alice+password", state: &localState{ sshEnabled: true, + varRoot: varRoot, matchingRule: acceptRule, }, usesPassword: true, diff --git a/tsnet/depaware.txt b/tsnet/depaware.txt index 2df729c21..79700c713 100644 --- a/tsnet/depaware.txt +++ b/tsnet/depaware.txt @@ -396,8 +396,7 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware) golang.org/x/crypto/argon2 from tailscale.com/tka golang.org/x/crypto/blake2b from golang.org/x/crypto/argon2+ golang.org/x/crypto/blake2s from github.com/tailscale/wireguard-go/device+ - LD golang.org/x/crypto/blowfish from golang.org/x/crypto/ssh/internal/bcrypt_pbkdf - golang.org/x/crypto/chacha20 from golang.org/x/crypto/chacha20poly1305+ + golang.org/x/crypto/chacha20 from golang.org/x/crypto/chacha20poly1305 golang.org/x/crypto/chacha20poly1305 from github.com/tailscale/wireguard-go/device+ golang.org/x/crypto/curve25519 from github.com/tailscale/wireguard-go/device+ golang.org/x/crypto/hkdf from tailscale.com/control/controlbase @@ -407,8 +406,6 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware) golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box golang.org/x/crypto/poly1305 from github.com/tailscale/wireguard-go/device golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+ - LD golang.org/x/crypto/ssh from tailscale.com/ipn/ipnlocal - LD golang.org/x/crypto/ssh/internal/bcrypt_pbkdf from golang.org/x/crypto/ssh golang.org/x/exp/constraints from tailscale.com/tsweb/varz+ golang.org/x/exp/maps from tailscale.com/ipn/store/mem+ golang.org/x/net/bpf from github.com/mdlayher/netlink+ @@ -469,7 +466,7 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware) crypto/aes from crypto/tls+ crypto/cipher from crypto/aes+ crypto/des from crypto/tls+ - crypto/dsa from crypto/x509+ + crypto/dsa from crypto/x509 crypto/ecdh from crypto/ecdsa+ crypto/ecdsa from crypto/tls+ crypto/ed25519 from crypto/tls+ @@ -518,9 +515,9 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware) crypto/internal/randutil from crypto/internal/rand crypto/internal/sysrand from crypto/internal/fips140/drbg crypto/md5 from crypto/tls+ - crypto/mlkem from golang.org/x/crypto/ssh+ + crypto/mlkem from crypto/hpke+ crypto/rand from crypto/ed25519+ - crypto/rc4 from crypto/tls+ + crypto/rc4 from crypto/tls crypto/rsa from crypto/tls+ crypto/sha1 from crypto/tls+ crypto/sha256 from crypto/tls+ diff --git a/tsnet/tsnet_test.go b/tsnet/tsnet_test.go index a2bf76e18..f9342d7a7 100644 --- a/tsnet/tsnet_test.go +++ b/tsnet/tsnet_test.go @@ -2631,6 +2631,10 @@ func TestDeps(t *testing.T) { deptest.DepChecker{ GOOS: "linux", GOARCH: "amd64", + BadDeps: map[string]string{ + "golang.org/x/crypto/ssh": "tsnet should not depend on SSH", + "golang.org/x/crypto/ssh/internal/bcrypt_pbkdf": "tsnet should not depend on SSH", + }, OnDep: func(dep string) { if strings.Contains(dep, "portlist") { t.Errorf("unexpected dep: %q", dep) diff --git a/tstest/integration/tailscaled_deps_test_darwin.go b/tstest/integration/tailscaled_deps_test_darwin.go index 112f04767..70e0d75fa 100644 --- a/tstest/integration/tailscaled_deps_test_darwin.go +++ b/tstest/integration/tailscaled_deps_test_darwin.go @@ -20,6 +20,7 @@ _ "tailscale.com/feature" _ "tailscale.com/feature/buildfeatures" _ "tailscale.com/feature/condregister" + _ "tailscale.com/feature/ssh" _ "tailscale.com/health" _ "tailscale.com/hostinfo" _ "tailscale.com/ipn" @@ -40,7 +41,6 @@ _ "tailscale.com/net/tstun" _ "tailscale.com/paths" _ "tailscale.com/safesocket" - _ "tailscale.com/ssh/tailssh" _ "tailscale.com/syncs" _ "tailscale.com/tailcfg" _ "tailscale.com/tsd" diff --git a/tstest/integration/tailscaled_deps_test_freebsd.go b/tstest/integration/tailscaled_deps_test_freebsd.go index 112f04767..70e0d75fa 100644 --- a/tstest/integration/tailscaled_deps_test_freebsd.go +++ b/tstest/integration/tailscaled_deps_test_freebsd.go @@ -20,6 +20,7 @@ _ "tailscale.com/feature" _ "tailscale.com/feature/buildfeatures" _ "tailscale.com/feature/condregister" + _ "tailscale.com/feature/ssh" _ "tailscale.com/health" _ "tailscale.com/hostinfo" _ "tailscale.com/ipn" @@ -40,7 +41,6 @@ _ "tailscale.com/net/tstun" _ "tailscale.com/paths" _ "tailscale.com/safesocket" - _ "tailscale.com/ssh/tailssh" _ "tailscale.com/syncs" _ "tailscale.com/tailcfg" _ "tailscale.com/tsd" diff --git a/tstest/integration/tailscaled_deps_test_linux.go b/tstest/integration/tailscaled_deps_test_linux.go index 112f04767..70e0d75fa 100644 --- a/tstest/integration/tailscaled_deps_test_linux.go +++ b/tstest/integration/tailscaled_deps_test_linux.go @@ -20,6 +20,7 @@ _ "tailscale.com/feature" _ "tailscale.com/feature/buildfeatures" _ "tailscale.com/feature/condregister" + _ "tailscale.com/feature/ssh" _ "tailscale.com/health" _ "tailscale.com/hostinfo" _ "tailscale.com/ipn" @@ -40,7 +41,6 @@ _ "tailscale.com/net/tstun" _ "tailscale.com/paths" _ "tailscale.com/safesocket" - _ "tailscale.com/ssh/tailssh" _ "tailscale.com/syncs" _ "tailscale.com/tailcfg" _ "tailscale.com/tsd" diff --git a/tstest/integration/tailscaled_deps_test_openbsd.go b/tstest/integration/tailscaled_deps_test_openbsd.go index 112f04767..70e0d75fa 100644 --- a/tstest/integration/tailscaled_deps_test_openbsd.go +++ b/tstest/integration/tailscaled_deps_test_openbsd.go @@ -20,6 +20,7 @@ _ "tailscale.com/feature" _ "tailscale.com/feature/buildfeatures" _ "tailscale.com/feature/condregister" + _ "tailscale.com/feature/ssh" _ "tailscale.com/health" _ "tailscale.com/hostinfo" _ "tailscale.com/ipn" @@ -40,7 +41,6 @@ _ "tailscale.com/net/tstun" _ "tailscale.com/paths" _ "tailscale.com/safesocket" - _ "tailscale.com/ssh/tailssh" _ "tailscale.com/syncs" _ "tailscale.com/tailcfg" _ "tailscale.com/tsd"