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