continuous-integration/drone Build is failing
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.
417 lines
12 KiB
Go
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")
|
|
}
|
|
}
|