Files
LinkPort/internal/api/target_draft.go
T
曾志威 408f346ab1 feat: add WebDAV sync backups and unauthenticated targets
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.
2026-09-22 14:00:42 +08:00

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