Files
曾志威 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

253 lines
7.5 KiB
Go

package api
import (
"errors"
"linkport/internal/gateway"
"linkport/internal/model"
"linkport/internal/outbound"
"linkport/internal/security"
"net/http"
"strings"
"time"
)
func (s *Server) outboundView(p model.OutboundProfile) any {
refs, _ := s.store.OutboundReferences(p.ID)
gateways, _ := s.store.OutboundGatewayReferences(p.ID)
flags := map[string]bool{"password": p.Outbound.Password != "", "singBoxOutbound": p.Outbound.SingBoxOutbound != ""}
flags["kubeConfigContent"] = p.Outbound.KubeConfigContent != ""
p.Outbound.Password = ""
p.Outbound.SingBoxOutbound = ""
p.Outbound.KubeConfigContent = ""
return map[string]any{"config": p, "targetIds": refs, "gatewayIds": gateways, "secretStatus": flags, "sshUnverified": p.Outbound.Mode == "ssh" && p.Outbound.SkipHostKeyVerify}
}
func (s *Server) outbounds(w http.ResponseWriter, r *http.Request, path string, sess session, token string) {
parts := strings.Split(path, "/")
if path == "outbounds/probe" && r.Method == "POST" {
s.probeOutbound(w, r, model.OutboundProfile{}, token)
return
}
if len(parts) == 1 {
switch r.Method {
case "GET":
profiles, err := s.store.Outbounds()
if err != nil {
fail(w, 500, "cannot load outbounds")
return
}
out := []any{}
for _, p := range profiles {
if !requestScope(r).Matches(p.Project, p.Environment) {
continue
}
out = append(out, s.outboundView(p))
}
respond(w, 200, out)
case "POST":
s.saveOutbound(w, r, model.OutboundProfile{Project: model.DefaultProject, Environment: model.DefaultEnvironment}, token)
default:
fail(w, 405, "method not allowed")
}
return
}
p, err := s.store.GetOutbound(parts[1])
if err != nil {
fail(w, 404, "outbound not found")
return
}
if len(parts) == 3 && parts[2] == "project" && r.Method == "PATCH" {
s.updateOutboundProject(w, r, p)
return
}
if len(parts) == 3 && parts[2] == "probe" && r.Method == "POST" {
s.probeOutbound(w, r, p, token)
return
}
if len(parts) == 3 && parts[2] == "test" && r.Method == "POST" {
if p.Outbound.Mode == "socks5" || p.Outbound.Mode == "k8s" {
s.testNetworkOutbound(w, r, p)
return
}
if p.Outbound.Mode == "sing-box" {
started := time.Now()
if err := outbound.CheckSingBox(r.Context(), p.Outbound.SingBoxOutbound); err != nil {
respond(w, 422, map[string]any{"error": singBoxCheckMessage(err), "durationMs": time.Since(started).Milliseconds()})
return
}
respond(w, 200, map[string]any{"valid": true, "type": p.Outbound.SingBoxType, "durationMs": time.Since(started).Milliseconds()})
return
}
if p.Outbound.Mode != "ssh" {
fail(w, 400, "unsupported proxy type for connection testing")
return
}
started := time.Now()
var options struct {
Skip bool `json:"testOnlySkipHostKeyVerify"`
}
if r.ContentLength != 0 && !decodeLimit(w, r, &options, 4096) {
return
}
testOutbound := p.Outbound
if options.Skip {
testOutbound.SkipHostKeyVerify = true
}
result, e := outbound.TestSSH(r.Context(), testOutbound)
if e != nil {
stage := gateway.FailureCategory(e)
respond(w, 422, map[string]any{"error": gateway.FailureMessage(stage), "stage": stage, "durationMs": time.Since(started).Milliseconds()})
return
}
respond(w, 200, map[string]any{"version": result.Version, "fingerprint": result.Fingerprint, "authenticated": true, "hostKeyVerified": !testOutbound.SkipHostKeyVerify, "testOnlySkipHostKeyVerify": options.Skip, "durationMs": time.Since(started).Milliseconds()})
return
}
if len(parts) == 3 && parts[2] == "reveal-password" && r.Method == "POST" {
s.reveal(w, r, model.Proxy{ID: p.ID, Outbound: p.Outbound}, sess, token, "socksPassword")
return
}
if len(parts) != 2 {
fail(w, 404, "not found")
return
}
switch r.Method {
case "GET":
respond(w, 200, s.outboundView(p))
case "PUT":
if !s.forwardingProxyEditable(w, p.ID, false) {
return
}
refs, e := s.store.OutboundReferences(p.ID)
if e != nil {
fail(w, 500, "cannot load references")
return
}
for _, id := range refs {
if !s.targetStopped(w, id) {
return
}
}
s.saveOutbound(w, r, p, token)
case "DELETE":
if !s.forwardingProxyEditable(w, p.ID, true) {
return
}
refs, e := s.store.OutboundReferences(p.ID)
if e != nil {
fail(w, 500, "cannot load references")
return
}
if len(refs) > 0 {
fail(w, 409, "outbound is referenced by targets; rebind those targets first")
return
}
if e = s.store.DeleteOutbound(p.ID); e != nil {
fail(w, 500, "delete failed")
return
}
s.store.Event(p.ID, source(r), "outbound_deleted", "")
respond(w, 200, map[string]bool{"ok": true})
default:
fail(w, 405, "method not allowed")
}
}
func singBoxCheckMessage(err error) string {
switch {
case errors.Is(err, outbound.ErrSingBoxUnavailable):
return outbound.ErrSingBoxUnavailable.Error()
case errors.Is(err, outbound.ErrSingBoxInvalid):
return outbound.ErrSingBoxInvalid.Error()
case errors.Is(err, outbound.ErrSingBoxCheckTimeout):
return outbound.ErrSingBoxCheckTimeout.Error()
default:
return outbound.ErrSingBoxCheck.Error()
}
}
func (s *Server) updateOutboundProject(w http.ResponseWriter, r *http.Request, profile model.OutboundProfile) {
var patch map[string]any
if !decodeLimit(w, r, &patch, 4096) {
return
}
project, valid := patch["project"].(string)
if !valid || len(patch) != 1 {
fail(w, 400, "invalid outbound fields")
return
}
if err := model.ValidateScope(project, profile.Environment); err != nil {
fail(w, 400, err.Error())
return
}
updated, err := s.store.UpdateOutboundProject(profile.ID, project)
if err != nil {
fail(w, 500, "save failed")
return
}
s.store.Event(updated.ID, source(r), "outbound_updated", updated.Outbound.Mode)
respond(w, 200, s.outboundView(updated))
}
func (s *Server) saveOutbound(w http.ResponseWriter, r *http.Request, current model.OutboundProfile, token string) {
var patch map[string]any
// JSON escaping can expand an uploaded 512 KiB document up to sixfold.
if !decodeLimit(w, r, &patch, 4<<20) {
return
}
confirmation := patch["sshConfirmation"]
delete(patch, "sshConfirmation")
if err := editable(patch); err != nil {
fail(w, 400, err.Error())
return
}
p, err := model.MergeOutbound(current, patch)
if err != nil {
fail(w, 400, "invalid outbound fields")
return
}
creating := p.ID == ""
if creating {
p.ID = security.Token()[:16]
}
if err = p.Validate(); err != nil {
fail(w, 400, err.Error())
return
}
if !s.validateOutboundConfirmation(current, p, token, confirmation) {
fail(w, 409, "probe again and explicitly confirm the SSH forwarding fingerprint before saving strict verification")
return
}
if p.Outbound.KubeConfigAssetID != "" {
asset, assetErr := s.store.GetAsset(p.Outbound.KubeConfigAssetID)
if assetErr != nil || asset.Type != "kubeconfig" {
fail(w, 400, "Kubeconfig asset not found or wrong type")
return
}
}
if err = s.store.ValidateOutboundGatewayReferences(p); err != nil {
fail(w, 409, err.Error())
return
}
if err = s.store.SaveOutbound(p); err != nil {
fail(w, 500, "save failed")
return
}
refs, _ := s.store.OutboundReferences(p.ID)
s.mu.Lock()
delete(s.probes, outboundProbeKey(current.ID, token))
s.mu.Unlock()
for _, id := range refs {
gids, _ := s.store.TargetReferences(id)
s.mu.Lock()
delete(s.probes, "target:"+id)
for _, gid := range gids {
delete(s.probes, gid)
}
s.mu.Unlock()
}
status, event := 200, "outbound_updated"
if creating {
status, event = 201, "outbound_created"
}
s.store.Event(p.ID, source(r), event, p.Outbound.Mode)
respond(w, status, s.outboundView(p))
}