diff --git a/docs/content/docs/en/config/amneziawg.mdx b/docs/content/docs/en/config/amneziawg.mdx index 161352e03..51a27f747 100644 --- a/docs/content/docs/en/config/amneziawg.mdx +++ b/docs/content/docs/en/config/amneziawg.mdx @@ -164,14 +164,18 @@ Endpoint = your-server:443 PersistentKeepalive = 25 ``` +## Multi-node (sub-nodes) + +An AmneziaWG inbound can be created on, or cloned to, a sub-node. The node's +own panel runs the interface, so the node must run panel **v3.7.0** or newer; +the master refuses an older node, or one that has not reported its version yet. +A client's `forwardedPorts` are checked against the ports in use on that node. + ## Not yet covered -- **Multi-node (sub-nodes)** and **Telegram bot** — AmneziaWG inbounds haven't - been exercised through those paths yet. They likely work (the reconciler - runs the same way regardless of how the panel itself is deployed), but - that's not the same as a confirmed, tested claim — treat it as unverified - rather than assume it either way until someone reports back. +- **Telegram bot** — AmneziaWG inbounds haven't been exercised through the + bot yet. Treat it as unverified until someone reports back. diff --git a/docs/content/docs/en/config/tuic.mdx b/docs/content/docs/en/config/tuic.mdx index 5bbb5c1b0..1d972cac8 100644 --- a/docs/content/docs/en/config/tuic.mdx +++ b/docs/content/docs/en/config/tuic.mdx @@ -109,5 +109,5 @@ tuic://:@:?congestion_control=bbr&alpn=h3&sni=vpn.ex - **Traffic accounting & limits**: The panel owns the inbound's public UDP port with a small relay and runs `tuic-server` behind it on a loopback port, so the inbound's upload and download bytes are counted exactly on every OS and enforced at the **inbound level** (`inbounds.total`); `tuic-server` therefore logs `127.0.0.1` as every client's address. Because upstream `tuic-server` does not provide an internal per-user metrics API, individual client traffic limits (`totalGB`) are not supported for TUIC clients. Client access can be controlled via expiration timestamps (`expiryTime`) and manual enable/disable toggles. - **Online status & "start after first use"**: The panel detects a client's activity from the sidecar's Info log lines (they carry the client UUID), so those features need the inbound's log level at `info` or `debug`; `warn` and `error` silence them. - **Client updates & connections**: Because upstream `tuic-server` lacks dynamic user reload APIs, client modifications (adding, updating, or disabling clients) restart the sidecar process and momentarily reset active connections. - - **Deployment**: Because TUIC operates via a host sidecar process, TUIC inbounds are panel-local (main instance). + - **Deployment**: A TUIC inbound can be created on, or cloned to, a sub-node. The node's own panel runs the sidecar, so the node must run panel v3.8.0 or newer; the master refuses an older node. diff --git a/docs/content/docs/ru/config/tuic.mdx b/docs/content/docs/ru/config/tuic.mdx index e47b5222e..3de967fa3 100644 --- a/docs/content/docs/ru/config/tuic.mdx +++ b/docs/content/docs/ru/config/tuic.mdx @@ -108,5 +108,5 @@ tuic://:@:?congestion_control=bbr&alpn=h3&sni=vpn.ex - **Учёт трафика и лимиты**: Панель сама занимает публичный UDP-порт инбаунда небольшим relay и запускает `tuic-server` за ним на loopback-порту, поэтому входящие и исходящие байты инбаунда считаются точно на любой ОС и ограничиваются на **уровне инбаунда** (`inbounds.total`); в логах `tuic-server` адресом каждого клиента будет `127.0.0.1`. Поскольку апстрим `tuic-server` не предоставляет внутреннего API метрик по отдельным пользователям, персональные квоты трафика (`totalGB`) для клиентов TUIC не поддерживаются. Доступ клиентов контролируется по сроку действия (`expiryTime`) и переключателю активности. - **Статус онлайн и «старт после первого использования»**: Панель определяет активность клиента по строкам Info в логе сайдкара (в них есть UUID клиента), поэтому этим функциям нужен уровень логов `info` или `debug`; `warn` и `error` их отключают. - **Изменения клиентов и соединения**: Поскольку апстрим `tuic-server` не поддерживает динамическую перезагрузку пользователей без перезапуска, любое изменение списка клиентов (добавление, редактирование или отключение) перезапускает процесс сайдкара и кратковременно сбрасывает активные соединения. - - **Развёртывание**: Поскольку TUIC управляется локальным процессом хоста, такие инбаунды работают локально на главной панели. + - **Развёртывание**: Инбаунд TUIC можно создать на дочернем узле или клонировать туда. Sidecar запускает панель самого узла, поэтому на узле нужна панель v3.8.0 или новее; более старый узел главная панель отклоняет. diff --git a/frontend/src/lib/xray/node-protocols.ts b/frontend/src/lib/xray/node-protocols.ts index a7adbc8d6..23d3420be 100644 --- a/frontend/src/lib/xray/node-protocols.ts +++ b/frontend/src/lib/xray/node-protocols.ts @@ -2,7 +2,8 @@ import { Protocols } from '@/schemas/primitives'; /* * Protocols whose inbounds can live on a sub-node (the "Deploy To" set). - * Everything else (http, mixed, tunnel, tun, mtproto) is panel-local only. + * Everything else (http, mixed, tunnel, tun) is panel-local only. The sidecar + * protocols run on the node's own panel; the backend refuses a node too old. * Shared by the inbound form's Deploy To selector and the clone dialog's * target picker so the two surfaces can never drift apart. */ @@ -13,4 +14,7 @@ export const NODE_ELIGIBLE_PROTOCOLS: Readonly> = { [Protocols.SHADOWSOCKS]: true, [Protocols.HYSTERIA]: true, [Protocols.WIREGUARD]: true, + [Protocols.MTPROTO]: true, + [Protocols.AMNEZIAWG]: true, + [Protocols.TUIC]: true, }; diff --git a/internal/web/service/client_inbound_apply.go b/internal/web/service/client_inbound_apply.go index fc242e207..b4386ca0c 100644 --- a/internal/web/service/client_inbound_apply.go +++ b/internal/web/service/client_inbound_apply.go @@ -433,7 +433,7 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model var portCtx portConflictContext if oldInbound.Protocol == model.AmneziaWG { - portCtx, err = inboundSvc.loadPortConflictContext(database.GetDB()) + portCtx, err = inboundSvc.loadPortConflictContext(database.GetDB(), oldInbound.NodeID) if err != nil { return false, err } @@ -547,7 +547,7 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model } } if oldInbound.Protocol == model.AmneziaWG { - txPortCtx, pErr := inboundSvc.loadPortConflictContext(tx) + txPortCtx, pErr := inboundSvc.loadPortConflictContext(tx, oldInbound.NodeID) if pErr != nil { return pErr } @@ -784,7 +784,7 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo } } if oldInbound.Protocol == model.AmneziaWG { - portCtx, err := inboundSvc.loadPortConflictContext(database.GetDB()) + portCtx, err := inboundSvc.loadPortConflictContext(database.GetDB(), oldInbound.NodeID) if err != nil { return false, err } @@ -921,7 +921,7 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo // Same re-check-inside-the-writer rule as AddInboundClient (#6225): // the pre-tx pass can race a concurrent writer on another inbound. if oldInbound.Protocol == model.AmneziaWG { - txPortCtx, pErr := inboundSvc.loadPortConflictContext(tx) + txPortCtx, pErr := inboundSvc.loadPortConflictContext(tx, oldInbound.NodeID) if pErr != nil { return pErr } diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index 44cdf88e2..08429512e 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -1086,6 +1086,21 @@ func (s *InboundService) normalizeMtprotoXrayPort(inbound *model.Inbound, oldSet // Prefer the already-stored port (carried across edits), then any value the // client sent, then allocate a fresh one. port := parseRouteXrayPort(oldSettings) + if inbound.NodeID != nil { + // The port is free or taken on the node's host, not here: the node's own + // panel allocates it, and node sync brings its choice back as oldSettings. + if port <= 0 { + delete(parsed, "routeXrayPort") + } else { + parsed["routeXrayPort"] = port + } + bs, err := json.MarshalIndent(parsed, "", " ") + if err != nil { + return common.NewError("mtproto: could not persist the Xray egress port:", err) + } + inbound.Settings = string(bs) + return nil + } if port <= 0 { port = settingsRouteXrayPort(parsed) } @@ -1136,8 +1151,10 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo if err := s.normalizeAmneziaWGSettings(inbound, ""); err != nil { return inbound, false, err } - if inbound.NodeID != nil && !isNodeEligibleProtocol(inbound.Protocol) { - return inbound, false, common.NewErrorf("%s inbounds cannot be assigned to a node", inbound.Protocol) + if inbound.NodeID != nil { + if err := checkNodeCanHostProtocol(database.GetDB(), *inbound.NodeID, inbound.Protocol); err != nil { + return inbound, false, err + } } inbound.SubSortIndex = normalizeSubSortIndex(inbound.SubSortIndex) if err := normalizeInboundShareAddressStrict(inbound); err != nil { @@ -1752,6 +1769,9 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, if err != nil { return inbound, false, err } + // Restore the stored NodeID before any host-scoped check so a node inbound + // stays scoped to its own node (the payload's nodeId is unreliable, often absent). + inbound.NodeID = oldInbound.NodeID if err := s.normalizeAmneziaWGSettings(inbound, oldInbound.Settings); err != nil { return inbound, false, err } @@ -1766,13 +1786,12 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, } } } - // Restore the stored NodeID before the port-conflict check so a node inbound - // stays scoped to its own node (the payload's nodeId is unreliable, often absent). - inbound.NodeID = oldInbound.NodeID // The node assignment is the stored one, so only a protocol change can // introduce one; a row adopted from a node keeps the protocol it arrived with. - if inbound.NodeID != nil && inbound.Protocol != oldInbound.Protocol && !isNodeEligibleProtocol(inbound.Protocol) { - return inbound, false, common.NewErrorf("%s inbounds cannot be assigned to a node", inbound.Protocol) + if inbound.NodeID != nil && inbound.Protocol != oldInbound.Protocol { + if err := checkNodeCanHostProtocol(database.GetDB(), *inbound.NodeID, inbound.Protocol); err != nil { + return inbound, false, err + } } // Capture the pre-edit protocol and routing state before oldInbound is diff --git a/internal/web/service/inbound_amneziawg.go b/internal/web/service/inbound_amneziawg.go index bd1454574..df87500ff 100644 --- a/internal/web/service/inbound_amneziawg.go +++ b/internal/web/service/inbound_amneziawg.go @@ -262,7 +262,7 @@ func (s *InboundService) normalizeAmneziaWGSettings(inbound *model.Inbound, oldS } } - portCtx, err := s.loadPortConflictContext(database.GetDB()) + portCtx, err := s.loadPortConflictContext(database.GetDB(), inbound.NodeID) if err != nil { return err } @@ -303,23 +303,31 @@ func (s *InboundService) normalizeAmneziaWGSettings(inbound *model.Inbound, oldS return nil } -// portConflictContext caches what checkForwardedPortsConflict needs — the panel's -// own port and this host's enabled rows — so one save costs one query, not N. +// portConflictContext caches what checkForwardedPortsConflict needs about the +// host a forward listener binds on, so one save costs one query, not N. type portConflictContext struct { webPort int inbounds []*model.Inbound + // onNode: the host is a node, whose web port and relay ports (derived from + // its own inbound ids) this panel does not know; the node re-checks both. + onNode bool } -// loadPortConflictContext loads the panel's own port and every enabled inbound -// hosted on THIS panel: a node-hosted one listens on that node's host, not here. -func (s *InboundService) loadPortConflictContext(db *gorm.DB) (portConflictContext, error) { +// loadPortConflictContext loads every enabled inbound hosted where nodeID's rows +// run -- this panel for nil, else that node -- plus this panel's own port. +func (s *InboundService) loadPortConflictContext(db *gorm.DB, nodeID *int) (portConflictContext, error) { var ctx portConflictContext - if webPort, err := (&SettingService{}).GetPort(); err == nil { - ctx.webPort = webPort + q := db.Model(model.Inbound{}).Where("enable = ?", true) + if nodeID != nil { + ctx.onNode = true + q = q.Where("node_id = ?", *nodeID) + } else { + if webPort, err := (&SettingService{}).GetPort(); err == nil { + ctx.webPort = webPort + } + q = q.Where("node_id IS NULL") } - err := db.Model(model.Inbound{}). - Where("enable = ? AND node_id IS NULL", true). - Find(&ctx.inbounds).Error + err := q.Find(&ctx.inbounds).Error return ctx, err } @@ -340,7 +348,7 @@ func (s *InboundService) checkAmneziaWGForwardedPorts(db *gorm.DB, settings stri if err := json.Unmarshal([]byte(settings), &parsed); err != nil { return nil } - ctx, err := s.loadPortConflictContext(db) + ctx, err := s.loadPortConflictContext(db, nil) if err != nil { return err } @@ -372,7 +380,7 @@ func (s *InboundService) checkForwardedPortsConflict(ctx portConflictContext, fo } return fmt.Sprintf("inbound '%s' (#%d, port %d)", name, ib.Id, ib.Port) } - if ib.Protocol != model.AmneziaWG { + if ctx.onNode || ib.Protocol != model.AmneziaWG { continue } socksPort := amneziawgnet.SOCKSPortForInbound(ib.Id) diff --git a/internal/web/service/inbound_amneziawg_test.go b/internal/web/service/inbound_amneziawg_test.go index 4b0435912..bdf1d2106 100644 --- a/internal/web/service/inbound_amneziawg_test.go +++ b/internal/web/service/inbound_amneziawg_test.go @@ -29,7 +29,7 @@ var awgTestPrivateKey, awgTestPublicKey = func() (string, string) { func TestCheckForwardedPortsConflict_EmptySpecNoConflict(t *testing.T) { setupConflictDB(t) svc := &InboundService{} - ctx, err := svc.loadPortConflictContext(database.GetDB()) + ctx, err := svc.loadPortConflictContext(database.GetDB(), nil) if err != nil { t.Fatalf("loadPortConflictContext: %v", err) } @@ -41,7 +41,7 @@ func TestCheckForwardedPortsConflict_EmptySpecNoConflict(t *testing.T) { func TestCheckForwardedPortsConflict_CollidesWithPanelPort(t *testing.T) { setupConflictDB(t) svc := &InboundService{} - ctx, err := svc.loadPortConflictContext(database.GetDB()) + ctx, err := svc.loadPortConflictContext(database.GetDB(), nil) if err != nil { t.Fatalf("loadPortConflictContext: %v", err) } @@ -58,7 +58,7 @@ func TestCheckForwardedPortsConflict_CollidesWithEnabledInboundPort(t *testing.T seedInboundConflict(t, "vless-8080", "0.0.0.0", 8080, model.VLESS, `{"network":"tcp"}`, `{}`) svc := &InboundService{} - ctx, err := svc.loadPortConflictContext(database.GetDB()) + ctx, err := svc.loadPortConflictContext(database.GetDB(), nil) if err != nil { t.Fatalf("loadPortConflictContext: %v", err) } @@ -76,7 +76,7 @@ func TestCheckForwardedPortsConflict_IgnoresDisabledInboundPort(t *testing.T) { } svc := &InboundService{} - ctx, err := svc.loadPortConflictContext(database.GetDB()) + ctx, err := svc.loadPortConflictContext(database.GetDB(), nil) if err != nil { t.Fatalf("loadPortConflictContext: %v", err) } @@ -90,7 +90,7 @@ func TestCheckForwardedPortsConflict_NoCollisionWhenPortsDontOverlap(t *testing. seedInboundConflict(t, "vless-8080", "0.0.0.0", 8080, model.VLESS, `{"network":"tcp"}`, `{}`) svc := &InboundService{} - ctx, err := svc.loadPortConflictContext(database.GetDB()) + ctx, err := svc.loadPortConflictContext(database.GetDB(), nil) if err != nil { t.Fatalf("loadPortConflictContext: %v", err) } @@ -110,7 +110,7 @@ func TestCheckForwardedPortsConflict_IgnoresPortOnDifferentNode(t *testing.T) { seedInboundConflictNode(t, "node1-8080", "0.0.0.0", 8080, model.VLESS, `{"network":"tcp"}`, `{}`, new(1)) svc := &InboundService{} - ctx, err := svc.loadPortConflictContext(database.GetDB()) + ctx, err := svc.loadPortConflictContext(database.GetDB(), nil) if err != nil { t.Fatalf("loadPortConflictContext: %v", err) } @@ -320,7 +320,7 @@ func TestGetAmneziaWGLogs_ClampsCountAndFiltersEvents(t *testing.T) { func TestCheckForwardedPortsConflict_RejectsSpecOverCap(t *testing.T) { setupConflictDB(t) svc := &InboundService{} - ctx, err := svc.loadPortConflictContext(database.GetDB()) + ctx, err := svc.loadPortConflictContext(database.GetDB(), nil) if err != nil { t.Fatalf("loadPortConflictContext: %v", err) } @@ -337,7 +337,7 @@ func TestCheckForwardedPortsConflict_RejectsSpecOverCap(t *testing.T) { func TestCheckForwardedPortsConflict_AcceptsSpecExactlyAtCap(t *testing.T) { setupConflictDB(t) svc := &InboundService{} - ctx, err := svc.loadPortConflictContext(database.GetDB()) + ctx, err := svc.loadPortConflictContext(database.GetDB(), nil) if err != nil { t.Fatalf("loadPortConflictContext: %v", err) } @@ -361,7 +361,7 @@ func TestCheckForwardedPortsConflict_CollidesWithAmneziawgnetSocksPort(t *testin relayPort := amneziawgnet.SOCKSPortForInbound(awgInbound.Id) svc := &InboundService{} - ctx, err := svc.loadPortConflictContext(database.GetDB()) + ctx, err := svc.loadPortConflictContext(database.GetDB(), nil) if err != nil { t.Fatalf("loadPortConflictContext: %v", err) } diff --git a/internal/web/service/inbound_node_protocol_update_test.go b/internal/web/service/inbound_node_protocol_update_test.go index 7bf3b8119..0813d3881 100644 --- a/internal/web/service/inbound_node_protocol_update_test.go +++ b/internal/web/service/inbound_node_protocol_update_test.go @@ -42,12 +42,12 @@ func TestUpdateInbound_NodeMtprotoShareAddrIsEditable(t *testing.T) { } } -// Converting a node inbound to a protocol the master's sidecars only reconcile -// for local rows is still refused: those loops query node_id IS NULL. +// Converting a node inbound to a protocol that never lives on a node is still +// refused, and so is one the node's panel is too old to run. func TestUpdateInbound_RejectsProtocolChangeToNodeIneligible(t *testing.T) { setupConflictDB(t) nodeID := 6 - seedNodeRow(t, database.GetDB(), &model.Node{Id: nodeID, Name: "n6", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true}) + seedNodeRow(t, database.GetDB(), &model.Node{Id: nodeID, Name: "n6", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, PanelVersion: "v3.4.1"}) seedInboundConflictNode(t, "vless-node", "127.0.0.1", 4064, model.VLESS, `{"network":"tcp","security":"none"}`, `{"clients":[{"id":"11111111-2222-4333-8444-555555555555","email":"vn-c","enable":true}],"decryption":"none"}`, &nodeID) @@ -57,11 +57,27 @@ func TestUpdateInbound_RejectsProtocolChangeToNodeIneligible(t *testing.T) { t.Fatalf("read seeded row: %v", err) } - update := existing - update.Protocol = model.MTProto - update.Settings = `{"clients":[{"email":"vn-c","enable":true,"secret":"ee0123456789abcdef0123456789abcdef"}]}` - if _, _, err := (&InboundService{}).UpdateInbound(&update); err == nil || - !strings.Contains(err.Error(), "cannot be assigned to a node") { - t.Fatalf("err = %v, want a node-eligibility refusal", err) + cases := []struct { + name string + protocol model.Protocol + settings string + wantErr string + }{ + {"panel-local protocol", model.HTTP, `{"accounts":[]}`, "cannot be assigned to a node"}, + { + "protocol newer than the node", model.MTProto, + `{"clients":[{"email":"vn-c","enable":true,"secret":"ee0123456789abcdef0123456789abcdef"}]}`, "need v3.5.0 or newer", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + update := existing + update.Protocol = tc.protocol + update.Settings = tc.settings + if _, _, err := (&InboundService{}).UpdateInbound(&update); err == nil || + !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("err = %v, want a refusal mentioning %q", err, tc.wantErr) + } + }) } } diff --git a/internal/web/service/inbound_node_sidecar_test.go b/internal/web/service/inbound_node_sidecar_test.go new file mode 100644 index 000000000..2db26d669 --- /dev/null +++ b/internal/web/service/inbound_node_sidecar_test.go @@ -0,0 +1,244 @@ +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) + } +} diff --git a/internal/web/service/inbound_protocol.go b/internal/web/service/inbound_protocol.go index db7b1fad8..b13f7a383 100644 --- a/internal/web/service/inbound_protocol.go +++ b/internal/web/service/inbound_protocol.go @@ -2,8 +2,13 @@ package service import ( "encoding/json" + "strings" "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/util/common" + "github.com/mhsanaei/3x-ui/v3/internal/util/version" + + "gorm.io/gorm" ) // inboundShadowsocksMethod extracts settings.method for Shadowsocks inbounds so @@ -53,10 +58,8 @@ func inboundCanEnableTlsFlow(protocol, streamSettings, settings string) bool { } } -// nodeEligibleProtocols mirrors the frontend's NODE_ELIGIBLE_PROTOCOLS. The -// sidecar-managed protocols are absent because their reconcile loops only query -// NodeID IS NULL rows, so a node-assigned one would never be reconciled at all. -// A new protocol defaults to ineligible until added here, as on the frontend. +// nodeEligibleProtocols mirrors the frontend's NODE_ELIGIBLE_PROTOCOLS. A sidecar +// protocol's row is local on the node it is pushed to, so that panel runs it. var nodeEligibleProtocols = map[model.Protocol]bool{ model.VLESS: true, model.VMESS: true, @@ -64,11 +67,43 @@ var nodeEligibleProtocols = map[model.Protocol]bool{ model.Shadowsocks: true, model.Hysteria: true, model.WireGuard: true, + model.MTProto: true, + model.AmneziaWG: true, + model.TUIC: true, } -// isNodeEligibleProtocol reports whether protocol may be assigned to a node. -func isNodeEligibleProtocol(protocol model.Protocol) bool { - return nodeEligibleProtocols[protocol] +// nodeProtocolFirstRelease is the panel release that introduced each protocol +// newer than node support itself; an older node would hand it to Xray as-is. +var nodeProtocolFirstRelease = map[model.Protocol]string{ + model.MTProto: "v3.5.0", + model.AmneziaWG: "v3.7.0", + model.TUIC: "v3.8.0", +} + +// checkNodeCanHostProtocol refuses assigning protocol to nodeID unless the +// protocol may live on a node and that node's panel is new enough to run it. +func checkNodeCanHostProtocol(db *gorm.DB, nodeID int, protocol model.Protocol) error { + if !nodeEligibleProtocols[protocol] { + return common.NewErrorf("%s inbounds cannot be assigned to a node", protocol) + } + firstRelease, ok := nodeProtocolFirstRelease[protocol] + if !ok { + return nil + } + var node model.Node + if err := db.Select("id", "name", "panel_version").First(&node, nodeID).Error; err != nil { + return err + } + if strings.TrimSpace(node.PanelVersion) == "" { + return common.NewErrorf("node %q has not reported its panel version yet; %s inbounds need %s or newer", + node.Name, protocol, firstRelease) + } + // A dev build reports "dev+": it tracks main, which carries every protocol. + if cmp, ok := version.Compare(node.PanelVersion, firstRelease); ok && cmp < 0 { + return common.NewErrorf("node %q runs panel %s; %s inbounds need %s or newer", + node.Name, node.PanelVersion, protocol, firstRelease) + } + return nil } // vlessEncryptionEnabled reports whether a VLESS inbound has VLESS-level diff --git a/internal/web/service/inbound_protocol_test.go b/internal/web/service/inbound_protocol_test.go index 3893f9dac..db696db20 100644 --- a/internal/web/service/inbound_protocol_test.go +++ b/internal/web/service/inbound_protocol_test.go @@ -88,21 +88,3 @@ func TestInboundCanHostFallbacks_StaysTcpOnly(t *testing.T) { t.Errorf("inboundCanHostFallbacks(nil) = true, want false") } } - -// Mirrors NODE_ELIGIBLE_PROTOCOLS in -// frontend/src/pages/inbounds/form/InboundFormModal.tsx -- keep both lists -// in sync if a protocol's node-eligibility ever changes. -func TestIsNodeEligibleProtocol(t *testing.T) { - eligible := []model.Protocol{model.VLESS, model.VMESS, model.Trojan, model.Shadowsocks, model.Hysteria, model.WireGuard} - for _, p := range eligible { - if !isNodeEligibleProtocol(p) { - t.Errorf("isNodeEligibleProtocol(%q) = false, want true", p) - } - } - ineligible := []model.Protocol{model.MTProto, model.AmneziaWG, model.Mixed, model.HTTP, model.Tunnel} - for _, p := range ineligible { - if isNodeEligibleProtocol(p) { - t.Errorf("isNodeEligibleProtocol(%q) = true, want false", p) - } - } -}