A connection that never sends a byte is a probe (Hammerhead health-checks every TCP resource that way), so it is no longer logged every 30 seconds. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
201 lines
5.7 KiB
Go
201 lines
5.7 KiB
Go
package sshd
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/ssh"
|
|
)
|
|
|
|
func newSigner(t *testing.T) ssh.Signer {
|
|
t.Helper()
|
|
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s, err := ssh.NewSignerFromKey(priv)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func authLine(s ssh.Signer) string { return string(ssh.MarshalAuthorizedKey(s.PublicKey())) }
|
|
|
|
// start runs a server that accepts the given signers and returns its address.
|
|
func start(t *testing.T, allowed ...ssh.Signer) (addr, keysFile string, srv *Server) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
keysFile = filepath.Join(dir, "authorized_keys")
|
|
var lines string
|
|
for _, s := range allowed {
|
|
lines += authLine(s)
|
|
}
|
|
if err := os.WriteFile(keysFile, []byte("# who may sign in\n"+lines), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv, err := New(Config{AuthorizedKeys: keysFile, HostKey: filepath.Join(dir, "host_key"), Logf: t.Logf})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan struct{})
|
|
go func() { srv.Serve(ctx, ln); close(done) }()
|
|
t.Cleanup(func() { cancel(); <-done })
|
|
return ln.Addr().String(), keysFile, srv
|
|
}
|
|
|
|
func dial(addr string, auth ...ssh.AuthMethod) (*ssh.Client, error) {
|
|
return ssh.Dial("tcp", addr, &ssh.ClientConfig{User: "tester", Auth: auth, HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: 5 * time.Second})
|
|
}
|
|
|
|
func TestNewNeedsKeys(t *testing.T) {
|
|
dir := t.TempDir()
|
|
cfg := Config{AuthorizedKeys: filepath.Join(dir, "authorized_keys"), HostKey: filepath.Join(dir, "host_key")}
|
|
if _, err := New(cfg); err == nil {
|
|
t.Fatal("a missing authorized_keys file should be an error")
|
|
}
|
|
os.WriteFile(cfg.AuthorizedKeys, []byte("# nothing yet\n\n"), 0o600)
|
|
if _, err := New(cfg); err == nil {
|
|
t.Fatal("an authorized_keys file without keys should be an error")
|
|
}
|
|
}
|
|
|
|
func TestOptionsLinesAreSkipped(t *testing.T) {
|
|
a, b := newSigner(t), newSigner(t)
|
|
path := filepath.Join(t.TempDir(), "authorized_keys")
|
|
os.WriteFile(path, []byte(`command="/bin/false" `+authLine(a)+authLine(b)), 0o600)
|
|
keys, err := loadAuthorizedKeys(path)
|
|
if err != nil || len(keys) != 1 || ssh.FingerprintSHA256(keys[0]) != ssh.FingerprintSHA256(b.PublicKey()) {
|
|
t.Fatalf("only the key without options should load, got %d keys, err %v", len(keys), err)
|
|
}
|
|
}
|
|
|
|
func TestHostKeyIsStable(t *testing.T) {
|
|
dir := t.TempDir()
|
|
keys := filepath.Join(dir, "authorized_keys")
|
|
os.WriteFile(keys, []byte(authLine(newSigner(t))), 0o600)
|
|
cfg := Config{AuthorizedKeys: keys, HostKey: filepath.Join(dir, "sub", "host_key")}
|
|
a, err := New(cfg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, err := New(cfg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if a.Fingerprint() != b.Fingerprint() {
|
|
t.Fatal("the host key changed between starts")
|
|
}
|
|
}
|
|
|
|
func TestKeyAuth(t *testing.T) {
|
|
good, bad := newSigner(t), newSigner(t)
|
|
addr, _, _ := start(t, good)
|
|
|
|
c, err := dial(addr, ssh.PublicKeys(good))
|
|
if err != nil {
|
|
t.Fatalf("authorized key refused: %v", err)
|
|
}
|
|
c.Close()
|
|
if _, err := dial(addr, ssh.PublicKeys(bad)); err == nil {
|
|
t.Fatal("unknown key was accepted")
|
|
}
|
|
if _, err := dial(addr, ssh.Password("hunter2")); err == nil {
|
|
t.Fatal("a password was accepted")
|
|
}
|
|
if _, err := dial(addr, ssh.KeyboardInteractive(func(string, string, []string, []bool) ([]string, error) { return nil, nil })); err == nil {
|
|
t.Fatal("keyboard-interactive was accepted")
|
|
}
|
|
}
|
|
|
|
func TestKeysCanChangeWhileRunning(t *testing.T) {
|
|
first, second := newSigner(t), newSigner(t)
|
|
addr, keysFile, _ := start(t, first)
|
|
if _, err := dial(addr, ssh.PublicKeys(second)); err == nil {
|
|
t.Fatal("second key accepted before it was added")
|
|
}
|
|
f, _ := os.OpenFile(keysFile, os.O_APPEND|os.O_WRONLY, 0o600)
|
|
f.WriteString(authLine(second))
|
|
f.Close()
|
|
c, err := dial(addr, ssh.PublicKeys(second))
|
|
if err != nil {
|
|
t.Fatalf("key added to the file was not picked up: %v", err)
|
|
}
|
|
c.Close()
|
|
}
|
|
|
|
func TestNoForwarding(t *testing.T) {
|
|
good := newSigner(t)
|
|
addr, _, _ := start(t, good)
|
|
c, err := dial(addr, ssh.PublicKeys(good))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer c.Close()
|
|
if conn, err := c.Dial("tcp", "127.0.0.1:1"); err == nil {
|
|
conn.Close()
|
|
t.Fatal("port forwarding was allowed")
|
|
}
|
|
}
|
|
|
|
// Hammerhead health-checks a TCP resource by connecting and hanging up; that must not look like
|
|
// a failed sign-in in the log, while a real failure still does.
|
|
func TestProbesAreNotLogged(t *testing.T) {
|
|
good := newSigner(t)
|
|
dir := t.TempDir()
|
|
keys := filepath.Join(dir, "authorized_keys")
|
|
if err := os.WriteFile(keys, []byte(authLine(good)), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var mu sync.Mutex
|
|
var logs []string
|
|
srv, err := New(Config{AuthorizedKeys: keys, HostKey: filepath.Join(dir, "host_key"), Logf: func(f string, a ...any) {
|
|
mu.Lock()
|
|
logs = append(logs, fmt.Sprintf(f, a...))
|
|
mu.Unlock()
|
|
}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan struct{})
|
|
go func() { srv.Serve(ctx, ln); close(done) }()
|
|
t.Cleanup(func() { cancel(); <-done })
|
|
|
|
for i := 0; i < 3; i++ {
|
|
c, err := net.Dial("tcp", ln.Addr().String())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c.Close()
|
|
}
|
|
time.Sleep(200 * time.Millisecond)
|
|
if _, err := dial(ln.Addr().String(), ssh.PublicKeys(newSigner(t))); err == nil {
|
|
t.Fatal("an unknown key signed in")
|
|
}
|
|
time.Sleep(200 * time.Millisecond)
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if len(logs) != 1 || !strings.Contains(logs[0], "failed") {
|
|
t.Fatalf("want exactly the one real failure logged, got %q", logs)
|
|
}
|
|
}
|