Files
LinkPort/internal/api/server_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

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)
}