Compare commits

...

18 Commits

Author SHA1 Message Date
sagit 0eed74fe10 fix: stabilize federation forward runtime cleanup and frontend version UX (#207)
## Summary
- fix federation forward runtime cleanup so valid forward listeners are
not treated as orphan during unbound binding windows
- add backend regression coverage for federation forward/runtime cleanup
and expiry handling paths
- wire frontend version update footer/flows and related UI updates
across admin pages

## Verification
- go test ./...
- make build
2026-02-25 11:22:12 +08:00
sagitchu 466cc65069 fix: stabilize federation forward runtime cleanup and wire frontend version update UX 2026-02-25 11:20:51 +08:00
sagit 9c41410f17 fix(backend): handle federation service arrays in runtime command (#206)
## Summary
- accept both top-level service arrays and wrapped `services` payloads
in federation runtime command parsing
- fix forward runtime service binding and port-range validation for
remote shared-node AddService/UpdateService calls
- add regression tests for top-level array payload handling to prevent
listener cleanup/flow mapping regressions

## Verification
- go test ./internal/http/handler -run
"TestBindPeerShareForwardRuntimeServicesAcceptsTopLevelServiceArray|TestValidateFederationCommandPortsAcceptsTopLevelServiceArray|TestBindPeerShareForwardRuntimeServicesOnlyBindsForwardRole|TestCleanOrphanedServicesSkipsActiveSharedForwardRuntimeServices|TestProcessFlowItemTracksPeerShareFlowByForwardServiceName"
- go test ./...
- make build
2026-02-25 00:08:32 +08:00
sagitchu bc71c524e0 fix(backend): accept top-level federation service payloads 2026-02-25 00:06:31 +08:00
sagit f46b2b4d86 fix(backend): keep shared federation port-forward listeners alive (#205)
## Summary
- prevent shared federation forward services from being misclassified as
orphaned during node config cleanup
- harden peer-share runtime mapping for service-name based flow
accounting and node/port conflict checks
- add regression tests for listener persistence, cleanup guards, runtime
binding, and federation port-forward lifecycle

## Verification
- go test ./internal/http/handler -run
\"TestCleanOrphanedServicesSkipsActiveSharedForwardRuntimeServices|TestCleanOrphanedServicesSkipsFederationServicePrefix|TestProcessFlowItemTracksPeerShareFlowByForwardServiceName|TestProcessFlowItemSkipsPeerShareFlowWhenServiceNameIsAmbiguous|TestFederationTunnelCreateRejectsOccupiedPort\"
- go test ./...
- make build
2026-02-24 23:08:02 +08:00
sagitchu 9f17d63cdc fix(backend): keep shared federation port-forward services stable 2026-02-24 23:06:09 +08:00
sagit 92f8ec47db fix(backend): track traffic stats for federation port-forward tunnels (#204)
## Summary

- Fixed traffic statistics not being tracked for federation port-forward
tunnels (tunnelType=1) in Panel Peering mode
- Added `parsePeerShareIDFromFederationTunnelName()` to extract shareID
from tunnel names matching `Share-{shareId}-Port-{port}` pattern
- Added `processPeerShareFlowFromForward()` to update
`peer_share.current_flow` when forward traffic belongs to a federation
port-forward tunnel
- Added regression test
`TestProcessFlowItemTracksPeerShareFlowForFederationPortForward`

## Root Cause

Federation + tunnel type=2 (隧道转发) traffic uses service names
`fed_svc_{runtimeID}` → correctly routed to `processPeerShareFlow()` →
`peer_share.current_flow` updated.

Federation + tunnel type=1 (端口转发) traffic uses regular forward service
names `{forwardId}_{userId}_{userTunnelId}` → only `AddFlow()` called →
`peer_share.current_flow` NOT updated → shared flow stats remain 0.

## Test Plan

- [x] `go test ./internal/http/handler -run
'TestProcessFlowItemTracksPeerShareFlow'` passes
- [x] `go test ./internal/http/handler` passes
- [x] `go test ./...` passes
- [x] `make build` succeeds
2026-02-24 20:41:18 +08:00
sagitchu a97484cd9b fix(backend): track traffic stats for federation port-forward tunnels
Federation mode panel peering with port-forward tunnel type (tunnelType=1)
was not updating peer_share.current_flow because regular forward traffic
uses different service name pattern than federation tunnel traffic.

Added parsePeerShareIDFromFederationTunnelName() to extract shareID from
tunnel names matching 'Share-{shareId}-Port-{port}' pattern, and
processPeerShareFlowFromForward() to update peer_share flow stats when
the forward belongs to a federation port-forward tunnel.
2026-02-24 20:25:04 +08:00
sagit ee6bc8c50e fix(frontend): keep mobile batch toolbar right-aligned (#203)
## Summary
- keep mobile batch action bars visually right-aligned while preserving
Android horizontal swipe behavior
- move overflow handling to an outer scroller and keep action layout in
an inner `min-w-full` flex row
- apply the same structure across forward, node, and tunnel pages for
consistent behavior

## Verification
- `npm run build` *(fails due to pre-existing issue: `src/main.tsx`
cannot find module `virtual:pwa-register`)*
- `npm run lint -- src/pages/forward.tsx src/pages/node.tsx
src/pages/tunnel.tsx` *(fails due to pre-existing a11y labels in
`forward.tsx` lines 3202/3223)*
2026-02-23 22:55:49 +08:00
sagitchu c94ab84ab9 fix(frontend): keep mobile batch toolbar right-aligned
Use an overflow wrapper with an inner min-w-full flex row so controls stay visually right-aligned when space is sufficient, while Android can still horizontally swipe when the toolbar overflows.
2026-02-23 22:53:48 +08:00
sagit 84a03215f4 fix(frontend): restore Android swipe for mobile batch toolbar (#202)
## Summary
- fix mobile batch action toolbar in forward/node/tunnel pages to keep
horizontal overflow reachable on Android
- switch toolbar alignment to start on mobile and keep right alignment
on `sm+` to avoid `justify-end` overflow reachability issues
- add `touch-pan-x` to improve horizontal swipe handling on mobile
browsers while preserving existing desktop layout

## Verification
- `npm run build` *(fails due to pre-existing issue: `src/main.tsx`
cannot find module `virtual:pwa-register`)*
- `npm run lint` *(fails due to pre-existing a11y errors in unrelated
files and in existing forward labels)*
2026-02-23 22:26:11 +08:00
sagitchu def93749eb fix(frontend): restore Android swipe for mobile batch toolbar
Switch mobile batch toolbar alignment to start and enable horizontal pan gestures so overflow actions remain reachable on Android while desktop right alignment stays unchanged.
2026-02-23 22:24:28 +08:00
sagit 945a1c0dfc fix(frontend): remove border from batch toggle button (#199) 2026-02-23 22:03:29 +08:00
sagit d752e096a3 fix(frontend): move batch operations into top toolbar (#198)
## Summary
- switch batch mode interactions to the top-right action bar on forward,
tunnel, and node pages
- remove bottom floating batch toolbars and restore normal action
toolbar when exiting batch mode
- stabilize toolbar layout and improve batch button visibility to avoid
visual jitter on toggle
2026-02-23 21:30:57 +08:00
sagitchu 880a3b81b0 fix(frontend): move batch actions into top toolbar
Switching batch controls to the top-right action bar with stable single-row layout improves clarity and prevents visual jumps while entering batch mode.
2026-02-23 21:28:50 +08:00
sagit 191aface2e fix(frontend): extend h5 tabbar safe-area coverage (#197)
Ensure the PWA bottom tab bar and tab hit areas fully cover the safe-area inset while keeping related frontend formatting updates consistent.
2026-02-23 07:45:14 +00:00
sagit e121dadb90 fix(backend): allow WHMCS API login when captcha is enabled (#196)
## Summary
- bypass captcha verification for machine API clients tagged as WHMCS
while keeping captcha enforcement for normal login flows
- add a backend contract test that verifies WHMCS-tagged login succeeds
when captcha is enabled
- keep WHMCS module repository changes out of this PR so only backend
behavior is merged to main
2026-02-23 14:42:24 +08:00
sagitchu bafcfbde3a fix(backend): allow WHMCS API login when captcha is enabled 2026-02-23 14:40:29 +08:00
27 changed files with 2168 additions and 502 deletions
+151 -27
View File
@@ -766,6 +766,37 @@ func (h *Handler) federationTunnelCreate(w http.ResponseWriter, r *http.Request)
return
}
usedPorts, err := h.repo.ListUsedPortsOnNode(share.NodeID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
for _, port := range usedPorts {
if port == req.RemotePort {
response.WriteJSON(w, response.Err(403, "Port already in use"))
return
}
}
runtimeOnPort, err := h.repo.GetActiveForwardPeerShareRuntimeByPort(share.ID, req.RemotePort)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if runtimeOnPort != nil {
response.WriteJSON(w, response.Err(403, "Port already in use"))
return
}
existsOnNodePort, err := h.repo.ExistsActivePeerShareRuntimeOnNodePort(share.NodeID, req.RemotePort)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if existsOnNodePort {
response.WriteJSON(w, response.Err(403, "Port already in use"))
return
}
now := time.Now().UnixMilli()
tunnelID, err := h.repo.CreateFederationTunnel(
fmt.Sprintf("Share-%d-Port-%d", share.ID, req.RemotePort),
@@ -780,6 +811,30 @@ func (h *Handler) federationTunnelCreate(w http.ResponseWriter, r *http.Request)
return
}
runtime := &repo.PeerShareRuntime{
ShareID: share.ID,
NodeID: share.NodeID,
ReservationID: randomToken(24),
ResourceKey: fmt.Sprintf("federation-forward-%d-%d-%d", share.ID, tunnelID, req.RemotePort),
BindingID: "",
Role: "forward",
ChainName: "",
ServiceName: "",
Protocol: defaultString(req.Protocol, "tcp"),
Strategy: "fifo",
Port: req.RemotePort,
Target: strings.TrimSpace(req.Target),
Applied: 0,
Status: 1,
CreatedTime: now,
UpdatedTime: now,
}
if err := h.repo.CreatePeerShareRuntime(runtime); err != nil {
_ = h.deleteTunnelByID(tunnelID)
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
h.wsServer.SendCommand(share.NodeID, "reload", nil, time.Second*5)
response.WriteJSON(w, response.OK(map[string]interface{}{
@@ -1211,9 +1266,88 @@ func (h *Handler) federationRuntimeCommand(w http.ResponseWriter, r *http.Reques
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
if strings.EqualFold(cmd, "addservice") || strings.EqualFold(cmd, "updateservice") {
h.bindPeerShareForwardRuntimeServices(share, req.Data)
}
response.WriteJSON(w, response.OK(res))
}
type federationForwardServiceBinding struct {
Name string
Port int
}
func extractFederationServiceEntries(data interface{}) []map[string]interface{} {
if data == nil {
return nil
}
if entries := asMapSlice(data); len(entries) > 0 {
return entries
}
dataMap, ok := data.(map[string]interface{})
if !ok {
return nil
}
if entries := asMapSlice(dataMap["services"]); len(entries) > 0 {
return entries
}
return nil
}
func parseFederationForwardServiceBindings(data interface{}) []federationForwardServiceBinding {
serviceList := extractFederationServiceEntries(data)
bindings := make([]federationForwardServiceBinding, 0, len(serviceList))
for _, svcMap := range serviceList {
name := normalizeForwardRuntimeServiceName(asString(svcMap["name"]))
if name == "" {
continue
}
if _, _, _, ok := parseFlowServiceIDs(name); !ok {
continue
}
addr := strings.TrimSpace(asString(svcMap["addr"]))
if addr == "" {
continue
}
_, portStr, err := net.SplitHostPort(addr)
if err != nil {
continue
}
port, err := strconv.Atoi(portStr)
if err != nil || port <= 0 {
continue
}
bindings = append(bindings, federationForwardServiceBinding{Name: name, Port: port})
}
return bindings
}
func (h *Handler) bindPeerShareForwardRuntimeServices(share *repo.PeerShare, data interface{}) {
if h == nil || h.repo == nil || share == nil {
return
}
bindings := parseFederationForwardServiceBindings(data)
if len(bindings) == 0 {
return
}
now := time.Now().UnixMilli()
for _, binding := range bindings {
runtime, err := h.repo.GetActiveForwardPeerShareRuntimeByPort(share.ID, binding.Port)
if err != nil || runtime == nil || runtime.Status != 1 {
continue
}
if runtime.ServiceName == binding.Name && runtime.Applied == 1 {
continue
}
_ = h.repo.UpdatePeerShareRuntimeServiceName(runtime.ID, binding.Name, now)
}
}
func isFederationRuntimeCommandAllowed(commandType string) bool {
switch strings.ToLower(strings.TrimSpace(commandType)) {
case "addservice", "updateservice", "deleteservice", "pauseservice", "resumeservice", "addchains", "deletechains", "addlimiters", "deletelimiters", "tcpping", "reload":
@@ -1236,36 +1370,26 @@ func validateFederationCommandPorts(share *repo.PeerShare, data interface{}) err
if share == nil || (share.PortRangeStart <= 0 && share.PortRangeEnd <= 0) {
return nil
}
dataMap, ok := data.(map[string]interface{})
if !ok {
serviceList := extractFederationServiceEntries(data)
if len(serviceList) == 0 {
return nil
}
if services, ok := dataMap["services"]; ok {
serviceList, ok := services.([]interface{})
if !ok {
return fmt.Errorf("invalid services format")
for _, svcMap := range serviceList {
addr := asString(svcMap["addr"])
if addr == "" {
continue
}
for _, svc := range serviceList {
svcMap, ok := svc.(map[string]interface{})
if !ok {
return fmt.Errorf("invalid service entry format")
}
addr, ok := svcMap["addr"].(string)
if !ok || addr == "" {
continue
}
_, portStr, err := net.SplitHostPort(addr)
if err != nil {
return fmt.Errorf("invalid service address: %s", addr)
}
port, err := strconv.Atoi(portStr)
if err != nil || port <= 0 {
return fmt.Errorf("invalid port in service address: %s", addr)
}
if port < share.PortRangeStart || port > share.PortRangeEnd {
return fmt.Errorf("port %d out of allowed range %d-%d", port, share.PortRangeStart, share.PortRangeEnd)
}
_, portStr, err := net.SplitHostPort(addr)
if err != nil {
return fmt.Errorf("invalid service address: %s", addr)
}
port, err := strconv.Atoi(portStr)
if err != nil || port <= 0 {
return fmt.Errorf("invalid port in service address: %s", addr)
}
if port < share.PortRangeStart || port > share.PortRangeEnd {
return fmt.Errorf("port %d out of allowed range %d-%d", port, share.PortRangeStart, share.PortRangeEnd)
}
}
@@ -414,6 +414,328 @@ func TestFederationShareResetFlow(t *testing.T) {
}
}
func TestFederationTunnelCreateCreatesPeerShareRuntime(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
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, is_remote, remote_url, remote_token, remote_config)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "federation-forward-node", "federation-forward-secret", "10.90.80.70", "10.90.80.70", "", "24000-24020", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 0, "", "", "").Error; err != nil {
t.Fatalf("insert node: %v", err)
}
nodeID := mustLastInsertID(t, r, "federation-forward-node")
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "federation-forward-share",
NodeID: nodeID,
Token: "federation-forward-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 24000,
PortRangeEnd: 24020,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share: %v", err)
}
share, err := r.GetPeerShareByToken("federation-forward-token")
if err != nil || share == nil {
t.Fatalf("load share: %v", err)
}
body, err := json.Marshal(federationTunnelRequest{
Protocol: "tcp",
RemotePort: 24001,
Target: "1.1.1.1:443",
})
if err != nil {
t.Fatalf("marshal request: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/tunnel/create", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+share.Token)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
h.federationTunnelCreate(res, req)
if res.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
}
var payload response.R
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Code != 0 {
t.Fatalf("expected response code 0, got %d (%s)", payload.Code, payload.Msg)
}
runtimeCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND port = ? AND status = 1`, share.ID, 24001)
if runtimeCount != 1 {
t.Fatalf("expected 1 runtime row for new federation forward tunnel, got %d", runtimeCount)
}
}
func TestFederationTunnelCreateRejectsOccupiedPort(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
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, is_remote, remote_url, remote_token, remote_config)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "federation-port-check-node", "federation-port-check-secret", "10.91.80.70", "10.91.80.70", "", "24100-24120", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 0, "", "", "").Error; err != nil {
t.Fatalf("insert node: %v", err)
}
nodeID := mustLastInsertID(t, r, "federation-port-check-node")
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "federation-port-check-share",
NodeID: nodeID,
Token: "federation-port-check-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 24100,
PortRangeEnd: 24120,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share: %v", err)
}
create := func() response.R {
body, err := json.Marshal(federationTunnelRequest{Protocol: "tcp", RemotePort: 24101, Target: "1.1.1.1:443"})
if err != nil {
t.Fatalf("marshal request: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/tunnel/create", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer federation-port-check-token")
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
h.federationTunnelCreate(res, req)
if res.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
}
var payload response.R
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
return payload
}
first := create()
if first.Code != 0 {
t.Fatalf("expected first create success, got %d (%s)", first.Code, first.Msg)
}
second := create()
if second.Code != 403 {
t.Fatalf("expected second create to be rejected with 403, got %d (%s)", second.Code, second.Msg)
}
if second.Msg != "Port already in use" {
t.Fatalf("expected occupied port message, got %q", second.Msg)
}
}
func TestDeleteTunnelReleasesFederationForwardRuntimeByPort(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "delete-forward-share",
NodeID: 1,
Token: "delete-forward-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 25000,
PortRangeEnd: 25020,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share: %v", err)
}
share, err := r.GetPeerShareByToken("delete-forward-token")
if err != nil || share == nil {
t.Fatalf("load share: %v", err)
}
tunnelName := fmt.Sprintf("Share-%d-Port-%d", share.ID, 25001)
if err := r.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, 1, tunnelName, 1.0, 1, "tcp", 1, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, share.ID, share.NodeID, "del-r1", "del-rk1", "", "forward", "", "20_2_10", "tcp", "fifo", 25001, "", 1, 1, now, now).Error; err != nil {
t.Fatalf("insert runtime: %v", err)
}
if err := h.deleteTunnelByID(1); err != nil {
t.Fatalf("delete tunnel: %v", err)
}
activeCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND port = ? AND status = 1`, share.ID, 25001)
if activeCount != 0 {
t.Fatalf("expected runtime released after tunnel delete, active rows=%d", activeCount)
}
}
func TestBindPeerShareForwardRuntimeServicesOnlyBindsForwardRole(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "bind-forward-role-share",
NodeID: 1,
Token: "bind-forward-role-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 26000,
PortRangeEnd: 26020,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share: %v", err)
}
share, err := r.GetPeerShareByToken("bind-forward-role-token")
if err != nil || share == nil {
t.Fatalf("load share: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(id, share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
1, share.ID, share.NodeID, "bind-r1", "bind-rk1", "", "forward", "", "", "tcp", "fifo", 26001, "", 0, 1, now, now,
2, share.ID, share.NodeID, "bind-r2", "bind-rk2", "", "middle", "", "", "tcp", "round", 26002, "", 0, 1, now, now,
).Error; err != nil {
t.Fatalf("insert runtimes: %v", err)
}
h.bindPeerShareForwardRuntimeServices(share, map[string]interface{}{
"services": []interface{}{
map[string]interface{}{"name": "77_2_10_tcp", "addr": "[::]:26001"},
map[string]interface{}{"name": "88_2_10_tcp", "addr": "[::]:26002"},
},
})
forwardServiceName := ""
middleServiceName := ""
if err := r.DB().Raw(`SELECT service_name FROM peer_share_runtime WHERE id = 1`).Scan(&forwardServiceName).Error; err != nil {
t.Fatalf("load forward runtime service name: %v", err)
}
if err := r.DB().Raw(`SELECT service_name FROM peer_share_runtime WHERE id = 2`).Scan(&middleServiceName).Error; err != nil {
t.Fatalf("load middle runtime service name: %v", err)
}
if forwardServiceName != "77_2_10" {
t.Fatalf("expected forward runtime service name bound, got %q", forwardServiceName)
}
if middleServiceName != "" {
t.Fatalf("expected non-forward runtime unchanged, got %q", middleServiceName)
}
}
func TestBindPeerShareForwardRuntimeServicesAcceptsTopLevelServiceArray(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "bind-array-share",
NodeID: 1,
Token: "bind-array-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 26100,
PortRangeEnd: 26120,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share: %v", err)
}
share, err := r.GetPeerShareByToken("bind-array-token")
if err != nil || share == nil {
t.Fatalf("load share: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(id, share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
1, share.ID, share.NodeID, "bind-array-r1", "bind-array-rk1", "", "forward", "", "", "tcp", "fifo", 26101, "", 0, 1, now, now,
).Error; err != nil {
t.Fatalf("insert runtime: %v", err)
}
h.bindPeerShareForwardRuntimeServices(share, []interface{}{
map[string]interface{}{"name": "99_2_10_tcp", "addr": "[::]:26101"},
map[string]interface{}{"name": "99_2_10_udp", "addr": "[::]:26101"},
})
forwardServiceName := ""
if err := r.DB().Raw(`SELECT service_name FROM peer_share_runtime WHERE id = 1`).Scan(&forwardServiceName).Error; err != nil {
t.Fatalf("load forward runtime service name: %v", err)
}
if forwardServiceName != "99_2_10" {
t.Fatalf("expected forward runtime service name bound from top-level array, got %q", forwardServiceName)
}
}
func TestValidateFederationCommandPortsAcceptsTopLevelServiceArray(t *testing.T) {
share := &repo.PeerShare{
PortRangeStart: 26200,
PortRangeEnd: 26210,
}
err := validateFederationCommandPorts(share, []interface{}{
map[string]interface{}{"name": "11_2_10_tcp", "addr": "[::]:26201"},
map[string]interface{}{"name": "11_2_10_udp", "addr": "[::]:26201"},
})
if err != nil {
t.Fatalf("expected top-level service array to pass port validation, got: %v", err)
}
}
func TestFederationRemoteUsageList(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
@@ -40,6 +40,7 @@ func (h *Handler) processFlowItem(item flowItem) {
if ok {
inFlow, outFlow := h.scaleFlowByTunnel(forwardID, item.D, item.U)
_ = h.repo.AddFlow(forwardID, userID, userTunnelID, inFlow, outFlow)
h.processPeerShareFlowFromForward(forwardID, serviceName, item)
if userTunnelID > 0 {
h.enforceFlowPolicies(userID, userTunnelID)
@@ -87,6 +88,45 @@ func parsePeerShareRuntimeServiceID(serviceName string) (int64, bool) {
return runtimeID, true
}
func parsePeerShareInfoFromFederationTunnelName(tunnelName string) (int64, int, bool) {
tunnelName = strings.TrimSpace(tunnelName)
if !strings.HasPrefix(tunnelName, "Share-") {
return 0, 0, false
}
raw := strings.TrimPrefix(tunnelName, "Share-")
idx := strings.Index(raw, "-Port-")
if idx <= 0 {
return 0, 0, false
}
shareID, err := strconv.ParseInt(raw[:idx], 10, 64)
if err != nil || shareID <= 0 {
return 0, 0, false
}
portValue := strings.TrimSpace(raw[idx+len("-Port-"):])
port, err := strconv.Atoi(portValue)
if err != nil || port <= 0 {
return 0, 0, false
}
return shareID, port, true
}
func parsePeerShareIDFromFederationTunnelName(tunnelName string) (int64, bool) {
tunnelName = strings.TrimSpace(tunnelName)
if !strings.HasPrefix(tunnelName, "Share-") {
return 0, false
}
raw := strings.TrimPrefix(tunnelName, "Share-")
idx := strings.Index(raw, "-Port-")
if idx <= 0 {
return 0, false
}
shareID, err := strconv.ParseInt(raw[:idx], 10, 64)
if err != nil || shareID <= 0 {
return 0, false
}
return shareID, true
}
func (h *Handler) processPeerShareFlow(runtimeID int64, item flowItem) {
if h == nil || h.repo == nil || runtimeID <= 0 {
return
@@ -113,6 +153,97 @@ func (h *Handler) processPeerShareFlow(runtimeID int64, item flowItem) {
h.enforcePeerShareFlowLimit(share.ID)
}
func (h *Handler) processPeerShareFlowFromForward(forwardID int64, serviceName string, item flowItem) {
if h == nil || h.repo == nil || forwardID <= 0 {
return
}
delta := item.D + item.U
if delta <= 0 {
return
}
forward, err := h.getForwardRecord(forwardID)
if err != nil || forward == nil {
// Forward not found in local database - might be a federation port-forward
// Try to find by service name in peer_share_runtime
h.processPeerShareFlowByServiceName(serviceName, item)
return
}
tunnelName, err := h.repo.GetTunnelName(forward.TunnelID)
if err != nil {
h.processPeerShareFlowByServiceName(serviceName, item)
return
}
shareID, ok := parsePeerShareIDFromFederationTunnelName(tunnelName)
if !ok {
h.processPeerShareFlowByServiceName(serviceName, item)
return
}
if err := h.repo.AddPeerShareCurrentFlow(shareID, delta); err != nil {
h.processPeerShareFlowByServiceName(serviceName, item)
return
}
share, err := h.repo.GetPeerShare(shareID)
if err != nil || share == nil {
return
}
if !isPeerShareFlowExceeded(share) {
return
}
h.enforcePeerShareFlowLimit(share.ID)
}
func normalizeForwardRuntimeServiceName(serviceName string) string {
name := strings.TrimSpace(serviceName)
if strings.HasSuffix(name, "_tcp") {
return strings.TrimSuffix(name, "_tcp")
}
if strings.HasSuffix(name, "_udp") {
return strings.TrimSuffix(name, "_udp")
}
return name
}
func (h *Handler) processPeerShareFlowByServiceName(serviceName string, item flowItem) {
if h == nil || h.repo == nil || strings.TrimSpace(serviceName) == "" {
return
}
delta := item.D + item.U
if delta <= 0 {
return
}
normalized := normalizeForwardRuntimeServiceName(serviceName)
runtimes, err := h.repo.ListActiveForwardPeerShareRuntimesByServiceName(normalized)
if err != nil {
return
}
if len(runtimes) == 0 && normalized != serviceName {
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByServiceName(serviceName)
if err != nil {
return
}
}
if len(runtimes) != 1 {
return
}
runtime := runtimes[0]
_ = h.repo.AddPeerShareCurrentFlow(runtime.ShareID, delta)
matchedShare, err := h.repo.GetPeerShare(runtime.ShareID)
if err != nil || matchedShare == nil {
return
}
if isPeerShareFlowExceeded(matchedShare) {
h.enforcePeerShareFlowLimit(matchedShare.ID)
}
}
func (h *Handler) enforcePeerShareFlowLimit(shareID int64) {
if h == nil || h.repo == nil || shareID <= 0 {
return
@@ -271,15 +402,46 @@ func (h *Handler) cleanNodeConfigs(nodeID int64, rawConfig string) {
}
func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem) {
runtimeServiceNames, err := h.repo.ListActiveForwardPeerShareRuntimeServiceNamesByNode(nodeID)
if err != nil {
return
}
minUpdatedTime := time.Now().Add(-10 * time.Minute).UnixMilli()
hasUnboundForwardPeerRuntime, err := h.repo.HasRecentUnboundForwardPeerShareRuntimeOnNode(nodeID, minUpdatedTime)
if err != nil {
hasUnboundForwardPeerRuntime = false
}
runtimeServiceSet := make(map[string]struct{}, len(runtimeServiceNames))
for _, serviceName := range runtimeServiceNames {
serviceName = strings.TrimSpace(serviceName)
if serviceName == "" {
continue
}
runtimeServiceSet[serviceName] = struct{}{}
}
for _, item := range services {
name := strings.TrimSpace(item.Name)
if name == "" || name == "web_api" {
continue
}
if strings.HasPrefix(name, "fed_svc_") {
continue
}
normalizedName := normalizeForwardRuntimeServiceName(name)
if _, ok := runtimeServiceSet[normalizedName]; ok {
continue
}
if _, ok := runtimeServiceSet[name]; ok {
continue
}
parts := strings.Split(name, "_")
if len(parts) >= 3 {
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
if err == nil && forwardID > 0 && hasUnboundForwardPeerRuntime {
continue
}
if err == nil && forwardID > 0 && !h.forwardExists(forwardID) {
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{name, parts[0] + "_" + parts[1] + "_" + parts[2], parts[0] + "_" + parts[1] + "_" + parts[2] + "_tcp", parts[0] + "_" + parts[1] + "_" + parts[2] + "_udp"}}, false, true)
continue
@@ -299,6 +461,9 @@ func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem
continue
}
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
if err == nil && forwardID > 0 && hasUnboundForwardPeerRuntime {
continue
}
if err != nil || forwardID <= 0 || h.forwardExists(forwardID) {
continue
}
@@ -2,6 +2,7 @@ package handler
import (
"path/filepath"
"strconv"
"testing"
"time"
@@ -61,3 +62,345 @@ func TestProcessFlowItemTracksPeerShareFlowAndEnforcesLimit(t *testing.T) {
t.Fatalf("expected runtime status=0 after limit enforcement, got %d", runtime.Status)
}
}
func TestProcessFlowItemTracksPeerShareFlowForFederationPortForward(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "forward-share",
NodeID: 1,
Token: "forward-share-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 30000,
PortRangeEnd: 30010,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create peer share: %v", err)
}
share, err := r.GetPeerShareByToken("forward-share-token")
if err != nil || share == nil {
t.Fatalf("load peer share: %v", err)
}
if err := r.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(2, 'u2', 'x', 1, ?, 99999, 0, 0, 1, 1, ?, ?, 1)
`, now+24*60*60*1000, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
tunnelName := "Share-" + strconv.FormatInt(share.ID, 10) + "-Port-30001"
if err := r.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(1, ?, 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
`, tunnelName, now, now).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
if err := r.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(10, 2, 1, NULL, 1, 99999, 0, 0, 1, ?, 1)
`, now+24*60*60*1000).Error; err != nil {
t.Fatalf("insert user_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(20, 2, 'u2', 'f20', 1, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
`, now, now).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
h := &Handler{repo: r}
h.processFlowItem(flowItem{N: "20_2_10", U: 120, D: 80})
updatedShare, err := r.GetPeerShare(share.ID)
if err != nil || updatedShare == nil {
t.Fatalf("reload share: %v", err)
}
if updatedShare.CurrentFlow != 200 {
t.Fatalf("expected current_flow=200, got %d", updatedShare.CurrentFlow)
}
}
func TestProcessFlowItemTracksPeerShareFlowByForwardServiceName(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward-service.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "forward-service-share",
NodeID: 1,
Token: "forward-service-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 31000,
PortRangeEnd: 31010,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create peer share: %v", err)
}
share, err := r.GetPeerShareByToken("forward-service-token")
if err != nil || share == nil {
t.Fatalf("load peer share: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, share.ID, share.NodeID, "svc-r1", "svc-rk1", "", "forward", "", "20_2_10", "tcp", "fifo", 31001, "", 1, 1, now, now).Error; err != nil {
t.Fatalf("insert peer_share_runtime: %v", err)
}
h := &Handler{repo: r}
h.processFlowItem(flowItem{N: "20_2_10_tcp", U: 120, D: 80})
updatedShare, err := r.GetPeerShare(share.ID)
if err != nil || updatedShare == nil {
t.Fatalf("reload share: %v", err)
}
if updatedShare.CurrentFlow != 200 {
t.Fatalf("expected current_flow=200, got %d", updatedShare.CurrentFlow)
}
}
func TestProcessFlowItemFallsBackToServiceNameWhenForwardIDCollidesAcrossPanels(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward-collision.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "collision-share",
NodeID: 1,
Token: "collision-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 31400,
PortRangeEnd: 31410,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create peer share: %v", err)
}
share, err := r.GetPeerShareByToken("collision-token")
if err != nil || share == nil {
t.Fatalf("load peer share: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, share.ID, share.NodeID, "collision-r1", "collision-rk1", "", "forward", "", "20_2_10", "tcp", "fifo", 31401, "", 1, 1, now, now).Error; err != nil {
t.Fatalf("insert peer_share_runtime: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(2, 'local-tunnel-with-colliding-forward-id', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
`, now, now).Error; err != nil {
t.Fatalf("insert local tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(20, 1, 'local-user', 'local-f20', 2, '8.8.8.8:53', 'fifo', 0, 0, ?, ?, 1, 0)
`, now, now).Error; err != nil {
t.Fatalf("insert local forward: %v", err)
}
h := &Handler{repo: r}
h.processFlowItem(flowItem{N: "20_2_10_tcp", U: 120, D: 80})
updatedShare, err := r.GetPeerShare(share.ID)
if err != nil || updatedShare == nil {
t.Fatalf("reload share: %v", err)
}
if updatedShare.CurrentFlow != 200 {
t.Fatalf("expected current_flow=200, got %d", updatedShare.CurrentFlow)
}
}
func TestProcessFlowItemSkipsPeerShareFlowWhenServiceNameIsAmbiguous(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward-ambiguous.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "ambiguous-share-a",
NodeID: 1,
Token: "ambiguous-token-a",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 31100,
PortRangeEnd: 31110,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share A: %v", err)
}
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "ambiguous-share-b",
NodeID: 1,
Token: "ambiguous-token-b",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 31200,
PortRangeEnd: 31210,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share B: %v", err)
}
shareA, _ := r.GetPeerShareByToken("ambiguous-token-a")
shareB, _ := r.GetPeerShareByToken("ambiguous-token-b")
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
shareA.ID, 1, "amb-r1", "amb-rk1", "", "forward", "", "99_2_10", "tcp", "fifo", 31101, "", 1, 1, now, now,
shareB.ID, 1, "amb-r2", "amb-rk2", "", "forward", "", "99_2_10", "tcp", "fifo", 31201, "", 1, 1, now, now,
).Error; err != nil {
t.Fatalf("insert ambiguous runtimes: %v", err)
}
h := &Handler{repo: r}
h.processFlowItem(flowItem{N: "99_2_10_tcp", U: 120, D: 80})
updatedA, _ := r.GetPeerShare(shareA.ID)
updatedB, _ := r.GetPeerShare(shareB.ID)
if updatedA.CurrentFlow != 0 || updatedB.CurrentFlow != 0 {
t.Fatalf("expected ambiguous service flow to be skipped, got shareA=%d shareB=%d", updatedA.CurrentFlow, updatedB.CurrentFlow)
}
}
func TestCleanOrphanedServicesSkipsActiveSharedForwardRuntimeServices(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-cleanup-runtime.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "cleanup-runtime-share",
NodeID: 1,
Token: "cleanup-runtime-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 31300,
PortRangeEnd: 31310,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create peer share: %v", err)
}
share, err := r.GetPeerShareByToken("cleanup-runtime-token")
if err != nil || share == nil {
t.Fatalf("load peer share: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, share.ID, share.NodeID, "cleanup-r1", "cleanup-rk1", "", "forward", "", "20_2_10", "tcp", "fifo", 31301, "", 1, 1, now, now).Error; err != nil {
t.Fatalf("insert peer_share_runtime: %v", err)
}
h := &Handler{repo: r}
defer func() {
if rec := recover(); rec != nil {
t.Fatalf("cleanOrphanedServices should skip active shared runtime service; got panic: %v", rec)
}
}()
h.cleanOrphanedServices(share.NodeID, []namedConfigItem{{Name: "20_2_10_tcp"}})
}
func TestCleanOrphanedServicesSkipsFederationServicePrefix(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-cleanup-fed-svc.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
h := &Handler{repo: r}
defer func() {
if rec := recover(); rec != nil {
t.Fatalf("cleanOrphanedServices should skip fed_svc_ service names; got panic: %v", rec)
}
}()
h.cleanOrphanedServices(1, []namedConfigItem{{Name: "fed_svc_999_tcp"}})
}
func TestCleanOrphanedServicesSkipsForwardPatternWhenNodeHasActivePeerShareForwardRuntime(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-cleanup-forward-runtime-empty-service.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "cleanup-forward-runtime-empty-service",
NodeID: 1,
Token: "cleanup-forward-runtime-empty-service-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 31420,
PortRangeEnd: 31430,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create peer share: %v", err)
}
share, err := r.GetPeerShareByToken("cleanup-forward-runtime-empty-service-token")
if err != nil || share == nil {
t.Fatalf("load peer share: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, share.ID, share.NodeID, "cleanup-forward-empty-r1", "cleanup-forward-empty-rk1", "", "forward", "", "", "tcp", "fifo", 31421, "", 0, 1, now, now).Error; err != nil {
t.Fatalf("insert peer_share_runtime with empty service name: %v", err)
}
h := &Handler{repo: r}
defer func() {
if rec := recover(); rec != nil {
t.Fatalf("cleanOrphanedServices should skip forward-pattern services when active peer-share forward runtime exists; got panic: %v", rec)
}
}()
h.cleanOrphanedServices(share.NodeID, []namedConfigItem{{Name: "20_2_10_tcp"}})
}
+14 -11
View File
@@ -219,7 +219,7 @@ func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if captchaEnabled {
if captchaEnabled && !h.apiClientCaptchaBypassEnabled(r) {
captchaID := strings.TrimSpace(req.CaptchaID)
if captchaID == "" {
response.WriteJSON(w, response.ErrDefault("验证码校验失败"))
@@ -988,21 +988,24 @@ func (h *Handler) captchaEnabled() (bool, error) {
if err != nil {
return false, err
}
if cfg == nil || !strings.EqualFold(cfg.Value, "true") {
if cfg == nil {
return false, nil
}
return strings.EqualFold(cfg.Value, "true"), nil
}
// captcha_enabled=true, but we need to verify cloudflare_secret_key is configured
// If secret key is not configured, treat captcha as disabled to avoid blocking login
secretKey, err := h.repo.GetConfigByName("cloudflare_secret_key")
if err != nil {
return false, err
}
if secretKey == nil || strings.TrimSpace(secretKey.Value) == "" {
return false, nil
func (h *Handler) apiClientCaptchaBypassEnabled(r *http.Request) bool {
if r == nil {
return false
}
return true, nil
client := strings.ToLower(strings.TrimSpace(r.Header.Get("X-FLVX-API-Client")))
switch client {
case "whmcs", "whmcs-module":
return true
default:
return false
}
}
func (h *Handler) markCaptchaToken(token string) {
@@ -69,6 +69,13 @@ func TestRunResetAndExpiryJobResetsFlowAndDisablesExpiredRecords(t *testing.T) {
t.Fatalf("insert expired user: %v", err)
}
if err := r.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(3, 'non_expiring_user', 'x', 1, 0, 100, 1000, 2000, 15, 1, ?, ?, 1)
`, nowMs, nowMs).Error; err != nil {
t.Fatalf("insert non-expiring user: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(1, 't1', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
@@ -83,6 +90,13 @@ func TestRunResetAndExpiryJobResetsFlowAndDisablesExpiredRecords(t *testing.T) {
t.Fatalf("insert expired user_tunnel: %v", err)
}
if err := r.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(11, 3, 1, NULL, 1, 1, 300, 400, 15, 0, 1)
`).Error; err != nil {
t.Fatalf("insert non-expiring user_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(20, 2, 'expired_user', 'f1', 1, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
@@ -90,6 +104,13 @@ func TestRunResetAndExpiryJobResetsFlowAndDisablesExpiredRecords(t *testing.T) {
t.Fatalf("insert forward: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(21, 3, 'non_expiring_user', 'f2', 1, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 1)
`, nowMs, nowMs).Error; err != nil {
t.Fatalf("insert non-expiring forward: %v", err)
}
h.runResetAndExpiryJob(now)
userIn, userOut, userStatus := mustQueryInt64Int64Int(t, r, `SELECT in_flow, out_flow, status FROM user WHERE id = 2`)
@@ -106,4 +127,19 @@ func TestRunResetAndExpiryJobResetsFlowAndDisablesExpiredRecords(t *testing.T) {
if forwardStatus != 0 {
t.Fatalf("expected forward status=0 after expiry handling, got %d", forwardStatus)
}
nonExpUserStatus := mustQueryInt(t, r, `SELECT status FROM user WHERE id = 3`)
if nonExpUserStatus != 1 {
t.Fatalf("expected non-expiring user to remain enabled, got status=%d", nonExpUserStatus)
}
nonExpTunnelStatus := mustQueryInt(t, r, `SELECT status FROM user_tunnel WHERE id = 11`)
if nonExpTunnelStatus != 1 {
t.Fatalf("expected non-expiring user_tunnel to remain enabled, got status=%d", nonExpTunnelStatus)
}
nonExpForwardStatus := mustQueryInt(t, r, `SELECT status FROM forward WHERE id = 21`)
if nonExpForwardStatus != 1 {
t.Fatalf("expected non-expiring forward to remain enabled, got status=%d", nonExpForwardStatus)
}
}
+15 -1
View File
@@ -2794,7 +2794,21 @@ func (h *Handler) deleteNodeByID(id int64) error {
}
func (h *Handler) deleteTunnelByID(id int64) error {
return h.repo.DeleteTunnelCascade(id)
if h == nil || h.repo == nil {
return errors.New("repository not initialized")
}
tunnelName, _ := h.repo.GetTunnelName(id)
if err := h.repo.DeleteTunnelCascade(id); err != nil {
return err
}
shareID, port, ok := parsePeerShareInfoFromFederationTunnelName(tunnelName)
if !ok {
return nil
}
return h.repo.MarkPeerShareRuntimeReleasedByPort(shareID, port, time.Now().UnixMilli())
}
func (h *Handler) deleteForwardByID(id int64) error {
+108 -2
View File
@@ -1270,6 +1270,112 @@ func (r *Repository) ListActivePeerShareRuntimePorts(shareID int64, nodeID int64
return ports, nil
}
func (r *Repository) ListActiveForwardPeerShareRuntimesByServiceName(serviceName string) ([]model.PeerShareRuntime, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var items []model.PeerShareRuntime
err := r.db.Where("service_name = ? AND status = 1 AND role = ?", serviceName, "forward").
Order("id ASC").
Find(&items).Error
if err != nil {
return nil, err
}
if items == nil {
items = make([]model.PeerShareRuntime, 0)
}
return items, nil
}
func (r *Repository) ListActiveForwardPeerShareRuntimeServiceNamesByNode(nodeID int64) ([]string, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var names []string
err := r.db.Model(&model.PeerShareRuntime{}).
Where("node_id = ? AND status = 1 AND role = ? AND service_name <> ''", nodeID, "forward").
Pluck("service_name", &names).Error
if err != nil {
return nil, err
}
if names == nil {
names = make([]string, 0)
}
return names, nil
}
func (r *Repository) HasRecentUnboundForwardPeerShareRuntimeOnNode(nodeID int64, minUpdatedTime int64) (bool, error) {
if r == nil || r.db == nil {
return false, errors.New("repository not initialized")
}
var count int64
err := r.db.Model(&model.PeerShareRuntime{}).
Where("node_id = ? AND status = 1 AND role = ? AND applied = 0 AND updated_time >= ? AND (service_name = '' OR service_name IS NULL)", nodeID, "forward", minUpdatedTime).
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
func (r *Repository) GetActiveForwardPeerShareRuntimeByPort(shareID int64, port int) (*model.PeerShareRuntime, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var item model.PeerShareRuntime
err := r.db.Where("share_id = ? AND port = ? AND status = 1 AND role = ?", shareID, port, "forward").First(&item).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &item, nil
}
func (r *Repository) ExistsActivePeerShareRuntimeOnNodePort(nodeID int64, port int) (bool, error) {
if r == nil || r.db == nil {
return false, errors.New("repository not initialized")
}
var count int64
err := r.db.Model(&model.PeerShareRuntime{}).
Where("node_id = ? AND port = ? AND status = 1", nodeID, port).
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
func (r *Repository) UpdatePeerShareRuntimeServiceName(id int64, serviceName string, updatedTime int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
return r.db.Model(&model.PeerShareRuntime{}).Where("id = ?", id).Updates(map[string]interface{}{
"service_name": serviceName,
"applied": 1,
"updated_time": updatedTime,
}).Error
}
func (r *Repository) MarkPeerShareRuntimeReleasedByPort(shareID int64, port int, updatedTime int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
if shareID <= 0 || port <= 0 {
return nil
}
if updatedTime <= 0 {
updatedTime = unixMilliNow()
}
return r.db.Model(&model.PeerShareRuntime{}).Where("share_id = ? AND port = ? AND status = 1", shareID, port).Updates(map[string]interface{}{
"status": 0,
"applied": 0,
"service_name": "",
"updated_time": updatedTime,
}).Error
}
// ─── FederationTunnelBinding ─────────────────────────────────────────
func (r *Repository) UpsertFederationTunnelBinding(item *model.FederationTunnelBinding) error {
@@ -2235,7 +2341,7 @@ func (r *Repository) ListExpiredActiveUserIDs(nowMs int64) ([]int64, error) {
}
var ids []int64
err := r.db.Model(&model.User{}).
Where("role_id != 0 AND status = 1 AND exp_time IS NOT NULL AND exp_time < ?", nowMs).
Where("role_id != 0 AND status = 1 AND exp_time > 0 AND exp_time < ?", nowMs).
Pluck("id", &ids).Error
if err != nil {
return nil, err
@@ -2255,7 +2361,7 @@ func (r *Repository) ListExpiredActiveUserTunnels(nowMs int64) ([]model.ExpiredU
return nil, errors.New("repository not initialized")
}
var uts []model.UserTunnel
err := r.db.Where("status = 1 AND exp_time IS NOT NULL AND exp_time < ?", nowMs).Find(&uts).Error
err := r.db.Where("status = 1 AND exp_time > 0 AND exp_time < ?", nowMs).Find(&uts).Error
if err != nil {
return nil, err
}
@@ -45,6 +45,18 @@ func TestCaptchaVerifyLoginContract(t *testing.T) {
assertCodeMsg(t, resp, -1, "验证码校验失败")
})
t.Run("whmcs api client bypasses captcha", func(t *testing.T) {
body := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":""}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", body)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-FLVX-API-Client", "whmcs")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assertCode(t, resp, 0)
})
t.Run("captcha token is one-time and consumed by login", func(t *testing.T) {
verifyReq := httptest.NewRequest(http.MethodPost, "/api/v1/captcha/verify", bytes.NewBufferString(`{"id":"captcha-token-1","data":"ok"}`))
verifyReq.Header.Set("Content-Type", "application/json")
@@ -55,7 +55,6 @@ export function SearchBar({
transition={{ duration: 0.18, ease: [0.25, 0.46, 0.45, 0.94] }}
>
<Input
autoFocus
classNames={{
base: "bg-default-100",
input:
+8 -2
View File
@@ -38,13 +38,19 @@ function Alert({
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"h5">) {
function AlertTitle({
className,
children,
...props
}: React.ComponentProps<"h5">) {
return (
<h5
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
data-slot="alert-title"
{...props}
/>
>
{children}
</h5>
);
}
+8 -2
View File
@@ -25,7 +25,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"h3">) {
function CardTitle({
className,
children,
...props
}: React.ComponentProps<"h3">) {
return (
<h3
className={cn(
@@ -34,7 +38,9 @@ function CardTitle({ className, ...props }: React.ComponentProps<"h3">) {
)}
data-slot="card-title"
{...props}
/>
>
{children}
</h3>
);
}
@@ -0,0 +1,110 @@
import { useEffect, useState } from "react";
import { siteConfig } from "@/config/site";
import {
UPDATE_CHANNEL_CHANGED_EVENT,
type UpdateReleaseChannel,
getLatestVersionByChannel,
getUpdateReleaseChannel,
hasVersionUpdate,
} from "@/utils/version-update";
const FALLBACK_GITHUB_REPO = "https://github.com/Sagit-chu/flux-panel";
interface VersionFooterProps {
version: string;
containerClassName?: string;
versionClassName?: string;
poweredClassName?: string;
updateBadgeClassName?: string;
}
export function VersionFooter({
version,
containerClassName,
versionClassName,
poweredClassName,
updateBadgeClassName,
}: VersionFooterProps) {
const [channel, setChannel] = useState<UpdateReleaseChannel>(
getUpdateReleaseChannel(),
);
const [updateAvailable, setUpdateAvailable] = useState(false);
const [latestUpdateVersion, setLatestUpdateVersion] = useState<string | null>(
null,
);
useEffect(() => {
const handleChannelChange = () => {
setChannel(getUpdateReleaseChannel());
};
window.addEventListener(UPDATE_CHANNEL_CHANGED_EVENT, handleChannelChange);
window.addEventListener("storage", handleChannelChange);
return () => {
window.removeEventListener(
UPDATE_CHANNEL_CHANGED_EVENT,
handleChannelChange,
);
window.removeEventListener("storage", handleChannelChange);
};
}, []);
useEffect(() => {
let active = true;
const checkUpdate = async () => {
const latestVersion = await getLatestVersionByChannel(
channel,
siteConfig.github_repo || FALLBACK_GITHUB_REPO,
);
if (!active) {
return;
}
if (!latestVersion) {
setUpdateAvailable(false);
setLatestUpdateVersion(null);
return;
}
const hasUpdate = hasVersionUpdate(version, latestVersion);
setUpdateAvailable(hasUpdate);
setLatestUpdateVersion(hasUpdate ? latestVersion : null);
};
void checkUpdate();
return () => {
active = false;
};
}, [channel, version]);
return (
<div className={containerClassName}>
<p className={versionClassName}>
v{version}
{updateAvailable && latestUpdateVersion && (
<span className={updateBadgeClassName} role="status">
{latestUpdateVersion}
</span>
)}
</p>
<p className={poweredClassName}>
Powered by{" "}
<a
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
href={siteConfig.github_repo}
rel="noopener noreferrer"
target="_blank"
>
FLVX
</a>
</p>
</div>
);
}
+53 -30
View File
@@ -20,6 +20,7 @@ import {
} from "@/shadcn-bridge/heroui/modal";
import { Input } from "@/shadcn-bridge/heroui/input";
import { Logo } from "@/components/icons";
import { VersionFooter } from "@/components/version-footer";
import { updatePassword } from "@/api";
import { safeLogout } from "@/utils/logout";
import { siteConfig } from "@/config/site";
@@ -50,7 +51,9 @@ export default function AdminLayout({
const { isOpen, onOpen, onOpenChange } = useDisclosure();
const [mobileMenuVisible, setMobileMenuVisible] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(() => localStorage.getItem("sidebar_collapsed") === "true");
const [isCollapsed, setIsCollapsed] = useState(
() => localStorage.getItem("sidebar_collapsed") === "true",
);
const [username, setUsername] = useState("");
const [isAdmin, setIsAdmin] = useState(false);
const [passwordLoading, setPasswordLoading] = useState(false);
@@ -208,6 +211,7 @@ export default function AdminLayout({
// 切换折叠状态
const toggleCollapse = () => {
const newCollapsed = !isCollapsed;
setIsCollapsed(newCollapsed);
localStorage.setItem("sidebar_collapsed", newCollapsed.toString());
};
@@ -328,11 +332,12 @@ export default function AdminLayout({
<div className="flex-shrink-0 flex items-center justify-center w-10">
<Logo size={28} />
</div>
<div className={`transition-all duration-300 overflow-hidden ${isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[180px] opacity-100 ml-2"}`}>
<div
className={`transition-all duration-300 overflow-hidden ${isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[180px] opacity-100 ml-2"}`}
>
<h1 className="text-sm font-bold text-foreground overflow-hidden whitespace-nowrap text-ellipsis">
{siteConfig.name}
</h1>
<p className="text-xs text-default-500 overflow-hidden whitespace-nowrap text-ellipsis">v{siteConfig.version}</p>
</div>
</div>
@@ -345,15 +350,16 @@ export default function AdminLayout({
return (
<li key={item.path}>
<motion.button
title={isCollapsed ? item.label : undefined}
className={`
w-full flex items-center p-2 rounded-lg text-left
relative min-h-[44px] overflow-hidden transition-colors
${isActive
? "text-primary-600 dark:text-primary-300"
: "text-gray-700 dark:text-gray-200"
}
${
isActive
? "text-primary-600 dark:text-primary-300"
: "text-gray-700 dark:text-gray-200"
}
`}
title={isCollapsed ? item.label : undefined}
transition={{ duration: 0.15 }}
onClick={() => handleMenuClick(item.path)}
>
@@ -378,7 +384,9 @@ export default function AdminLayout({
<div className="flex-shrink-0 w-10 h-10 flex items-center justify-center relative z-10">
{item.icon}
</div>
<div className={`transition-all duration-300 overflow-hidden flex items-center ${isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[200px] opacity-100 ml-2"}`}>
<div
className={`transition-all duration-300 overflow-hidden flex items-center ${isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[200px] opacity-100 ml-2"}`}
>
<span className="font-medium text-sm relative z-10 whitespace-nowrap">
{item.label}
</span>
@@ -392,38 +400,55 @@ export default function AdminLayout({
{/* 底部版权信息和折叠按钮 */}
<div className="px-5 py-2 pb-4 mt-auto flex-shrink-0 flex items-center justify-between overflow-hidden whitespace-nowrap box-border">
<div className={`transition-all duration-300 overflow-hidden flex items-center ${isCollapsed ? "max-w-0 opacity-0" : "max-w-[200px] opacity-100"}`}>
<p className="text-xs text-gray-400 dark:text-gray-500">
Powered by{" "}
<a
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
href={siteConfig.github_repo}
rel="noopener noreferrer"
target="_blank"
>
FLVX
</a>
</p>
<div
className={`transition-all duration-300 overflow-hidden flex items-center ${isCollapsed ? "max-w-0 opacity-0" : "max-w-[200px] opacity-100"}`}
>
<VersionFooter
poweredClassName="text-xs text-gray-400 dark:text-gray-500"
updateBadgeClassName="ml-2 inline-flex items-center rounded-full bg-rose-500/90 px-2 py-0.5 text-[10px] font-semibold tracking-wide text-white"
version={siteConfig.version}
versionClassName="text-xs text-gray-400 dark:text-gray-500"
/>
</div>
{/* 桌面端折叠按钮 */}
{!isMobile && (
<Button
isIconOnly
variant="light"
size="sm"
className="flex-shrink-0 text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300 min-w-0 w-10 h-10 rounded-full ml-auto"
size="sm"
variant="light"
onPress={toggleCollapse}
>
{isCollapsed ? (
// 向右扩展的提示
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M13 5l7 7-7 7M5 5l7 7-7 7"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
) : (
// 向左收起的提示
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M11 19l-7-7 7-7m8 14l-7-7 7-7"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
)}
</Button>
@@ -532,9 +557,7 @@ export default function AdminLayout({
</header>
{/* 主内容 */}
<main
className="flex-1 bg-gray-100 dark:bg-black overflow-y-auto"
>
<main className="flex-1 bg-gray-100 dark:bg-black overflow-y-auto">
<AnimatePresence mode="wait">
<motion.div
key={location.pathname}
+3 -3
View File
@@ -115,10 +115,10 @@ export default function H5Layout({ children }: { children: React.ReactNode }) {
<main className="flex-1 bg-gray-100 dark:bg-black">{children}</main>
{/* 用于给固定 Tabbar 腾出空间的占位元素 */}
<div aria-hidden className="h-16 safe-bottom" />
<div aria-hidden className="h-[calc(4rem+var(--safe-area-bottom))]" />
{/* 底部Tabbar */}
<nav className="bg-white dark:bg-black border-t border-gray-200 dark:border-gray-600 h-16 safe-bottom flex-shrink-0 flex items-center justify-around px-2 fixed bottom-0 left-0 right-0 z-30">
<nav className="bg-white dark:bg-black border-t border-gray-200 dark:border-gray-600 h-[calc(4rem+var(--safe-area-bottom))] flex-shrink-0 flex items-center justify-around px-2 fixed bottom-0 left-0 right-0 z-30">
{filteredTabItems.map((item) => {
const isActive = location.pathname === item.path;
@@ -126,7 +126,7 @@ export default function H5Layout({ children }: { children: React.ReactNode }) {
<button
key={item.path}
className={`
flex flex-col items-center justify-center flex-1 h-full
flex flex-col items-center justify-center flex-1 h-full pb-[var(--safe-area-bottom)]
transition-colors duration-200 min-h-[44px]
${
isActive
+52
View File
@@ -33,6 +33,11 @@ import {
clearConfigCache,
updateSiteConfig,
} from "@/config/site";
import {
type UpdateReleaseChannel,
getUpdateReleaseChannel,
setUpdateReleaseChannel,
} from "@/utils/version-update";
// 简单的保存图标组件
const SaveIcon = ({ className }: { className?: string }) => (
@@ -182,6 +187,9 @@ export default function ConfigPage() {
});
const [announcementLoading, setAnnouncementLoading] = useState(true);
const [announcementSaving, setAnnouncementSaving] = useState(false);
const [updateChannel, setUpdateChannel] = useState<UpdateReleaseChannel>(
getUpdateReleaseChannel(),
);
// 权限检查
useEffect(() => {
@@ -267,6 +275,14 @@ export default function ConfigPage() {
}
};
const handleUpdateChannelChange = (channel: UpdateReleaseChannel) => {
setUpdateChannel(channel);
setUpdateReleaseChannel(channel);
toast.success(
`更新通道已切换为${channel === "stable" ? "稳定版" : "开发版"}`,
);
};
const handleConfigChange = (key: string, value: string) => {
const newConfigs = { ...configs, [key]: value };
@@ -643,6 +659,42 @@ export default function ConfigPage() {
</div>
);
})}
<Divider className="my-2" />
<div className="space-y-3">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
更新通道
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
稳定版仅匹配纯数字版本;开发版仅匹配包含 alpha / beta / rc
的版本。
</p>
</div>
<Select
selectedKeys={[updateChannel]}
size="md"
variant="bordered"
onSelectionChange={(keys) => {
const selected =
(Array.from(keys)[0] as UpdateReleaseChannel) || "stable";
handleUpdateChannelChange(selected);
}}
>
<SelectItem key="stable" description="仅纯数字版本,如 2.1.4">
稳定版
</SelectItem>
<SelectItem
key="dev"
description="仅 alpha / beta / rc 关键字版本"
>
开发版
</SelectItem>
</Select>
</div>
</CardBody>
</Card>
+312 -275
View File
@@ -230,7 +230,7 @@ export default function ForwardPage() {
setViewMode(newMode);
try {
localStorage.setItem("forward-view-mode", newMode);
} catch { }
} catch {}
};
// 加载所有数据
@@ -1196,10 +1196,10 @@ export default function ForwardPage() {
const style: React.CSSProperties = {
transform: transform
? CSS.Transform.toString({
...transform,
x: Math.round(transform.x),
y: Math.round(transform.y),
})
...transform,
x: Math.round(transform.x),
y: Math.round(transform.y),
})
: undefined,
transition: isDragging ? undefined : transition || undefined,
opacity: isDragging ? 0.5 : 1,
@@ -1257,10 +1257,10 @@ export default function ForwardPage() {
const style = {
transform: transform
? CSS.Transform.toString({
...transform,
x: Math.round(transform.x),
y: Math.round(transform.y),
})
...transform,
x: Math.round(transform.x),
y: Math.round(transform.y),
})
: undefined,
transition: isDragging ? undefined : transition || undefined,
opacity: isDragging ? 0.5 : 1,
@@ -1317,10 +1317,11 @@ export default function ForwardPage() {
</TableCell>
<TableCell className="max-w-[220px]">
<button
className={`w-full truncate rounded-md bg-default-100/50 px-2.5 py-1.5 text-left font-mono text-xs font-medium text-default-700 transition-all ${hasMultipleAddresses(forward.inIp)
? "hover:bg-default-200 hover:shadow-sm"
: ""
}`}
className={`w-full truncate rounded-md bg-default-100/50 px-2.5 py-1.5 text-left font-mono text-xs font-medium text-default-700 transition-all ${
hasMultipleAddresses(forward.inIp)
? "hover:bg-default-200 hover:shadow-sm"
: ""
}`}
title={formatInAddress(forward.inIp, forward.inPort)}
type="button"
onClick={() =>
@@ -1332,10 +1333,11 @@ export default function ForwardPage() {
</TableCell>
<TableCell className="max-w-[240px]">
<button
className={`w-full truncate rounded-md bg-default-100/50 px-2.5 py-1.5 text-left font-mono text-xs font-medium text-default-700 transition-all ${hasMultipleAddresses(forward.remoteAddr)
? "hover:bg-default-200 hover:shadow-sm"
: ""
}`}
className={`w-full truncate rounded-md bg-default-100/50 px-2.5 py-1.5 text-left font-mono text-xs font-medium text-default-700 transition-all ${
hasMultipleAddresses(forward.remoteAddr)
? "hover:bg-default-200 hover:shadow-sm"
: ""
}`}
title={formatRemoteAddress(forward.remoteAddr)}
type="button"
onClick={() =>
@@ -1472,10 +1474,11 @@ export default function ForwardPage() {
<div className="flex items-center gap-1.5 ml-2">
{viewMode === "direct" && (
<div
className={`cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation ${isMobile
? "opacity-100" // 移动端始终显示
: "opacity-0 group-hover:opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
}`}
className={`cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation ${
isMobile
? "opacity-100" // 移动端始终显示
: "opacity-0 group-hover:opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
}`}
{...listeners}
style={{ touchAction: "none" }}
title={isMobile ? "长按拖拽排序" : "拖拽排序"}
@@ -1513,10 +1516,11 @@ export default function ForwardPage() {
{/* 地址信息 */}
<div className="space-y-1">
<button
className={`cursor-pointer px-2 py-1 bg-default-50 dark:bg-default-100/50 rounded border border-default-200 dark:border-default-300 transition-colors duration-200 ${hasMultipleAddresses(forward.inIp)
? "hover:bg-default-100 dark:hover:bg-default-200/50"
: ""
}`}
className={`cursor-pointer px-2 py-1 bg-default-50 dark:bg-default-100/50 rounded border border-default-200 dark:border-default-300 transition-colors duration-200 ${
hasMultipleAddresses(forward.inIp)
? "hover:bg-default-100 dark:hover:bg-default-200/50"
: ""
}`}
title={formatInAddress(forward.inIp, forward.inPort)}
type="button"
onClick={() =>
@@ -1552,10 +1556,11 @@ export default function ForwardPage() {
</button>
<button
className={`cursor-pointer px-2 py-1 bg-default-50 dark:bg-default-100/50 rounded border border-default-200 dark:border-default-300 transition-colors duration-200 ${hasMultipleAddresses(forward.remoteAddr)
? "hover:bg-default-100 dark:hover:bg-default-200/50"
: ""
}`}
className={`cursor-pointer px-2 py-1 bg-default-50 dark:bg-default-100/50 rounded border border-default-200 dark:border-default-300 transition-colors duration-200 ${
hasMultipleAddresses(forward.remoteAddr)
? "hover:bg-default-100 dark:hover:bg-default-200/50"
: ""
}`}
title={formatRemoteAddress(forward.remoteAddr)}
type="button"
onClick={() =>
@@ -1717,186 +1722,209 @@ export default function ForwardPage() {
onOpen={() => setIsSearchVisible(true)}
/>
</div>
<div className="flex flex-wrap items-center justify-end gap-2 sm:gap-3">
{/* 筛选按钮 */}
<Button
isIconOnly
aria-label="筛选条件"
className={
filterUserId !== "all" || filterTunnelId !== "all"
? "bg-primary/20 text-primary relative"
: "text-default-600 relative"
}
color={
filterUserId !== "all" || filterTunnelId !== "all"
? "primary"
: "default"
}
size="sm"
title="筛选条件"
variant="flat"
onPress={() => setIsFilterModalOpen(true)}
>
<svg
aria-hidden="true"
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
{(filterUserId !== "all" || filterTunnelId !== "all") && (
<span className="absolute top-1.5 right-1.5 flex h-1.5 w-1.5 rounded-full bg-primary" />
)}
</Button>
{/* 显示模式切换按钮 */}
<Button
isIconOnly
aria-label={
viewMode === "grouped" ? "切换到直接显示" : "切换到分类显示"
}
className="text-sm"
color="default"
size="sm"
title={viewMode === "grouped" ? "切换到直接显示" : "切换到分类显示"}
variant="flat"
onPress={handleViewModeChange}
>
{viewMode === "grouped" ? (
<svg
aria-hidden="true"
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
clipRule="evenodd"
d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1v-2zM3 16a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1v-2z"
fillRule="evenodd"
/>
</svg>
<div className="min-h-9 min-w-0 max-w-full overflow-x-auto touch-pan-x">
<div className="flex min-h-9 w-max min-w-full items-center justify-end gap-2 whitespace-nowrap sm:gap-3 [&>*]:shrink-0">
{selectMode ? (
<>
<span className="text-sm text-default-600 shrink-0">
已选择 {selectedIds.size} 项
</span>
<Button
color="primary"
size="sm"
variant="flat"
onPress={selectAll}
>
全选
</Button>
<Button
color="secondary"
size="sm"
variant="flat"
onPress={deselectAll}
>
清空
</Button>
<Button
color="danger"
isDisabled={selectedIds.size === 0}
size="sm"
variant="flat"
onPress={() => setBatchDeleteModalOpen(true)}
>
删除
</Button>
<Button
color="warning"
isDisabled={selectedIds.size === 0}
isLoading={batchLoading}
size="sm"
variant="flat"
onPress={() => handleBatchToggleService(false)}
>
停用
</Button>
<Button
color="success"
isDisabled={selectedIds.size === 0}
isLoading={batchLoading}
size="sm"
variant="flat"
onPress={() => handleBatchToggleService(true)}
>
启用
</Button>
<Button
color="primary"
isDisabled={selectedIds.size === 0}
isLoading={batchLoading}
size="sm"
variant="flat"
onPress={handleBatchRedeploy}
>
下发
</Button>
<Button
color="secondary"
isDisabled={selectedIds.size === 0}
size="sm"
variant="flat"
onPress={() => setBatchChangeTunnelModalOpen(true)}
>
隧道
</Button>
<Button
color="secondary"
size="sm"
variant="solid"
onPress={toggleSelectMode}
>
退出
</Button>
</>
) : (
<svg
aria-hidden="true"
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z" />
</svg>
<>
{/* 筛选按钮 */}
<Button
isIconOnly
aria-label="筛选条件"
className={
filterUserId !== "all" || filterTunnelId !== "all"
? "bg-primary/20 text-primary relative"
: "text-default-600 relative"
}
color={
filterUserId !== "all" || filterTunnelId !== "all"
? "primary"
: "default"
}
size="sm"
title="筛选条件"
variant="flat"
onPress={() => setIsFilterModalOpen(true)}
>
<svg
aria-hidden="true"
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
{(filterUserId !== "all" || filterTunnelId !== "all") && (
<span className="absolute top-1.5 right-1.5 flex h-1.5 w-1.5 rounded-full bg-primary" />
)}
</Button>
{/* 显示模式切换按钮 */}
<Button
isIconOnly
aria-label={
viewMode === "grouped" ? "切换到直接显示" : "切换到分类显示"
}
className="text-sm"
color="default"
size="sm"
title={
viewMode === "grouped" ? "切换到直接显示" : "切换到分类显示"
}
variant="flat"
onPress={handleViewModeChange}
>
{viewMode === "grouped" ? (
<svg
aria-hidden="true"
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
clipRule="evenodd"
d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1v-2zM3 16a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1v-2z"
fillRule="evenodd"
/>
</svg>
) : (
<svg
aria-hidden="true"
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z" />
</svg>
)}
</Button>
{/* 导入按钮 */}
<Button
color="warning"
size="sm"
variant="flat"
onPress={handleImport}
>
导入
</Button>
{/* 导出按钮 */}
<Button
color="success"
isLoading={exportLoading}
size="sm"
variant="flat"
onPress={handleExport}
>
导出
</Button>
<Button
className="bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-300 dark:hover:bg-sky-900/45"
color="default"
size="sm"
variant="flat"
onPress={toggleSelectMode}
>
批量
</Button>
<Button
color="primary"
size="sm"
variant="flat"
onPress={handleAdd}
>
新增
</Button>
</>
)}
</Button>
{/* 导入按钮 */}
<Button
color="warning"
size="sm"
variant="flat"
onPress={handleImport}
>
导入
</Button>
{/* 导出按钮 */}
<Button
color="success"
isLoading={exportLoading}
size="sm"
variant="flat"
onPress={handleExport}
>
导出
</Button>
<Button
color={selectMode ? "secondary" : "default"}
size="sm"
variant={selectMode ? "solid" : "flat"}
onPress={toggleSelectMode}
>
{selectMode ? "退出" : "批量"}
</Button>
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
新增
</Button>
</div>
</div>
{selectMode && selectedIds.size > 0 && (
<div className="fixed bottom-7 left-1/2 z-50 w-[calc(100vw-1rem)] max-w-max -translate-x-1/2 overflow-x-auto rounded-lg border border-divider bg-content1 p-2 shadow-lg">
<div className="flex min-w-max items-center gap-2">
<span className="text-sm text-default-600 shrink-0">
已选择 {selectedIds.size} 项
</span>
<Button
color="primary"
size="sm"
variant="flat"
onPress={selectAll}
>
全选
</Button>
<Button
color="secondary"
size="sm"
variant="flat"
onPress={deselectAll}
>
清空
</Button>
<Button
color="danger"
size="sm"
variant="flat"
onPress={() => setBatchDeleteModalOpen(true)}
>
删除
</Button>
<Button
color="warning"
isLoading={batchLoading}
size="sm"
variant="flat"
onPress={() => handleBatchToggleService(false)}
>
停用
</Button>
<Button
color="success"
isLoading={batchLoading}
size="sm"
variant="flat"
onPress={() => handleBatchToggleService(true)}
>
启用
</Button>
<Button
color="primary"
isLoading={batchLoading}
size="sm"
variant="flat"
onPress={handleBatchRedeploy}
>
下发
</Button>
<Button
color="secondary"
size="sm"
variant="flat"
onPress={() => setBatchChangeTunnelModalOpen(true)}
>
隧道
</Button>
</div>
</div>
)}
</div>
{/* 根据显示模式渲染不同内容 */}
{viewMode === "grouped" ? (
@@ -1973,39 +2001,39 @@ export default function ForwardPage() {
</Card>
)
) : /* 直接显示模式 */
forwards.length > 0 ? (
<DndContext
collisionDetection={closestCenter}
sensors={sensors}
onDragEnd={handleDragEnd}
onDragStart={() => { }} // 添加空的 onDragStart 处理器
forwards.length > 0 ? (
<DndContext
collisionDetection={closestCenter}
sensors={sensors}
onDragEnd={handleDragEnd}
onDragStart={() => {}} // 添加空的 onDragStart 处理器
>
<SortableContext
items={sortableForwardIds}
strategy={rectSortingStrategy}
>
<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>
)}
<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>
)}
{/* 新增/编辑模态框 */}
<Modal
@@ -2474,10 +2502,11 @@ export default function ForwardPage() {
{importResults.map((result, index) => (
<div
key={index}
className={`p-2 rounded border ${result.success
? "bg-success-50 dark:bg-success-100/10 border-success-200 dark:border-success-300/20"
: "bg-danger-50 dark:bg-danger-100/10 border-danger-200 dark:border-danger-300/20"
}`}
className={`p-2 rounded border ${
result.success
? "bg-success-50 dark:bg-success-100/10 border-success-200 dark:border-success-300/20"
: "bg-danger-50 dark:bg-danger-100/10 border-danger-200 dark:border-danger-300/20"
}`}
>
<div className="flex items-center gap-2">
{result.success ? (
@@ -2510,10 +2539,11 @@ export default function ForwardPage() {
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span
className={`text-xs font-medium ${result.success
? "text-success-700 dark:text-success-300"
: "text-danger-700 dark:text-danger-300"
}`}
className={`text-xs font-medium ${
result.success
? "text-success-700 dark:text-success-300"
: "text-danger-700 dark:text-danger-300"
}`}
>
{result.success ? "成功" : "失败"}
</span>
@@ -2525,10 +2555,11 @@ export default function ForwardPage() {
</code>
</div>
<div
className={`text-xs ${result.success
? "text-success-600 dark:text-success-400"
: "text-danger-600 dark:text-danger-400"
}`}
className={`text-xs ${
result.success
? "text-success-600 dark:text-success-400"
: "text-danger-600 dark:text-danger-400"
}`}
>
{result.message}
</div>
@@ -2711,18 +2742,20 @@ export default function ForwardPage() {
return (
<tr
key={index}
className={`hover:bg-default-50 dark:hover:bg-gray-700/50 ${result.success
? "bg-white dark:bg-gray-800"
: "bg-danger-50 dark:bg-danger-900/30"
}`}
className={`hover:bg-default-50 dark:hover:bg-gray-700/50 ${
result.success
? "bg-white dark:bg-gray-800"
: "bg-danger-50 dark:bg-danger-900/30"
}`}
>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
<span
className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${result.success
? "bg-success text-white"
: "bg-danger text-white"
}`}
className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
result.success
? "bg-success text-white"
: "bg-danger text-white"
}`}
>
{result.success ? "✓" : "✗"}
</span>
@@ -2764,10 +2797,11 @@ export default function ForwardPage() {
<td className="px-3 py-2 text-center">
{result.success ? (
<span
className={`font-semibold ${(result.packetLoss || 0) > 0
? "text-warning"
: "text-success"
}`}
className={`font-semibold ${
(result.packetLoss || 0) > 0
? "text-warning"
: "text-success"
}`}
>
{result.packetLoss?.toFixed(1)}%
</span>
@@ -2881,17 +2915,19 @@ export default function ForwardPage() {
return (
<div
key={index}
className={`border rounded-lg p-3 ${result.success
? "border-divider bg-white dark:bg-gray-800"
: "border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30"
}`}
className={`border rounded-lg p-3 ${
result.success
? "border-divider bg-white dark:bg-gray-800"
: "border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30"
}`}
>
<div className="flex items-start gap-2 mb-2">
<span
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${result.success
? "bg-success text-white"
: "bg-danger text-white"
}`}
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${
result.success
? "bg-success text-white"
: "bg-danger text-white"
}`}
>
{result.success ? "✓" : "✗"}
</span>
@@ -2927,10 +2963,11 @@ export default function ForwardPage() {
</div>
<div className="text-center">
<div
className={`text-lg font-bold ${(result.packetLoss || 0) > 0
? "text-warning"
: "text-success"
}`}
className={`text-lg font-bold ${
(result.packetLoss || 0) > 0
? "text-warning"
: "text-success"
}`}
>
{result.packetLoss?.toFixed(1)}%
</div>
@@ -3162,7 +3199,7 @@ export default function ForwardPage() {
<ModalBody>
<div className="flex flex-col gap-4 py-2">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">按用户筛选</label>
<p className="text-sm font-medium">按用户筛选</p>
<Select
aria-label="筛选用户"
className="w-full"
@@ -3183,7 +3220,7 @@ export default function ForwardPage() {
</Select>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">按隧道筛选</label>
<p className="text-sm font-medium">按隧道筛选</p>
<Select
aria-label="筛选隧道"
className="w-full"
+8 -16
View File
@@ -9,6 +9,7 @@ import { Input } from "@/shadcn-bridge/heroui/input";
import { Button } from "@/shadcn-bridge/heroui/button";
import { siteConfig } from "@/config/site";
import { title } from "@/components/primitives";
import { VersionFooter } from "@/components/version-footer";
import DefaultLayout from "@/layouts/default";
import { login, LoginData, checkCaptcha, getConfigByName } from "@/api";
import { writeLoginSession } from "@/utils/session";
@@ -213,22 +214,13 @@ export default function IndexPage() {
{/* 版权信息 - 固定在底部,不占据布局空间 */}
<div className="fixed inset-x-0 bottom-4 text-center py-4">
<p className="text-xs text-gray-400 dark:text-gray-500">
Powered by{" "}
<a
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
href={siteConfig.github_repo}
rel="noopener noreferrer"
target="_blank"
>
FLVX
</a>
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
v{isWebView ? siteConfig.app_version : siteConfig.version}
</p>
</div>
<VersionFooter
containerClassName="fixed inset-x-0 bottom-4 text-center py-4"
poweredClassName="text-xs text-gray-400 dark:text-gray-500"
updateBadgeClassName="ml-2 inline-flex items-center rounded-full bg-rose-500/90 px-2 py-0.5 text-[10px] font-semibold tracking-wide text-white"
version={isWebView ? siteConfig.app_version : siteConfig.version}
versionClassName="text-xs text-gray-400 dark:text-gray-500 mt-1"
/>
{/* 验证码弹层 */}
{showCaptcha && siteKey && (
+73 -56
View File
@@ -1075,64 +1075,81 @@ export default function NodePage() {
/>
</div>
<div className="flex gap-2 items-center">
<Button
color={selectMode ? "secondary" : "default"}
size="sm"
variant={selectMode ? "solid" : "flat"}
onPress={toggleSelectMode}
>
{selectMode ? "退出" : "批量"}
</Button>
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
新增
</Button>
</div>
</div>
{/* 批量操作浮动工具栏 */}
{selectMode && selectedIds.size > 0 && (
<div className="fixed bottom-7 left-1/2 z-50 w-[calc(100vw-1rem)] max-w-max -translate-x-1/2 overflow-x-auto rounded-lg border border-divider bg-content1 p-2 shadow-lg">
<div className="flex min-w-max items-center gap-2">
<span className="text-sm font-medium shrink-0">
已选 {selectedIds.size} 项
</span>
<Button
color="primary"
size="sm"
variant="flat"
onPress={selectAll}
>
全选
</Button>
<Button
color="secondary"
size="sm"
variant="flat"
onPress={deselectAll}
>
清空
</Button>
<Button
color="warning"
isLoading={batchUpgradeLoading}
size="sm"
variant="flat"
onPress={() => openUpgradeModal("batch")}
>
升级
</Button>
<Button
color="danger"
size="sm"
variant="flat"
onPress={() => setBatchDeleteModalOpen(true)}
>
删除
</Button>
<div className="min-h-9 min-w-0 max-w-full overflow-x-auto touch-pan-x">
<div className="flex min-h-9 w-max min-w-full items-center justify-end gap-2 whitespace-nowrap [&>*]:shrink-0">
{selectMode ? (
<>
<span className="text-sm text-default-600 shrink-0">
已选 {selectedIds.size} 项
</span>
<Button
color="primary"
size="sm"
variant="flat"
onPress={selectAll}
>
全选
</Button>
<Button
color="secondary"
size="sm"
variant="flat"
onPress={deselectAll}
>
清空
</Button>
<Button
color="warning"
isDisabled={selectedIds.size === 0}
isLoading={batchUpgradeLoading}
size="sm"
variant="flat"
onPress={() => openUpgradeModal("batch")}
>
升级
</Button>
<Button
color="danger"
isDisabled={selectedIds.size === 0}
size="sm"
variant="flat"
onPress={() => setBatchDeleteModalOpen(true)}
>
删除
</Button>
<Button
color="secondary"
size="sm"
variant="solid"
onPress={toggleSelectMode}
>
退出
</Button>
</>
) : (
<>
<Button
className="bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-300 dark:hover:bg-sky-900/45"
color="default"
size="sm"
variant="flat"
onPress={toggleSelectMode}
>
批量
</Button>
<Button
color="primary"
size="sm"
variant="flat"
onPress={handleAdd}
>
新增
</Button>
</>
)}
</div>
</div>
)}
</div>
{!wsConnected && (
<Alert
+10 -2
View File
@@ -674,7 +674,11 @@ export default function PanelSharingPage() {
</Tabs>
{/* Create Share Modal */}
<Modal isOpen={createShareOpen} onClose={() => setCreateShareOpen(false)} scrollBehavior="inside">
<Modal
isOpen={createShareOpen}
scrollBehavior="inside"
onClose={() => setCreateShareOpen(false)}
>
<ModalContent>
<ModalHeader>创建分享</ModalHeader>
<ModalBody>
@@ -777,7 +781,11 @@ export default function PanelSharingPage() {
</Modal>
{/* Edit Share Modal */}
<Modal isOpen={editShareOpen} onClose={() => setEditShareOpen(false)} scrollBehavior="inside">
<Modal
isOpen={editShareOpen}
scrollBehavior="inside"
onClose={() => setEditShareOpen(false)}
>
<ModalContent>
<ModalHeader>编辑分享</ModalHeader>
<ModalBody>
+10 -16
View File
@@ -15,6 +15,7 @@ import {
import { Input } from "@/shadcn-bridge/heroui/input";
import { isWebViewFunc } from "@/utils/panel";
import { siteConfig } from "@/config/site";
import { VersionFooter } from "@/components/version-footer";
import { updatePassword } from "@/api";
import { safeLogout } from "@/utils/logout";
import { getAdminFlag, getSessionName } from "@/utils/session";
@@ -312,22 +313,15 @@ export default function ProfilePage() {
</CardBody>
</Card>
<div className="fixed inset-x-0 bottom-20 text-center py-4">
<p className="text-xs text-gray-400 dark:text-gray-500">
Powered by{" "}
<a
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
href={siteConfig.github_repo}
rel="noopener noreferrer"
target="_blank"
>
FLVX
</a>
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
v{isWebViewFunc() ? siteConfig.app_version : siteConfig.version}
</p>
</div>
<VersionFooter
containerClassName="fixed inset-x-0 bottom-20 text-center py-4"
poweredClassName="text-xs text-gray-400 dark:text-gray-500"
updateBadgeClassName="ml-2 inline-flex items-center rounded-full bg-rose-500/90 px-2 py-0.5 text-[10px] font-semibold tracking-wide text-white"
version={
isWebViewFunc() ? siteConfig.app_version : siteConfig.version
}
versionClassName="text-xs text-gray-400 dark:text-gray-500 mt-1"
/>
</div>
{/* 修改密码弹窗 */}
+47
View File
@@ -5,7 +5,13 @@ import toast from "react-hot-toast";
import { Input } from "@/shadcn-bridge/heroui/input";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
import { reinitializeBaseURL } from "@/api/network";
import {
type UpdateReleaseChannel,
getUpdateReleaseChannel,
setUpdateReleaseChannel,
} from "@/utils/version-update";
import {
getPanelAddresses,
savePanelAddress,
@@ -25,6 +31,9 @@ export const SettingsPage = () => {
const [panelAddresses, setPanelAddresses] = useState<PanelAddress[]>([]);
const [newName, setNewName] = useState("");
const [newAddress, setNewAddress] = useState("");
const [updateChannel, setUpdateChannel] = useState<UpdateReleaseChannel>(
getUpdateReleaseChannel(),
);
const setPanelAddressesFunc = (newAddress: PanelAddress[]) => {
setPanelAddresses(newAddress);
@@ -79,6 +88,14 @@ export const SettingsPage = () => {
loadPanelAddresses();
}, []);
const handleUpdateChannelChange = (channel: UpdateReleaseChannel) => {
setUpdateChannel(channel);
setUpdateReleaseChannel(channel);
toast.success(
`更新通道已切换为${channel === "stable" ? "稳定版" : "开发版"}`,
);
};
return (
<div className="min-h-screen bg-gray-50 dark:bg-black">
{/* 顶部导航 */}
@@ -117,6 +134,36 @@ export const SettingsPage = () => {
{/* 内容区域 */}
<div className="max-w-4xl mx-auto px-4 py-6">
<div className="space-y-6">
<Card className="border border-gray-200 dark:border-gray-700">
<CardBody className="p-6">
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
更新设置
</h2>
<div className="space-y-2">
<Select
label="更新通道"
selectedKeys={[updateChannel]}
onSelectionChange={(keys) => {
const selected =
(Array.from(keys)[0] as UpdateReleaseChannel) || "stable";
handleUpdateChannelChange(selected);
}}
>
<SelectItem key="stable" textValue="stable">
稳定版(纯数字版本,如 2.1.4)
</SelectItem>
<SelectItem key="dev" textValue="dev">
开发版(含 alpha / beta / rc)
</SelectItem>
</Select>
<p className="text-xs text-gray-500 dark:text-gray-400">
版本提示会根据该通道检查最新版本。
</p>
</div>
</CardBody>
</Card>
{/* 添加新地址 */}
<Card className="border border-gray-200 dark:border-gray-700">
<CardBody className="p-6">
+73 -55
View File
@@ -710,63 +710,81 @@ export default function TunnelPage() {
/>
</div>
<div className="flex items-center gap-2">
<Button
color={selectMode ? "secondary" : "default"}
size="sm"
variant={selectMode ? "solid" : "flat"}
onPress={toggleSelectMode}
>
{selectMode ? "退出" : "批量"}
</Button>
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
新增
</Button>
</div>
</div>
{selectMode && selectedIds.size > 0 && (
<div className="fixed bottom-7 left-1/2 z-50 w-[calc(100vw-1rem)] max-w-max -translate-x-1/2 overflow-x-auto rounded-lg border border-divider bg-content1 p-2 shadow-lg">
<div className="flex min-w-max items-center gap-2">
<span className="text-sm text-default-600 shrink-0">
已选择 {selectedIds.size} 项
</span>
<Button
color="primary"
size="sm"
variant="flat"
onPress={selectAll}
>
全选
</Button>
<Button
color="secondary"
size="sm"
variant="flat"
onPress={deselectAll}
>
清空
</Button>
<Button
color="danger"
size="sm"
variant="flat"
onPress={() => setBatchDeleteModalOpen(true)}
>
删除
</Button>
<Button
color="primary"
isLoading={batchLoading}
size="sm"
variant="flat"
onPress={handleBatchRedeploy}
>
下发
</Button>
<div className="min-h-9 min-w-0 max-w-full overflow-x-auto touch-pan-x">
<div className="flex min-h-9 w-max min-w-full items-center justify-end gap-2 whitespace-nowrap [&>*]:shrink-0">
{selectMode ? (
<>
<span className="text-sm text-default-600 shrink-0">
已选择 {selectedIds.size} 项
</span>
<Button
color="primary"
size="sm"
variant="flat"
onPress={selectAll}
>
全选
</Button>
<Button
color="secondary"
size="sm"
variant="flat"
onPress={deselectAll}
>
清空
</Button>
<Button
color="danger"
isDisabled={selectedIds.size === 0}
size="sm"
variant="flat"
onPress={() => setBatchDeleteModalOpen(true)}
>
删除
</Button>
<Button
color="primary"
isDisabled={selectedIds.size === 0}
isLoading={batchLoading}
size="sm"
variant="flat"
onPress={handleBatchRedeploy}
>
下发
</Button>
<Button
color="secondary"
size="sm"
variant="solid"
onPress={toggleSelectMode}
>
退出
</Button>
</>
) : (
<>
<Button
className="bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-300 dark:hover:bg-sky-900/45"
color="default"
size="sm"
variant="flat"
onPress={toggleSelectMode}
>
批量
</Button>
<Button
color="primary"
size="sm"
variant="flat"
onPress={handleAdd}
>
新增
</Button>
</>
)}
</div>
</div>
)}
</div>
{/* 隧道卡片网格 */}
{tunnels.length > 0 ? (
-1
View File
@@ -638,7 +638,6 @@ export default function UserPage() {
) : (
<div className="flex w-full items-center gap-2 animate-appearance-in">
<Input
autoFocus
classNames={{
base: "bg-default-100",
input: "bg-transparent",
@@ -19,11 +19,18 @@ function mapColor(color: LinkColor) {
return "text-default-600 hover:text-default-700";
}
export function Link({ className, color = "default", ...props }: LinkProps) {
export function Link({
className,
color = "default",
children,
...props
}: LinkProps) {
return (
<a
className={cn("transition-colors", mapColor(color), className)}
{...props}
/>
>
{children}
</a>
);
}
+218
View File
@@ -0,0 +1,218 @@
export type UpdateReleaseChannel = "stable" | "dev";
export const UPDATE_CHANNEL_STORAGE_KEY = "update-release-channel";
export const UPDATE_CHANNEL_CHANGED_EVENT = "updateReleaseChannelChanged";
const CHANNEL_STABLE: UpdateReleaseChannel = "stable";
const CHANNEL_DEV: UpdateReleaseChannel = "dev";
const stableVersionPattern = /^\d+(?:\.\d+)+$/;
const testKeywordPattern = /(alpha|beta|rc)/i;
const VERSION_CACHE_TTL_MS = 10 * 60 * 1000;
type ReleaseItem = {
tag_name?: string;
draft?: boolean;
};
type LatestVersionCacheEntry = {
value: string | null;
expiresAt: number;
};
const latestVersionCache: Record<
UpdateReleaseChannel,
LatestVersionCacheEntry
> = {
stable: { value: null, expiresAt: 0 },
dev: { value: null, expiresAt: 0 },
};
const normalizeChannel = (
value: string | null | undefined,
): UpdateReleaseChannel => {
return value === CHANNEL_DEV ? CHANNEL_DEV : CHANNEL_STABLE;
};
export const getUpdateReleaseChannel = (): UpdateReleaseChannel => {
if (typeof window === "undefined") {
return CHANNEL_STABLE;
}
return normalizeChannel(localStorage.getItem(UPDATE_CHANNEL_STORAGE_KEY));
};
export const setUpdateReleaseChannel = (
channel: UpdateReleaseChannel,
): void => {
if (typeof window === "undefined") {
return;
}
localStorage.setItem(UPDATE_CHANNEL_STORAGE_KEY, normalizeChannel(channel));
window.dispatchEvent(new Event(UPDATE_CHANNEL_CHANGED_EVENT));
};
const normalizeTag = (tag: string): string => {
return tag.trim().replace(/^v/i, "");
};
type ReleaseTagChannel = UpdateReleaseChannel | null;
const releaseChannelFromTag = (tag: string): ReleaseTagChannel => {
const normalizedTag = normalizeTag(tag).toLowerCase();
if (!normalizedTag) {
return null;
}
if (stableVersionPattern.test(normalizedTag)) {
return CHANNEL_STABLE;
}
if (testKeywordPattern.test(normalizedTag)) {
return CHANNEL_DEV;
}
return null;
};
type VersionParts = {
numbers: number[];
stageRank: number;
stageNumber: number;
};
const parseVersionParts = (version: string): VersionParts => {
const normalized = normalizeTag(version).toLowerCase();
const numberMatches = normalized.match(/\d+/g) || [];
const numbers = numberMatches.map((item) => Number.parseInt(item, 10));
let stageRank = 0;
if (normalized.includes("rc")) {
stageRank = 3;
} else if (normalized.includes("beta")) {
stageRank = 2;
} else if (normalized.includes("alpha")) {
stageRank = 1;
} else if (stableVersionPattern.test(normalized)) {
stageRank = 4;
}
const stageNumberMatch = normalized.match(/(?:alpha|beta|rc)[.-]?(\d+)/);
const stageNumber = stageNumberMatch
? Number.parseInt(stageNumberMatch[1], 10)
: 0;
return {
numbers,
stageRank,
stageNumber,
};
};
export const compareVersions = (left: string, right: string): number => {
const a = parseVersionParts(left);
const b = parseVersionParts(right);
const maxLength = Math.max(a.numbers.length, b.numbers.length);
for (let i = 0; i < maxLength; i += 1) {
const aValue = a.numbers[i] || 0;
const bValue = b.numbers[i] || 0;
if (aValue !== bValue) {
return aValue - bValue;
}
}
if (a.stageRank !== b.stageRank) {
return a.stageRank - b.stageRank;
}
if (a.stageNumber !== b.stageNumber) {
return a.stageNumber - b.stageNumber;
}
return 0;
};
const repoPathFromUrl = (repoUrl: string): string | null => {
try {
const parsed = new URL(repoUrl);
const segments = parsed.pathname
.replace(/\.git$/i, "")
.split("/")
.filter(Boolean);
if (segments.length < 2) {
return null;
}
return `${segments[0]}/${segments[1]}`;
} catch {
return null;
}
};
export const getLatestVersionByChannel = async (
channel: UpdateReleaseChannel,
repoUrl: string,
): Promise<string | null> => {
const normalizedChannel = normalizeChannel(channel);
const now = Date.now();
const cached = latestVersionCache[normalizedChannel];
if (cached.value && cached.expiresAt > now) {
return cached.value;
}
const repoPath = repoPathFromUrl(repoUrl);
if (!repoPath) {
return null;
}
const response = await fetch(
`https://api.github.com/repos/${repoPath}/releases?per_page=50`,
{
headers: {
Accept: "application/vnd.github+json",
},
},
);
if (!response.ok) {
return null;
}
const releases = (await response.json()) as ReleaseItem[];
const candidateTags = releases
.filter((release) => !release.draft && typeof release.tag_name === "string")
.map((release) => (release.tag_name || "").trim())
.filter((tag) => releaseChannelFromTag(tag) === normalizedChannel);
if (candidateTags.length === 0) {
return null;
}
const latest = candidateTags.sort((a, b) => compareVersions(b, a))[0];
latestVersionCache[normalizedChannel] = {
value: latest,
expiresAt: now + VERSION_CACHE_TTL_MS,
};
return latest;
};
export const hasVersionUpdate = (
currentVersion: string,
latestVersion: string,
): boolean => {
return (
compareVersions(normalizeTag(currentVersion), normalizeTag(latestVersion)) <
0
);
};
+8
View File
@@ -9,3 +9,11 @@ interface ImportMetaEnv {
interface ImportMeta {
readonly env: ImportMetaEnv;
}
declare module "virtual:pwa-register" {
export function registerSW(options?: {
immediate?: boolean;
onNeedRefresh?: () => void;
onOfflineReady?: () => void;
}): (reloadPage?: boolean) => Promise<void>;
}