Compare commits

..

24 Commits

Author SHA1 Message Date
sagit addf83a249 fix(ui): improve date input parsing and add missing back navigation (#284)
## Summary
- Improve DatePicker text parsing to accept more input formats
(including `YYYYMMDD`) without requiring explicit separators
- Add missing H5 back-navigation for panel-sharing by using simple
layout route
- Add a back button on config page with history fallback (`navigate(-1)`
then `/profile`)

## Why
These are usability/accessibility improvements that are generic and not
project-brand specific:
- Mobile keyboards may not easily input `-` in date fields
- Certain pages in H5 lacked a consistent return path
- Config page had no explicit in-page back action

## Scope
- `vite-frontend/src/shadcn-bridge/heroui/date-picker.tsx`
- `vite-frontend/src/App.tsx`
- `vite-frontend/src/pages/config.tsx`

## Notes
No branding/identity/ownership/visual-theme customizations included in
this PR.
2026-03-07 18:07:07 +08:00
sagitchu c3e35fd416 fix(ui): tighten date parsing and back navigation
Entire-Checkpoint: fea62b38d8c7
2026-03-07 18:02:00 +08:00
sagit 775dfe19f1 Merge branch 'main' into fix/upstream-friendly-ui-3pack 2026-03-07 17:36:29 +08:00
sagit db3b2f651b fix: improve bind-conflict detection and forward cleanup reliability (#287)
## Summary
- Normalize whitespace in bind-conflict error messages to handle
collapsed variants (e.g., "address alreadyin use")
- Update forward cleanup to delete all service name variants (_tcp,
_udp, base) instead of stopping after first success
- Add comprehensive test coverage for edge cases with missing-space
error variants

## Test plan
- ✅ Unit tests: `cd go-backend && go test ./internal/http/handler/...`
- ✅ Contract tests: `cd go-backend && go test ./tests/contract/... -run
'TestForwardUpdateRecoversFromAddressInUseContract|TestTunnelUpdateRecoversFromAddressInUseContract'`
2026-03-07 17:21:00 +08:00
sagitchu 669323f926 fix: improve bind-conflict detection and forward cleanup reliability
- Normalize whitespace in error messages to handle collapsed variants (e.g., 'address alreadyin use')
- Delete all forward service name variants (_tcp, _udp, base) during cleanup instead of stopping after first success
- Add comprehensive test coverage for edge cases
2026-03-07 17:19:28 +08:00
𝓐𝓵𝓽𝓲𝓸𝓷 7202b69e4e Merge branch 'main' into fix/upstream-friendly-ui-3pack 2026-03-07 17:00:31 +08:00
sagit 31977a62e6 fix: add retry mechanism for tunnel service bind conflicts (#286)
## Summary
- Add retry logic for tunnel service creation/update when encountering
"address already in use" bind errors
- Automatically cleanup stale service and retry once before failing
- Add comprehensive unit and contract tests for bind conflict scenarios

## Changes
- `mutations.go`: Add `retryTunnelServiceAddWithCleanup` helper and
`addTunnelServiceOnNode` wrapper
- `control_plane_test.go`: Unit tests for retry behavior on address
conflicts
- `dual_stack_test.go`: Test fallback to node listen address
- `forward_contract_test.go`: Contract test for forward update with bind
retry
- `limiter_sync_failure_contract_test.go`: Contract test for tunnel
update with bind retry
- `plans/016-tunnel-runtime-bind-conflict-retry.md`: Implementation plan
document

## Test Plan
- Unit tests verify retry logic executes correctly
- Contract tests validate end-to-end behavior with mock nodes
- All tests pass with race detector enabled
2026-03-07 16:23:13 +08:00
sagitchu 87479c2ac1 fix: add retry mechanism for tunnel service bind conflicts
When tunnel services encounter 'address already in use' errors during
creation/update, automatically cleanup and retry once instead of failing
immediately. This handles race conditions during rapid tunnel reconfiguration.

Entire-Checkpoint: 39e6fb9de836
2026-03-07 16:21:44 +08:00
Su-cyber-art ffda0fb71a fix(ui): improve date input parsing and add missing back navigation 2026-03-07 14:19:23 +08:00
sagit 9c0e7341c3 feat: add helpful hints for form fields in tunnel and node management (#279)
Entire-Checkpoint: 20cc01a9700d
2026-03-06 08:21:19 +00:00
sagit 1db5452be9 fix: add forward port occupancy validation and runtime residual cleanup (#278)
* fix: tolerate service not found during forward deletion

- Refactor deleteForwardServicesOnNode to handle not-found errors gracefully
- Extract deleteForwardServiceCandidates helper for reuse
- Add tests for not-found tolerance scenarios
- Ensures compatibility with legacy node versions

Entire-Checkpoint: a3bacf836c57

* fix: add forward port occupancy validation and runtime residual cleanup

- Add forward port occupancy validation on create/update paths
- Extend self-occupy recovery to clean residual candidate service names
- Add regression tests for address-in-use recovery with legacy runtime residue

Fixes port conflict issues when upgrading from 2.1.6 to later versions

Entire-Checkpoint: fb0a2aee4cb5
2026-03-06 10:57:07 +08:00
sagit c10f894afd fix: tolerate service not found during forward deletion (#277)
- Refactor deleteForwardServicesOnNode to handle not-found errors gracefully
- Extract deleteForwardServiceCandidates helper for reuse
- Add tests for not-found tolerance scenarios
- Ensures compatibility with legacy node versions

Entire-Checkpoint: a3bacf836c57
2026-03-06 09:03:28 +08:00
sagit 7fb75baa73 feat: allow user custom inport with range validation (#274)
## Summary
- Allow users to specify custom inlet ports within a defined range
- Add port range validation for tunnel configurations
- Implement UI controls for custom port selection
- Add contract tests for custom port functionality
2026-03-05 17:04:58 +08:00
sagitchu 15e6cd69eb feat: allow user custom inport with range validation
Entire-Checkpoint: fcd76aac10e9
2026-03-05 17:03:19 +08:00
sagit f6eb88d75e refactor: 统一术语,将'转发'改为'规则' (#273)
- 更新所有页面中的'转发'术语为'规则'
- 统一UI文案,提升用户体验一致性
- 关联 #269 #271

Entire-Checkpoint: 5646e42aa33b
2026-03-05 15:07:42 +08:00
sagit f45b580984 fix: prevent effect execution when forwards list is empty (#272)
## Summary
- 添加空列表检查,防止在forwards为空时执行effect
- 避免不必要的groupOrder状态更新和持久化操作
2026-03-05 14:09:19 +08:00
sagitchu 4f50c47550 fix: prevent effect execution when forwards list is empty
Entire-Checkpoint: 69eeade13bf9
2026-03-05 14:07:25 +08:00
sagit 2e1d75dc36 fix: add self-healing for forward service name migration (#270)
## Summary
- Fix `service not found` errors when upgrading from older versions
where service names migrated from placeholder IDs (`forward_user_0`) to
real `user_tunnel_id`
- Add fallback cleanup+rebuild logic on `UpdateService` when service not
found during upgrade transition
- Add self-healing retry on `Pause/Resume` operations when all service
variants are missing
- Refactor `controlForwardServicesOnNode` to support unit testing
- Add tests for the new helper functions

## Test plan
- [x] Unit tests pass: `cd go-backend && go test
./internal/http/handler/...`

## Upgrade path
1. Deploy this backend patch first (no need to wait for all agents)
2. Gradually upgrade agents in batches (10-20%)
3. Run "forward batch redeploy" after each batch to unify service naming
4. Monitor logs for `service .* not found` errors
2026-03-05 12:42:04 +08:00
sagitchu f496f58a4d fix: add self-healing for forward service name migration
When upgrading from older versions, service names changed from
placeholder IDs (forward_user_0) to real user_tunnel IDs, causing
service not found errors during control operations.

- Add fallback cleanup+rebuild logic on UpdateService when service
  not found during the upgrade transition period.
- Add self-healing retry on Pause/Resume when all variants are missing.
- Refactor controlForwardServicesOnNode to support unit testing.
- Add tests for shouldSelfHealForwardServiceControl and
  controlForwardServiceCommand helper functions.

Entire-Checkpoint: a7f0c3175d06
2026-03-05 12:40:20 +08:00
sagit 32474bec20 fix: resolve user tunnel early to use real ID in service name (#265)
## Summary
- Fix service name generation to use the actual user_tunnel ID instead
of 0
- Move user tunnel resolution before building service base name
- Add `buildForwardServiceBaseWithResolvedUserTunnel` helper function

## Details
Previously, the service base name was built with `userTunnelID=0` before
the actual user tunnel was resolved. This caused the runtime service
name to not carry the real user_tunnel ID.

The fix resolves the user tunnel early and passes the resolved ID to the
service name builder, ensuring proper service identification.
2026-03-04 19:35:47 +08:00
sagitchu 581cda7edc fix: resolve user tunnel early to use real ID in service name
- Move user tunnel resolution before building service base name
- Add buildForwardServiceBaseWithResolvedUserTunnel helper
- Ensure service names carry the actual user_tunnel ID instead of 0

Entire-Checkpoint: 9559e6447fda
2026-03-04 19:34:16 +08:00
sagit 96aebb8d61 fix: handle drag-and-drop order correctly in compact mode (#264) 2026-03-04 16:49:03 +08:00
sagit 735fd40786 fix: correct SortableContext nesting for table drag-and-drop (#263)
## Summary
- Fixed incorrect nesting of SortableContext component in forward table
- Moved SortableContext wrapper to properly wrap the entire Table
component instead of wrapping individual rows
- This ensures drag-and-drop functionality works correctly with the
table structure
2026-03-04 16:01:38 +08:00
sagitchu a3b0bf4898 fix: correct SortableContext nesting for table drag-and-drop
Entire-Checkpoint: 356ceb26d6bc
2026-03-04 16:00:07 +08:00
27 changed files with 1995 additions and 512 deletions
+139 -58
View File
@@ -245,6 +245,12 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
}
warnings := make([]string, 0)
// Resolve user tunnel first so runtime service name can carry the real user_tunnel id.
userTunnelID, utLimiterID, utSpeed, err := h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
if err != nil {
return nil, err
}
// Determine limiter from forward's SpeedID first, fallback to UserTunnel's limiter
var limiterID *int64
var speed *int
@@ -260,17 +266,11 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
if limiterID == nil {
// Fall back to UserTunnel speed limit
var utLimiterID *int64
var utSpeed *int
_, utLimiterID, utSpeed, err = h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
if err != nil {
return nil, err
}
limiterID = utLimiterID
speed = utSpeed
}
serviceBase := buildForwardServiceBase(forward.ID, forward.UserID, 0)
serviceBase := buildForwardServiceBaseWithResolvedUserTunnel(forward.ID, forward.UserID, userTunnelID)
tunnelTLSProtocol, err := h.isTunnelSelectedTLSProtocol(forward.TunnelID)
if err != nil {
return nil, err
@@ -290,6 +290,11 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, strings.TrimSpace(fp.InIP), limiterID, tunnelTLSProtocol)
_, err = h.sendNodeCommand(node.ID, method, services, true, false)
if err != nil && allowFallbackAdd && method == "UpdateService" {
if isNotFoundError(err) {
if delErr := h.deleteForwardServicesOnNode(forward, node.ID); delErr != nil && !isNotFoundError(delErr) {
return warnings, fmt.Errorf("节点 %s 清理旧服务失败: %w", node.Name, delErr)
}
}
_, err = h.sendNodeCommand(node.ID, "AddService", services, true, false)
}
if err != nil && strings.EqualFold(strings.TrimSpace(method), "UpdateService") && isAddressAlreadyInUseError(err) {
@@ -354,7 +359,12 @@ func (h *Handler) rebindForwardServiceOnSelfOccupiedPort(forward *forwardRecord,
return fmt.Errorf("端口 %d 已被其他转发占用", port)
}
if err := h.deleteForwardServicesOnNode(forward, node.ID); err != nil {
bases, err := h.forwardServiceBaseCandidates(forward)
if err != nil {
return err
}
if err := h.deleteForwardServiceBasesOnNode(node.ID, bases); err != nil {
return err
}
@@ -372,41 +382,45 @@ func (h *Handler) deleteForwardServicesOnNode(forward *forwardRecord, nodeID int
if h == nil || forward == nil {
return errors.New("invalid forward delete context")
}
userTunnelID, _, _, err := h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
bases, err := h.forwardServiceBaseCandidates(forward)
if err != nil {
return err
}
return h.deleteForwardServiceBasesOnNode(nodeID, bases)
}
func (h *Handler) forwardServiceBaseCandidates(forward *forwardRecord) ([]string, error) {
if h == nil || forward == nil {
return nil, errors.New("invalid forward service base context")
}
userTunnelID, _, _, err := h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
if err != nil {
return nil, err
}
userTunnelIDs, err := h.listUserTunnelIDs(forward.UserID, forward.TunnelID)
if err != nil {
return err
return nil, err
}
allUserTunnelIDs, err := h.listUserTunnelIDsByUser(forward.UserID)
if err != nil {
return err
return nil, err
}
candidateTunnelIDs := make([]int64, 0, len(userTunnelIDs)+len(allUserTunnelIDs))
candidateTunnelIDs = append(candidateTunnelIDs, userTunnelIDs...)
candidateTunnelIDs = append(candidateTunnelIDs, allUserTunnelIDs...)
bases := buildForwardServiceBaseCandidates(forward.ID, forward.UserID, userTunnelID, candidateTunnelIDs)
return buildForwardServiceBaseCandidates(forward.ID, forward.UserID, userTunnelID, candidateTunnelIDs), nil
var lastErr error
for _, base := range bases {
names := buildForwardControlServiceNames(base, "DeleteService")
}
func (h *Handler) deleteForwardServiceBasesOnNode(nodeID int64, bases []string) error {
return deleteForwardServiceCandidates(bases, func(name string) error {
payload := map[string]interface{}{
"services": names,
"services": []string{name},
}
_, cmdErr := h.sendNodeCommand(nodeID, "DeleteService", payload, false, true)
if cmdErr == nil {
return nil
}
lastErr = cmdErr
}
if lastErr != nil {
return lastErr
}
return nil
_, err := h.sendNodeCommand(nodeID, "DeleteService", payload, false, false)
return err
})
}
func (h *Handler) controlForwardServices(forward *forwardRecord, commandType string, tolerateNotFound bool) error {
@@ -437,40 +451,26 @@ func (h *Handler) controlForwardServices(forward *forwardRecord, commandType str
candidateTunnelIDs = append(candidateTunnelIDs, allUserTunnelIDs...)
bases := buildForwardServiceBaseCandidates(forward.ID, forward.UserID, userTunnelID, candidateTunnelIDs)
seen := map[int64]struct{}{}
healed := false
for _, fp := range ports {
if _, ok := seen[fp.NodeID]; ok {
continue
}
seen[fp.NodeID] = struct{}{}
var lastNotFoundErr error
nodeHandled := false
nodeHandled, lastNotFoundErr, err := h.controlForwardServicesOnNode(fp.NodeID, bases, commandType)
if err != nil {
return err
}
for _, base := range bases {
variants := []string{base + "_tcp", base + "_udp"}
if shouldTryLegacySingleService(commandType) || strings.EqualFold(strings.TrimSpace(commandType), "DeleteService") {
variants = append(variants, base)
if !nodeHandled && lastNotFoundErr != nil && !healed && shouldSelfHealForwardServiceControl(commandType) {
if healErr := h.syncForwardServices(forward, "UpdateService", true); healErr != nil {
return healErr
}
candidateHandled := false
for _, name := range variants {
payload := map[string]interface{}{
"services": []string{name},
}
_, err := h.sendNodeCommand(fp.NodeID, commandType, payload, false, false)
if err == nil {
candidateHandled = true
continue
}
if !isNotFoundError(err) {
return err
}
lastNotFoundErr = err
}
if candidateHandled {
nodeHandled = true
break
healed = true
nodeHandled, lastNotFoundErr, err = h.controlForwardServicesOnNode(fp.NodeID, bases, commandType)
if err != nil {
return err
}
}
@@ -488,6 +488,65 @@ func (h *Handler) controlForwardServices(forward *forwardRecord, commandType str
return nil
}
func (h *Handler) controlForwardServicesOnNode(nodeID int64, bases []string, commandType string) (bool, error, error) {
return controlForwardServiceCommand(bases, commandType, func(name string) error {
payload := map[string]interface{}{
"services": []string{name},
}
_, err := h.sendNodeCommand(nodeID, commandType, payload, false, false)
return err
})
}
func controlForwardServiceCommand(bases []string, commandType string, send func(name string) error) (bool, error, error) {
var lastNotFoundErr error
for _, base := range bases {
variants := []string{base + "_tcp", base + "_udp"}
if shouldTryLegacySingleService(commandType) || strings.EqualFold(strings.TrimSpace(commandType), "DeleteService") {
variants = append(variants, base)
}
candidateHandled := false
for _, name := range variants {
err := send(name)
if err == nil {
candidateHandled = true
continue
}
if !isNotFoundError(err) {
return false, lastNotFoundErr, err
}
lastNotFoundErr = err
}
if candidateHandled {
return true, nil, nil
}
}
return false, lastNotFoundErr, nil
}
func deleteForwardServiceCandidates(bases []string, send func(name string) error) error {
for _, base := range bases {
for _, name := range append([]string{base + "_tcp", base + "_udp", base}, []string{}...) {
err := send(name)
if err == nil {
continue
}
if isNotFoundError(err) {
continue
}
return err
}
}
return nil
}
func shouldSelfHealForwardServiceControl(commandType string) bool {
cmd := strings.ToLower(strings.TrimSpace(commandType))
return cmd == "pauseservice" || cmd == "resumeservice"
}
func (h *Handler) applyNodeProtocolChange(nodeID int64, httpVal, tlsVal, socksVal int) error {
_, err := h.sendNodeCommand(nodeID, "SetProtocol", map[string]interface{}{
"http": httpVal,
@@ -1362,6 +1421,13 @@ func buildForwardServiceBase(forwardID, userID, userTunnelID int64) string {
return fmt.Sprintf("%d_%d_%d", forwardID, userID, userTunnelID)
}
func buildForwardServiceBaseWithResolvedUserTunnel(forwardID, userID, resolvedUserTunnelID int64) string {
if resolvedUserTunnelID <= 0 {
return buildForwardServiceBase(forwardID, userID, 0)
}
return buildForwardServiceBase(forwardID, userID, resolvedUserTunnelID)
}
func buildForwardServiceBaseCandidates(forwardID, userID, preferredUserTunnelID int64, userTunnelIDs []int64) []string {
orderedIDs := make([]int64, 0, len(userTunnelIDs)+2)
seen := make(map[int64]struct{}, len(userTunnelIDs)+2)
@@ -1413,10 +1479,11 @@ func isAlreadyExistsMessage(message string) bool {
if msg == "" {
return false
}
if strings.Contains(msg, "address already in use") {
if isAddressAlreadyInUseMessage(msg) {
return false
}
return strings.Contains(msg, "already exists") || strings.Contains(msg, "已存在")
compact := compactErrorMessage(msg)
return strings.Contains(msg, "already exists") || strings.Contains(msg, "已存在") || strings.Contains(compact, "alreadyexists")
}
func isBindAddressInUseError(err error) bool {
@@ -1441,7 +1508,10 @@ func isAddressAlreadyInUseMessage(msg string) bool {
if msg == "" {
return false
}
return strings.Contains(msg, "address already in use")
if strings.Contains(msg, "address already in use") {
return true
}
return strings.Contains(compactErrorMessage(msg), "addressalreadyinuse")
}
func isCannotAssignRequestedAddressError(err error) bool {
@@ -1452,7 +1522,18 @@ func isCannotAssignRequestedAddressError(err error) bool {
if msg == "" {
return false
}
return strings.Contains(msg, "cannot assign requested address")
if strings.Contains(msg, "cannot assign requested address") {
return true
}
return strings.Contains(compactErrorMessage(msg), "cannotassignrequestedaddress")
}
func compactErrorMessage(msg string) string {
msg = strings.TrimSpace(msg)
if msg == "" {
return ""
}
return strings.Join(strings.Fields(strings.ToLower(msg)), "")
}
func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, bindIP string, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} {
@@ -4,6 +4,8 @@ import (
"errors"
"reflect"
"testing"
"go-backend/internal/store/repo"
)
func TestBuildForwardControlServiceNamesPauseResume(t *testing.T) {
@@ -43,6 +45,20 @@ func TestBuildForwardServiceBaseCandidatesWithZeroPreferred(t *testing.T) {
}
}
func TestBuildForwardServiceBaseWithResolvedUserTunnel(t *testing.T) {
got := buildForwardServiceBaseWithResolvedUserTunnel(12, 34, 56)
if got != "12_34_56" {
t.Fatalf("expected 12_34_56, got %s", got)
}
}
func TestBuildForwardServiceBaseWithResolvedUserTunnelFallbackToZero(t *testing.T) {
got := buildForwardServiceBaseWithResolvedUserTunnel(12, 34, 0)
if got != "12_34_0" {
t.Fatalf("expected 12_34_0, got %s", got)
}
}
func TestShouldTryLegacySingleService(t *testing.T) {
if !shouldTryLegacySingleService("PauseService") {
t.Fatalf("PauseService should require legacy fallback")
@@ -55,6 +71,184 @@ func TestShouldTryLegacySingleService(t *testing.T) {
}
}
func TestShouldSelfHealForwardServiceControl(t *testing.T) {
if !shouldSelfHealForwardServiceControl("PauseService") {
t.Fatalf("PauseService should trigger self-heal")
}
if !shouldSelfHealForwardServiceControl(" resumeService ") {
t.Fatalf("ResumeService should trigger self-heal")
}
if shouldSelfHealForwardServiceControl("DeleteService") {
t.Fatalf("DeleteService should not trigger self-heal")
}
}
func TestControlForwardServiceCommandHandledOnKnownVariant(t *testing.T) {
bases := []string{"12_34_56"}
called := make([]string, 0)
handled, lastNotFoundErr, err := controlForwardServiceCommand(bases, "PauseService", func(name string) error {
called = append(called, name)
if name == "12_34_56_udp" {
return nil
}
return errors.New("service " + name + " not found")
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !handled {
t.Fatalf("expected handled=true")
}
if lastNotFoundErr != nil {
t.Fatalf("expected lastNotFoundErr=nil when handled")
}
wantCalls := []string{"12_34_56_tcp", "12_34_56_udp", "12_34_56"}
if !reflect.DeepEqual(called, wantCalls) {
t.Fatalf("expected calls %v, got %v", wantCalls, called)
}
}
func TestControlForwardServiceCommandReturnsLastNotFoundWhenAllMissing(t *testing.T) {
bases := []string{"12_34_56"}
handled, lastNotFoundErr, err := controlForwardServiceCommand(bases, "PauseService", func(name string) error {
return errors.New("service " + name + " not found")
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if handled {
t.Fatalf("expected handled=false")
}
if lastNotFoundErr == nil {
t.Fatalf("expected lastNotFoundErr when all variants are missing")
}
}
func TestDeleteForwardServiceCandidatesSkipsNotFoundUntilLegacyMatch(t *testing.T) {
bases := []string{"12_34_56", "12_34_0"}
called := make([]string, 0)
err := deleteForwardServiceCandidates(bases, func(name string) error {
called = append(called, name)
if name == "12_34_0" {
return nil
}
return errors.New("service " + name + " not found")
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
wantCalls := []string{"12_34_56_tcp", "12_34_56_udp", "12_34_56", "12_34_0_tcp", "12_34_0_udp", "12_34_0"}
if !reflect.DeepEqual(called, wantCalls) {
t.Fatalf("expected calls %v, got %v", wantCalls, called)
}
}
func TestDeleteForwardServiceCandidatesTreatsAllMissingAsSuccess(t *testing.T) {
bases := []string{"12_34_56", "12_34_0"}
err := deleteForwardServiceCandidates(bases, func(name string) error {
return errors.New("service " + name + " not found")
})
if err != nil {
t.Fatalf("all-missing delete should be tolerated, got %v", err)
}
}
func TestForwardServiceBaseCandidatesIncludesResolvedAndLegacyZero(t *testing.T) {
bases := buildForwardServiceBaseCandidates(46, 9, 123, []int64{123, 77, 0})
want := []string{"46_9_123", "46_9_77", "46_9_0"}
if !reflect.DeepEqual(bases, want) {
t.Fatalf("expected %v, got %v", want, bases)
}
}
func TestDeleteForwardServiceBasesOnNodeRetriesLegacyZeroResidue(t *testing.T) {
bases := []string{"46_9_123", "46_9_0"}
called := make([]string, 0)
err := deleteForwardServiceCandidates(bases, func(name string) error {
called = append(called, name)
if name == "46_9_0_tcp" || name == "46_9_0_udp" {
return nil
}
return errors.New("service " + name + " not found")
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []string{"46_9_123_tcp", "46_9_123_udp", "46_9_123", "46_9_0_tcp", "46_9_0_udp", "46_9_0"}
if !reflect.DeepEqual(called, want) {
t.Fatalf("expected calls %v, got %v", want, called)
}
}
func TestDeleteForwardServiceCandidatesDeletesAllMatchingVariants(t *testing.T) {
bases := []string{"57_7_7", "57_7_0"}
called := make([]string, 0)
err := deleteForwardServiceCandidates(bases, func(name string) error {
called = append(called, name)
switch name {
case "57_7_7_tcp", "57_7_7_udp", "57_7_0_tcp", "57_7_0_udp":
return nil
default:
return errors.New("service " + name + " not found")
}
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []string{"57_7_7_tcp", "57_7_7_udp", "57_7_7", "57_7_0_tcp", "57_7_0_udp", "57_7_0"}
if !reflect.DeepEqual(called, want) {
t.Fatalf("expected calls %v, got %v", want, called)
}
}
func TestValidateForwardPortAvailabilityRejectsOtherForwardOccupancy(t *testing.T) {
h := &Handler{repo: nil}
node := &nodeRecord{ID: 9, Name: "test-node"}
_ = h
_ = node
rawRepo, err := repo.Open(":memory:")
if err != nil {
t.Fatalf("open repo: %v", err)
}
h = &Handler{repo: rawRepo}
if err := rawRepo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(1, 9, 2000)`).Error; err != nil {
t.Fatalf("insert forward port: %v", err)
}
err = h.validateForwardPortAvailability(&nodeRecord{ID: 9, Name: "test-node"}, 2000, 2)
if err == nil {
t.Fatalf("expected occupancy error")
}
if err.Error() != "节点 test-node 端口 2000 已被其他转发占用" {
t.Fatalf("unexpected error: %v", err)
}
err = h.validateForwardPortAvailability(&nodeRecord{ID: 9, Name: "test-node"}, 2000, 1)
if err != nil {
t.Fatalf("same forward should be allowed, got %v", err)
}
}
func TestControlForwardServiceCommandReturnsHardError(t *testing.T) {
bases := []string{"12_34_56"}
handled, lastNotFoundErr, err := controlForwardServiceCommand(bases, "PauseService", func(name string) error {
if name == "12_34_56_tcp" {
return errors.New("network timeout")
}
return nil
})
if err == nil {
t.Fatalf("expected hard error")
}
if handled {
t.Fatalf("expected handled=false on hard error")
}
if lastNotFoundErr != nil {
t.Fatalf("did not expect not-found error alongside hard error")
}
}
func TestIsAlreadyExistsMessage(t *testing.T) {
if !isAlreadyExistsMessage("service demo already exists") {
t.Fatalf("expected already exists message to be tolerated")
@@ -62,9 +256,15 @@ func TestIsAlreadyExistsMessage(t *testing.T) {
if !isAlreadyExistsMessage("服务已存在") {
t.Fatalf("expected Chinese already exists message to be tolerated")
}
if !isAlreadyExistsMessage("service demo alreadyexists") {
t.Fatalf("missing-space alreadyexists should be tolerated")
}
if isAlreadyExistsMessage("listen tcp [::]:10001: bind: address already in use") {
t.Fatalf("address already in use must not be treated as already exists")
}
if isAlreadyExistsMessage("create service 57_7_7_tcp failed: listen tcp4 0.0.0.0:46222: bind: address alreadyin use") {
t.Fatalf("alreadyin-use variant must not be treated as already exists")
}
}
func TestIsBindAddressInUseError(t *testing.T) {
@@ -86,6 +286,9 @@ func TestIsAddressAlreadyInUseError(t *testing.T) {
if !isAddressAlreadyInUseError(errors.New("listen tcp [::]:10001: bind: address already in use")) {
t.Fatalf("address already in use should be detected")
}
if !isAddressAlreadyInUseError(errors.New("create service 57_7_7_tcp failed: listen tcp4 0.0.0.0:46222: bind: address alreadyin use")) {
t.Fatalf("missing-space alreadyin-use variant should be detected")
}
if isAddressAlreadyInUseError(errors.New("listen tcp4 13.228.170.187:16765: bind: cannot assign requested address")) {
t.Fatalf("cannot assign requested address should not be treated as address-in-use")
}
@@ -95,11 +298,83 @@ func TestIsCannotAssignRequestedAddressError(t *testing.T) {
if !isCannotAssignRequestedAddressError(errors.New("listen tcp4 13.228.170.187:16765: bind: cannot assign requested address")) {
t.Fatalf("cannot assign requested address should be detected")
}
if !isCannotAssignRequestedAddressError(errors.New("listen tcp4 13.228.170.187:16765: bind: cannotassignrequestedaddress")) {
t.Fatalf("missing-space cannotassignrequestedaddress variant should be detected")
}
if isCannotAssignRequestedAddressError(errors.New("listen tcp [::]:10001: bind: address already in use")) {
t.Fatalf("address already in use should not be treated as cannot-assign")
}
}
func TestRetryTunnelServiceAddWithCleanupRetriesOnAddressInUse(t *testing.T) {
addCalls := 0
cleanupCalls := 0
err := retryTunnelServiceAddWithCleanup(
func() error {
addCalls++
if addCalls == 1 {
return errors.New("listen tcp 10.0.0.1:32000: bind: address already in use")
}
return nil
},
func() error {
cleanupCalls++
return nil
},
0,
)
if err != nil {
t.Fatalf("expected retry to succeed, got %v", err)
}
if addCalls != 2 {
t.Fatalf("expected 2 add attempts, got %d", addCalls)
}
if cleanupCalls != 1 {
t.Fatalf("expected 1 cleanup attempt, got %d", cleanupCalls)
}
}
func TestRetryTunnelServiceAddWithCleanupSkipsCleanupOnNonBindError(t *testing.T) {
addCalls := 0
cleanupCalls := 0
err := retryTunnelServiceAddWithCleanup(
func() error {
addCalls++
return errors.New("network timeout")
},
func() error {
cleanupCalls++
return nil
},
0,
)
if err == nil {
t.Fatalf("expected hard error")
}
if addCalls != 1 {
t.Fatalf("expected 1 add attempt, got %d", addCalls)
}
if cleanupCalls != 0 {
t.Fatalf("expected 0 cleanup attempts, got %d", cleanupCalls)
}
}
func TestRetryTunnelServiceAddWithCleanupReturnsCleanupError(t *testing.T) {
cleanupErr := errors.New("delete failed")
err := retryTunnelServiceAddWithCleanup(
func() error {
return errors.New("listen tcp 10.0.0.1:32000: bind: address already in use")
},
func() error {
return cleanupErr
},
0,
)
if !errors.Is(err, cleanupErr) {
t.Fatalf("expected cleanup error %v, got %v", cleanupErr, err)
}
}
func TestBuildForwardServiceConfigs_UsesBindIPForListen(t *testing.T) {
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
node := &nodeRecord{TCPListenAddr: "[::]", UDPListenAddr: "[::]"}
@@ -43,6 +43,19 @@ func TestBuildTunnelChainServiceConfig_UsesConnectIPForListen(t *testing.T) {
}
}
func TestBuildTunnelChainServiceConfig_FallsBackToNodeListenAddr(t *testing.T) {
node := &nodeRecord{TCPListenAddr: "10.8.0.5"}
chain := tunnelRuntimeNode{Protocol: "tls", Port: 21002}
services := buildTunnelChainServiceConfig(99, chain, node)
if len(services) != 1 {
t.Fatalf("expected 1 service, got %d", len(services))
}
addr, _ := services[0]["addr"].(string)
if addr != "10.8.0.5:21002" {
t.Fatalf("expected node listen addr 10.8.0.5:21002, got %q", addr)
}
}
func TestBuildTunnelChainServiceConfig_DefaultListenAddrWhenConnectIPEmpty(t *testing.T) {
node := &nodeRecord{TCPListenAddr: "[::]"}
chain := tunnelRuntimeNode{Protocol: "tls", Port: 21001}
+137 -16
View File
@@ -25,6 +25,8 @@ import (
"gorm.io/gorm"
)
const tunnelServiceBindRetryDelay = 150 * time.Millisecond
func (h *Handler) userCreate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
@@ -1158,13 +1160,6 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-1, "普通用户无法设置限速规则"))
return
}
if inPortVal, ok := req["inPort"]; ok {
port := asInt(inPortVal, 0)
if port > 0 {
response.WriteJSON(w, response.Err(-1, "普通用户无法设置自定义端口"))
return
}
}
}
speedID := asAnyToInt64Ptr(req["speedId"])
speedID, err = h.normalizeSpeedLimitReference(speedID)
@@ -1194,6 +1189,14 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
if err := validateLocalNodePort(node, port); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
if err := h.validateForwardPortAvailability(node, port, 0); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
}
now := time.Now().UnixMilli()
inx := h.repo.NextIndex("forward")
@@ -1281,13 +1284,6 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-1, "普通用户无法修改限速规则"))
return
}
if inPortVal, ok := req["inPort"]; ok {
port := asInt(inPortVal, 0)
if port > 0 {
response.WriteJSON(w, response.Err(-1, "普通用户无法修改自定义端口"))
return
}
}
}
speedID := asAnyToInt64Ptr(req["speedId"])
speedID, err = h.normalizeSpeedLimitReference(speedID)
@@ -1332,6 +1328,14 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
if err := validateLocalNodePort(node, port); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
if err := h.validateForwardPortAvailability(node, port, id); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
}
now := time.Now().UnixMilli()
if err := h.repo.UpdateForward(id, name, tunnelID, remoteAddr, strategy, now, newSpeedID); err != nil {
@@ -2582,7 +2586,7 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
createdChains = append(createdChains, chainNode.NodeID)
serviceData := buildTunnelChainServiceConfig(state.TunnelID, chainNode, state.Nodes[chainNode.NodeID])
if _, err := h.sendNodeCommand(chainNode.NodeID, "AddService", serviceData, true, false); err != nil {
if err := h.addTunnelServiceOnNode(chainNode.NodeID, state.TunnelID, serviceData); err != nil {
return createdChains, createdServices, fmt.Errorf("转发链节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)
}
createdServices = append(createdServices, chainNode.NodeID)
@@ -2594,7 +2598,7 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
continue
}
serviceData := buildTunnelChainServiceConfig(state.TunnelID, outNode, state.Nodes[outNode.NodeID])
if _, err := h.sendNodeCommand(outNode.NodeID, "AddService", serviceData, true, false); err != nil {
if err := h.addTunnelServiceOnNode(outNode.NodeID, state.TunnelID, serviceData); err != nil {
return createdChains, createdServices, fmt.Errorf("出口节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[outNode.NodeID]), err)
}
createdServices = append(createdServices, outNode.NodeID)
@@ -2603,6 +2607,44 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
return createdChains, createdServices, nil
}
func retryTunnelServiceAddWithCleanup(add func() error, cleanup func() error, wait time.Duration) error {
if add == nil {
return errors.New("invalid tunnel service add callback")
}
err := add()
if err == nil || !isAddressAlreadyInUseError(err) {
return err
}
if cleanup == nil {
return err
}
if cleanupErr := cleanup(); cleanupErr != nil {
return cleanupErr
}
if wait > 0 {
time.Sleep(wait)
}
return add()
}
func (h *Handler) addTunnelServiceOnNode(nodeID, tunnelID int64, serviceData []map[string]interface{}) error {
if h == nil {
return errors.New("invalid tunnel service context")
}
serviceName := fmt.Sprintf("%d_tls", tunnelID)
return retryTunnelServiceAddWithCleanup(
func() error {
_, err := h.sendNodeCommand(nodeID, "AddService", serviceData, true, false)
return err
},
func() error {
_, err := h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{serviceName}}, false, true)
return err
},
tunnelServiceBindRetryDelay,
)
}
func (h *Handler) rollbackTunnelRuntime(chainNodeIDs, serviceNodeIDs []int64, tunnelID int64) {
if h == nil || tunnelID <= 0 {
return
@@ -3519,3 +3561,82 @@ func asInt64Slice(v interface{}) []int64 {
}
return ids
}
func validateLocalNodePort(node *nodeRecord, port int) error {
if node == nil || node.IsRemote == 1 || port <= 0 {
return nil
}
portRange := strings.TrimSpace(node.PortRange)
if portRange == "" {
return nil
}
minPort, maxPort := parsePortRangeMinMax(portRange)
if minPort <= 0 || maxPort <= 0 {
return nil
}
if port < minPort || port > maxPort {
return fmt.Errorf("端口 %d 超出节点 %s 允许范围 %d-%d", port, node.Name, minPort, maxPort)
}
return nil
}
func (h *Handler) validateForwardPortAvailability(node *nodeRecord, port int, currentForwardID int64) error {
if h == nil || h.repo == nil || node == nil || port <= 0 {
return nil
}
occupied, err := h.repo.HasOtherForwardOnNodePort(node.ID, port, currentForwardID)
if err != nil {
return err
}
if occupied {
return fmt.Errorf("节点 %s 端口 %d 已被其他转发占用", node.Name, port)
}
return nil
}
func parsePortRangeMinMax(input string) (int, int) {
input = strings.TrimSpace(input)
if input == "" {
return 0, 0
}
minPort, maxPort := 0, 0
parts := strings.Split(input, ",")
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if strings.Contains(part, "-") {
r := strings.SplitN(part, "-", 2)
if len(r) != 2 {
continue
}
start, err1 := strconv.Atoi(strings.TrimSpace(r[0]))
end, err2 := strconv.Atoi(strings.TrimSpace(r[1]))
if err1 != nil || err2 != nil || start <= 0 || end <= 0 {
continue
}
if end < start {
start, end = end, start
}
if minPort == 0 || start < minPort {
minPort = start
}
if maxPort == 0 || end > maxPort {
maxPort = end
}
continue
}
p, err := strconv.Atoi(part)
if err != nil || p <= 0 {
continue
}
if minPort == 0 || p < minPort {
minPort = p
}
if maxPort == 0 || p > maxPort {
maxPort = p
}
}
return minPort, maxPort
}
+149 -4
View File
@@ -767,9 +767,21 @@ func (r *Repository) ListUserAccessibleTunnels(userID int64) ([]map[string]inter
if err != nil {
return nil, err
}
tunnelIDs := make([]int64, 0, len(rows))
for _, rw := range rows {
tunnelIDs = append(tunnelIDs, rw.ID)
}
portRangeMap := r.getTunnelEntryPortRanges(tunnelIDs)
items := make([]map[string]interface{}, 0, len(rows))
for _, r := range rows {
items = append(items, map[string]interface{}{"id": r.ID, "name": r.Name})
for _, rw := range rows {
item := map[string]interface{}{"id": rw.ID, "name": rw.Name}
if pr, ok := portRangeMap[rw.ID]; ok {
item["portRangeMin"] = pr.min
item["portRangeMax"] = pr.max
}
items = append(items, item)
}
return items, nil
}
@@ -788,13 +800,146 @@ func (r *Repository) ListEnabledTunnelSummaries() ([]map[string]interface{}, err
if err != nil {
return nil, err
}
tunnelIDs := make([]int64, 0, len(rows))
for _, rw := range rows {
tunnelIDs = append(tunnelIDs, rw.ID)
}
portRangeMap := r.getTunnelEntryPortRanges(tunnelIDs)
items := make([]map[string]interface{}, 0, len(rows))
for _, r := range rows {
items = append(items, map[string]interface{}{"id": r.ID, "name": r.Name})
for _, rw := range rows {
item := map[string]interface{}{"id": rw.ID, "name": rw.Name}
if pr, ok := portRangeMap[rw.ID]; ok {
item["portRangeMin"] = pr.min
item["portRangeMax"] = pr.max
}
items = append(items, item)
}
return items, nil
}
type tunnelPortRange struct {
min int
max int
}
func (r *Repository) getTunnelEntryPortRanges(tunnelIDs []int64) map[int64]tunnelPortRange {
result := make(map[int64]tunnelPortRange)
if len(tunnelIDs) == 0 {
return result
}
type entryNode struct {
TunnelID int64
NodeID int64
}
var entries []entryNode
r.db.Model(&model.ChainTunnel{}).
Select("tunnel_id, node_id").
Where("tunnel_id IN (?) AND chain_type = ?", tunnelIDs, "1").
Find(&entries)
nodeIDs := make([]int64, 0, len(entries))
nodeSet := make(map[int64]struct{})
for _, e := range entries {
if _, exists := nodeSet[e.NodeID]; !exists {
nodeSet[e.NodeID] = struct{}{}
nodeIDs = append(nodeIDs, e.NodeID)
}
}
type nodePort struct {
ID int64
Port string
}
var nodePorts []nodePort
if len(nodeIDs) > 0 {
r.db.Model(&model.Node{}).Select("id, port").Where("id IN (?)", nodeIDs).Find(&nodePorts)
}
nodePortMap := make(map[int64]string)
for _, np := range nodePorts {
nodePortMap[np.ID] = np.Port
}
for _, e := range entries {
portSpec := nodePortMap[e.NodeID]
if portSpec == "" {
continue
}
minP, maxP := parsePortRangeMinMax(portSpec)
if minP <= 0 || maxP <= 0 {
continue
}
pr, exists := result[e.TunnelID]
if !exists {
result[e.TunnelID] = tunnelPortRange{min: minP, max: maxP}
} else {
if minP < pr.min {
pr.min = minP
}
if maxP > pr.max {
pr.max = maxP
}
result[e.TunnelID] = pr
}
}
return result
}
func parsePortRangeMinMax(input string) (int, int) {
input = strings.TrimSpace(input)
if input == "" {
return 0, 0
}
minPort, maxPort := 0, 0
parts := strings.Split(input, ",")
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if strings.Contains(part, "-") {
r := strings.SplitN(part, "-", 2)
if len(r) != 2 {
continue
}
start, end := parseIntPort(r[0]), parseIntPort(r[1])
if start <= 0 || end <= 0 {
continue
}
if end < start {
start, end = end, start
}
if minPort == 0 || start < minPort {
minPort = start
}
if maxPort == 0 || end > maxPort {
maxPort = end
}
continue
}
p := parseIntPort(part)
if p <= 0 {
continue
}
if minPort == 0 || p < minPort {
minPort = p
}
if maxPort == 0 || p > maxPort {
maxPort = p
}
}
return minPort, maxPort
}
func parseIntPort(s string) int {
var p int
fmt.Sscanf(strings.TrimSpace(s), "%d", &p)
return p
}
func (r *Repository) ListTunnels() ([]map[string]interface{}, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
@@ -7,6 +7,8 @@ import (
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"testing"
"time"
@@ -914,6 +916,164 @@ func TestForwardCreateThenPauseResumeContract(t *testing.T) {
}
}
func TestForwardUpdateRecoversFromAddressInUseContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
server := httptest.NewServer(router)
defer server.Close()
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
now := time.Now().UnixMilli()
if err := repo.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(202, 'forward_bind_retry_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "forward-bind-retry-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, repo, "forward-bind-retry-tunnel")
if err := repo.DB().Exec(`
INSERT INTO node(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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "forward-bind-retry-node", "forward-bind-retry-secret", "10.42.0.1", "10.42.0.1", "", "44000-44010", "", "v1", 1, 1, 1, now, now, 1, "10.42.0.9", "[::]", 0).Error; err != nil {
t.Fatalf("insert node: %v", err)
}
nodeID := mustLastInsertID(t, repo, "forward-bind-retry-node")
if err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 44001, 'round', 1, 'tls')
`, tunnelID, nodeID).Error; err != nil {
t.Fatalf("insert chain_tunnel: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(41, 202, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
`, tunnelID).Error; err != nil {
t.Fatalf("insert user_tunnel: %v", err)
}
createPayload := map[string]interface{}{
"name": "forward-bind-retry-target",
"tunnelId": tunnelID,
"remoteAddr": "1.1.1.1:443",
"strategy": "fifo",
}
createBody, err := json.Marshal(createPayload)
if err != nil {
t.Fatalf("marshal create payload: %v", err)
}
var mu sync.Mutex
counts := map[string]int{}
var addServiceAddrs []string
triggerConflict := false
stopNode := startMockNodeSessionWithCommandRecorder(t, server.URL, "forward-bind-retry-secret", func(cmdType string, data json.RawMessage) (bool, string) {
key := strings.ToLower(strings.TrimSpace(cmdType))
mu.Lock()
counts[key]++
attempt := counts[key]
if strings.EqualFold(strings.TrimSpace(cmdType), "AddService") || strings.EqualFold(strings.TrimSpace(cmdType), "UpdateService") {
var services []map[string]interface{}
if err := json.Unmarshal(data, &services); err == nil {
for _, svc := range services {
if addr, _ := svc["addr"].(string); strings.TrimSpace(addr) != "" {
addServiceAddrs = append(addServiceAddrs, addr)
}
}
}
}
shouldFail := false
if triggerConflict {
if strings.EqualFold(strings.TrimSpace(cmdType), "UpdateService") && attempt == 1 {
shouldFail = true
}
if strings.EqualFold(strings.TrimSpace(cmdType), "AddService") && attempt == 1 {
shouldFail = true
}
}
mu.Unlock()
if shouldFail {
return true, "create service 57_7_7_tcp failed: listen tcp4 0.0.0.0:46222: bind: address alreadyin use"
}
return false, ""
})
defer stopNode()
waitNodeStatus(t, repo, nodeID, 1)
createReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
createReq.Header.Set("Authorization", adminToken)
createReq.Header.Set("Content-Type", "application/json")
createRes := httptest.NewRecorder()
router.ServeHTTP(createRes, createReq)
assertCode(t, createRes, 0)
mu.Lock()
counts = map[string]int{}
addServiceAddrs = nil
triggerConflict = true
mu.Unlock()
forwardID := mustLastInsertID(t, repo, "forward-bind-retry-target")
updatePayload := map[string]interface{}{
"id": forwardID,
"name": "forward-bind-retry-target-updated",
"tunnelId": tunnelID,
"remoteAddr": "9.9.9.9:8443",
"strategy": "fifo",
}
updateBody, err := json.Marshal(updatePayload)
if err != nil {
t.Fatalf("marshal update payload: %v", err)
}
updateReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody))
updateReq.Header.Set("Authorization", adminToken)
updateReq.Header.Set("Content-Type", "application/json")
updateRes := httptest.NewRecorder()
router.ServeHTTP(updateRes, updateReq)
assertCode(t, updateRes, 0)
mu.Lock()
defer mu.Unlock()
boundPort := mustQueryInt(t, repo, `SELECT port FROM forward_port WHERE forward_id = ? LIMIT 1`, forwardID)
if counts["updateservice"] != 1 {
t.Fatalf("expected one UpdateService attempt, got %d (%v)", counts["updateservice"], counts)
}
if counts["deleteservice"] == 0 {
t.Fatalf("expected DeleteService cleanup after address-in-use (%v)", counts)
}
if counts["addservice"] < 2 {
t.Fatalf("expected AddService retry path to run at least twice total, got %d (%v)", counts["addservice"], counts)
}
foundBindAddr := false
for _, addr := range addServiceAddrs {
if addr == "10.42.0.9:"+strconv.Itoa(boundPort) {
foundBindAddr = true
break
}
}
if !foundBindAddr {
t.Fatalf("expected forward runtime to keep node listen addr 10.42.0.9:%d, got %v", boundPort, addServiceAddrs)
}
storedRemoteAddr := mustQueryString(t, repo, `SELECT remote_addr FROM forward WHERE id = ?`, forwardID)
if storedRemoteAddr != "9.9.9.9:8443" {
t.Fatalf("expected remote_addr update to persist, got %q", storedRemoteAddr)
}
}
func jsonNumber(v int64) string {
return strconv.FormatInt(v, 10)
}
@@ -998,9 +1158,9 @@ func TestNonAdminCannotSetSpeedIdOrPort(t *testing.T) {
assertCodeMsg(t, res, -1, "普通用户无法设置限速规则")
})
t.Run("non-admin cannot set inPort on create", func(t *testing.T) {
t.Run("non-admin cannot set inPort out of range on create", func(t *testing.T) {
createPayload := map[string]interface{}{
"name": "perm-forward-port",
"name": "perm-forward-port-out",
"tunnelId": tunnelID,
"remoteAddr": "1.2.3.4:443",
"strategy": "fifo",
@@ -1015,7 +1175,33 @@ func TestNonAdminCannotSetSpeedIdOrPort(t *testing.T) {
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCodeMsg(t, res, -1, "普通用户无法设置自定义端口")
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code >= 0 {
t.Errorf("expected port out of range error, got code=%d msg=%s", out.Code, out.Msg)
}
})
t.Run("non-admin can set inPort within range on create", func(t *testing.T) {
createPayload := map[string]interface{}{
"name": "perm-forward-port-in",
"tunnelId": tunnelID,
"remoteAddr": "1.2.3.4:443",
"strategy": "fifo",
"inPort": 30005,
}
createBody, err := json.Marshal(createPayload)
if err != nil {
t.Fatalf("marshal create payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
req.Header.Set("Authorization", userToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCode(t, res, 0)
})
t.Run("non-admin can create without speedId and inPort", func(t *testing.T) {
@@ -1059,7 +1245,7 @@ func TestNonAdminCannotSetSpeedIdOrPort(t *testing.T) {
assertCodeMsg(t, res, -1, "普通用户无法修改限速规则")
})
t.Run("non-admin cannot update inPort", func(t *testing.T) {
t.Run("non-admin cannot update inPort out of range", func(t *testing.T) {
updatePayload := map[string]interface{}{
"id": forwardID,
"name": "perm-forward-updated2",
@@ -1076,7 +1262,33 @@ func TestNonAdminCannotSetSpeedIdOrPort(t *testing.T) {
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCodeMsg(t, res, -1, "普通用户无法修改自定义端口")
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code >= 0 {
t.Errorf("expected port out of range error, got code=%d msg=%s", out.Code, out.Msg)
}
})
t.Run("non-admin can update inPort within range", func(t *testing.T) {
updatePayload := map[string]interface{}{
"id": forwardID,
"name": "perm-forward-updated3",
"tunnelId": tunnelID,
"remoteAddr": "5.6.7.8:443",
"inPort": 30006,
}
updateBody, err := json.Marshal(updatePayload)
if err != nil {
t.Fatalf("marshal update payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody))
req.Header.Set("Authorization", userToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCode(t, res, 0)
})
t.Run("non-admin can update without speedId and inPort", func(t *testing.T) {
@@ -8,6 +8,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"sort"
"strings"
"sync"
"testing"
@@ -538,6 +539,134 @@ func TestBatchAssignInsertRollbackWhenLimiterDispatchFailsContract(t *testing.T)
}
}
func TestTunnelUpdateRecoversFromAddressInUseContract(t *testing.T) {
secret := "contract-jwt-secret"
router, r := setupContractRouter(t, secret)
server := httptest.NewServer(router)
defer server.Close()
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "tunnel-bind-retry", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, r, "tunnel-bind-retry")
if err := r.DB().Exec(`
INSERT INTO node(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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "tunnel-bind-entry", "tunnel-bind-entry-secret", "10.41.0.1", "10.41.0.1", "", "43000-43010", "", "v1", 1, 1, 1, now, now, 1, "10.41.0.1", "[::]", 0).Error; err != nil {
t.Fatalf("insert entry node: %v", err)
}
entryNodeID := mustLastInsertID(t, r, "tunnel-bind-entry")
if err := r.DB().Exec(`
INSERT INTO node(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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "tunnel-bind-exit", "tunnel-bind-exit-secret", "10.41.0.2", "10.41.0.2", "", "43100-43110", "eth0", "v1", 1, 1, 1, now, now, 1, "10.41.0.9", "[::]", 0).Error; err != nil {
t.Fatalf("insert exit node: %v", err)
}
exitNodeID := mustLastInsertID(t, r, "tunnel-bind-exit")
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 43001, 'round', 1, 'tls')
`, tunnelID, entryNodeID).Error; err != nil {
t.Fatalf("insert entry chain_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol, connect_ip)
VALUES(?, 3, ?, 43101, 'round', 1, 'tls', ?)
`, tunnelID, exitNodeID, "10.41.0.99").Error; err != nil {
t.Fatalf("insert exit chain_tunnel: %v", err)
}
var commandMu sync.Mutex
commandCounts := map[string]int{}
var addServiceAddrs []string
stopEntry := startMockNodeSessionWithCommandRecorder(t, server.URL, "tunnel-bind-entry-secret", func(cmdType string, data json.RawMessage) (bool, string) {
commandMu.Lock()
defer commandMu.Unlock()
commandCounts["entry:"+strings.ToLower(strings.TrimSpace(cmdType))]++
return false, ""
})
defer stopEntry()
stopExit := startMockNodeSessionWithCommandRecorder(t, server.URL, "tunnel-bind-exit-secret", func(cmdType string, data json.RawMessage) (bool, string) {
key := "exit:" + strings.ToLower(strings.TrimSpace(cmdType))
commandMu.Lock()
commandCounts[key]++
attempt := commandCounts[key]
if strings.EqualFold(strings.TrimSpace(cmdType), "AddService") {
var services []map[string]interface{}
if err := json.Unmarshal(data, &services); err == nil {
for _, svc := range services {
if addr, _ := svc["addr"].(string); strings.TrimSpace(addr) != "" {
addServiceAddrs = append(addServiceAddrs, addr)
}
}
}
}
commandMu.Unlock()
if strings.EqualFold(strings.TrimSpace(cmdType), "AddService") && attempt == 1 {
return true, "listen tcp 10.41.0.99:43101: bind: address already in use"
}
return false, ""
})
defer stopExit()
waitNodeStatus(t, r, entryNodeID, 1)
waitNodeStatus(t, r, exitNodeID, 1)
payload := map[string]interface{}{
"id": tunnelID,
"name": "tunnel-bind-retry",
"type": 2,
"flow": 99999,
"trafficRatio": 1.0,
"status": 1,
"inNodeId": []map[string]interface{}{
{"nodeId": entryNodeID, "protocol": "tls", "strategy": "round"},
},
"chainNodes": []interface{}{},
"outNodeId": []map[string]interface{}{
{"nodeId": exitNodeID, "protocol": "tls", "strategy": "round", "port": 43101, "connectIp": "10.41.0.99"},
},
}
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(body))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCode(t, res, 0)
commandMu.Lock()
defer commandMu.Unlock()
if commandCounts["exit:addservice"] != 2 {
t.Fatalf("expected exit AddService twice, got %d (%v)", commandCounts["exit:addservice"], sortedCommandCounts(commandCounts))
}
if commandCounts["exit:deleteservice"] == 0 {
t.Fatalf("expected exit DeleteService retry cleanup to run (%v)", sortedCommandCounts(commandCounts))
}
if len(addServiceAddrs) < 2 {
t.Fatalf("expected recorded AddService addresses, got %v", addServiceAddrs)
}
for _, addr := range addServiceAddrs {
if addr != "10.41.0.99:43101" {
t.Fatalf("expected connectIp to stay preferred in AddService addr, got %q", addr)
}
}
}
func startMockNodeSessionWithCommandFailures(t *testing.T, baseURL string, nodeSecret string, failCommands map[string]string) func() {
t.Helper()
@@ -633,3 +762,112 @@ func startMockNodeSessionWithCommandFailures(t *testing.T, baseURL string, nodeS
})
}
}
func startMockNodeSessionWithCommandRecorder(t *testing.T, baseURL string, nodeSecret string, onCommand func(cmdType string, data json.RawMessage) (bool, string)) func() {
t.Helper()
u, err := url.Parse(baseURL)
if err != nil {
t.Fatalf("parse provider url: %v", err)
}
if strings.EqualFold(u.Scheme, "https") {
u.Scheme = "wss"
} else {
u.Scheme = "ws"
}
u.Path = "/system-info"
q := u.Query()
q.Set("type", "1")
q.Set("secret", nodeSecret)
q.Set("version", "v1")
q.Set("http", "1")
q.Set("tls", "1")
q.Set("socks", "1")
u.RawQuery = q.Encode()
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
if err != nil {
t.Fatalf("dial mock node websocket: %v", err)
}
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for {
_, raw, readErr := conn.ReadMessage()
if readErr != nil {
return
}
plain := raw
var wrap struct {
Encrypted bool `json:"encrypted"`
Data string `json:"data"`
}
if err := json.Unmarshal(raw, &wrap); err == nil && wrap.Encrypted && strings.TrimSpace(wrap.Data) != "" {
crypto, cryptoErr := security.NewAESCrypto(nodeSecret)
if cryptoErr == nil {
if dec, decErr := crypto.Decrypt(wrap.Data); decErr == nil {
plain = []byte(dec)
}
}
}
var cmd struct {
Type string `json:"type"`
RequestID string `json:"requestId"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(plain, &cmd); err != nil {
continue
}
if strings.TrimSpace(cmd.RequestID) == "" {
continue
}
shouldFail := false
failMsg := ""
if onCommand != nil {
shouldFail, failMsg = onCommand(strings.TrimSpace(cmd.Type), cmd.Data)
}
respType := fmt.Sprintf("%sResponse", cmd.Type)
respPayload := map[string]interface{}{
"type": respType,
"success": !shouldFail,
"message": "OK",
"requestId": cmd.RequestID,
}
if shouldFail {
if strings.TrimSpace(failMsg) == "" {
failMsg = "mock command failed"
}
respPayload["message"] = failMsg
}
respBytes, err := json.Marshal(respPayload)
if err != nil {
continue
}
_ = conn.WriteMessage(websocket.TextMessage, respBytes)
}
}()
var stopOnce sync.Once
return func() {
stopOnce.Do(func() {
_ = conn.Close()
wg.Wait()
})
}
}
func sortedCommandCounts(counts map[string]int) []string {
items := make([]string, 0, len(counts))
for key, value := range counts {
items = append(items, fmt.Sprintf("%s=%d", key, value))
}
sort.Strings(items)
return items
}
@@ -0,0 +1,28 @@
# 011 转发服务名升级兼容与节点滚动升级
## 目标
- 修复旧版本升级后编辑转发/隧道出现 `service not found`(service不存在)的问题。
- 在后端加入兼容自愈逻辑,允许旧命名与新命名共存过渡。
- 给出低风险节点升级顺序,避免一次性全量切换带来的中断。
## Checklist
- [x] 定位回归路径:服务名从 `forward_user_0` 迁移到真实 `user_tunnel_id` 后,与旧运行态不一致导致控制失败。
- [x] 在 `UpdateService` 的兼容路径加入旧服务清理后重建逻辑。
- [x] 在 `Pause/Resume` 控制路径加入首次 not found 后自愈重试逻辑。
- [x] 增加回归测试覆盖兼容行为。
- [x] 执行 `go-backend` 相关测试并记录结果。
- [x] 输出运维侧“后端先行 + agent 灰度升级 + 批量重部署”操作步骤。
## 变更说明(实施中)
- 后端控制面将在检测到升级期的服务名不一致时进行自动自愈,降低人工干预和手工重建成本。
## 测试记录
- 命令:`cd go-backend && go test ./internal/http/handler/...`
- 结果:通过。
## 运维升级顺序(推荐)
1. 先发布本次后端兼容补丁(无需等待所有 agent 同步升级)。
2. 按 10%-20% 灰度分批升级 agent(低风险节点 -> 非高峰节点 -> 全量)。
3. 每批升级后执行一次“转发批量重部署”,将运行态统一到新服务命名。
4. 观察日志中 `service .* not found` 是否清零,再推进下一批。
5. 全量稳定后保留兼容逻辑至少一个小版本周期,再评估收敛。
@@ -0,0 +1,158 @@
# Plan 012: 允许用户自定义转发入口端口(限制在节点端口范围内)
**Issue**: #268
**状态**: 已完成
## 背景
当前版本限制了普通用户自定义转发入口端口 (inPort) 的能力,导致:
- 用户迁移数据后无法保留原有端口配置
- 无法编辑转发配置
- 需要重建所有转发,操作繁琐
## 实现方案
允许用户和管理员自定义转发入口端口,但强制在节点端口设置的范围内。
### 默认行为
- 不填写端口 → 随机分配(在端口范围内)
- 填写端口 → 使用指定端口(需在范围内且不冲突)
---
## 任务清单
### 1. 后端修改
- [x] **1.1 移除非管理员 inPort 权限限制**
- 文件: `go-backend/internal/http/handler/mutations.go`
- 位置: `forwardCreate` 函数 (约 L1156-1167)
- 位置: `forwardUpdate` 函数 (约 L1279-1291)
- 操作: 删除 `roleID != 0` 时阻止 inPort 设置的逻辑
- 状态: 代码中已无 inPort 权限限制
- [x] **1.2 添加本地节点端口范围验证函数**
- 文件: `go-backend/internal/http/handler/mutations.go`
- 新增函数: `validateLocalNodePort(node *nodeRecord, port int) error`
- 逻辑: 使用 `parsePortRangeSpec` 解析端口范围,验证 port 是否在范围内
- 状态: 函数已存在于 L3517-3533
- [x] **1.3 修改 forwardCreate 端口验证**
- 文件: `go-backend/internal/http/handler/mutations.go`
- 位置: `forwardCreate` 中 entry nodes 遍历处 (约 L1188-1197)
- 操作:
- 对远程节点使用现有 `validateRemoteNodePort`
- 对本地节点使用新的 `validateLocalNodePort`
- 若用户指定的端口超出节点范围,返回错误提示
- 状态: 已实现
- [x] **1.4 修改 forwardUpdate 端口验证**
- 文件: `go-backend/internal/http/handler/mutations.go`
- 位置: `forwardUpdate` 中 entry nodes 遍历处 (约 L1326-1335)
- 操作: 同 1.3,添加本地节点端口范围验证
- 状态: 已实现
- [x] **1.5 `ListUserAccessibleTunnels` 添加端口范围信息**
- 文件: `go-backend/internal/store/repo/repository.go`
- 位置: L751-775
- 操作:
- 查询隧道关联的入口节点 (通过 `chain_tunnel` 表 `chain_type=1`)
- 获取入口节点的端口范围 (`node.port` 字段)
- 使用 `parsePortRangeSpec` 解析并计算 min/max
- 在返回的 map 中添加 `portRangeMin` 和 `portRangeMax` 字段
- 状态: 已实现
- [x] **1.6 `ListEnabledTunnelSummaries` 添加端口范围信息**
- 文件: `go-backend/internal/store/repo/repository.go`
- 位置: L777-796
- 操作: 同 1.5,为管理员视图也提供端口范围信息
- 状态: 已实现
### 2. 前端修改
- [x] **2.1 为所有用户显示 inPort 输入框**
- 文件: `vite-frontend/src/pages/forward.tsx`
- 位置: 约 L4350-4369
- 操作: 移除 `{isAdmin && (` 条件包装,改为所有用户可见
- 状态: 已实现
- [x] **2.2 提交时包含 inPort(非仅管理员)**
- 文件: `vite-frontend/src/pages/forward.tsx`
- 位置: `handleSave` 函数 (约 L1435, L1447)
- 操作: 移除 `...(isAdmin ? { inPort: form.inPort } : {})` 条件,直接包含 inPort
- 状态: 已实现
- [x] **2.3 更新 Tunnel 接口添加 portRangeMin/Max**
- 文件: `vite-frontend/src/pages/forward.tsx`
- 位置: L123-131
- 操作: 添加 `portRangeMin?: number; portRangeMax?: number;`
- 状态: 已实现
- [x] **2.4 inPort 输入框显示端口范围提示**
- 文件: `vite-frontend/src/pages/forward.tsx`
- 位置: L4350-4369
- 操作:
- 基于 `form.tunnelId` 获取当前隧道的端口范围
- 在 Input 的 `description` 中显示提示,如: `"指定入口端口,留空自动分配 (允许范围: 10000-20000)"`
- 状态: 已实现
- [x] **2.5 前端端口范围验证**
- 文件: `vite-frontend/src/pages/forward.tsx`
- 位置: 验证函数 (L1271-1279)
- 操作: 前端也做范围预检查,超出范围时显示错误
- 状态: 已实现并修复语法错误
### 3. 测试修改
- [x] **3.1 更新权限测试**
- 文件: `go-backend/tests/contract/forward_contract_test.go`
- 位置: L1001-1119
- 操作:
- 修改 "non-admin cannot set inPort" 测试为允许设置
- 新增 "non-admin inPort within range" 测试(通过)
- 新增 "non-admin inPort out of range" 测试(失败)
- 状态: 已更新
- [x] **3.2 新增端口范围验证测试**
- 文件: `go-backend/tests/contract/forward_contract_test.go`
- 操作:
- 测试本地节点端口范围验证
- 测试远程节点端口范围验证(已有 `validateRemoteNodePort` 相关测试可参考)
- 状态: 已添加
---
## 关键代码位置
| 功能 | 文件 | 行号 |
|------|------|------|
| 前端 inPort 输入框 | `vite-frontend/src/pages/forward.tsx` | L4350-4369 |
| 前端提交条件 | `vite-frontend/src/pages/forward.tsx` | L1435, L1447 |
| 后端创建权限检查 | `go-backend/internal/http/handler/mutations.go` | L1156-1167 |
| 后端更新权限检查 | `go-backend/internal/http/handler/mutations.go` | L1279-1291 |
| 远程节点端口验证 | `go-backend/internal/http/handler/federation.go` | L562-574 |
| 本地节点端口验证 | `go-backend/internal/http/handler/mutations.go` | L3517-3533 |
| 端口范围解析 | `go-backend/internal/store/repo/repository_mutations.go` | L1370-1412 |
| 用户隧道列表 | `go-backend/internal/store/repo/repository.go` | L751-775 |
| 管理员隧道列表 | `go-backend/internal/store/repo/repository.go` | L777-796 |
| 合约测试 | `go-backend/tests/contract/forward_contract_test.go` | L1001-1119 |
---
## 验收标准
1. ✅ 普通用户可以在创建转发时指定 inPort
2. ✅ 普通用户可以在编辑转发时修改 inPort
3. ✅ 指定的端口必须在节点端口范围内,否则返回错误
4. ✅ 留空 inPort 时行为不变(自动分配)
5. ✅ 前端显示端口范围提示
6. ✅ 所有合约测试通过
---
## 实施总结
该计划的大部分代码已在之前的开发中实现。本次实施主要完成了以下工作:
1. **修复前端验证代码语法错误** - `forward.tsx` 中 `validateForm` 函数的端口范围验证代码存在语法错误,已修复
2. **更新测试用例** - 将原本期望权限拒绝的测试改为端口范围验证测试,并修正了测试中使用的端口号
@@ -0,0 +1,13 @@
# 013 Forward Delete NotFound Compatibility Fix
## Checklist
- [x] Confirm forward update failure path caused by delete fallback short-circuiting on the first not-found service name.
- [x] Update forward service deletion logic to continue across all candidate runtime names until one is actually deleted or every candidate is exhausted.
- [x] Add regression tests covering mixed not-found and legacy-name delete recovery during forward control/update flows.
- [x] Run focused backend handler tests and record the result.
## Test Record
- Command: `cd go-backend && go test ./internal/http/handler/...`
- Result: passed.
@@ -0,0 +1,13 @@
# 014 Forward Port Occupancy Validation
## Checklist
- [x] Confirm current forward create/update only validates node port range and misses DB-backed occupancy checks for local nodes.
- [x] Add shared forward port occupancy validation for create/update paths before runtime dispatch.
- [x] Add focused tests covering create/update validation when another forward already uses the same node+port.
- [x] Run focused backend handler tests and record the result.
## Test Record
- Command: `cd go-backend && go test ./internal/http/handler/...`
- Result: passed.
@@ -0,0 +1,13 @@
# 015 Forward Runtime Port Residual Cleanup
## Checklist
- [x] Confirm 2.1.6 used service names with `_0` runtime base while later versions may target resolved `user_tunnel_id`, leaving old runtime services behind after direct upgrade.
- [x] Extend self-occupy recovery to clean residual candidate service names and retry update/add when the port is only occupied by self-owned legacy runtime services.
- [x] Add regression tests covering address-in-use recovery with legacy `_0` runtime residue.
- [x] Run focused backend handler tests and record the result.
## Test Record
- Command: `cd go-backend && go test ./internal/http/handler/...`
- Result: passed.
@@ -0,0 +1,31 @@
# 016 Tunnel Runtime Bind Conflict Retry
## Checklist
- [x] Confirm tunnel `connectIp` precedence remains `connectIp > node tcp_listen_addr` for runtime service listen address.
- [x] Add tunnel runtime `address already in use` recovery that deletes the stale service and retries `AddService`.
- [x] Keep non-bind failures unchanged and avoid altering tunnel chain apply semantics.
- [x] Add regression tests for tunnel service address precedence and bind-conflict retry behavior.
- [x] Run focused backend handler tests and record the result.
- [ ] Add a contract test that simulates node-side `address already in use` during tunnel update and verifies retry success.
- [ ] Investigate whether forward update `address already in use` reports are only tunnel-redeploy linkage or also an independent forward path.
- [x] Add a contract test that simulates node-side `address already in use` during tunnel update and verifies retry success.
- [x] Investigate whether forward update `address already in use` reports are only tunnel-redeploy linkage or also an independent forward path.
## Test Record
- Command: `cd go-backend && go test ./internal/http/handler/...`
- Result: passed.
- Command: `cd go-backend && go test ./tests/contract/... -run 'TestTunnelUpdateRecoversFromAddressInUseContract|TestForwardCreateRollbackWhenServiceDispatchReturnsAddressInUseContract|TestForwardUpdateIgnoresDeletedSpeedLimitContract'`
- Result: passed.
- Command: `cd go-backend && go test ./tests/contract/... -run 'TestForwardUpdateRecoversFromAddressInUseContract|TestTunnelUpdateRecoversFromAddressInUseContract'`
- Result: passed.
- Command: `cd go-backend && go test ./internal/http/handler/... && go test ./tests/contract/... -run 'TestForwardUpdateRecoversFromAddressInUseContract|TestTunnelUpdateRecoversFromAddressInUseContract'`
- Result: passed.
## Investigation Note
- Forward update still has its own independent `address already in use` recovery path in `syncForwardServicesWithWarnings` / `rebindForwardServiceOnSelfOccupiedPort`; tunnel update linkage is not the only possible source of the symptom.
- Tunnel update also triggers downstream forward `UpdateService` for bound forwards, so users can still observe the same error around a tunnel edit even when the failing runtime is on the tunnel side.
- Real node output can collapse spaces into variants like `address alreadyin use` / `cannotassignrequestedaddress`; bind-conflict detection now normalizes whitespace before classifying the error.
- Forward self-heal cleanup now deletes every candidate runtime name variant instead of stopping after the first successful delete, which avoids leaving sibling `_tcp`/`_udp` services behind to keep the port occupied.
+16
View File
@@ -0,0 +1,16 @@
# 017 PR 284 UI Follow-up Fixes
## Checklist
- [x] Review the current frontend route and component state related to PR 284 follow-up fixes.
- [x] Restore the intended H5 simple-layout route behavior for panel sharing.
- [x] Improve date text parsing to support separator-free and flexible formats without ambiguous fallbacks.
- [x] Add config-page back navigation with a safer history fallback and shared icon usage.
- [x] Run focused frontend verification for the updated files and record the result.
## Test Record
- Command: `cd vite-frontend && npm install`
- Result: passed.
- Command: `cd vite-frontend && npm run build`
- Result: passed.
+1 -1
View File
@@ -188,7 +188,7 @@ function App() {
/>
<Route
element={
<ProtectedRoute>
<ProtectedRoute useSimpleLayout={true}>
<PanelSharingPage />
</ProtectedRoute>
}
+26
View File
@@ -259,3 +259,29 @@ export const SettingsIcon = ({
/>
</svg>
);
export const BackIcon = ({
size = 24,
width,
height,
...props
}: IconSvgProps) => (
<svg
aria-hidden="true"
focusable="false"
height={size || height}
role="presentation"
viewBox="0 0 24 24"
width={size || width}
{...props}
>
<path
d="M15 19l-7-7 7-7"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
</svg>
);
+1 -1
View File
@@ -78,7 +78,7 @@ export default function AdminLayout({
},
{
path: "/forward",
label: "转发",
label: "规则",
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path
+2 -7
View File
@@ -2,6 +2,7 @@ import React from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "@/shadcn-bridge/heroui/button";
import { BackIcon } from "@/components/icons";
import { BrandLogo } from "@/components/brand-logo";
import { siteConfig } from "@/config/site";
import { useScrollTopOnPathChange } from "@/hooks/useScrollTopOnPathChange";
@@ -25,13 +26,7 @@ export default function H5SimpleLayout({
<header className="bg-white dark:bg-black shadow-sm border-b border-gray-200 dark:border-gray-600 h-14 safe-top flex-shrink-0 flex items-center justify-between px-4 relative z-10">
<div className="flex items-center gap-2">
<Button isIconOnly size="sm" variant="light" onPress={handleBack}>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path
clipRule="evenodd"
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
fillRule="evenodd"
/>
</svg>
<BackIcon className="w-5 h-5" />
</Button>
<BrandLogo size={20} />
<h1 className="text-sm font-bold text-foreground">
+1 -1
View File
@@ -33,7 +33,7 @@ export default function H5Layout({ children }: { children: React.ReactNode }) {
},
{
path: "/forward",
label: "转发",
label: "规则",
icon: (
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path
+29 -4
View File
@@ -26,7 +26,7 @@ import {
updateAnnouncement,
type AnnouncementData,
} from "@/api";
import { SettingsIcon } from "@/components/icons";
import { BackIcon, SettingsIcon } from "@/components/icons";
import { isAdmin } from "@/utils/auth";
import { getCachedConfigs, configCache, updateSiteConfig } from "@/config/site";
import {
@@ -119,8 +119,8 @@ const CONFIG_ITEMS: ConfigItem[] = [
},
{
key: "forward_compact_mode",
label: "转发页面精简模式",
description: "开启后,转发页面列表使用 2.1.6-alpha8 样式(全局配置)",
label: "规则页面精简模式",
description: "开启后,规则页面列表使用 2.1.6-alpha8 样式(全局配置)",
type: "switch",
},
{
@@ -153,7 +153,7 @@ const BACKUP_TYPE_OPTIONS = [
{ value: "users", label: "用户" },
{ value: "nodes", label: "节点" },
{ value: "tunnels", label: "隧道" },
{ value: "forwards", label: "转发" },
{ value: "forwards", label: "规则" },
{ value: "userTunnels", label: "用户隧道权限" },
{ value: "speedLimits", label: "限速规则" },
{ value: "tunnelGroups", label: "隧道分组" },
@@ -234,6 +234,21 @@ export default function ConfigPage() {
Partial<Record<BrandPreviewKey, boolean>>
>({});
const canGoBack =
typeof window !== "undefined" &&
typeof window.history.state?.idx === "number" &&
window.history.state.idx > 0;
const handleBack = () => {
if (canGoBack) {
navigate(-1);
return;
}
navigate("/profile", { replace: true });
};
// 权限检查
useEffect(() => {
if (!isAdmin()) {
@@ -846,6 +861,16 @@ export default function ConfigPage() {
<div className="p-6 max-w-4xl mx-auto">
{/* 页面标题 */}
<div className="flex items-center gap-3 mb-6">
<Button
isIconOnly
aria-label="返回上一页"
className="min-w-0 w-9 h-9"
size="sm"
variant="flat"
onPress={handleBack}
>
<BackIcon className="w-5 h-5" />
</Button>
<SettingsIcon className="w-8 h-8 text-primary" />
<div>
<h1 className="text-2xl font-bold">网站配置</h1>
+8 -8
View File
@@ -616,7 +616,7 @@ export default function DashboardPage() {
</svg>
}
iconClassName="bg-purple-100 dark:bg-purple-500/20"
title="转发配额"
title="规则配额"
value={formatNumber(userInfo.num || 0)}
/>
@@ -650,7 +650,7 @@ export default function DashboardPage() {
</svg>
}
iconClassName="bg-orange-100 dark:bg-orange-500/20"
title="已用转发"
title="已用规则"
value={forwardList.length}
/>
</div>
@@ -753,7 +753,7 @@ export default function DashboardPage() {
</div>
<div>
<p className="text-sm text-default-600 mb-1">
转发配额
规则配额
</p>
<p className="font-semibold text-foreground">
{formatNumber(tunnel.num)}
@@ -761,7 +761,7 @@ export default function DashboardPage() {
</div>
<div>
<p className="text-sm text-default-600 mb-1">
已用转发
已用规则
</p>
<p className="font-semibold text-foreground">
{getTunnelUsedForwards(tunnel.tunnelId)}
@@ -784,7 +784,7 @@ export default function DashboardPage() {
</Card>
)}
{/* 转发配置 */}
{/* 规则配置 */}
<Card className="border border-gray-200 dark:border-default-200 shadow-md">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
@@ -801,7 +801,7 @@ export default function DashboardPage() {
/>
</svg>
<h2 className="text-lg lg:text-xl font-semibold text-foreground">
转发配置
规则配置
</h2>
<span className="px-2 py-1 bg-default-100 dark:bg-default-50 text-default-600 rounded-full text-xs">
{forwardList.length}
@@ -825,7 +825,7 @@ export default function DashboardPage() {
strokeWidth={1.5}
/>
</svg>
<p className="text-default-500">暂无转发配置</p>
<p className="text-default-500">暂无规则配置</p>
</div>
) : (
<div className="space-y-4">
@@ -839,7 +839,7 @@ export default function DashboardPage() {
{group.tunnelName}
</h3>
<span className="px-2 py-1 bg-primary-100 dark:bg-primary-500/20 text-primary-700 dark:text-primary-300 rounded-md text-sm">
{group.forwards.length} 个转发
{group.forwards.length} 个规则
</span>
</div>
+197 -145
View File
@@ -128,6 +128,8 @@ interface Tunnel {
inNodeId?: Array<{ nodeId: number }>;
inNodePortSta?: number;
inNodePortEnd?: number;
portRangeMin?: number;
portRangeMax?: number;
}
interface Node {
@@ -675,11 +677,34 @@ export default function ForwardPage() {
return false;
}
const currentTunnel = allTunnels.find((tunnel) => tunnel.id === form.tunnelId);
const currentTunnel = allTunnels.find(
(tunnel) => tunnel.id === form.tunnelId,
);
return (currentTunnel?.inNodeId?.length || 0) > 1;
}, [allTunnels, form.tunnelId]);
const currentTunnelPortRange = useMemo(() => {
if (!form.tunnelId) {
return null;
}
const currentTunnel = allTunnels.find(
(tunnel) => tunnel.id === form.tunnelId,
);
if (
currentTunnel?.portRangeMin &&
currentTunnel?.portRangeMax &&
currentTunnel.portRangeMin > 0 &&
currentTunnel.portRangeMax > 0
) {
return { min: currentTunnel.portRangeMin, max: currentTunnel.portRangeMax };
}
return null;
}, [allTunnels, form.tunnelId]);
useEffect(() => {
return () => {
diagnosisAbortRef.current?.abort();
@@ -1174,7 +1199,7 @@ export default function ForwardPage() {
saveOrder(FORWARD_ORDER_KEY, order);
}
} else {
toast.error(forwardsRes.msg || "获取转发列表失败");
toast.error(forwardsRes.msg || "获取规则列表失败");
}
if (tunnelsRes.code === 0) {
@@ -1256,21 +1281,18 @@ export default function ForwardPage() {
const newErrors: { [key: string]: string } = {};
if (!form.name.trim()) {
newErrors.name = "请输入转发名称";
newErrors.name = "请输入规则名称";
} else if (form.name.length < 2 || form.name.length > 50) {
newErrors.name = "转发名称长度应在2-50个字符之间";
newErrors.name = "规则名称长度应在2-50个字符之间";
}
if (!form.tunnelId) {
newErrors.tunnelId = "请选择关联隧道";
}
// 验证入口端口(可选,如果填写则验证)
if (form.inPort !== null && form.inPort !== undefined) {
const port = Number(form.inPort);
if (isNaN(port) || port < 1 || port > 65535) {
newErrors.inPort = "端口必须在 1-65535 之间";
if (form.inPort !== null && form.inPort !== undefined && form.inPort > 0 && currentTunnelPortRange) {
if (form.inPort < currentTunnelPortRange.min || form.inPort > currentTunnelPortRange.max) {
newErrors.inPort = `端口 ${currentTunnelPortRange.min}-${currentTunnelPortRange.max} 超出允许范围`;
}
}
@@ -1308,7 +1330,7 @@ export default function ForwardPage() {
return Object.keys(newErrors).length === 0;
};
// 新增转发
// 新增规则
const handleAdd = () => {
setIsEdit(false);
setInIpTouched(false);
@@ -1326,7 +1348,7 @@ export default function ForwardPage() {
setModalOpen(true);
};
// 编辑转发
// 编辑规则
const handleEdit = (forward: Forward) => {
setIsEdit(true);
setInIpTouched(false);
@@ -1352,7 +1374,7 @@ export default function ForwardPage() {
setDeleteModalOpen(true);
};
// 确认删除转发
// 确认删除规则
const confirmDelete = async () => {
if (!forwardToDelete) return;
@@ -1367,7 +1389,7 @@ export default function ForwardPage() {
} else {
// 删除失败,询问是否强制删除
const confirmed = window.confirm(
`常规删除失败:${res.msg || "删除失败"}\n\n是否需要强制删除?\n\n⚠️ 注意:强制删除不会去验证节点端是否已经删除对应的转发服务。`,
`常规删除失败:${res.msg || "删除失败"}\n\n是否需要强制删除?\n\n⚠️ 注意:强制删除不会去验证节点端是否已经删除对应的规则服务。`,
);
if (confirmed) {
@@ -1430,11 +1452,11 @@ export default function ForwardPage() {
id: form.id,
name: form.name,
tunnelId: form.tunnelId,
...(isAdmin ? { inPort: form.inPort } : {}),
inPort: form.inPort,
...(inIpTouched ? { inIp: form.inIp || "" } : {}),
remoteAddr: processedRemoteAddr,
strategy: addressCount > 1 ? form.strategy : "fifo",
...(isAdmin ? { speedId: normalizedSpeedId } : {}),
speedId: normalizedSpeedId,
};
res = await updateForward(updateData);
@@ -1442,13 +1464,12 @@ export default function ForwardPage() {
const createData = {
name: form.name,
tunnelId: form.tunnelId,
...(isAdmin ? { inPort: form.inPort } : {}),
inPort: form.inPort,
inIp: form.inIp || undefined,
remoteAddr: processedRemoteAddr,
strategy: addressCount > 1 ? form.strategy : "fifo",
...(isAdmin ? { speedId: normalizedSpeedId } : {}),
speedId: normalizedSpeedId,
};
res = await createForward(createData);
}
@@ -1489,7 +1510,7 @@ export default function ForwardPage() {
// 处理服务开关
const handleServiceToggle = async (forward: Forward) => {
if (forward.status !== 1 && forward.status !== 0) {
toast.error("转发状态异常,无法操作");
toast.error("规则状态异常,无法操作");
return;
}
@@ -1514,7 +1535,7 @@ export default function ForwardPage() {
if (res.code === 0) {
toast.success(targetState ? "服务已启动" : "服务已暂停");
// 更新转发状态
// 更新规则状态
setForwards((prev) =>
prev.map((f) =>
f.id === forward.id ? { ...f, status: targetState ? 1 : 0 } : f,
@@ -1540,7 +1561,7 @@ export default function ForwardPage() {
}
};
// 诊断转发
// 诊断规则
const handleDiagnose = async (forward: Forward) => {
diagnosisAbortRef.current?.abort();
const abortController = new AbortController();
@@ -1795,7 +1816,7 @@ export default function ForwardPage() {
await copyToClipboard(allAddresses, "所有地址");
};
// 导出转发数据
// 导出规则数据
const handleExport = () => {
setSelectedTunnelForExport(null);
setExportData("");
@@ -1813,13 +1834,13 @@ export default function ForwardPage() {
setExportLoading(true);
try {
// 获取要导出的转发列表
// 获取要导出的规则列表
const forwardsToExport = sortedForwards.filter(
(forward) => forward.tunnelId === selectedTunnelForExport,
);
if (forwardsToExport.length === 0) {
toast.error("所选隧道没有转发数据");
toast.error("所选隧道没有规则数据");
setExportLoading(false);
return;
@@ -1842,10 +1863,10 @@ export default function ForwardPage() {
// 复制导出数据
const copyExportData = async () => {
await copyToClipboard(exportData, "转发数据");
await copyToClipboard(exportData, "规则数据");
};
// 导入转发数据
// 导入规则数据
const handleImport = () => {
setImportData("");
setImportResults([]);
@@ -1967,7 +1988,7 @@ export default function ForwardPage() {
{
line,
success: false,
message: "格式错误:需要至少包含目标地址和转发名称",
message: "格式错误:需要至少包含目标地址和规则名称",
},
...prev,
]);
@@ -1981,7 +2002,7 @@ export default function ForwardPage() {
{
line,
success: false,
message: "目标地址和转发名称不能为空",
message: "目标地址和规则名称不能为空",
},
...prev,
]);
@@ -2187,15 +2208,27 @@ export default function ForwardPage() {
}
}
const oldIndex = forwardOrder.indexOf(activeId);
const newIndex = forwardOrder.indexOf(overId);
let oldIndex: number;
let newIndex: number;
let currentOrder: number[];
if (compactMode) {
currentOrder = sortedForwards.map((f) => f.id);
oldIndex = currentOrder.indexOf(activeId);
newIndex = currentOrder.indexOf(overId);
} else {
currentOrder = forwardOrder;
oldIndex = forwardOrder.indexOf(activeId);
newIndex = forwardOrder.indexOf(overId);
}
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
const newOrder = arrayMove(forwardOrder, oldIndex, newIndex);
const newOrder = arrayMove(currentOrder, oldIndex, newIndex);
setForwardOrder(newOrder);
saveOrder(FORWARD_ORDER_KEY, newOrder);
if (!compactMode) {
setForwardOrder(newOrder);
saveOrder(FORWARD_ORDER_KEY, newOrder);
}
// 持久化到数据库
try {
@@ -2383,7 +2416,7 @@ export default function ForwardPage() {
}),
);
// 根据排序顺序获取转发列表
// 根据排序顺序获取规则列表
const orderedForwards = useMemo((): Forward[] => {
// 确保 forwards 数组存在且有效
if (!forwards || forwards.length === 0) {
@@ -2418,7 +2451,7 @@ export default function ForwardPage() {
);
}
// 确保过滤后的转发列表有效
// 确保过滤后的规则列表有效
if (!filteredForwards || filteredForwards.length === 0) {
return [];
}
@@ -2452,7 +2485,7 @@ export default function ForwardPage() {
}
});
// 添加不在排序列表中的转发(新添加的)
// 添加不在排序列表中的规则(新添加的)
filteredForwards.forEach((forward) => {
if (!forwardOrder.includes(forward.id)) {
localSortedForwards.push(forward);
@@ -2493,6 +2526,10 @@ export default function ForwardPage() {
return;
}
if (forwards.length === 0) {
return;
}
if (!isSameGroupOrderMap(groupOrderMap, sanitizedGroupOrderMap)) {
setGroupOrderMap(sanitizedGroupOrderMap);
persistGroupOrderToLocal(sanitizedGroupOrderMap);
@@ -2512,6 +2549,7 @@ export default function ForwardPage() {
}, [
groupPreferenceHydrated,
tokenUserId,
forwards,
groupOrderMap,
sanitizedGroupOrderMap,
collapsedTunnelGroups,
@@ -2751,7 +2789,7 @@ export default function ForwardPage() {
<span className={titleClassName}>{tunnel.tunnelName}</span>
</div>
<div className="flex items-center gap-2">
<span className={countClassName}>{tunnel.items.length} 条转发</span>
<span className={countClassName}>{tunnel.items.length} 条规则</span>
<div
className="cursor-grab active:cursor-grabbing p-1 text-default-400 hover:text-default-600 transition-colors"
title="拖拽分组排序"
@@ -2774,7 +2812,7 @@ export default function ForwardPage() {
);
};
// 可拖拽的转发卡片组件
// 可拖拽的规则卡片组件
const SortableForwardCard = ({ forward }: { forward: Forward }) => {
const {
attributes,
@@ -3275,7 +3313,7 @@ export default function ForwardPage() {
);
};
// 渲染转发卡片
// 渲染规则卡片
const renderForwardCard = (forward: Forward, listeners?: any) => {
const statusDisplay = getStatusDisplay(forward.status);
const strategyDisplay = getStrategyDisplay(forward.strategy);
@@ -3568,7 +3606,7 @@ export default function ForwardPage() {
<div className="flex-1 max-w-sm flex items-center gap-2">
<SearchBar
isVisible={isSearchVisible}
placeholder="搜索转发名称、地址或用户名"
placeholder="搜索规则名称、地址或用户名"
value={searchKeyword}
onChange={setSearchKeyword}
onClose={() => setIsSearchVisible(false)}
@@ -3789,36 +3827,38 @@ export default function ForwardPage() {
sensors={sensors}
onDragEnd={handleDragEnd}
>
<Table
aria-label="全部转发列表"
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
}}
<SortableContext
items={sortableForwardIds}
strategy={verticalListSortingStrategy}
>
<TableHeader>
{selectMode && (
<TableColumn className="w-14">选择</TableColumn>
)}
<TableColumn className="w-10 pl-4" />
<TableColumn>用户</TableColumn>
<TableColumn>名称</TableColumn>
<TableColumn>隧道</TableColumn>
<TableColumn>入口</TableColumn>
<TableColumn>目标</TableColumn>
<TableColumn>策略</TableColumn>
<TableColumn>总流量</TableColumn>
<TableColumn>状态</TableColumn>
<TableColumn className="text-right">操作</TableColumn>
</TableHeader>
<TableBody emptyContent="暂无转发配置" items={sortedForwards}>
{(forward) => (
<SortableContext
key={forward.id}
items={sortableForwardIds}
strategy={verticalListSortingStrategy}
>
<Table
aria-label="全部规则列表"
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
}}
>
<TableHeader>
{selectMode && (
<TableColumn className="w-14">选择</TableColumn>
)}
<TableColumn className="w-10 pl-4" />
<TableColumn>用户</TableColumn>
<TableColumn>名称</TableColumn>
<TableColumn>隧道</TableColumn>
<TableColumn>入口</TableColumn>
<TableColumn>目标</TableColumn>
<TableColumn>策略</TableColumn>
<TableColumn>总流量</TableColumn>
<TableColumn>状态</TableColumn>
<TableColumn className="text-right">操作</TableColumn>
</TableHeader>
<TableBody
emptyContent="暂无规则配置"
items={sortedForwards}
>
{(forward) => (
<SortableCompactTableRow
formatFlow={formatFlow}
formatInAddress={formatInAddress}
@@ -3835,52 +3875,62 @@ export default function ForwardPage() {
showAddressModal={showAddressModal}
toggleSelect={toggleSelect}
/>
</SortableContext>
)}
</TableBody>
</Table>
)}
</TableBody>
</Table>
</SortableContext>
</DndContext>
</div>
) : (
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
<h3 className="text-xl font-medium text-foreground tracking-tight mb-2">
暂无转发配置
暂无规则配置
</h3>
<p className="text-default-500 text-sm max-w-xs mx-auto leading-relaxed">
还没有创建任何转发配置,点击上方按钮开始创建
还没有创建任何规则配置,点击上方按钮开始创建
</p>
</CardBody>
</Card>
)
) : sortedForwards.length > 0 ? (
<DndContext
collisionDetection={closestCenter}
sensors={sensors}
onDragEnd={handleDragEnd}
onDragStart={() => {}}
>
<SortableContext
items={sortableForwardIds}
strategy={rectSortingStrategy}
<>
<div className="flex items-center justify-between px-1 mb-3">
<span className="text-sm font-semibold text-foreground">
全部规则
</span>
<span className="text-xs text-default-600">
{sortedForwards.length} 条规则
</span>
</div>
<DndContext
collisionDetection={closestCenter}
sensors={sensors}
onDragEnd={handleDragEnd}
onDragStart={() => {}}
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{sortedForwards.map((forward) =>
forward && forward.id ? (
<SortableForwardCard key={forward.id} forward={forward} />
) : null,
)}
</div>
</SortableContext>
</DndContext>
<SortableContext
items={sortableForwardIds}
strategy={rectSortingStrategy}
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{sortedForwards.map((forward) =>
forward && forward.id ? (
<SortableForwardCard key={forward.id} forward={forward} />
) : null,
)}
</div>
</SortableContext>
</DndContext>
</>
) : (
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
<h3 className="text-xl font-medium text-foreground tracking-tight mb-2">
暂无转发配置
暂无规则配置
</h3>
<p className="text-default-500 text-sm max-w-xs mx-auto leading-relaxed">
还没有创建任何转发配置,点击上方按钮开始创建
还没有创建任何规则配置,点击上方按钮开始创建
</p>
</CardBody>
</Card>
@@ -3913,7 +3963,7 @@ export default function ForwardPage() {
)}
</div>
<span className="text-xs text-default-600">
{groupForwardCount} 条转发
{groupForwardCount} 条规则
</span>
</div>
@@ -3968,7 +4018,7 @@ export default function ForwardPage() {
onDragEnd={handleDragEnd}
>
<Table
aria-label={`${group.userName}-${tunnel.tunnelName}转发列表`}
aria-label={`${group.userName}-${tunnel.tunnelName}规则列表`}
className={`table-fixed ${FORWARD_GROUPED_TABLE_MIN_WIDTH_CLASS}`}
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
@@ -4042,7 +4092,7 @@ export default function ForwardPage() {
</TableColumn>
</TableHeader>
<TableBody
emptyContent="暂无转发配置"
emptyContent="暂无规则配置"
items={tunnel.items}
>
{(forward) => (
@@ -4094,10 +4144,10 @@ export default function ForwardPage() {
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
<h3 className="text-xl font-medium text-foreground tracking-tight mb-2">
暂无转发配置
暂无规则配置
</h3>
<p className="text-default-500 text-sm max-w-xs mx-auto leading-relaxed">
还没有创建任何转发配置,点击上方按钮开始创建
还没有创建任何规则配置,点击上方按钮开始创建
</p>
</CardBody>
</Card>
@@ -4129,7 +4179,7 @@ export default function ForwardPage() {
)}
</div>
<span className="text-xs text-default-600">
{groupForwardCount} 条转发
{groupForwardCount} 条规则
</span>
</div>
@@ -4214,10 +4264,10 @@ export default function ForwardPage() {
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
<h3 className="text-xl font-medium text-foreground tracking-tight mb-2">
暂无转发配置
暂无规则配置
</h3>
<p className="text-default-500 text-sm max-w-xs mx-auto leading-relaxed">
还没有创建任何转发配置,点击上方按钮开始创建
还没有创建任何规则配置,点击上方按钮开始创建
</p>
</CardBody>
</Card>
@@ -4237,10 +4287,10 @@ export default function ForwardPage() {
<>
<ModalHeader className="flex flex-col gap-1">
<h2 className="text-xl font-bold">
{isEdit ? "编辑转发" : "新增转发"}
{isEdit ? "编辑规则" : "新增规则"}
</h2>
<p className="text-small text-default-500">
{isEdit ? "修改现有转发配置的信息" : "创建新的转发配置"}
{isEdit ? "修改现有规则配置的信息" : "创建新的规则配置"}
</p>
</ModalHeader>
<ModalBody>
@@ -4248,8 +4298,8 @@ export default function ForwardPage() {
<Input
errorMessage={errors.name}
isInvalid={!!errors.name}
label="转发名称"
placeholder="请输入转发名称"
label="规则名称"
placeholder="请输入规则名称"
value={form.name}
variant="bordered"
onChange={(e) =>
@@ -4316,26 +4366,28 @@ export default function ForwardPage() {
))}
</Select>
{isAdmin && (
<Input
description="指定入口端口,留空则从节点可用端口中自动分配"
errorMessage={errors.inPort}
isInvalid={!!errors.inPort}
label="入口端口"
placeholder="留空则自动分配可用端口"
type="number"
value={form.inPort !== null ? form.inPort.toString() : ""}
variant="bordered"
onChange={(e) => {
const value = e.target.value;
<Input
description={
currentTunnelPortRange
? `指定入口端口,留空自动分配 (允许范围: ${currentTunnelPortRange.min}-${currentTunnelPortRange.max})`
: "指定入口端口,留空则从节点可用端口中自动分配"
}
errorMessage={errors.inPort}
isInvalid={!!errors.inPort}
label="入口端口"
placeholder="留空则自动分配可用端口"
type="number"
value={form.inPort !== null ? form.inPort.toString() : ""}
variant="bordered"
onChange={(e) => {
const value = e.target.value;
setForm((prev) => ({
...prev,
inPort: value ? parseInt(value) : null,
}));
}}
/>
)}
setForm((prev) => ({
...prev,
inPort: value ? parseInt(value) : null,
}));
}}
/>
<Select
description={
@@ -4353,10 +4405,10 @@ export default function ForwardPage() {
isCurrentTunnelMultiEntrance
? "多入口隧道使用节点默认IP"
: form.tunnelId
? currentTunnelIpOptions.length > 0
? "选择入口监听IP"
: "当前隧道入口节点暂无可选IP"
: "请先选择隧道"
? currentTunnelIpOptions.length > 0
? "选择入口监听IP"
: "当前隧道入口节点暂无可选IP"
: "请先选择隧道"
}
selectedKeys={[form.inIp || "__default__"]}
variant="bordered"
@@ -4425,7 +4477,7 @@ export default function ForwardPage() {
isLoading={submitLoading}
onPress={handleSubmit}
>
{isEdit ? "保存修改" : "创建转发"}
{isEdit ? "保存修改" : "创建规则"}
</Button>
</ModalFooter>
</>
@@ -4450,14 +4502,14 @@ export default function ForwardPage() {
</ModalHeader>
<ModalBody>
<p className="text-default-600">
确定要删除转发{" "}
确定要删除规则{" "}
<span className="font-semibold text-foreground">
&quot;{forwardToDelete?.name}&quot;
</span>{" "}
吗?
</p>
<p className="text-small text-default-500 mt-2">
此操作无法撤销,删除后该转发将永久消失。
此操作无法撤销,删除后该规则将永久消失。
</p>
</ModalBody>
<ModalFooter>
@@ -4532,9 +4584,9 @@ export default function ForwardPage() {
>
<ModalContent>
<ModalHeader className="flex flex-col gap-1">
<h2 className="text-xl font-bold">导出转发数据</h2>
<h2 className="text-xl font-bold">导出规则数据</h2>
<p className="text-small text-default-500">
格式:目标地址|转发名称|入口端口
格式:目标地址|规则名称|入口端口
</p>
</ModalHeader>
<ModalBody className="pb-6">
@@ -4684,11 +4736,11 @@ export default function ForwardPage() {
>
<ModalContent>
<ModalHeader className="flex flex-col gap-1">
<h2 className="text-xl font-bold">导入转发数据</h2>
<h2 className="text-xl font-bold">导入规则数据</h2>
{importFormat === "flvx" ? (
<>
<p className="text-small text-default-500">
格式:目标地址|转发名称|入口端口,每行一个,入口端口留空将自动分配可用端口
格式:目标地址|规则名称|入口端口,每行一个,入口端口留空将自动分配可用端口
</p>
<p className="text-small text-default-400">
目标地址支持单个地址(如:example.com:8080)或多个地址用逗号分隔(如:3.3.3.3:3,4.4.4.4:4)
@@ -4772,8 +4824,8 @@ export default function ForwardPage() {
minRows={8}
placeholder={
importFormat === "flvx"
? "请输入要导入的转发数据,格式:目标地址|转发名称|入口端口"
: '请输入ny格式数据,每行一个JSON对象,如:{"dest":["1.2.3.4:80"],"listen_port":8080,"name":"转发1"};listen_port可省略自动分配'
? "请输入要导入的规则数据,格式:目标地址|规则名称|入口端口"
: '请输入ny格式数据,每行一个JSON对象,如:{"dest":["1.2.3.4:80"],"listen_port":8080,"name":"规则1"};listen_port可省略自动分配'
}
value={importData}
variant="flat"
@@ -4913,7 +4965,7 @@ export default function ForwardPage() {
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1 bg-content1 border-b border-divider">
<h2 className="text-xl font-bold">转发诊断结果</h2>
<h2 className="text-xl font-bold">规则诊断结果</h2>
{currentDiagnosisForward && (
<div className="flex items-center gap-2 min-w-0">
<span className="text-small text-default-500 truncate flex-1 min-w-0">
@@ -4925,7 +4977,7 @@ export default function ForwardPage() {
size="sm"
variant="flat"
>
转发服务
规则服务
</Chip>
</div>
)}
@@ -5484,7 +5536,7 @@ export default function ForwardPage() {
<ModalHeader>确认删除</ModalHeader>
<ModalBody>
<p>
确定要删除选中的 {selectedIds.size} 项转发吗?此操作不可撤销。
确定要删除选中的 {selectedIds.size} 项规则吗?此操作不可撤销。
</p>
</ModalBody>
<ModalFooter>
@@ -5515,7 +5567,7 @@ export default function ForwardPage() {
<ModalHeader>隧道</ModalHeader>
<ModalBody>
<p className="mb-4">
将选中的 {selectedIds.size} 项转发迁移到新隧道:
将选中的 {selectedIds.size} 项规则迁移到新隧道:
</p>
<Select
label="目标隧道"
+1 -1
View File
@@ -1563,7 +1563,7 @@ export default function NodePage() {
/>
<Input
description="可选:不带协议、不带端口。至少填写一个 IPv4/IPv6/域名"
description="可选:不带协议、不带端口。建议在 IPv4 和 IPv6 都未填写时使用。至少填写一个 IPv4/IPv6/域名"
errorMessage={errors.serverHost}
isInvalid={!!errors.serverHost}
label="服务器域名/主机名"
+5 -17
View File
@@ -9,6 +9,7 @@ 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 { BackIcon } from "@/components/icons";
import {
type UpdateReleaseChannel,
getUpdateReleaseChannel,
@@ -129,7 +130,7 @@ export const SettingsPage = () => {
);
if (response.code === 0) {
toast.success(`转发页面精简模式已${enabled ? "开启" : "关闭"}`);
toast.success(`规则页面精简模式已${enabled ? "开启" : "关闭"}`);
window.dispatchEvent(
new CustomEvent("forwardCompactModeChanged", {
detail: { enabled },
@@ -168,20 +169,7 @@ export const SettingsPage = () => {
variant="light"
onPress={() => navigate(-1)}
>
<svg
aria-hidden="true"
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M15 19l-7-7 7-7"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
<BackIcon className="w-5 h-5" />
</Button>
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
面板设置
@@ -232,10 +220,10 @@ export const SettingsPage = () => {
<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 样式。
开启后,规则页面列表使用 2.1.6-alpha8 样式。{" "}
</p>
</div>
<Switch
+255 -233
View File
@@ -1435,7 +1435,7 @@ export default function TunnelPage() {
</div>
<Textarea
description="支持多个IP,每行一个地址,为空时使用入口节点ip"
description="入口IP由系统自动从入口节点采集,无需手动填写。支持多个IP,每行一个地址,留空则使用入口节点IP"
errorMessage={errors.inIp}
isInvalid={!!errors.inIp}
label="入口IP"
@@ -1843,10 +1843,10 @@ export default function TunnelPage() {
isMultiNodeGroup
? "多节点跳使用节点默认IP"
: groupSelectedNodeIds.length === 0
? "请先选择节点"
: groupIpOptions.length > 0
? "选择连接IP"
: "所选节点无共同可选IP"
? "请先选择节点"
: groupIpOptions.length > 0
? "选择连接IP"
: "所选节点无共同可选IP"
}
selectedKeys={[
selectedGroupConnectIp || "__default__",
@@ -1906,243 +1906,259 @@ export default function TunnelPage() {
return (
<>
<div className="grid grid-cols-1 md:grid-cols-4 gap-2">
{/* 节点选择 - 移动端100%,桌面端50% */}
<div className="col-span-1 md:col-span-2">
<Select
classNames={{
label: "text-xs",
value: "text-sm",
}}
disabledKeys={[
...nodes
.filter((node) => node.status !== 1)
.map((node) => node.id.toString()),
...form.inNodeId.map((ct) =>
ct.nodeId.toString(),
),
...getSelectedChainNodeIds().map((id) =>
id.toString(),
),
]}
dropdownPlacement="top"
errorMessage={errors.outNodeId}
isInvalid={!!errors.outNodeId}
label="节点"
placeholder="请选择出口节点(可多选)"
selectedKeys={
form.outNodeId
? form.outNodeId
.filter((ct) => ct.nodeId !== -1)
.map((ct) => ct.nodeId.toString())
: []
}
selectionMode="multiple"
variant="bordered"
onSelectionChange={(keys) => {
const selectedIds = toSelectedNodeIds(keys);
{/* 节点选择 - 移动端100%,桌面端50% */}
<div className="col-span-1 md:col-span-2">
<Select
classNames={{
label: "text-xs",
value: "text-sm",
}}
disabledKeys={[
...nodes
.filter((node) => node.status !== 1)
.map((node) => node.id.toString()),
...form.inNodeId.map((ct) =>
ct.nodeId.toString(),
),
...getSelectedChainNodeIds().map((id) =>
id.toString(),
),
]}
dropdownPlacement="top"
errorMessage={errors.outNodeId}
isInvalid={!!errors.outNodeId}
label="节点"
placeholder="请选择出口节点(可多选)"
selectedKeys={
form.outNodeId
? form.outNodeId
.filter((ct) => ct.nodeId !== -1)
.map((ct) => ct.nodeId.toString())
: []
}
selectionMode="multiple"
variant="bordered"
onSelectionChange={(keys) => {
const selectedIds = toSelectedNodeIds(keys);
setForm((prev) => {
const currentOutNodes = prev.outNodeId || [];
const protocol =
currentOutNodes[0]?.protocol || "tls";
const strategy =
currentOutNodes[0]?.strategy || "round";
const realNodes = currentOutNodes.filter(
(ct) => ct.nodeId !== -1,
);
setForm((prev) => {
const currentOutNodes =
prev.outNodeId || [];
const protocol =
currentOutNodes[0]?.protocol || "tls";
const strategy =
currentOutNodes[0]?.strategy || "round";
const realNodes = currentOutNodes.filter(
(ct) => ct.nodeId !== -1,
);
return {
...prev,
outNodeId: mergeOrderedNodes(
realNodes,
selectedIds,
(nodeId) => ({
nodeId,
chainType: 3,
protocol,
strategy,
}),
),
};
});
}}
>
{nodes.map((node) => (
<SelectItem
key={node.id}
textValue={`${node.name}`}
>
<div className="flex items-center justify-between">
<span>{node.name}</span>
<div className="flex items-center gap-2">
<Chip
color={
node.status === 1
? "success"
: "default"
}
size="sm"
variant="flat"
return {
...prev,
outNodeId: mergeOrderedNodes(
realNodes,
selectedIds,
(nodeId) => ({
nodeId,
chainType: 3,
protocol,
strategy,
}),
),
};
});
}}
>
{nodes.map((node) => (
<SelectItem
key={node.id}
textValue={`${node.name}`}
>
{node.status === 1 ? "在线" : "离线"}
</Chip>
{form.inNodeId.some(
(ct) => ct.nodeId === node.id,
) && (
<Chip
color="warning"
size="sm"
variant="flat"
>
已选为入口
</Chip>
)}
{getSelectedChainNodeIds().includes(
node.id,
) && (
<Chip
color="primary"
size="sm"
variant="flat"
>
已选为转发链
</Chip>
)}
</div>
</div>
</SelectItem>
))}
</Select>
</div>
<div className="flex items-center justify-between">
<span>{node.name}</span>
<div className="flex items-center gap-2">
<Chip
color={
node.status === 1
? "success"
: "default"
}
size="sm"
variant="flat"
>
{node.status === 1
? "在线"
: "离线"}
</Chip>
{form.inNodeId.some(
(ct) => ct.nodeId === node.id,
) && (
<Chip
color="warning"
size="sm"
variant="flat"
>
已选为入口
</Chip>
)}
{getSelectedChainNodeIds().includes(
node.id,
) && (
<Chip
color="primary"
size="sm"
variant="flat"
>
已选为转发链
</Chip>
)}
</div>
</div>
</SelectItem>
))}
</Select>
</div>
{/* 协议选择 - 25% */}
<Select
classNames={{
label: "text-xs",
value: "text-sm",
}}
errorMessage={errors.protocol}
isInvalid={!!errors.protocol}
label="协议"
placeholder="选择协议"
selectedKeys={[
(() => {
if (
!form.outNodeId ||
form.outNodeId.length === 0
)
return "tls";
{/* 协议选择 - 25% */}
<Select
classNames={{
label: "text-xs",
value: "text-sm",
}}
errorMessage={errors.protocol}
isInvalid={!!errors.protocol}
label="协议"
placeholder="选择协议"
selectedKeys={[
(() => {
if (
!form.outNodeId ||
form.outNodeId.length === 0
)
return "tls";
return form.outNodeId[0].protocol || "tls";
})(),
]}
variant="bordered"
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as string;
return form.outNodeId[0].protocol || "tls";
})(),
]}
variant="bordered"
onSelectionChange={(keys) => {
const selectedKey = Array.from(
keys,
)[0] as string;
if (selectedKey) {
setForm((prev) => {
const currentOutNodes = prev.outNodeId || [];
const currentStrategy =
currentOutNodes.length > 0
? currentOutNodes[0].strategy || "round"
: "round";
if (selectedKey) {
setForm((prev) => {
const currentOutNodes =
prev.outNodeId || [];
const currentStrategy =
currentOutNodes.length > 0
? currentOutNodes[0].strategy ||
"round"
: "round";
if (currentOutNodes.length === 0) {
// 如果还没有出口节点,创建一个占位节点保存设置
return {
...prev,
outNodeId: [
{
nodeId: -1,
chainType: 3,
protocol: selectedKey,
strategy: currentStrategy,
},
],
};
}
if (currentOutNodes.length === 0) {
// 如果还没有出口节点,创建一个占位节点保存设置
return {
...prev,
outNodeId: [
{
nodeId: -1,
chainType: 3,
protocol: selectedKey,
strategy: currentStrategy,
},
],
};
}
// 更新所有出口节点的协议
return {
...prev,
outNodeId: currentOutNodes.map((ct) => ({
...ct,
protocol: selectedKey,
})),
};
});
}
}}
>
<SelectItem key="tls">TLS</SelectItem>
<SelectItem key="wss">WSS</SelectItem>
<SelectItem key="tcp">TCP</SelectItem>
<SelectItem key="mtls">MTLS</SelectItem>
<SelectItem key="mwss">MWSS</SelectItem>
<SelectItem key="mtcp">MTCP</SelectItem>
</Select>
// 更新所有出口节点的协议
return {
...prev,
outNodeId: currentOutNodes.map(
(ct) => ({
...ct,
protocol: selectedKey,
}),
),
};
});
}
}}
>
<SelectItem key="tls">TLS</SelectItem>
<SelectItem key="wss">WSS</SelectItem>
<SelectItem key="tcp">TCP</SelectItem>
<SelectItem key="mtls">MTLS</SelectItem>
<SelectItem key="mwss">MWSS</SelectItem>
<SelectItem key="mtcp">MTCP</SelectItem>
</Select>
{/* 负载策略 - 25% */}
<Select
classNames={{
label: "text-xs",
value: "text-sm",
}}
label="负载策略"
placeholder="选择策略"
selectedKeys={[
(() => {
if (
!form.outNodeId ||
form.outNodeId.length === 0
)
return "round";
{/* 负载策略 - 25% */}
<Select
classNames={{
label: "text-xs",
value: "text-sm",
}}
label="负载策略"
placeholder="选择策略"
selectedKeys={[
(() => {
if (
!form.outNodeId ||
form.outNodeId.length === 0
)
return "round";
return form.outNodeId[0].strategy || "round";
})(),
]}
variant="bordered"
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as string;
return (
form.outNodeId[0].strategy || "round"
);
})(),
]}
variant="bordered"
onSelectionChange={(keys) => {
const selectedKey = Array.from(
keys,
)[0] as string;
if (selectedKey) {
setForm((prev) => {
const currentOutNodes = prev.outNodeId || [];
const currentProtocol =
currentOutNodes.length > 0
? currentOutNodes[0].protocol || "tls"
: "tls";
if (selectedKey) {
setForm((prev) => {
const currentOutNodes =
prev.outNodeId || [];
const currentProtocol =
currentOutNodes.length > 0
? currentOutNodes[0].protocol || "tls"
: "tls";
if (currentOutNodes.length === 0) {
return {
...prev,
outNodeId: [
{
nodeId: -1,
chainType: 3,
protocol: currentProtocol,
strategy: selectedKey,
},
],
};
}
if (currentOutNodes.length === 0) {
return {
...prev,
outNodeId: [
{
nodeId: -1,
chainType: 3,
protocol: currentProtocol,
strategy: selectedKey,
},
],
};
}
return {
...prev,
outNodeId: currentOutNodes.map((ct) => ({
...ct,
strategy: selectedKey,
})),
};
});
}
}}
>
<SelectItem key="fifo">主备</SelectItem>
<SelectItem key="round">轮询</SelectItem>
<SelectItem key="rand">随机</SelectItem>
</Select>
return {
...prev,
outNodeId: currentOutNodes.map(
(ct) => ({
...ct,
strategy: selectedKey,
}),
),
};
});
}
}}
>
<SelectItem key="fifo">主备</SelectItem>
<SelectItem key="round">轮询</SelectItem>
<SelectItem key="rand">随机</SelectItem>
</Select>
</div>
{/* 连接IP - 出口节点 */}
@@ -2179,9 +2195,13 @@ export default function TunnelPage() {
size="sm"
variant="bordered"
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as string;
const selectedKey = Array.from(
keys,
)[0] as string;
const value =
selectedKey === "__default__" ? "" : selectedKey;
selectedKey === "__default__"
? ""
: selectedKey;
setForm((prev) => {
const currentOutNodes = prev.outNodeId || [];
@@ -2211,7 +2231,9 @@ export default function TunnelPage() {
});
}}
>
<SelectItem key="__default__">默认连接IP</SelectItem>
<SelectItem key="__default__">
默认连接IP
</SelectItem>
{commonOutIpOptions.map((ip) => (
<SelectItem key={ip}>{ip}</SelectItem>
))}
@@ -2887,7 +2909,7 @@ export default function TunnelPage() {
<ModalBody>
<p>
确定要删除选中的 {selectedIds.size}{" "}
项隧道吗?此操作不可撤销,相关转发也将被删除。
项隧道吗?此操作不可撤销,相关规则也将被删除。
</p>
</ModalBody>
<ModalFooter>
+6 -6
View File
@@ -845,7 +845,7 @@ export default function UserPage() {
{/* 其他信息 */}
<div className="space-y-1.5 pt-2 border-t border-divider">
<div className="flex justify-between text-sm">
<span className="text-default-600">转发数量</span>
<span className="text-default-600">规则数量</span>
<span className="font-medium text-xs">
{user.num}
</span>
@@ -1001,7 +1001,7 @@ export default function UserPage() {
/>
<Input
isRequired
label="转发数量"
label="规则数量"
max="99999"
min="1"
type="number"
@@ -1138,7 +1138,7 @@ export default function UserPage() {
<h3 className="text-lg font-semibold mb-4">分配新权限</h3>
<div className="space-y-4">
<div className="text-sm text-default-500 bg-default-100 dark:bg-default-50 p-3 rounded-lg border border-default-200 dark:border-default-100/30">
流量限制、转发数量、到期时间、流量重置时间将自动继承用户设置
流量限制、规则数量、到期时间、流量重置时间将自动继承用户设置
</div>
<div className="grid gap-2 max-h-72 overflow-y-auto pr-1">
@@ -1296,7 +1296,7 @@ export default function UserPage() {
<TableHeader>
<TableColumn>隧道名称</TableColumn>
<TableColumn>流量统计</TableColumn>
<TableColumn>转发数量</TableColumn>
<TableColumn>规则数量</TableColumn>
<TableColumn>状态</TableColumn>
<TableColumn>限速规则</TableColumn>
<TableColumn>重置时间</TableColumn>
@@ -1453,7 +1453,7 @@ export default function UserPage() {
/>
<Input
label="转发数量"
label="规则数量"
max="99999"
min="1"
type="number"
@@ -1668,7 +1668,7 @@ export default function UserPage() {
的权限吗?
</p>
<p className="text-small text-default-500 mt-1">
删除后该用户将无法使用此隧道创建转发,此操作不可撤销。
删除后该用户将无法使用此隧道创建规则,此操作不可撤销。{" "}
</p>
</div>
</div>
@@ -38,15 +38,33 @@ function parseDateText(value: string) {
return null;
}
const matched = trimmed.match(/^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})$/);
const digitsOnly = trimmed.replace(/\D/g, "");
if (!matched) {
if (/^\d+$/.test(trimmed)) {
if (digitsOnly.length !== 8) {
return null;
}
const year = Number(digitsOnly.slice(0, 4));
const month = Number(digitsOnly.slice(4, 6));
const day = Number(digitsOnly.slice(6, 8));
if (!isValidCalendarDate(year, month, day)) {
return null;
}
return { day, month, year };
}
const numberParts = trimmed.match(/\d+/g);
if (!numberParts || numberParts.length !== 3 || numberParts[0].length !== 4) {
return null;
}
const year = Number(matched[1]);
const month = Number(matched[2]);
const day = Number(matched[3]);
const year = Number(numberParts[0]);
const month = Number(numberParts[1]);
const day = Number(numberParts[2]);
if (!isValidCalendarDate(year, month, day)) {
return null;