Replace active S3 sync with encrypted WebDAV synchronization and independent backups. Support explicit empty passwords and unauthenticated MongoDB, Redis, HTTP and HTTPS targets. Include migration, design and validation documentation. Validation: build and go vet passed; Playwright 113 passed with 4 existing failures. Full Go run reproduced the existing cluster discovery test failure. Preserve unrelated SSH fingerprint changes outside this commit.
164 lines
4.6 KiB
Go
164 lines
4.6 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"serviceport/internal/gateway"
|
|
"serviceport/internal/model"
|
|
"serviceport/internal/outbound"
|
|
"serviceport/internal/protocol/sshproxy"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// testTargetDraft resolves credentials and assets into an ephemeral snapshot.
|
|
// It never saves targets, history, probes or gateway runtime configuration.
|
|
func (s *Server) testTargetDraft(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
fail(w, 405, "method not allowed")
|
|
return
|
|
}
|
|
var body struct {
|
|
TargetID string `json:"targetId"`
|
|
AccountID string `json:"accountId"`
|
|
Config map[string]any `json:"config"`
|
|
HostFingerprint string `json:"hostFingerprint"`
|
|
}
|
|
if !decode(w, r, &body) {
|
|
return
|
|
}
|
|
if body.Config == nil || (body.AccountID == "" && body.Config["type"] != "tcp") {
|
|
fail(w, 400, "select a target account and provide draft config")
|
|
return
|
|
}
|
|
if err := editable(body.Config); err != nil {
|
|
fail(w, 400, err.Error())
|
|
return
|
|
}
|
|
current := model.TargetDefaults()
|
|
if body.TargetID != "" {
|
|
var err error
|
|
current, err = s.store.GetTarget(body.TargetID)
|
|
if err != nil {
|
|
fail(w, 404, "target not found")
|
|
return
|
|
}
|
|
} else {
|
|
switch body.Config["type"] {
|
|
case "sqlserver":
|
|
current.AuthSource = "master"
|
|
case "pgsql":
|
|
current.AuthSource = "postgres"
|
|
case "rabbitmq":
|
|
current.AuthSource = "/"
|
|
current.TargetTLS = false
|
|
case "redis", "http", "tcp":
|
|
current.TargetTLS = false
|
|
}
|
|
}
|
|
target, err := model.MergeTarget(current, body.Config)
|
|
if err != nil {
|
|
fail(w, 400, "invalid target fields")
|
|
return
|
|
}
|
|
if body.TargetID != "" && target.Type != current.Type {
|
|
fail(w, 400, "target protocol cannot change")
|
|
return
|
|
}
|
|
if _, selected := body.Config["outboundId"]; selected {
|
|
if _, inline := body.Config["outbound"]; inline {
|
|
fail(w, 400, "choose outboundId or inline outbound")
|
|
return
|
|
}
|
|
if target.OutboundID == "" {
|
|
target.Outbound = model.Outbound{Mode: "direct", Auth: "none"}
|
|
}
|
|
} else if _, inline := body.Config["outbound"]; inline {
|
|
target.OutboundID = ""
|
|
}
|
|
if target.OutboundID != "" {
|
|
profile, e := s.store.GetOutbound(target.OutboundID)
|
|
if e != nil {
|
|
fail(w, 400, "outbound not found")
|
|
return
|
|
}
|
|
target.Outbound = profile.Outbound
|
|
}
|
|
if target.Outbound.KubeConfigAssetID != "" {
|
|
asset, e := s.store.GetAsset(target.Outbound.KubeConfigAssetID)
|
|
if e != nil || asset.Type != "kubeconfig" {
|
|
fail(w, 400, "Kubeconfig asset not found or wrong type")
|
|
return
|
|
}
|
|
target.Outbound.KubeConfigContent = asset.Content
|
|
}
|
|
if target.TargetHost != current.TargetHost || target.TargetPort != current.TargetPort || target.OutboundID != current.OutboundID || target.Outbound != current.Outbound {
|
|
target.SSH.TargetHostKey = ""
|
|
}
|
|
// Incomplete unrelated accounts must not block testing the selected account.
|
|
if !target.HasAccount(body.AccountID) && !(target.Type == "tcp" && body.AccountID == "") {
|
|
fail(w, 400, "target account not found")
|
|
return
|
|
}
|
|
accounts := []model.TargetAccount{}
|
|
for _, a := range target.Accounts {
|
|
if a.ID == body.AccountID || a.ID == target.Cluster.SentinelAccountID {
|
|
if strings.TrimSpace(a.Name) == "" {
|
|
a.Name = a.Username
|
|
if a.AuthMethod == "none" {
|
|
a.Name = "无认证账号"
|
|
}
|
|
}
|
|
accounts = append(accounts, a)
|
|
}
|
|
}
|
|
target.Accounts = accounts
|
|
if strings.TrimSpace(target.Name) == "" {
|
|
target.Name = "连接测试"
|
|
}
|
|
target, err = s.store.ResolveTargetAssets(target)
|
|
if err != nil {
|
|
fail(w, 400, err.Error())
|
|
return
|
|
}
|
|
if err = target.Validate(); err != nil {
|
|
fail(w, 400, err.Error())
|
|
return
|
|
}
|
|
p := target.Runtime()
|
|
p.TargetAccountID = body.AccountID
|
|
p, err = target.Resolve(p)
|
|
if err != nil {
|
|
fail(w, 400, err.Error())
|
|
return
|
|
}
|
|
if err = validateKeys(p); err != nil {
|
|
fail(w, 400, err.Error())
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second)
|
|
defer cancel()
|
|
failure := func(err error) {
|
|
stage := gateway.FailureCategory(err)
|
|
respond(w, 422, map[string]string{"error": gateway.FailureMessage(stage), "stage": stage})
|
|
}
|
|
if model.IsSSH(p.Type) && !p.SSH.IgnoreHostKey && p.SSH.TargetHostKey == "" {
|
|
probe, e := sshproxy.Probe(ctx, p, outbound.Dialer(p))
|
|
if e != nil {
|
|
failure(e)
|
|
return
|
|
}
|
|
if body.HostFingerprint == "" || body.HostFingerprint != probe.Fingerprint {
|
|
respond(w, 409, map[string]string{"error": "请核对 SSH 主机指纹后测试当前配置", "stage": "draft_host_confirmation", "fingerprint": probe.Fingerprint})
|
|
return
|
|
}
|
|
p.SSH.TargetHostKey = probe.HostKey
|
|
}
|
|
result, err := s.manager.Test(ctx, p)
|
|
if err != nil {
|
|
failure(err)
|
|
return
|
|
}
|
|
respond(w, 200, result)
|
|
}
|