mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cf9f4f1f7 | |||
| 05bd6a686d | |||
| b8193417f5 | |||
| 15e4508be4 | |||
| 634c6cd620 | |||
| 4eaecb289b | |||
| 98a9e5c666 | |||
| d244920dd4 | |||
| 77e4387b35 | |||
| 7a40ddb1ef | |||
| d33814e18c | |||
| cf51b305b0 |
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Task",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "entire hooks claude-code post-task"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "TodoWrite",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "entire hooks claude-code post-todo"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Task",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "entire hooks claude-code pre-task"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionEnd": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "entire hooks claude-code session-end"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "entire hooks claude-code session-start"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "entire hooks claude-code stop"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "entire hooks claude-code user-prompt-submit"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"deny": [
|
||||
"Read(./.entire/metadata/**)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"telemetry": false
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
# Issue #211: 转发自定义监听IP / 隧道指定连接IP
|
||||
|
||||
## 需求总结
|
||||
1. **节点**: 高级配置增加"额外IP地址"字段(逗号分隔)
|
||||
2. **转发**: 创建/编辑时可指定入口监听IP
|
||||
3. **隧道**: 配置出口节点时可指定连接IP
|
||||
|
||||
---
|
||||
|
||||
## 任务清单
|
||||
|
||||
### 后端
|
||||
- [x] 1. 数据模型扩展 - Node/ForwardPort/ChainTunnel 增加字段
|
||||
- [x] 2. Repository - CreateNode/UpdateNode 处理 extraIPs
|
||||
- [x] 3. Repository - resolveForwardIngress 使用 forward_port.in_ip
|
||||
- [x] 4. Repository - GetNodeAllIPs 辅助函数(返回节点所有可用IP)
|
||||
- [x] 5. Handler - 转发创建/更新处理 inIp 参数
|
||||
- [x] 6. Handler - 隧道出口节点处理 connectIp 参数
|
||||
- [x] 7. Handler - 节点API返回 extraIPs 字段
|
||||
|
||||
### 前端
|
||||
- [x] 8. 节点编辑页 - 高级配置增加"额外IP"输入
|
||||
- [x] 9. 转发编辑弹窗 - 增加"监听IP"下拉选择
|
||||
- [x] 10. 隧道配置页 - 出口节点增加"连接IP"输入
|
||||
|
||||
---
|
||||
|
||||
## 完成进度
|
||||
- 开始时间: 2026-03-02
|
||||
- 完成时间: 2026-03-02
|
||||
- 完成任务: 10/10
|
||||
- 后端完成: ✅
|
||||
- 前端完成: ✅
|
||||
@@ -58,12 +58,63 @@ type diagnosisProgress struct {
|
||||
|
||||
type diagnosisItemEmitter func(index int, item map[string]interface{}, progress diagnosisProgress)
|
||||
|
||||
func (h *Handler) buildDiagnosisStreamStartItems(workItems []diagnosisWorkItem) []map[string]interface{} {
|
||||
if len(workItems) == 0 {
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
|
||||
nodeCache := map[int64]*nodeRecord{}
|
||||
items := make([]map[string]interface{}, 0, len(workItems))
|
||||
for _, workItem := range workItems {
|
||||
targetIP := strings.TrimSpace(workItem.targetIP)
|
||||
targetPort := workItem.targetPort
|
||||
if workItem.hasChainHop {
|
||||
fromNode, _ := h.cachedNode(nodeCache, workItem.fromNodeID)
|
||||
targetNode, err := h.cachedNode(nodeCache, workItem.toNode.NodeID)
|
||||
if err == nil {
|
||||
resolvedIP, resolvedPort, resolveErr := resolveChainProbeTarget(fromNode, targetNode, workItem.toNode.Port, workItem.ipPreference, "")
|
||||
if resolveErr == nil {
|
||||
targetIP = resolvedIP
|
||||
targetPort = resolvedPort
|
||||
}
|
||||
}
|
||||
}
|
||||
if targetPort <= 0 {
|
||||
targetPort = 443
|
||||
}
|
||||
|
||||
nodeName := fmt.Sprintf("node_%d", workItem.fromNodeID)
|
||||
if node, err := h.cachedNode(nodeCache, workItem.fromNodeID); err == nil && strings.TrimSpace(node.Name) != "" {
|
||||
nodeName = node.Name
|
||||
}
|
||||
|
||||
item := map[string]interface{}{
|
||||
"success": false,
|
||||
"diagnosing": true,
|
||||
"description": workItem.description,
|
||||
"nodeName": nodeName,
|
||||
"nodeId": strconv.FormatInt(workItem.fromNodeID, 10),
|
||||
"targetIp": targetIP,
|
||||
"targetPort": targetPort,
|
||||
"message": "诊断中...",
|
||||
}
|
||||
for key, value := range workItem.metadata {
|
||||
item[key] = value
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
const diagnosisMaxConcurrency = 8
|
||||
|
||||
const (
|
||||
defaultNodeCommandTimeout = 6 * time.Second
|
||||
diagnosisCommandTimeout = 2 * time.Minute
|
||||
diagnosisRequestTimeout = 2 * time.Minute
|
||||
defaultNodeCommandTimeout = 6 * time.Second
|
||||
diagnosisCommandTimeout = 30 * time.Second
|
||||
diagnosisRequestTimeout = 2 * time.Minute
|
||||
diagnosisCommandTimeoutMsg = "诊断超时(30秒)"
|
||||
diagnosisRequestTimeoutMsg = "诊断超时(2分钟)"
|
||||
)
|
||||
|
||||
func (h *Handler) resolveForwardAccess(r *http.Request, forwardID int64) (*forwardRecord, int64, int, error) {
|
||||
@@ -799,15 +850,15 @@ func resolveDiagnosisTargets(remoteAddr string) ([]diagnosisTarget, error) {
|
||||
|
||||
func diagnosisContextMessage(ctx context.Context) string {
|
||||
if ctx == nil {
|
||||
return "诊断超时(2分钟)"
|
||||
return diagnosisRequestTimeoutMsg
|
||||
}
|
||||
switch ctx.Err() {
|
||||
case context.DeadlineExceeded:
|
||||
return "诊断超时(2分钟)"
|
||||
return diagnosisRequestTimeoutMsg
|
||||
case context.Canceled:
|
||||
return "诊断已取消"
|
||||
default:
|
||||
return "诊断超时(2分钟)"
|
||||
return diagnosisRequestTimeoutMsg
|
||||
}
|
||||
}
|
||||
|
||||
@@ -846,7 +897,7 @@ func newDiagnosisTimeoutItem(workItem diagnosisWorkItem, message string) map[str
|
||||
item := newDiagnosisResultItem(workItem.fromNodeID, workItem.targetIP, targetPort, workItem.description, workItem.metadata)
|
||||
item["success"] = false
|
||||
if strings.TrimSpace(message) == "" {
|
||||
message = "诊断超时(2分钟)"
|
||||
message = diagnosisCommandTimeoutMsg
|
||||
}
|
||||
item["message"] = message
|
||||
return item
|
||||
@@ -923,8 +974,10 @@ enqueueLoop:
|
||||
}
|
||||
}
|
||||
close(jobs)
|
||||
wg.Wait()
|
||||
close(resultCh)
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(resultCh)
|
||||
}()
|
||||
|
||||
progress := diagnosisProgress{Total: len(workItems)}
|
||||
for result := range resultCh {
|
||||
@@ -942,7 +995,7 @@ enqueueLoop:
|
||||
|
||||
for i := range results {
|
||||
if results[i] == nil {
|
||||
results[i] = newDiagnosisTimeoutItem(workItems[i], "诊断超时(2分钟)")
|
||||
results[i] = newDiagnosisTimeoutItem(workItems[i], diagnosisCommandTimeoutMsg)
|
||||
}
|
||||
}
|
||||
return results
|
||||
@@ -1046,7 +1099,7 @@ func (h *Handler) appendChainHopDiagnosis(results *[]map[string]interface{}, nod
|
||||
h.appendFailedDiagnosis(results, nodeCache, fromNodeID, "", 0, description, metadata, err.Error())
|
||||
return
|
||||
}
|
||||
targetIP, targetPort, err := resolveChainProbeTarget(fromNode, targetNode, toNode.Port, ipPreference)
|
||||
targetIP, targetPort, err := resolveChainProbeTarget(fromNode, targetNode, toNode.Port, ipPreference, "")
|
||||
if err != nil {
|
||||
h.appendFailedDiagnosis(results, nodeCache, fromNodeID, strings.Trim(strings.TrimSpace(targetNode.ServerIP), "[]"), toNode.Port, description, metadata, err.Error())
|
||||
return
|
||||
@@ -1054,11 +1107,11 @@ func (h *Handler) appendChainHopDiagnosis(results *[]map[string]interface{}, nod
|
||||
h.appendPathDiagnosis(results, nodeCache, fromNodeID, targetIP, targetPort, description, metadata, options)
|
||||
}
|
||||
|
||||
func resolveChainProbeTarget(fromNode, targetNode *nodeRecord, preferredPort int, ipPreference string) (string, int, error) {
|
||||
func resolveChainProbeTarget(fromNode, targetNode *nodeRecord, preferredPort int, ipPreference string, connectIp string) (string, int, error) {
|
||||
if targetNode == nil {
|
||||
return "", 0, errors.New("目标节点不存在")
|
||||
}
|
||||
host, err := selectTunnelDialHost(fromNode, targetNode, ipPreference)
|
||||
host, err := selectTunnelDialHost(fromNode, targetNode, ipPreference, connectIp)
|
||||
if err != nil {
|
||||
host = strings.Trim(strings.TrimSpace(targetNode.ServerIP), "[]")
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ func (h *Handler) streamDiagnosisRuntime(ctx context.Context, cancel context.Can
|
||||
payload := map[string]interface{}{
|
||||
"total": len(workItems),
|
||||
"timestamp": time.Now().UnixMilli(),
|
||||
"items": h.buildDiagnosisStreamStartItems(workItems),
|
||||
}
|
||||
for key, value := range startPayload {
|
||||
payload[key] = value
|
||||
|
||||
@@ -8,9 +8,25 @@ import (
|
||||
// nodeSupportsV4 / nodeSupportsV6
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNodeSupportsV4_Nil(t *testing.T) {
|
||||
if nodeSupportsV4(nil) {
|
||||
t.Fatal("nil node must not support v4")
|
||||
func TestSelectTunnelDialHost_ConnectIpPriority(t *testing.T) {
|
||||
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
|
||||
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
|
||||
|
||||
// Empty connectIp should be ignored, IP preference takes effect
|
||||
host, err := selectTunnelDialHost(from, to, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if host != "10.0.0.2" {
|
||||
t.Fatalf("empty connectIp should be ignored (v4 preference applies), got %q", host)
|
||||
}
|
||||
// Non-empty connectIp should override IP preference
|
||||
host, err = selectTunnelDialHost(from, to, "v6", "192.168.0.3")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if host != "192.168.0.3" {
|
||||
t.Fatalf("connectIp should override v6 preference, got %q", host)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,14 +39,14 @@ func TestNodeSupportsV6_Nil(t *testing.T) {
|
||||
func TestNodeSupportsV4_ExplicitV4(t *testing.T) {
|
||||
n := &nodeRecord{ServerIPv4: "10.0.0.1"}
|
||||
if !nodeSupportsV4(n) {
|
||||
t.Fatal("explicit server_ip_v4 must support v4")
|
||||
t.Fatal("explicit server_ip_v4 needs support v4")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeSupportsV6_ExplicitV6(t *testing.T) {
|
||||
n := &nodeRecord{ServerIPv6: "2001:db8::1"}
|
||||
if !nodeSupportsV6(n) {
|
||||
t.Fatal("explicit server_ip_v6 must support v6")
|
||||
t.Fatal("explicit server_ip_v6 needs support v6")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +84,7 @@ func TestNodeSupportsV4_LegacyV4Only(t *testing.T) {
|
||||
t.Fatal("legacy v4 ip in server_ip must support v4")
|
||||
}
|
||||
if nodeSupportsV6(n) {
|
||||
t.Fatal("legacy v4 ip in server_ip must not support v6")
|
||||
t.Fatal("legacy v4 ip in server_ip should not support v6")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +94,7 @@ func TestNodeSupportsV6_LegacyV6Only(t *testing.T) {
|
||||
t.Fatal("legacy v6 ip in server_ip must support v6")
|
||||
}
|
||||
if nodeSupportsV4(n) {
|
||||
t.Fatal("legacy v6 ip in server_ip must not support v4")
|
||||
t.Fatal("legacy v6 ip in server_ip should not support v4")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,15 +193,15 @@ func v6OnlyNode(name, v6 string) *nodeRecord {
|
||||
}
|
||||
|
||||
func TestSelectTunnelDialHost_NilNodes(t *testing.T) {
|
||||
_, err := selectTunnelDialHost(nil, nil, "")
|
||||
_, err := selectTunnelDialHost(nil, nil, "", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil nodes")
|
||||
}
|
||||
_, err = selectTunnelDialHost(dualStackNode("a", "1.1.1.1", "::1"), nil, "")
|
||||
_, err = selectTunnelDialHost(dualStackNode("a", "1.1.1.1", "::1"), nil, "", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil toNode")
|
||||
}
|
||||
_, err = selectTunnelDialHost(nil, dualStackNode("b", "1.1.1.1", "::1"), "")
|
||||
_, err = selectTunnelDialHost(nil, dualStackNode("b", "1.1.1.1", "::1"), "", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil fromNode")
|
||||
}
|
||||
@@ -194,8 +210,7 @@ func TestSelectTunnelDialHost_NilNodes(t *testing.T) {
|
||||
func TestSelectTunnelDialHost_DualStack_DefaultPreference(t *testing.T) {
|
||||
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
|
||||
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
|
||||
|
||||
host, err := selectTunnelDialHost(from, to, "")
|
||||
host, err := selectTunnelDialHost(from, to, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -208,8 +223,7 @@ func TestSelectTunnelDialHost_DualStack_DefaultPreference(t *testing.T) {
|
||||
func TestSelectTunnelDialHost_DualStack_PreferV4(t *testing.T) {
|
||||
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
|
||||
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
|
||||
|
||||
host, err := selectTunnelDialHost(from, to, "v4")
|
||||
host, err := selectTunnelDialHost(from, to, "v4", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -221,8 +235,7 @@ func TestSelectTunnelDialHost_DualStack_PreferV4(t *testing.T) {
|
||||
func TestSelectTunnelDialHost_DualStack_PreferV6(t *testing.T) {
|
||||
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
|
||||
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
|
||||
|
||||
host, err := selectTunnelDialHost(from, to, "v6")
|
||||
host, err := selectTunnelDialHost(from, to, "v6", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -234,9 +247,8 @@ func TestSelectTunnelDialHost_DualStack_PreferV6(t *testing.T) {
|
||||
func TestSelectTunnelDialHost_V4Only_PreferV6Fallback(t *testing.T) {
|
||||
from := v4OnlyNode("from", "10.0.0.1")
|
||||
to := v4OnlyNode("to", "10.0.0.2")
|
||||
|
||||
// User prefers v6, but both nodes are v4-only — should fallback to v4
|
||||
host, err := selectTunnelDialHost(from, to, "v6")
|
||||
host, err := selectTunnelDialHost(from, to, "v6", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -248,9 +260,8 @@ func TestSelectTunnelDialHost_V4Only_PreferV6Fallback(t *testing.T) {
|
||||
func TestSelectTunnelDialHost_V6Only_PreferV4Fallback(t *testing.T) {
|
||||
from := v6OnlyNode("from", "2001:db8::1")
|
||||
to := v6OnlyNode("to", "2001:db8::2")
|
||||
|
||||
// User prefers v4, but both nodes are v6-only — should fallback to v6
|
||||
host, err := selectTunnelDialHost(from, to, "v4")
|
||||
host, err := selectTunnelDialHost(from, to, "v4", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -262,8 +273,7 @@ func TestSelectTunnelDialHost_V6Only_PreferV4Fallback(t *testing.T) {
|
||||
func TestSelectTunnelDialHost_Incompatible(t *testing.T) {
|
||||
from := v4OnlyNode("from", "10.0.0.1")
|
||||
to := v6OnlyNode("to", "2001:db8::2")
|
||||
|
||||
_, err := selectTunnelDialHost(from, to, "")
|
||||
_, err := selectTunnelDialHost(from, to, "", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for incompatible nodes (v4-only -> v6-only)")
|
||||
}
|
||||
@@ -272,8 +282,7 @@ func TestSelectTunnelDialHost_Incompatible(t *testing.T) {
|
||||
func TestSelectTunnelDialHost_Incompatible_Reverse(t *testing.T) {
|
||||
from := v6OnlyNode("from", "2001:db8::1")
|
||||
to := v4OnlyNode("to", "10.0.0.2")
|
||||
|
||||
_, err := selectTunnelDialHost(from, to, "")
|
||||
_, err := selectTunnelDialHost(from, to, "", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for incompatible nodes (v6-only -> v4-only)")
|
||||
}
|
||||
@@ -282,9 +291,8 @@ func TestSelectTunnelDialHost_Incompatible_Reverse(t *testing.T) {
|
||||
func TestSelectTunnelDialHost_WhitespacePreference(t *testing.T) {
|
||||
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
|
||||
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
|
||||
|
||||
// Whitespace should be trimmed, treated as "v6"
|
||||
host, err := selectTunnelDialHost(from, to, " v6 ")
|
||||
host, err := selectTunnelDialHost(from, to, " v6 ", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -296,9 +304,8 @@ func TestSelectTunnelDialHost_WhitespacePreference(t *testing.T) {
|
||||
func TestSelectTunnelDialHost_MixedStack_FromDualToV4(t *testing.T) {
|
||||
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
|
||||
to := v4OnlyNode("to", "10.0.0.2")
|
||||
|
||||
// v6 preferred, but target only has v4 — should succeed with v4
|
||||
host, err := selectTunnelDialHost(from, to, "v6")
|
||||
host, err := selectTunnelDialHost(from, to, "v6", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -310,9 +317,8 @@ func TestSelectTunnelDialHost_MixedStack_FromDualToV4(t *testing.T) {
|
||||
func TestSelectTunnelDialHost_MixedStack_FromDualToV6(t *testing.T) {
|
||||
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
|
||||
to := v6OnlyNode("to", "2001:db8::2")
|
||||
|
||||
// v4 preferred, but target only has v6 — should succeed with v6
|
||||
host, err := selectTunnelDialHost(from, to, "v4")
|
||||
host, err := selectTunnelDialHost(from, to, "v4", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -324,9 +330,8 @@ func TestSelectTunnelDialHost_MixedStack_FromDualToV6(t *testing.T) {
|
||||
func TestSelectTunnelDialHost_MixedStack_FromV4ToDual(t *testing.T) {
|
||||
from := v4OnlyNode("from", "10.0.0.1")
|
||||
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
|
||||
|
||||
// v6 preferred, but from only has v4 — should use v4 (from can only reach v4 of target)
|
||||
host, err := selectTunnelDialHost(from, to, "v6")
|
||||
host, err := selectTunnelDialHost(from, to, "v6", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -338,9 +343,8 @@ func TestSelectTunnelDialHost_MixedStack_FromV4ToDual(t *testing.T) {
|
||||
func TestSelectTunnelDialHost_MixedStack_FromV6ToDual(t *testing.T) {
|
||||
from := v6OnlyNode("from", "2001:db8::1")
|
||||
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
|
||||
|
||||
// v4 preferred, but from only has v6 — should use v6
|
||||
host, err := selectTunnelDialHost(from, to, "v4")
|
||||
host, err := selectTunnelDialHost(from, to, "v4", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -367,7 +371,6 @@ func TestNodeDisplayName_Named(t *testing.T) {
|
||||
t.Fatalf("expected 'hk-node', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeDisplayName_Unnamed(t *testing.T) {
|
||||
n := &nodeRecord{ID: 42}
|
||||
got := nodeDisplayName(n)
|
||||
|
||||
@@ -271,6 +271,7 @@ func (h *Handler) nodeCreate(w http.ResponseWriter, r *http.Request) {
|
||||
nullableText(asString(req["remoteUrl"])),
|
||||
nullableText(asString(req["remoteToken"])),
|
||||
nullableText(asString(req["remoteConfig"])),
|
||||
nullableText(asString(req["extraIPs"])),
|
||||
); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
@@ -322,6 +323,7 @@ func (h *Handler) nodeUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
nullableText(asString(req["serverIpV6"])),
|
||||
defaultString(asString(req["port"]), "1000-65535"),
|
||||
nullableText(asString(req["interfaceName"])),
|
||||
nullableText(asString(req["extraIPs"])),
|
||||
newHTTP,
|
||||
newTLS,
|
||||
newSocks,
|
||||
@@ -1176,7 +1178,8 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if userName == "" {
|
||||
userName = "user"
|
||||
}
|
||||
forwardID, err := h.repo.CreateForwardTx(userID, userName, name, tunnelID, remoteAddr, defaultString(asString(req["strategy"]), "fifo"), now, inx, entryNodes, port, nullableInt(speedID))
|
||||
inIp := strings.TrimSpace(asString(req["inIp"]))
|
||||
forwardID, err := h.repo.CreateForwardTx(userID, userName, name, tunnelID, remoteAddr, defaultString(asString(req["strategy"]), "fifo"), now, inx, entryNodes, port, inIp, nullableInt(speedID))
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
@@ -1280,6 +1283,7 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
port = h.pickTunnelPort(tunnelID)
|
||||
}
|
||||
}
|
||||
inIp := asString(req["inIp"])
|
||||
fwdEntryNodes, _ := h.tunnelEntryNodeIDs(tunnelID)
|
||||
for _, nodeID := range fwdEntryNodes {
|
||||
node, nodeErr := h.getNodeRecord(nodeID)
|
||||
@@ -1296,7 +1300,7 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if err := h.replaceForwardPorts(id, tunnelID, port); err != nil {
|
||||
if err := h.replaceForwardPorts(id, tunnelID, port, inIp); err != nil {
|
||||
h.rollbackForwardMutation(forward, oldPorts)
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
@@ -1628,7 +1632,7 @@ func (h *Handler) forwardBatchChangeTunnel(w http.ResponseWriter, r *http.Reques
|
||||
fail++
|
||||
continue
|
||||
}
|
||||
if err := h.replaceForwardPorts(id, req.TargetTunnelID, p); err != nil {
|
||||
if err := h.replaceForwardPorts(id, req.TargetTunnelID, p, ""); err != nil {
|
||||
h.rollbackForwardMutation(forward, oldPorts)
|
||||
fail++
|
||||
continue
|
||||
@@ -1954,6 +1958,7 @@ type tunnelRuntimeNode struct {
|
||||
Inx int
|
||||
ChainType int
|
||||
Port int
|
||||
ConnectIP string
|
||||
}
|
||||
|
||||
type tunnelCreateState struct {
|
||||
@@ -2027,6 +2032,7 @@ func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface
|
||||
Strategy: defaultString(asString(item["strategy"]), "round"),
|
||||
ChainType: 3,
|
||||
Port: port,
|
||||
ConnectIP: asString(item["connectIp"]),
|
||||
})
|
||||
}
|
||||
if len(state.OutNodes) == 0 {
|
||||
@@ -2062,6 +2068,7 @@ func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface
|
||||
Inx: hopIdx + 1,
|
||||
ChainType: 2,
|
||||
Port: port,
|
||||
ConnectIP: asString(item["connectIp"]),
|
||||
})
|
||||
}
|
||||
if len(hop) > 0 {
|
||||
@@ -2338,7 +2345,7 @@ func (h *Handler) applyFederationRuntime(state *tunnelCreateState, localDomain s
|
||||
h.releaseFederationRuntimeRefs(releaseRefs)
|
||||
return nil, nil, errors.New("节点不存在")
|
||||
}
|
||||
host, hostErr := selectTunnelDialHost(node, targetNode, state.IPPreference)
|
||||
host, hostErr := selectTunnelDialHost(node, targetNode, state.IPPreference, target.ConnectIP)
|
||||
if hostErr != nil {
|
||||
h.releaseFederationRuntimeRefs(releaseRefs)
|
||||
return nil, nil, hostErr
|
||||
@@ -2578,7 +2585,7 @@ func buildTunnelChainConfig(tunnelID int64, fromNodeID int64, targets []tunnelRu
|
||||
if targetNode == nil {
|
||||
return nil, errors.New("节点不存在")
|
||||
}
|
||||
host, err := selectTunnelDialHost(fromNode, targetNode, ipPreference)
|
||||
host, err := selectTunnelDialHost(fromNode, targetNode, ipPreference, target.ConnectIP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2651,10 +2658,13 @@ func buildTunnelChainServiceConfig(tunnelID int64, chainNode tunnelRuntimeNode,
|
||||
return []map[string]interface{}{service}
|
||||
}
|
||||
|
||||
func selectTunnelDialHost(fromNode, toNode *nodeRecord, ipPreference string) (string, error) {
|
||||
func selectTunnelDialHost(fromNode, toNode *nodeRecord, ipPreference string, connectIp string) (string, error) {
|
||||
if fromNode == nil || toNode == nil {
|
||||
return "", errors.New("节点不存在")
|
||||
}
|
||||
if strings.TrimSpace(connectIp) != "" {
|
||||
return strings.TrimSpace(connectIp), nil
|
||||
}
|
||||
fromV4 := nodeSupportsV4(fromNode)
|
||||
fromV6 := nodeSupportsV6(fromNode)
|
||||
toV4 := nodeSupportsV4(toNode)
|
||||
@@ -2789,6 +2799,7 @@ func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[str
|
||||
defaultString(asString(n["strategy"]), "round"),
|
||||
i+1,
|
||||
defaultString(asString(n["protocol"]), "tls"),
|
||||
"",
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -2806,6 +2817,7 @@ func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[str
|
||||
return pickErr
|
||||
}
|
||||
}
|
||||
connectIp := asString(n["connectIp"])
|
||||
if err := h.repo.CreateChainTunnelTx(
|
||||
tx,
|
||||
tunnelID,
|
||||
@@ -2815,6 +2827,7 @@ func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[str
|
||||
defaultString(asString(n["strategy"]), "round"),
|
||||
i+1,
|
||||
defaultString(asString(n["protocol"]), "tls"),
|
||||
connectIp,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -2834,6 +2847,7 @@ func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[str
|
||||
return pickErr
|
||||
}
|
||||
}
|
||||
connectIp := asString(n["connectIp"])
|
||||
if err := h.repo.CreateChainTunnelTx(
|
||||
tx,
|
||||
tunnelID,
|
||||
@@ -2843,6 +2857,7 @@ func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[str
|
||||
defaultString(asString(n["strategy"]), "round"),
|
||||
i+1,
|
||||
defaultString(asString(n["protocol"]), "tls"),
|
||||
connectIp,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -2999,7 +3014,7 @@ func parsePorts(portRange string) ([]int, error) {
|
||||
return ports, nil
|
||||
}
|
||||
|
||||
func (h *Handler) replaceForwardPorts(forwardID, tunnelID int64, port int) error {
|
||||
func (h *Handler) replaceForwardPorts(forwardID, tunnelID int64, port int, inIp string) error {
|
||||
entryNodes, err := h.tunnelEntryNodeIDs(tunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -3007,12 +3022,14 @@ func (h *Handler) replaceForwardPorts(forwardID, tunnelID int64, port int) error
|
||||
entries := make([]struct {
|
||||
NodeID int64
|
||||
Port int
|
||||
InIP string
|
||||
}, len(entryNodes))
|
||||
for i, nid := range entryNodes {
|
||||
entries[i] = struct {
|
||||
NodeID int64
|
||||
Port int
|
||||
}{NodeID: nid, Port: port}
|
||||
InIP string
|
||||
}{NodeID: nid, Port: port, InIP: inIp}
|
||||
}
|
||||
return h.repo.ReplaceForwardPorts(forwardID, entries)
|
||||
}
|
||||
@@ -3021,12 +3038,14 @@ func (h *Handler) replaceForwardPortsWithRecords(forwardID int64, ports []forwar
|
||||
entries := make([]struct {
|
||||
NodeID int64
|
||||
Port int
|
||||
InIP string
|
||||
}, len(ports))
|
||||
for i, fp := range ports {
|
||||
entries[i] = struct {
|
||||
NodeID int64
|
||||
Port int
|
||||
}{NodeID: fp.NodeID, Port: fp.Port}
|
||||
InIP string
|
||||
}{NodeID: fp.NodeID, Port: fp.Port, InIP: fp.InIP}
|
||||
}
|
||||
return h.repo.ReplaceForwardPorts(forwardID, entries)
|
||||
}
|
||||
|
||||
@@ -48,10 +48,11 @@ type Forward struct {
|
||||
func (Forward) TableName() string { return "forward" }
|
||||
|
||||
type ForwardPort struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
ForwardID int64 `gorm:"column:forward_id;not null"`
|
||||
NodeID int64 `gorm:"column:node_id;not null"`
|
||||
Port int `gorm:"not null"`
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
ForwardID int64 `gorm:"column:forward_id;not null"`
|
||||
NodeID int64 `gorm:"column:node_id;not null"`
|
||||
Port int `gorm:"not null"`
|
||||
InIP sql.NullString `gorm:"column:in_ip;type:text"`
|
||||
}
|
||||
|
||||
func (ForwardPort) TableName() string { return "forward_port" }
|
||||
@@ -63,6 +64,7 @@ type Node struct {
|
||||
ServerIP string `gorm:"column:server_ip;type:varchar(100);not null"`
|
||||
ServerIPV4 sql.NullString `gorm:"column:server_ip_v4;type:varchar(100)"`
|
||||
ServerIPV6 sql.NullString `gorm:"column:server_ip_v6;type:varchar(100)"`
|
||||
ExtraIPs sql.NullString `gorm:"column:extra_ips;type:text"`
|
||||
Port string `gorm:"type:text;not null"`
|
||||
InterfaceName sql.NullString `gorm:"column:interface_name;type:varchar(200)"`
|
||||
Version sql.NullString `gorm:"type:varchar(100)"`
|
||||
@@ -133,6 +135,7 @@ type ChainTunnel struct {
|
||||
Strategy sql.NullString `gorm:"type:varchar(10)"`
|
||||
Inx sql.NullInt64 `gorm:"column:inx"`
|
||||
Protocol sql.NullString `gorm:"type:varchar(10)"`
|
||||
ConnectIP sql.NullString `gorm:"column:connect_ip;type:varchar(45)"`
|
||||
}
|
||||
|
||||
func (ChainTunnel) TableName() string { return "chain_tunnel" }
|
||||
@@ -337,6 +340,7 @@ type NodeBackup struct {
|
||||
ServerIP string `json:"serverIp"`
|
||||
ServerIPv4 string `json:"serverIpV4,omitempty"`
|
||||
ServerIPv6 string `json:"serverIpV6,omitempty"`
|
||||
ExtraIPs string `json:"extraIPs,omitempty"`
|
||||
Port string `json:"port"`
|
||||
InterfaceName string `json:"interfaceName,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
@@ -510,6 +514,7 @@ type TunnelRecord struct {
|
||||
type ForwardPortRecord struct {
|
||||
NodeID int64
|
||||
Port int
|
||||
InIP string
|
||||
}
|
||||
|
||||
// NodeRecord is a node view used by control plane.
|
||||
@@ -519,6 +524,7 @@ type NodeRecord struct {
|
||||
ServerIP string
|
||||
ServerIPv4 string
|
||||
ServerIPv6 string
|
||||
ExtraIPs string
|
||||
Status int
|
||||
PortRange string
|
||||
TCPListenAddr string
|
||||
@@ -538,6 +544,7 @@ type ChainNodeRecord struct {
|
||||
NodeName string
|
||||
Protocol string
|
||||
Strategy string
|
||||
ConnectIP string
|
||||
}
|
||||
|
||||
type UserTunnelLimiterInfo struct {
|
||||
|
||||
@@ -260,7 +260,7 @@ func prepareSQLiteLegacyColumns(db *gorm.DB) error {
|
||||
m := db.Migrator()
|
||||
|
||||
if m.HasTable(&model.Node{}) {
|
||||
for _, field := range []string{"ServerIPV4", "ServerIPV6", "Inx", "IsRemote", "RemoteURL", "RemoteToken", "RemoteConfig"} {
|
||||
for _, field := range []string{"ServerIPV4", "ServerIPV6", "ExtraIPs", "TCPListenAddr", "UDPListenAddr", "Inx", "IsRemote", "RemoteURL", "RemoteToken", "RemoteConfig"} {
|
||||
if m.HasColumn(&model.Node{}, field) {
|
||||
continue
|
||||
}
|
||||
@@ -637,6 +637,7 @@ func (r *Repository) ListNodes() ([]map[string]interface{}, error) {
|
||||
"ip": n.ServerIP, "serverIp": n.ServerIP,
|
||||
"serverIpV4": nullableString(n.ServerIPV4),
|
||||
"serverIpV6": nullableString(n.ServerIPV6),
|
||||
"extraIPs": nullableString(n.ExtraIPs),
|
||||
"port": n.Port,
|
||||
"tcpListenAddr": n.TCPListenAddr,
|
||||
"udpListenAddr": n.UDPListenAddr,
|
||||
@@ -864,6 +865,9 @@ func (r *Repository) ListTunnels() ([]map[string]interface{}, error) {
|
||||
if c.Strategy.Valid {
|
||||
nodeObj["strategy"] = c.Strategy.String
|
||||
}
|
||||
if c.ConnectIP.Valid {
|
||||
nodeObj["connectIp"] = c.ConnectIP.String
|
||||
}
|
||||
|
||||
switch chainTypeInt {
|
||||
case 1:
|
||||
@@ -2731,10 +2735,11 @@ func resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string
|
||||
type fpRow struct {
|
||||
Port sql.NullInt64
|
||||
ServerIP sql.NullString
|
||||
InIP sql.NullString
|
||||
}
|
||||
var fpRows []fpRow
|
||||
err := db.Model(&model.ForwardPort{}).
|
||||
Select("forward_port.port, node.server_ip").
|
||||
Select("forward_port.port, node.server_ip, forward_port.in_ip").
|
||||
Joins("LEFT JOIN node ON node.id = forward_port.node_id").
|
||||
Where("forward_port.forward_id = ?", forwardID).
|
||||
Order("forward_port.id ASC").
|
||||
@@ -2744,10 +2749,22 @@ func resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string
|
||||
}
|
||||
|
||||
ports := make([]int64, 0)
|
||||
nodePairs := make([]string, 0)
|
||||
entries := make([]string, 0)
|
||||
seenPorts := make(map[int64]struct{})
|
||||
seenPairs := make(map[string]struct{})
|
||||
|
||||
var tunnelFirstIP string
|
||||
if tunnelInIP.Valid && strings.TrimSpace(tunnelInIP.String) != "" {
|
||||
tunnelIPs := strings.Split(tunnelInIP.String, ",")
|
||||
for _, ip := range tunnelIPs {
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip != "" {
|
||||
tunnelFirstIP = ip
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, row := range fpRows {
|
||||
if !row.Port.Valid {
|
||||
continue
|
||||
@@ -2756,11 +2773,21 @@ func resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string
|
||||
seenPorts[row.Port.Int64] = struct{}{}
|
||||
ports = append(ports, row.Port.Int64)
|
||||
}
|
||||
if row.ServerIP.Valid && strings.TrimSpace(row.ServerIP.String) != "" {
|
||||
pair := fmt.Sprintf("%s:%d", strings.TrimSpace(row.ServerIP.String), row.Port.Int64)
|
||||
|
||||
var ip string
|
||||
if row.InIP.Valid && strings.TrimSpace(row.InIP.String) != "" {
|
||||
ip = strings.TrimSpace(row.InIP.String)
|
||||
} else if tunnelFirstIP != "" {
|
||||
ip = tunnelFirstIP
|
||||
} else if row.ServerIP.Valid && strings.TrimSpace(row.ServerIP.String) != "" {
|
||||
ip = strings.TrimSpace(row.ServerIP.String)
|
||||
}
|
||||
|
||||
if ip != "" {
|
||||
pair := fmt.Sprintf("%s:%d", ip, row.Port.Int64)
|
||||
if _, ok := seenPairs[pair]; !ok {
|
||||
seenPairs[pair] = struct{}{}
|
||||
nodePairs = append(nodePairs, pair)
|
||||
entries = append(entries, pair)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2771,27 +2798,6 @@ func resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string
|
||||
|
||||
inPort := sql.NullInt64{Int64: ports[0], Valid: true}
|
||||
|
||||
entries := make([]string, 0)
|
||||
if tunnelInIP.Valid && strings.TrimSpace(tunnelInIP.String) != "" {
|
||||
tunnelIPs := strings.Split(tunnelInIP.String, ",")
|
||||
seen := make(map[string]struct{})
|
||||
for _, ip := range tunnelIPs {
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[ip]; ok {
|
||||
continue
|
||||
}
|
||||
seen[ip] = struct{}{}
|
||||
for _, port := range ports {
|
||||
entries = append(entries, fmt.Sprintf("%s:%d", ip, port))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
entries = append(entries, nodePairs...)
|
||||
}
|
||||
|
||||
return strings.Join(entries, ","), inPort, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -102,7 +102,11 @@ func (r *Repository) ListForwardPorts(forwardID int64) ([]model.ForwardPortRecor
|
||||
}
|
||||
rows := make([]model.ForwardPortRecord, 0, len(ports))
|
||||
for _, p := range ports {
|
||||
rows = append(rows, model.ForwardPortRecord{NodeID: p.NodeID, Port: p.Port})
|
||||
inIP := ""
|
||||
if p.InIP.Valid {
|
||||
inIP = p.InIP.String
|
||||
}
|
||||
rows = append(rows, model.ForwardPortRecord{NodeID: p.NodeID, Port: p.Port, InIP: inIP})
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
@@ -177,6 +181,9 @@ func nodeRecordFromModel(n *model.Node) *model.NodeRecord {
|
||||
if n.ServerIPV6.Valid {
|
||||
rec.ServerIPv6 = strings.TrimSpace(n.ServerIPV6.String)
|
||||
}
|
||||
if n.ExtraIPs.Valid {
|
||||
rec.ExtraIPs = strings.TrimSpace(n.ExtraIPs.String)
|
||||
}
|
||||
if n.InterfaceName.Valid {
|
||||
rec.InterfaceName = strings.TrimSpace(n.InterfaceName.String)
|
||||
}
|
||||
@@ -289,10 +296,11 @@ func (r *Repository) ListChainNodesForTunnel(tunnelID int64) ([]model.ChainNodeR
|
||||
Name sql.NullString
|
||||
Protocol sql.NullString
|
||||
Strategy sql.NullString
|
||||
ConnectIP sql.NullString
|
||||
}
|
||||
var rows []row
|
||||
err := r.db.Model(&model.ChainTunnel{}).
|
||||
Select("chain_tunnel.chain_type, chain_tunnel.inx, chain_tunnel.node_id, chain_tunnel.port, node.name, chain_tunnel.protocol, chain_tunnel.strategy").
|
||||
Select("chain_tunnel.chain_type, chain_tunnel.inx, chain_tunnel.node_id, chain_tunnel.port, node.name, chain_tunnel.protocol, chain_tunnel.strategy, chain_tunnel.connect_ip").
|
||||
Joins("LEFT JOIN node ON node.id = chain_tunnel.node_id").
|
||||
Where("chain_tunnel.tunnel_id = ?", tunnelID).
|
||||
Order("chain_tunnel.chain_type ASC, chain_tunnel.inx ASC, chain_tunnel.id ASC").
|
||||
@@ -337,6 +345,9 @@ func (r *Repository) ListChainNodesForTunnel(tunnelID int64) ([]model.ChainNodeR
|
||||
} else {
|
||||
item.Strategy = row.Strategy.String
|
||||
}
|
||||
if row.ConnectIP.Valid {
|
||||
item.ConnectIP = row.ConnectIP.String
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, nil
|
||||
|
||||
@@ -196,7 +196,7 @@ func (r *Repository) GetUserDefaultsForTunnel(userID int64) (flow int64, num int
|
||||
return user.Flow, user.Num, user.ExpTime, user.FlowResetTime, nil
|
||||
}
|
||||
|
||||
func (r *Repository) CreateNode(name, secret, serverIP string, serverIPV4, serverIPV6, port, interfaceName, version interface{}, httpFlag, tlsFlag, socksFlag int, now int64, status int, tcpAddr, udpAddr string, inx, isRemote int, remoteURL, remoteToken, remoteConfig interface{}) error {
|
||||
func (r *Repository) CreateNode(name, secret, serverIP string, serverIPV4, serverIPV6, port, interfaceName, version interface{}, httpFlag, tlsFlag, socksFlag int, now int64, status int, tcpAddr, udpAddr string, inx, isRemote int, remoteURL, remoteToken, remoteConfig, extraIPs interface{}) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
@@ -206,6 +206,7 @@ func (r *Repository) CreateNode(name, secret, serverIP string, serverIPV4, serve
|
||||
ServerIP: serverIP,
|
||||
ServerIPV4: nullStringFromInterface(serverIPV4),
|
||||
ServerIPV6: nullStringFromInterface(serverIPV6),
|
||||
ExtraIPs: nullStringFromInterface(extraIPs),
|
||||
Port: stringFromInterface(port),
|
||||
InterfaceName: nullStringFromInterface(interfaceName),
|
||||
Version: nullStringFromInterface(version),
|
||||
@@ -238,7 +239,7 @@ func (r *Repository) GetNodeStatusFields(nodeID int64) (status, httpFlag, tlsFla
|
||||
return node.Status, node.HTTP, node.TLS, node.Socks, nil
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, serverIPV6, port, interfaceName interface{}, httpFlag, tlsFlag, socksFlag int, tcpAddr, udpAddr string, now int64) error {
|
||||
func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, serverIPV6, port, interfaceName, extraIPs interface{}, httpFlag, tlsFlag, socksFlag int, tcpAddr, udpAddr string, now int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
@@ -249,6 +250,7 @@ func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, ser
|
||||
"server_ip": serverIP,
|
||||
"server_ip_v4": nullStringFromInterface(serverIPV4),
|
||||
"server_ip_v6": nullStringFromInterface(serverIPV6),
|
||||
"extra_ips": nullStringFromInterface(extraIPs),
|
||||
"port": stringFromInterface(port),
|
||||
"interface_name": nullStringFromInterface(interfaceName),
|
||||
"http": httpFlag,
|
||||
@@ -395,7 +397,7 @@ func (r *Repository) DeleteChainTunnelsByTunnelTx(tx *gorm.DB, tunnelID int64) e
|
||||
return tx.Where("tunnel_id = ?", tunnelID).Delete(&model.ChainTunnel{}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) CreateChainTunnelTx(tx *gorm.DB, tunnelID int64, chainType string, nodeID int64, port sql.NullInt64, strategy string, inx int, protocol string) error {
|
||||
func (r *Repository) CreateChainTunnelTx(tx *gorm.DB, tunnelID int64, chainType string, nodeID int64, port sql.NullInt64, strategy string, inx int, protocol string, connectIp string) error {
|
||||
if tx == nil {
|
||||
return errors.New("database unavailable")
|
||||
}
|
||||
@@ -407,6 +409,7 @@ func (r *Repository) CreateChainTunnelTx(tx *gorm.DB, tunnelID int64, chainType
|
||||
Strategy: nullStringFromInterface(strategy),
|
||||
Inx: nullInt64FromInterface(inx),
|
||||
Protocol: nullStringFromInterface(protocol),
|
||||
ConnectIP: sql.NullString{String: connectIp, Valid: connectIp != ""},
|
||||
}
|
||||
return tx.Create(&ct).Error
|
||||
}
|
||||
@@ -692,6 +695,7 @@ func (r *Repository) DeleteForwardCascade(forwardID int64) error {
|
||||
func (r *Repository) ReplaceForwardPorts(forwardID int64, entries []struct {
|
||||
NodeID int64
|
||||
Port int
|
||||
InIP string
|
||||
}) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
@@ -705,7 +709,12 @@ func (r *Repository) ReplaceForwardPorts(forwardID int64, entries []struct {
|
||||
}
|
||||
rows := make([]model.ForwardPort, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
rows = append(rows, model.ForwardPort{ForwardID: forwardID, NodeID: e.NodeID, Port: e.Port})
|
||||
rows = append(rows, model.ForwardPort{
|
||||
ForwardID: forwardID,
|
||||
NodeID: e.NodeID,
|
||||
Port: e.Port,
|
||||
InIP: sql.NullString{String: e.InIP, Valid: e.InIP != ""},
|
||||
})
|
||||
}
|
||||
return tx.Create(&rows).Error
|
||||
})
|
||||
@@ -1168,7 +1177,7 @@ func (r *Repository) EnsureUserTunnelGrant(userID, tunnelID int64) (int64, bool,
|
||||
return ut.ID, true, nil
|
||||
}
|
||||
|
||||
func (r *Repository) CreateForwardTx(userID int64, userName, name string, tunnelID int64, remoteAddr, strategy string, now int64, inx int, entryNodeIDs []int64, port int, speedID interface{}) (int64, error) {
|
||||
func (r *Repository) CreateForwardTx(userID int64, userName, name string, tunnelID int64, remoteAddr, strategy string, now int64, inx int, entryNodeIDs []int64, port int, inIp string, speedID interface{}) (int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return 0, errors.New("repository not initialized")
|
||||
}
|
||||
@@ -1198,6 +1207,7 @@ func (r *Repository) CreateForwardTx(userID int64, userName, name string, tunnel
|
||||
ForwardID: forwardID,
|
||||
NodeID: nodeID,
|
||||
Port: port,
|
||||
InIP: sql.NullString{String: inIp, Valid: inIp != ""},
|
||||
}
|
||||
if err := tx.Create(&fp).Error; err != nil {
|
||||
return err
|
||||
|
||||
@@ -684,15 +684,107 @@ func TestOpenMigratesLegacyNodeDualStackColumns(t *testing.T) {
|
||||
|
||||
columns := readTableColumns(t, r.DB(), "node")
|
||||
|
||||
for _, required := range []string{"server_ip_v4", "server_ip_v6", "inx"} {
|
||||
for _, required := range []string{"server_ip_v4", "server_ip_v6", "inx", "extra_ips"} {
|
||||
if !columns[required] {
|
||||
t.Fatalf("expected node column %q to exist after migration", required)
|
||||
}
|
||||
}
|
||||
|
||||
tunnelColumns := readTableColumns(t, r.DB(), "tunnel")
|
||||
if !tunnelColumns["inx"] {
|
||||
t.Fatalf("expected tunnel column %q to exist after migration", "inx")
|
||||
for _, required := range []string{"inx", "ip_preference"} {
|
||||
if !tunnelColumns[required] {
|
||||
t.Fatalf("expected tunnel column %q to exist after migration", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenMigratesVeryLegacyNodeAndTunnelColumns(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "legacy-1.x.db")
|
||||
legacyDB, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open legacy sqlite: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = legacyDB.Close()
|
||||
})
|
||||
|
||||
if _, err := legacyDB.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS node (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
secret VARCHAR(100) NOT NULL,
|
||||
server_ip VARCHAR(100) NOT NULL,
|
||||
port TEXT NOT NULL,
|
||||
interface_name VARCHAR(200),
|
||||
version VARCHAR(100),
|
||||
http INTEGER NOT NULL DEFAULT 0,
|
||||
tls INTEGER NOT NULL DEFAULT 0,
|
||||
socks INTEGER NOT NULL DEFAULT 0,
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time INTEGER,
|
||||
status INTEGER NOT NULL
|
||||
)
|
||||
`); err != nil {
|
||||
t.Fatalf("create very legacy node table: %v", err)
|
||||
}
|
||||
|
||||
if _, err := legacyDB.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS tunnel (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
traffic_ratio REAL NOT NULL DEFAULT 1.0,
|
||||
type INTEGER NOT NULL,
|
||||
protocol VARCHAR(10) NOT NULL DEFAULT 'tls',
|
||||
flow INTEGER NOT NULL,
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
in_ip TEXT
|
||||
)
|
||||
`); err != nil {
|
||||
t.Fatalf("create very legacy tunnel table: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if _, err := legacyDB.Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, port, interface_name, version, http, tls, socks, created_time, updated_time, status)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "legacy-node", "legacy-secret", "10.10.0.1", "10000-10010", "eth0", "v-old", 1, 1, 1, now, now, 1); err != nil {
|
||||
t.Fatalf("seed legacy node row: %v", err)
|
||||
}
|
||||
|
||||
r, err := repo.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open migrated sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = r.Close()
|
||||
})
|
||||
|
||||
columns := readTableColumns(t, r.DB(), "node")
|
||||
for _, required := range []string{
|
||||
"server_ip_v4",
|
||||
"server_ip_v6",
|
||||
"extra_ips",
|
||||
"tcp_listen_addr",
|
||||
"udp_listen_addr",
|
||||
"inx",
|
||||
"is_remote",
|
||||
"remote_url",
|
||||
"remote_token",
|
||||
"remote_config",
|
||||
} {
|
||||
if !columns[required] {
|
||||
t.Fatalf("expected node column %q to exist after migration", required)
|
||||
}
|
||||
}
|
||||
|
||||
tunnelColumns := readTableColumns(t, r.DB(), "tunnel")
|
||||
for _, required := range []string{"inx", "ip_preference"} {
|
||||
if !tunnelColumns[required] {
|
||||
t.Fatalf("expected tunnel column %q to exist after migration", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -217,6 +217,7 @@ export interface NodeMutationPayload {
|
||||
serverIp?: string;
|
||||
serverIpV4?: string;
|
||||
serverIpV6?: string;
|
||||
extraIPs?: string;
|
||||
port?: string;
|
||||
tcpListenAddr?: string;
|
||||
udpListenAddr?: string;
|
||||
@@ -230,6 +231,7 @@ export interface TunnelChainNodePayload {
|
||||
nodeId: number;
|
||||
protocol?: string;
|
||||
strategy?: string;
|
||||
connectIp?: string;
|
||||
chainType?: number;
|
||||
inx?: number;
|
||||
}
|
||||
|
||||
@@ -117,6 +117,12 @@ const CONFIG_ITEMS: ConfigItem[] = [
|
||||
description: "用于浏览器标签页图标,上传后会自动转换为 PNG 并持久化保存",
|
||||
type: "input",
|
||||
},
|
||||
{
|
||||
key: "forward_compact_mode",
|
||||
label: "转发页面精简模式",
|
||||
description: "开启后,转发页面列表使用 2.1.6-alpha8 样式(全局配置)",
|
||||
type: "switch",
|
||||
},
|
||||
{
|
||||
key: "captcha_enabled",
|
||||
label: "启用验证码",
|
||||
@@ -167,6 +173,7 @@ const getInitialConfigs = (): Record<string, string> => {
|
||||
"captcha_enabled",
|
||||
"cloudflare_site_key",
|
||||
"cloudflare_secret_key",
|
||||
"forward_compact_mode",
|
||||
"ip",
|
||||
"panel_domain",
|
||||
"app_logo",
|
||||
|
||||
+1478
-237
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
export interface ForwardDiagnosisEntry {
|
||||
success: boolean;
|
||||
success?: boolean;
|
||||
diagnosing?: boolean;
|
||||
description: string;
|
||||
nodeName: string;
|
||||
nodeId: string;
|
||||
|
||||
@@ -76,6 +76,7 @@ interface Node {
|
||||
port: string;
|
||||
tcpListenAddr?: string;
|
||||
udpListenAddr?: string;
|
||||
extraIPs?: string;
|
||||
version?: string;
|
||||
http?: number; // 0 关 1 开
|
||||
tls?: number; // 0 关 1 开
|
||||
@@ -99,7 +100,7 @@ interface Node {
|
||||
rollbackLoading?: boolean;
|
||||
}
|
||||
|
||||
interface NodeForm {
|
||||
interface NodeForm {
|
||||
id: number | null;
|
||||
name: string;
|
||||
serverHost: string;
|
||||
@@ -109,6 +110,7 @@ interface NodeForm {
|
||||
tcpListenAddr: string;
|
||||
udpListenAddr: string;
|
||||
interfaceName: string;
|
||||
extraIPs: string;
|
||||
http: number; // 0 关 1 开
|
||||
tls: number; // 0 关 1 开
|
||||
socks: number; // 0 关 1 开
|
||||
@@ -183,6 +185,7 @@ export default function NodePage() {
|
||||
tcpListenAddr: "[::]",
|
||||
udpListenAddr: "[::]",
|
||||
interfaceName: "",
|
||||
extraIPs: "",
|
||||
http: 0,
|
||||
tls: 0,
|
||||
socks: 0,
|
||||
@@ -590,6 +593,7 @@ export default function NodePage() {
|
||||
tcpListenAddr: node.tcpListenAddr || "[::]",
|
||||
udpListenAddr: node.udpListenAddr || "[::]",
|
||||
interfaceName: (node as any).interfaceName || "",
|
||||
extraIPs: node.extraIPs || "",
|
||||
http: typeof node.http === "number" ? node.http : 1,
|
||||
tls: typeof node.tls === "number" ? node.tls : 1,
|
||||
socks: typeof node.socks === "number" ? node.socks : 1,
|
||||
@@ -817,6 +821,7 @@ export default function NodePage() {
|
||||
const { serverHost, ...rest } = form;
|
||||
const data = {
|
||||
...rest,
|
||||
extraIPs: form.extraIPs,
|
||||
serverIp:
|
||||
form.serverIpV4?.trim() ||
|
||||
form.serverIpV6?.trim() ||
|
||||
@@ -880,6 +885,7 @@ export default function NodePage() {
|
||||
tcpListenAddr: "[::]",
|
||||
udpListenAddr: "[::]",
|
||||
interfaceName: "",
|
||||
extraIPs: "",
|
||||
http: 0,
|
||||
tls: 0,
|
||||
socks: 0,
|
||||
@@ -1637,6 +1643,17 @@ export default function NodePage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Input
|
||||
description="多IP服务器可填写额外IP地址,逗号分隔"
|
||||
label="额外IP地址"
|
||||
placeholder="例如: 192.168.1.100, 10.0.0.5"
|
||||
value={form.extraIPs}
|
||||
variant="bordered"
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, extraIPs: e.target.value }))
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
errorMessage={errors.tcpListenAddr}
|
||||
|
||||
@@ -6,12 +6,15 @@ import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
|
||||
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
|
||||
import { Switch } from "@/shadcn-bridge/heroui/switch";
|
||||
import { reinitializeBaseURL } from "@/api/network";
|
||||
import { getConfigByName, updateConfig } from "@/api";
|
||||
import {
|
||||
type UpdateReleaseChannel,
|
||||
getUpdateReleaseChannel,
|
||||
setUpdateReleaseChannel,
|
||||
} from "@/utils/version-update";
|
||||
import { isAdmin } from "@/utils/auth";
|
||||
import {
|
||||
getPanelAddresses,
|
||||
savePanelAddress,
|
||||
@@ -26,6 +29,8 @@ interface PanelAddress {
|
||||
inx: boolean;
|
||||
}
|
||||
|
||||
const FORWARD_COMPACT_MODE_CONFIG_KEY = "forward_compact_mode";
|
||||
|
||||
export const SettingsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [panelAddresses, setPanelAddresses] = useState<PanelAddress[]>([]);
|
||||
@@ -34,6 +39,11 @@ export const SettingsPage = () => {
|
||||
const [updateChannel, setUpdateChannel] = useState<UpdateReleaseChannel>(
|
||||
getUpdateReleaseChannel(),
|
||||
);
|
||||
const [forwardCompactMode, setForwardCompactMode] = useState(false);
|
||||
const [forwardCompactModeSaving, setForwardCompactModeSaving] =
|
||||
useState(false);
|
||||
|
||||
const admin = isAdmin();
|
||||
|
||||
const setPanelAddressesFunc = (newAddress: PanelAddress[]) => {
|
||||
setPanelAddresses(newAddress);
|
||||
@@ -86,8 +96,57 @@ export const SettingsPage = () => {
|
||||
// 页面加载时获取数据
|
||||
useEffect(() => {
|
||||
loadPanelAddresses();
|
||||
loadForwardCompactMode();
|
||||
}, []);
|
||||
|
||||
const loadForwardCompactMode = async () => {
|
||||
try {
|
||||
const res = await getConfigByName(FORWARD_COMPACT_MODE_CONFIG_KEY);
|
||||
const enabled =
|
||||
res.code === 0 &&
|
||||
typeof res.data?.value === "string" &&
|
||||
res.data.value === "true";
|
||||
|
||||
setForwardCompactMode(enabled);
|
||||
} catch {
|
||||
setForwardCompactMode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleForwardCompactModeChange = async (enabled: boolean) => {
|
||||
if (!admin || forwardCompactModeSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = forwardCompactMode;
|
||||
|
||||
setForwardCompactMode(enabled);
|
||||
setForwardCompactModeSaving(true);
|
||||
try {
|
||||
const response = await updateConfig(
|
||||
FORWARD_COMPACT_MODE_CONFIG_KEY,
|
||||
enabled ? "true" : "false",
|
||||
);
|
||||
|
||||
if (response.code === 0) {
|
||||
toast.success(`转发页面精简模式已${enabled ? "开启" : "关闭"}`);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("forwardCompactModeChanged", {
|
||||
detail: { enabled },
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
setForwardCompactMode(previous);
|
||||
toast.error(response.msg || "保存精简模式失败");
|
||||
}
|
||||
} catch {
|
||||
setForwardCompactMode(previous);
|
||||
toast.error("保存精简模式失败");
|
||||
} finally {
|
||||
setForwardCompactModeSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateChannelChange = (channel: UpdateReleaseChannel) => {
|
||||
setUpdateChannel(channel);
|
||||
setUpdateReleaseChannel(channel);
|
||||
@@ -164,6 +223,37 @@ export const SettingsPage = () => {
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-gray-200 dark:border-gray-700">
|
||||
<CardBody className="p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
|
||||
显示设置
|
||||
</h2>
|
||||
<div className="rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
转发页面精简模式
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
开启后,转发页面列表使用 2.1.6-alpha8 样式。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
color="primary"
|
||||
isDisabled={!admin || forwardCompactModeSaving}
|
||||
isSelected={forwardCompactMode}
|
||||
onValueChange={handleForwardCompactModeChange}
|
||||
/>
|
||||
</div>
|
||||
{!admin && (
|
||||
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">
|
||||
仅管理员可修改该全局配置。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 添加新地址 */}
|
||||
<Card className="border border-gray-200 dark:border-gray-700">
|
||||
<CardBody className="p-6">
|
||||
|
||||
@@ -70,6 +70,7 @@ interface ChainTunnel {
|
||||
strategy?: string; // 'fifo' | 'round' | 'rand' - 仅转发链需要
|
||||
chainType?: number; // 1: 入口, 2: 转发链, 3: 出口
|
||||
inx?: number; // 转发链序号
|
||||
connectIp?: string; // 连接IP(多IP节点指定连接地址)
|
||||
}
|
||||
|
||||
interface Tunnel {
|
||||
@@ -94,6 +95,10 @@ interface Node {
|
||||
id: number;
|
||||
name: string;
|
||||
status: number; // 1: 在线, 0: 离线
|
||||
serverIp?: string;
|
||||
serverIpV4?: string;
|
||||
serverIpV6?: string;
|
||||
extraIPs?: string;
|
||||
}
|
||||
|
||||
interface TunnelForm {
|
||||
@@ -143,6 +148,46 @@ export default function TunnelPage() {
|
||||
});
|
||||
const diagnosisAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const getNodeIpOptions = (nodeId: number): string[] => {
|
||||
const node = nodes.find((item) => item.id === nodeId);
|
||||
|
||||
if (!node) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const values: string[] = [];
|
||||
const push = (value?: string) => {
|
||||
const trimmed = (value || "").trim();
|
||||
|
||||
if (trimmed) {
|
||||
values.push(trimmed);
|
||||
}
|
||||
};
|
||||
|
||||
push(node.serverIpV4);
|
||||
push(node.serverIpV6);
|
||||
push(node.serverIp);
|
||||
|
||||
(node.extraIPs || "")
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v)
|
||||
.forEach((v) => values.push(v));
|
||||
|
||||
return Array.from(new Set(values));
|
||||
};
|
||||
|
||||
const getCommonIpOptions = (nodeIds: number[]): string[] => {
|
||||
if (nodeIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const optionSets = nodeIds.map((nodeId) => new Set(getNodeIpOptions(nodeId)));
|
||||
const base = optionSets[0];
|
||||
|
||||
return Array.from(base).filter((ip) => optionSets.every((set) => set.has(ip)));
|
||||
};
|
||||
|
||||
// 表单状态
|
||||
const [form, setForm] = useState<TunnelForm>(createTunnelFormDefaults());
|
||||
|
||||
@@ -343,6 +388,20 @@ export default function TunnelPage() {
|
||||
});
|
||||
};
|
||||
|
||||
// 更新某一跳的所有节点的连接IP
|
||||
const updateChainConnectIp = (groupIndex: number, connectIp: string) => {
|
||||
setForm((prev) => {
|
||||
const chainNodes = [...(prev.chainNodes || [])];
|
||||
|
||||
chainNodes[groupIndex] = (chainNodes[groupIndex] || []).map((node) => ({
|
||||
...node,
|
||||
connectIp,
|
||||
}));
|
||||
|
||||
return { ...prev, chainNodes };
|
||||
});
|
||||
};
|
||||
|
||||
// 获取所有转发链中已选择的节点ID列表
|
||||
const getSelectedChainNodeIds = (): number[] => {
|
||||
return (form.chainNodes || []).flatMap((group) =>
|
||||
@@ -490,11 +549,14 @@ export default function TunnelPage() {
|
||||
? "端口转发"
|
||||
: "隧道转发";
|
||||
const startTotal = Number(payload.total);
|
||||
const startItems = Array.isArray(payload.items)
|
||||
? (payload.items as DiagnosisResult["results"])
|
||||
: [];
|
||||
setDiagnosisResult((prev) => ({
|
||||
tunnelName: startTunnelName,
|
||||
tunnelType: startTunnelType,
|
||||
timestamp: Date.now(),
|
||||
results: prev?.results || [],
|
||||
results: startItems.length > 0 ? startItems : prev?.results || [],
|
||||
}));
|
||||
if (Number.isFinite(startTotal) && startTotal >= 0) {
|
||||
setDiagnosisProgress((prev) => ({
|
||||
@@ -521,9 +583,15 @@ export default function TunnelPage() {
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
nextResults[existingIndex] = result;
|
||||
nextResults[existingIndex] = {
|
||||
...result,
|
||||
diagnosing: false,
|
||||
};
|
||||
} else {
|
||||
nextResults.push(result);
|
||||
nextResults.push({
|
||||
...result,
|
||||
diagnosing: false,
|
||||
});
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
@@ -596,7 +664,7 @@ export default function TunnelPage() {
|
||||
toast.error(streamErrorMessage);
|
||||
}
|
||||
if (streamResult.timedOut) {
|
||||
toast.error("诊断达到2分钟超时,已返回当前结果");
|
||||
toast.error("诊断超时(单条30秒 / 整体2分钟),已返回当前结果");
|
||||
}
|
||||
} catch {
|
||||
if (abortController.signal.aborted) {
|
||||
@@ -970,6 +1038,10 @@ export default function TunnelPage() {
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
{sortedTunnels.map((tunnel) => {
|
||||
const typeDisplay = getTunnelTypeDisplay(tunnel.type);
|
||||
const tunnelTypeChipClassName =
|
||||
tunnel.type === 1
|
||||
? "text-xs bg-primary-100 text-primary-800 border-primary-300 dark:bg-primary-900/45 dark:text-primary-200 dark:border-primary-700"
|
||||
: "text-xs bg-success-100 text-success-800 border-success-300 dark:bg-success-900/35 dark:text-success-200 dark:border-success-700";
|
||||
|
||||
return (
|
||||
<SortableItem key={tunnel.id} id={tunnel.id}>
|
||||
@@ -993,7 +1065,7 @@ export default function TunnelPage() {
|
||||
</h3>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
<Chip
|
||||
className="text-xs"
|
||||
className={tunnelTypeChipClassName}
|
||||
color={typeDisplay.color as any}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
@@ -1517,6 +1589,15 @@ export default function TunnelPage() {
|
||||
groupNodes.length > 0
|
||||
? groupNodes[0].strategy || "round"
|
||||
: "round";
|
||||
const groupSelectedNodeIds = groupNodes
|
||||
.filter((ct) => ct.nodeId !== -1)
|
||||
.map((ct) => ct.nodeId);
|
||||
const groupIpOptions =
|
||||
getCommonIpOptions(groupSelectedNodeIds);
|
||||
const selectedGroupConnectIp =
|
||||
groupNodes.length > 0
|
||||
? groupNodes[0].connectIp || ""
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -1728,6 +1809,47 @@ export default function TunnelPage() {
|
||||
<SelectItem key="rand">随机</SelectItem>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 连接IP - 转发链节点 */}
|
||||
<Select
|
||||
classNames={{
|
||||
label: "text-xs",
|
||||
value: "text-sm",
|
||||
}}
|
||||
description="按当前跳所选节点的共有IP进行选择,留空使用默认"
|
||||
isDisabled={
|
||||
groupSelectedNodeIds.length === 0 ||
|
||||
groupIpOptions.length === 0
|
||||
}
|
||||
label="连接IP"
|
||||
placeholder={
|
||||
groupSelectedNodeIds.length === 0
|
||||
? "请先选择节点"
|
||||
: groupIpOptions.length > 0
|
||||
? "选择连接IP"
|
||||
: "所选节点无共同可选IP"
|
||||
}
|
||||
selectedKeys={[
|
||||
selectedGroupConnectIp || "__default__",
|
||||
]}
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedKey = Array.from(keys)[0] as string;
|
||||
|
||||
updateChainConnectIp(
|
||||
groupIndex,
|
||||
selectedKey === "__default__"
|
||||
? ""
|
||||
: selectedKey,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectItem key="__default__">默认连接IP</SelectItem>
|
||||
{groupIpOptions.map((ip) => (
|
||||
<SelectItem key={ip}>{ip}</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -1989,6 +2111,82 @@ export default function TunnelPage() {
|
||||
<SelectItem key="rand">随机</SelectItem>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 连接IP - 出口节点 */}
|
||||
<Select
|
||||
classNames={{
|
||||
label: "text-xs",
|
||||
value: "text-sm",
|
||||
}}
|
||||
description="按出口节点共同可用IP选择,留空使用默认"
|
||||
isDisabled={
|
||||
(form.outNodeId || []).filter((ct) => ct.nodeId !== -1)
|
||||
.length === 0 ||
|
||||
getCommonIpOptions(
|
||||
(form.outNodeId || [])
|
||||
.filter((ct) => ct.nodeId !== -1)
|
||||
.map((ct) => ct.nodeId),
|
||||
).length === 0
|
||||
}
|
||||
label="连接IP"
|
||||
placeholder={
|
||||
(form.outNodeId || []).filter((ct) => ct.nodeId !== -1)
|
||||
.length === 0
|
||||
? "请先选择出口节点"
|
||||
: getCommonIpOptions(
|
||||
(form.outNodeId || [])
|
||||
.filter((ct) => ct.nodeId !== -1)
|
||||
.map((ct) => ct.nodeId),
|
||||
).length > 0
|
||||
? "选择连接IP"
|
||||
: "所选节点无共同可选IP"
|
||||
}
|
||||
selectedKeys={[
|
||||
form.outNodeId && form.outNodeId.length > 0
|
||||
? form.outNodeId[0].connectIp || "__default__"
|
||||
: "__default__",
|
||||
]}
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedKey = Array.from(keys)[0] as string;
|
||||
const value =
|
||||
selectedKey === "__default__" ? "" : selectedKey;
|
||||
setForm((prev) => {
|
||||
const currentOutNodes = prev.outNodeId || [];
|
||||
if (currentOutNodes.length === 0) {
|
||||
return {
|
||||
...prev,
|
||||
outNodeId: [
|
||||
{
|
||||
nodeId: -1,
|
||||
chainType: 3,
|
||||
protocol: "tls",
|
||||
strategy: "round",
|
||||
connectIp: value,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
outNodeId: currentOutNodes.map((ct) => ({
|
||||
...ct,
|
||||
connectIp: value,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectItem key="__default__">默认连接IP</SelectItem>
|
||||
{getCommonIpOptions(
|
||||
(form.outNodeId || [])
|
||||
.filter((ct) => ct.nodeId !== -1)
|
||||
.map((ct) => ct.nodeId),
|
||||
).map((ip) => (
|
||||
<SelectItem key={ip}>{ip}</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -2106,15 +2304,7 @@ export default function TunnelPage() {
|
||||
)}
|
||||
</ModalHeader>
|
||||
<ModalBody className="bg-content1">
|
||||
{diagnosisLoading &&
|
||||
(!diagnosisResult || diagnosisResult.results.length === 0) ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="flex items-center gap-3">
|
||||
<Spinner size="sm" />
|
||||
<span className="text-default-600">正在诊断...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : diagnosisResult ? (
|
||||
{diagnosisResult ? (
|
||||
<div className="space-y-4">
|
||||
{diagnosisLoading && (
|
||||
<div className="flex items-center justify-between rounded-lg border border-primary/20 bg-primary/5 px-3 py-2">
|
||||
@@ -2136,7 +2326,7 @@ export default function TunnelPage() {
|
||||
{diagnosisProgress.timedOut && (
|
||||
<Alert
|
||||
color="warning"
|
||||
description="诊断已达到2分钟超时,以下为当前已完成结果。"
|
||||
description="诊断超时(单条30秒 / 整体2分钟),以下为当前已完成结果。"
|
||||
title="诊断超时"
|
||||
variant="flat"
|
||||
/>
|
||||
@@ -2245,6 +2435,10 @@ export default function TunnelPage() {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-divider bg-white dark:bg-gray-800">
|
||||
{results.map((result, index) => {
|
||||
const isDiagnosing = Boolean(
|
||||
result.diagnosing,
|
||||
);
|
||||
const isSuccess = result.success === true;
|
||||
const quality = getDiagnosisQualityDisplay(
|
||||
result.averageTime,
|
||||
result.packetLoss,
|
||||
@@ -2254,22 +2448,28 @@ export default function TunnelPage() {
|
||||
<tr
|
||||
key={index}
|
||||
className={`hover:bg-default-50 dark:hover:bg-gray-700/50 ${
|
||||
result.success
|
||||
isDiagnosing
|
||||
? "bg-warning-50 dark:bg-warning-900/20"
|
||||
: isSuccess
|
||||
? "bg-white dark:bg-gray-800"
|
||||
: "bg-danger-50 dark:bg-danger-900/30"
|
||||
}`}
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
|
||||
result.success
|
||||
? "bg-success text-white"
|
||||
: "bg-danger text-white"
|
||||
}`}
|
||||
>
|
||||
{result.success ? "✓" : "✗"}
|
||||
</span>
|
||||
{isDiagnosing ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
<span
|
||||
className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
|
||||
isSuccess
|
||||
? "bg-success text-white"
|
||||
: "bg-danger text-white"
|
||||
}`}
|
||||
>
|
||||
{isSuccess ? "✓" : "✗"}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-foreground truncate">
|
||||
{result.description}
|
||||
@@ -2284,18 +2484,24 @@ export default function TunnelPage() {
|
||||
<td className="px-3 py-2 text-center">
|
||||
<Chip
|
||||
color={
|
||||
result.success
|
||||
isDiagnosing
|
||||
? "warning"
|
||||
: isSuccess
|
||||
? "success"
|
||||
: "danger"
|
||||
}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{result.success ? "成功" : "失败"}
|
||||
{isDiagnosing
|
||||
? "诊断中"
|
||||
: isSuccess
|
||||
? "成功"
|
||||
: "失败"}
|
||||
</Chip>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{result.success ? (
|
||||
{isSuccess ? (
|
||||
<span className="font-semibold text-primary">
|
||||
{result.averageTime?.toFixed(0)}
|
||||
</span>
|
||||
@@ -2306,7 +2512,7 @@ export default function TunnelPage() {
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{result.success ? (
|
||||
{isSuccess ? (
|
||||
<span
|
||||
className={`font-semibold ${
|
||||
(result.packetLoss || 0) > 0
|
||||
@@ -2323,7 +2529,7 @@ export default function TunnelPage() {
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{result.success && quality ? (
|
||||
{isSuccess && quality ? (
|
||||
<Chip
|
||||
className="text-xs whitespace-nowrap"
|
||||
color={quality.color as any}
|
||||
@@ -2417,6 +2623,8 @@ export default function TunnelPage() {
|
||||
</h3>
|
||||
</div>
|
||||
{results.map((result, index) => {
|
||||
const isDiagnosing = Boolean(result.diagnosing);
|
||||
const isSuccess = result.success === true;
|
||||
const quality = getDiagnosisQualityDisplay(
|
||||
result.averageTime,
|
||||
result.packetLoss,
|
||||
@@ -2426,21 +2634,27 @@ export default function TunnelPage() {
|
||||
<div
|
||||
key={index}
|
||||
className={`border rounded-lg p-3 ${
|
||||
result.success
|
||||
isDiagnosing
|
||||
? "border-warning-200 dark:border-warning-300/30 bg-warning-50 dark:bg-warning-900/20"
|
||||
: isSuccess
|
||||
? "border-divider bg-white dark:bg-gray-800"
|
||||
: "border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2 mb-2">
|
||||
<span
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${
|
||||
result.success
|
||||
? "bg-success text-white"
|
||||
: "bg-danger text-white"
|
||||
}`}
|
||||
>
|
||||
{result.success ? "✓" : "✗"}
|
||||
</span>
|
||||
{isDiagnosing ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
<span
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${
|
||||
isSuccess
|
||||
? "bg-success text-white"
|
||||
: "bg-danger text-white"
|
||||
}`}
|
||||
>
|
||||
{isSuccess ? "✓" : "✗"}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-sm text-foreground break-words">
|
||||
{result.description}
|
||||
@@ -2452,16 +2666,24 @@ export default function TunnelPage() {
|
||||
<Chip
|
||||
className="flex-shrink-0"
|
||||
color={
|
||||
result.success ? "success" : "danger"
|
||||
isDiagnosing
|
||||
? "warning"
|
||||
: isSuccess
|
||||
? "success"
|
||||
: "danger"
|
||||
}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{result.success ? "成功" : "失败"}
|
||||
{isDiagnosing
|
||||
? "诊断中"
|
||||
: isSuccess
|
||||
? "成功"
|
||||
: "失败"}
|
||||
</Chip>
|
||||
</div>
|
||||
|
||||
{result.success ? (
|
||||
{isSuccess ? (
|
||||
<div className="grid grid-cols-3 gap-2 mt-2 pt-2 border-t border-divider">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-primary">
|
||||
@@ -2505,8 +2727,16 @@ export default function TunnelPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 pt-2 border-t border-divider">
|
||||
<div className="text-xs text-danger">
|
||||
{result.message || "连接失败"}
|
||||
<div
|
||||
className={`text-xs ${
|
||||
isDiagnosing
|
||||
? "text-warning"
|
||||
: "text-danger"
|
||||
}`}
|
||||
>
|
||||
{isDiagnosing
|
||||
? result.message || "诊断中..."
|
||||
: result.message || "连接失败"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -2547,14 +2777,16 @@ export default function TunnelPage() {
|
||||
</div>
|
||||
|
||||
{/* 失败详情(仅桌面端显示,移动端已在卡片中显示) */}
|
||||
{diagnosisResult.results.some((r) => !r.success) && (
|
||||
{diagnosisResult.results.some(
|
||||
(r) => r.success === false && !r.diagnosing,
|
||||
) && (
|
||||
<div className="space-y-2 hidden md:block">
|
||||
<h4 className="text-sm font-semibold text-danger">
|
||||
失败详情
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{diagnosisResult.results
|
||||
.filter((r) => !r.success)
|
||||
.filter((r) => r.success === false && !r.diagnosing)
|
||||
.map((result, index) => (
|
||||
<Alert
|
||||
key={index}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export interface DiagnosisEntry {
|
||||
success: boolean;
|
||||
success?: boolean;
|
||||
diagnosing?: boolean;
|
||||
description: string;
|
||||
nodeName: string;
|
||||
nodeId: string;
|
||||
|
||||
Reference in New Issue
Block a user