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>main
parent
99e3e9af51
commit
f905871fb1
@ -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" |
||||||
@ -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() |
|
||||||
} |
|
||||||
@ -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") |
|
||||||
} |
|
||||||
@ -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))) |
|
||||||
} |
|
||||||
@ -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") |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue