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
+109
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
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
}
+39
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")
}
}
+3 -2
View File
@@ -74,7 +74,6 @@ const (
// 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
}
-23
View File
@@ -32,7 +32,6 @@ import (
"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
}
+15 -23
View File
@@ -9,7 +9,6 @@ import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"encoding/json"
@@ -34,7 +33,6 @@ import (
"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,