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:
Rouzbeh†
2026-08-15 18:20:20 +03:30
committed by GitHub
parent 1793a9b8b4
commit 694ad6deae
45 changed files with 1212 additions and 50 deletions
@@ -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)
}
+1
View File
@@ -75,6 +75,7 @@ func allModels() []any {
&model.ApiToken{},
&model.ClientRecord{},
&model.ClientInbound{},
&model.ClientHwid{},
&model.ClientExternalLink{},
&model.ClientGroup{},
&model.InboundFallback{},
+1
View File
@@ -48,6 +48,7 @@ func migrationModels() []any {
&model.InboundClientIps{},
&model.ClientRecord{},
&model.ClientInbound{},
&model.ClientHwid{},
&model.ClientExternalLink{},
&model.ClientGroup{},
&model.InboundFallback{},
+25
View File
@@ -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)
+45
View File
@@ -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)
}
+134
View File
@@ -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())
}
}
+21 -3
View File
@@ -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 })
+30
View File
@@ -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,
})
}
+13 -3
View File
@@ -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
}
+13 -3
View File
@@ -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)
}
+271
View File
@@ -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
}
+172
View File
@@ -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)
}
}
+25 -15
View File
@@ -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 {
+9 -1
View File
@@ -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)
}
+2 -2
View File
@@ -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")
+7
View File
@@ -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 معطّلة على هذا الخادم.",
+7
View File
@@ -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.",
+7
View File
@@ -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.",
+7
View File
@@ -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 روی این سرور غیرفعال است.",
+7
View File
@@ -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.",
+7
View File
@@ -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 制限機能が無効になっています。",
+7
View File
@@ -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.",
+7
View File
@@ -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 отключена на этом сервере.",
+7
View File
@@ -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ışı.",
+7
View File
@@ -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 вимкнено на цьому сервері.",
+7
View File
@@ -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.",
+7
View File
@@ -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 限制功能。",
+7
View File
@@ -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 限制功能。",