Compare commits

...

3 Commits

Author SHA1 Message Date
sagitchu 86477728c9 fix(user): revoke tunnel access when disabling accounts
Pause every reachable entry without self-healing missing services, aggregate unconfirmed nodes for retry, and show persistent partial-disable feedback. Refs #562.
2026-11-01 17:53:40 +08:00
tsosunchia 3549fe5ad8 fix(udp): preserve wildcard reply sources and isolate destination sessions (#565)
* fix(udp): preserve local destinations and unicast reply sources on Linux

* fix(udp): select unicast sources for IPv6 multicast replies

* fix(udp): retain interface zones for link-local multicast sessions

* fix(udp): isolate interface-local multicast sessions

* fix(udp): include ingress interfaces in session identity

* fix(udp): restrict destination zones to IPv6

* ci(udp): follow rc5 patched Go toolchain requirement

---------

Co-authored-by: sagit <36596628+Sagit-chu@users.noreply.github.com>
2026-10-24 18:35:22 +08:00
Sebastian Luo dd3ed4e284 fix(frontend): keep node id when upgrading from list view (#567)
In list view, the per-node upgrade button set upgradeTargetNodeId and then
called openUpgradeModal("single") without an id, which resets the target to
null. handleConfirmUpgrade then matched neither branch, so "确认升级" sent no
request and the modal stayed open. Pass node.id the same way grid view does.

Co-authored-by: Sebastian Luo <sebastianluo@Sebastians-MacBook-Pro.local>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 18:18:20 +08:00
18 changed files with 1753 additions and 41 deletions
+87
View File
@@ -0,0 +1,87 @@
name: UDP Destination Regression
on:
push:
branches: ['main', 'fix/udp-wildcard-source', 'pr/udp-wildcard-source']
pull_request:
paths:
- 'go-gost/**'
- '.github/workflows/udp-destination.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: udp-destination-${{ github.ref }}
cancel-in-progress: true
jobs:
linux:
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: 'go-gost/go.mod'
cache-dependency-path: |
go-gost/go.sum
go-gost/x/go.sum
- name: Test agent module
working-directory: go-gost
run: go test ./...
- name: Test protocol module
working-directory: go-gost/x
run: go test ./...
- name: Check UDP races
working-directory: go-gost/x
run: go test -race ./internal/net/udp ./listener/udp
- name: Vet UDP implementation
working-directory: go-gost/x
run: go vet ./internal/net/udp ./listener/udp
- name: Build destination regression executable
working-directory: go-gost/x
run: |
go test -c -race -o "$RUNNER_TEMP/udp-destination.test" ./listener/udp
go test -c -race -o "$RUNNER_TEMP/udp-packet.test" ./internal/net/udp
- name: Test multiple destinations in isolated network namespace
run: |
sudo unshare --net -- sh -eu -c '
sysctl -qw net.ipv6.conf.all.disable_ipv6=0
ip link set lo up
ip addr add 198.18.0.1/32 dev lo
ip addr add 169.254.1.2/32 dev lo
ip -6 addr add fd00:1::1/128 dev lo nodad
ip -6 addr add fd00:2::1/128 dev lo nodad
ip -6 addr add fd00:3::1/128 dev lo nodad
for suffix in "" 2; do
ip link add "mc-tx$suffix" type veth peer name "mc-rx$suffix"
ip link set "mc-tx$suffix" addrgenmode none up
ip link set "mc-rx$suffix" addrgenmode none up
ip -6 addr add fe80::1/64 dev "mc-tx$suffix" nodad
ip -6 addr add fe80::2/64 dev "mc-rx$suffix" nodad
done
ip addr add 198.18.1.1/24 dev mc-tx
ip addr add 198.18.2.1/24 dev mc-tx2
export FLUX_TEST_IPV6_ADDRS=fd00:1::1,fd00:2::1,fd00:3::1
export FLUX_TEST_IPV4_LINK_LOCAL=169.254.1.2
export FLUX_TEST_MULTICAST_RX=mc-rx FLUX_TEST_MULTICAST_TX=mc-tx
export FLUX_TEST_MULTICAST_RX2=mc-rx2 FLUX_TEST_MULTICAST_TX2=mc-tx2
"$1" -test.v -test.timeout=60s -test.count=5
exec "$2" -test.v -test.timeout=60s -test.count=5
' sh "$RUNNER_TEMP/udp-destination.test" "$RUNNER_TEMP/udp-packet.test"
- name: Build Linux agent
working-directory: go-gost
env:
CGO_ENABLED: '0'
GOOS: linux
GOARCH: amd64
run: go build -trimpath -o "$RUNNER_TEMP/flux-agent" .
+133
View File
@@ -0,0 +1,133 @@
# Preserve UDP reply source addresses on Linux
The Linux UDP listener preserves the destination address of each incoming
datagram so that replies use the correct local source. This change was developed
against FLVX `3.0.1-rc4`, commit
`d9303032cad09b33cd0b7c91704fd671fbfdd078`.
It also includes the `3.0.1-rc5` runtime update at
`c794586cc7773e5f967783895d3071538442bbf7`.
## Problem
A wildcard UDP socket bound to `0.0.0.0:port` or `[::]:port` can receive datagrams
addressed to several local IP addresses. Previously, the listener retained only
the client address. A reply sent through `WriteTo` could therefore use a source
IP chosen by the kernel instead of the IP the client contacted. DNS clients, for
example, can reject such replies as coming from an unexpected source.
The UDP session pool was also keyed only by the client address and port. When
one client socket contacted multiple local addresses on the same listener port,
these requests could share a session despite having different destinations.
## Changes
- Linux wildcard UDP sockets enable IPv4/IPv6 packet information. `recvmsg`
preserves the datagram's local destination and interface; `sendmsg` uses the
corresponding source address and interface when replying.
- IPv4 keeps the original destination (`ipi_addr`) separate from the local
reply address (`ipi_spec_dst`), so broadcast requests receive unicast replies
rather than attempting to send with a broadcast source address.
- IPv6 multicast destinations remain part of session identity, but replies
leave the source unspecified and retain the received interface index so the
kernel can select a valid local unicast source.
- The destination metadata travels through existing metrics, statistics,
admission and traffic-limiter wrappers. The exposed remote address remains
the real client address.
- Sessions are keyed by the local destination endpoint, remote client endpoint
and received interface index. Identical IPv4 broadcast or multicast endpoints
on different interfaces therefore retain separate reply control information.
An accepted session reports its actual local destination.
Link-local IPv6 unicast and link-local or interface-local multicast
destinations retain their interface zone, keeping identical multicast groups
on different interfaces separate even when the client uses the same
non-link-local IP and source port.
IPv4 destinations, including link-local unicast and multicast, never receive
an IPv6 zone; their interface isolation uses the separate index in the key.
- Explicitly bound UDP sockets retain their existing behavior. Non-Linux
platforms retain their existing implementation. No configuration keys or
panel API changes are required.
The fix affects the shared UDP listener, including services dynamically created
after startup. It does not configure listening addresses, NAT, routing or
firewalls. Multi-homed hosts still need correct source policy routing for their
uplinks; this patch does not fix SSH/TCP routing or packet loss before ingress.
## Build
Use Go 1.26.10 or a compatible newer toolchain. From the repository root:
```sh
cd go-gost
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath \
-ldflags '-s -w' -o flux_agent .
```
Use the agent with the normal panel integration and forwarding configuration.
Selecting a wildcard listener enables destination preservation automatically
on Linux. The patch does not select a listener address or change the upstream
connection's outbound interface.
## Regression tests
The Linux destination tests cover IPv4 wildcard and dual-stack wildcard sockets,
explicit IPv4/IPv6 bindings, one client source port contacting multiple local
destinations, repeated requests, session expiry, and active metrics, statistics,
admission and traffic-limit wrappers. A separate IPv6 case binds the client to
`::1` and checks the exact remote IP and port for every destination. Broadcast
tests exercise both IPv4 and dual-stack listeners, retaining the broadcast
destination while checking that replies use a local unicast address.
An IPv6 multicast regression sends across an isolated veth pair and checks the
multicast session destination and the unicast reply, including interface zones.
A two-interface case uses one fixed ULA client endpoint to contact the same
link-local or interface-local multicast group on both interfaces and checks
distinct sessions and their individual reply-interface metadata.
IPv4 and dual-stack cases likewise use a single fixed client endpoint to send
to one multicast group or the limited broadcast address on two interfaces,
checking separate sessions and their reply-interface indices.
Link-local IPv4 unicast and multicast regressions also check that accepted
local endpoints have no IPv6 zone, on both IPv4 and dual-stack listeners.
Run the standard suites on Linux:
```sh
(cd go-gost && go test ./...)
(cd go-gost/x && go test ./...)
```
To exercise multiple IPv6 addresses without modifying the host's live network,
compile the tests first and run them inside a disposable network namespace.
The following commands require Linux, root access for `unshare`, and `iproute2`:
```sh
cd go-gost/x
go test -c -race -o /tmp/flvx-udp-destination.test ./listener/udp
go test -c -race -o /tmp/flvx-udp-packet.test ./internal/net/udp
sudo unshare --net -- sh -eu -c '
sysctl -qw net.ipv6.conf.all.disable_ipv6=0
ip link set lo up
ip addr add 198.18.0.1/32 dev lo
ip addr add 169.254.1.2/32 dev lo
ip -6 addr add fd00:1::1/128 dev lo nodad
ip -6 addr add fd00:2::1/128 dev lo nodad
ip -6 addr add fd00:3::1/128 dev lo nodad
for suffix in "" 2; do
ip link add "mc-tx$suffix" type veth peer name "mc-rx$suffix"
ip link set "mc-tx$suffix" addrgenmode none up
ip link set "mc-rx$suffix" addrgenmode none up
ip -6 addr add fe80::1/64 dev "mc-tx$suffix" nodad
ip -6 addr add fe80::2/64 dev "mc-rx$suffix" nodad
done
ip addr add 198.18.1.1/24 dev mc-tx
ip addr add 198.18.2.1/24 dev mc-tx2
export FLUX_TEST_IPV6_ADDRS=fd00:1::1,fd00:2::1,fd00:3::1
export FLUX_TEST_IPV4_LINK_LOCAL=169.254.1.2
export FLUX_TEST_MULTICAST_RX=mc-rx FLUX_TEST_MULTICAST_TX=mc-tx
export FLUX_TEST_MULTICAST_RX2=mc-rx2 FLUX_TEST_MULTICAST_TX2=mc-tx2
/tmp/flvx-udp-destination.test -test.v -test.timeout=60s -test.count=5
exec /tmp/flvx-udp-packet.test -test.v -test.timeout=60s -test.count=5
'
```
The addresses above are isolated test addresses, not deployment settings.
The `UDP Destination Regression` GitHub Actions workflow runs these namespace
tests, the race detector, both agent module suites, and a Linux agent build.
@@ -534,7 +534,22 @@ func buildForwardServiceDeleteNames(bases []string) []string {
return names
}
type forwardServiceControlOptions struct {
tolerateNotFound bool
tolerateOffline bool
allowSelfHeal bool
continueOnError bool
}
func (h *Handler) controlForwardServices(forward *forwardRecord, commandType string, tolerateNotFound bool) error {
return h.controlForwardServicesWithOptions(forward, commandType, forwardServiceControlOptions{
tolerateNotFound: tolerateNotFound,
tolerateOffline: true,
allowSelfHeal: true,
})
}
func (h *Handler) controlForwardServicesWithOptions(forward *forwardRecord, commandType string, opts forwardServiceControlOptions) error {
if h == nil || forward == nil {
return errors.New("invalid forward control context")
}
@@ -561,6 +576,7 @@ func (h *Handler) controlForwardServices(forward *forwardRecord, commandType str
candidateTunnelIDs = append(candidateTunnelIDs, userTunnelIDs...)
candidateTunnelIDs = append(candidateTunnelIDs, allUserTunnelIDs...)
bases := buildForwardServiceBaseCandidates(forward.ID, forward.UserID, userTunnelID, candidateTunnelIDs)
var nodeErrors []error
if strings.EqualFold(strings.TrimSpace(commandType), "DeleteService") {
seen := map[int64]struct{}{}
for _, fp := range ports {
@@ -569,16 +585,20 @@ func (h *Handler) controlForwardServices(forward *forwardRecord, commandType str
}
seen[fp.NodeID] = struct{}{}
if err := h.deleteForwardServiceBasesOnNode(fp.NodeID, bases); err != nil {
if isNodeOfflineOrTimeoutError(err) {
if opts.tolerateOffline && isNodeOfflineOrTimeoutError(err) {
continue
}
if tolerateNotFound && isNotFoundError(err) {
if opts.tolerateNotFound && isNotFoundError(err) {
continue
}
if opts.continueOnError {
nodeErrors = append(nodeErrors, h.nodeCommandError(fp.NodeID, err))
continue
}
return err
}
}
return nil
return errors.Join(nodeErrors...)
}
seen := map[int64]struct{}{}
healed := false
@@ -588,37 +608,50 @@ func (h *Handler) controlForwardServices(forward *forwardRecord, commandType str
}
seen[fp.NodeID] = struct{}{}
nodeHandled, lastNotFoundErr, err := h.controlForwardServicesOnNode(fp.NodeID, bases, commandType)
if err != nil {
if isNodeOfflineOrTimeoutError(err) {
continue
}
return err
}
if !nodeHandled && lastNotFoundErr != nil && !healed && shouldSelfHealForwardServiceControl(commandType) {
if healErr := h.syncForwardServices(forward, "UpdateService", true); healErr != nil {
return healErr
}
healed = true
nodeHandled, lastNotFoundErr, err = h.controlForwardServicesOnNode(fp.NodeID, bases, commandType)
nodeErr := func() error {
nodeHandled, lastNotFoundErr, err := h.controlForwardServicesOnNode(fp.NodeID, bases, commandType)
if err != nil {
if opts.tolerateOffline && isNodeOfflineOrTimeoutError(err) {
return nil
}
return err
}
}
if nodeHandled {
continue
if opts.allowSelfHeal && !nodeHandled && lastNotFoundErr != nil && !healed && shouldSelfHealForwardServiceControl(commandType) {
if healErr := h.syncForwardServices(forward, "UpdateService", true); healErr != nil {
return healErr
}
healed = true
nodeHandled, lastNotFoundErr, err = h.controlForwardServicesOnNode(fp.NodeID, bases, commandType)
if err != nil {
return err
}
}
if nodeHandled || opts.tolerateNotFound {
return nil
}
if lastNotFoundErr != nil {
return lastNotFoundErr
}
return errors.New("service control failed")
}()
if nodeErr != nil {
if !opts.continueOnError {
return nodeErr
}
nodeErrors = append(nodeErrors, h.nodeCommandError(fp.NodeID, nodeErr))
}
if tolerateNotFound {
continue
}
if lastNotFoundErr != nil {
return lastNotFoundErr
}
return errors.New("service control failed")
}
return nil
return errors.Join(nodeErrors...)
}
func (h *Handler) nodeCommandError(nodeID int64, err error) error {
name := strconv.FormatInt(nodeID, 10)
if node, lookupErr := h.getNodeRecord(nodeID); lookupErr == nil && node != nil {
name = defaultString(strings.TrimSpace(node.Name), name)
}
return fmt.Errorf("节点 %s (ID %d): %w", name, nodeID, err)
}
func (h *Handler) controlForwardServicesOnNode(nodeID int64, bases []string, commandType string) (bool, error, error) {
@@ -3,6 +3,7 @@ package handler
import (
"encoding/json"
"errors"
"fmt"
"log"
"math"
"strconv"
@@ -393,6 +394,48 @@ func (h *Handler) getUserTunnelPolicy(userTunnelID int64) (*userTunnelPolicy, er
}, nil
}
// disableUserAccess retains unconfirmed forwards as active so a repeated disable
// can retry them. Login and tunnel access remain disabled when a node is offline.
func (h *Handler) disableUserAccess(userID int64, now int64) error {
permissionErr := h.repo.DisableUserTunnels(userID)
forwards, err := h.listActiveForwardsByUser(userID)
if err != nil {
return errors.Join(permissionErr, err)
}
errs := []error{permissionErr}
for i := range forwards {
forward := &forwards[i]
nftMode, entryNodeIDs, pauseErr := h.tunnelUsesNftables(forward.TunnelID)
if pauseErr == nil {
if nftMode {
// Exclude the rule from the desired plan before marking its pause
// confirmed. A failed SSH apply must remain eligible for retry.
pauseErr = h.syncNftablesNode(entryNodeIDs[0], forward.ID)
if pauseErr != nil {
pauseErr = h.nodeCommandError(entryNodeIDs[0], pauseErr)
}
} else {
// Missing services are already stopped. Self-healing would recreate
// services on every entry and could restart one already paused.
pauseErr = h.controlForwardServicesWithOptions(forward, "PauseService", forwardServiceControlOptions{
tolerateNotFound: true,
tolerateOffline: false,
allowSelfHeal: false,
continueOnError: true,
})
}
}
if pauseErr != nil {
errs = append(errs, fmt.Errorf("转发 %d 暂停失败: %w", forward.ID, pauseErr))
continue
}
if err := h.repo.UpdateForwardStatus(forward.ID, 0, now); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
func (h *Handler) pauseUserForwards(userID int64, now int64) {
forwards, err := h.listActiveForwardsByUser(userID)
if err != nil {
@@ -205,6 +205,15 @@ func (h *Handler) userUpdate(w http.ResponseWriter, r *http.Request) {
}
}
if status != 1 {
// Revoke access before optional quota/group updates can fail or return.
if err := h.disableUserAccess(id, now); err != nil {
result := response.Err(-2, fmt.Sprintf("账号已禁用,以下权限或节点暂停未确认;其他转发已继续处理,请排查后重试禁用:\n%v", err))
result.Data = map[string]interface{}{"userDisabled": true}
response.WriteJSON(w, result)
return
}
}
h.repo.PropagateUserFlowToTunnels(id, flow, num, expTime, flowResetTime, flowMiB)
if hasDailyQuota || hasMonthlyQuota {
dailyQuotaGB := asInt64(req["dailyQuotaGB"], 0)
@@ -243,6 +252,13 @@ func (h *Handler) userUpdate(w http.ResponseWriter, r *http.Request) {
}
}
}
if status != 1 {
// Group changes may have created additional tunnel grants.
if err := h.repo.DisableUserTunnels(id); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
}
if oldUser.MaxConn != maxConn {
warnings, syncErr := h.syncUserMaxConnForwards(id)
if syncErr != nil {
@@ -7,6 +7,7 @@ import (
"fmt"
"net"
"net/http"
"slices"
"strings"
"time"
@@ -150,7 +151,7 @@ func (h *Handler) validateNftablesTunnelStateTx(tx *gorm.DB, entryNodeIDs []int6
return nil
}
func (h *Handler) buildNftablesNodePlan(nodeID int64) (runtimenft.NodePlan, *model.NodeSSHConfig, error) {
func (h *Handler) buildNftablesNodePlan(nodeID int64, excludedForwardIDs ...int64) (runtimenft.NodePlan, *model.NodeSSHConfig, error) {
cfg, err := h.repo.GetNodeSSHConfig(nodeID)
if err != nil {
return runtimenft.NodePlan{}, nil, err
@@ -162,6 +163,9 @@ func (h *Handler) buildNftablesNodePlan(nodeID int64) (runtimenft.NodePlan, *mod
plan := runtimenft.NodePlan{NodeID: nodeID, Rules: make([]runtimenft.Rule, 0, len(forwards))}
for i := range forwards {
forward := &forwards[i]
if slices.Contains(excludedForwardIDs, forward.ID) {
continue
}
tunnel, err := h.getTunnelRecord(forward.TunnelID)
if err != nil || tunnel == nil || tunnel.Status != 1 {
continue
@@ -201,11 +205,11 @@ func (h *Handler) buildNftablesNodePlan(nodeID int64) (runtimenft.NodePlan, *mod
return plan, cfg, nil
}
func (h *Handler) syncNftablesNode(nodeID int64) error {
func (h *Handler) syncNftablesNode(nodeID int64, excludedForwardIDs ...int64) error {
if h == nil || h.repo == nil {
return errors.New("handler not initialized")
}
plan, cfgModel, err := h.buildNftablesNodePlan(nodeID)
plan, cfgModel, err := h.buildNftablesNodePlan(nodeID, excludedForwardIDs...)
if err != nil {
return err
}
@@ -0,0 +1,527 @@
package handler
import (
"bytes"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"go-backend/internal/http/response"
"go-backend/internal/store/model"
"go-backend/internal/ws"
)
func TestUserUpdateDisablesPermissionsAndRunningForwards(t *testing.T) {
for _, password := range []string{"", "replacement-password"} {
t.Run(fmt.Sprintf("password_changed=%t", password != ""), func(t *testing.T) {
a := newCleanupAgent(t)
now := time.Now().UnixMilli()
for _, userID := range []int64{2, 3} {
if err := a.h.repo.DB().Create(&model.User{ID: userID, User: fmt.Sprintf("user%d", userID), Pwd: "pwd", RoleID: 1, Status: 1, ExpTime: now + 86400000, Flow: 100, Num: 10}).Error; err != nil {
t.Fatal(err)
}
}
if err := a.h.repo.DB().Create(&model.Tunnel{ID: 1, Name: "test", Type: 1, Protocol: "tls", Status: 1, Flow: 1}).Error; err != nil {
t.Fatal(err)
}
for _, permission := range []model.UserTunnel{
{ID: 10, UserID: 2, TunnelID: 1, Status: 1, Flow: 100, Num: 10},
{ID: 11, UserID: 2, TunnelID: 2, Status: 0, Flow: 100, Num: 10},
{ID: 12, UserID: 3, TunnelID: 1, Status: 1, Flow: 100, Num: 10},
} {
if err := a.h.repo.DB().Create(&permission).Error; err != nil {
t.Fatal(err)
}
}
for _, forward := range []model.Forward{
{ID: 20, UserID: 2, UserName: "user2", Name: "running", TunnelID: 1, RemoteAddr: "127.0.0.1:80", Status: 1},
{ID: 21, UserID: 2, UserName: "user2", Name: "paused", TunnelID: 1, RemoteAddr: "127.0.0.1:81", Status: 0},
{ID: 22, UserID: 3, UserName: "user3", Name: "other-user", TunnelID: 1, RemoteAddr: "127.0.0.1:82", Status: 1},
} {
if err := a.h.repo.DB().Create(&forward).Error; err != nil {
t.Fatal(err)
}
if err := a.h.repo.DB().Create(&model.ForwardPort{ForwardID: forward.ID, NodeID: 1, Port: 31000 + int(forward.ID)}).Error; err != nil {
t.Fatal(err)
}
}
update := func(status int) {
t.Helper()
payload, err := json.Marshal(map[string]interface{}{"id": 2, "user": "user2", "status": status, "pwd": password, "flow": 100, "num": 10, "expTime": now + 86400000})
if err != nil {
t.Fatal(err)
}
res := httptest.NewRecorder()
a.h.userUpdate(res, httptest.NewRequest(http.MethodPost, "/api/v1/user/update", bytes.NewReader(payload)))
var result response.R
if err := json.Unmarshal(res.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if result.Code != 0 {
t.Fatalf("update user: %d %s", result.Code, result.Msg)
}
}
update(1)
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM user_tunnel WHERE id = 10"); got != 1 {
t.Errorf("ordinary account update disabled an enabled permission: %d", got)
}
if len(a.commandsOfType("PauseService")) != 0 {
t.Error("ordinary account update paused services")
}
update(0)
for _, check := range []struct {
table string
id int64
want int
}{{"user", 2, 0}, {"user_tunnel", 10, 0}, {"user_tunnel", 11, 0}, {"user_tunnel", 12, 1}, {"forward", 20, 0}, {"forward", 21, 0}, {"forward", 22, 1}} {
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM "+check.table+" WHERE id = ?", check.id); got != check.want {
t.Errorf("%s %d status = %d, want %d", check.table, check.id, got, check.want)
}
}
commands := a.commandsOfType("PauseService")
if len(commands) == 0 {
t.Error("running forward never received PauseService")
}
for _, command := range commands {
var data struct {
Services []string `json:"services"`
}
if err := json.Unmarshal(command.Data, &data); err != nil {
t.Fatal(err)
}
for _, name := range data.Services {
if !strings.HasPrefix(name, "20_2_") {
t.Errorf("paused unrelated service %q", name)
}
}
}
update(0)
if len(a.commandsOfType("PauseService")) != len(commands) {
t.Error("repeated disable replayed pauses for already paused forwards")
}
// Account edits and re-enabling must not grant disabled permissions or
// resume forwards that an administrator previously paused.
update(1)
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM user_tunnel WHERE id = 10"); got != 0 {
t.Errorf("account re-enable unexpectedly granted a disabled permission: %d", got)
}
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM user_tunnel WHERE id = 11"); got != 0 {
t.Errorf("re-enabled independently disabled permission: %d", got)
}
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM forward WHERE id = 21"); got != 0 {
t.Errorf("resumed previously paused forward: %d", got)
}
if len(a.commandsOfType("ResumeService")) != 0 {
t.Error("account re-enable unexpectedly resumed services")
}
})
}
}
func TestUserDisableMultiEntryNeverRecreatesMissingServices(t *testing.T) {
for _, secondNodeState := range []string{"missing", "offline", "timeout"} {
t.Run(secondNodeState, func(t *testing.T) {
a := newCleanupAgent(t)
var remoteMu sync.Mutex
remoteServices := make(map[string]bool)
var remoteCommands []string
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var command struct {
Type string `json:"commandType"`
Data json.RawMessage `json:"data"`
}
if err := json.NewDecoder(r.Body).Decode(&command); err != nil {
t.Error(err)
return
}
remoteMu.Lock()
defer remoteMu.Unlock()
remoteCommands = append(remoteCommands, command.Type)
switch command.Type {
case "UpdateService", "AddService":
var services []struct {
Name string `json:"name"`
}
if err := json.Unmarshal(command.Data, &services); err != nil {
t.Error(err)
return
}
for _, service := range services {
remoteServices[service.Name] = true
}
case "PauseService":
if secondNodeState != "missing" {
message := "节点不在线"
if secondNodeState == "timeout" {
message = "等待节点响应超时"
}
response.WriteJSON(w, response.ErrDefault(message))
return
}
var data struct {
Services []string `json:"services"`
}
if err := json.Unmarshal(command.Data, &data); err != nil {
t.Error(err)
return
}
for _, name := range data.Services {
if _, exists := remoteServices[name]; !exists {
response.WriteJSON(w, response.OK(map[string]interface{}{"success": false, "message": "service not found"}))
return
}
remoteServices[name] = false
}
}
response.WriteJSON(w, response.OK(map[string]interface{}{"success": true, "type": command.Type}))
}))
defer remote.Close()
now := time.Now().UnixMilli()
userID, err := a.h.repo.CreateUser("multi-entry-disable", "pwd", 1, now+86400000, 100, 1, 10, 1, 0, now)
if err != nil {
t.Fatal(err)
}
for _, value := range []interface{}{
&model.Node{ID: 2, Name: "second-entry", Secret: "test", ServerIP: "127.0.0.1", Status: 1, IsRemote: 1, RemoteURL: sql.NullString{String: remote.URL, Valid: true}, RemoteToken: sql.NullString{String: "test-token", Valid: true}},
&model.Tunnel{ID: 1, Name: "multi-entry", Type: 1, Status: 1, Protocol: "tls", Flow: 1},
&model.ChainTunnel{TunnelID: 1, NodeID: 1, ChainType: "1"},
&model.ChainTunnel{TunnelID: 1, NodeID: 2, ChainType: "1"},
&model.UserTunnel{ID: 10, UserID: userID, TunnelID: 1, Status: 1, Flow: 100, Num: 10},
&model.Forward{ID: 20, UserID: userID, UserName: "multi-entry-disable", Name: "running", TunnelID: 1, RemoteAddr: "127.0.0.1:80", Status: 1},
&model.ForwardPort{ForwardID: 20, NodeID: 1, Port: 31020},
&model.ForwardPort{ForwardID: 20, NodeID: 2, Port: 31020},
} {
if err := a.h.repo.DB().Create(value).Error; err != nil {
t.Fatal(err)
}
}
res := postJSONToHandler(t, a.h.userUpdate, map[string]interface{}{"id": userID, "user": "multi-entry-disable", "status": 0})
var out response.R
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
wantStatus := 1
if secondNodeState == "missing" {
wantStatus = 0
if out.Code != 0 {
t.Errorf("missing service should count as stopped: %s", out.Msg)
}
} else if out.Code == 0 {
t.Error("unconfirmed second entry pause returned success")
}
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM forward WHERE id = 20"); got != wantStatus {
t.Errorf("forward status = %d, want %d", got, wantStatus)
}
a.mu.Lock()
commands := append([]cleanupAgentCommand(nil), a.commands...)
a.mu.Unlock()
firstEntryRunning := true
for _, command := range commands {
switch command.Type {
case "PauseService":
firstEntryRunning = false
case "UpdateService", "AddService", "ResumeService":
firstEntryRunning = true
t.Errorf("disable sent %s to already paused entry A", command.Type)
}
}
if firstEntryRunning {
t.Error("entry A is running after disable")
}
remoteMu.Lock()
defer remoteMu.Unlock()
if len(remoteCommands) == 0 {
t.Error("entry B was never checked")
}
for _, command := range remoteCommands {
if command != "PauseService" {
t.Errorf("disable sent %s to entry B", command)
}
}
for name, running := range remoteServices {
if running {
t.Errorf("entry B service %s is still running", name)
}
}
})
}
}
func TestUserDisableContinuesAcrossNodesAndForwards(t *testing.T) {
for _, failure := range []string{"节点不在线", "等待节点响应超时", "pause rejected"} {
for _, failedNodeFirst := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/failed_node_first=%t", failure, failedNodeFirst), func(t *testing.T) {
a := newCleanupAgent(t)
var mu sync.Mutex
recovered := false
attempts := make(map[string]int)
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var command struct {
Type string `json:"commandType"`
}
if err := json.NewDecoder(r.Body).Decode(&command); err != nil {
t.Error(err)
return
}
if command.Type != "PauseService" {
t.Errorf("disable sent %s", command.Type)
}
mu.Lock()
attempts[r.Header.Get("Authorization")]++
isRecovered := recovered
mu.Unlock()
if !isRecovered {
response.WriteJSON(w, response.ErrDefault(failure))
return
}
response.WriteJSON(w, response.OK(map[string]interface{}{"success": true, "type": command.Type}))
}))
defer remote.Close()
now := time.Now().UnixMilli()
userID, err := a.h.repo.CreateUser("continue-disable", "pwd", 1, now+86400000, 100, 1, 10, 1, 0, now)
if err != nil {
t.Fatal(err)
}
for _, value := range []interface{}{
&model.Tunnel{ID: 1, Name: "multi-entry", Type: 2, Status: 1, Flow: 1},
&model.Node{ID: 4, Name: "offline-middle", Secret: "test", ServerIP: "127.0.0.1", Status: 0},
&model.ChainTunnel{TunnelID: 1, NodeID: 4, ChainType: "2"},
&model.UserTunnel{ID: 10, UserID: userID, TunnelID: 1, Status: 1, Flow: 100, Num: 10},
&model.Forward{ID: 20, UserID: userID, UserName: "continue-disable", Name: "mixed-nodes", TunnelID: 1, RemoteAddr: "127.0.0.1:80", Status: 1},
&model.Forward{ID: 21, UserID: userID, UserName: "continue-disable", Name: "healthy-forward", TunnelID: 1, RemoteAddr: "127.0.0.1:81", Status: 1},
} {
if err := a.h.repo.DB().Create(value).Error; err != nil {
t.Fatal(err)
}
}
for _, nodeID := range []int64{2, 3} {
node := model.Node{ID: nodeID, Name: fmt.Sprintf("failed-node-%d", nodeID), Secret: "test", ServerIP: "127.0.0.1", Status: 1, IsRemote: 1, RemoteURL: sql.NullString{String: remote.URL, Valid: true}, RemoteToken: sql.NullString{String: fmt.Sprintf("test-node-%d", nodeID), Valid: true}}
if err := a.h.repo.DB().Create(&node).Error; err != nil {
t.Fatal(err)
}
}
nodeOrder := []int64{1, 2, 3}
if failedNodeFirst {
nodeOrder = []int64{2, 1, 3}
}
for _, nodeID := range nodeOrder {
if err := a.h.repo.DB().Create(&model.ForwardPort{ForwardID: 20, NodeID: nodeID, Port: 31020}).Error; err != nil {
t.Fatal(err)
}
if err := a.h.repo.DB().Create(&model.ChainTunnel{TunnelID: 1, NodeID: nodeID, ChainType: "1"}).Error; err != nil {
t.Fatal(err)
}
}
if err := a.h.repo.DB().Create(&model.ForwardPort{ForwardID: 21, NodeID: 1, Port: 31021}).Error; err != nil {
t.Fatal(err)
}
update := func() response.R {
t.Helper()
res := postJSONToHandler(t, a.h.userUpdate, map[string]interface{}{"id": userID, "user": "continue-disable", "status": 0})
var out response.R
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
return out
}
out := update()
if out.Code == 0 {
t.Error("partial node failure reported complete success")
}
state, ok := out.Data.(map[string]interface{})
if !ok || state["userDisabled"] != true {
t.Errorf("partial response did not tell the UI the account is disabled: %#v", out.Data)
}
for _, nodeID := range []int64{2, 3} {
if !strings.Contains(out.Msg, fmt.Sprintf("failed-node-%d", nodeID)) || !strings.Contains(out.Msg, fmt.Sprintf("ID %d", nodeID)) {
t.Errorf("failure response did not identify node %d: %s", nodeID, out.Msg)
}
mu.Lock()
count := attempts[fmt.Sprintf("Bearer test-node-%d", nodeID)]
mu.Unlock()
if count == 0 {
t.Errorf("failure on another node prevented node %d from being attempted", nodeID)
}
}
paused := make(map[string]bool)
for _, command := range a.commandsOfType("PauseService") {
var data struct {
Services []string `json:"services"`
}
if err := json.Unmarshal(command.Data, &data); err != nil {
t.Fatal(err)
}
for _, name := range data.Services {
paused[strings.Split(name, "_")[0]] = true
}
}
for _, id := range []string{"20", "21"} {
if !paused[id] {
t.Errorf("healthy node never paused forward %s", id)
}
}
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM forward WHERE id = 20"); got != 1 {
t.Errorf("unconfirmed forward status = %d", got)
}
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM forward WHERE id = 21"); got != 0 {
t.Errorf("healthy later forward was not paused: %d", got)
}
mu.Lock()
recovered = true
mu.Unlock()
if retry := update(); retry.Code != 0 {
t.Errorf("retry after recovery failed: %s", retry.Msg)
}
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM forward WHERE id = 20"); got != 0 {
t.Errorf("retry did not confirm all nodes: %d", got)
}
for _, kind := range []string{"UpdateService", "AddService", "ResumeService"} {
if len(a.commandsOfType(kind)) != 0 {
t.Errorf("disable sent %s", kind)
}
}
})
}
}
}
func TestUserDisableRemovesNftablesRuleOnlyAfterConfirmedApply(t *testing.T) {
fixture := setupNftablesHandler(t)
h := fixture.handler
t.Cleanup(func() { _ = h.repo.Close() })
seedNftablesSSHConfig(t, h, fixture.nodeID)
tunnelID := seedTunnelForNftables(t, h, "disable-nft", fixture.nodeID)
forward := seedForwardForNftables(t, h, tunnelID, fixture.nodeID, "203.0.113.9:8080")
now := time.Now().UnixMilli()
userID, err := h.repo.CreateUser("disable-nft", "pwd", 1, now+86400000, 100, 1, 10, 1, 0, now)
if err != nil {
t.Fatal(err)
}
if err := h.repo.DB().Model(&model.Forward{}).Where("id = ?", forward.ID).Update("user_id", userID).Error; err != nil {
t.Fatal(err)
}
if err := h.repo.DB().Create(&model.UserTunnel{UserID: userID, TunnelID: tunnelID, Status: 1, Flow: 100, Num: 10}).Error; err != nil {
t.Fatal(err)
}
otherForwardID := forward.ID + 100
for _, value := range []interface{}{
&model.Forward{ID: otherForwardID, UserID: 1, UserName: "admin", Name: "other-user", TunnelID: tunnelID, RemoteAddr: "203.0.113.9:8081", Status: 1},
&model.ForwardPort{ForwardID: otherForwardID, NodeID: fixture.nodeID, Port: 20001},
} {
if err := h.repo.DB().Create(value).Error; err != nil {
t.Fatal(err)
}
}
manager := &fakeNftablesManager{reconcileErr: errors.New("ssh unavailable")}
h.nftablesManager = manager
update := func() response.R {
t.Helper()
res := postJSONToHandler(t, h.userUpdate, map[string]interface{}{"id": userID, "user": "disable-nft", "status": 0})
var out response.R
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
return out
}
if out := update(); out.Code == 0 {
t.Error("failed SSH apply returned success")
}
if got := mustQueryInt(t, h.repo, "SELECT status FROM forward WHERE id = ?", forward.ID); got != 1 {
t.Errorf("unconfirmed nftables pause marked completed: %d", got)
}
if manager.reconcileCount() != 1 || len(manager.lastPlan.Rules) != 1 || manager.lastPlan.Rules[0].ForwardID != otherForwardID {
t.Fatalf("did not attempt to remove disabled user's nftables rule: %+v", manager.lastPlan)
}
manager.reconcileErr = nil
if out := update(); out.Code != 0 {
t.Fatalf("retry nftables disable: %s", out.Msg)
}
if got := mustQueryInt(t, h.repo, "SELECT status FROM forward WHERE id = ?", forward.ID); got != 0 {
t.Errorf("confirmed nftables pause was not stored: %d", got)
}
if manager.reconcileCount() != 2 || len(manager.lastPlan.Rules) != 1 || manager.lastPlan.Rules[0].ForwardID != otherForwardID {
t.Errorf("retry did not apply a plan without disabled forward: %+v", manager.lastPlan)
}
if got := mustQueryInt(t, h.repo, "SELECT status FROM forward WHERE id = ?", otherForwardID); got != 1 {
t.Errorf("disabled another user's nftables forward: %d", got)
}
}
func TestUserDisableReportsPauseFailureAndCanRetry(t *testing.T) {
for _, offline := range []bool{false, true} {
t.Run(fmt.Sprintf("offline=%t", offline), func(t *testing.T) {
a := newCleanupAgent(t)
now := time.Now().UnixMilli()
userID, err := a.h.repo.CreateUser("disable-retry", "pwd", 1, now+86400000, 100, 1, 10, 1, 0, now)
if err != nil {
t.Fatal(err)
}
for _, value := range []interface{}{
&model.Tunnel{ID: 1, Name: "test", Type: 1, Status: 1, Flow: 1},
&model.UserTunnel{ID: 10, UserID: userID, TunnelID: 1, Status: 1, Flow: 100, Num: 10},
&model.Forward{ID: 20, UserID: userID, UserName: "disable-retry", Name: "running", TunnelID: 1, RemoteAddr: "127.0.0.1:80", Status: 1},
&model.ForwardPort{ForwardID: 20, NodeID: 1, Port: 31020},
} {
if err := a.h.repo.DB().Create(value).Error; err != nil {
t.Fatal(err)
}
}
onlineServer := a.h.wsServer
if offline {
a.h.wsServer = ws.NewServer(a.h.repo, "test-jwt-secret")
} else {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
response.WriteJSON(w, response.ErrDefault("pause rejected"))
}))
defer ts.Close()
if err := a.h.repo.DB().Model(&model.Node{}).Where("id = 1").Updates(map[string]interface{}{"is_remote": 1, "remote_url": ts.URL, "remote_token": "test-token"}).Error; err != nil {
t.Fatal(err)
}
}
update := func() response.R {
t.Helper()
res := postJSONToHandler(t, a.h.userUpdate, map[string]interface{}{"id": userID, "user": "disable-retry", "status": 0})
var out response.R
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
return out
}
if out := update(); out.Code == 0 {
t.Error("pause failure returned success")
}
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM user WHERE id = ?", userID); got != 0 {
t.Errorf("failed pause restored account access: %d", got)
}
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM user_tunnel WHERE id = 10"); got != 0 {
t.Errorf("failed pause restored tunnel access: %d", got)
}
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM forward WHERE id = 20"); got != 1 {
t.Errorf("unconfirmed pause marked completed, status = %d", got)
}
a.h.wsServer = onlineServer
if err := a.h.repo.DB().Model(&model.Node{}).Where("id = 1").Update("is_remote", 0).Error; err != nil {
t.Fatal(err)
}
if out := update(); out.Code != 0 {
t.Fatalf("retry failed: %s", out.Msg)
}
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM forward WHERE id = 20"); got != 0 {
t.Errorf("retry did not pause forward: %d", got)
}
if len(a.commandsOfType("PauseService")) == 0 {
t.Error("retry never sent PauseService")
}
})
}
}
@@ -151,6 +151,13 @@ func (r *Repository) PropagateUserFlowToTunnels(userID int64, flow int64, num in
}).Error
}
func (r *Repository) DisableUserTunnels(userID int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
return r.db.Model(&model.UserTunnel{}).Where("user_id = ?", userID).Update("status", 0).Error
}
func (r *Repository) DeleteUserCascade(userID int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
@@ -0,0 +1,87 @@
package contract_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"go-backend/internal/auth"
"go-backend/internal/http/response"
"go-backend/internal/store/model"
)
func TestManualUserDisableRevokesLoginTokenAndForwardAccess(t *testing.T) {
const secret = "contract-jwt-secret"
router, r := setupContractRouter(t, secret)
seedContractUser(t, r, 2, "disable-contract", 1, 1)
for _, value := range []interface{}{
&model.Tunnel{ID: 1, Name: "disable-contract", Type: 1, Status: 1, Flow: 1},
&model.UserTunnel{ID: 10, UserID: 2, TunnelID: 1, Status: 1, Flow: 100, Num: 10},
&model.Forward{ID: 20, UserID: 2, UserName: "disable-contract", Name: "paused", TunnelID: 1, RemoteAddr: "127.0.0.1:80", Status: 0},
} {
if err := r.DB().Create(value).Error; err != nil {
t.Fatal(err)
}
}
userToken, err := auth.GenerateToken(2, "disable-contract", 1, secret)
if err != nil {
t.Fatal(err)
}
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatal(err)
}
request := func(path, token, body string) response.R {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(body))
req.Header.Set("Authorization", token)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
return out
}
if out := request("/api/v1/forward/list", userToken, `{}`); out.Code != 0 {
t.Fatalf("token invalid before disable: %s", out.Msg)
}
if out := request("/api/v1/user/update", adminToken, `{"id":2,"user":"disable-contract","status":0}`); out.Code != 0 {
t.Fatalf("disable user: %s", out.Msg)
}
for _, check := range []struct{ path, body string }{
{"/api/v1/forward/list", `{}`},
{"/api/v1/forward/create", `{"tunnelId":1,"name":"bypass","remoteAddr":"127.0.0.1:80"}`},
{"/api/v1/forward/resume", `{"id":20}`},
{"/api/v1/forward/batch-resume", `{"ids":[20]}`},
} {
if out := request(check.path, userToken, check.body); out.Code != 401 {
t.Errorf("disabled user's old token accepted at %s: %+v", check.path, out)
}
}
if out := request("/api/v1/user/login", "", `{"username":"disable-contract","password":"contract-pass","captchaId":""}`); out.Code == 0 || !strings.Contains(out.Msg, "停用") {
t.Errorf("disabled account login: %+v", out)
}
if out := request("/api/v1/forward/resume", adminToken, `{"id":20}`); out.Code == 0 || !strings.Contains(out.Msg, "禁用") {
t.Errorf("admin bypassed disabled owner's policy: %+v", out)
}
out := request("/api/v1/tunnel/user/list", adminToken, `{"userId":2}`)
if out.Code != 0 {
t.Fatalf("list permissions: %s", out.Msg)
}
items, ok := out.Data.([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("permission list: %#v", out.Data)
}
permission, ok := items[0].(map[string]interface{})
if !ok || permission["status"] != float64(0) {
t.Errorf("permission still displayed as enabled: %#v", items[0])
}
if got := mustQueryInt(t, r, "SELECT status FROM forward WHERE id = 20"); got != 0 {
t.Errorf("disabled forward resumed: %d", got)
}
}
+21 -3
View File
@@ -3,6 +3,7 @@ package udp
import (
"errors"
"net"
"strconv"
"sync"
"sync/atomic"
"time"
@@ -73,6 +74,7 @@ func (ln *listener) listenLoop() {
n, raddr, err := ln.conn.ReadFrom(b)
if err != nil {
bufpool.Put(b)
ln.errChan <- err
close(ln.errChan)
return
@@ -110,15 +112,27 @@ func (ln *listener) Close() error {
}
func (ln *listener) getConn(raddr net.Addr) *conn {
c, ok := ln.connPool.Get(raddr.String())
laddr := ln.Addr()
peer := raddr
var packet *packetAddr
if received, ok := raddr.(*packetAddr); ok {
packet = received
laddr, peer = received.local, received.Addr
}
key := laddr.String() + "|" + peer.String()
if packet != nil {
key += "|" + strconv.Itoa(packet.ifIndex)
}
c, ok := ln.connPool.Get(key)
if ok && !c.isClosed() {
return c
}
c = newConn(ln.conn, ln.Addr(), raddr, ln.config.ReadQueueSize, ln.config.Keepalive)
c = newConn(ln.conn, laddr, peer, ln.config.ReadQueueSize, ln.config.Keepalive)
c.packet = packet
select {
case ln.cqueue <- c:
ln.connPool.Set(raddr.String(), c)
ln.connPool.Set(key, c)
return c
default:
c.Close()
@@ -132,6 +146,7 @@ type conn struct {
net.PacketConn
localAddr net.Addr
remoteAddr net.Addr
packet *packetAddr
rc chan []byte // data receive queue
idle int32 // indicate the connection is idle
closed chan struct{}
@@ -176,6 +191,9 @@ func (c *conn) WriteTo(b []byte, addr net.Addr) (n int, err error) {
if !c.keepalive {
defer c.Close()
}
if c.packet != nil {
addr = &packetAddr{Addr: addr, local: c.packet.local, control: c.packet.control, ifIndex: c.packet.ifIndex}
}
return c.PacketConn.WriteTo(b, addr)
}
+12
View File
@@ -0,0 +1,12 @@
package udp
import "net"
// packetAddr carries the received destination through the PacketConn wrappers.
// Its public address remains the real peer for admission, metrics and limits.
type packetAddr struct {
net.Addr
local *net.UDPAddr
control []byte
ifIndex int
}
+116
View File
@@ -0,0 +1,116 @@
package udp
import (
"encoding/binary"
"errors"
"net"
"golang.org/x/net/ipv6"
"golang.org/x/sys/unix"
)
// PreserveDestination pins wildcard UDP replies to the address they arrived on.
func PreserveDestination(c *net.UDPConn) (net.PacketConn, error) {
local := c.LocalAddr().(*net.UDPAddr)
if !local.IP.IsUnspecified() {
return c, nil
}
raw, err := c.SyscallConn()
if err != nil {
return nil, err
}
var sockErr error
if err = raw.Control(func(fd uintptr) {
if local.IP.To4() != nil {
sockErr = unix.SetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_PKTINFO, 1)
return
}
if sockErr = unix.SetsockoptInt(int(fd), unix.IPPROTO_IPV6, unix.IPV6_RECVPKTINFO, 1); sockErr != nil {
return
}
var onlyV6 int
onlyV6, sockErr = unix.GetsockoptInt(int(fd), unix.IPPROTO_IPV6, unix.IPV6_V6ONLY)
if sockErr == nil && onlyV6 == 0 {
sockErr = unix.SetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_PKTINFO, 1)
}
}); err != nil {
return nil, err
}
if sockErr != nil {
return nil, sockErr
}
return &destinationConn{UDPConn: c}, nil
}
type destinationConn struct {
*net.UDPConn
}
func (c *destinationConn) ReadFrom(b []byte) (int, net.Addr, error) {
var oob [128]byte
n, oobn, flags, remote, err := c.ReadMsgUDP(b, oob[:])
if err != nil {
return 0, remote, err
}
if flags&unix.MSG_CTRUNC != 0 {
return 0, nil, errors.New("UDP destination control message truncated")
}
var dst net.IP
var control []byte
var ifIndex int
if remote.IP.To4() != nil {
messages, err := unix.ParseSocketControlMessage(oob[:oobn])
if err != nil {
return 0, nil, err
}
for _, message := range messages {
if message.Header.Level != unix.IPPROTO_IP || message.Header.Type != unix.IP_PKTINFO {
continue
}
var info unix.Inet4Pktinfo
if _, err := binary.Decode(message.Data, binary.NativeEndian, &info); err != nil {
return 0, nil, err
}
dst, ifIndex = net.IP(info.Addr[:]), int(info.Ifindex)
// Spec_dst remains a local unicast address for broadcast destinations.
control = unix.PktInfo4(&unix.Inet4Pktinfo{Ifindex: info.Ifindex, Spec_dst: info.Spec_dst})
break
}
} else {
var cm ipv6.ControlMessage
if err = cm.Parse(oob[:oobn]); err != nil {
return 0, nil, err
}
dst, ifIndex = cm.Dst, cm.IfIndex
source := dst
if dst.IsMulticast() {
source = nil
}
control = (&ipv6.ControlMessage{Src: source, IfIndex: ifIndex}).Marshal()
}
if len(dst) == 0 || dst.IsUnspecified() {
return 0, nil, errors.New("UDP destination address missing")
}
local := &net.UDPAddr{IP: append(net.IP(nil), dst...), Port: c.LocalAddr().(*net.UDPAddr).Port}
if dst.To4() == nil && (dst.IsLinkLocalUnicast() || dst.IsLinkLocalMulticast() || dst.IsInterfaceLocalMulticast()) {
if iface, lookupErr := net.InterfaceByIndex(ifIndex); lookupErr == nil {
local.Zone = iface.Name
}
}
return n, &packetAddr{Addr: remote, local: local, control: control, ifIndex: ifIndex}, nil
}
func (c *destinationConn) WriteTo(b []byte, addr net.Addr) (int, error) {
if packet, ok := addr.(*packetAddr); ok {
remote, ok := packet.Addr.(*net.UDPAddr)
if !ok {
return 0, errors.New("invalid UDP peer address")
}
n, _, err := c.WriteMsgUDP(b, packet.control, remote)
if n < 0 {
n = 0
}
return n, err
}
return c.UDPConn.WriteTo(b, addr)
}
@@ -0,0 +1,304 @@
package udp
import (
"encoding/binary"
"fmt"
"io"
"net"
"os"
"testing"
"time"
"golang.org/x/net/ipv4"
"golang.org/x/net/ipv6"
"golang.org/x/sys/unix"
xlogger "github.com/go-gost/x/logger"
)
func TestMulticastDestinationRepliesFromUnicast(t *testing.T) {
rxName, txName := os.Getenv("FLUX_TEST_MULTICAST_RX"), os.Getenv("FLUX_TEST_MULTICAST_TX")
if rxName == "" || txName == "" {
t.Skip("requires isolated multicast veth interfaces")
}
rx, err := net.InterfaceByName(rxName)
if err != nil {
t.Fatal(err)
}
tx, err := net.InterfaceByName(txName)
if err != nil {
t.Fatal(err)
}
group := net.ParseIP("ff02::114")
socket, err := net.ListenMulticastUDP("udp6", rx, &net.UDPAddr{IP: group})
if err != nil {
t.Fatal(err)
}
defer socket.Close()
pc, err := PreserveDestination(socket)
if err != nil {
t.Fatal(err)
}
ln := NewListener(pc, &ListenConfig{
Backlog: 1, ReadQueueSize: 1, ReadBufferSize: 128, TTL: time.Second, Keepalive: true,
Logger: xlogger.NewLogger(xlogger.OutputOption(io.Discard)),
})
defer ln.Close()
client, err := net.ListenUDP("udp6", &net.UDPAddr{IP: net.ParseIP("fe80::1"), Zone: txName})
if err != nil {
t.Fatal(err)
}
defer client.Close()
if err := ipv6.NewPacketConn(client).SetMulticastInterface(tx); err != nil {
t.Fatal(err)
}
client.SetDeadline(time.Now().Add(3 * time.Second))
port := socket.LocalAddr().(*net.UDPAddr).Port
serverResult := make(chan error, 1)
go func() {
c, err := ln.Accept()
if err != nil {
serverResult <- err
return
}
defer c.Close()
local, peer := c.LocalAddr().(*net.UDPAddr), c.RemoteAddr().(*net.UDPAddr)
if !local.IP.Equal(group) || local.Port != port || local.Zone != rxName || !peer.IP.Equal(net.ParseIP("fe80::1")) ||
peer.Port != client.LocalAddr().(*net.UDPAddr).Port || peer.Zone != rxName {
serverResult <- fmt.Errorf("unexpected multicast session: local=%s peer=%s", local, peer)
return
}
var buf [128]byte
n, err := c.Read(buf[:])
if err == nil {
_, err = c.Write(buf[:n])
}
serverResult <- err
}()
if _, err := client.WriteToUDP([]byte("multicast"), &net.UDPAddr{IP: group, Port: port, Zone: txName}); err != nil {
t.Fatal(err)
}
select {
case err := <-serverResult:
if err != nil {
t.Fatal(err)
}
case <-time.After(3 * time.Second):
t.Fatal("multicast session timed out")
}
var buf [128]byte
n, from, err := client.ReadFromUDP(buf[:])
if err != nil {
t.Fatal(err)
}
if !from.IP.Equal(net.ParseIP("fe80::2")) || from.Port != port || from.Zone != txName || string(buf[:n]) != "multicast" {
t.Fatalf("unexpected multicast reply %q from %s", buf[:n], from)
}
}
func TestLinkLocalMulticastSessionsRetainZones(t *testing.T) {
testScopedMulticastSessions(t, "ff02::114", false)
}
func TestIPv4DestinationSessionsRetainInterfaces(t *testing.T) {
ifNames := []string{os.Getenv("FLUX_TEST_MULTICAST_TX"), os.Getenv("FLUX_TEST_MULTICAST_TX2")}
if ifNames[0] == "" || ifNames[1] == "" {
t.Skip("requires two isolated IPv4 multicast interfaces")
}
for _, network := range []string{"udp4", "udp"} {
for _, destination := range []string{"239.255.0.114", "224.0.0.114", "255.255.255.255"} {
t.Run(network+"/"+destination, func(t *testing.T) {
bind := net.IPv4zero
if network == "udp" {
bind = net.IPv6unspecified
}
server, err := net.ListenUDP(network, &net.UDPAddr{IP: bind})
if err != nil {
t.Fatal(err)
}
defer server.Close()
pc, err := PreserveDestination(server)
if err != nil {
t.Fatal(err)
}
client, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.ParseIP("198.18.0.1")})
if err != nil {
t.Fatal(err)
}
defer client.Close()
raw, err := client.SyscallConn()
if err != nil {
t.Fatal(err)
}
var sockErr error
if err := raw.Control(func(fd uintptr) {
sockErr = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_BROADCAST, 1)
}); err != nil {
t.Fatal(err)
}
if sockErr != nil {
t.Fatal(sockErr)
}
target := &net.UDPAddr{IP: net.ParseIP(destination), Port: server.LocalAddr().(*net.UDPAddr).Port}
for _, name := range ifNames {
iface, err := net.InterfaceByName(name)
if err != nil {
t.Fatal(err)
}
if target.IP.IsMulticast() {
if err := ipv4.NewPacketConn(server).JoinGroup(iface, target); err != nil {
t.Fatal(err)
}
}
if _, err := ipv4.NewPacketConn(client).WriteTo([]byte(name), &ipv4.ControlMessage{
Src: client.LocalAddr().(*net.UDPAddr).IP, IfIndex: iface.Index,
}, target); err != nil {
t.Fatal(err)
}
}
server.SetReadDeadline(time.Now().Add(3 * time.Second))
ln := NewListener(pc, &ListenConfig{
Backlog: 2, ReadQueueSize: 2, ReadBufferSize: 128, TTL: 10 * time.Second, Keepalive: true,
Logger: xlogger.NewLogger(xlogger.OutputOption(io.Discard)),
})
defer ln.Close()
seen := make(map[string]bool)
for range ifNames {
c, err := ln.Accept()
if err != nil {
t.Fatalf("interface did not get a separate session: %v", err)
}
defer c.Close()
var buf [128]byte
n, err := c.Read(buf[:])
if err != nil {
t.Fatal(err)
}
name := string(buf[:n])
if (name != ifNames[0] && name != ifNames[1]) || seen[name] ||
c.LocalAddr().String() != target.String() || c.RemoteAddr().String() != client.LocalAddr().String() {
t.Fatalf("unexpected interface session: data=%q local=%s peer=%s", buf[:n], c.LocalAddr(), c.RemoteAddr())
}
iface, err := net.InterfaceByName(name)
if err != nil {
t.Fatal(err)
}
messages, err := unix.ParseSocketControlMessage(c.(*conn).packet.control)
if err != nil || len(messages) != 1 {
t.Fatalf("invalid reply control: %v", err)
}
var info unix.Inet4Pktinfo
if _, err := binary.Decode(messages[0].Data, binary.NativeEndian, &info); err != nil {
t.Fatal(err)
}
if int(info.Ifindex) != iface.Index {
t.Fatalf("reply interface = %d, want %d", info.Ifindex, iface.Index)
}
seen[name] = true
}
})
}
}
}
func TestInterfaceLocalMulticastSessionsRetainZones(t *testing.T) {
testScopedMulticastSessions(t, "ff01::114", true)
}
func testScopedMulticastSessions(t *testing.T, destination string, interfaceLocal bool) {
t.Helper()
rxNames := []string{os.Getenv("FLUX_TEST_MULTICAST_RX"), os.Getenv("FLUX_TEST_MULTICAST_RX2")}
txNames := []string{os.Getenv("FLUX_TEST_MULTICAST_TX"), os.Getenv("FLUX_TEST_MULTICAST_TX2")}
for i := range rxNames {
if rxNames[i] == "" || txNames[i] == "" {
t.Skip("requires two isolated multicast veth pairs")
}
}
if interfaceLocal {
// Interface-local multicast is looped back on the sending interface.
rxNames = txNames
}
group := net.ParseIP(destination)
server, err := net.ListenUDP("udp6", &net.UDPAddr{IP: net.IPv6unspecified})
if err != nil {
t.Fatal(err)
}
defer server.Close()
clientIP := net.ParseIP("fd00:1::1")
client, err := net.ListenUDP("udp6", &net.UDPAddr{IP: clientIP})
if err != nil {
t.Fatal(err)
}
defer client.Close()
pc, err := PreserveDestination(server)
if err != nil {
t.Fatal(err)
}
for i, rxName := range rxNames {
rx, err := net.InterfaceByName(rxName)
if err != nil {
t.Fatal(err)
}
if err := ipv6.NewPacketConn(server).JoinGroup(rx, &net.UDPAddr{IP: group}); err != nil {
t.Fatal(err)
}
tx, err := net.InterfaceByName(txNames[i])
if err != nil {
t.Fatal(err)
}
if err := ipv6.NewPacketConn(client).SetMulticastInterface(tx); err != nil {
t.Fatal(err)
}
if _, err := client.WriteToUDP([]byte(rxName), &net.UDPAddr{
IP: group, Port: server.LocalAddr().(*net.UDPAddr).Port, Zone: txNames[i],
}); err != nil {
t.Fatal(err)
}
}
server.SetReadDeadline(time.Now().Add(3 * time.Second))
ln := NewListener(pc, &ListenConfig{
Backlog: 2, ReadQueueSize: 2, ReadBufferSize: 128, TTL: 10 * time.Second, Keepalive: true,
Logger: xlogger.NewLogger(xlogger.OutputOption(io.Discard)),
})
defer ln.Close()
first, err := ln.Accept()
if err != nil {
t.Fatal(err)
}
defer first.Close()
second, err := ln.Accept()
if err != nil {
t.Fatalf("second interface did not get a separate session: %v", err)
}
defer second.Close()
if first == second {
t.Fatal("interface-scoped multicast sessions were shared")
}
seen := make(map[string]bool)
for _, c := range []net.Conn{first, second} {
var buf [128]byte
n, err := c.Read(buf[:])
if err != nil {
t.Fatal(err)
}
zone := string(buf[:n])
local, peer := c.LocalAddr().(*net.UDPAddr), c.RemoteAddr().(*net.UDPAddr)
if (zone != rxNames[0] && zone != rxNames[1]) || seen[zone] || !local.IP.Equal(group) ||
local.Port != server.LocalAddr().(*net.UDPAddr).Port || local.Zone != zone ||
!peer.IP.Equal(clientIP) || peer.Port != client.LocalAddr().(*net.UDPAddr).Port || peer.Zone != "" {
t.Fatalf("unexpected scoped session: data=%q local=%s peer=%s", buf[:n], local, peer)
}
var reply ipv6.ControlMessage
if err := reply.Parse(c.(*conn).packet.control); err != nil {
t.Fatal(err)
}
iface, err := net.InterfaceByName(zone)
if err != nil {
t.Fatal(err)
}
if reply.IfIndex != iface.Index {
t.Fatalf("reply interface = %d, want %d", reply.IfIndex, iface.Index)
}
seen[zone] = true
}
}
@@ -0,0 +1,9 @@
//go:build !linux
package udp
import "net"
func PreserveDestination(c *net.UDPConn) (net.PacketConn, error) {
return c, nil
}
@@ -0,0 +1,289 @@
package udp
import (
"context"
"fmt"
"io"
"net"
"os"
"strings"
"sync/atomic"
"testing"
"time"
"golang.org/x/sys/unix"
corelimiter "github.com/go-gost/core/limiter"
coretraffic "github.com/go-gost/core/limiter/traffic"
corelistener "github.com/go-gost/core/listener"
corestats "github.com/go-gost/core/observer/stats"
xadmission "github.com/go-gost/x/admission"
xtraffic "github.com/go-gost/x/limiter/traffic"
xlogger "github.com/go-gost/x/logger"
xmetadata "github.com/go-gost/x/metadata"
"github.com/go-gost/x/metrics"
xstats "github.com/go-gost/x/observer/stats"
)
type countedTraffic struct {
coretraffic.TrafficLimiter
in, out atomic.Int64
}
func (l *countedTraffic) In(ctx context.Context, key string, opts ...corelimiter.Option) coretraffic.Limiter {
l.in.Add(1)
return l.TrafficLimiter.In(ctx, key, opts...)
}
func (l *countedTraffic) Out(ctx context.Context, key string, opts ...corelimiter.Option) coretraffic.Limiter {
l.out.Add(1)
return l.TrafficLimiter.Out(ctx, key, opts...)
}
func TestDestinationPreservedThroughWrappers(t *testing.T) {
v4 := []string{"127.0.0.1", "127.0.0.2"}
if address := os.Getenv("FLUX_TEST_IPV4_LINK_LOCAL"); address != "" {
v4 = append(v4, address)
}
v6 := strings.FieldsFunc(os.Getenv("FLUX_TEST_IPV6_ADDRS"), func(r rune) bool { return r == ',' })
if len(v6) == 0 {
v6 = []string{"::1"}
}
for _, tc := range []struct {
name, bind string
targets []string
clientBind string
}{
{"ipv4-wildcard", "0.0.0.0:0", v4, ""},
{"dual-stack-wildcard", "[::]:0", append(v4, v6...), ""},
{"fixed-ipv6-source", "[::]:0", v6, "::1"},
{"explicit-ipv4", "127.0.0.1:0", []string{"127.0.0.1"}, ""},
{"explicit-ipv6", "[::1]:0", []string{"::1"}, ""},
} {
t.Run(tc.name, func(t *testing.T) {
metrics.Enable(true)
defer metrics.Enable(false)
log := xlogger.NewLogger(xlogger.OutputOption(io.Discard))
stats := xstats.NewStats(false)
lim := &countedTraffic{TrafficLimiter: xtraffic.NewTrafficLimiter(
xtraffic.LimitsOption("$ 1MB 1MB", "$$ 1MB 1MB"), xtraffic.LoggerOption(log),
)}
ln := NewListener(corelistener.AddrOption(tc.bind), corelistener.LoggerOption(log),
corelistener.ServiceOption(t.Name()), corelistener.StatsOption(stats),
corelistener.TrafficLimiterOption(lim),
corelistener.AdmissionOption(xadmission.NewAdmission(
xadmission.WhitelistOption(true),
xadmission.MatchersOption([]string{"127.0.0.0/8", "169.254.0.0/16", "::1", "fd00::/8"}),
xadmission.LoggerOption(log),
)),
)
if err := ln.Init(xmetadata.NewMetadata(map[string]any{"keepAlive": true})); err != nil {
t.Fatal(err)
}
defer ln.Close()
clientIP := net.IPv6unspecified
if tc.clientBind != "" {
clientIP = net.ParseIP(tc.clientBind)
}
client, err := net.ListenUDP("udp", &net.UDPAddr{IP: clientIP})
if err != nil {
t.Fatal(err)
}
defer client.Close()
client.SetDeadline(time.Now().Add(5 * time.Second))
clientAddr := client.LocalAddr().(*net.UDPAddr)
var accepted atomic.Int64
errors := make(chan error, 128)
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
accepted.Add(1)
go func() {
defer c.Close()
buf := make([]byte, 128)
for {
n, err := c.Read(buf)
if err != nil {
return
}
var round, target int
if _, err := fmt.Sscanf(string(buf[:n]), "%d/%d", &round, &target); err != nil {
errors <- err
return
}
local := c.LocalAddr().(*net.UDPAddr)
if local.IP.To4() != nil && local.Zone != "" {
errors <- fmt.Errorf("IPv4 destination has a zone: %s", local)
return
}
if !local.IP.Equal(net.ParseIP(tc.targets[target])) {
errors <- fmt.Errorf("local destination = %s, want %s", local.IP, tc.targets[target])
return
}
peer, ok := c.RemoteAddr().(*net.UDPAddr)
if !ok {
errors <- fmt.Errorf("peer type changed: %T", c.RemoteAddr())
return
}
if tc.clientBind != "" && (!peer.IP.Equal(clientAddr.IP) || peer.Port != clientAddr.Port) {
errors <- fmt.Errorf("peer endpoint = %s, want %s", peer, clientAddr)
return
}
if _, err := c.Write(buf[:n]); err != nil {
errors <- err
return
}
}
}()
}
}()
port := ln.Addr().(*net.UDPAddr).Port
var bytes int
for round := 0; round < 3; round++ {
for target, ip := range tc.targets {
payload := []byte(fmt.Sprintf("%d/%d", round, target))
bytes += len(payload)
if _, err := client.WriteToUDP(payload, &net.UDPAddr{IP: net.ParseIP(ip), Port: port}); err != nil {
t.Fatal(err)
}
}
seen := make(map[int]bool)
for range tc.targets {
buf := make([]byte, 128)
n, from, err := client.ReadFromUDP(buf)
if err != nil {
select {
case serverErr := <-errors:
t.Fatal(serverErr)
default:
t.Fatal(err)
}
}
var gotRound, target int
if _, err := fmt.Sscanf(string(buf[:n]), "%d/%d", &gotRound, &target); err != nil {
t.Fatal(err)
}
if gotRound != round || seen[target] || from.Port != port || !from.IP.Equal(net.ParseIP(tc.targets[target])) {
t.Fatalf("unexpected reply %q from %s", buf[:n], from)
}
seen[target] = true
}
}
if accepted.Load() != int64(len(tc.targets)) {
t.Fatalf("accepted %d sessions, want %d", accepted.Load(), len(tc.targets))
}
deadline := time.Now().Add(time.Second)
for stats.Get(corestats.KindOutputBytes) < uint64(bytes) && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
if stats.Get(corestats.KindInputBytes) != uint64(bytes) || stats.Get(corestats.KindOutputBytes) != uint64(bytes) {
t.Fatalf("traffic stats lost: in=%d out=%d want=%d", stats.Get(corestats.KindInputBytes), stats.Get(corestats.KindOutputBytes), bytes)
}
if lim.in.Load() == 0 || lim.out.Load() == 0 {
t.Fatal("traffic limiter bypassed")
}
})
}
}
func TestBroadcastDestinationRepliesFromUnicast(t *testing.T) {
for _, bind := range []string{"0.0.0.0:0", "[::]:0"} {
t.Run(bind, func(t *testing.T) {
ln := NewListener(corelistener.AddrOption(bind),
corelistener.LoggerOption(xlogger.NewLogger(xlogger.OutputOption(io.Discard))))
if err := ln.Init(xmetadata.NewMetadata(map[string]any{"keepAlive": true})); err != nil {
t.Fatal(err)
}
defer ln.Close()
client, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
if err != nil {
t.Fatal(err)
}
defer client.Close()
raw, err := client.SyscallConn()
if err != nil {
t.Fatal(err)
}
var sockErr error
if err := raw.Control(func(fd uintptr) {
sockErr = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_BROADCAST, 1)
}); err != nil {
t.Fatal(err)
}
if sockErr != nil {
t.Fatal(sockErr)
}
client.SetDeadline(time.Now().Add(5 * time.Second))
target := &net.UDPAddr{IP: net.IPv4(127, 255, 255, 255), Port: ln.Addr().(*net.UDPAddr).Port}
if _, err := client.WriteToUDP([]byte("broadcast"), target); err != nil {
t.Fatal(err)
}
conn, err := acceptWithTimeout(t, ln, time.Second)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
if conn.LocalAddr().String() != target.String() {
t.Fatalf("local destination = %s, want %s", conn.LocalAddr(), target)
}
if conn.RemoteAddr().String() != client.LocalAddr().String() {
t.Fatalf("peer endpoint = %s, want %s", conn.RemoteAddr(), client.LocalAddr())
}
var buf [128]byte
n, err := conn.Read(buf[:])
if err != nil {
t.Fatal(err)
}
if _, err := conn.Write(buf[:n]); err != nil {
t.Fatalf("reply to broadcast request: %v", err)
}
n, from, err := client.ReadFromUDP(buf[:])
if err != nil {
t.Fatal(err)
}
if !from.IP.Equal(net.IPv4(127, 0, 0, 1)) || from.Port != target.Port || string(buf[:n]) != "broadcast" {
t.Fatalf("unexpected broadcast reply %q from %s", buf[:n], from)
}
})
}
}
func TestDestinationSessionExpires(t *testing.T) {
ln := NewListener(corelistener.AddrOption("0.0.0.0:0"),
corelistener.LoggerOption(xlogger.NewLogger(xlogger.OutputOption(io.Discard))))
if err := ln.Init(xmetadata.NewMetadata(map[string]any{"keepAlive": true, "ttl": "20ms"})); err != nil {
t.Fatal(err)
}
defer ln.Close()
client, err := net.DialUDP("udp4", nil, &net.UDPAddr{IP: net.IPv4(127, 0, 0, 2), Port: ln.Addr().(*net.UDPAddr).Port})
if err != nil {
t.Fatal(err)
}
defer client.Close()
client.Write([]byte("first"))
first, err := acceptWithTimeout(t, ln, time.Second)
if err != nil {
t.Fatal(err)
}
var b [16]byte
first.Read(b[:])
time.Sleep(80 * time.Millisecond)
if _, err := first.Read(b[:]); err == nil {
t.Fatal("idle session did not expire")
}
client.Write([]byte("second"))
second, err := acceptWithTimeout(t, ln, time.Second)
if err != nil {
t.Fatal(err)
}
defer second.Close()
if first == second {
t.Fatal("expired session reused")
}
if second.LocalAddr().(*net.UDPAddr).IP.String() != "127.0.0.2" {
t.Fatalf("new session lost destination: %s", second.LocalAddr())
}
}
+6 -2
View File
@@ -56,11 +56,15 @@ func (l *udpListener) Init(md md.Metadata) (err error) {
return
}
var conn net.PacketConn
conn, err = net.ListenUDP(network, laddr)
socket, err := net.ListenUDP(network, laddr)
if err != nil {
return
}
conn, err := udp.PreserveDestination(socket)
if err != nil {
socket.Close()
return err
}
conn = metrics.WrapPacketConn(l.options.Service, conn)
conn = stats.WrapPacketConn(conn, l.options.Stats)
conn = admission.WrapPacketConn(l.options.Admission, conn)
+1 -4
View File
@@ -1977,10 +1977,7 @@ export default function NodePage() {
isLoading={node.upgradeLoading}
size="sm"
variant="flat"
onPress={() => {
setUpgradeTargetNodeId(node.id);
openUpgradeModal("single");
}}
onPress={() => openUpgradeModal("single", node.id)}
>
升级
</Button>
+27 -1
View File
@@ -231,6 +231,9 @@ export default function UserPage() {
maxConn: 0,
});
const [userFormLoading, setUserFormLoading] = useState(false);
const [userSubmitWarning, setUserSubmitWarning] = useState<string | null>(
null,
);
const [userFlowInput, setUserFlowInput] = useState("1000");
const [userFlowUnit, setUserFlowUnit] = useState<TrafficUnit>("GB");
const [quotaResetLoading, setQuotaResetLoading] = useState(false);
@@ -517,6 +520,7 @@ export default function UserPage() {
};
const handleAdd = () => {
setUserSubmitWarning(null);
setIsEdit(false);
setUserFlowInput("1000");
setUserFlowUnit("GB");
@@ -537,6 +541,7 @@ export default function UserPage() {
};
const handleEdit = async (user: User) => {
setUserSubmitWarning(null);
setIsEdit(true);
const trafficInput = trafficInputFor(user.flow, user.flowMiB);
@@ -618,6 +623,7 @@ export default function UserPage() {
}
setUserFormLoading(true);
setUserSubmitWarning(null);
try {
const submitData: any = {
...userForm,
@@ -660,7 +666,19 @@ export default function UserPage() {
await loadUsers();
}
} else {
toast.error(response.msg || (isEdit ? "更新失败" : "创建失败"));
const userDisabled =
isEdit &&
response.data !== null &&
typeof response.data === "object" &&
"userDisabled" in response.data &&
response.data.userDisabled === true;
if (userDisabled) {
setUserSubmitWarning(response.msg);
await loadUsers();
} else {
toast.error(response.msg || (isEdit ? "更新失败" : "创建失败"));
}
}
} catch {
toast.error(isEdit ? "更新失败" : "创建失败");
@@ -1532,6 +1550,14 @@ export default function UserPage() {
<ModalContent>
<ModalHeader>{isEdit ? "编辑用户" : "新增用户"}</ModalHeader>
<ModalBody>
{userSubmitWarning ? (
<div
className="whitespace-pre-wrap break-words rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-700 dark:bg-amber-950/50 dark:text-amber-100"
role="alert"
>
{userSubmitWarning}
</div>
) : null}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
isRequired