mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 12:44:22 +00:00
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.
103 lines
2.9 KiB
Go
103 lines
2.9 KiB
Go
package model
|
|
|
|
import "testing"
|
|
|
|
func TestIsHysteria(t *testing.T) {
|
|
cases := []struct {
|
|
in Protocol
|
|
want bool
|
|
}{
|
|
{Hysteria, true},
|
|
{Hysteria2, true},
|
|
{VLESS, false},
|
|
{Shadowsocks, false},
|
|
{Protocol(""), false},
|
|
{Protocol("hysteria3"), false},
|
|
}
|
|
for _, c := range cases {
|
|
if got := IsHysteria(c.in); got != c.want {
|
|
t.Errorf("IsHysteria(%q) = %v, want %v", c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSocksProtocolConstant pins the wire value of the SOCKS5 protocol
|
|
// constant. It must stay "socks" because that's the literal Xray expects
|
|
// in inbound.protocol JSON (see https://xtls.github.io/config/inbounds/socks.html);
|
|
// changing it would silently break every stored inbound row.
|
|
func TestSocksProtocolConstant(t *testing.T) {
|
|
if got, want := string(Socks), "socks"; got != want {
|
|
t.Errorf("Socks protocol constant = %q, want %q", got, want)
|
|
}
|
|
if Socks == Mixed {
|
|
t.Error("Socks and Mixed must be distinct protocols")
|
|
}
|
|
}
|
|
|
|
func TestIsSocksLike(t *testing.T) {
|
|
cases := []struct {
|
|
in Protocol
|
|
want bool
|
|
}{
|
|
{Socks, true},
|
|
{Mixed, true},
|
|
{HTTP, false},
|
|
{VLESS, false},
|
|
{VMESS, false},
|
|
{Trojan, false},
|
|
{Shadowsocks, false},
|
|
{WireGuard, false},
|
|
{Hysteria, false},
|
|
{Hysteria2, false},
|
|
{Tunnel, false},
|
|
{Protocol(""), false},
|
|
{Protocol("SOCKS"), false}, // case-sensitive: must match the stored lowercase value
|
|
}
|
|
for _, c := range cases {
|
|
if got := IsSocksLike(c.in); got != c.want {
|
|
t.Errorf("IsSocksLike(%q) = %v, want %v", c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestIsAccountBased pins the set of inbounds that route credentials
|
|
// through settings.accounts[] rather than settings.clients[]. Anything
|
|
// that returns true here is OFF-LIMITS for the client-lifecycle code
|
|
// paths (AddInboundClient / UpdateInboundClient / DelInboundClient,
|
|
// depletion, traffic reset, telegram 'add client' keyboard).
|
|
//
|
|
// HTTP is intentionally included even though the panel UI doesn't
|
|
// currently surface it as a standalone protocol — the runtime API
|
|
// AddUser branch in xray/api.go handles it symmetrically with socks,
|
|
// and any future UI work plugging HTTP back in inherits the same
|
|
// safety net for free.
|
|
//
|
|
// WireGuard is intentionally EXCLUDED: its peers live under
|
|
// settings.peers[] and are managed through a separate path; treating
|
|
// it as account-based would lock out the existing wireguard UI.
|
|
func TestIsAccountBased(t *testing.T) {
|
|
cases := []struct {
|
|
in Protocol
|
|
want bool
|
|
}{
|
|
{Socks, true},
|
|
{Mixed, true},
|
|
{HTTP, true},
|
|
|
|
{VLESS, false},
|
|
{VMESS, false},
|
|
{Trojan, false},
|
|
{Shadowsocks, false},
|
|
{WireGuard, false}, // peers, not accounts; managed separately
|
|
{Hysteria, false},
|
|
{Hysteria2, false},
|
|
{Tunnel, false},
|
|
{Protocol(""), false},
|
|
{Protocol("SOCKS"), false}, // case-sensitive lowercase invariant
|
|
}
|
|
for _, c := range cases {
|
|
if got := IsAccountBased(c.in); got != c.want {
|
|
t.Errorf("IsAccountBased(%q) = %v, want %v", c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|