Files
LinkPort/internal/protocol/sshproxy/ssh_test.go
T
曾志威 9da95beb42
continuous-integration/drone Build is failing
feat: refine management UI, connection diagnostics and local startup
Migrate the workspace to React and Ant Design, improve account forms and responsive layouts, support outbound diagnostics and SSH fingerprint confirmation, and persist the management startup port. Preserve database migrations, remove S3 settings storage, and improve gateway audit handling.
2026-10-13 19:43:23 +08:00

417 lines
12 KiB
Go

package sshproxy
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/pem"
"errors"
"io"
"net"
"strconv"
"sync/atomic"
"testing"
"time"
"golang.org/x/crypto/ssh"
"linkport/internal/model"
"linkport/internal/outbound"
)
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, lateReply ...bool) (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" {
if len(lateReply) == 0 {
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}))
if len(lateReply) > 0 {
r.Reply(true, nil)
}
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 TestExecReplyIsForwardedBeforeChannelClose(t *testing.T) {
address, signer, _ := testTarget(t, true)
p := testProxy(t, address, signer)
gateway, cancel := startGateway(t, p)
defer cancel()
c, err := ssh.Dial("tcp", gateway, &ssh.ClientConfig{User: p.GatewayUser, Auth: []ssh.AuthMethod{ssh.Password(p.GatewayPassword)}, HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: 3 * time.Second})
if err != nil {
t.Fatal(err)
}
defer c.Close()
for i := 0; i < 30; i++ {
session, err := c.NewSession()
if err != nil {
t.Fatal(err)
}
out, err := session.CombinedOutput("fixture")
session.Close()
var exit *ssh.ExitError
if !errors.As(err, &exit) || exit.ExitStatus() != 7 || len(out) != 14 {
t.Fatalf("lost reply/output: %q %v", out, err)
}
}
}
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{Type: "ssh", GatewayAccounts: []model.GatewayAccount{{ID: "client", Username: "gateway", Password: "gateway-secret"}}, TargetAccountID: "upstream", TargetAccounts: []model.TargetAccount{{ID: "upstream", Username: "target", Password: "secret"}}, 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()
ctx, trace := outbound.NewTrace(context.Background(), p)
if _, err = New().Test(ctx, p, direct); err == nil {
t.Fatal("host key mismatch accepted")
}
var failure *model.ConnectionFailure
if !errors.As(err, &failure) || failure.Stage != "ssh_host_identity" || failure.Cause.Error() != "ssh: host key mismatch" {
t.Fatalf("host-key mismatch not classified: %v", err)
}
observed := false
for _, stage := range trace.Snapshot().Stages {
if stage.ID == "ssh_host_identity" && stage.Status == "failed" && stage.Kind == "identity" {
observed = true
}
}
if !observed {
t.Fatal("fingerprint verification failure not observed")
}
if calls.Load() != before {
t.Fatal("sent credentials to mismatched host")
}
p.SSH.IgnoreHostKey = true
for _, saved := range []string{"", p.SSH.TargetHostKey, "invalid-key"} {
p.SSH.TargetHostKey = saved
got, err := New().Test(context.Background(), p, direct)
if err != nil {
t.Fatal(err)
}
if got.Fingerprint != ssh.FingerprintSHA256(signer.PublicKey()) {
t.Fatal("test did not report actual server fingerprint")
}
}
}
func TestSSHTestRejectsUnconfirmedHostKeyBeforeDial(t *testing.T) {
proxy := model.Proxy{Type: "ssh", TargetHost: "example.invalid", TargetPort: 22, SSH: model.SSHConfig{AuthMethod: "password"}}
dialed := false
_, err := New().Test(context.Background(), proxy, func(context.Context, string, string) (net.Conn, error) {
dialed = true
return nil, errors.New("unexpected dial")
})
var failure *model.ConnectionFailure
if !errors.As(err, &failure) || failure.Stage != "ssh_host_key_unconfirmed" || dialed {
t.Fatalf("expected pre-dial host identity error, got %v (dialed=%v)", err, dialed)
}
}
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)
p.GatewayAccounts = []model.GatewayAccount{{ID: "other", Username: "other", Password: "other-secret"}, {ID: "gateway", Username: p.GatewayUser, Password: p.GatewayPassword}}
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()))}
p.GatewayAccounts = []model.GatewayAccount{{ID: "other", Username: "other", Password: "other-secret"}, {ID: "gateway", Username: p.GatewayUser, AuthorizedKeys: p.SSH.AuthorizedKeys}}
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")
}
}