chore: remove HWID feature (subscription device binding)
This commit is contained in:
1 parent
e89750f9d0
commit
79e6c778a1
31 files changed
+35
-319
No files matched your search
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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、備份還原 |
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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, ¬null, &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).
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')}
|
||||
|
||||
Reference in new issue
Block a user