mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-07 10:16:38 +08:00
1084 lines
35 KiB
Markdown
1084 lines
35 KiB
Markdown
# Custom Best-Exit Probe Target Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Let each tunnel define the TCP host/port used for exit-side quality probing and `best` exit scoring, defaulting to `www.bing.com:443` when unset.
|
|
|
|
**Architecture:** Add small backend probe-target normalization helpers, persist configured target fields on `tunnel`, and thread the effective target through tunnel quality probing and best-exit scoring. Frontend adds compact tunnel-form inputs and replaces Bing-specific monitoring labels with target-aware text while keeping existing API fields compatible.
|
|
|
|
**Tech Stack:** Go `net/http` handlers + GORM models/repository, SQLite/PostgreSQL auto-migration, Vite/React/TypeScript frontend with shadcn bridge components.
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
- Create `go-backend/internal/http/handler/tunnel_probe_target.go`: target constants, value type, validation, request parsing, formatting, and effective default helpers.
|
|
- Create `go-backend/internal/http/handler/tunnel_probe_target_test.go`: TDD coverage for normalization, defaulting, invalid inputs, and formatting.
|
|
- Modify `go-backend/internal/store/model/model.go`: add `probe_target_host` and `probe_target_port` columns to `model.Tunnel`.
|
|
- Modify `go-backend/internal/store/repo/repository.go`: include configured target fields in `ListTunnels()`.
|
|
- Modify `go-backend/internal/store/repo/repository_mutations.go`: persist target fields in tunnel create/update repository helpers.
|
|
- Modify `go-backend/internal/http/handler/mutations.go`: validate request target and save it during `tunnelCreate` / `tunnelUpdate`.
|
|
- Create `go-backend/internal/http/handler/tunnel_probe_target_api_test.go`: handler-level persistence/list/get tests.
|
|
- Modify `go-backend/internal/http/handler/tunnel_best_exit.go` and `tunnel_best_exit_test.go`: make exit-to-public scoring use the configured target.
|
|
- Modify `go-backend/internal/http/handler/tunnel_quality_prober.go` and add tests in `tunnel_quality_prober_test.go`: make quality snapshots use and expose the effective target.
|
|
- Modify `go-backend/internal/http/handler/monitoring.go`: include effective target metadata in DB fallback quality responses.
|
|
- Modify `vite-frontend/src/pages/tunnel.tsx`: tunnel types, edit/create payload, form inputs, list display for target.
|
|
- Modify `vite-frontend/src/pages/tunnel/form.ts`: frontend validation/defaults for target fields.
|
|
- Modify `vite-frontend/src/api/types.ts`: tunnel quality target metadata.
|
|
- Modify `vite-frontend/src/pages/node/tunnel-monitor-view.tsx`: target-aware monitoring labels and chart names.
|
|
|
|
## Task 1: Backend Probe Target Helpers
|
|
|
|
**Files:**
|
|
- Create: `go-backend/internal/http/handler/tunnel_probe_target.go`
|
|
- Create: `go-backend/internal/http/handler/tunnel_probe_target_test.go`
|
|
|
|
- [ ] **Step 1: Write failing helper tests**
|
|
|
|
Create `go-backend/internal/http/handler/tunnel_probe_target_test.go`:
|
|
|
|
```go
|
|
package handler
|
|
|
|
import "testing"
|
|
|
|
func TestNormalizeTunnelProbeTargetDefaultsWhenEmpty(t *testing.T) {
|
|
target, configured, err := normalizeTunnelProbeTarget("", 0)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if configured {
|
|
t.Fatalf("expected empty input to be default, not configured")
|
|
}
|
|
if target.Host != defaultTunnelProbeTargetHost || target.Port != defaultTunnelProbeTargetPort {
|
|
t.Fatalf("unexpected default target: %+v", target)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeTunnelProbeTargetAcceptsHostPortAndIPv6(t *testing.T) {
|
|
target, configured, err := normalizeTunnelProbeTarget(" [2001:db8::1] ", 8443)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !configured {
|
|
t.Fatalf("expected explicit target")
|
|
}
|
|
if target.Host != "2001:db8::1" || target.Port != 8443 {
|
|
t.Fatalf("unexpected normalized target: %+v", target)
|
|
}
|
|
if got := formatTunnelProbeTarget(target); got != "[2001:db8::1]:8443" {
|
|
t.Fatalf("unexpected formatted target: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeTunnelProbeTargetRejectsPartialAndInvalidInputs(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
host string
|
|
port int
|
|
}{
|
|
{name: "missing host", host: "", port: 443},
|
|
{name: "missing port", host: "example.com", port: 0},
|
|
{name: "port too high", host: "example.com", port: 70000},
|
|
{name: "scheme", host: "https://example.com", port: 443},
|
|
{name: "path", host: "example.com/ping", port: 443},
|
|
{name: "space", host: "example .com", port: 443},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if _, _, err := normalizeTunnelProbeTarget(tt.host, tt.port); err == nil {
|
|
t.Fatalf("expected validation error")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseTunnelProbeTargetFromRequest(t *testing.T) {
|
|
req := map[string]interface{}{
|
|
"probeTargetHost": "speed.example.com",
|
|
"probeTargetPort": float64(1443),
|
|
}
|
|
target, configured, err := parseTunnelProbeTargetFromRequest(req)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !configured || target.Host != "speed.example.com" || target.Port != 1443 {
|
|
t.Fatalf("unexpected request target: %+v configured=%v", target, configured)
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to verify RED**
|
|
|
|
Run from `go-backend`:
|
|
|
|
```bash
|
|
go test ./internal/http/handler -run 'TestNormalizeTunnelProbeTarget|TestParseTunnelProbeTarget' -count=1
|
|
```
|
|
|
|
Expected: FAIL with undefined symbols such as `normalizeTunnelProbeTarget`, `defaultTunnelProbeTargetHost`, and `parseTunnelProbeTargetFromRequest`.
|
|
|
|
- [ ] **Step 3: Implement helper**
|
|
|
|
Create `go-backend/internal/http/handler/tunnel_probe_target.go`:
|
|
|
|
```go
|
|
package handler
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/netip"
|
|
"strings"
|
|
|
|
"go-backend/internal/store/model"
|
|
)
|
|
|
|
const (
|
|
defaultTunnelProbeTargetHost = "www.bing.com"
|
|
defaultTunnelProbeTargetPort = 443
|
|
)
|
|
|
|
type tunnelProbeTarget struct {
|
|
Host string
|
|
Port int
|
|
}
|
|
|
|
func defaultTunnelProbeTarget() tunnelProbeTarget {
|
|
return tunnelProbeTarget{Host: defaultTunnelProbeTargetHost, Port: defaultTunnelProbeTargetPort}
|
|
}
|
|
|
|
func normalizeTunnelProbeTarget(host string, port int) (tunnelProbeTarget, bool, error) {
|
|
host = strings.TrimSpace(host)
|
|
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
|
|
host = strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[")
|
|
}
|
|
|
|
if host == "" && port == 0 {
|
|
return defaultTunnelProbeTarget(), false, nil
|
|
}
|
|
if host == "" {
|
|
return tunnelProbeTarget{}, false, errors.New("测试目标 Host 不能为空")
|
|
}
|
|
if port <= 0 || port > 65535 {
|
|
return tunnelProbeTarget{}, false, errors.New("测试目标端口必须是 1-65535")
|
|
}
|
|
if strings.Contains(host, "://") || strings.ContainsAny(host, "/?#") || strings.ContainsAny(host, " \t\r\n") {
|
|
return tunnelProbeTarget{}, false, errors.New("测试目标 Host 不能包含协议或路径")
|
|
}
|
|
|
|
return tunnelProbeTarget{Host: host, Port: port}, true, nil
|
|
}
|
|
|
|
func parseTunnelProbeTargetFromRequest(req map[string]interface{}) (tunnelProbeTarget, bool, error) {
|
|
if req == nil {
|
|
return defaultTunnelProbeTarget(), false, nil
|
|
}
|
|
return normalizeTunnelProbeTarget(asString(req["probeTargetHost"]), asInt(req["probeTargetPort"], 0))
|
|
}
|
|
|
|
func effectiveTunnelProbeTarget(tunnel *model.Tunnel) tunnelProbeTarget {
|
|
if tunnel == nil {
|
|
return defaultTunnelProbeTarget()
|
|
}
|
|
return effectiveTunnelProbeTargetValues(tunnel.ProbeTargetHost, tunnel.ProbeTargetPort)
|
|
}
|
|
|
|
func effectiveTunnelProbeTargetValues(host string, port int) tunnelProbeTarget {
|
|
target, configured, err := normalizeTunnelProbeTarget(host, port)
|
|
if err != nil || !configured {
|
|
return defaultTunnelProbeTarget()
|
|
}
|
|
return target
|
|
}
|
|
|
|
func formatTunnelProbeTarget(target tunnelProbeTarget) string {
|
|
if addr, err := netip.ParseAddr(target.Host); err == nil && addr.Is6() {
|
|
return fmt.Sprintf("[%s]:%d", target.Host, target.Port)
|
|
}
|
|
return fmt.Sprintf("%s:%d", target.Host, target.Port)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run helper tests to verify GREEN**
|
|
|
|
Run from `go-backend`:
|
|
|
|
```bash
|
|
go test ./internal/http/handler -run 'TestNormalizeTunnelProbeTarget|TestParseTunnelProbeTarget' -count=1
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Format and commit**
|
|
|
|
```bash
|
|
gofmt -w internal/http/handler/tunnel_probe_target.go internal/http/handler/tunnel_probe_target_test.go
|
|
git add internal/http/handler/tunnel_probe_target.go internal/http/handler/tunnel_probe_target_test.go
|
|
git commit -m "feat: add tunnel probe target normalization"
|
|
```
|
|
|
|
## Task 2: Persist Probe Target On Tunnels
|
|
|
|
**Files:**
|
|
- Modify: `go-backend/internal/store/model/model.go`
|
|
- Modify: `go-backend/internal/store/repo/repository.go`
|
|
- Modify: `go-backend/internal/store/repo/repository_mutations.go`
|
|
- Modify: `go-backend/internal/http/handler/mutations.go`
|
|
- Create: `go-backend/internal/http/handler/tunnel_probe_target_api_test.go`
|
|
|
|
- [ ] **Step 1: Write failing API persistence tests**
|
|
|
|
Create `go-backend/internal/http/handler/tunnel_probe_target_api_test.go`:
|
|
|
|
```go
|
|
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
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to verify RED**
|
|
|
|
Run from `go-backend`:
|
|
|
|
```bash
|
|
go test ./internal/http/handler -run 'TestTunnel(Create|Update).*ProbeTarget' -count=1
|
|
```
|
|
|
|
Expected: FAIL because `probe_target_host` / `probe_target_port` columns and response fields do not exist yet.
|
|
|
|
- [ ] **Step 3: Add model and repository fields**
|
|
|
|
Modify `go-backend/internal/store/model/model.go` in `type Tunnel`:
|
|
|
|
```go
|
|
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"`
|
|
```
|
|
|
|
Modify `go-backend/internal/store/repo/repository.go` in `ListTunnels()` tunnel map:
|
|
|
|
```go
|
|
"ipPreference": t.IPPreference,
|
|
"probeTargetHost": t.ProbeTargetHost,
|
|
"probeTargetPort": t.ProbeTargetPort,
|
|
```
|
|
|
|
Modify `go-backend/internal/store/repo/repository_mutations.go` signatures and fields:
|
|
|
|
```go
|
|
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 {
|
|
```
|
|
|
|
Add to the `Updates` map:
|
|
|
|
```go
|
|
"probe_target_host": probeTargetHost,
|
|
"probe_target_port": probeTargetPort,
|
|
```
|
|
|
|
Update `CreateTunnelTx` for consistency:
|
|
|
|
```go
|
|
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) {
|
|
```
|
|
|
|
Set fields in `model.Tunnel`:
|
|
|
|
```go
|
|
ProbeTargetHost: probeTargetHost,
|
|
ProbeTargetPort: probeTargetPort,
|
|
```
|
|
|
|
- [ ] **Step 4: Validate and save fields in handlers**
|
|
|
|
In `tunnelCreate`, after `ipPreference := asString(req["ipPreference"])`, add:
|
|
|
|
```go
|
|
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
|
|
}
|
|
```
|
|
|
|
Set fields on the `model.Tunnel` literal:
|
|
|
|
```go
|
|
ProbeTargetHost: probeTargetHost,
|
|
ProbeTargetPort: probeTargetPort,
|
|
```
|
|
|
|
In `tunnelUpdate`, after `ipPreference := asString(req["ipPreference"])`, add the same validation block and pass `probeTargetHost`, `probeTargetPort` into `h.repo.UpdateTunnelTx(...)` before `now`.
|
|
|
|
- [ ] **Step 5: Run API persistence tests to verify GREEN**
|
|
|
|
Run from `go-backend`:
|
|
|
|
```bash
|
|
go test ./internal/http/handler -run 'TestTunnel(Create|Update).*ProbeTarget' -count=1
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 6: Run broader backend compile check and commit**
|
|
|
|
```bash
|
|
gofmt -w internal/store/model/model.go internal/store/repo/repository.go internal/store/repo/repository_mutations.go internal/http/handler/mutations.go internal/http/handler/tunnel_probe_target_api_test.go
|
|
go test ./internal/http/handler -run 'TestTunnel(Create|Update).*ProbeTarget|TestNormalizeTunnelProbeTarget' -count=1
|
|
git add internal/store/model/model.go internal/store/repo/repository.go internal/store/repo/repository_mutations.go internal/http/handler/mutations.go internal/http/handler/tunnel_probe_target_api_test.go
|
|
git commit -m "feat: persist tunnel probe targets"
|
|
```
|
|
|
|
## Task 3: Use Probe Target In Best-Exit Scoring
|
|
|
|
**Files:**
|
|
- Modify: `go-backend/internal/http/handler/tunnel_best_exit.go`
|
|
- Modify: `go-backend/internal/http/handler/tunnel_best_exit_test.go`
|
|
- Modify: `go-backend/internal/http/handler/tunnel_quality_prober.go`
|
|
|
|
- [ ] **Step 1: Write failing best-exit scoring test**
|
|
|
|
Append to `go-backend/internal/http/handler/tunnel_best_exit_test.go`:
|
|
|
|
```go
|
|
func TestEvaluateBestExitOwnerUsesConfiguredPublicProbeTarget(t *testing.T) {
|
|
owner := chainNodeRecord{NodeID: 10, NodeName: "entry-a"}
|
|
exits := []chainNodeRecord{{NodeID: 30, NodeName: "exit-a", Port: 30001}}
|
|
nodes := map[int64]*nodeRecord{
|
|
10: {ID: 10, Name: "entry-a", ServerIP: "10.0.0.10", ServerIPv4: "10.0.0.10"},
|
|
30: {ID: 30, Name: "exit-a", ServerIP: "10.0.0.30", ServerIPv4: "10.0.0.30"},
|
|
}
|
|
target := tunnelProbeTarget{Host: "speed.example.com", Port: 8443}
|
|
var calls []string
|
|
ping := func(nodeID int64, ip string, port int, options diagnosisExecOptions) (float64, float64, error) {
|
|
calls = append(calls, fmt.Sprintf("%d|%s|%d", nodeID, ip, port))
|
|
return 10, 0, nil
|
|
}
|
|
|
|
scores := evaluateBestExitOwner(owner, exits, nodes, "", diagnosisExecOptions{}, target, ping)
|
|
if len(scores) != 1 || !scores[0].Success {
|
|
t.Fatalf("expected successful score, got %+v", scores)
|
|
}
|
|
if !slices.Contains(calls, "30|speed.example.com|8443") {
|
|
t.Fatalf("expected exit public probe to use configured target, calls=%+v", calls)
|
|
}
|
|
for _, call := range calls {
|
|
if strings.Contains(call, defaultTunnelProbeTargetHost) {
|
|
t.Fatalf("did not expect default target call when custom target configured: %+v", calls)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
Ensure the `tunnel_best_exit_test.go` import block contains these imports after adding the test:
|
|
|
|
```go
|
|
import (
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify RED**
|
|
|
|
Run from `go-backend`:
|
|
|
|
```bash
|
|
go test ./internal/http/handler -run TestEvaluateBestExitOwnerUsesConfiguredPublicProbeTarget -count=1
|
|
```
|
|
|
|
Expected: FAIL because `evaluateBestExitOwner` does not accept a target and still uses the hardcoded default.
|
|
|
|
- [ ] **Step 3: Thread target into best-exit scoring**
|
|
|
|
Modify `evaluateBestExitOwner` signature in `tunnel_best_exit.go`:
|
|
|
|
```go
|
|
func evaluateBestExitOwner(owner chainNodeRecord, exits []chainNodeRecord, nodes map[int64]*nodeRecord, ipPreference string, options diagnosisExecOptions, target tunnelProbeTarget, ping bestExitProbeFunc) []bestExitCandidateScore {
|
|
```
|
|
|
|
Replace the hardcoded public probe call:
|
|
|
|
```go
|
|
publicLatency, publicLoss, publicErr := ping(exit.NodeID, target.Host, target.Port, options)
|
|
```
|
|
|
|
Update existing tests and callers to pass `defaultTunnelProbeTarget()` unless they are testing custom targets.
|
|
|
|
In `tunnel_quality_prober.go`, compute and pass the target from `probeTunnel`:
|
|
|
|
```go
|
|
probeTarget := effectiveTunnelProbeTarget(tunnel)
|
|
p.probeBestExitOwners(tunnelID, inNodes, midNodesGrouped, outNodes, ipPreference, options, probeTarget)
|
|
```
|
|
|
|
Change `probeBestExitOwners` signature:
|
|
|
|
```go
|
|
func (p *tunnelQualityProber) probeBestExitOwners(tunnelID int64, inNodes []chainNodeRecord, chainHops [][]chainNodeRecord, outNodes []chainNodeRecord, ipPreference string, options diagnosisExecOptions, probeTarget tunnelProbeTarget) {
|
|
```
|
|
|
|
Pass it into scoring:
|
|
|
|
```go
|
|
scores := evaluateBestExitOwner(owner, outNodes, nodeMap, ipPreference, options, probeTarget, roundPinger)
|
|
```
|
|
|
|
- [ ] **Step 4: Make round pinger cache target-safe**
|
|
|
|
In `tunnel_best_exit.go`, replace `newBestExitRoundPinger` cache key with node+host+port:
|
|
|
|
```go
|
|
type bestExitProbeCacheKey struct {
|
|
NodeID int64
|
|
Host string
|
|
Port int
|
|
}
|
|
|
|
func newBestExitRoundPinger(base bestExitProbeFunc) bestExitProbeFunc {
|
|
cache := make(map[bestExitProbeCacheKey]bestExitProbeResult)
|
|
return func(nodeID int64, ip string, port int, options diagnosisExecOptions) (float64, float64, error) {
|
|
key := bestExitProbeCacheKey{NodeID: nodeID, Host: ip, Port: port}
|
|
if cached, ok := cache[key]; ok {
|
|
return cached.latency, cached.loss, cached.err
|
|
}
|
|
lat, loss, err := base(nodeID, ip, port, options)
|
|
cache[key] = bestExitProbeResult{latency: lat, loss: loss, err: err}
|
|
return lat, loss, err
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Run best-exit tests to verify GREEN**
|
|
|
|
Run from `go-backend`:
|
|
|
|
```bash
|
|
go test ./internal/http/handler -run 'TestEvaluateBestExitOwner|TestBestExit|TestBuildTunnelChainConfigUsesBestRuntimeStrategy' -count=1
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 6: Format and commit**
|
|
|
|
```bash
|
|
gofmt -w internal/http/handler/tunnel_best_exit.go internal/http/handler/tunnel_best_exit_test.go internal/http/handler/tunnel_quality_prober.go
|
|
git add internal/http/handler/tunnel_best_exit.go internal/http/handler/tunnel_best_exit_test.go internal/http/handler/tunnel_quality_prober.go
|
|
git commit -m "feat: use probe target for best exit scoring"
|
|
```
|
|
|
|
## Task 4: Use Probe Target In Tunnel Quality Monitoring
|
|
|
|
**Files:**
|
|
- Modify: `go-backend/internal/http/handler/tunnel_quality_prober.go`
|
|
- Modify: `go-backend/internal/http/handler/monitoring.go`
|
|
- Create: `go-backend/internal/http/handler/tunnel_quality_prober_test.go`
|
|
|
|
- [ ] **Step 1: Write failing quality prober test**
|
|
|
|
Create `go-backend/internal/http/handler/tunnel_quality_prober_test.go`:
|
|
|
|
```go
|
|
func TestTunnelQualityProberUsesConfiguredProbeTarget(t *testing.T) {
|
|
h := setupProbeTargetTunnelHandler(t)
|
|
seedProbeTargetTunnel(t, h, 77, "quality-target", "speed.example.com", 8443)
|
|
if err := h.repo.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(30, 'exit-a', 'exit-secret', '10.0.0.30', '10.0.0.30', '', '30000-30010', '', 'v1', 1, 1, 1, ?, ?, 1, '[::]', '[::]', 0)
|
|
`, time.Now().UnixMilli(), time.Now().UnixMilli()).Error; err != nil {
|
|
t.Fatalf("insert exit node: %v", err)
|
|
}
|
|
if err := h.repo.DB().Exec(`
|
|
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
|
VALUES(77, '3', 30, 30001, 'round', 1, 'tls')
|
|
`).Error; err != nil {
|
|
t.Fatalf("insert exit chain: %v", err)
|
|
}
|
|
|
|
p := newTunnelQualityProber(h)
|
|
var calls []string
|
|
p.probeNode = func(nodeID int64, ip string, port int, options diagnosisExecOptions) (float64, float64, error) {
|
|
calls = append(calls, fmt.Sprintf("%d|%s|%d", nodeID, ip, port))
|
|
return 10, 0, nil
|
|
}
|
|
p.probeTunnel(77)
|
|
|
|
if !slices.Contains(calls, "30|speed.example.com|8443") {
|
|
t.Fatalf("expected exit probe to configured target, calls=%+v", calls)
|
|
}
|
|
snaps := p.GetAll()
|
|
if len(snaps) != 1 {
|
|
t.Fatalf("expected one quality snapshot, got %+v", snaps)
|
|
}
|
|
if snaps[0].ProbeTargetHost != "speed.example.com" || snaps[0].ProbeTargetPort != 8443 {
|
|
t.Fatalf("unexpected snapshot target metadata: %+v", snaps[0])
|
|
}
|
|
}
|
|
```
|
|
|
|
Use this import block in the new `tunnel_quality_prober_test.go` file:
|
|
|
|
```go
|
|
import (
|
|
"fmt"
|
|
"slices"
|
|
"testing"
|
|
"time"
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify RED**
|
|
|
|
Run from `go-backend`:
|
|
|
|
```bash
|
|
go test ./internal/http/handler -run TestTunnelQualityProberUsesConfiguredProbeTarget -count=1
|
|
```
|
|
|
|
Expected: FAIL because `tunnelQualityProber` has no injectable `probeNode` and snapshots do not include target metadata.
|
|
|
|
- [ ] **Step 3: Add injectable probe function and target metadata**
|
|
|
|
Modify `tunnelQualitySnapshot` in `tunnel_quality_prober.go`:
|
|
|
|
```go
|
|
ProbeTargetHost string `json:"probeTargetHost,omitempty"`
|
|
ProbeTargetPort int `json:"probeTargetPort,omitempty"`
|
|
```
|
|
|
|
Modify `tunnelQualityProber` struct:
|
|
|
|
```go
|
|
probeNode bestExitProbeFunc
|
|
```
|
|
|
|
Add method:
|
|
|
|
```go
|
|
func (p *tunnelQualityProber) pingNode(nodeID int64, ip string, port int, options diagnosisExecOptions) (float64, float64, error) {
|
|
if p != nil && p.probeNode != nil {
|
|
return p.probeNode(nodeID, ip, port, options)
|
|
}
|
|
return p.tcpPingNode(nodeID, ip, port, options)
|
|
}
|
|
```
|
|
|
|
Replace existing `p.tcpPingNode(...)` calls in `probeTunnel` and `probeBestExitOwners` setup with `p.pingNode(...)` / `newBestExitRoundPinger(p.pingNode)`.
|
|
|
|
- [ ] **Step 4: Use effective target in quality probes**
|
|
|
|
In `probeTunnel`, after loading `tunnel` and before the switch:
|
|
|
|
```go
|
|
probeTarget := effectiveTunnelProbeTarget(tunnel)
|
|
snap.ProbeTargetHost = probeTarget.Host
|
|
snap.ProbeTargetPort = probeTarget.Port
|
|
```
|
|
|
|
Replace hardcoded Bing probes:
|
|
|
|
```go
|
|
lat, loss, err := p.pingNode(inNodes[0].NodeID, probeTarget.Host, probeTarget.Port, options)
|
|
```
|
|
|
|
and:
|
|
|
|
```go
|
|
lat, loss, err := p.pingNode(outNodes[0].NodeID, probeTarget.Host, probeTarget.Port, options)
|
|
```
|
|
|
|
Use the same replacement in the default case. Keep persisted DB columns unchanged.
|
|
|
|
- [ ] **Step 5: Include target metadata in DB fallback monitoring response**
|
|
|
|
In `monitoring.go`, when converting DB `TunnelQuality` rows to `tunnelQualitySnapshot`, fetch tunnel list once and build an effective-target map:
|
|
|
|
```go
|
|
targetsByTunnelID := map[int64]tunnelProbeTarget{}
|
|
if tunnels, listErr := h.repo.ListTunnels(); listErr == nil {
|
|
for _, item := range tunnels {
|
|
id := asInt64(item["id"], 0)
|
|
if id > 0 {
|
|
targetsByTunnelID[id] = effectiveTunnelProbeTargetValues(asString(item["probeTargetHost"]), asInt(item["probeTargetPort"], 0))
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
Set snapshot metadata:
|
|
|
|
```go
|
|
target := targetsByTunnelID[q.TunnelID]
|
|
if target.Host == "" {
|
|
target = defaultTunnelProbeTarget()
|
|
}
|
|
```
|
|
|
|
Then include:
|
|
|
|
```go
|
|
ProbeTargetHost: target.Host,
|
|
ProbeTargetPort: target.Port,
|
|
```
|
|
|
|
- [ ] **Step 6: Run quality prober tests to verify GREEN**
|
|
|
|
Run from `go-backend`:
|
|
|
|
```bash
|
|
go test ./internal/http/handler -run 'TestTunnelQualityProberUsesConfiguredProbeTarget|TestTunnelCreatePersistsProbeTarget' -count=1
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 7: Format and commit**
|
|
|
|
```bash
|
|
gofmt -w internal/http/handler/tunnel_quality_prober.go internal/http/handler/tunnel_quality_prober_test.go internal/http/handler/monitoring.go
|
|
git add internal/http/handler/tunnel_quality_prober.go internal/http/handler/tunnel_quality_prober_test.go internal/http/handler/monitoring.go
|
|
git commit -m "feat: use probe target for tunnel quality checks"
|
|
```
|
|
|
|
## Task 5: Frontend Form And Monitoring Display
|
|
|
|
**Files:**
|
|
- Modify: `vite-frontend/src/pages/tunnel.tsx`
|
|
- Modify: `vite-frontend/src/pages/tunnel/form.ts`
|
|
- Modify: `vite-frontend/src/api/types.ts`
|
|
- Modify: `vite-frontend/src/pages/node/tunnel-monitor-view.tsx`
|
|
|
|
- [ ] **Step 1: Extend frontend tunnel and quality types**
|
|
|
|
In `vite-frontend/src/pages/tunnel.tsx`, add fields to `Tunnel` and `TunnelForm`:
|
|
|
|
```ts
|
|
probeTargetHost?: string;
|
|
probeTargetPort?: number;
|
|
```
|
|
|
|
In `vite-frontend/src/api/types.ts`, extend `TunnelQualityApiItem`:
|
|
|
|
```ts
|
|
probeTargetHost?: string;
|
|
probeTargetPort?: number;
|
|
```
|
|
|
|
In `vite-frontend/src/pages/tunnel/form.ts`, extend `TunnelFormInput`:
|
|
|
|
```ts
|
|
probeTargetHost?: string;
|
|
probeTargetPort?: number;
|
|
```
|
|
|
|
- [ ] **Step 2: Add frontend defaults and validation**
|
|
|
|
In `createTunnelFormDefaults()` add:
|
|
|
|
```ts
|
|
probeTargetHost: "",
|
|
probeTargetPort: 0,
|
|
```
|
|
|
|
In `validateTunnelForm`, add after traffic ratio validation:
|
|
|
|
```ts
|
|
const probeHost = (form.probeTargetHost || "").trim();
|
|
const probePort = Number(form.probeTargetPort || 0);
|
|
|
|
if (probeHost || probePort > 0) {
|
|
if (!probeHost) {
|
|
errors.probeTargetHost = "请输入测试目标 Host";
|
|
} else if (
|
|
probeHost.includes("://") ||
|
|
/[\s/?#]/.test(probeHost)
|
|
) {
|
|
errors.probeTargetHost = "Host 不能包含协议、空格或路径";
|
|
}
|
|
|
|
if (!Number.isInteger(probePort) || probePort < 1 || probePort > 65535) {
|
|
errors.probeTargetPort = "端口必须是 1-65535";
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Round-trip form values**
|
|
|
|
In `handleEdit`, add:
|
|
|
|
```ts
|
|
probeTargetHost: tunnel.probeTargetHost || "",
|
|
probeTargetPort: tunnel.probeTargetPort || 0,
|
|
```
|
|
|
|
Before building submit `data`, normalize:
|
|
|
|
```ts
|
|
const probeTargetHost = (form.probeTargetHost || "").trim();
|
|
const probeTargetPort = probeTargetHost ? Number(form.probeTargetPort || 0) : 0;
|
|
```
|
|
|
|
Set in payload:
|
|
|
|
```ts
|
|
probeTargetHost,
|
|
probeTargetPort,
|
|
```
|
|
|
|
- [ ] **Step 4: Add form inputs**
|
|
|
|
In the tunnel modal body near `ipPreference` and before `入口配置`, add:
|
|
|
|
```tsx
|
|
<div className="rounded-xl border border-divider/60 bg-default-50/40 p-3 space-y-3">
|
|
<div>
|
|
<div className="text-sm font-medium">质量检测目标</div>
|
|
<p className="text-xs text-default-500 mt-0.5">
|
|
用于实时隧道质量检测和 best 最优出口评分,留空使用 www.bing.com:443
|
|
</p>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-[1fr_140px] gap-3">
|
|
<Input
|
|
errorMessage={errors.probeTargetHost}
|
|
isInvalid={!!errors.probeTargetHost}
|
|
label="Host"
|
|
placeholder="www.bing.com"
|
|
value={form.probeTargetHost || ""}
|
|
variant="bordered"
|
|
onChange={(e) =>
|
|
setForm((prev) => ({ ...prev, probeTargetHost: e.target.value }))
|
|
}
|
|
/>
|
|
<Input
|
|
errorMessage={errors.probeTargetPort}
|
|
isInvalid={!!errors.probeTargetPort}
|
|
label="Port"
|
|
max={65535}
|
|
min={1}
|
|
placeholder="443"
|
|
type="number"
|
|
value={form.probeTargetPort ? String(form.probeTargetPort) : ""}
|
|
variant="bordered"
|
|
onChange={(e) =>
|
|
setForm((prev) => ({
|
|
...prev,
|
|
probeTargetPort: e.target.value ? Number(e.target.value) : 0,
|
|
}))
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
- [ ] **Step 5: Add target-aware labels in monitoring view**
|
|
|
|
In `vite-frontend/src/pages/node/tunnel-monitor-view.tsx`, add helper near constants:
|
|
|
|
```ts
|
|
const DEFAULT_PROBE_TARGET_LABEL = "www.bing.com:443";
|
|
|
|
const probeTargetLabel = (quality?: TunnelQualityApiItem | null) => {
|
|
if (!quality?.probeTargetHost || !quality.probeTargetPort) {
|
|
return DEFAULT_PROBE_TARGET_LABEL;
|
|
}
|
|
const host = quality.probeTargetHost.includes(":")
|
|
? `[${quality.probeTargetHost}]`
|
|
: quality.probeTargetHost;
|
|
return `${host}:${quality.probeTargetPort}`;
|
|
};
|
|
```
|
|
|
|
Replace visible labels `出口 → Bing 延迟`, `出口 → Bing 丢包`, `出口→Bing`, and table header `出口→Bing` with target-neutral labels:
|
|
|
|
```tsx
|
|
出口 → 测试目标 延迟
|
|
```
|
|
|
|
and add `title={probeTargetLabel(quality)}` to compact labels where space is constrained.
|
|
|
|
Where the detail view has auto-probe status, append:
|
|
|
|
```tsx
|
|
<span className="text-default-400">· 测试目标: {probeTargetLabel(quality)}</span>
|
|
```
|
|
|
|
- [ ] **Step 6: Run frontend build**
|
|
|
|
Run from `vite-frontend`:
|
|
|
|
```bash
|
|
pnpm run build
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 7: Commit frontend changes**
|
|
|
|
```bash
|
|
git add src/pages/tunnel.tsx src/pages/tunnel/form.ts src/api/types.ts src/pages/node/tunnel-monitor-view.tsx
|
|
git commit -m "feat: add tunnel probe target UI"
|
|
```
|
|
|
|
## Task 6: Full Verification And Final Review
|
|
|
|
**Files:**
|
|
- No planned code changes. Fix only issues found by verification or review.
|
|
|
|
- [ ] **Step 1: Run backend tests**
|
|
|
|
Run from `go-backend`:
|
|
|
|
```bash
|
|
go test ./...
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 2: Run frontend build**
|
|
|
|
Run from `vite-frontend`:
|
|
|
|
```bash
|
|
pnpm run build
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 3: Check diff hygiene**
|
|
|
|
Run from repo root:
|
|
|
|
```bash
|
|
git diff --check origin/main...HEAD
|
|
git status --short
|
|
```
|
|
|
|
Expected: no whitespace errors; only intended branch changes.
|
|
|
|
- [ ] **Step 4: Manual smoke checklist**
|
|
|
|
Use local backend/frontend if available:
|
|
|
|
```bash
|
|
# terminal 1
|
|
cd go-backend && go run ./cmd/paneld
|
|
|
|
# terminal 2
|
|
cd vite-frontend && pnpm run dev
|
|
```
|
|
|
|
Check:
|
|
|
|
- Create tunnel with blank quality target; list/get shows empty target fields and monitoring uses `www.bing.com:443` effectively.
|
|
- Edit tunnel with `speed.example.com` and `8443`; reopen edit modal and confirm values round-trip.
|
|
- Enable tunnel quality detection; monitoring labels show `测试目标`, not hardcoded Bing.
|
|
- A `best` multi-exit tunnel eventually leaves `等待探测` when probes succeed against the configured target.
|
|
|
|
- [ ] **Step 5: Final code review**
|
|
|
|
Dispatch a final reviewer with this context:
|
|
|
|
```text
|
|
Review custom per-tunnel probe target implementation. Verify configured host/port persists, defaults to www.bing.com:443 when unset, is used by best-exit scoring and tunnel quality prober, preserves existing API fields, and does not change routing/switching thresholds or add frontend dependencies.
|
|
```
|
|
|
|
Fix any Critical or Important findings, then rerun Steps 1-3.
|
|
|
|
- [ ] **Step 6: Commit review fixes if any**
|
|
|
|
If review fixes were needed, stage the files touched by those fixes and commit them. Use the exact changed paths from `git status --short`; do not stage unrelated files.
|
|
|
|
```bash
|
|
git status --short
|
|
git add go-backend/internal/http/handler/tunnel_probe_target.go go-backend/internal/http/handler/tunnel_probe_target_test.go go-backend/internal/http/handler/tunnel_probe_target_api_test.go go-backend/internal/http/handler/tunnel_best_exit.go go-backend/internal/http/handler/tunnel_best_exit_test.go go-backend/internal/http/handler/tunnel_quality_prober.go go-backend/internal/http/handler/tunnel_quality_prober_test.go go-backend/internal/http/handler/monitoring.go go-backend/internal/http/handler/mutations.go go-backend/internal/store/model/model.go go-backend/internal/store/repo/repository.go go-backend/internal/store/repo/repository_mutations.go vite-frontend/src/pages/tunnel.tsx vite-frontend/src/pages/tunnel/form.ts vite-frontend/src/api/types.ts vite-frontend/src/pages/node/tunnel-monitor-view.tsx
|
|
git commit -m "fix: stabilize custom probe target handling"
|
|
```
|
|
|
|
If `git status --short` shows no review-fix changes, do not create an empty commit.
|
|
|
|
---
|
|
|
|
## Implementation Notes
|
|
|
|
- Keep the existing `exitToBingLatency` / `exitToBingLoss` JSON and DB fields for compatibility; change labels, not keys.
|
|
- Do not add frontend tests. This repository has no frontend test framework and `AGENTS.md` explicitly says not to add one.
|
|
- Backend handlers should continue using repository methods for new persistence paths. Do not introduce new `repo.DB()` usage in production handlers.
|
|
- SQLite and PostgreSQL compatibility: use plain string/int GORM tags, no `jsonb` or `serial`.
|
|
- Do not edit `install.sh` or `panel_install.sh`.
|