Every go-libvirt call runs under its own budget and a cancelled or overlong call kills the connection; credentials are read on every dial; one span and one log line per RPC come from a decorator applied where connections are made. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
290 lines
6.5 KiB
Go
290 lines
6.5 KiB
Go
package kvm2
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"encoding/pem"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
|
|
"golang.org/x/crypto/ssh"
|
|
"golang.org/x/crypto/ssh/knownhosts"
|
|
)
|
|
|
|
// testSSHServer is an in-process x/crypto/ssh server that accepts one
|
|
// authorised public key and forwards direct-streamlocal@openssh.com channels
|
|
// for socketPath to that unix socket, rejecting every other channel.
|
|
type testSSHServer struct {
|
|
addr string
|
|
hostKey ssh.PublicKey
|
|
socketPath string
|
|
|
|
listener net.Listener
|
|
mu sync.Mutex
|
|
conns []net.Conn
|
|
wg sync.WaitGroup
|
|
stopOnce sync.Once
|
|
|
|
reqMu sync.Mutex
|
|
requested []string
|
|
}
|
|
|
|
func startSSHServer(t *testing.T, authorized ssh.PublicKey, socketPath string) *testSSHServer {
|
|
t.Helper()
|
|
_, hostPriv, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("generating host key: %v", err)
|
|
}
|
|
hostSigner, err := ssh.NewSignerFromKey(hostPriv)
|
|
if err != nil {
|
|
t.Fatalf("host signer: %v", err)
|
|
}
|
|
cfg := &ssh.ServerConfig{
|
|
PublicKeyCallback: func(_ ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
|
|
if bytes.Equal(key.Marshal(), authorized.Marshal()) {
|
|
return &ssh.Permissions{}, nil
|
|
}
|
|
return nil, errors.New("key not authorised")
|
|
},
|
|
}
|
|
cfg.AddHostKey(hostSigner)
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
s := &testSSHServer{addr: ln.Addr().String(), hostKey: hostSigner.PublicKey(), socketPath: socketPath, listener: ln}
|
|
s.wg.Add(1)
|
|
go s.serve(cfg)
|
|
t.Cleanup(s.stop)
|
|
return s
|
|
}
|
|
|
|
func (s *testSSHServer) serve(cfg *ssh.ServerConfig) {
|
|
defer s.wg.Done()
|
|
for {
|
|
conn, err := s.listener.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
s.conns = append(s.conns, conn)
|
|
s.mu.Unlock()
|
|
s.wg.Add(1)
|
|
go s.handle(conn, cfg)
|
|
}
|
|
}
|
|
|
|
func (s *testSSHServer) handle(conn net.Conn, cfg *ssh.ServerConfig) {
|
|
defer s.wg.Done()
|
|
sc, chans, reqs, err := ssh.NewServerConn(conn, cfg)
|
|
if err != nil {
|
|
_ = conn.Close()
|
|
return
|
|
}
|
|
defer sc.Close()
|
|
go ssh.DiscardRequests(reqs)
|
|
for nc := range chans {
|
|
if nc.ChannelType() != "direct-streamlocal@openssh.com" {
|
|
_ = nc.Reject(ssh.UnknownChannelType, "only direct-streamlocal@openssh.com is served")
|
|
continue
|
|
}
|
|
var open struct {
|
|
SocketPath string
|
|
Reserved0 string
|
|
Reserved1 uint32
|
|
}
|
|
if err := ssh.Unmarshal(nc.ExtraData(), &open); err != nil {
|
|
_ = nc.Reject(ssh.ConnectionFailed, "bad direct-streamlocal payload")
|
|
continue
|
|
}
|
|
s.reqMu.Lock()
|
|
s.requested = append(s.requested, open.SocketPath)
|
|
s.reqMu.Unlock()
|
|
if open.SocketPath != s.socketPath {
|
|
_ = nc.Reject(ssh.ConnectionFailed, "no such socket")
|
|
continue
|
|
}
|
|
target, err := net.Dial("unix", s.socketPath)
|
|
if err != nil {
|
|
_ = nc.Reject(ssh.ConnectionFailed, "socket unavailable")
|
|
continue
|
|
}
|
|
ch, chReqs, err := nc.Accept()
|
|
if err != nil {
|
|
_ = target.Close()
|
|
continue
|
|
}
|
|
go ssh.DiscardRequests(chReqs)
|
|
s.wg.Add(1)
|
|
go func() {
|
|
defer s.wg.Done()
|
|
proxy(ch, target)
|
|
}()
|
|
}
|
|
}
|
|
|
|
func proxy(ch ssh.Channel, target net.Conn) {
|
|
var wg sync.WaitGroup
|
|
wg.Add(2)
|
|
go func() {
|
|
defer wg.Done()
|
|
_, _ = io.Copy(target, ch)
|
|
_ = target.Close()
|
|
}()
|
|
go func() {
|
|
defer wg.Done()
|
|
_, _ = io.Copy(ch, target)
|
|
_ = ch.Close()
|
|
}()
|
|
wg.Wait()
|
|
}
|
|
|
|
func (s *testSSHServer) stop() {
|
|
s.stopOnce.Do(func() {
|
|
_ = s.listener.Close()
|
|
s.mu.Lock()
|
|
for _, c := range s.conns {
|
|
_ = c.Close()
|
|
}
|
|
s.mu.Unlock()
|
|
s.wg.Wait()
|
|
})
|
|
}
|
|
|
|
func (s *testSSHServer) requestedPaths() []string {
|
|
s.reqMu.Lock()
|
|
defer s.reqMu.Unlock()
|
|
return append([]string(nil), s.requested...)
|
|
}
|
|
|
|
// startEchoSocket listens on a unix socket that echoes what it reads. The
|
|
// path is kept short: unix socket paths are limited to about 100 bytes and
|
|
// t.TempDir() names are long.
|
|
func startEchoSocket(t *testing.T) string {
|
|
t.Helper()
|
|
dir, err := os.MkdirTemp("", "kvm2")
|
|
if err != nil {
|
|
t.Fatalf("temp dir: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
|
path := filepath.Join(dir, "libvirt-sock")
|
|
ln, err := net.Listen("unix", path)
|
|
if err != nil {
|
|
t.Fatalf("listen unix: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = ln.Close() })
|
|
go func() {
|
|
for {
|
|
c, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
go func() {
|
|
_, _ = io.Copy(c, c)
|
|
_ = c.Close()
|
|
}()
|
|
}
|
|
}()
|
|
return path
|
|
}
|
|
|
|
// silentListener accepts TCP connections and never speaks, for handshake
|
|
// timeouts. It returns the address.
|
|
func silentListener(t *testing.T) string {
|
|
t.Helper()
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
var conns []net.Conn
|
|
var mu sync.Mutex
|
|
go func() {
|
|
for {
|
|
c, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
mu.Lock()
|
|
conns = append(conns, c)
|
|
mu.Unlock()
|
|
}
|
|
}()
|
|
t.Cleanup(func() {
|
|
_ = ln.Close()
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
for _, c := range conns {
|
|
_ = c.Close()
|
|
}
|
|
})
|
|
return ln.Addr().String()
|
|
}
|
|
|
|
// closedPort returns an address nothing listens on.
|
|
func closedPort(t *testing.T) string {
|
|
t.Helper()
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
addr := ln.Addr().String()
|
|
_ = ln.Close()
|
|
return addr
|
|
}
|
|
|
|
// writeKeyFile generates an ed25519 key, writes it in OpenSSH format and
|
|
// returns the path and the public half.
|
|
func writeKeyFile(t *testing.T, dir, name string) (string, ssh.PublicKey) {
|
|
t.Helper()
|
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("generating key: %v", err)
|
|
}
|
|
block, err := ssh.MarshalPrivateKey(priv, "kvm2 test")
|
|
if err != nil {
|
|
t.Fatalf("marshalling key: %v", err)
|
|
}
|
|
path := writeFile(t, dir, name, string(pem.EncodeToMemory(block)))
|
|
sshPub, err := ssh.NewPublicKey(pub)
|
|
if err != nil {
|
|
t.Fatalf("public key: %v", err)
|
|
}
|
|
return path, sshPub
|
|
}
|
|
|
|
// writeKnownHosts writes one known_hosts line for addr and key.
|
|
func writeKnownHosts(t *testing.T, dir, name, addr string, key ssh.PublicKey) string {
|
|
t.Helper()
|
|
return writeFile(t, dir, name, knownhosts.Line([]string{addr}, key)+"\n")
|
|
}
|
|
|
|
func writeFile(t *testing.T, dir, name, content string) string {
|
|
t.Helper()
|
|
path := filepath.Join(dir, name)
|
|
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
|
t.Fatalf("writing %s: %v", path, err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
// otherPublicKey returns a public key unrelated to any file, for mismatch
|
|
// cases.
|
|
func otherPublicKey(t *testing.T) ssh.PublicKey {
|
|
t.Helper()
|
|
pub, _, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("generating key: %v", err)
|
|
}
|
|
sshPub, err := ssh.NewPublicKey(pub)
|
|
if err != nil {
|
|
t.Fatalf("public key: %v", err)
|
|
}
|
|
return sshPub
|
|
}
|