mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-15 15:50:59 +00:00
feat(sub): add per-client subscription HWID limits (#5802)
* feat(sub): add per-client subscription HWID limits * fix(sub): address HWID review on shared subId and bulk create * fix(sub): store HWID devices by sub_id and drop anchor client workaround * fix(sub): restore UA auto-detect and HTML page routing in subs() The cherry-pick of the HWID gate onto main's refactored SUBController had dropped main's UA-based format auto-detection and sub-page handling from subs(). Restore those branches, slotting enforceHwid after the HTML page and before format detection so the gate only applies to machine-readable subscription bodies. Also adapt tests to main's options-struct constructor and to the ClientService.Update signature extended with limitHwid. * fix(frontend): drop axios from HttpUtil.delete The bulk-delete rework's committed version still referenced axios, which this file no longer imports, breaking typecheck in CI. Use the httpRequest wrapper like the other verbs. --------- Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func assertClientHwidSchema(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
if !db.Migrator().HasColumn(&model.ClientRecord{}, "limit_hwid") {
|
||||
t.Fatalf("clients.limit_hwid missing")
|
||||
}
|
||||
if !db.Migrator().HasTable(&model.ClientHwid{}) {
|
||||
t.Fatalf("client_hwids table missing")
|
||||
}
|
||||
for _, col := range []string{"sub_id", "hwid_hash", "first_seen", "last_seen", "user_agent", "device_os", "os_version", "device_model"} {
|
||||
if !db.Migrator().HasColumn(&model.ClientHwid{}, col) {
|
||||
t.Fatalf("client_hwids.%s missing", col)
|
||||
}
|
||||
}
|
||||
if !db.Migrator().HasIndex(&model.ClientHwid{}, "idx_client_hwids_sub_hash") {
|
||||
t.Fatalf("client_hwids unique hash index missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientHwidSchemaSQLite(t *testing.T) {
|
||||
dbDir := t.TempDir()
|
||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||
if err := InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = CloseDB() })
|
||||
assertClientHwidSchema(t, GetDB())
|
||||
}
|
||||
|
||||
func TestClientHwidSchemaPostgres(t *testing.T) {
|
||||
dsn := strings.TrimSpace(os.Getenv("XUI_TEST_PG_DSN"))
|
||||
if dsn == "" {
|
||||
t.Skip("set XUI_TEST_PG_DSN to a reachable Postgres to run this test")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("open postgres: %v", err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("postgres db handle: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
if err := db.AutoMigrate(&model.ClientRecord{}, &model.ClientHwid{}); err != nil {
|
||||
t.Fatalf("automigrate postgres: %v", err)
|
||||
}
|
||||
assertClientHwidSchema(t, db)
|
||||
}
|
||||
@@ -75,6 +75,7 @@ func allModels() []any {
|
||||
&model.ApiToken{},
|
||||
&model.ClientRecord{},
|
||||
&model.ClientInbound{},
|
||||
&model.ClientHwid{},
|
||||
&model.ClientExternalLink{},
|
||||
&model.ClientGroup{},
|
||||
&model.InboundFallback{},
|
||||
|
||||
@@ -48,6 +48,7 @@ func migrationModels() []any {
|
||||
&model.InboundClientIps{},
|
||||
&model.ClientRecord{},
|
||||
&model.ClientInbound{},
|
||||
&model.ClientHwid{},
|
||||
&model.ClientExternalLink{},
|
||||
&model.ClientGroup{},
|
||||
&model.InboundFallback{},
|
||||
|
||||
@@ -914,6 +914,7 @@ type ClientRecord struct {
|
||||
Secret string `json:"secret" gorm:"column:secret"`
|
||||
AdTag string `json:"adTag" gorm:"column:ad_tag;default:''"`
|
||||
LimitIP int `json:"limitIp" gorm:"column:limit_ip"`
|
||||
LimitHwid int `json:"limitHwid" gorm:"column:limit_hwid;default:0"`
|
||||
TotalGB int64 `json:"totalGB" gorm:"column:total_gb"`
|
||||
ExpiryTime int64 `json:"expiryTime" gorm:"column:expiry_time"`
|
||||
Enable bool `json:"enable" gorm:"default:true"`
|
||||
@@ -981,6 +982,20 @@ type ClientInbound struct {
|
||||
|
||||
func (ClientInbound) TableName() string { return "client_inbounds" }
|
||||
|
||||
type ClientHwid struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
SubID string `json:"subId" gorm:"column:sub_id;not null;index;uniqueIndex:idx_client_hwids_sub_hash,priority:1"`
|
||||
HwidHash string `json:"-" gorm:"column:hwid_hash;size:64;not null;uniqueIndex:idx_client_hwids_sub_hash,priority:2"`
|
||||
FirstSeen int64 `json:"firstSeen" gorm:"column:first_seen;not null"`
|
||||
LastSeen int64 `json:"lastSeen" gorm:"column:last_seen;not null;index"`
|
||||
UserAgent string `json:"userAgent" gorm:"column:user_agent"`
|
||||
DeviceOS string `json:"deviceOs" gorm:"column:device_os"`
|
||||
OsVersion string `json:"osVersion" gorm:"column:os_version"`
|
||||
DeviceModel string `json:"deviceModel" gorm:"column:device_model"`
|
||||
}
|
||||
|
||||
func (ClientHwid) TableName() string { return "client_hwids" }
|
||||
|
||||
// ClientExternalLink is a per-client entry surfaced in the client's
|
||||
// subscription. Two kinds:
|
||||
// - "link": a single third-party share link (vless://, vmess://, trojan://,
|
||||
@@ -1267,6 +1282,16 @@ func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientM
|
||||
existing.LimitIP = picked
|
||||
}
|
||||
}
|
||||
if existing.LimitHwid != incoming.LimitHwid && incoming.LimitHwid != 0 {
|
||||
picked := existing.LimitHwid
|
||||
if existing.LimitHwid == 0 || incoming.LimitHwid > existing.LimitHwid {
|
||||
picked = incoming.LimitHwid
|
||||
}
|
||||
if picked != existing.LimitHwid {
|
||||
keep("limitHwid", existing.LimitHwid, incoming.LimitHwid, picked)
|
||||
existing.LimitHwid = picked
|
||||
}
|
||||
}
|
||||
if existing.TgID != incoming.TgID && incoming.TgID != 0 {
|
||||
if incomingNewer || existing.TgID == 0 {
|
||||
keep("tgId", existing.TgID, incoming.TgID, incoming.TgID)
|
||||
|
||||
@@ -72,6 +72,7 @@ type SUBController struct {
|
||||
subService *SubService
|
||||
subJsonService *SubJsonService
|
||||
subClashService *SubClashService
|
||||
clientService service.ClientService
|
||||
settingService service.SettingService
|
||||
|
||||
subTemplateMu sync.RWMutex
|
||||
@@ -384,6 +385,9 @@ func (a *SUBController) subs(c *gin.Context) {
|
||||
logSubscriptionRoute(userAgent, "html")
|
||||
return
|
||||
}
|
||||
if !a.enforceHwid(c) {
|
||||
return
|
||||
}
|
||||
if shouldAutoServeClash(a.subClashAutoDetect, a.clashEnabled, false, userAgent, a.clashUserAgent) && a.serveClashBody(c, false) {
|
||||
a.recordSubscriptionFetch(c)
|
||||
logSubscriptionRoute(userAgent, "clash")
|
||||
@@ -605,6 +609,41 @@ func (a *SUBController) subPageContext(page PageData) map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *SUBController) enforceHwid(c *gin.Context) bool {
|
||||
result, err := a.clientService.EnforceHwidForSubID(c.Param("subid"), service.HwidRequest{
|
||||
Hwid: c.GetHeader("X-HWID"),
|
||||
UserAgent: c.GetHeader("User-Agent"),
|
||||
DeviceOS: c.GetHeader("X-Device-OS"),
|
||||
OsVersion: c.GetHeader("X-Ver-OS"),
|
||||
DeviceModel: c.GetHeader("X-Device-Model"),
|
||||
})
|
||||
if err != nil {
|
||||
writeSubError(c, err)
|
||||
return false
|
||||
}
|
||||
applyHwidHeaders(c, result)
|
||||
if !result.Allowed {
|
||||
c.Status(http.StatusNotFound)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func applyHwidHeaders(c *gin.Context, result service.HwidGateResult) {
|
||||
if result.Active {
|
||||
c.Header("X-Hwid-Active", "true")
|
||||
}
|
||||
if result.NotSupported {
|
||||
c.Header("X-Hwid-Not-Supported", "true")
|
||||
}
|
||||
if result.LimitReached {
|
||||
c.Header("X-Hwid-Limit", "true")
|
||||
}
|
||||
if result.MaxDevicesReached {
|
||||
c.Header("X-Hwid-Max-Devices-Reached", "true")
|
||||
}
|
||||
}
|
||||
|
||||
// setNoCacheHeaders marks a subscription page response as non-cacheable so VPN
|
||||
// clients and browsers always fetch fresh traffic/expiry data.
|
||||
func setNoCacheHeaders(c *gin.Context) {
|
||||
@@ -668,6 +707,9 @@ func (a *SUBController) subJsons(c *gin.Context) {
|
||||
if a.maybeServeSubPage(c) {
|
||||
return
|
||||
}
|
||||
if !a.enforceHwid(c) {
|
||||
return
|
||||
}
|
||||
a.serveJson(c, a.jsonAlwaysArray, "text/plain; charset=utf-8")
|
||||
}
|
||||
|
||||
@@ -713,6 +755,9 @@ func (a *SUBController) subClashs(c *gin.Context) {
|
||||
if a.maybeServeSubPage(c) {
|
||||
return
|
||||
}
|
||||
if !a.enforceHwid(c) {
|
||||
return
|
||||
}
|
||||
if !a.serveClashBody(c, false) {
|
||||
writeSubError(c, nil)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func initHwidSubRouter(t *testing.T, limit int) (*gin.Engine, string) {
|
||||
t.Helper()
|
||||
tmp := t.TempDir()
|
||||
t.Chdir(tmp)
|
||||
if err := os.MkdirAll("internal/web/dist", 0o755); err != nil {
|
||||
t.Fatalf("mkdir dist: %v", err)
|
||||
}
|
||||
if err := os.WriteFile("internal/web/dist/subpage.html", []byte("<html><head></head><body></body></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write subpage: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("XUI_DB_FOLDER", tmp)
|
||||
if err := database.InitDB(filepath.Join(tmp, "x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
|
||||
const subID = "sub-hwid-route"
|
||||
const email = "route@example.com"
|
||||
const uuid = "11111111-2222-4333-8444-555555555555"
|
||||
db := database.GetDB()
|
||||
ib := &model.Inbound{
|
||||
UserId: 1,
|
||||
Tag: "hwid-sub",
|
||||
Enable: true,
|
||||
Port: 443,
|
||||
Protocol: model.VLESS,
|
||||
Settings: `{"clients":[]}`,
|
||||
StreamSettings: `{"network":"tcp","security":"none"}`,
|
||||
}
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatalf("seed inbound: %v", err)
|
||||
}
|
||||
client := &model.ClientRecord{Email: email, SubID: subID, UUID: uuid, Enable: true, LimitHwid: limit}
|
||||
if err := db.Create(client).Error; err != nil {
|
||||
t.Fatalf("seed client: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: ib.Id}).Error; err != nil {
|
||||
t.Fatalf("seed client inbound: %v", err)
|
||||
}
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
NewSUBController(
|
||||
router.Group("/"),
|
||||
WithSUBPath("/sub/"),
|
||||
WithSUBJsonPath("/json/"),
|
||||
WithSUBClashPath("/clash/"),
|
||||
WithSUBClashAutoDetect(true),
|
||||
WithSUBJsonAutoDetect(true),
|
||||
WithSUBJsonEnabled(true),
|
||||
WithSUBClashEnabled(true),
|
||||
)
|
||||
return router, subID
|
||||
}
|
||||
|
||||
func requestSub(t *testing.T, router *gin.Engine, method string, path string, hwid string, accept string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
req.Host = "sub.example.com"
|
||||
if hwid != "" {
|
||||
req.Header.Set("X-HWID", hwid)
|
||||
}
|
||||
if accept != "" {
|
||||
req.Header.Set("Accept", accept)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestSubscriptionHwidGateAcrossBodyRoutes(t *testing.T) {
|
||||
router, subID := initHwidSubRouter(t, 1)
|
||||
|
||||
for _, path := range []string{"/sub/" + subID, "/json/" + subID, "/clash/" + subID} {
|
||||
rec := requestSub(t, router, http.MethodGet, path, "", "")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("%s missing HWID status = %d, want 404", path, rec.Code)
|
||||
}
|
||||
if rec.Header().Get("X-Hwid-Active") != "true" || rec.Header().Get("X-Hwid-Not-Supported") != "true" {
|
||||
t.Fatalf("%s missing HWID headers = %#v", path, rec.Header())
|
||||
}
|
||||
}
|
||||
|
||||
rec := requestSub(t, router, http.MethodHead, "/sub/"+subID, "", "")
|
||||
if rec.Code != http.StatusNotFound || rec.Header().Get("X-Hwid-Not-Supported") != "true" {
|
||||
t.Fatalf("HEAD missing HWID = %d %#v", rec.Code, rec.Header())
|
||||
}
|
||||
|
||||
for _, path := range []string{"/sub/" + subID, "/json/" + subID, "/clash/" + subID} {
|
||||
rec = requestSub(t, router, http.MethodGet, path, "device-one", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s registered HWID status = %d, body=%q", path, rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Header().Get("X-Hwid-Active") != "true" {
|
||||
t.Fatalf("%s allowed response missing active HWID header", path)
|
||||
}
|
||||
}
|
||||
|
||||
rec = requestSub(t, router, http.MethodGet, "/json/"+subID, "device-two", "")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("new HWID after limit status = %d, want 404", rec.Code)
|
||||
}
|
||||
if rec.Header().Get("X-Hwid-Max-Devices-Reached") != "true" || rec.Header().Get("X-Hwid-Limit") != "true" {
|
||||
t.Fatalf("limit headers missing: %#v", rec.Header())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscriptionHwidGateSkipsHtmlInfoPage(t *testing.T) {
|
||||
router, subID := initHwidSubRouter(t, 1)
|
||||
|
||||
rec := requestSub(t, router, http.MethodGet, "/sub/"+subID, "", "text/html")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("HTML sub page status = %d, want 200, body=%q", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Header().Get("X-Hwid-Not-Supported") != "" {
|
||||
t.Fatalf("HTML sub page should not be HWID-gated: %#v", rec.Header())
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,8 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
|
||||
g.POST("/updateTraffic/:email", a.updateTrafficByEmail)
|
||||
g.POST("/ips/:email", a.getIps)
|
||||
g.POST("/clearIps/:email", a.clearIps)
|
||||
g.POST("/hwids/:email", a.getHwids)
|
||||
g.DELETE("/hwids/:email", a.clearHwids)
|
||||
g.POST("/onlines", a.onlines)
|
||||
g.POST("/onlinesByGuid", a.onlinesByGuid)
|
||||
g.POST("/clientIpsByGuid", a.clientIpsByGuid)
|
||||
@@ -191,13 +193,16 @@ func (a *ClientController) create(c *gin.Context) {
|
||||
|
||||
func (a *ClientController) update(c *gin.Context) {
|
||||
email := c.Param("email")
|
||||
var updated model.Client
|
||||
if err := c.ShouldBindJSON(&updated); err != nil {
|
||||
var req struct {
|
||||
model.Client
|
||||
LimitHwid int `json:"limitHwid"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
inboundFilter := parseInboundIdsQuery(c.Query("inboundIds"))
|
||||
needRestart, err := a.clientService.UpdateByEmail(&a.inboundService, email, updated, inboundFilter...)
|
||||
needRestart, err := a.clientService.UpdateByEmail(&a.inboundService, email, req.Client, req.LimitHwid, inboundFilter...)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
@@ -540,6 +545,19 @@ func (a *ClientController) clearIps(c *gin.Context) {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.logCleanSuccess"), nil)
|
||||
}
|
||||
|
||||
func (a *ClientController) getHwids(c *gin.Context) {
|
||||
infos, err := a.clientService.ListClientHwids(c.Param("email"))
|
||||
jsonObj(c, infos, err)
|
||||
}
|
||||
|
||||
func (a *ClientController) clearHwids(c *gin.Context) {
|
||||
if err := a.clientService.ClearClientHwids(c.Param("email")); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.updateSuccess"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.logCleanSuccess"), nil)
|
||||
}
|
||||
|
||||
func (a *ClientController) onlines(c *gin.Context) {
|
||||
jsonObj(c, a.inboundService.GetOnlineClients(), nil)
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ func TestAllAPIsPostgresScale(t *testing.T) {
|
||||
run("UpdateByEmail", func() error {
|
||||
upd := clients[n/3]
|
||||
upd.Comment = "touched"
|
||||
_, err := svc.UpdateByEmail(inboundSvc, upd.Email, upd)
|
||||
_, err := svc.UpdateByEmail(inboundSvc, upd.Email, upd, 0)
|
||||
return err
|
||||
})
|
||||
run("AttachByEmail", func() error { _, err := svc.AttachByEmail(inboundSvc, emails[n/3], []int{ib2.Id}); return err })
|
||||
|
||||
@@ -68,6 +68,36 @@ var ErrClientNotInInbound = errors.New("client not found in inbound")
|
||||
type ClientCreatePayload struct {
|
||||
Client model.Client `json:"client"`
|
||||
InboundIds []int `json:"inboundIds"`
|
||||
LimitHwid int `json:"-"`
|
||||
}
|
||||
|
||||
const sqlInChunk = 400
|
||||
|
||||
type clientPayloadWithHwid struct {
|
||||
model.Client
|
||||
LimitHwid int `json:"limitHwid"`
|
||||
}
|
||||
|
||||
func (p *ClientCreatePayload) UnmarshalJSON(data []byte) error {
|
||||
var raw struct {
|
||||
Client clientPayloadWithHwid `json:"client"`
|
||||
InboundIds []int `json:"inboundIds"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
p.Client = raw.Client.Client
|
||||
p.InboundIds = raw.InboundIds
|
||||
p.LimitHwid = raw.Client.LimitHwid
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p ClientCreatePayload) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Client clientPayloadWithHwid `json:"client"`
|
||||
InboundIds []int `json:"inboundIds"`
|
||||
}{
|
||||
Client: clientPayloadWithHwid{Client: p.Client, LimitHwid: p.LimitHwid},
|
||||
InboundIds: p.InboundIds,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -816,6 +816,7 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
|
||||
successEmails := make([]string, 0, len(recordsByEmail))
|
||||
successIds := make([]int, 0, len(recordsByEmail))
|
||||
failedEmails := make([]string, 0, len(recordsByEmail))
|
||||
successSubIDs := make([]string, 0, len(recordsByEmail))
|
||||
for email, rec := range recordsByEmail {
|
||||
if _, skipped := skippedReasons[email]; skipped {
|
||||
failedEmails = append(failedEmails, email)
|
||||
@@ -823,6 +824,7 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
|
||||
}
|
||||
successEmails = append(successEmails, email)
|
||||
successIds = append(successIds, rec.Id)
|
||||
successSubIDs = append(successSubIDs, rec.SubID)
|
||||
}
|
||||
withdrawClientTombstones(failedEmails...)
|
||||
|
||||
@@ -833,6 +835,9 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
|
||||
if e := adjustGroupBaselinesForRemovedTraffic(tx, successEmails); e != nil {
|
||||
return e
|
||||
}
|
||||
if e := clearClientHwidsBySubIDTx(tx, successSubIDs...); e != nil {
|
||||
return e
|
||||
}
|
||||
for _, batch := range chunkInts(successIds, sqlInChunk) {
|
||||
if e := tx.Where("client_id IN ?", batch).Delete(&model.ClientInbound{}).Error; e != nil {
|
||||
return e
|
||||
@@ -1119,6 +1124,7 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client
|
||||
type prepared struct {
|
||||
client model.Client
|
||||
inboundIds []int
|
||||
limitHwid int
|
||||
}
|
||||
prep := make([]prepared, 0, len(payloads))
|
||||
emails := make([]string, 0, len(payloads))
|
||||
@@ -1171,7 +1177,7 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client
|
||||
seenEmail[le] = struct{}{}
|
||||
seenSubID[client.SubID] = le
|
||||
|
||||
prep = append(prep, prepared{client: client, inboundIds: payloads[i].InboundIds})
|
||||
prep = append(prep, prepared{client: client, inboundIds: payloads[i].InboundIds, limitHwid: payloads[i].LimitHwid})
|
||||
emails = append(emails, email)
|
||||
subIDs = append(subIDs, client.SubID)
|
||||
}
|
||||
@@ -1303,9 +1309,13 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client
|
||||
for idx := range prep {
|
||||
if failed[idx] {
|
||||
skip(prep[idx].client.Email, reason[idx])
|
||||
} else {
|
||||
result.Created++
|
||||
continue
|
||||
}
|
||||
if err := s.setClientLimitHwidByEmail(nil, prep[idx].client.Email, prep[idx].limitHwid); err != nil {
|
||||
skip(prep[idx].client.Email, err.Error())
|
||||
continue
|
||||
}
|
||||
result.Created++
|
||||
}
|
||||
return result, needRestart, nil
|
||||
}
|
||||
|
||||
@@ -140,6 +140,9 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate
|
||||
needRestart = true
|
||||
}
|
||||
}
|
||||
if err := s.setClientLimitHwidByEmail(nil, client.Email, payload.LimitHwid); err != nil {
|
||||
return needRestart, err
|
||||
}
|
||||
return needRestart, nil
|
||||
}
|
||||
|
||||
@@ -309,7 +312,7 @@ func applyShadowsocksClientMethod(clients []any, settings map[string]any) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model.Client, inboundFilter ...int) (bool, error) {
|
||||
func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model.Client, limitHwid int, inboundFilter ...int) (bool, error) {
|
||||
existing, err := s.GetByID(id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -507,6 +510,10 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
|
||||
return needRestart, err
|
||||
}
|
||||
|
||||
if err := s.setClientLimitHwidByEmail(nil, updated.Email, limitHwid); err != nil {
|
||||
return needRestart, err
|
||||
}
|
||||
|
||||
if err := database.GetDB().Model(&model.ClientRecord{}).
|
||||
Where("id = ?", id).
|
||||
UpdateColumn("updated_at", time.Now().UnixMilli()).Error; err != nil {
|
||||
@@ -581,6 +588,9 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
|
||||
if err := tx.Where("client_id = ?", id).Delete(&model.ClientExternalLink{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := clearClientHwidsBySubIDTx(tx, existing.SubID); err != nil {
|
||||
return err
|
||||
}
|
||||
if !keepTraffic && existing.Email != "" {
|
||||
if err := tx.Where("email = ?", existing.Email).Delete(&xray.ClientTraffic{}).Error; err != nil {
|
||||
return err
|
||||
@@ -755,7 +765,7 @@ func (s *ClientService) DeleteByEmail(inboundSvc *InboundService, email string,
|
||||
return needRestart, nil
|
||||
}
|
||||
|
||||
func (s *ClientService) UpdateByEmail(inboundSvc *InboundService, email string, updated model.Client, inboundFilter ...int) (bool, error) {
|
||||
func (s *ClientService) UpdateByEmail(inboundSvc *InboundService, email string, updated model.Client, limitHwid int, inboundFilter ...int) (bool, error) {
|
||||
if email == "" {
|
||||
return false, common.NewError("client email is required")
|
||||
}
|
||||
@@ -763,7 +773,7 @@ func (s *ClientService) UpdateByEmail(inboundSvc *InboundService, email string,
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return s.Update(inboundSvc, rec.Id, updated, inboundFilter...)
|
||||
return s.Update(inboundSvc, rec.Id, updated, limitHwid, inboundFilter...)
|
||||
}
|
||||
|
||||
func (s *ClientService) Detach(inboundSvc *InboundService, id int, inboundIds []int) (bool, error) {
|
||||
|
||||
@@ -171,7 +171,7 @@ func TestClientUpdate_ClearsGroup(t *testing.T) {
|
||||
// Edit the client and remove the group.
|
||||
updated := *rec.ToClient()
|
||||
updated.Group = ""
|
||||
if _, err := svc.Update(inboundSvc, rec.Id, updated); err != nil {
|
||||
if _, err := svc.Update(inboundSvc, rec.Id, updated, 0); err != nil {
|
||||
t.Fatalf("Update (clear group): %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type HwidRequest struct {
|
||||
Hwid string
|
||||
UserAgent string
|
||||
DeviceOS string
|
||||
OsVersion string
|
||||
DeviceModel string
|
||||
}
|
||||
|
||||
type HwidGateResult struct {
|
||||
Allowed bool
|
||||
Active bool
|
||||
NotSupported bool
|
||||
MaxDevicesReached bool
|
||||
LimitReached bool
|
||||
Limit int
|
||||
Registered int
|
||||
}
|
||||
|
||||
const minHwidLength = 6
|
||||
|
||||
type ClientHwidInfo struct {
|
||||
Id int `json:"id"`
|
||||
FirstSeen int64 `json:"firstSeen"`
|
||||
LastSeen int64 `json:"lastSeen"`
|
||||
UserAgent string `json:"userAgent"`
|
||||
DeviceOS string `json:"deviceOs"`
|
||||
OsVersion string `json:"osVersion"`
|
||||
DeviceModel string `json:"deviceModel"`
|
||||
}
|
||||
|
||||
func hashHwid(raw string) string {
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func trimHwidMeta(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
r := []rune(s)
|
||||
if len(r) > 512 {
|
||||
return string(r[:512])
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func normalizeHwidRequest(req HwidRequest) HwidRequest {
|
||||
return HwidRequest{
|
||||
Hwid: strings.TrimSpace(req.Hwid),
|
||||
UserAgent: trimHwidMeta(req.UserAgent),
|
||||
DeviceOS: trimHwidMeta(req.DeviceOS),
|
||||
OsVersion: trimHwidMeta(req.OsVersion),
|
||||
DeviceModel: trimHwidMeta(req.DeviceModel),
|
||||
}
|
||||
}
|
||||
|
||||
func effectiveHwidLimitForSubID(tx *gorm.DB, subID string) (int, error) {
|
||||
var limit int
|
||||
err := tx.Model(&model.ClientRecord{}).
|
||||
Where("sub_id = ? AND enable = ?", subID, true).
|
||||
Select("COALESCE(MAX(limit_hwid), 0)").
|
||||
Scan(&limit).Error
|
||||
return limit, err
|
||||
}
|
||||
|
||||
func (s *ClientService) EnforceHwidForSubID(subID string, req HwidRequest) (HwidGateResult, error) {
|
||||
var res HwidGateResult
|
||||
subID = strings.TrimSpace(subID)
|
||||
if subID == "" {
|
||||
res.Allowed = true
|
||||
return res, nil
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
limit, err := effectiveHwidLimitForSubID(db, subID)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
if limit <= 0 {
|
||||
res.Allowed = true
|
||||
return res, nil
|
||||
}
|
||||
|
||||
req = normalizeHwidRequest(req)
|
||||
res.Active = true
|
||||
res.Limit = limit
|
||||
if len(req.Hwid) < minHwidLength {
|
||||
res.NotSupported = true
|
||||
return res, nil
|
||||
}
|
||||
hwidHash := hashHwid(req.Hwid)
|
||||
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
limit, err := effectiveHwidLimitForSubID(tx, subID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if limit <= 0 {
|
||||
res = HwidGateResult{Allowed: true}
|
||||
return nil
|
||||
}
|
||||
res.Active = true
|
||||
res.Limit = limit
|
||||
now := time.Now().UnixMilli()
|
||||
var existing model.ClientHwid
|
||||
err = tx.Where("sub_id = ? AND hwid_hash = ?", subID, hwidHash).First(&existing).Error
|
||||
if err == nil {
|
||||
if err := tx.Model(&model.ClientHwid{}).Where("id = ?", existing.Id).Updates(map[string]any{
|
||||
"last_seen": now, "user_agent": req.UserAgent, "device_os": req.DeviceOS, "os_version": req.OsVersion, "device_model": req.DeviceModel,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var count int64
|
||||
if err := tx.Model(&model.ClientHwid{}).Where("sub_id = ?", subID).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
res.Allowed = true
|
||||
res.Registered = int(count)
|
||||
res.LimitReached = count >= int64(limit)
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
var count int64
|
||||
if err := tx.Model(&model.ClientHwid{}).Where("sub_id = ?", subID).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
res.Registered = int(count)
|
||||
if count >= int64(limit) {
|
||||
res.MaxDevicesReached = true
|
||||
res.LimitReached = true
|
||||
return nil
|
||||
}
|
||||
if err := tx.Create(&model.ClientHwid{SubID: subID, HwidHash: hwidHash, FirstSeen: now, LastSeen: now, UserAgent: req.UserAgent, DeviceOS: req.DeviceOS, OsVersion: req.OsVersion, DeviceModel: req.DeviceModel}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
res.Allowed = true
|
||||
res.Registered = int(count) + 1
|
||||
res.LimitReached = res.Registered >= limit
|
||||
return nil
|
||||
})
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (s *ClientService) ListClientHwids(email string) ([]ClientHwidInfo, error) {
|
||||
rec, err := s.GetRecordByEmail(nil, email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
subID := strings.TrimSpace(rec.SubID)
|
||||
if subID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var rows []model.ClientHwid
|
||||
if err := database.GetDB().
|
||||
Where("sub_id = ?", subID).
|
||||
Order("last_seen DESC").
|
||||
Order("id DESC").
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ClientHwidInfo, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, ClientHwidInfo{
|
||||
Id: r.Id,
|
||||
FirstSeen: r.FirstSeen,
|
||||
LastSeen: r.LastSeen,
|
||||
UserAgent: r.UserAgent,
|
||||
DeviceOS: r.DeviceOS,
|
||||
OsVersion: r.OsVersion,
|
||||
DeviceModel: r.DeviceModel,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ClientService) ClearClientHwids(email string) error {
|
||||
rec, err := s.GetRecordByEmail(nil, email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
subID := strings.TrimSpace(rec.SubID)
|
||||
if subID == "" {
|
||||
return nil
|
||||
}
|
||||
return database.GetDB().Where("sub_id = ?", subID).Delete(&model.ClientHwid{}).Error
|
||||
}
|
||||
|
||||
func (s *ClientService) setClientLimitHwidByEmail(tx *gorm.DB, email string, limit int) error {
|
||||
if tx == nil {
|
||||
tx = database.GetDB()
|
||||
}
|
||||
if limit < 0 {
|
||||
limit = 0
|
||||
}
|
||||
var rec model.ClientRecord
|
||||
if err := tx.Where("email = ?", email).First(&rec).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&model.ClientRecord{}).Where("id = ?", rec.Id).UpdateColumn("limit_hwid", limit).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
subID := strings.TrimSpace(rec.SubID)
|
||||
if subID == "" {
|
||||
return nil
|
||||
}
|
||||
effective, err := effectiveHwidLimitForSubID(tx, subID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return trimClientHwidsForSubID(tx, subID, effective)
|
||||
}
|
||||
|
||||
func trimClientHwidsForSubID(tx *gorm.DB, subID string, limit int) error {
|
||||
subID = strings.TrimSpace(subID)
|
||||
if subID == "" || limit <= 0 {
|
||||
return nil
|
||||
}
|
||||
var keep []int
|
||||
if err := tx.Model(&model.ClientHwid{}).
|
||||
Where("sub_id = ?", subID).
|
||||
Order("last_seen DESC").
|
||||
Order("id DESC").
|
||||
Limit(limit).
|
||||
Pluck("id", &keep).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(keep) == 0 {
|
||||
return tx.Where("sub_id = ?", subID).Delete(&model.ClientHwid{}).Error
|
||||
}
|
||||
return tx.Where("sub_id = ? AND id NOT IN ?", subID, keep).Delete(&model.ClientHwid{}).Error
|
||||
}
|
||||
|
||||
func clearClientHwidsBySubIDTx(tx *gorm.DB, subIDs ...string) error {
|
||||
if tx == nil {
|
||||
tx = database.GetDB()
|
||||
}
|
||||
clean := make([]string, 0, len(subIDs))
|
||||
seen := map[string]struct{}{}
|
||||
for _, subID := range subIDs {
|
||||
subID = strings.TrimSpace(subID)
|
||||
if subID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[subID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[subID] = struct{}{}
|
||||
clean = append(clean, subID)
|
||||
}
|
||||
for _, batch := range chunkStrings(clean, sqlInChunk) {
|
||||
if err := tx.Where("sub_id IN ?", batch).Delete(&model.ClientHwid{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func initClientHwidTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
dbDir := t.TempDir()
|
||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
}
|
||||
|
||||
func seedHwidClient(t *testing.T, limit int) *model.ClientRecord {
|
||||
t.Helper()
|
||||
rec := &model.ClientRecord{
|
||||
Email: "hwid@example.com",
|
||||
SubID: "sub-hwid",
|
||||
UUID: "11111111-2222-4333-8444-555555555555",
|
||||
Enable: true,
|
||||
LimitHwid: limit,
|
||||
}
|
||||
if err := database.GetDB().Create(rec).Error; err != nil {
|
||||
t.Fatalf("seed client: %v", err)
|
||||
}
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestClientHwidGate(t *testing.T) {
|
||||
initClientHwidTestDB(t)
|
||||
svc := &ClientService{}
|
||||
|
||||
seedHwidClient(t, 0)
|
||||
res, err := svc.EnforceHwidForSubID("sub-hwid", HwidRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("no-limit gate: %v", err)
|
||||
}
|
||||
if !res.Allowed || res.Active {
|
||||
t.Fatalf("no limit should allow missing HWID without active headers: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientHwidGateRegistersAndBlocks(t *testing.T) {
|
||||
initClientHwidTestDB(t)
|
||||
svc := &ClientService{}
|
||||
rec := seedHwidClient(t, 2)
|
||||
|
||||
res, err := svc.EnforceHwidForSubID(rec.SubID, HwidRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("missing HWID gate: %v", err)
|
||||
}
|
||||
if res.Allowed || !res.Active || !res.NotSupported {
|
||||
t.Fatalf("missing HWID should be denied as not supported: %+v", res)
|
||||
}
|
||||
|
||||
firstRaw := "device-one"
|
||||
for _, raw := range []string{firstRaw, "device-two"} {
|
||||
res, err = svc.EnforceHwidForSubID(rec.SubID, HwidRequest{
|
||||
Hwid: raw,
|
||||
UserAgent: "Happ/1.0",
|
||||
DeviceOS: "android",
|
||||
OsVersion: "15",
|
||||
DeviceModel: raw + "-model",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("register %s: %v", raw, err)
|
||||
}
|
||||
if !res.Allowed {
|
||||
t.Fatalf("register %s denied: %+v", raw, res)
|
||||
}
|
||||
}
|
||||
|
||||
res, err = svc.EnforceHwidForSubID(rec.SubID, HwidRequest{Hwid: "device-three"})
|
||||
if err != nil {
|
||||
t.Fatalf("third HWID gate: %v", err)
|
||||
}
|
||||
if res.Allowed || !res.MaxDevicesReached || !res.LimitReached {
|
||||
t.Fatalf("third unique HWID should be denied after limit: %+v", res)
|
||||
}
|
||||
|
||||
res, err = svc.EnforceHwidForSubID(rec.SubID, HwidRequest{
|
||||
Hwid: firstRaw,
|
||||
UserAgent: "Karing/2.0",
|
||||
DeviceOS: "ios",
|
||||
OsVersion: "18",
|
||||
DeviceModel: "updated-model",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("existing HWID after full limit: %v", err)
|
||||
}
|
||||
if !res.Allowed || !res.LimitReached {
|
||||
t.Fatalf("existing registered HWID should pass after limit: %+v", res)
|
||||
}
|
||||
|
||||
var hashes []string
|
||||
if err := database.GetDB().Model(&model.ClientHwid{}).Pluck("hwid_hash", &hashes).Error; err != nil {
|
||||
t.Fatalf("pluck hashes: %v", err)
|
||||
}
|
||||
if len(hashes) != 2 {
|
||||
t.Fatalf("stored HWIDs = %d, want 2", len(hashes))
|
||||
}
|
||||
for _, h := range hashes {
|
||||
if h == firstRaw || h == "device-two" || len(h) != 64 {
|
||||
t.Fatalf("raw HWID leaked or invalid hash stored: %q", h)
|
||||
}
|
||||
}
|
||||
|
||||
list, err := svc.ListClientHwids(rec.Email)
|
||||
if err != nil {
|
||||
t.Fatalf("list HWIDs: %v", err)
|
||||
}
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("list count = %d, want 2", len(list))
|
||||
}
|
||||
foundUpdated := false
|
||||
for _, row := range list {
|
||||
if row.DeviceModel == "updated-model" && row.UserAgent == "Karing/2.0" && row.DeviceOS == "ios" && row.OsVersion == "18" {
|
||||
foundUpdated = true
|
||||
}
|
||||
}
|
||||
if !foundUpdated {
|
||||
t.Fatalf("updated HWID metadata missing: %#v", list)
|
||||
}
|
||||
|
||||
if err := svc.setClientLimitHwidByEmail(nil, rec.Email, 1); err != nil {
|
||||
t.Fatalf("lower limit: %v", err)
|
||||
}
|
||||
var count int64
|
||||
if err := database.GetDB().Model(&model.ClientHwid{}).Where("sub_id = ?", rec.SubID).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count after trim: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("lowered limit should trim stored HWIDs to 1, got %d", count)
|
||||
}
|
||||
|
||||
if err := svc.ClearClientHwids(rec.Email); err != nil {
|
||||
t.Fatalf("clear HWIDs: %v", err)
|
||||
}
|
||||
if err := database.GetDB().Model(&model.ClientHwid{}).Where("sub_id = ?", rec.SubID).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count after clear: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("clear should remove all HWIDs, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientHwidGateSharedSubIdUsesMaxLimit(t *testing.T) {
|
||||
initClientHwidTestDB(t)
|
||||
svc := &ClientService{}
|
||||
db := database.GetDB()
|
||||
subID := "shared-sub"
|
||||
if err := db.Create(&model.ClientRecord{Email: "a@ex.com", SubID: subID, UUID: "11111111-2222-4333-8444-555555555555", Enable: true, LimitHwid: 0}).Error; err != nil {
|
||||
t.Fatalf("seed anchor: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.ClientRecord{Email: "b@ex.com", SubID: subID, UUID: "22222222-2222-4333-8444-555555555555", Enable: true, LimitHwid: 2}).Error; err != nil {
|
||||
t.Fatalf("seed second: %v", err)
|
||||
}
|
||||
res, err := svc.EnforceHwidForSubID(subID, HwidRequest{})
|
||||
if err != nil || !res.Active || res.Limit != 2 {
|
||||
t.Fatalf("expected active gate limit 2 from max row, err=%v res=%+v", err, res)
|
||||
}
|
||||
if res.Allowed || !res.NotSupported {
|
||||
t.Fatalf("missing HWID should be denied: %+v", res)
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ type ClientSlim struct {
|
||||
TotalGB int64 `json:"totalGB"`
|
||||
ExpiryTime int64 `json:"expiryTime"`
|
||||
LimitIP int `json:"limitIp"`
|
||||
LimitHwid int `json:"limitHwid"`
|
||||
Reset int `json:"reset"`
|
||||
Group string `json:"group,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
@@ -457,21 +458,11 @@ func (q clientQuery) pageRows(params ClientPageParams, onlines []string, offset,
|
||||
if rec == nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, ClientSlim{
|
||||
Email: rec.Email,
|
||||
SubID: rec.SubID,
|
||||
Enable: rec.Enable,
|
||||
TotalGB: rec.TotalGB,
|
||||
ExpiryTime: rec.ExpiryTime,
|
||||
LimitIP: rec.LimitIP,
|
||||
Reset: rec.Reset,
|
||||
Group: rec.Group,
|
||||
Comment: rec.Comment,
|
||||
InboundIds: attachments[rec.Id],
|
||||
Traffic: trafficByEmail[rec.Email],
|
||||
CreatedAt: rec.CreatedAt,
|
||||
UpdatedAt: rec.UpdatedAt,
|
||||
})
|
||||
items = append(items, toClientSlim(ClientWithAttachments{
|
||||
ClientRecord: *rec,
|
||||
InboundIds: attachments[rec.Id],
|
||||
Traffic: trafficByEmail[rec.Email],
|
||||
}))
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -604,6 +595,25 @@ func sqlInt(v int64) string {
|
||||
return strconv.FormatInt(v, 10)
|
||||
}
|
||||
|
||||
func toClientSlim(c ClientWithAttachments) ClientSlim {
|
||||
return ClientSlim{
|
||||
Email: c.Email,
|
||||
SubID: c.SubID,
|
||||
Enable: c.Enable,
|
||||
TotalGB: c.TotalGB,
|
||||
ExpiryTime: c.ExpiryTime,
|
||||
LimitIP: c.LimitIP,
|
||||
LimitHwid: c.LimitHwid,
|
||||
Reset: c.Reset,
|
||||
Group: c.Group,
|
||||
Comment: c.Comment,
|
||||
InboundIds: c.InboundIds,
|
||||
Traffic: c.Traffic,
|
||||
CreatedAt: c.CreatedAt,
|
||||
UpdatedAt: c.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// escapeLikeLiteral neutralises LIKE wildcards so searching for "a_b" keeps
|
||||
// matching literally, the way strings.Contains did.
|
||||
func escapeLikeLiteral(s string) string {
|
||||
|
||||
@@ -54,6 +54,7 @@ func (s *ClientService) ExportAll() ([]ClientCreatePayload, error) {
|
||||
out = append(out, ClientCreatePayload{
|
||||
Client: *client,
|
||||
InboundIds: attachments[rows[i].Id],
|
||||
LimitHwid: rows[i].LimitHwid,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
@@ -151,7 +152,9 @@ func (s *ClientService) ImportClients(inboundSvc *InboundService, items []Client
|
||||
}
|
||||
client.UpdatedAt = now
|
||||
|
||||
if err := db.Create(client.ToRecord()).Error; err != nil {
|
||||
rec := client.ToRecord()
|
||||
rec.LimitHwid = orphans[i].LimitHwid
|
||||
if err := db.Create(rec).Error; err != nil {
|
||||
skip(email, err.Error())
|
||||
continue
|
||||
}
|
||||
@@ -178,11 +181,13 @@ func (s *ClientService) DeleteOrphans() (int, error) {
|
||||
|
||||
ids := make([]int, 0, len(rows))
|
||||
emails := make([]string, 0, len(rows))
|
||||
subIDs := make([]string, 0, len(rows))
|
||||
for i := range rows {
|
||||
ids = append(ids, rows[i].Id)
|
||||
if rows[i].Email != "" {
|
||||
emails = append(emails, rows[i].Email)
|
||||
}
|
||||
subIDs = append(subIDs, rows[i].SubID)
|
||||
}
|
||||
tombstoneClientEmails(emails)
|
||||
|
||||
@@ -190,6 +195,9 @@ func (s *ClientService) DeleteOrphans() (int, error) {
|
||||
if e := adjustGroupBaselinesForRemovedTraffic(tx, emails); e != nil {
|
||||
return e
|
||||
}
|
||||
if e := clearClientHwidsBySubIDTx(tx, subIDs...); e != nil {
|
||||
return e
|
||||
}
|
||||
for _, batch := range chunkInts(ids, sqlInChunk) {
|
||||
if e := tx.Where("client_id IN ?", batch).Delete(&model.ClientInbound{}).Error; e != nil {
|
||||
return e
|
||||
|
||||
@@ -43,7 +43,7 @@ func TestAddClientStat_RefreshesStaleRowOnInboundDeleteThenReuse(t *testing.T) {
|
||||
if _, err := svc.Update(inboundSvc, rec0.Id, model.Client{
|
||||
Email: email, SubID: subID, Enable: false,
|
||||
TotalGB: 0, ExpiryTime: 1000, Reset: 0,
|
||||
}); err != nil {
|
||||
}, 0); err != nil {
|
||||
t.Fatalf("Update to disabled: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email st
|
||||
if !rec.Enable {
|
||||
updated := rec.ToClient()
|
||||
updated.Enable = true
|
||||
nr, uErr := s.Update(inboundSvc, rec.Id, *updated)
|
||||
nr, uErr := s.Update(inboundSvc, rec.Id, *updated, rec.LimitHwid)
|
||||
if uErr != nil {
|
||||
logger.Warning("Failed to auto-enable client during traffic reset:", uErr)
|
||||
}
|
||||
@@ -84,7 +84,7 @@ func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []st
|
||||
if err == nil && !rec.Enable {
|
||||
updated := rec.ToClient()
|
||||
updated.Enable = true
|
||||
if _, uErr := s.Update(inboundSvc, rec.Id, *updated); uErr != nil {
|
||||
if _, uErr := s.Update(inboundSvc, rec.Id, *updated, rec.LimitHwid); uErr != nil {
|
||||
logger.Warning("Failed to auto-enable client during bulk traffic reset:", uErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ func TestUpdate_PersistsRecordEnable_True(t *testing.T) {
|
||||
}
|
||||
updated := rec.ToClient()
|
||||
updated.Enable = true
|
||||
if _, err := svc.Update(inboundSvc, rec.Id, *updated); err != nil {
|
||||
if _, err := svc.Update(inboundSvc, rec.Id, *updated, 0); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ func TestUpdate_PersistsRecordEnable_False(t *testing.T) {
|
||||
}
|
||||
updated := rec.ToClient()
|
||||
updated.Enable = false
|
||||
if _, err := svc.Update(inboundSvc, rec.Id, *updated); err != nil {
|
||||
if _, err := svc.Update(inboundSvc, rec.Id, *updated, 0); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ func TestUpdate_PersistsRecordEnable_NoInbound(t *testing.T) {
|
||||
|
||||
updated := rec.ToClient()
|
||||
updated.Enable = true
|
||||
if _, err := svc.Update(inboundSvc, rec.Id, *updated); err != nil {
|
||||
if _, err := svc.Update(inboundSvc, rec.Id, *updated, 0); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ func TestUpdate_PersistsFields_NoInbound(t *testing.T) {
|
||||
|
||||
updated := rec.ToClient()
|
||||
tc.mutate(updated)
|
||||
if _, err := svc.Update(inboundSvc, rec.Id, *updated); err != nil {
|
||||
if _, err := svc.Update(inboundSvc, rec.Id, *updated, rec.LimitHwid); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ func TestUpdate_NoInbound_PreservesCredentialsWhenOmitted(t *testing.T) {
|
||||
updated.Auth = ""
|
||||
updated.Secret = ""
|
||||
updated.Comment = "only comment changed"
|
||||
if _, err := svc.Update(inboundSvc, rec.Id, *updated); err != nil {
|
||||
if _, err := svc.Update(inboundSvc, rec.Id, *updated, rec.LimitHwid); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ func TestUpdateInboundClientCaseOnlyRenameDoesNotDuplicateRecord(t *testing.T) {
|
||||
|
||||
updated := source[0]
|
||||
updated.Email = "Test"
|
||||
if _, err := svc.Update(inboundSvc, origId, updated); err != nil {
|
||||
if _, err := svc.Update(inboundSvc, origId, updated, 0); err != nil {
|
||||
t.Fatalf("Update case-only email: %v", err)
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ func TestClientUpdateDuplicateSubIDDoesNotRenameEmail(t *testing.T) {
|
||||
updated := source[0]
|
||||
updated.Email = "kept@x"
|
||||
updated.SubID = "sub-other"
|
||||
if _, err := svc.Update(inboundSvc, origId, updated); err == nil {
|
||||
if _, err := svc.Update(inboundSvc, origId, updated, 0); err == nil {
|
||||
t.Fatalf("Update with colliding subId succeeded, want error")
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ func TestClientUpdateKeepsSharedSubIDEditable(t *testing.T) {
|
||||
|
||||
updated := source[0]
|
||||
updated.TotalGB = 42
|
||||
if _, err := svc.Update(inboundSvc, first.Id, updated); err != nil {
|
||||
if _, err := svc.Update(inboundSvc, first.Id, updated, 0); err != nil {
|
||||
t.Fatalf("Update of a client whose subId is already shared: %v", err)
|
||||
}
|
||||
if got := lookupClientRecord(t, "a@node").TotalGB; got != 42 {
|
||||
@@ -175,7 +175,7 @@ func TestClientUpdateKeepsSharedSubIDEditable(t *testing.T) {
|
||||
omitted := source[0]
|
||||
omitted.SubID = ""
|
||||
omitted.TotalGB = 43
|
||||
if _, err := svc.Update(inboundSvc, first.Id, omitted); err != nil {
|
||||
if _, err := svc.Update(inboundSvc, first.Id, omitted, 0); err != nil {
|
||||
t.Fatalf("Update with subId omitted entirely: %v", err)
|
||||
}
|
||||
other := lookupClientRecord(t, "b@node")
|
||||
|
||||
@@ -714,6 +714,13 @@
|
||||
"addClients": "إضافة عملاء",
|
||||
"limitIp": "حد عناوين IP",
|
||||
"limitIpDesc": "الحد الأقصى لعناوين IP المتزامنة. 0 = غير محدود.",
|
||||
"limitHwid": "حد HWID",
|
||||
"limitHwidDesc": "الحد الأقصى للأجهزة المسجلة لطلبات الاشتراك. 0 = غير محدود.",
|
||||
"hwidLog": "أجهزة HWID",
|
||||
"hwidDevice": "جهاز مسجل",
|
||||
"noHwids": "لا توجد أجهزة HWID بعد",
|
||||
"firstSeen": "أول ظهور",
|
||||
"lastSeen": "آخر ظهور",
|
||||
"limitIpFail2banMissing": "Fail2ban غير مثبّت، لذا لا يمكن تطبيق حد عناوين IP. ثبّت Fail2ban من قائمة x-ui النصية لتفعيل هذا الخيار.",
|
||||
"limitIpFail2banWindows": "Fail2ban غير متوفّر على نظام Windows، لذا لا يمكن تطبيق حد عناوين IP.",
|
||||
"limitIpDisabled": "ميزة حد عناوين IP معطّلة على هذا الخادم.",
|
||||
|
||||
@@ -714,6 +714,13 @@
|
||||
"addClients": "Add Clients",
|
||||
"limitIp": "IP Limit",
|
||||
"limitIpDesc": "Maximum simultaneous IPs. 0 = unlimited.",
|
||||
"limitHwid": "HWID Limit",
|
||||
"limitHwidDesc": "Maximum registered devices for subscription requests. 0 = unlimited.",
|
||||
"hwidLog": "HWID Devices",
|
||||
"hwidDevice": "Registered device",
|
||||
"noHwids": "No HWID devices yet",
|
||||
"firstSeen": "First seen",
|
||||
"lastSeen": "Last seen",
|
||||
"limitIpFail2banMissing": "Fail2ban is not installed, so the IP limit cannot be enforced. Install Fail2ban from the x-ui bash menu to enable this option.",
|
||||
"limitIpFail2banWindows": "Fail2ban is not available on Windows, so the IP limit cannot be enforced.",
|
||||
"limitIpDisabled": "The IP limit feature is disabled on this server.",
|
||||
|
||||
@@ -714,6 +714,13 @@
|
||||
"addClients": "Añadir clientes",
|
||||
"limitIp": "Límite de IP",
|
||||
"limitIpDesc": "Máximo de IP simultáneas. 0 = ilimitado.",
|
||||
"limitHwid": "Límite de HWID",
|
||||
"limitHwidDesc": "Máximo de dispositivos registrados para solicitudes de suscripción. 0 = ilimitado.",
|
||||
"hwidLog": "Dispositivos HWID",
|
||||
"hwidDevice": "Dispositivo registrado",
|
||||
"noHwids": "Aún no hay dispositivos HWID",
|
||||
"firstSeen": "Visto por primera vez",
|
||||
"lastSeen": "Visto por última vez",
|
||||
"limitIpFail2banMissing": "Fail2ban no está instalado, por lo que no se puede aplicar el límite de IP. Instala Fail2ban desde el menú bash de x-ui para habilitar esta opción.",
|
||||
"limitIpFail2banWindows": "Fail2ban no está disponible en Windows, por lo que no se puede aplicar el límite de IP.",
|
||||
"limitIpDisabled": "La función de límite de IP está deshabilitada en este servidor.",
|
||||
|
||||
@@ -714,6 +714,13 @@
|
||||
"addClients": "افزودن کلاینتها",
|
||||
"limitIp": "محدودیت IP",
|
||||
"limitIpDesc": "حداکثر تعداد IP همزمان. ۰ = نامحدود",
|
||||
"limitHwid": "محدودیت HWID",
|
||||
"limitHwidDesc": "حداکثر دستگاه ثبتشده برای درخواستهای اشتراک. ۰ = نامحدود",
|
||||
"hwidLog": "دستگاههای HWID",
|
||||
"hwidDevice": "دستگاه ثبتشده",
|
||||
"noHwids": "هنوز دستگاه HWID ثبت نشده است",
|
||||
"firstSeen": "اولین مشاهده",
|
||||
"lastSeen": "آخرین مشاهده",
|
||||
"limitIpFail2banMissing": "Fail2ban نصب نشده است، بنابراین محدودیت IP اعمال نمیشود. برای فعالسازی این گزینه، Fail2ban را از منوی بش x-ui نصب کنید.",
|
||||
"limitIpFail2banWindows": "Fail2ban روی ویندوز در دسترس نیست، بنابراین محدودیت IP قابل اعمال نیست.",
|
||||
"limitIpDisabled": "قابلیت محدودیت IP روی این سرور غیرفعال است.",
|
||||
|
||||
@@ -714,6 +714,13 @@
|
||||
"addClients": "Tambah klien",
|
||||
"limitIp": "Batas IP",
|
||||
"limitIpDesc": "Jumlah maksimum IP bersamaan. 0 = tidak terbatas.",
|
||||
"limitHwid": "Batas HWID",
|
||||
"limitHwidDesc": "Jumlah maksimum perangkat terdaftar untuk permintaan langganan. 0 = tidak terbatas.",
|
||||
"hwidLog": "Perangkat HWID",
|
||||
"hwidDevice": "Perangkat terdaftar",
|
||||
"noHwids": "Belum ada perangkat HWID",
|
||||
"firstSeen": "Pertama terlihat",
|
||||
"lastSeen": "Terakhir terlihat",
|
||||
"limitIpFail2banMissing": "Fail2ban tidak terpasang, sehingga batas IP tidak dapat diterapkan. Pasang Fail2ban dari menu bash x-ui untuk mengaktifkan opsi ini.",
|
||||
"limitIpFail2banWindows": "Fail2ban tidak tersedia di Windows, sehingga batas IP tidak dapat diterapkan.",
|
||||
"limitIpDisabled": "Fitur batas IP dinonaktifkan di server ini.",
|
||||
|
||||
@@ -714,6 +714,13 @@
|
||||
"addClients": "クライアントを追加",
|
||||
"limitIp": "IP 制限",
|
||||
"limitIpDesc": "同時接続 IP の最大数。0 = 無制限。",
|
||||
"limitHwid": "HWID 制限",
|
||||
"limitHwidDesc": "サブスクリプション要求で登録できる最大デバイス数。0 = 無制限。",
|
||||
"hwidLog": "HWID デバイス",
|
||||
"hwidDevice": "登録済みデバイス",
|
||||
"noHwids": "HWID デバイスはまだありません",
|
||||
"firstSeen": "初回確認",
|
||||
"lastSeen": "最終確認",
|
||||
"limitIpFail2banMissing": "Fail2ban がインストールされていないため、IP 制限を適用できません。このオプションを有効にするには、x-ui の bash メニューから Fail2ban をインストールしてください。",
|
||||
"limitIpFail2banWindows": "Windows では Fail2ban を利用できないため、IP 制限を適用できません。",
|
||||
"limitIpDisabled": "このサーバーでは IP 制限機能が無効になっています。",
|
||||
|
||||
@@ -714,6 +714,13 @@
|
||||
"addClients": "Adicionar clientes",
|
||||
"limitIp": "Limite de IP",
|
||||
"limitIpDesc": "Máximo de IPs simultâneos. 0 = ilimitado.",
|
||||
"limitHwid": "Limite de HWID",
|
||||
"limitHwidDesc": "Máximo de dispositivos registrados para solicitações de assinatura. 0 = ilimitado.",
|
||||
"hwidLog": "Dispositivos HWID",
|
||||
"hwidDevice": "Dispositivo registrado",
|
||||
"noHwids": "Ainda não há dispositivos HWID",
|
||||
"firstSeen": "Visto primeiro",
|
||||
"lastSeen": "Visto por último",
|
||||
"limitIpFail2banMissing": "O Fail2ban não está instalado, portanto o limite de IP não pode ser aplicado. Instale o Fail2ban pelo menu bash do x-ui para ativar esta opção.",
|
||||
"limitIpFail2banWindows": "O Fail2ban não está disponível no Windows, portanto o limite de IP não pode ser aplicado.",
|
||||
"limitIpDisabled": "O recurso de limite de IP está desativado neste servidor.",
|
||||
|
||||
@@ -714,6 +714,13 @@
|
||||
"addClients": "Добавить клиентов",
|
||||
"limitIp": "Лимит IP",
|
||||
"limitIpDesc": "Максимум одновременных IP-адресов. 0 = без ограничений.",
|
||||
"limitHwid": "Лимит HWID",
|
||||
"limitHwidDesc": "Максимум зарегистрированных устройств для запросов подписки. 0 = без ограничений.",
|
||||
"hwidLog": "Устройства HWID",
|
||||
"hwidDevice": "Зарегистрированное устройство",
|
||||
"noHwids": "Устройств HWID пока нет",
|
||||
"firstSeen": "Первое появление",
|
||||
"lastSeen": "Последнее появление",
|
||||
"limitIpFail2banMissing": "Fail2ban не установлен, поэтому ограничение по IP не может быть применено. Установите Fail2ban из bash-меню x-ui, чтобы включить эту опцию.",
|
||||
"limitIpFail2banWindows": "Fail2ban недоступен в Windows, поэтому ограничение по IP не может быть применено.",
|
||||
"limitIpDisabled": "Функция ограничения по IP отключена на этом сервере.",
|
||||
|
||||
@@ -714,6 +714,13 @@
|
||||
"addClients": "Kullanıcı Ekle",
|
||||
"limitIp": "IP Limiti",
|
||||
"limitIpDesc": "Eş zamanlı en fazla IP sayısı. 0 = sınırsız.",
|
||||
"limitHwid": "HWID Limiti",
|
||||
"limitHwidDesc": "Abonelik istekleri için en fazla kayıtlı cihaz. 0 = sınırsız.",
|
||||
"hwidLog": "HWID Cihazları",
|
||||
"hwidDevice": "Kayıtlı cihaz",
|
||||
"noHwids": "Henüz HWID cihazı yok",
|
||||
"firstSeen": "İlk görülme",
|
||||
"lastSeen": "Son görülme",
|
||||
"limitIpFail2banMissing": "Fail2ban yüklü değil, bu nedenle IP sınırı uygulanamaz. Bu seçeneği etkinleştirmek için x-ui bash menüsünden Fail2ban'ı yükleyin.",
|
||||
"limitIpFail2banWindows": "Fail2ban Windows'ta kullanılamadığından IP sınırı uygulanamaz.",
|
||||
"limitIpDisabled": "IP sınırı özelliği bu sunucuda devre dışı.",
|
||||
|
||||
@@ -714,6 +714,13 @@
|
||||
"addClients": "Додати клієнтів",
|
||||
"limitIp": "Ліміт IP",
|
||||
"limitIpDesc": "Максимум одночасних IP-адрес. 0 = без обмежень.",
|
||||
"limitHwid": "Ліміт HWID",
|
||||
"limitHwidDesc": "Максимум зареєстрованих пристроїв для запитів підписки. 0 = без обмежень.",
|
||||
"hwidLog": "Пристрої HWID",
|
||||
"hwidDevice": "Зареєстрований пристрій",
|
||||
"noHwids": "Пристроїв HWID ще немає",
|
||||
"firstSeen": "Перша поява",
|
||||
"lastSeen": "Остання поява",
|
||||
"limitIpFail2banMissing": "Fail2ban не встановлено, тому обмеження за IP не може бути застосоване. Встановіть Fail2ban із bash-меню x-ui, щоб увімкнути цю опцію.",
|
||||
"limitIpFail2banWindows": "Fail2ban недоступний у Windows, тому обмеження за IP не може бути застосоване.",
|
||||
"limitIpDisabled": "Функцію обмеження за IP вимкнено на цьому сервері.",
|
||||
|
||||
@@ -714,6 +714,13 @@
|
||||
"addClients": "Thêm khách hàng",
|
||||
"limitIp": "Giới hạn IP",
|
||||
"limitIpDesc": "Số IP đồng thời tối đa. 0 = không giới hạn.",
|
||||
"limitHwid": "Giới hạn HWID",
|
||||
"limitHwidDesc": "Số thiết bị đăng ký tối đa cho yêu cầu đăng ký. 0 = không giới hạn.",
|
||||
"hwidLog": "Thiết bị HWID",
|
||||
"hwidDevice": "Thiết bị đã đăng ký",
|
||||
"noHwids": "Chưa có thiết bị HWID",
|
||||
"firstSeen": "Lần đầu thấy",
|
||||
"lastSeen": "Lần cuối thấy",
|
||||
"limitIpFail2banMissing": "Fail2ban chưa được cài đặt nên không thể áp dụng giới hạn IP. Hãy cài đặt Fail2ban từ menu bash x-ui để bật tùy chọn này.",
|
||||
"limitIpFail2banWindows": "Fail2ban không khả dụng trên Windows nên không thể áp dụng giới hạn IP.",
|
||||
"limitIpDisabled": "Tính năng giới hạn IP đã bị tắt trên máy chủ này.",
|
||||
|
||||
@@ -714,6 +714,13 @@
|
||||
"addClients": "添加客户端",
|
||||
"limitIp": "IP 限制",
|
||||
"limitIpDesc": "最大同时连接 IP 数。0 = 不限制。",
|
||||
"limitHwid": "HWID 限制",
|
||||
"limitHwidDesc": "订阅请求最多可注册的设备数。0 = 不限制。",
|
||||
"hwidLog": "HWID 设备",
|
||||
"hwidDevice": "已注册设备",
|
||||
"noHwids": "暂无 HWID 设备",
|
||||
"firstSeen": "首次出现",
|
||||
"lastSeen": "最后出现",
|
||||
"limitIpFail2banMissing": "未安装 Fail2ban,无法实施 IP 限制。请从 x-ui 命令行菜单安装 Fail2ban 以启用此选项。",
|
||||
"limitIpFail2banWindows": "Windows 上不支持 Fail2ban,无法实施 IP 限制。",
|
||||
"limitIpDisabled": "此服务器已禁用 IP 限制功能。",
|
||||
|
||||
@@ -714,6 +714,13 @@
|
||||
"addClients": "新增客戶端",
|
||||
"limitIp": "IP 限制",
|
||||
"limitIpDesc": "最大同時連線 IP 數。0 = 不限制。",
|
||||
"limitHwid": "HWID 限制",
|
||||
"limitHwidDesc": "訂閱請求最多可註冊的裝置數。0 = 不限制。",
|
||||
"hwidLog": "HWID 裝置",
|
||||
"hwidDevice": "已註冊裝置",
|
||||
"noHwids": "尚無 HWID 裝置",
|
||||
"firstSeen": "首次出現",
|
||||
"lastSeen": "最後出現",
|
||||
"limitIpFail2banMissing": "未安裝 Fail2ban,無法實施 IP 限制。請從 x-ui 命令列選單安裝 Fail2ban 以啟用此選項。",
|
||||
"limitIpFail2banWindows": "Windows 上不支援 Fail2ban,無法實施 IP 限制。",
|
||||
"limitIpDisabled": "此伺服器已停用 IP 限制功能。",
|
||||
|
||||
Reference in New Issue
Block a user