From 90012725cca906e2e8aee4b387a04247bfbee214 Mon Sep 17 00:00:00 2001 From: sagitchu Date: Fri, 1 May 2026 22:58:47 +0800 Subject: [PATCH] feat: persist tunnel probe targets --- go-backend/internal/http/handler/mutations.go | 48 +++-- .../http/handler/tunnel_probe_target.go | 2 +- .../handler/tunnel_probe_target_api_test.go | 168 ++++++++++++++++++ go-backend/internal/store/model/model.go | 26 +-- go-backend/internal/store/repo/repository.go | 12 +- .../store/repo/repository_mutations.go | 48 ++--- 6 files changed, 253 insertions(+), 51 deletions(-) create mode 100644 go-backend/internal/http/handler/tunnel_probe_target_api_test.go diff --git a/go-backend/internal/http/handler/mutations.go b/go-backend/internal/http/handler/mutations.go index 463d6b8..21ed68d 100644 --- a/go-backend/internal/http/handler/mutations.go +++ b/go-backend/internal/http/handler/mutations.go @@ -607,6 +607,17 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) { trafficRatio := asFloat(req["trafficRatio"], 1.0) inIP := asString(req["inIp"]) ipPreference := asString(req["ipPreference"]) + probeTarget, probeTargetConfigured, err := parseTunnelProbeTargetFromRequest(req) + if err != nil { + response.WriteJSON(w, response.ErrDefault(err.Error())) + return + } + probeTargetHost := "" + probeTargetPort := 0 + if probeTargetConfigured { + probeTargetHost = probeTarget.Host + probeTargetPort = probeTarget.Port + } now := time.Now().UnixMilli() inx := h.repo.NextIndex("tunnel") localDomain := h.federationLocalDomain() @@ -685,17 +696,19 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) { tunnelProtocol = strings.TrimSpace(runtimeState.InNodes[0].Protocol) } tunnel := model.Tunnel{ - Name: name, - TrafficRatio: trafficRatio, - Type: typeVal, - Protocol: tunnelProtocol, - Flow: flow, - CreatedTime: now, - UpdatedTime: now, - Status: status, - InIP: tunnelInIP, - Inx: inx, - IPPreference: ipPreference, + Name: name, + TrafficRatio: trafficRatio, + Type: typeVal, + Protocol: tunnelProtocol, + Flow: flow, + CreatedTime: now, + UpdatedTime: now, + Status: status, + InIP: tunnelInIP, + Inx: inx, + IPPreference: ipPreference, + ProbeTargetHost: probeTargetHost, + ProbeTargetPort: probeTargetPort, } if err := tx.Create(&tunnel).Error; err != nil { response.WriteJSON(w, response.Err(-2, err.Error())) @@ -882,6 +895,17 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) { now := time.Now().UnixMilli() ipPreference := asString(req["ipPreference"]) + probeTarget, probeTargetConfigured, err := parseTunnelProbeTargetFromRequest(req) + if err != nil { + response.WriteJSON(w, response.ErrDefault(err.Error())) + return + } + probeTargetHost := "" + probeTargetPort := 0 + if probeTargetConfigured { + probeTargetHost = probeTarget.Host + probeTargetPort = probeTarget.Port + } localDomain := h.federationLocalDomain() runtimeState, err := h.prepareTunnelCreateState(h.repo.DB(), req, typeVal, id) @@ -928,6 +952,8 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) { inIp, ipPreference, updateProtocol, + probeTargetHost, + probeTargetPort, now, ); err != nil { response.WriteJSON(w, response.Err(-2, err.Error())) diff --git a/go-backend/internal/http/handler/tunnel_probe_target.go b/go-backend/internal/http/handler/tunnel_probe_target.go index 6a03284..2110854 100644 --- a/go-backend/internal/http/handler/tunnel_probe_target.go +++ b/go-backend/internal/http/handler/tunnel_probe_target.go @@ -142,7 +142,7 @@ func effectiveTunnelProbeTarget(tunnel *model.Tunnel) tunnelProbeTarget { if tunnel == nil { return defaultTunnelProbeTarget() } - return defaultTunnelProbeTarget() + return effectiveTunnelProbeTargetValues(tunnel.ProbeTargetHost, tunnel.ProbeTargetPort) } func effectiveTunnelProbeTargetValues(host string, port int) tunnelProbeTarget { diff --git a/go-backend/internal/http/handler/tunnel_probe_target_api_test.go b/go-backend/internal/http/handler/tunnel_probe_target_api_test.go new file mode 100644 index 0000000..ef1cd56 --- /dev/null +++ b/go-backend/internal/http/handler/tunnel_probe_target_api_test.go @@ -0,0 +1,168 @@ +package handler + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "go-backend/internal/store/repo" +) + +func TestTunnelCreatePersistsProbeTargetAndListReturnsConfiguredValue(t *testing.T) { + h := setupProbeTargetTunnelHandler(t) + body := bytes.NewReader([]byte(`{ + "name":"custom-target", + "type":1, + "flow":1, + "trafficRatio":1, + "status":1, + "inNodeId":[{"nodeId":10,"protocol":"tls"}], + "probeTargetHost":"speed.example.com", + "probeTargetPort":8443 + }`)) + + res := httptest.NewRecorder() + h.tunnelCreate(res, httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", body)) + assertProbeTargetSuccess(t, res) + + listRes := httptest.NewRecorder() + h.tunnelList(listRes, httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil)) + var payload struct { + Code int `json:"code"` + Data []map[string]any `json:"data"` + } + decodeProbeTargetResponse(t, listRes, &payload) + if payload.Code != 0 { + t.Fatalf("expected success, got code %d", payload.Code) + } + item := payload.Data[0] + if item["probeTargetHost"] != "speed.example.com" || item["probeTargetPort"] != float64(8443) { + t.Fatalf("unexpected probe target in list response: %+v", item) + } +} + +func TestTunnelUpdatePersistsDefaultProbeTargetAsEmpty(t *testing.T) { + h := setupProbeTargetTunnelHandler(t) + seedProbeTargetTunnel(t, h, 77, "existing", "old.example.com", 9443) + body := bytes.NewReader([]byte(`{ + "id":77, + "name":"existing", + "type":1, + "flow":1, + "trafficRatio":1, + "status":1, + "inNodeId":[{"nodeId":10,"protocol":"tls"}], + "probeTargetHost":"", + "probeTargetPort":0 + }`)) + + res := httptest.NewRecorder() + h.tunnelUpdate(res, httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", body)) + assertProbeTargetSuccess(t, res) + + items, err := h.repo.ListTunnels() + if err != nil { + t.Fatalf("list tunnels: %v", err) + } + item := findProbeTargetTunnelItem(t, items, 77) + if item["probeTargetHost"] != "" || item["probeTargetPort"] != 0 { + t.Fatalf("expected default target to round-trip as empty/0, got %+v", item) + } +} + +func TestTunnelCreateRejectsInvalidProbeTarget(t *testing.T) { + h := setupProbeTargetTunnelHandler(t) + body := bytes.NewReader([]byte(`{ + "name":"bad-target", + "type":1, + "flow":1, + "trafficRatio":1, + "status":1, + "inNodeId":[{"nodeId":10,"protocol":"tls"}], + "probeTargetHost":"https://example.com", + "probeTargetPort":443 + }`)) + + res := httptest.NewRecorder() + h.tunnelCreate(res, httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", body)) + var payload struct { + Code int `json:"code"` + Msg string `json:"msg"` + } + decodeProbeTargetResponse(t, res, &payload) + if payload.Code == 0 || payload.Msg == "" { + t.Fatalf("expected validation failure, got %+v", payload) + } +} + +func setupProbeTargetTunnelHandler(t *testing.T) *Handler { + t.Helper() + r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db")) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { _ = r.Close() }) + h := New(r, "secret") + now := time.Now().UnixMilli() + if err := r.DB().Exec(` + INSERT INTO node(id, name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx) + VALUES(10, 'entry-a', 'entry-secret', '10.0.0.1', '10.0.0.1', '', '30000-30010', '', 'v1', 1, 1, 1, ?, ?, 1, '[::]', '[::]', 0) + `, now, now).Error; err != nil { + t.Fatalf("insert node: %v", err) + } + return h +} + +func seedProbeTargetTunnel(t *testing.T, h *Handler, id int64, name string, host string, port int) { + t.Helper() + now := time.Now().UnixMilli() + if err := h.repo.DB().Exec(` + INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, inx, ip_preference, probe_target_host, probe_target_port) + VALUES(?, ?, 1, 1, 'tls', 1, ?, ?, 1, ?, '', ?, ?) + `, id, name, now, now, id, host, port).Error; err != nil { + t.Fatalf("insert tunnel: %v", err) + } + if err := h.repo.DB().Exec(` + INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) + VALUES(?, '1', 10, 30001, 'round', 1, 'tls') + `, id).Error; err != nil { + t.Fatalf("insert chain: %v", err) + } +} + +func assertProbeTargetSuccess(t *testing.T, res *httptest.ResponseRecorder) { + t.Helper() + var payload struct { + Code int `json:"code"` + Msg string `json:"msg"` + } + decodeProbeTargetResponse(t, res, &payload) + if payload.Code != 0 { + t.Fatalf("expected success, got %+v", payload) + } +} + +func decodeProbeTargetResponse(t *testing.T, res *httptest.ResponseRecorder, v any) { + t.Helper() + if res.Code != http.StatusOK { + t.Fatalf("expected HTTP %d, got %d", http.StatusOK, res.Code) + } + if err := json.NewDecoder(res.Body).Decode(v); err != nil { + t.Fatalf("decode response: %v", err) + } +} + +func findProbeTargetTunnelItem(t *testing.T, items []map[string]interface{}, id int64) map[string]interface{} { + t.Helper() + for _, item := range items { + if asInt64(item["id"], 0) == id { + return item + } + } + t.Fatalf("tunnel %d not found: %+v", id, items) + return nil +} diff --git a/go-backend/internal/store/model/model.go b/go-backend/internal/store/model/model.go index 2bf5d41..31646b5 100644 --- a/go-backend/internal/store/model/model.go +++ b/go-backend/internal/store/model/model.go @@ -119,18 +119,20 @@ type StatisticsFlow struct { func (StatisticsFlow) TableName() string { return "statistics_flow" } type Tunnel struct { - ID int64 `gorm:"primaryKey;autoIncrement"` - Name string `gorm:"type:varchar(100);not null"` - TrafficRatio float64 `gorm:"column:traffic_ratio;not null;default:1.0"` - Type int `gorm:"not null"` - Protocol string `gorm:"type:varchar(10);not null;default:'tls'"` - Flow int64 `gorm:"not null"` - CreatedTime int64 `gorm:"column:created_time;not null"` - UpdatedTime int64 `gorm:"column:updated_time;not null"` - Status int `gorm:"not null"` - InIP sql.NullString `gorm:"column:in_ip;type:text"` - Inx int `gorm:"not null;default:0"` - IPPreference string `gorm:"column:ip_preference;type:varchar(10);not null;default:''"` + ID int64 `gorm:"primaryKey;autoIncrement"` + Name string `gorm:"type:varchar(100);not null"` + TrafficRatio float64 `gorm:"column:traffic_ratio;not null;default:1.0"` + Type int `gorm:"not null"` + Protocol string `gorm:"type:varchar(10);not null;default:'tls'"` + Flow int64 `gorm:"not null"` + CreatedTime int64 `gorm:"column:created_time;not null"` + UpdatedTime int64 `gorm:"column:updated_time;not null"` + Status int `gorm:"not null"` + InIP sql.NullString `gorm:"column:in_ip;type:text"` + Inx int `gorm:"not null;default:0"` + IPPreference string `gorm:"column:ip_preference;type:varchar(10);not null;default:''"` + ProbeTargetHost string `gorm:"column:probe_target_host;type:text;not null;default:''"` + ProbeTargetPort int `gorm:"column:probe_target_port;not null;default:0"` } func (Tunnel) TableName() string { return "tunnel" } diff --git a/go-backend/internal/store/repo/repository.go b/go-backend/internal/store/repo/repository.go index 22b3fff..47a09e1 100644 --- a/go-backend/internal/store/repo/repository.go +++ b/go-backend/internal/store/repo/repository.go @@ -1141,11 +1141,13 @@ func (r *Repository) ListTunnels() ([]map[string]interface{}, error) { "id": t.ID, "inx": t.Inx, "name": t.Name, "type": t.Type, "flow": t.Flow, "trafficRatio": t.TrafficRatio, "status": t.Status, "createdTime": t.CreatedTime, - "inIp": nullableString(t.InIP), - "ipPreference": t.IPPreference, - "inNodeId": make([]map[string]interface{}, 0), - "outNodeId": make([]map[string]interface{}, 0), - "chainNodes": make([][]map[string]interface{}, 0), + "inIp": nullableString(t.InIP), + "ipPreference": t.IPPreference, + "probeTargetHost": t.ProbeTargetHost, + "probeTargetPort": t.ProbeTargetPort, + "inNodeId": make([]map[string]interface{}, 0), + "outNodeId": make([]map[string]interface{}, 0), + "chainNodes": make([][]map[string]interface{}, 0), } orderedIDs = append(orderedIDs, t.ID) } diff --git a/go-backend/internal/store/repo/repository_mutations.go b/go-backend/internal/store/repo/repository_mutations.go index 394ea86..54f2cdc 100644 --- a/go-backend/internal/store/repo/repository_mutations.go +++ b/go-backend/internal/store/repo/repository_mutations.go @@ -397,22 +397,24 @@ func (r *Repository) UpdateTunnelOrder(tunnelID int64, inx int, now int64) { Updates(map[string]interface{}{"inx": inx, "updated_time": now}).Error } -func (r *Repository) UpdateTunnelTx(tx *gorm.DB, tunnelID int64, name string, typeVal int, flow int64, trafficRatio float64, status int, inIP, ipPreference string, protocol string, now int64) error { +func (r *Repository) UpdateTunnelTx(tx *gorm.DB, tunnelID int64, name string, typeVal int, flow int64, trafficRatio float64, status int, inIP, ipPreference string, protocol string, probeTargetHost string, probeTargetPort int, now int64) error { if tx == nil { return errors.New("database unavailable") } return tx.Model(&model.Tunnel{}). Where("id = ?", tunnelID). Updates(map[string]interface{}{ - "name": name, - "type": typeVal, - "flow": flow, - "traffic_ratio": trafficRatio, - "status": status, - "in_ip": nullStringFromInterface(inIP), - "ip_preference": ipPreference, - "protocol": protocol, - "updated_time": now, + "name": name, + "type": typeVal, + "flow": flow, + "traffic_ratio": trafficRatio, + "status": status, + "in_ip": nullStringFromInterface(inIP), + "ip_preference": ipPreference, + "protocol": protocol, + "probe_target_host": probeTargetHost, + "probe_target_port": probeTargetPort, + "updated_time": now, }).Error } @@ -1326,20 +1328,22 @@ func (r *Repository) BatchUpdateForwardStatus(ids []int64, status int) (int, int return s, f } -func (r *Repository) CreateTunnelTx(tx *gorm.DB, name string, trafficRatio float64, typeVal int, flow int64, now int64, status int, inIP interface{}, inx int, ipPreference string) (int64, error) { +func (r *Repository) CreateTunnelTx(tx *gorm.DB, name string, trafficRatio float64, typeVal int, flow int64, now int64, status int, inIP interface{}, inx int, ipPreference string, probeTargetHost string, probeTargetPort int) (int64, error) { inIPVal := nullStringFromInterface(inIP) tunnel := model.Tunnel{ - Name: name, - TrafficRatio: trafficRatio, - Type: typeVal, - Protocol: "tls", - Flow: flow, - CreatedTime: now, - UpdatedTime: now, - Status: status, - InIP: inIPVal, - Inx: inx, - IPPreference: ipPreference, + Name: name, + TrafficRatio: trafficRatio, + Type: typeVal, + Protocol: "tls", + Flow: flow, + CreatedTime: now, + UpdatedTime: now, + Status: status, + InIP: inIPVal, + Inx: inx, + IPPreference: ipPreference, + ProbeTargetHost: probeTargetHost, + ProbeTargetPort: probeTargetPort, } if err := tx.Create(&tunnel).Error; err != nil { return 0, err