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.
230 lines
12 KiB
Go
230 lines
12 KiB
Go
// Package model defines the database models and data structures used by the 3x-ui panel.
|
|
package model
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/util/json_util"
|
|
"github.com/mhsanaei/3x-ui/v3/xray"
|
|
)
|
|
|
|
// Protocol represents the protocol type for Xray inbounds.
|
|
type Protocol string
|
|
|
|
// Protocol constants for different Xray inbound protocols
|
|
const (
|
|
VMESS Protocol = "vmess"
|
|
VLESS Protocol = "vless"
|
|
Tunnel Protocol = "tunnel"
|
|
HTTP Protocol = "http"
|
|
Trojan Protocol = "trojan"
|
|
Shadowsocks Protocol = "shadowsocks"
|
|
Mixed Protocol = "mixed"
|
|
// Socks is a dedicated SOCKS5 inbound (Xray "socks" protocol).
|
|
// Unlike Mixed (HTTP+SOCKS), this is a pure SOCKS5 inbound and is
|
|
// intended for tunneling clients that only speak SOCKS5.
|
|
// See: https://xtls.github.io/config/inbounds/socks.html
|
|
Socks Protocol = "socks"
|
|
WireGuard Protocol = "wireguard"
|
|
Hysteria Protocol = "hysteria"
|
|
Hysteria2 Protocol = "hysteria2"
|
|
)
|
|
|
|
// IsHysteria returns true for both "hysteria" and "hysteria2".
|
|
// Use instead of a bare ==model.Hysteria check: a v2 inbound stored
|
|
// with the literal v2 string would otherwise fall through (#4081).
|
|
func IsHysteria(p Protocol) bool {
|
|
return p == Hysteria || p == Hysteria2
|
|
}
|
|
|
|
// IsSocksLike returns true for both the dedicated "socks" inbound and
|
|
// the combined "mixed" inbound, since Mixed exposes SOCKS5 alongside
|
|
// HTTP on the same port and accepts the exact same settings shape
|
|
// (auth/accounts/udp/ip) that the pure Socks inbound does.
|
|
//
|
|
// Use this helper anywhere routing, sub-link generation, or UI code
|
|
// needs to treat "this inbound speaks SOCKS5" uniformly without
|
|
// re-listing both constants at every call site.
|
|
func IsSocksLike(p Protocol) bool {
|
|
return p == Socks || p == Mixed
|
|
}
|
|
|
|
// IsAccountBased returns true for protocols whose Xray inbound config
|
|
// stores user credentials under settings.accounts[] (an array of
|
|
// {user, pass} objects) rather than settings.clients[] (the
|
|
// Client struct with id/password/auth/email/etc.).
|
|
//
|
|
// This currently covers Socks, Mixed, and HTTP — all three inbounds
|
|
// share the same proxy/socks-style Account wire type. The panel's
|
|
// client-lifecycle code paths (AddInboundClient / UpdateInboundClient /
|
|
// DelInboundClient, depletion, traffic reset, telegram bot 'add client'
|
|
// keyboard, …) all assume settings.clients[] and would either panic
|
|
// or silently corrupt the JSON if pointed at an account-based inbound,
|
|
// so they bail out early on this helper.
|
|
//
|
|
// WireGuard is intentionally NOT in this set: its peers live under
|
|
// settings.peers[] and the panel manages them through a separate
|
|
// dedicated path, not the client lifecycle covered here.
|
|
func IsAccountBased(p Protocol) bool {
|
|
return p == Socks || p == Mixed || p == HTTP
|
|
}
|
|
|
|
// User represents a user account in the 3x-ui panel.
|
|
type User struct {
|
|
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
LoginEpoch int64 `json:"-" gorm:"default:0"`
|
|
}
|
|
|
|
// Inbound represents an Xray inbound configuration with traffic statistics and settings.
|
|
type Inbound struct {
|
|
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier
|
|
UserId int `json:"-"` // Associated user ID
|
|
Up int64 `json:"up" form:"up"` // Upload traffic in bytes
|
|
Down int64 `json:"down" form:"down"` // Download traffic in bytes
|
|
Total int64 `json:"total" form:"total"` // Total traffic limit in bytes
|
|
AllTime int64 `json:"allTime" form:"allTime" gorm:"default:0"` // All-time traffic usage
|
|
Remark string `json:"remark" form:"remark"` // Human-readable remark
|
|
Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1"` // Whether the inbound is enabled
|
|
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
|
TrafficReset string `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2"` // Traffic reset schedule
|
|
LastTrafficResetTime int64 `json:"lastTrafficResetTime" form:"lastTrafficResetTime" gorm:"default:0"` // Last traffic reset timestamp
|
|
ClientStats []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"` // Client traffic statistics
|
|
|
|
// Xray configuration fields
|
|
Listen string `json:"listen" form:"listen"`
|
|
Port int `json:"port" form:"port"`
|
|
Protocol Protocol `json:"protocol" form:"protocol"`
|
|
Settings string `json:"settings" form:"settings"`
|
|
StreamSettings string `json:"streamSettings" form:"streamSettings"`
|
|
Tag string `json:"tag" form:"tag" gorm:"unique"`
|
|
Sniffing string `json:"sniffing" form:"sniffing"`
|
|
NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
|
|
}
|
|
|
|
// OutboundTraffics tracks traffic statistics for Xray outbound connections.
|
|
type OutboundTraffics struct {
|
|
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
|
|
Tag string `json:"tag" form:"tag" gorm:"unique"`
|
|
Up int64 `json:"up" form:"up" gorm:"default:0"`
|
|
Down int64 `json:"down" form:"down" gorm:"default:0"`
|
|
Total int64 `json:"total" form:"total" gorm:"default:0"`
|
|
}
|
|
|
|
// InboundClientIps stores IP addresses associated with inbound clients for access control.
|
|
type InboundClientIps struct {
|
|
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
|
ClientEmail string `json:"clientEmail" form:"clientEmail" gorm:"unique"`
|
|
Ips string `json:"ips" form:"ips"`
|
|
}
|
|
|
|
// HistoryOfSeeders tracks which database seeders have been executed to prevent re-running.
|
|
type HistoryOfSeeders struct {
|
|
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
|
SeederName string `json:"seederName"`
|
|
}
|
|
|
|
type ApiToken struct {
|
|
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
|
Name string `json:"name" gorm:"uniqueIndex;not null"`
|
|
Token string `json:"token" gorm:"not null"`
|
|
Enabled bool `json:"enabled" gorm:"default:true"`
|
|
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"`
|
|
}
|
|
|
|
// GenXrayInboundConfig generates an Xray inbound configuration from the Inbound model.
|
|
func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
|
|
listen := i.Listen
|
|
// Default to 0.0.0.0 (all interfaces) when listen is empty
|
|
// This ensures proper dual-stack IPv4/IPv6 binding in systems where bindv6only=0
|
|
if listen == "" {
|
|
listen = "0.0.0.0"
|
|
}
|
|
listen = fmt.Sprintf("\"%v\"", listen)
|
|
return &xray.InboundConfig{
|
|
Listen: json_util.RawMessage(listen),
|
|
Port: i.Port,
|
|
Protocol: string(i.Protocol),
|
|
Settings: json_util.RawMessage(i.Settings),
|
|
StreamSettings: json_util.RawMessage(i.StreamSettings),
|
|
Tag: i.Tag,
|
|
Sniffing: json_util.RawMessage(i.Sniffing),
|
|
}
|
|
}
|
|
|
|
// Setting stores key-value configuration settings for the 3x-ui panel.
|
|
type Setting struct {
|
|
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
|
|
Key string `json:"key" form:"key"`
|
|
Value string `json:"value" form:"value"`
|
|
}
|
|
|
|
// Node represents a remote 3x-ui panel registered with the central panel.
|
|
// The central panel polls each node's existing /panel/api/server/status
|
|
// endpoint over HTTP using the per-node ApiToken to populate the runtime
|
|
// status fields below.
|
|
type Node struct {
|
|
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
|
|
Name string `json:"name" form:"name" gorm:"uniqueIndex"`
|
|
Remark string `json:"remark" form:"remark"`
|
|
Scheme string `json:"scheme" form:"scheme"`
|
|
Address string `json:"address" form:"address"`
|
|
Port int `json:"port" form:"port"`
|
|
BasePath string `json:"basePath" form:"basePath"`
|
|
ApiToken string `json:"apiToken" form:"apiToken"`
|
|
Enable bool `json:"enable" form:"enable" gorm:"default:true"`
|
|
AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"`
|
|
|
|
// Heartbeat-updated fields. UpdatedAt advances on every probe even when
|
|
// the row is otherwise unchanged so the UI's "last seen" tooltip is
|
|
// truthful without us having to read LastHeartbeat separately.
|
|
Status string `json:"status" gorm:"default:unknown"` // online|offline|unknown
|
|
LastHeartbeat int64 `json:"lastHeartbeat"` // unix seconds, 0 = never
|
|
LatencyMs int `json:"latencyMs"`
|
|
XrayVersion string `json:"xrayVersion"`
|
|
CpuPct float64 `json:"cpuPct"`
|
|
MemPct float64 `json:"memPct"`
|
|
UptimeSecs uint64 `json:"uptimeSecs"`
|
|
LastError string `json:"lastError"`
|
|
|
|
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"`
|
|
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime"`
|
|
}
|
|
|
|
type CustomGeoResource struct {
|
|
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
|
Type string `json:"type" gorm:"not null;uniqueIndex:idx_custom_geo_type_alias;column:geo_type"`
|
|
Alias string `json:"alias" gorm:"not null;uniqueIndex:idx_custom_geo_type_alias"`
|
|
Url string `json:"url" gorm:"not null"`
|
|
LocalPath string `json:"localPath" gorm:"column:local_path"`
|
|
LastUpdatedAt int64 `json:"lastUpdatedAt" gorm:"default:0;column:last_updated_at"`
|
|
LastModified string `json:"lastModified" gorm:"column:last_modified"`
|
|
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime;column:created_at"`
|
|
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime;column:updated_at"`
|
|
}
|
|
|
|
type ClientReverse struct {
|
|
Tag string `json:"tag"`
|
|
}
|
|
|
|
// Client represents a client configuration for Xray inbounds with traffic limits and settings.
|
|
type Client struct {
|
|
ID string `json:"id,omitempty"` // Unique client identifier
|
|
Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm")
|
|
Password string `json:"password,omitempty"` // Client password
|
|
Flow string `json:"flow,omitempty"` // Flow control (XTLS)
|
|
Reverse *ClientReverse `json:"reverse,omitempty"` // VLESS simple reverse proxy settings
|
|
Auth string `json:"auth,omitempty"` // Auth password (Hysteria)
|
|
Email string `json:"email"` // Client email identifier
|
|
LimitIP int `json:"limitIp"` // IP limit for this client
|
|
TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB
|
|
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
|
Enable bool `json:"enable" form:"enable"` // Whether the client is enabled
|
|
TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications
|
|
SubID string `json:"subId" form:"subId"` // Subscription identifier
|
|
Comment string `json:"comment" form:"comment"` // Client comment
|
|
Reset int `json:"reset" form:"reset"` // Reset period in days
|
|
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
|
|
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
|
|
}
|