chore: remove HWID feature (subscription device binding)

This commit is contained in:
kazeyukiro committed 2026-09-21 00:00:00 +08:00
1 parent e89750f9d0
commit 79e6c778a1
31 files changed
+35 -319

No files matched your search

+1 -1
View File
@@ -25,7 +25,7 @@
| المجال | القدرات |
|------|------|
| **العُقد** | Protocol-registry listeners (VLESS / VMess / Trojan / Shadowsocks / Hysteria2 / TUIC / AnyTLS / Snell / ShadowQUIC, …); REALITY target scan; TLS self-signed certs; transport field validation |
| **المستخدمون** | Bind nodes, traffic limits, expiry, IP/HWID limits, batch ops, subscription tokens |
| **المستخدمون** | Bind nodes, traffic limits, expiry, IP limits, batch ops, subscription tokens |
| **الاشتراكات** | UA routing for Clash/Mihomo YAML, v2ray Base64, sing-box JSON; optional `?target=`; HTML info page |
| **الإعدادات** | Generate → validate → apply; rollback previous `config.yaml` on failure |
| **التشغيل** | Core start/stop/update, logs, dashboard metrics, Geo files, panel SSL/ACME, backup/restore |
+1 -1
View File
@@ -25,7 +25,7 @@ Leichtgewichtig und selbst gehostet. Verwalten Sie [Mihomo](https://github.com/M
| Bereich | Fähigkeiten |
|------|------|
| **Knoten** | Protocol-registry listeners (VLESS / VMess / Trojan / Shadowsocks / Hysteria2 / TUIC / AnyTLS / Snell / ShadowQUIC, …); REALITY target scan; TLS self-signed certs; transport field validation |
| **Benutzer** | Bind nodes, traffic limits, expiry, IP/HWID limits, batch ops, subscription tokens |
| **Benutzer** | Bind nodes, traffic limits, expiry, IP limits, batch ops, subscription tokens |
| **Abonnements** | UA routing for Clash/Mihomo YAML, v2ray Base64, sing-box JSON; optional `?target=`; HTML info page |
| **Konfiguration** | Generate → validate → apply; rollback previous `config.yaml` on failure |
| **Betrieb** | Core start/stop/update, logs, dashboard metrics, Geo files, panel SSL/ACME, backup/restore |
+2 -2
View File
@@ -25,7 +25,7 @@ Lightweight and self-hosted. Manage [Mihomo](https://github.com/MetaCubeX/mihomo
| Area | Capabilities |
|------|------|
| **Nodes** | Protocol-registry listeners (VLESS / VMess / Trojan / Shadowsocks / Hysteria2 / TUIC / AnyTLS / Snell / ShadowQUIC, …); REALITY; TLS self-signed; batch certificate; **traffic multiplier**; transport validation |
| **Users** | Bind nodes, traffic/expiry, **per-node usage** (billed = raw × node multiplier), IP/HWID/sub-pull limits, start-on-first-use, cycle renew/reset, groups/tags, external sub merge, batch ops, subscription tokens |
| **Users** | Bind nodes, traffic/expiry, **per-node usage** (billed = raw × node multiplier), IP/sub-pull limits, start-on-first-use, cycle renew/reset, groups/tags, external sub merge, batch ops, subscription tokens |
| **Subscriptions** | UA routing for Clash/Mihomo YAML, v2ray Base64 (`?target=v2ray` always Base64), sing-box JSON; optional `?target=`; HTML info page; TUIC/HY2 share TLS params |
| **Config** | Generate → validate → apply; rollback previous `config.yaml` on failure |
| **Ops** | Core start/stop/update, logs, dashboard, online connections, Geo, panel SSL/ACME, WARP one-click, backup/restore |
@@ -93,7 +93,7 @@ Static Linux binaries for multiple architectures are on [Releases](https://githu
### Extra docs (limits / certs / subscriptions)
- [User limits: IP · HWID · sub pulls](docs/users-limits.md)
- [User limits: IP · sub pulls](docs/users-limits.md)
- [Per-node traffic & multiplier](docs/node-traffic.md)
- [Batch node certificates](docs/batch-certificate.md)
- [Subscription formats](docs/subscription-formats.md)
+1 -1
View File
@@ -25,7 +25,7 @@ Ligero y autoalojado. Gestiona listeners, usuarios, suscripciones y estado de [M
| Área | Capacidades |
|------|------|
| **Nodos** | Protocol-registry listeners (VLESS / VMess / Trojan / Shadowsocks / Hysteria2 / TUIC / AnyTLS / Snell / ShadowQUIC, …); REALITY target scan; TLS self-signed certs; transport field validation |
| **Usuarios** | Bind nodes, traffic limits, expiry, IP/HWID limits, batch ops, subscription tokens |
| **Usuarios** | Bind nodes, traffic limits, expiry, IP limits, batch ops, subscription tokens |
| **Suscripciones** | UA routing for Clash/Mihomo YAML, v2ray Base64, sing-box JSON; optional `?target=`; HTML info page |
| **Configuración** | Generate → validate → apply; rollback previous `config.yaml` on failure |
| **Operaciones** | Core start/stop/update, logs, dashboard metrics, Geo files, panel SSL/ACME, backup/restore |
+1 -1
View File
@@ -25,7 +25,7 @@ Léger et auto-hébergé. Gérez les listeners, utilisateurs, abonnements et l
| Domaine | Capacités |
|------|------|
| **Nœuds** | Protocol-registry listeners (VLESS / VMess / Trojan / Shadowsocks / Hysteria2 / TUIC / AnyTLS / Snell / ShadowQUIC, …); REALITY target scan; TLS self-signed certs; transport field validation |
| **Utilisateurs** | Bind nodes, traffic limits, expiry, IP/HWID limits, batch ops, subscription tokens |
| **Utilisateurs** | Bind nodes, traffic limits, expiry, IP limits, batch ops, subscription tokens |
| **Abonnements** | UA routing for Clash/Mihomo YAML, v2ray Base64, sing-box JSON; optional `?target=`; HTML info page |
| **Configuration** | Generate → validate → apply; rollback previous `config.yaml` on failure |
| **Exploitation** | Core start/stop/update, logs, dashboard metrics, Geo files, panel SSL/ACME, backup/restore |
+1 -1
View File
@@ -25,7 +25,7 @@
| क्षेत्र | क्षमताएँ |
|------|------|
| **नोड्स** | Protocol-registry listeners (VLESS / VMess / Trojan / Shadowsocks / Hysteria2 / TUIC / AnyTLS / Snell / ShadowQUIC, …); REALITY target scan; TLS self-signed certs; transport field validation |
| **उपयोगकर्ता** | Bind nodes, traffic limits, expiry, IP/HWID limits, batch ops, subscription tokens |
| **उपयोगकर्ता** | Bind nodes, traffic limits, expiry, IP limits, batch ops, subscription tokens |
| **सब्सक्रिप्शन** | UA routing for Clash/Mihomo YAML, v2ray Base64, sing-box JSON; optional `?target=`; HTML info page |
| **कॉन्फ़िग** | Generate → validate → apply; rollback previous `config.yaml` on failure |
| **ऑप्स** | Core start/stop/update, logs, dashboard metrics, Geo files, panel SSL/ACME, backup/restore |
+1 -1
View File
@@ -25,7 +25,7 @@ Ringan dan self-hosted. Kelola listener, pengguna, langganan, dan status [Mihomo
| Area | Kemampuan |
|------|------|
| **Node** | Protocol-registry listeners (VLESS / VMess / Trojan / Shadowsocks / Hysteria2 / TUIC / AnyTLS / Snell / ShadowQUIC, …); REALITY target scan; TLS self-signed certs; transport field validation |
| **Pengguna** | Bind nodes, traffic limits, expiry, IP/HWID limits, batch ops, subscription tokens |
| **Pengguna** | Bind nodes, traffic limits, expiry, IP limits, batch ops, subscription tokens |
| **Langganan** | UA routing for Clash/Mihomo YAML, v2ray Base64, sing-box JSON; optional `?target=`; HTML info page |
| **Konfigurasi** | Generate → validate → apply; rollback previous `config.yaml` on failure |
| **Operasi** | Core start/stop/update, logs, dashboard metrics, Geo files, panel SSL/ACME, backup/restore |
+1 -1
View File
@@ -25,7 +25,7 @@
| 区分 | 内容 |
|------|------|
| **ノード** | Protocol-registry listeners (VLESS / VMess / Trojan / Shadowsocks / Hysteria2 / TUIC / AnyTLS / Snell / ShadowQUIC, …); REALITY target scan; TLS self-signed certs; transport field validation |
| **ユーザー** | Bind nodes, traffic limits, expiry, IP/HWID limits, batch ops, subscription tokens |
| **ユーザー** | Bind nodes, traffic limits, expiry, IP limits, batch ops, subscription tokens |
| **購読** | UA routing for Clash/Mihomo YAML, v2ray Base64, sing-box JSON; optional `?target=`; HTML info page |
| **設定** | Generate → validate → apply; rollback previous `config.yaml` on failure |
| **運用** | Core start/stop/update, logs, dashboard metrics, Geo files, panel SSL/ACME, backup/restore |
+1 -1
View File
@@ -25,7 +25,7 @@
| 구분 | 기능 |
|------|------|
| **노드** | Protocol-registry listeners (VLESS / VMess / Trojan / Shadowsocks / Hysteria2 / TUIC / AnyTLS / Snell / ShadowQUIC, …); REALITY target scan; TLS self-signed certs; transport field validation |
| **사용자** | Bind nodes, traffic limits, expiry, IP/HWID limits, batch ops, subscription tokens |
| **사용자** | Bind nodes, traffic limits, expiry, IP limits, batch ops, subscription tokens |
| **구독** | UA routing for Clash/Mihomo YAML, v2ray Base64, sing-box JSON; optional `?target=`; HTML info page |
| **설정** | Generate → validate → apply; rollback previous `config.yaml` on failure |
| **운영** | Core start/stop/update, logs, dashboard metrics, Geo files, panel SSL/ACME, backup/restore |
+2 -2
View File
@@ -28,7 +28,7 @@
| 类别 | 能力 |
|------|------|
| **节点** | 协议注册表驱动的 Listener:VLESS / VMess / Trojan / Shadowsocks / Hysteria2 / TUIC / AnyTLS / Snell / ShadowQUIC 等;Reality、TLS 自签、一键批量证书、**流量倍率**、传输层字段互斥校验 |
| **用户** | 绑定节点、流量限额、**按节点分别计量**(计费 = 实际 × 节点倍率)、到期、IP/HWID/订阅拉取限制、首次使用起算、周期续期/重置、分组标签、外部订阅合并、批量操作、订阅 Token |
| **用户** | 绑定节点、流量限额、**按节点分别计量**(计费 = 实际 × 节点倍率)、到期、IP/订阅拉取限制、首次使用起算、周期续期/重置、分组标签、外部订阅合并、批量操作、订阅 Token |
| **订阅** | UA 自动识别 Clash/Mihomo YAML、v2ray Base64(`?target=v2ray` 始终 Base64)、sing-box JSON;可选 `?target=`;HTML 订阅页;TUIC/HY2 分享链含 SNI/`allow_insecure` 等客户端参数 |
| **配置** | 生成 → 校验 → 应用 分离;失败回滚上一份 `config.yaml` |
| **运维** | 核心启停/更新、日志、仪表盘、在线连接、Geo、面板 SSL/ACME、WARP 一键、备份恢复 |
@@ -39,7 +39,7 @@
### 补充说明(用户限制 / 证书 / 订阅)
- [用户限制:IP · HWID · 订阅拉取](docs/users-limits.md)
- [用户限制:IP · 订阅拉取](docs/users-limits.md)
- [按节点流量与倍率](docs/node-traffic.md)
- [批量应用节点证书](docs/batch-certificate.md)
- [订阅格式与 target](docs/subscription-formats.md)
+1 -1
View File
@@ -25,7 +25,7 @@ Lekki, self-hosted. Zarządzaj listenerami, użytkownikami, subskrypcjami i stat
| Obszar | Możliwości |
|------|------|
| **Węzły** | Protocol-registry listeners (VLESS / VMess / Trojan / Shadowsocks / Hysteria2 / TUIC / AnyTLS / Snell / ShadowQUIC, …); REALITY target scan; TLS self-signed certs; transport field validation |
| **Użytkownicy** | Bind nodes, traffic limits, expiry, IP/HWID limits, batch ops, subscription tokens |
| **Użytkownicy** | Bind nodes, traffic limits, expiry, IP limits, batch ops, subscription tokens |
| **Subskrypcje** | UA routing for Clash/Mihomo YAML, v2ray Base64, sing-box JSON; optional `?target=`; HTML info page |
| **Konfiguracja** | Generate → validate → apply; rollback previous `config.yaml` on failure |
| **Administracja** | Core start/stop/update, logs, dashboard metrics, Geo files, panel SSL/ACME, backup/restore |
+1 -1
View File
@@ -25,7 +25,7 @@ Leve e auto-hospedado. Gerencie listeners, usuários, assinaturas e status do [M
| Área | Capacidades |
|------|------|
| **Nós** | Protocol-registry listeners (VLESS / VMess / Trojan / Shadowsocks / Hysteria2 / TUIC / AnyTLS / Snell / ShadowQUIC, …); REALITY target scan; TLS self-signed certs; transport field validation |
| **Usuários** | Bind nodes, traffic limits, expiry, IP/HWID limits, batch ops, subscription tokens |
| **Usuários** | Bind nodes, traffic limits, expiry, IP limits, batch ops, subscription tokens |
| **Assinaturas** | UA routing for Clash/Mihomo YAML, v2ray Base64, sing-box JSON; optional `?target=`; HTML info page |
| **Configuração** | Generate → validate → apply; rollback previous `config.yaml` on failure |
| **Operações** | Core start/stop/update, logs, dashboard metrics, Geo files, panel SSL/ACME, backup/restore |
+1 -1
View File
@@ -25,7 +25,7 @@
| Раздел | Описание |
|------|------|
| **Узлы** | Protocol-registry listeners (VLESS / VMess / Trojan / Shadowsocks / Hysteria2 / TUIC / AnyTLS / Snell / ShadowQUIC, …); REALITY target scan; TLS self-signed certs; transport field validation |
| **Пользователи** | Bind nodes, traffic limits, expiry, IP/HWID limits, batch ops, subscription tokens |
| **Пользователи** | Bind nodes, traffic limits, expiry, IP limits, batch ops, subscription tokens |
| **Подписки** | UA routing for Clash/Mihomo YAML, v2ray Base64, sing-box JSON; optional `?target=`; HTML info page |
| **Конфиг** | Generate → validate → apply; rollback previous `config.yaml` on failure |
| **Администрирование** | Core start/stop/update, logs, dashboard metrics, Geo files, panel SSL/ACME, backup/restore |
+1 -1
View File
@@ -25,7 +25,7 @@
| หมวด | ความสามารถ |
|------|------|
| **โหนด** | Protocol-registry listeners (VLESS / VMess / Trojan / Shadowsocks / Hysteria2 / TUIC / AnyTLS / Snell / ShadowQUIC, …); REALITY target scan; TLS self-signed certs; transport field validation |
| **ผู้ใช้** | Bind nodes, traffic limits, expiry, IP/HWID limits, batch ops, subscription tokens |
| **ผู้ใช้** | Bind nodes, traffic limits, expiry, IP limits, batch ops, subscription tokens |
| **สับสไครบ์** | UA routing for Clash/Mihomo YAML, v2ray Base64, sing-box JSON; optional `?target=`; HTML info page |
| **คอนฟิก** | Generate → validate → apply; rollback previous `config.yaml` on failure |
| **ดูแลระบบ** | Core start/stop/update, logs, dashboard metrics, Geo files, panel SSL/ACME, backup/restore |
+1 -1
View File
@@ -25,7 +25,7 @@ Hafif ve self-hosted. Linux üzerinde [Mihomo](https://github.com/MetaCubeX/miho
| Alan | Yetenekler |
|------|------|
| **Düğümler** | Protocol-registry listeners (VLESS / VMess / Trojan / Shadowsocks / Hysteria2 / TUIC / AnyTLS / Snell / ShadowQUIC, …); REALITY target scan; TLS self-signed certs; transport field validation |
| **Kullanıcılar** | Bind nodes, traffic limits, expiry, IP/HWID limits, batch ops, subscription tokens |
| **Kullanıcılar** | Bind nodes, traffic limits, expiry, IP limits, batch ops, subscription tokens |
| **Abonelikler** | UA routing for Clash/Mihomo YAML, v2ray Base64, sing-box JSON; optional `?target=`; HTML info page |
| **Yapılandırma** | Generate → validate → apply; rollback previous `config.yaml` on failure |
| **İşletim** | Core start/stop/update, logs, dashboard metrics, Geo files, panel SSL/ACME, backup/restore |
+1 -1
View File
@@ -25,7 +25,7 @@
| Розділ | Опис |
|------|------|
| **Вузли** | Protocol-registry listeners (VLESS / VMess / Trojan / Shadowsocks / Hysteria2 / TUIC / AnyTLS / Snell / ShadowQUIC, …); REALITY target scan; TLS self-signed certs; transport field validation |
| **Користувачі** | Bind nodes, traffic limits, expiry, IP/HWID limits, batch ops, subscription tokens |
| **Користувачі** | Bind nodes, traffic limits, expiry, IP limits, batch ops, subscription tokens |
| **Підписки** | UA routing for Clash/Mihomo YAML, v2ray Base64, sing-box JSON; optional `?target=`; HTML info page |
| **Конфіг** | Generate → validate → apply; rollback previous `config.yaml` on failure |
| **Адміністрування** | Core start/stop/update, logs, dashboard metrics, Geo files, panel SSL/ACME, backup/restore |
+1 -1
View File
@@ -25,7 +25,7 @@ Nhẹ, tự lưu trữ. Quản lý listener, người dùng, subscription và tr
| Hạng mục | Khả năng |
|------|------|
| **Node** | Protocol-registry listeners (VLESS / VMess / Trojan / Shadowsocks / Hysteria2 / TUIC / AnyTLS / Snell / ShadowQUIC, …); REALITY target scan; TLS self-signed certs; transport field validation |
| **Người dùng** | Bind nodes, traffic limits, expiry, IP/HWID limits, batch ops, subscription tokens |
| **Người dùng** | Bind nodes, traffic limits, expiry, IP limits, batch ops, subscription tokens |
| **Subscription** | UA routing for Clash/Mihomo YAML, v2ray Base64, sing-box JSON; optional `?target=`; HTML info page |
| **Cấu hình** | Generate → validate → apply; rollback previous `config.yaml` on failure |
| **Vận hành** | Core start/stop/update, logs, dashboard metrics, Geo files, panel SSL/ACME, backup/restore |
+2 -2
View File
@@ -25,7 +25,7 @@
| 类别 | 能力 |
|------|------|
| **节点** | 协议注册表驱动的 Listener:VLESS / VMess / Trojan / Shadowsocks / Hysteria2 / TUIC / AnyTLS / Snell / ShadowQUIC 等;REALITY、TLS 自签、一键批量证书、**流量倍率**、传输层字段校验 |
| **用户** | 绑定节点、流量限额、**按节点分别计量**(计费 = 实际 × 节点倍率)、到期、IP/HWID/订阅拉取限制、首次使用起算、周期续期/重置、分组标签、外部订阅合并、批量操作、订阅 Token |
| **用户** | 绑定节点、流量限额、**按节点分别计量**(计费 = 实际 × 节点倍率)、到期、IP/订阅拉取限制、首次使用起算、周期续期/重置、分组标签、外部订阅合并、批量操作、订阅 Token |
| **订阅** | UA 识别 Clash/Mihomo YAML、v2ray Base64(`?target=v2ray` 始终 Base64)、sing-box JSON;可选 `?target=`;HTML 订阅页;TUIC/HY2 分享链含客户端 TLS 参数 |
| **配置** | 生成 → 校验 → 应用;失败回滚上一份 `config.yaml` |
| **运维** | 核心启停/更新、日志、仪表盘、在线连接、Geo、面板 SSL/ACME、WARP 一键、备份恢复 |
@@ -36,7 +36,7 @@
### 补充说明
- [用户限制:IP · HWID · 订阅拉取](docs/users-limits.md)
- [用户限制:IP · 订阅拉取](docs/users-limits.md)
- [按节点流量与倍率](docs/node-traffic.md)
- [批量应用节点证书](docs/batch-certificate.md)
- [订阅格式与 target](docs/subscription-formats.md)
+1 -1
View File
@@ -25,7 +25,7 @@
| 類別 | 能力 |
|------|------|
| **節點** | 協定登錄表驅動的 Listener(VLESS / VMess / Trojan / Shadowsocks / Hysteria2 / TUIC 等);REALITY 目標掃描、TLS 自簽、傳輸層欄位校驗 |
| **使用者** | 綁定節點、流量限額、到期、IP/HWID 限制、批次操作、訂閱 Token |
| **使用者** | 綁定節點、流量限額、到期、IP 限制、批次操作、訂閱 Token |
| **訂閱** | UA 識別 Clash/Mihomo YAML、v2ray Base64、sing-box JSON;可選 `?target=`;HTML 訂閱頁 |
| **設定** | 產生 → 校驗 → 套用;失敗回滾上一份 `config.yaml` |
| **維運** | 核心啟停/更新、日誌、儀表板、Geo、面板 SSL/ACME、備份還原 |
-2
View File
@@ -1210,7 +1210,6 @@ components:
online: { type: boolean }
blocked: { type: boolean }
ip_limit: { type: integer, description: "0 = unlimited concurrent source IPs; enforced by ~5s connection poll (disconnect excess)" }
hwid_limit: { type: integer, description: "0 = unlimited (still records x-hwid when sent); >0 requires x-hwid on sub fetch and caps devices" }
sub_pull_limit: { type: integer, description: "0 = unlimited; max successful subscription pulls per rolling 24h" }
remark: { type: string }
group: { type: string, description: Admin group label }
@@ -1230,7 +1229,6 @@ components:
uuid: { type: string, description: Empty = auto-generate }
traffic_limit: { type: integer, format: int64 }
ip_limit: { type: integer, description: "0 = unlimited concurrent source IPs" }
hwid_limit: { type: integer, description: "0 = unlimited; >0 requires x-hwid" }
sub_pull_limit: { type: integer, description: "0 = unlimited pulls per rolling 24h" }
remark: { type: string }
group: { type: string }
+2 -61
View File
@@ -46,14 +46,11 @@ func InitDB(dbPath string) (*gorm.DB, error) {
return nil, fmt.Errorf("ensure unique sub tokens: %w", err)
}
if err := prepareHWIDDeviceSchema(db); err != nil {
return nil, fmt.Errorf("prepare hwid_devices schema: %w", err)
}
err = db.AutoMigrate(
&models.User{}, &models.Listener{}, &models.ListenerUser{}, &models.ListenerVersion{},
&models.ListenerTemplate{}, &models.Subscription{}, &models.AccessToken{}, &models.Config{},
&models.ProxyUser{}, &models.UserNodeTraffic{}, &models.HWIDDevice{}, &models.TrafficRecord{}, &models.PanelSetting{}, &models.RemoteServer{}, &models.RemoteNodeMirror{}, &models.ProxyUserRemoteNode{},
&models.ProxyUser{}, &models.UserNodeTraffic{}, &models.TrafficRecord{}, &models.PanelSetting{}, &models.RemoteServer{}, &models.RemoteNodeMirror{}, &models.ProxyUserRemoteNode{},
)
if err != nil {
// One more cleanup pass then retry — soft-deleted collisions are the usual cause.
@@ -62,7 +59,7 @@ func InitDB(dbPath string) (*gorm.DB, error) {
if retryErr := db.AutoMigrate(
&models.User{}, &models.Listener{}, &models.ListenerUser{}, &models.ListenerVersion{},
&models.ListenerTemplate{}, &models.Subscription{}, &models.AccessToken{}, &models.Config{},
&models.ProxyUser{}, &models.UserNodeTraffic{}, &models.HWIDDevice{}, &models.TrafficRecord{}, &models.PanelSetting{}, &models.RemoteServer{}, &models.RemoteNodeMirror{}, &models.ProxyUserRemoteNode{},
&models.ProxyUser{}, &models.UserNodeTraffic{}, &models.TrafficRecord{}, &models.PanelSetting{}, &models.RemoteServer{}, &models.RemoteNodeMirror{}, &models.ProxyUserRemoteNode{},
); retryErr != nil {
return nil, fmt.Errorf("failed to run database auto-migration: %w (after dedupe retry)", retryErr)
}
@@ -154,59 +151,3 @@ func randomHexToken(n int) (string, error) {
return hex.EncodeToString(b), nil
}
// prepareHWIDDeviceSchema runs before AutoMigrate.
// SQLite index names are global: a leftover uidx_user_hwid from hw_id_devices
// causes "index uidx_user_hwid already exists" when creating hwid_devices.
func prepareHWIDDeviceSchema(db *gorm.DB) error {
if db == nil {
return nil
}
// Drop legacy unique index names (SQLite does not scope indexes per table).
for _, idx := range []string{
"uidx_user_hwid",
"idx_hw_id_devices_proxy_user_id",
"idx_hwid_devices_proxy_user_id",
} {
if err := db.Exec("DROP INDEX IF EXISTS `" + idx + "`").Error; err != nil {
log.Printf("database: drop index %s: %v", idx, err)
}
}
for _, name := range []string{"hw_id_devices", "hwid_devices"} {
if !db.Migrator().HasTable(name) {
continue
}
rows, err := db.Raw("PRAGMA table_info(`" + name + "`)").Rows()
if err != nil {
// Non-SQLite: drop legacy table name only.
if name == "hw_id_devices" {
_ = db.Migrator().DropTable(name)
}
continue
}
hasHWID := false
colCount := 0
for rows.Next() {
var cid int
var cname, ctype string
var notnull, pk int
var dflt interface{}
if scanErr := rows.Scan(&cid, &cname, &ctype, &notnull, &dflt, &pk); scanErr != nil {
_ = rows.Close()
return scanErr
}
colCount++
if cname == "hwid" {
hasHWID = true
}
}
_ = rows.Close()
// Drop legacy table always; drop current if missing hwid column.
if name == "hw_id_devices" || (colCount > 0 && !hasHWID) {
log.Printf("database: dropping HWID table %s for clean migrate (hasHWID=%v)", name, hasHWID)
if err := db.Exec("DROP TABLE IF EXISTS `" + name + "`").Error; err != nil {
return err
}
}
}
return nil
}
@@ -21,8 +21,6 @@ type ProxyUser struct {
Enabled bool `gorm:"not null;default:true" json:"enabled"`
// IPLimit is max concurrent client IPs (0 = unlimited). Max concurrent client IPs.
IPLimit int `gorm:"not null;default:0" json:"ip_limit"`
// HWIDLimit is max devices allowed via subscription HWID headers (0 = unlimited / tracking only when seen).
HWIDLimit int `gorm:"not null;default:0" json:"hwid_limit"`
// SubPullLimit is max subscription fetches per rolling 24h (0 = unlimited).
SubPullLimit int `gorm:"not null;default:0" json:"sub_pull_limit"`
// SubPullCount is successful pulls in the current window (server-side counter).
-2
View File
@@ -1259,7 +1259,6 @@ components:
online: { type: boolean }
blocked: { type: boolean }
ip_limit: { type: integer, description: "0 = unlimited concurrent source IPs; enforced by ~5s connection poll (disconnect excess)" }
hwid_limit: { type: integer, description: "0 = unlimited (still records x-hwid when sent); >0 requires x-hwid on sub fetch and caps devices" }
sub_pull_limit: { type: integer, description: "0 = unlimited; max successful subscription pulls per rolling 24h" }
remark: { type: string }
group: { type: string, description: Admin group label }
@@ -1279,7 +1278,6 @@ components:
uuid: { type: string, description: Empty = auto-generate }
traffic_limit: { type: integer, format: int64 }
ip_limit: { type: integer, description: "0 = unlimited concurrent source IPs" }
hwid_limit: { type: integer, description: "0 = unlimited; >0 requires x-hwid" }
sub_pull_limit: { type: integer, description: "0 = unlimited pulls per rolling 24h" }
remark: { type: string }
group: { type: string }
-24
View File
@@ -14,7 +14,6 @@ import (
"github.com/kazeyukiro/3m-ui/backend/internal/config"
"github.com/kazeyukiro/3m-ui/backend/internal/converter"
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
"github.com/kazeyukiro/3m-ui/backend/internal/hwid"
"github.com/kazeyukiro/3m-ui/backend/internal/node"
"github.com/kazeyukiro/3m-ui/backend/internal/subpage"
"github.com/kazeyukiro/3m-ui/backend/internal/subpull"
@@ -75,29 +74,6 @@ func subscriptionHandler(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
}
isProxyUser = true
user.TouchFirstUse(db, pu.ID)
// HWID device registration (Happ / Remnawave-compatible headers).
info := hwid.ParseRequest(c.Request)
if err := hwid.Enforce(db, pu.ID, pu.HWIDLimit, info, c.Writer.Header()); err != nil {
if errors.Is(err, hwid.ErrMaxDevices) {
c.Header(hwid.HeaderMaxDevicesReached, "true")
if pu.HWIDLimit > 0 {
c.Header(hwid.HeaderActive, "true")
}
c.JSON(http.StatusForbidden, gin.H{"error": "hwid device limit reached"})
return
}
if errors.Is(err, hwid.ErrRequired) {
c.Header(hwid.HeaderNotSupported, "true")
if pu.HWIDLimit > 0 {
c.Header(hwid.HeaderActive, "true")
}
c.JSON(http.StatusForbidden, gin.H{"error": "hwid required: enable device binding in a compatible client (x-hwid)"})
return
}
// Unexpected errors: log only. Never 500 a subscription for HWID
// bookkeeping — hwid_limit=0 must always remain fetchable.
log.Printf("hwid enforce (ignored): %v", err)
}
if err := subpull.Enforce(db, pu.ID, pu.SubPullLimit); err != nil {
if errors.Is(err, subpull.ErrLimitReached) {
c.Header("Retry-After", "3600")
-48
View File
@@ -10,7 +10,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
"github.com/kazeyukiro/3m-ui/backend/internal/hwid"
"gorm.io/gorm"
)
@@ -46,9 +45,6 @@ func (h *Handler) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/:id/node-traffic", h.ListNodeTraffic)
rg.GET("/:id/subscription", h.GetSubscription)
rg.POST("/:id/subscription/rotate", h.RotateSubscription)
rg.GET("/:id/hwid-devices", h.ListHWIDDevices)
rg.DELETE("/:id/hwid-devices", h.ClearHWIDDevices)
rg.DELETE("/:id/hwid-devices/:deviceId", h.DeleteHWIDDevice)
}
func parseID(c *gin.Context) (uint, bool) {
@@ -412,51 +408,7 @@ func (h *Handler) ListRemoteNodes(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"mirror_ids": ids})
}
func (h *Handler) ListHWIDDevices(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
if h.svc == nil || h.svc.DB() == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "service unavailable"})
return
}
rows, err := hwid.List(h.svc.DB(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": rows})
}
func (h *Handler) DeleteHWIDDevice(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
did, err := strconv.ParseUint(c.Param("deviceId"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid device id"})
return
}
if err := hwid.Delete(h.svc.DB(), id, uint(did)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *Handler) ClearHWIDDevices(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
if err := hwid.DeleteAll(h.svc.DB(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *Handler) ListNodeTraffic(c *gin.Context) {
id, ok := parseID(c)
-2
View File
@@ -72,7 +72,6 @@ type CreateInput struct {
UUID string `json:"uuid"`
TrafficLimit int64 `json:"traffic_limit"`
IPLimit int `json:"ip_limit"`
HWIDLimit int `json:"hwid_limit"`
SubPullLimit int `json:"sub_pull_limit"`
Remark string `json:"remark"`
Group string `json:"group"`
@@ -94,7 +93,6 @@ type UpdateInput struct {
UUID string `json:"uuid"`
TrafficLimit *int64 `json:"traffic_limit"`
IPLimit *int `json:"ip_limit"`
HWIDLimit *int `json:"hwid_limit"`
SubPullLimit *int `json:"sub_pull_limit"`
Remark *string `json:"remark"`
Group *string `json:"group"`
-3
View File
@@ -103,7 +103,6 @@ func (s *Service) Create(in CreateInput) (*models.ProxyUser, error) {
UUID: uuid,
TrafficLimit: in.TrafficLimit,
IPLimit: max0(in.IPLimit),
HWIDLimit: max0(in.HWIDLimit),
SubPullLimit: max0(in.SubPullLimit),
Remark: strings.TrimSpace(in.Remark),
Group: strings.TrimSpace(in.Group),
@@ -170,8 +169,6 @@ func (s *Service) Update(id uint, in UpdateInput) (*models.ProxyUser, error) {
if in.IPLimit != nil {
u.IPLimit = max0(*in.IPLimit)
}
if in.HWIDLimit != nil {
u.HWIDLimit = max0(*in.HWIDLimit)
}
if in.SubPullLimit != nil {
u.SubPullLimit = max0(*in.SubPullLimit)
-2
View File
@@ -31,7 +31,6 @@ type SafeUser struct {
Enabled bool `json:"enabled"`
Blocked bool `json:"blocked"`
IPLimit int `json:"ip_limit"`
HWIDLimit int `json:"hwid_limit"`
SubPullLimit int `json:"sub_pull_limit"`
Remark string `json:"remark"`
Group string `json:"group"`
@@ -63,7 +62,6 @@ func ToSafeUser(u *models.ProxyUser) SafeUser {
Enabled: u.Enabled,
Blocked: !IsCredentialActive(*u),
IPLimit: u.IPLimit,
HWIDLimit: u.HWIDLimit,
SubPullLimit: u.SubPullLimit,
Remark: u.Remark,
Group: u.Group,
+2 -34
View File
@@ -1,4 +1,4 @@
# User limits: IP, HWID, subscription pulls
# User limits: IP and subscription pulls
Panel fields on each **ProxyUser** (Users page / API).
@@ -11,17 +11,6 @@ Panel fields on each **ProxyUser** (Users page / API).
**How it works:** the traffic collector polls Mihomo connections about every **5 seconds**. Connections that cannot be attributed to a user are ignored. When a user exceeds `N` source IPs, excess IPs’ connections are closed (not blocked at handshake time).
## HWID limit (`hwid_limit`)
Compatible with Happ / Remnawave-style headers: `x-hwid`, `x-device-os`, `x-ver-os`, `x-device-model`.
| Value | Meaning |
|-------|---------|
| `0` | No device cap (devices still recorded when `x-hwid` is sent; DB errors never block the subscription) |
| `N ≥ 1` | At most **N** devices; **subscription requests without a valid `x-hwid` are rejected (403)** |
Device list: `GET /api/v1/users/{id}/hwid-devices`. Delete one or clear all via the Users UI / DELETE APIs.
## Subscription pull limit (`sub_pull_limit`)
| Value | Meaning |
@@ -35,30 +24,9 @@ HTML subscription pages and client downloads both count.
## Related APIs
- User CRUD: `ip_limit`, `hwid_limit`, `sub_pull_limit` on create/update
- User CRUD: `ip_limit`, `sub_pull_limit` on create/update
- Subscription: public `/api/v1/client/sub/{token}` (and aliases)
## Why is the device list empty?
HWID is recorded **only when a client fetches the user subscription URL** and sends a device id header (typically `x-hwid`).
| Client | Usually sends `x-hwid`? |
|--------|-------------------------|
| Happ | Yes (on subscription update) |
| v2RayTun | Yes (when HWID feature enabled) |
| FlClashX / some Remnawave forks | Yes |
| Clash Meta / mihomo / v2rayNG / NekoBox (stock) | **No** — will never appear in device list |
Also check:
1. You opened the **user** subscription link (`/api/v1/client/sub/{token}`), not an access-token or single-node URI.
2. After enabling HWID support in the app, **force update** the subscription (not only connect).
3. Reverse proxies must forward `x-hwid` / `x-device-os` / `x-device-model` (do not strip unknown headers).
4. Panel log lines `hwid: registered` / `hwid: seen` confirm the server received the header.
## Related
- [Per-node traffic & multiplier](node-traffic.md)
-18
View File
@@ -15,7 +15,6 @@ export interface ProxyUser {
expire_time?: string;
blocked?: boolean;
ip_limit?: number;
hwid_limit?: number;
sub_pull_limit?: number;
remark?: string;
group?: string;
@@ -88,23 +87,6 @@ export const exportUserLinks = (params?: { q?: string; group?: string }) =>
.then((r) => r.data);
export interface HWIDDevice {
id: number;
proxy_user_id: number;
hwid: string;
device_os?: string;
ver_os?: string;
device_model?: string;
user_agent?: string;
last_seen_at?: string;
}
export const fetchUserHWIDDevices = (userId: number) =>
client.get<{ items: HWIDDevice[] }>(`/users/${userId}/hwid-devices`).then((r) => r.data.items || []);
export const deleteUserHWIDDevice = (userId: number, deviceId: number) =>
client.delete(`/users/${userId}/hwid-devices/${deviceId}`);
export const clearUserHWIDDevices = (userId: number) =>
client.delete(`/users/${userId}/hwid-devices`);
export interface UserNodeTrafficItem {
+9 -99
View File
@@ -3,12 +3,12 @@ import {
Card, Table, Button, Space, Modal, Form, Input, Switch, message, Popconfirm, Select, Tag,
InputNumber, DatePicker, Progress, Tooltip, Dropdown, Checkbox, Spin,
} from 'antd';
import { PlusOutlined, DeleteOutlined, EditOutlined, LinkOutlined, ClearOutlined, ShareAltOutlined, CopyOutlined, MoreOutlined, TabletOutlined, FundOutlined } from '@ant-design/icons';
import { PlusOutlined, DeleteOutlined, EditOutlined, LinkOutlined, ClearOutlined, ShareAltOutlined, CopyOutlined, MoreOutlined, FundOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import {
fetchUsers, createUser, updateUser, deleteUser, resetUserTraffic, deleteDepletedUsers, batchUsers,
fetchUserNodes, bindUserNodes, fetchUserRemoteNodes, bindUserRemoteNodes, ProxyUser,
fetchUserHWIDDevices, deleteUserHWIDDevice, clearUserHWIDDevices, fetchUserNodeTraffic, type HWIDDevice, type UserNodeTrafficItem,
fetchUserNodeTraffic, type UserNodeTrafficItem,
} from '../api/users';
import { fetchListeners, Listener } from '../api/nodes';
import { fetchMirroredNodes, RemoteNodeMirror } from '../api/cluster';
@@ -28,10 +28,6 @@ const Users: React.FC = () => {
const [modalOpen, setModalOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const submittingRef = useRef(false);
const [hwidOpen, setHwidOpen] = useState(false);
const [hwidUser, setHwidUser] = useState<ProxyUser | null>(null);
const [hwidRows, setHwidRows] = useState<HWIDDevice[]>([]);
const [hwidLoading, setHwidLoading] = useState(false);
const [nodeTrafficOpen, setNodeTrafficOpen] = useState(false);
const [nodeTrafficUser, setNodeTrafficUser] = useState<ProxyUser | null>(null);
const [nodeTrafficRows, setNodeTrafficRows] = useState<UserNodeTrafficItem[]>([]);
@@ -86,8 +82,7 @@ const Users: React.FC = () => {
enabled: !!values.enabled,
traffic_limit: trafficGB && trafficGB > 0 ? Math.round(Number(trafficGB) * 1024 * 1024 * 1024) : 0,
ip_limit: values.ip_limit != null ? Number(values.ip_limit) : 0,
hwid_limit: values.hwid_limit != null ? Number(values.hwid_limit) : 0,
sub_pull_limit: values.sub_pull_limit != null ? Number(values.sub_pull_limit) : 0,
sub_pull_limit: values.sub_pull_limit != null ? Number(values.sub_pull_limit) : 0,
remark: values.remark || '',
group: values.group || '',
tags: values.tags || '',
@@ -143,18 +138,6 @@ const Users: React.FC = () => {
}
};
const openHwid = async (record: ProxyUser) => {
setHwidUser(record);
setHwidOpen(true);
setHwidLoading(true);
try {
setHwidRows(await fetchUserHWIDDevices(record.id));
} catch (e: any) {
message.error(e?.message || t('common.error'));
} finally {
setHwidLoading(false);
}
};
const openNodeTraffic = async (record: ProxyUser) => {
setNodeTrafficUser(record);
@@ -188,8 +171,7 @@ const Users: React.FC = () => {
expire_time: exp,
password: undefined,
ip_limit: record.ip_limit || 0,
hwid_limit: record.hwid_limit || 0,
sub_pull_limit: record.sub_pull_limit || 0,
sub_pull_limit: record.sub_pull_limit || 0,
remark: record.remark || '',
group: record.group || '',
tags: record.tags || '',
@@ -363,7 +345,6 @@ const Users: React.FC = () => {
<Button size="small" icon={<ClearOutlined />} />
</Popconfirm>
</Tooltip>
<Button size="small" icon={<TabletOutlined />} onClick={() => openHwid(record)} title={t('users.devices', 'Devices')} aria-label={t('users.devices', 'Devices')} />
<Button size="small" icon={<FundOutlined />} onClick={() => openNodeTraffic(record)} title={t('users.nodeTraffic', 'Node traffic')} aria-label={t('users.nodeTraffic', 'Node traffic')} />
<Button size="small" icon={<EditOutlined />} onClick={() => openEdit(record)} />
<Popconfirm title={t('users.deleteConfirm')} onConfirm={() => onDelete(record.id)}>
@@ -396,7 +377,7 @@ const Users: React.FC = () => {
onClick={() => {
setEditing(null);
form.resetFields();
form.setFieldsValue({ enabled: true, ip_limit: 0, hwid_limit: 0, sub_pull_limit: 0 });
form.setFieldsValue({ enabled: true, ip_limit: 0, sub_pull_limit: 0 });
submittingRef.current = false;
setSubmitting(false);
setModalOpen(true);
@@ -525,7 +506,7 @@ const Users: React.FC = () => {
onClick={() => {
setEditing(null);
form.resetFields();
form.setFieldsValue({ enabled: true, ip_limit: 0, hwid_limit: 0, sub_pull_limit: 0 });
form.setFieldsValue({ enabled: true, ip_limit: 0, sub_pull_limit: 0 });
submittingRef.current = false;
setSubmitting(false);
setModalOpen(true);
@@ -577,8 +558,7 @@ const Users: React.FC = () => {
? dayjs(record.expire_time)
: undefined,
ip_limit: record.ip_limit || 0,
hwid_limit: record.hwid_limit || 0,
sub_pull_limit: record.sub_pull_limit || 0,
sub_pull_limit: record.sub_pull_limit || 0,
remark: record.remark,
group: record.group || '',
tags: record.tags || '',
@@ -628,10 +608,7 @@ const Users: React.FC = () => {
</div>
</div>
<Dropdown
menu={{
items: [
{
key: 'devices', label: t('users.devices', 'Devices'), onClick: () => openHwid(record) },
menu=
{ key: 'edit',
icon: <EditOutlined />,
label: t('common.edit'),
@@ -649,8 +626,7 @@ const Users: React.FC = () => {
? dayjs(record.expire_time)
: undefined,
ip_limit: record.ip_limit || 0,
hwid_limit: record.hwid_limit || 0,
sub_pull_limit: record.sub_pull_limit || 0,
sub_pull_limit: record.sub_pull_limit || 0,
remark: record.remark,
group: record.group || '',
tags: record.tags || '',
@@ -731,33 +707,6 @@ const Users: React.FC = () => {
className={isMobile ? 'mobile-full-modal' : undefined}
>
<Form form={form} layout="vertical" onFinish={onSubmit} disabled={submitting}>
<Form.Item name="username" label={t('users.username')} rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="password" label={t('users.password')} rules={[{ required: !editing }]}>
<Input.Password placeholder={editing ? t('users.passwordKeep') : ''} />
</Form.Item>
<Form.Item
name="traffic_limit_gb"
label={t('users.trafficLimitGB')}
tooltip={t('users.trafficLimitHint')}
>
<InputNumber min={0} step={1} style={{ width: '100%' }} placeholder="0 = unlimited" />
</Form.Item>
<Form.Item
name="ip_limit"
label={t('users.ipLimit') || 'IP limit'}
tooltip={t('users.ipLimitHint') || '0 = unlimited concurrent client IPs'}
>
<InputNumber min={0} step={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="hwid_limit"
label={t('users.hwidLimit') || 'HWID device limit'}
tooltip={t('users.hwidLimitHint') || '0 = unlimited. Compatible with Happ x-hwid import headers (Remnawave-style).'}
>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="sub_pull_limit"
@@ -843,45 +792,6 @@ const Users: React.FC = () => {
}))}
/>
</Modal>
<Modal
open={hwidOpen}
title={hwidUser ? `${t('users.devices', 'Devices')} — ${hwidUser.username}` : t('users.devices', 'Devices')}
onCancel={() => { setHwidOpen(false); setHwidUser(null); }}
footer={[
<Button key="clear" danger disabled={!hwidUser || !hwidRows.length} onClick={async () => {
if (!hwidUser) return;
await clearUserHWIDDevices(hwidUser.id);
setHwidRows([]);
message.success(t('common.success'));
}}>{t('users.clearDevices', 'Clear all')}</Button>,
<Button key="close" type="primary" onClick={() => setHwidOpen(false)}>{t('common.close', 'Close')}</Button>,
]}
width={isMobile ? '100%' : 720}
>
<Table
rowKey="id"
loading={hwidLoading}
dataSource={hwidRows}
size="small"
pagination={false}
scroll={{ x: true }}
columns={[
{ title: 'HWID', dataIndex: 'hwid', ellipsis: true },
{ title: t('users.deviceOs', 'OS'), dataIndex: 'device_os', width: 90 },
{ title: t('users.deviceModel', 'Model'), dataIndex: 'device_model', ellipsis: true },
{ title: t('users.lastSeen', 'Last seen'), dataIndex: 'last_seen_at', width: 160, render: (v: string) => v ? new Date(v).toLocaleString() : '-' },
{ title: t('common.actions'), key: 'a', width: 90, render: (_: any, r: HWIDDevice) => (
<Button size="small" danger onClick={async () => {
if (!hwidUser) return;
await deleteUserHWIDDevice(hwidUser.id, r.id);
setHwidRows((rows) => rows.filter((x) => x.id !== r.id));
}}>{t('common.delete')}</Button>
)},
]}
/>
</Modal>
<Modal
open={nodeTrafficOpen}
title={nodeTrafficUser ? `${t('users.nodeTraffic', 'Node traffic')} — ${nodeTrafficUser.username}` : t('users.nodeTraffic', 'Node traffic')}