249 lines
9.0 KiB
Go
249 lines
9.0 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net"
|
|
"reflect"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/ssh"
|
|
"linkport/internal/gateway"
|
|
"linkport/internal/model"
|
|
"linkport/internal/protocol/sshproxy"
|
|
)
|
|
|
|
type recordingDraftProtocol struct {
|
|
inertProtocol
|
|
seen []model.Proxy
|
|
}
|
|
|
|
func (h *recordingDraftProtocol) Test(_ context.Context, p model.Proxy, _ model.DialFunc) (model.TestResult, error) {
|
|
h.seen = append(h.seen, p)
|
|
return model.TestResult{Version: "draft-fixture"}, nil
|
|
}
|
|
|
|
func draftConfig(kind string) map[string]any {
|
|
return map[string]any{"type": kind, "targetHost": "draft.invalid", "targetPort": 12345,
|
|
"targetTLS": false, "outboundId": "", "ssh": map[string]any{"ignoreHostKey": true},
|
|
"accounts": []any{map[string]any{"id": "chosen", "username": "draft-user", "password": "fixture-password", "authMethod": "password"}, map[string]any{"id": "unfinished"}}}
|
|
}
|
|
|
|
func TestTargetDraftAllProtocolsWithoutPersistence(t *testing.T) {
|
|
f := newFixture(t)
|
|
f.login()
|
|
h := &recordingDraftProtocol{}
|
|
handlers := map[string]model.Handler{}
|
|
kinds := []string{"mysql", "mongodb", "pgsql", "sqlserver", "redis", "rabbitmq", "http", "https", "ssh", "sftp"}
|
|
for _, kind := range kinds {
|
|
handlers[kind] = h
|
|
}
|
|
m := gateway.New(f.store, nil, handlers)
|
|
f.server.manager = m
|
|
t.Cleanup(m.Shutdown)
|
|
before := f.request("GET", "/api/v1/targets", nil, false).Body.String()
|
|
events, _ := f.store.Events()
|
|
for _, kind := range kinds {
|
|
t.Run(kind, func(t *testing.T) {
|
|
w := f.request("POST", "/api/v1/targets/test", map[string]any{"accountId": "chosen", "config": draftConfig(kind)}, true)
|
|
requireStatus(t, w, 200)
|
|
p := h.seen[len(h.seen)-1]
|
|
if p.Type != kind || p.TargetHost != "draft.invalid" || p.TargetPort != 12345 || p.TargetUser != "draft-user" || p.TargetPassword != "fixture-password" || p.TargetTLS || len(p.TargetAccounts) != 1 {
|
|
t.Fatal("test did not receive selected draft configuration")
|
|
}
|
|
if strings.Contains(w.Body.String(), "fixture-password") {
|
|
t.Fatal("credential leaked")
|
|
}
|
|
})
|
|
}
|
|
if after := f.request("GET", "/api/v1/targets", nil, false).Body.String(); before != after {
|
|
t.Fatal("draft test created targets")
|
|
}
|
|
afterEvents, _ := f.store.Events()
|
|
if !reflect.DeepEqual(events, afterEvents) {
|
|
t.Fatal("draft test persisted events")
|
|
}
|
|
}
|
|
|
|
func TestTargetDraftRetainsSecretsByIDAndDoesNotChangeSavedTarget(t *testing.T) {
|
|
f := newFixture(t)
|
|
f.login()
|
|
v := f.create("mysql")
|
|
before, err := f.store.GetTarget(v.Config.TargetID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
history, _ := f.store.History("targets", before.ID)
|
|
h := &recordingDraftProtocol{}
|
|
m := gateway.New(f.store, nil, map[string]model.Handler{"mysql": h})
|
|
f.server.manager = m
|
|
t.Cleanup(m.Shutdown)
|
|
cfg := draftConfig("mysql")
|
|
a := map[string]any{"id": "test-account", "username": "new-user"}
|
|
cfg["accounts"] = []any{map[string]any{"id": "unfinished"}, a}
|
|
body := map[string]any{"targetId": before.ID, "accountId": "test-account", "config": cfg}
|
|
for _, replacement := range []bool{false, true} {
|
|
if replacement {
|
|
a["password"] = "replacement-fixture"
|
|
}
|
|
requireStatus(t, f.request("POST", "/api/v1/targets/test", body, true), 200)
|
|
p := h.seen[len(h.seen)-1]
|
|
want := "target-secret-unique"
|
|
if replacement {
|
|
want = "replacement-fixture"
|
|
}
|
|
if p.TargetPassword != want || p.TargetHost != "draft.invalid" || p.TargetUser != "new-user" || p.Outbound.Mode != "direct" {
|
|
t.Fatal("wrong draft credentials or route")
|
|
}
|
|
}
|
|
after, _ := f.store.GetTarget(before.ID)
|
|
afterHistory, _ := f.store.History("targets", before.ID)
|
|
if !reflect.DeepEqual(before, after) || !reflect.DeepEqual(history, afterHistory) {
|
|
t.Fatal("draft test modified saved target or history")
|
|
}
|
|
p, _ := f.store.Get(v.Config.ID)
|
|
if p.TargetHost != before.TargetHost || p.TargetPassword != before.Accounts[0].Password {
|
|
t.Fatal("draft test changed gateway target")
|
|
}
|
|
}
|
|
|
|
func TestTargetDraftValidationAndSanitizedFailures(t *testing.T) {
|
|
f := newFixture(t)
|
|
f.login()
|
|
body := map[string]any{"accountId": "chosen", "config": draftConfig("mysql")}
|
|
requireStatus(t, f.request("POST", "/api/v1/targets/test", body, false), 403)
|
|
requireStatus(t, f.request("GET", "/api/v1/targets/test", nil, false), 405)
|
|
for _, mutate := range []func(map[string]any){
|
|
func(b map[string]any) { b["accountId"] = "missing" },
|
|
func(b map[string]any) { b["config"] = nil },
|
|
func(b map[string]any) { b["config"].(map[string]any)["id"] = "injected" },
|
|
func(b map[string]any) {
|
|
b["config"].(map[string]any)["ssh"] = map[string]any{"targetHostKey": "injected"}
|
|
},
|
|
func(b map[string]any) { b["config"].(map[string]any)["targetCAAssetId"] = "missing" },
|
|
func(b map[string]any) { b["config"].(map[string]any)["outboundId"] = "missing" },
|
|
} {
|
|
b := map[string]any{"accountId": "chosen", "config": draftConfig("mysql")}
|
|
mutate(b)
|
|
requireStatus(t, f.request("POST", "/api/v1/targets/test", b, true), 400)
|
|
}
|
|
m := gateway.New(f.store, nil, map[string]model.Handler{"mysql": failingProtocol{}})
|
|
f.server.manager = m
|
|
t.Cleanup(m.Shutdown)
|
|
w := f.request("POST", "/api/v1/targets/test", body, true)
|
|
requireStatus(t, w, 422)
|
|
// This fixture supplies only error text, not an observed authentication rejection.
|
|
if strings.Contains(w.Body.String(), "private-upstream-diagnostic") || !strings.Contains(w.Body.String(), "target_connection_or_protocol") {
|
|
t.Fatal("unsanitized test failure")
|
|
}
|
|
}
|
|
|
|
func TestTargetDraftResolvesSelectedOutboundAndPrivateKeyAssets(t *testing.T) {
|
|
f := newFixture(t)
|
|
f.login()
|
|
key, err := sshproxy.GenerateHostKey()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, asset := range []model.Asset{
|
|
{ID: "draft-key", Name: "fixture key", Type: "ssh_private_key", Content: key},
|
|
{ID: "draft-kube", Name: "fixture kube", Type: "kubeconfig", Content: assetKubeconfigFixture},
|
|
} {
|
|
if err := f.store.SaveAsset(asset); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
w := f.request("POST", "/api/v1/outbounds", map[string]any{"name": "fixture transport", "outbound": map[string]any{"mode": "k8s", "resource": "pod/fixture", "kubeConfigAssetId": "draft-kube"}}, true)
|
|
requireStatus(t, w, 201)
|
|
var out struct {
|
|
Config model.OutboundProfile `json:"config"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h := &recordingDraftProtocol{}
|
|
m := gateway.New(f.store, nil, map[string]model.Handler{"ssh": h})
|
|
f.server.manager = m
|
|
t.Cleanup(m.Shutdown)
|
|
cfg := draftConfig("ssh")
|
|
cfg["outboundId"] = out.Config.ID
|
|
cfg["accounts"] = []any{map[string]any{"id": "chosen", "username": "fixture", "authMethod": "privatekey", "privateKeyAssetId": "draft-key"}}
|
|
requireStatus(t, f.request("POST", "/api/v1/targets/test", map[string]any{"accountId": "chosen", "config": cfg}, true), 200)
|
|
if len(h.seen) != 1 || h.seen[0].SSH.PrivateKey != key || h.seen[0].Outbound.KubeConfigContent != assetKubeconfigFixture || h.seen[0].Outbound.Resource != "pod/fixture" {
|
|
t.Fatal("draft assets or transport not resolved")
|
|
}
|
|
}
|
|
|
|
func TestTargetDraftSSHTrustIsTemporaryAndPrecedesAuthentication(t *testing.T) {
|
|
f := newFixture(t)
|
|
f.login()
|
|
key, err := sshproxy.GenerateHostKey()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
signer, err := ssh.ParsePrivateKey([]byte(key))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var authenticated atomic.Int32
|
|
server := &ssh.ServerConfig{PasswordCallback: func(_ ssh.ConnMetadata, _ []byte) (*ssh.Permissions, error) { authenticated.Add(1); return nil, nil }}
|
|
server.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 {
|
|
c, e := l.Accept()
|
|
if e != nil {
|
|
return
|
|
}
|
|
go func() {
|
|
defer c.Close()
|
|
_ = c.SetDeadline(time.Now().Add(5 * time.Second))
|
|
conn, channels, requests, e := ssh.NewServerConn(c, server)
|
|
if e != nil {
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
go ssh.DiscardRequests(requests)
|
|
for ch := range channels {
|
|
_ = ch.Reject(ssh.UnknownChannelType, "fixture")
|
|
}
|
|
}()
|
|
}
|
|
}()
|
|
m := gateway.New(f.store, nil, map[string]model.Handler{"ssh": &sshproxy.Handler{}})
|
|
f.server.manager = m
|
|
t.Cleanup(m.Shutdown)
|
|
cfg := draftConfig("ssh")
|
|
cfg["targetHost"], cfg["targetPort"], cfg["ssh"] = "127.0.0.1", l.Addr().(*net.TCPAddr).Port, map[string]any{}
|
|
body := map[string]any{"accountId": "chosen", "config": cfg}
|
|
for _, fingerprint := range []string{"", "SHA256:wrong"} {
|
|
body["hostFingerprint"] = fingerprint
|
|
w := f.request("POST", "/api/v1/targets/test", body, true)
|
|
requireStatus(t, w, 409)
|
|
var result map[string]string
|
|
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result["fingerprint"] != ssh.FingerprintSHA256(signer.PublicKey()) || authenticated.Load() != 0 {
|
|
t.Fatal("authentication before host confirmation")
|
|
}
|
|
}
|
|
body["hostFingerprint"] = ssh.FingerprintSHA256(signer.PublicKey())
|
|
requireStatus(t, f.request("POST", "/api/v1/targets/test", body, true), 200)
|
|
if authenticated.Load() != 1 {
|
|
t.Fatal("SSH test did not authenticate once")
|
|
}
|
|
delete(body, "hostFingerprint")
|
|
requireStatus(t, f.request("POST", "/api/v1/targets/test", body, true), 409)
|
|
if authenticated.Load() != 1 {
|
|
t.Fatal("temporary host trust was persisted")
|
|
}
|
|
}
|