ipn/ipnlocal, feature/ssh: move SSH code out of LocalBackend to feature

This makes tsnet apps not depend on x/crypto/ssh and locks that in with a test.

It also paves the wave for tsnet apps to opt-in to SSH support via a
blank feature import in the future.

Updates #12614

Change-Id: Ica85628f89c8f015413b074f5001b82b27c953a9
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick
2026-03-10 21:33:12 +00:00
committed by Brad Fitzpatrick
parent 99e3e9af51
commit f905871fb1
23 changed files with 371 additions and 423 deletions

View File

@@ -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+

View File

@@ -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

View File

@@ -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"

View File

@@ -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+

View File

@@ -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]

11
feature/ssh/ssh.go Normal file
View File

@@ -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"

View File

@@ -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 {

View File

@@ -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 ""
}

View File

@@ -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()
}

View File

@@ -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")
}

View File

@@ -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)))
}

109
ssh/tailssh/c2n.go Normal file
View File

@@ -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
}

155
ssh/tailssh/hostkeys.go Normal file
View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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/<action-name>.
@@ -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,

View File

@@ -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+

View File

@@ -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)

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"