mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-04 21:22:07 +03:00
ed31ee432c
The node gate assumed a node-assigned sidecar row would never converge,
because the master's reconcile loops only read node_id IS NULL rows. But
a pushed row is local on the node's own panel, whose AmneziaWG, TUIC and
mtg loops run it like any other; node-adopted rows of these protocols
already worked. Only creating or cloning them from the master was blocked.
Open the three protocols on both lists and fix what assumed the master's
host for a node row:
- A node older than the release that introduced the protocol (MTProto
v3.5.0, AmneziaWG v3.7.0, TUIC v3.8.0) would hand it to Xray as-is, so
add and protocol-changing update refuse it, and a node that has not
reported its version yet. Dev builds ("dev+<sha>") track main and pass.
- MTProto's routeXrayPort is a loopback port on the host running mtg.
The master no longer allocates one, nor forwards a cloned source's, for
a node row; the node allocates its own and node sync adopts it back.
- AmneziaWG forwardedPorts were checked against the master's inbounds,
web port and id-derived relay ports. A node row is now checked against
its own node's inbounds; the node re-checks what only it knows.
UpdateInbound restores the stored nodeId before that check.
Verified on a docker master+node pair: AWG, routed MTProto and TUIC
created and cloned from the master start on the node (interface, mtg,
tuic-server); an AWG client added and an MTProto client edited on the
master apply on the node; the node-chosen egress port survives edits.
Closes #6306
245 lines
9.2 KiB
Go
245 lines
9.2 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
)
|
|
|
|
func seedVersionedNode(t *testing.T, panelVersion string) *model.Node {
|
|
t.Helper()
|
|
node := &model.Node{
|
|
Name: "n-" + panelVersion, Address: "127.0.0.1", Port: 2096, Scheme: "https",
|
|
Enable: true, Status: "online", PanelVersion: panelVersion,
|
|
}
|
|
seedNodeRow(t, database.GetDB(), node)
|
|
return node
|
|
}
|
|
|
|
func sidecarNodeInbound(t *testing.T, protocol model.Protocol, tag string, nodeID int) *model.Inbound {
|
|
t.Helper()
|
|
ib := &model.Inbound{Tag: tag, Enable: true, Listen: "0.0.0.0", Port: 44300, Protocol: protocol, NodeID: &nodeID}
|
|
switch protocol {
|
|
case model.AmneziaWG:
|
|
ib.Settings = awgRelayWindowSettings(t, tag)
|
|
case model.TUIC:
|
|
ib.Settings = `{"clients":[{"id":"8a4f0c7e-1d2b-4c3a-9e5f-6a7b8c9d0e1f","password":"pass","email":"` + tag + `@tuic","enable":true}]}`
|
|
case model.MTProto:
|
|
ib.Settings = `{"clients":[{"email":"` + tag + `@mt","enable":true,"secret":"ee0123456789abcdef0123456789abcdef"}]}`
|
|
default:
|
|
t.Fatalf("no fixture for %s", protocol)
|
|
}
|
|
return ib
|
|
}
|
|
|
|
// The node's own panel runs the sidecar for a pushed row, so a sidecar protocol
|
|
// is deployable to any node new enough to know it -- the release that added it.
|
|
func TestAddInbound_SidecarProtocolDeploysToANodeAtItsFirstRelease(t *testing.T) {
|
|
cases := []struct {
|
|
protocol model.Protocol
|
|
firstRelease string
|
|
}{
|
|
{model.MTProto, "v3.5.0"},
|
|
{model.AmneziaWG, "v3.7.0"},
|
|
{model.TUIC, "v3.8.0"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(string(tc.protocol), func(t *testing.T) {
|
|
setupConflictDB(t)
|
|
node := seedVersionedNode(t, tc.firstRelease)
|
|
|
|
created, _, err := (&InboundService{}).AddInbound(sidecarNodeInbound(t, tc.protocol, "side-"+string(tc.protocol), node.Id))
|
|
if err != nil {
|
|
t.Fatalf("AddInbound(%s on a %s node): %v", tc.protocol, tc.firstRelease, err)
|
|
}
|
|
var stored model.Inbound
|
|
if err := database.GetDB().First(&stored, created.Id).Error; err != nil {
|
|
t.Fatalf("read created row: %v", err)
|
|
}
|
|
if stored.NodeID == nil || *stored.NodeID != node.Id {
|
|
t.Fatalf("stored nodeId = %v, want %d", stored.NodeID, node.Id)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A node that predates a protocol stores it as an Xray inbound Xray cannot load,
|
|
// so the assignment is refused while the version is too old or still unknown.
|
|
func TestAddInbound_SidecarProtocolNodeVersionGate(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
panelVersion string
|
|
wantErr string
|
|
}{
|
|
{"one release too old", "v3.6.9", "v3.7.0"},
|
|
{"version not reported yet", "", "has not reported its panel version"},
|
|
{"dev build", "dev+1d1128cf", ""},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
setupConflictDB(t)
|
|
node := seedVersionedNode(t, tc.panelVersion)
|
|
|
|
_, _, err := (&InboundService{}).AddInbound(sidecarNodeInbound(t, model.AmneziaWG, "awg-gate", node.Id))
|
|
if tc.wantErr == "" {
|
|
if err != nil {
|
|
t.Fatalf("a dev build tracks main and knows every protocol; got %v", err)
|
|
}
|
|
return
|
|
}
|
|
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
|
t.Fatalf("err = %v, want a refusal mentioning %q", err, tc.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The egress port is a loopback port on the host running mtg; one picked or
|
|
// copied on the master can be taken on the node, so the node must allocate it.
|
|
func TestAddInbound_NodeMtprotoLeavesTheEgressPortToTheNode(t *testing.T) {
|
|
setupConflictDB(t)
|
|
node := seedVersionedNode(t, "v3.8.0")
|
|
|
|
ib := sidecarNodeInbound(t, model.MTProto, "mt-routed", node.Id)
|
|
ib.Settings = `{"routeThroughXray":true,"routeXrayPort":4444,` + strings.TrimPrefix(ib.Settings, "{")
|
|
created, _, err := (&InboundService{}).AddInbound(ib)
|
|
if err != nil {
|
|
t.Fatalf("AddInbound: %v", err)
|
|
}
|
|
var stored model.Inbound
|
|
if err := database.GetDB().First(&stored, created.Id).Error; err != nil {
|
|
t.Fatalf("read created row: %v", err)
|
|
}
|
|
var settings map[string]any
|
|
if err := json.Unmarshal([]byte(stored.Settings), &settings); err != nil {
|
|
t.Fatalf("decode settings: %v", err)
|
|
}
|
|
if port, ok := settings["routeXrayPort"]; ok {
|
|
t.Fatalf("routeXrayPort = %v reached the node row; the node must allocate its own", port)
|
|
}
|
|
if settings["routeThroughXray"] != true {
|
|
t.Fatalf("routeThroughXray = %v, want true kept", settings["routeThroughXray"])
|
|
}
|
|
}
|
|
|
|
// A peer's forward listener binds on the host running the AmneziaWG row, so a
|
|
// node row is checked against that node's inbounds, never the master's.
|
|
func TestAddInbound_NodeAmneziaWGForwardedPortsCheckTheNodesHost(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
onNode bool
|
|
wantErr bool
|
|
}{
|
|
{"port of a master-local inbound", false, false},
|
|
{"port of an inbound on the same node", true, true},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
setupConflictDB(t)
|
|
node := seedVersionedNode(t, "v3.8.0")
|
|
var owner *int
|
|
if tc.onNode {
|
|
owner = &node.Id
|
|
}
|
|
seedInboundConflictNode(t, "holder", "0.0.0.0", 8443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, owner)
|
|
|
|
ib := sidecarNodeInbound(t, model.AmneziaWG, "awg-fwd", node.Id)
|
|
ib.Settings = awgRelayWindowSettingsWithForward(t, "awg-fwd", "8443")
|
|
_, _, err := (&InboundService{}).AddInbound(ib)
|
|
if !tc.wantErr {
|
|
if err != nil {
|
|
t.Fatalf("8443 is bound on the master, not on the node; the create must be allowed: %v", err)
|
|
}
|
|
return
|
|
}
|
|
if err == nil || !strings.Contains(err.Error(), "forwardedPorts collides with inbound 'holder'") {
|
|
t.Fatalf("err = %v, want a forwardedPorts refusal naming 'holder'", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// awgClientsPayload is the clients-only body the client endpoints take: one
|
|
// fresh peer of tag at address, forwarding forwardedPorts.
|
|
func awgClientsPayload(t *testing.T, tag, address, forwardedPorts string) string {
|
|
t.Helper()
|
|
settings := replaceFirst(t, awgRelayWindowSettingsWithForward(t, tag, forwardedPorts),
|
|
`"allowedIPs":["10.8.1.2/32"]`, `"allowedIPs":["`+address+`"]`)
|
|
var parsed map[string]json.RawMessage
|
|
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
|
|
t.Fatalf("decode fixture: %v", err)
|
|
}
|
|
return `{"clients":` + string(parsed["clients"]) + `}`
|
|
}
|
|
|
|
// seedNodeAmneziaWGBesideLocalHolder seeds a node AmneziaWG row and a local
|
|
// inbound on 8443, the port a forward on the node is free to take.
|
|
func seedNodeAmneziaWGBesideLocalHolder(t *testing.T) *model.Inbound {
|
|
t.Helper()
|
|
setupConflictDB(t)
|
|
node := seedVersionedNode(t, "v3.8.0")
|
|
seedInboundConflict(t, "holder", "0.0.0.0", 8443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`)
|
|
seedInboundConflictNode(t, "awg-node", "0.0.0.0", 51820, model.AmneziaWG, ``, awgRelayWindowSettings(t, "awg-node"), &node.Id)
|
|
var row model.Inbound
|
|
if err := database.GetDB().Where("tag = ?", "awg-node").First(&row).Error; err != nil {
|
|
t.Fatalf("read seeded row: %v", err)
|
|
}
|
|
return &row
|
|
}
|
|
|
|
// The edit payload carries no reliable nodeId, so the forward check must use
|
|
// the stored one or it judges a node row against the master's own ports.
|
|
func TestUpdateInbound_NodeAmneziaWGForwardIgnoresMasterPorts(t *testing.T) {
|
|
row := seedNodeAmneziaWGBesideLocalHolder(t)
|
|
|
|
update := *row
|
|
update.NodeID = nil
|
|
update.Settings = replaceFirst(t, row.Settings, `"enable":true`, `"enable":true,"forwardedPorts":"8443"`)
|
|
if _, _, err := (&InboundService{}).UpdateInbound(&update); err != nil {
|
|
t.Fatalf("8443 is bound on the master, not on the node; the edit must be allowed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAddInboundClient_NodeAmneziaWGForwardIgnoresMasterPorts(t *testing.T) {
|
|
row := seedNodeAmneziaWGBesideLocalHolder(t)
|
|
|
|
data := &model.Inbound{Id: row.Id, Settings: awgClientsPayload(t, "awg-new", "10.8.1.3/32", "8443")}
|
|
if _, err := (&ClientService{}).AddInboundClient(&InboundService{}, data); err != nil {
|
|
t.Fatalf("8443 is bound on the master, not on the node; the add must be allowed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestUpdateInboundClient_NodeAmneziaWGForwardIgnoresMasterPorts(t *testing.T) {
|
|
row := seedNodeAmneziaWGBesideLocalHolder(t)
|
|
|
|
var stored struct {
|
|
Clients []json.RawMessage `json:"clients"`
|
|
}
|
|
if err := json.Unmarshal([]byte(row.Settings), &stored); err != nil {
|
|
t.Fatalf("decode seeded settings: %v", err)
|
|
}
|
|
edited := replaceFirst(t, string(stored.Clients[0]), `"enable":true`, `"enable":true,"forwardedPorts":"8443"`)
|
|
data := &model.Inbound{Id: row.Id, Settings: `{"clients":[` + edited + `]}`}
|
|
if _, err := (&ClientService{}).UpdateInboundClient(&InboundService{}, data, "awg-node@relay-window"); err != nil {
|
|
t.Fatalf("8443 is bound on the master, not on the node; the edit must be allowed: %v", err)
|
|
}
|
|
}
|
|
|
|
// A relay port derives from the inbound id on the panel running it; the node's
|
|
// id differs from the master's, so a master-side derivation names a wrong port.
|
|
func TestAddInbound_NodeAmneziaWGForwardIgnoresMasterDerivedRelayPorts(t *testing.T) {
|
|
row := seedNodeAmneziaWGBesideLocalHolder(t)
|
|
|
|
ib := sidecarNodeInbound(t, model.AmneziaWG, "awg-fwd", *row.NodeID)
|
|
ib.Port = 51821
|
|
ib.Settings = awgRelayWindowSettingsWithForward(t, "awg-fwd", fmt.Sprintf("%d", amneziawgnet.SOCKSPortForInbound(row.Id)))
|
|
if _, _, err := (&InboundService{}).AddInbound(ib); err != nil {
|
|
t.Fatalf("the master's id for a node row derives no port on the node; the create must be allowed: %v", err)
|
|
}
|
|
}
|