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:
committed by
Brad Fitzpatrick
parent
99e3e9af51
commit
f905871fb1
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user