Files
LinkPort/internal/protocol/sshproxy/ssh_test.go
T
2026-09-09 14:34:04 +08:00

347 lines
9.2 KiB
Go

package sshproxy
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/pem"
"errors"
"io"
"net"
"strconv"
"sync/atomic"
"testing"
"time"
"agentdb/internal/model"
"golang.org/x/crypto/ssh"
)
func testSigner(t *testing.T) (string, ssh.Signer) {
t.Helper()
key, err := GenerateHostKey()
if err != nil {
t.Fatal(err)
}
s, err := ssh.ParsePrivateKey([]byte(key))
if err != nil {
t.Fatal(err)
}
return key, s
}
func testTarget(t *testing.T) (string, ssh.Signer, *atomic.Int32) {
t.Helper()
_, signer := testSigner(t)
calls := &atomic.Int32{}
cfg := &ssh.ServerConfig{PasswordCallback: func(c ssh.ConnMetadata, b []byte) (*ssh.Permissions, error) {
calls.Add(1)
if c.User() == "target" && string(b) == "secret" {
return nil, nil
}
return nil, errors.New("denied")
}}
cfg.AddHostKey(signer)
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { l.Close() })
go func() {
for {
raw, e := l.Accept()
if e != nil {
return
}
go func() {
defer raw.Close()
c, chs, reqs, e := ssh.NewServerConn(raw, cfg)
if e != nil {
return
}
defer c.Close()
go ssh.DiscardRequests(reqs)
for nc := range chs {
ch, rs, e := nc.Accept()
if e != nil {
continue
}
go func() {
defer ch.Close()
for r := range rs {
if r.Type == "exec" {
r.Reply(true, nil)
io.WriteString(ch, "hello\n")
io.WriteString(ch.Stderr(), "warning\n")
ch.SendRequest("exit-status", false, ssh.Marshal(struct{ Code uint32 }{7}))
return
}
if r.Type == "subsystem" {
r.Reply(true, nil)
io.Copy(ch, ch)
return
}
r.Reply(true, nil)
}
}()
}
}()
}
}()
return l.Addr().String(), signer, calls
}
func testProxy(t *testing.T, address string, signer ssh.Signer) model.Proxy {
key, _ := testSigner(t)
host, port, _ := net.SplitHostPort(address)
n, _ := strconv.Atoi(port)
return model.Proxy{GatewayUser: "gateway", GatewayPassword: "gateway-secret", TargetHost: host, TargetPort: n, TargetUser: "target", TargetPassword: "secret", SSH: model.SSHConfig{HostKey: key, TargetHostKey: string(ssh.MarshalAuthorizedKey(signer.PublicKey())), AuthMethod: "password"}}
}
func direct(ctx context.Context, n, a string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, n, a)
}
func TestProbeDoesNotAuthenticateAndPinIsEnforced(t *testing.T) {
address, signer, calls := testTarget(t)
p := testProxy(t, address, signer)
result, err := Probe(context.Background(), p, direct)
if err != nil {
t.Fatal(err)
}
if calls.Load() != 0 {
t.Fatal("probe sent credentials")
}
if result.Fingerprint != ssh.FingerprintSHA256(signer.PublicKey()) || result.HostKey != p.SSH.TargetHostKey {
t.Fatal("wrong probe identity")
}
if _, err = New().Test(context.Background(), p, direct); err != nil {
t.Fatal(err)
}
_, other := testSigner(t)
p.SSH.TargetHostKey = string(ssh.MarshalAuthorizedKey(other.PublicKey()))
before := calls.Load()
if _, err = New().Test(context.Background(), p, direct); err == nil {
t.Fatal("host key mismatch accepted")
}
if calls.Load() != before {
t.Fatal("sent credentials to mismatched host")
}
}
func startGateway(t *testing.T, p model.Proxy) (string, context.CancelFunc) {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { cancel(); l.Close() })
go func() {
for {
c, err := l.Accept()
if err != nil {
return
}
go New().Serve(ctx, c, p, direct, nil, nil)
}
}()
return l.Addr().String(), cancel
}
func TestGatewayExecSFTPAndRejectedForwarding(t *testing.T) {
address, signer, _ := testTarget(t)
p := testProxy(t, address, signer)
gateway, cancel := startGateway(t, p)
gatewayKey, err := ssh.ParsePrivateKey([]byte(p.SSH.HostKey))
if err != nil {
t.Fatal(err)
}
c, err := ssh.Dial("tcp", gateway, &ssh.ClientConfig{User: p.GatewayUser, Auth: []ssh.AuthMethod{ssh.Password(p.GatewayPassword)}, HostKeyCallback: ssh.FixedHostKey(gatewayKey.PublicKey()), Timeout: 5 * time.Second})
if err != nil {
t.Fatal(err)
}
defer c.Close()
session, err := c.NewSession()
if err != nil {
t.Fatal(err)
}
if err = session.Setenv("LD_PRELOAD", "bad"); err == nil {
t.Fatal("unsafe environment accepted")
}
if err = session.Setenv("LANG", "en_US.UTF-8"); err != nil {
t.Fatal(err)
}
if err = session.RequestPty("xterm", 24, 80, ssh.TerminalModes{}); err != nil {
t.Fatal(err)
}
if err = session.WindowChange(40, 120); err != nil {
t.Fatal(err)
}
out, err := session.CombinedOutput("ignored")
var exit *ssh.ExitError
if !errors.As(err, &exit) || exit.ExitStatus() != 7 {
t.Fatalf("exit status: %v", err)
}
if len(out) != 14 {
t.Fatalf("lost output: %q", out)
}
session.Close()
if ch, _, err := c.OpenChannel("direct-tcpip", nil); err == nil {
ch.Close()
t.Fatal("forwarding accepted")
}
if ok, _, err := c.SendRequest("tcpip-forward", true, nil); err != nil || ok {
t.Fatalf("global forwarding: %v %v", ok, err)
}
ch, reqs, err := c.OpenChannel("session", nil)
if err != nil {
t.Fatal(err)
}
go ssh.DiscardRequests(reqs)
ok, err := ch.SendRequest("subsystem", true, ssh.Marshal(struct{ Name string }{"sftp"}))
if err != nil || !ok {
t.Fatalf("sftp: %v", err)
}
payload := []byte("sftp bytes")
if _, err = ch.Write(payload); err != nil {
t.Fatal(err)
}
got := make([]byte, len(payload))
if _, err = io.ReadFull(ch, got); err != nil {
t.Fatal(err)
}
if string(got) != string(payload) {
t.Fatal("subsystem payload changed")
}
ch.Close()
cancel()
done := make(chan error, 1)
go func() { done <- c.Wait() }()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("cancel did not close gateway")
}
}
func TestGatewayPublicKeyAndSessionLimit(t *testing.T) {
address, signer, _ := testTarget(t)
p := testProxy(t, address, signer)
_, clientKey := testSigner(t)
p.GatewayPassword = ""
p.SSH.AuthorizedKeys = []string{string(ssh.MarshalAuthorizedKey(clientKey.PublicKey()))}
gateway, _ := startGateway(t, p)
host, _ := ssh.ParsePrivateKey([]byte(p.SSH.HostKey))
c, err := ssh.Dial("tcp", gateway, &ssh.ClientConfig{User: p.GatewayUser, Auth: []ssh.AuthMethod{ssh.PublicKeys(clientKey)}, HostKeyCallback: ssh.FixedHostKey(host.PublicKey()), Timeout: 5 * time.Second})
if err != nil {
t.Fatal(err)
}
defer c.Close()
for i := 0; i < 10; i++ {
ch, reqs, err := c.OpenChannel("session", nil)
if err != nil {
t.Fatal(err)
}
defer ch.Close()
go ssh.DiscardRequests(reqs)
}
if ch, _, err := c.OpenChannel("session", nil); err == nil {
ch.Close()
t.Fatal("session limit bypassed")
}
}
func TestRequestPolicy(t *testing.T) {
for _, name := range []string{"auth-agent-req@openssh.com", "x11-req", "unknown"} {
if allowedRequest(&ssh.Request{Type: name}) {
t.Fatal(name)
}
}
for _, name := range []string{"TERM", "LANG", "LC_ALL"} {
if !allowedRequest(&ssh.Request{Type: "env", Payload: ssh.Marshal(struct{ Name, Value string }{name, "value"})}) {
t.Fatal(name)
}
}
if allowedRequest(&ssh.Request{Type: "subsystem", Payload: ssh.Marshal(struct{ Name string }{"other"})}) {
t.Fatal("other subsystem accepted")
}
if allowedRequest(&ssh.Request{Type: "env", Payload: []byte{0xff}}) {
t.Fatal("malformed env accepted")
}
}
func TestEncryptedPrivateKeyUpstream(t *testing.T) {
_, key, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
signer, err := ssh.NewSignerFromKey(key)
if err != nil {
t.Fatal(err)
}
block, err := ssh.MarshalPrivateKeyWithPassphrase(key, "test", []byte("passphrase"))
if err != nil {
t.Fatal(err)
}
_, host := testSigner(t)
cfg := &ssh.ServerConfig{PublicKeyCallback: func(c ssh.ConnMetadata, k ssh.PublicKey) (*ssh.Permissions, error) {
if c.User() == "target" && string(k.Marshal()) == string(signer.PublicKey().Marshal()) {
return nil, nil
}
return nil, errors.New("denied")
}}
cfg.AddHostKey(host)
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer l.Close()
go func() {
raw, e := l.Accept()
if e != nil {
return
}
defer raw.Close()
c, chs, reqs, e := ssh.NewServerConn(raw, cfg)
if e != nil {
return
}
defer c.Close()
go ssh.DiscardRequests(reqs)
for ch := range chs {
ch.Reject(ssh.Prohibited, "test")
}
}()
p := testProxy(t, l.Addr().String(), host)
p.SSH.AuthMethod = "privatekey"
p.SSH.PrivateKey = string(pem.EncodeToMemory(block))
p.SSH.Passphrase = "passphrase"
if _, err := New().Test(context.Background(), p, direct); err != nil {
t.Fatal(err)
}
p.SSH.Passphrase = "wrong"
if _, err := upstreamConfig(p); err == nil {
t.Fatal("bad passphrase accepted")
}
}
func TestCanceledHandshakeClosesConnection(t *testing.T) {
_, signer := testSigner(t)
p := testProxy(t, "127.0.0.1:22", signer)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
raw, peer := net.Pipe()
defer peer.Close()
done := make(chan error, 1)
go func() {
_, err := New().Test(ctx, p, func(context.Context, string, string) (net.Conn, error) { return raw, nil })
done <- err
}()
cancel()
select {
case err := <-done:
if err == nil {
t.Fatal("canceled handshake succeeded")
}
case <-time.After(2 * time.Second):
t.Fatal("handshake cancellation leaked")
}
}