3x-ui/web/service/inbound_socks_guards_test.go
GenSpark AI Developer 6a5cac385d feat(socks): guard client-lifecycle paths against account-based inbounds
Account-based inbounds (Socks/Mixed/HTTP) keep their credentials in
`settings.accounts[]` — an array of plain {user, pass} objects — while
every other inbound (vless/vmess/trojan/shadowsocks/hysteria/…) keeps
them in `settings.clients[]`, the rich Client struct with id, email,
sub-id, totalGB, expiry, traffic-reset cadence, etc.

The whole client lifecycle on InboundService (AddInboundClient,
UpdateInboundClient, DelInboundClient, CopyInboundClients) was written
against the latter shape, and several of those methods do an unchecked
`settings["clients"].([]any)` cast on the way in. If anything ever
managed to call them against a SOCKS5 inbound the panel would panic
straight out of the goroutine.

In practice the UI itself can't get there — `dbinbound.isMultiUser()`
returns false for SOCKS, which already gates the ClientRowTable,
"add client" menu, copy-clients menu, etc. — but the HTTP API is
addressable directly, the Telegram bot path is independent, and a
future feature could easily plug into one of those entry points and
hit the cast. Defense in depth is cheap here.

Backend
-------
* Add `model.IsAccountBased(p Protocol) bool` covering Socks, Mixed
  and HTTP. WireGuard is *not* in the set — its peers live under
  `settings.peers[]` and are managed through a separate code path
  that already knows about them.

* AddInboundClient / UpdateInboundClient / DelInboundClient now load
  the target inbound up front and bail out with a clear, actionable
  error when the protocol is account-based, instead of falling into
  the unchecked clients cast. The error message points the caller at
  the right escape hatch ("update the inbound directly with
  settings.accounts[] instead").

* CopyInboundClients refuses account-based inbounds on either side
  of the copy — neither direction has well-defined semantics
  (downcasting a rich client to {user, pass} silently drops
  sub-id/totalGB/expiry; upcasting the other way invents fields the
  runtime can't honor).

Tests
-----
* TestIsAccountBased pins the protocol set, including the explicit
  WireGuard-excluded and lowercase-invariant cases.

* TestAddInboundClient_RejectsSocks, TestUpdateInboundClient_RejectsSocks,
  TestDelInboundClient_RejectsSocks: the three guards must fire on a
  SOCKS inbound seeded with a realistic settings.accounts[] payload.

* TestCopyInboundClients_RejectsSocksSource and ...Target: both
  directions are refused.

* TestAddInboundClient_AllowsVless: sanity check that the guard does
  not fire on a client-based protocol — if this ever flipped the
  feature would be broken for everyone, not just SOCKS users.

Other scenarios reviewed (no code changes needed):
* Routing rules — keyed off inbound tag, protocol-agnostic.
* Balancers — outbound-tag based, untouched by inbound protocol.
* Outbound side — frontend already exposes SOCKS as an outbound
  with user/pass through the existing OutboundFormModal.
* Depletion / traffic reset / disable-invalid-clients — driven by
  SQL queries on the client_traffics table, which is naturally empty
  for account-based inbounds (they never create rows there).
* SetInboundEnable — operates on the inbound table directly, no
  per-client surgery, safe for SOCKS.
* Sub-link generators (sub/subService, subJsonService, subClashService)
  — already return empty for SOCKS/Mixed/HTTP/Tunnel/WireGuard.
* Frontend client modals (ClientFormModal, ClientRowTable,
  ClientBulkModal, CopyClientsModal) — gated upstream by
  `dbInbound.isMultiUser()`, which is false for SOCKS.
2026-05-25 15:55:34 +00:00

150 lines
5.3 KiB
Go

package service
// Guard tests for the client-lifecycle methods on InboundService when
// applied to account-based inbounds (Socks/Mixed/HTTP).
//
// The intent here is narrow: prove that AddInboundClient,
// UpdateInboundClient, DelInboundClient and CopyInboundClients refuse
// account-based protocols cleanly *before* they ever hit the
// `settings["clients"].([]any)` cast — which would otherwise panic,
// because account-based inbounds carry settings.accounts[] instead.
//
// We deliberately don't exercise the happy path for client-based
// protocols here — that's covered elsewhere — and we don't need to
// boot xray / runtimes because the guards short-circuit at the very
// top of each method. A tiny in-memory sqlite from setupConflictDB is
// enough.
import (
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
)
// socksAccountsSettings is a minimal but realistic settings payload for
// a SOCKS5 inbound. The shape (accounts[]) is what makes the unguarded
// `settings["clients"].([]any)` cast panic — we keep it real so the
// regression is obvious if the guards ever get removed.
const socksAccountsSettings = `{"auth":"password","accounts":[{"user":"alice","pass":"hunter2"}],"udp":false,"ip":"127.0.0.1"}`
// clientPayloadJSON is a stub "client add" payload. The guards must
// reject the call regardless of what the client envelope contains, so
// the actual fields here don't matter — what matters is that the call
// is made against a SOCKS inbound.
const clientPayloadJSON = `{"clients":[{"id":"00000000-0000-0000-0000-000000000000","email":"ignored@example.com","enable":true}]}`
func seedSocksInbound(t *testing.T) *model.Inbound {
t.Helper()
seedInboundConflict(t, "socks-guard", "0.0.0.0", 1080, model.Socks, `{"network":"tcp"}`, socksAccountsSettings)
var ib model.Inbound
if err := database.GetDB().Where("tag = ?", "socks-guard").First(&ib).Error; err != nil {
t.Fatalf("load seeded socks inbound: %v", err)
}
return &ib
}
func seedVlessInbound(t *testing.T) *model.Inbound {
t.Helper()
const vlessSettings = `{"clients":[{"id":"11111111-1111-1111-1111-111111111111","email":"v@example.com","enable":true}],"decryption":"none"}`
seedInboundConflict(t, "vless-source", "0.0.0.0", 1443, model.VLESS, `{"network":"tcp"}`, vlessSettings)
var ib model.Inbound
if err := database.GetDB().Where("tag = ?", "vless-source").First(&ib).Error; err != nil {
t.Fatalf("load seeded vless inbound: %v", err)
}
return &ib
}
// expectAccountBasedError fails the test unless err is the well-known
// "client lifecycle is not supported for account-based protocol" error
// emitted by the guards. We match on substring to stay decoupled from
// the exact common.NewError formatting (which space-joins its args).
func expectAccountBasedError(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatalf("expected account-based guard error, got nil — guard may be missing")
}
msg := err.Error()
if !strings.Contains(msg, "account-based protocol") {
t.Fatalf("expected account-based guard error, got: %v", err)
}
}
func TestAddInboundClient_RejectsSocks(t *testing.T) {
setupConflictDB(t)
ib := seedSocksInbound(t)
svc := &InboundService{}
_, err := svc.AddInboundClient(&model.Inbound{
Id: ib.Id,
Settings: clientPayloadJSON,
})
expectAccountBasedError(t, err)
}
func TestUpdateInboundClient_RejectsSocks(t *testing.T) {
setupConflictDB(t)
ib := seedSocksInbound(t)
svc := &InboundService{}
_, err := svc.UpdateInboundClient(&model.Inbound{
Id: ib.Id,
Settings: clientPayloadJSON,
}, "any-client-id")
expectAccountBasedError(t, err)
}
func TestDelInboundClient_RejectsSocks(t *testing.T) {
setupConflictDB(t)
ib := seedSocksInbound(t)
svc := &InboundService{}
_, err := svc.DelInboundClient(ib.Id, "any-client-id")
expectAccountBasedError(t, err)
}
// SOCKS as source and as target both have to be refused — neither
// direction has well-defined semantics (downcasting a rich client to
// {user, pass} would silently drop sub-id / totalGB / expiry; upcasting
// the other way would invent fields that the runtime can't honor).
func TestCopyInboundClients_RejectsSocksSource(t *testing.T) {
setupConflictDB(t)
socks := seedSocksInbound(t)
vless := seedVlessInbound(t)
svc := &InboundService{}
_, _, err := svc.CopyInboundClients(vless.Id, socks.Id, nil, "")
expectAccountBasedError(t, err)
}
func TestCopyInboundClients_RejectsSocksTarget(t *testing.T) {
setupConflictDB(t)
socks := seedSocksInbound(t)
vless := seedVlessInbound(t)
svc := &InboundService{}
_, _, err := svc.CopyInboundClients(socks.Id, vless.Id, nil, "")
expectAccountBasedError(t, err)
}
// Sanity check: the guards must NOT fire on client-based inbounds.
// If this ever flips, AddInboundClient is broken for everyone, not
// just SOCKS users. We don't assert success (the call may legitimately
// fail later in the pipeline because we haven't booted xray) — we just
// assert that whatever error comes back is *not* the guard error.
func TestAddInboundClient_AllowsVless(t *testing.T) {
setupConflictDB(t)
ib := seedVlessInbound(t)
svc := &InboundService{}
_, err := svc.AddInboundClient(&model.Inbound{
Id: ib.Id,
Settings: clientPayloadJSON,
})
if err != nil && strings.Contains(err.Error(), "account-based protocol") {
t.Fatalf("guard should not fire on VLESS, got: %v", err)
}
}