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.
447 lines
16 KiB
Go
447 lines
16 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/ssh"
|
|
"linkport/internal/gateway"
|
|
"linkport/internal/model"
|
|
"linkport/internal/protocol/sshproxy"
|
|
"linkport/internal/security"
|
|
"linkport/internal/store"
|
|
)
|
|
|
|
const adminPassword = "integration-admin-password"
|
|
|
|
type failingProtocol struct{ inertProtocol }
|
|
|
|
func (failingProtocol) Test(context.Context, model.Proxy, model.DialFunc) (model.TestResult, error) {
|
|
return model.TestResult{}, errors.New("SOCKS5 authentication failed: private-upstream-diagnostic")
|
|
}
|
|
|
|
func TestConnectionErrorsExposeStageWithoutDiagnostic(t *testing.T) {
|
|
f := newFixture(t)
|
|
f.login()
|
|
v := f.create("mysql")
|
|
manager := gateway.New(f.store, nil, map[string]model.Handler{"mysql": failingProtocol{}})
|
|
f.server.manager = manager
|
|
t.Cleanup(manager.Shutdown)
|
|
w := f.request("POST", "/api/v1/proxies/"+v.Config.ID+"/test", map[string]any{}, true)
|
|
requireStatus(t, w, 422)
|
|
var result struct {
|
|
Stage string `json:"stage"`
|
|
Diagnostics struct {
|
|
Stages []struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"`
|
|
} `json:"stages"`
|
|
} `json:"diagnostics"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.Stage != "target_connection_or_protocol" {
|
|
t.Fatal("wrong connection failure stage")
|
|
}
|
|
if len(result.Diagnostics.Stages) == 0 {
|
|
t.Fatal("missing safe stage diagnostics")
|
|
}
|
|
for _, stage := range result.Diagnostics.Stages {
|
|
if stage.ID == "target_connect" && stage.Status != "not_run" {
|
|
t.Fatal("fixture did not observe a connection", stage)
|
|
}
|
|
}
|
|
if strings.Contains(w.Body.String(), "private-upstream-diagnostic") {
|
|
t.Fatal("diagnostic leaked to API")
|
|
}
|
|
logs, err := f.store.Events()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, _ := json.Marshal(logs)
|
|
if bytes.Contains(raw, []byte("private-upstream-diagnostic")) {
|
|
t.Fatal("diagnostic leaked to audit")
|
|
}
|
|
}
|
|
|
|
type fixture struct {
|
|
t *testing.T
|
|
server *Server
|
|
store *store.Store
|
|
handler http.Handler
|
|
cookie *http.Cookie
|
|
csrf string
|
|
databasePath string
|
|
}
|
|
type inertProtocol struct{}
|
|
|
|
func (inertProtocol) Serve(ctx context.Context, c net.Conn, _ model.Proxy, _ model.DialFunc, _ *tls.Config, _ model.EventFunc) error {
|
|
defer c.Close()
|
|
<-ctx.Done()
|
|
return ctx.Err()
|
|
}
|
|
func (inertProtocol) Test(context.Context, model.Proxy, model.DialFunc) (model.TestResult, error) {
|
|
return model.TestResult{Version: "test"}, nil
|
|
}
|
|
func newFixture(t *testing.T) *fixture {
|
|
t.Helper()
|
|
v, err := security.NewVault(base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
databasePath := filepath.Join(t.TempDir(), "test.db")
|
|
s, err := store.Open(databasePath, v)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { s.Close() })
|
|
if err = s.InitAdmin("admin", adminPassword); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m := gateway.New(s, &tls.Config{MinVersion: tls.VersionTLS12}, map[string]model.Handler{"mysql": inertProtocol{}, "mongodb": inertProtocol{}, "ssh": inertProtocol{}})
|
|
t.Cleanup(m.Shutdown)
|
|
api := New(s, m, true)
|
|
return &fixture{t: t, server: api, store: s, handler: api.Handler(http.NotFoundHandler()), databasePath: databasePath}
|
|
}
|
|
func (f *fixture) request(method, path string, body any, csrf bool) *httptest.ResponseRecorder {
|
|
f.t.Helper()
|
|
var data []byte
|
|
var err error
|
|
if body != nil {
|
|
data, err = json.Marshal(body)
|
|
if err != nil {
|
|
f.t.Fatal(err)
|
|
}
|
|
}
|
|
req := httptest.NewRequest(method, "https://linkport.test"+path, bytes.NewReader(data))
|
|
req.RemoteAddr = "127.0.0.1:12345"
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if f.cookie != nil {
|
|
req.AddCookie(f.cookie)
|
|
}
|
|
if csrf {
|
|
req.Header.Set("X-CSRF-Token", f.csrf)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
f.handler.ServeHTTP(w, req)
|
|
return w
|
|
}
|
|
func requireStatus(t *testing.T, w *httptest.ResponseRecorder, want int) {
|
|
t.Helper()
|
|
if w.Code != want {
|
|
t.Fatalf("status %d, want %d: %s", w.Code, want, w.Body.String())
|
|
}
|
|
}
|
|
func (f *fixture) login() {
|
|
f.t.Helper()
|
|
w := f.request("POST", "/api/v1/login", map[string]string{"username": "admin", "password": adminPassword}, false)
|
|
requireStatus(f.t, w, 200)
|
|
cookies := w.Result().Cookies()
|
|
if len(cookies) != 1 {
|
|
f.t.Fatal("missing session cookie")
|
|
}
|
|
f.cookie = cookies[0]
|
|
var body map[string]string
|
|
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
|
f.t.Fatal(err)
|
|
}
|
|
f.csrf = body["csrfToken"]
|
|
if f.csrf == "" {
|
|
f.t.Fatal("missing CSRF")
|
|
}
|
|
}
|
|
func freePort(t *testing.T) int {
|
|
t.Helper()
|
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
port := l.Addr().(*net.TCPAddr).Port
|
|
l.Close()
|
|
return port
|
|
}
|
|
func config(t *testing.T, kind string) map[string]any {
|
|
return map[string]any{"name": "test proxy", "type": kind, "listenHost": "127.0.0.1", "listenPort": freePort(t), "targetHost": "db.example", "targetPort": 3306, "allowCIDRs": []string{"127.0.0.1/32"}, "targetUser": "upstream", "targetPassword": "target-secret-unique", "gatewayUser": "gateway", "gatewayPassword": "gateway-secret-unique", "outbound": map[string]any{"mode": "socks5", "host": "socks.example", "port": 1080, "auth": "password", "username": "socks-user", "password": "socks-secret-unique"}}
|
|
}
|
|
|
|
type proxyView struct {
|
|
Config model.Proxy `json:"config"`
|
|
Flags map[string]bool `json:"secretStatus"`
|
|
Runtime gateway.Status `json:"runtime"`
|
|
}
|
|
|
|
func readView(t *testing.T, w *httptest.ResponseRecorder) proxyView {
|
|
t.Helper()
|
|
var v proxyView
|
|
if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return v
|
|
}
|
|
func (f *fixture) create(kind string) proxyView {
|
|
f.t.Helper()
|
|
w := f.createConfigured(config(f.t, kind))
|
|
requireStatus(f.t, w, 201)
|
|
return readView(f.t, w)
|
|
}
|
|
|
|
// Build independent target/account and gateway fixtures using the current API.
|
|
func (f *fixture) createConfigured(c map[string]any) *httptest.ResponseRecorder {
|
|
f.t.Helper()
|
|
account := map[string]any{"id": "test-account", "remark": "Test", "username": c["targetUser"], "password": c["targetPassword"], "authMethod": "password"}
|
|
if sshConfig, ok := c["ssh"].(map[string]any); ok {
|
|
for _, key := range []string{"authMethod", "privateKey", "passphrase"} {
|
|
if value, ok := sshConfig[key]; ok {
|
|
account[key] = value
|
|
}
|
|
}
|
|
}
|
|
target := map[string]any{"name": "Test target", "type": c["type"], "targetHost": c["targetHost"], "targetPort": c["targetPort"], "outbound": c["outbound"], "accounts": []any{account}}
|
|
if outbound, ok := c["outbound"].(map[string]any); ok && outbound["mode"] != "direct" {
|
|
response := f.request("POST", "/api/v1/outbounds", map[string]any{"name": "Fixture proxy", "outbound": outbound}, true)
|
|
requireStatus(f.t, response, 201)
|
|
var profile struct {
|
|
Config model.OutboundProfile `json:"config"`
|
|
}
|
|
if err := json.Unmarshal(response.Body.Bytes(), &profile); err != nil {
|
|
f.t.Fatal(err)
|
|
}
|
|
delete(target, "outbound")
|
|
target["outboundId"] = profile.Config.ID
|
|
}
|
|
w := f.request("POST", "/api/v1/targets", target, true)
|
|
requireStatus(f.t, w, 201)
|
|
var v struct {
|
|
Config model.Target `json:"config"`
|
|
}
|
|
json.Unmarshal(w.Body.Bytes(), &v)
|
|
gateway := map[string]any{"targetId": v.Config.ID, "targetAccountId": "test-account"}
|
|
gateway["gatewayAccounts"] = []any{map[string]any{"id": "client", "username": c["gatewayUser"], "password": c["gatewayPassword"]}}
|
|
for _, key := range []string{"name", "type", "listenHost", "listenPort", "allowCIDRs", "accessMode"} {
|
|
if value, ok := c[key]; ok {
|
|
gateway[key] = value
|
|
}
|
|
}
|
|
return f.request("POST", "/api/v1/proxies", gateway, true)
|
|
}
|
|
|
|
func TestSessionsCSRFAndPasswordReset(t *testing.T) {
|
|
f := newFixture(t)
|
|
requireStatus(t, f.request("GET", "/api/v1/proxies", nil, false), 401)
|
|
requireStatus(t, f.request("POST", "/api/v1/login", map[string]string{"username": "admin", "password": "wrong"}, false), 401)
|
|
f.login()
|
|
if !f.cookie.HttpOnly || !f.cookie.Secure || f.cookie.SameSite != http.SameSiteStrictMode {
|
|
t.Fatal("session cookie protections missing")
|
|
}
|
|
requireStatus(t, f.request("GET", "/api/v1/session", nil, false), 200)
|
|
requireStatus(t, f.request("POST", "/api/v1/proxies", config(t, "mysql"), false), 403)
|
|
req := httptest.NewRequest("POST", "https://linkport.test/api/v1/logout", nil)
|
|
req.RemoteAddr = "127.0.0.1:12345"
|
|
req.AddCookie(f.cookie)
|
|
req.Header.Set("X-CSRF-Token", f.csrf)
|
|
req.Header.Set("Origin", "https://evil.test")
|
|
w := httptest.NewRecorder()
|
|
f.handler.ServeHTTP(w, req)
|
|
requireStatus(t, w, 403)
|
|
if err := f.store.ResetPassword("replacement-password"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
requireStatus(t, f.request("GET", "/api/v1/session", nil, false), 401)
|
|
}
|
|
|
|
func TestDefaultReadwriteRedactionAndSecretPreservingUpdate(t *testing.T) {
|
|
f := newFixture(t)
|
|
f.login()
|
|
for _, kind := range []string{"mysql", "mongodb"} {
|
|
v := f.create(kind)
|
|
if v.Config.AccessMode != "readwrite" {
|
|
t.Fatalf("%s does not default readwrite", kind)
|
|
}
|
|
if !v.Flags["targetPassword"] || !v.Flags["gatewayAccount:client:password"] || !v.Flags["socksPassword"] {
|
|
t.Fatal("secret presence flags missing")
|
|
}
|
|
if v.Config.TargetPassword != "" || v.Config.GatewayPassword != "" || v.Config.Outbound.Password != "" {
|
|
t.Fatal("create leaked secrets")
|
|
}
|
|
path := "/api/v1/proxies/" + v.Config.ID
|
|
w := f.request("PUT", path, map[string]any{"name": "renamed", "accessMode": "readonly"}, true)
|
|
requireStatus(t, w, 200)
|
|
requireStatus(t, f.request("PUT", "/api/v1/outbounds/"+v.Config.OutboundID, map[string]any{"outbound": map[string]any{"host": "new-socks.example"}}, true), 200)
|
|
saved, err := f.store.Get(v.Config.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if saved.TargetPassword != "target-secret-unique" || saved.GatewayAccounts[0].Password != "gateway-secret-unique" || saved.Outbound.Password != "socks-secret-unique" || saved.AccessMode != "readonly" {
|
|
t.Fatal("partial update lost secret or mode")
|
|
}
|
|
}
|
|
for _, path := range []string{"/api/v1/proxies", "/api/v1/events"} {
|
|
w := f.request("GET", path, nil, false)
|
|
requireStatus(t, w, 200)
|
|
for _, secret := range []string{"target-secret-unique", "gateway-secret-unique", "socks-secret-unique", adminPassword} {
|
|
if strings.Contains(w.Body.String(), secret) {
|
|
t.Fatal("secret leaked in " + path)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRevealRequiresReauthenticationAndLimitsBothSessionAndIP(t *testing.T) {
|
|
f := newFixture(t)
|
|
f.login()
|
|
v := f.create("mysql")
|
|
path := "/api/v1/proxies/" + v.Config.ID + "/reveal-password"
|
|
body := map[string]string{"field": "targetPassword", "password": adminPassword}
|
|
requireStatus(t, f.request("POST", path, body, false), 403)
|
|
requireStatus(t, f.request("POST", path, map[string]string{"field": "targetPassword", "password": "wrong"}, true), 401)
|
|
w := f.request("POST", path, body, true)
|
|
requireStatus(t, w, 200)
|
|
if w.Header().Get("Cache-Control") != "no-store" {
|
|
t.Fatal("secret response cacheable")
|
|
}
|
|
if !strings.Contains(w.Body.String(), "target-secret-unique") || strings.Contains(w.Body.String(), "gateway-secret-unique") {
|
|
t.Fatal("wrong secret disclosed")
|
|
}
|
|
requireStatus(t, f.request("POST", path, map[string]string{"field": "sshPrivateKey", "password": adminPassword}, true), 400)
|
|
requireStatus(t, f.request("POST", path, body, true), 200)
|
|
requireStatus(t, f.request("POST", path, body, true), 200)
|
|
requireStatus(t, f.request("POST", path, body, true), 429)
|
|
encoded, _ := json.Marshal(body)
|
|
req := httptest.NewRequest("POST", "https://linkport.test"+path, bytes.NewReader(encoded))
|
|
req.RemoteAddr = "127.0.0.2:12345"
|
|
req.AddCookie(f.cookie)
|
|
req.Header.Set("X-CSRF-Token", f.csrf)
|
|
otherIP := httptest.NewRecorder()
|
|
f.handler.ServeHTTP(otherIP, req)
|
|
requireStatus(t, otherIP, 429)
|
|
// New session at the same address cannot bypass the independent IP limit.
|
|
f.login()
|
|
requireStatus(t, f.request("POST", path, body, true), 429)
|
|
events, err := f.store.Events()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, _ := json.Marshal(events)
|
|
if bytes.Contains(raw, []byte("target-secret-unique")) || bytes.Contains(raw, []byte(adminPassword)) {
|
|
t.Fatal("audit leaked credentials")
|
|
}
|
|
}
|
|
|
|
func TestRunningGatewayCanBeEditedButMustStopBeforeDeleting(t *testing.T) {
|
|
f := newFixture(t)
|
|
f.login()
|
|
v := f.create("mysql")
|
|
path := "/api/v1/proxies/" + v.Config.ID
|
|
requireStatus(t, f.request("POST", path+"/start", nil, true), 200)
|
|
requireStatus(t, f.request("PUT", path, map[string]any{"accessMode": "readonly"}, true), 200)
|
|
if !f.server.manager.Status(v.Config.ID).Running {
|
|
t.Fatal("gateway update stopped listener")
|
|
}
|
|
requireStatus(t, f.request("DELETE", path, nil, true), 409)
|
|
requireStatus(t, f.request("POST", path+"/stop", nil, true), 200)
|
|
requireStatus(t, f.request("PUT", path, map[string]any{"type": "mongodb"}, true), 400)
|
|
requireStatus(t, f.request("DELETE", path, nil, true), 200)
|
|
requireStatus(t, f.request("GET", path, nil, false), 404)
|
|
}
|
|
|
|
func TestSSHHostKeyCannotBeInjectedAndPrivateKeyIsRedacted(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)
|
|
}
|
|
public := string(ssh.MarshalAuthorizedKey(signer.PublicKey()))
|
|
for _, field := range []string{"targetHostKey", "TargetHostKey", "TARGETHOSTKEY", "hostKey", "HostKey"} {
|
|
c := config(t, "ssh")
|
|
c["ssh"] = map[string]any{field: public}
|
|
w := f.request("POST", "/api/v1/proxies", c, true)
|
|
requireStatus(t, w, 400)
|
|
}
|
|
c := config(t, "ssh")
|
|
c["ssh"] = map[string]any{"authMethod": "privatekey", "privateKey": key}
|
|
w := f.createConfigured(c)
|
|
requireStatus(t, w, 201)
|
|
if strings.Contains(w.Body.String(), "PRIVATE KEY") {
|
|
t.Fatal("SSH key leaked")
|
|
}
|
|
v := readView(t, w)
|
|
if v.Config.AccessMode != "" || !v.Flags["sshPrivateKey"] {
|
|
t.Fatal("invalid SSH view")
|
|
}
|
|
path := "/api/v1/proxies/" + v.Config.ID
|
|
requireStatus(t, f.request("POST", path+"/start", nil, true), 409)
|
|
requireStatus(t, f.request("POST", path+"/confirm-host-key", map[string]string{"fingerprint": ssh.FingerprintSHA256(signer.PublicKey())}, true), 409)
|
|
requireStatus(t, f.request("PUT", path, map[string]any{"ssh": map[string]any{"TargetHostKey": public}}, true), 400)
|
|
saved, err := f.store.Get(v.Config.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if saved.SSH.TargetHostKey != "" || saved.SSH.HostKey == "" {
|
|
t.Fatal("host pin injected or gateway key missing")
|
|
}
|
|
}
|
|
|
|
func TestLogoutInvalidatesSessionAndMalformedJSONRejected(t *testing.T) {
|
|
f := newFixture(t)
|
|
f.login()
|
|
req := httptest.NewRequest("POST", "https://linkport.test/api/v1/proxies", io.NopCloser(strings.NewReader(`{} {}`)))
|
|
req.AddCookie(f.cookie)
|
|
req.Header.Set("X-CSRF-Token", f.csrf)
|
|
w := httptest.NewRecorder()
|
|
f.handler.ServeHTTP(w, req)
|
|
requireStatus(t, w, 400)
|
|
requireStatus(t, f.request("POST", "/api/v1/logout", nil, true), 200)
|
|
requireStatus(t, f.request("GET", "/api/v1/proxies", nil, false), 401)
|
|
}
|
|
|
|
func TestExpiredSessionCannotRevealPassword(t *testing.T) {
|
|
f := newFixture(t)
|
|
f.login()
|
|
v := f.create("mysql")
|
|
f.server.mu.Lock()
|
|
s := f.server.sessions[f.cookie.Value]
|
|
s.Expires = time.Now().Add(-time.Second)
|
|
f.server.sessions[f.cookie.Value] = s
|
|
f.server.mu.Unlock()
|
|
requireStatus(t, f.request("POST", "/api/v1/proxies/"+v.Config.ID+"/reveal-password", map[string]string{"field": "targetPassword", "password": adminPassword}, true), 401)
|
|
}
|
|
|
|
func TestManagedFieldsAndInvalidSSHKeysRejected(t *testing.T) {
|
|
f := newFixture(t)
|
|
f.login()
|
|
for _, field := range []string{"id", "ID", "enabled", "Enabled", "Type", "unknown"} {
|
|
c := config(t, "mysql")
|
|
c[field] = "injected"
|
|
requireStatus(t, f.request("POST", "/api/v1/proxies", c, true), 400)
|
|
}
|
|
for _, sshConfig := range []map[string]any{
|
|
{"authMethod": "privatekey", "privateKey": "invalid"},
|
|
{"authorizedKeys": []string{"not a key"}},
|
|
} {
|
|
c := config(t, "ssh")
|
|
c["ssh"] = sshConfig
|
|
requireStatus(t, f.request("POST", "/api/v1/proxies", c, true), 400)
|
|
}
|
|
c := config(t, "ssh")
|
|
c["accessMode"] = "readonly"
|
|
requireStatus(t, f.request("POST", "/api/v1/proxies", c, true), 400)
|
|
}
|