diff --git a/app/db/crud/hwid.py b/app/db/crud/hwid.py
new file mode 100644
index 000000000..11688550e
--- /dev/null
+++ b/app/db/crud/hwid.py
@@ -0,0 +1,69 @@
+from datetime import datetime, timezone
+
+from sqlalchemy import delete, func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.db.models import UserHWID
+
+
+async def get_user_hwids(db: AsyncSession, user_id: int) -> list[UserHWID]:
+ """Retrieve all HWIDs registered for a specific user."""
+ stmt = select(UserHWID).where(UserHWID.user_id == user_id).order_by(UserHWID.created_at.desc())
+ result = await db.execute(stmt)
+ return list(result.scalars().all())
+
+
+async def get_user_hwid_by_value(db: AsyncSession, user_id: int, hwid_str: str) -> UserHWID | None:
+ """Retrieve a specific HWID for a user by its value."""
+ stmt = select(UserHWID).where(UserHWID.user_id == user_id, UserHWID.hwid == hwid_str)
+ return (await db.execute(stmt)).scalar_one_or_none()
+
+
+async def get_user_hwid_count(db: AsyncSession, user_id: int) -> int:
+ """Count the number of HWIDs registered for a user."""
+ stmt = select(func.count(UserHWID.id)).where(UserHWID.user_id == user_id)
+ return (await db.execute(stmt)).scalar_one()
+
+
+async def register_user_hwid(
+ db: AsyncSession,
+ user_id: int,
+ hwid: str,
+ device_os: str | None = None,
+ os_version: str | None = None,
+ device_model: str | None = None,
+) -> UserHWID:
+ """Register a new HWID for a user."""
+ new_hwid = UserHWID(
+ user_id=user_id,
+ hwid=hwid,
+ device_os=device_os[:256] if device_os else None,
+ os_version=os_version[:128] if os_version else None,
+ device_model=device_model[:256] if device_model else None,
+ )
+ db.add(new_hwid)
+ await db.commit()
+ await db.refresh(new_hwid)
+ return new_hwid
+
+
+async def update_hwid_last_used(db: AsyncSession, hwid_obj: UserHWID) -> None:
+ """Update the last_used_at timestamp for an HWID."""
+ hwid_obj.last_used_at = datetime.now(timezone.utc)
+ await db.commit()
+
+
+async def delete_user_hwid(db: AsyncSession, user_id: int, hwid: str) -> bool:
+ """Delete a specific HWID for a user by its value. Returns True if deleted."""
+ stmt = delete(UserHWID).where(UserHWID.user_id == user_id, UserHWID.hwid == hwid)
+ result = await db.execute(stmt)
+ await db.commit()
+ return result.rowcount > 0
+
+
+async def reset_user_hwids(db: AsyncSession, user_id: int) -> int:
+ """Delete all HWIDs for a user. Returns the number of HWIDs deleted."""
+ stmt = delete(UserHWID).where(UserHWID.user_id == user_id)
+ result = await db.execute(stmt)
+ await db.commit()
+ return result.rowcount
diff --git a/app/db/crud/user.py b/app/db/crud/user.py
index 7acd88a65..62546141e 100644
--- a/app/db/crud/user.py
+++ b/app/db/crud/user.py
@@ -790,6 +790,10 @@ async def create_user(db: AsyncSession, new_user: UserCreate, groups: list[Group
db_user.groups = groups
db_user.expire = new_user.expire or None
db_user.on_hold_timeout = new_user.on_hold_timeout or None
+
+ if new_user.hwid_limit is not None:
+ db_user.hwid_limit = new_user.hwid_limit
+
db_user.proxy_settings = new_user.proxy_settings.dict()
db.add(db_user)
@@ -821,6 +825,7 @@ async def create_users_bulk(
db_user.groups = list(groups)
db_user.expire = new_user.expire or None
db_user.on_hold_timeout = new_user.on_hold_timeout or None
+ db_user.hwid_limit = new_user.hwid_limit if new_user.hwid_limit is not None else None
db_user.proxy_settings = new_user.proxy_settings.dict()
db_users.append(db_user)
@@ -961,6 +966,9 @@ async def modify_user(
if modify.on_hold_expire_duration is not None:
db_user.on_hold_expire_duration = modify.on_hold_expire_duration
+ if modify.hwid_limit is not None:
+ db_user.hwid_limit = modify.hwid_limit
+
if modify.next_plan is not None:
db_user.next_plan = NextPlan(
user_id=db_user.id,
@@ -1173,7 +1181,9 @@ async def bulk_revoke_user_sub(db: AsyncSession, users: list[User]) -> list[User
return users
-async def user_sub_update(db: AsyncSession, user_id: int, user_agent: str, ip: str | None = None) -> None:
+async def user_sub_update(
+ db: AsyncSession, user_id: int, user_agent: str, ip: str | None = None, hwid: str | None = None
+) -> None:
"""
Updates the user's subscription details.
@@ -1182,12 +1192,15 @@ async def user_sub_update(db: AsyncSession, user_id: int, user_agent: str, ip: s
user_id (int): The user id whose subscription is to be updated.
user_agent (str): The user agent string.
ip (str | None): The client IP address.
-
+ hwid (str | None): The hardware ID of the client.
"""
# Clamp to column length; some clients send very long strings (e.g. encoded configs) as User-Agent.
sanitized_user_agent = (user_agent or "")[:_USER_AGENT_MAX_LEN]
sanitized_ip = (ip or "")[:_SUBSCRIPTION_UPDATE_IP_MAX_LEN] or None
- agent = UserSubscriptionUpdate(user_id=user_id, user_agent=sanitized_user_agent, ip=sanitized_ip)
+ sanitized_hwid = (hwid or "")[:256] or None
+ agent = UserSubscriptionUpdate(
+ user_id=user_id, user_agent=sanitized_user_agent, ip=sanitized_ip, hwid=sanitized_hwid
+ )
db.add(agent)
await db.commit()
diff --git a/app/db/migrations/versions/f02194c811d6_add_hwid_models_and_fields.py b/app/db/migrations/versions/f02194c811d6_add_hwid_models_and_fields.py
new file mode 100644
index 000000000..1b11baffc
--- /dev/null
+++ b/app/db/migrations/versions/f02194c811d6_add_hwid_models_and_fields.py
@@ -0,0 +1,79 @@
+"""Add HWID support
+
+Revision ID: f02194c811d6
+Revises: 73c78c6a9b24
+Create Date: 2026-05-14 14:23:22.927015
+
+"""
+from alembic import op
+import sqlalchemy as sa
+import app.db.compiles_types
+
+
+# revision identifiers, used by Alembic.
+revision = 'f02194c811d6'
+down_revision = '73c78c6a9b24'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ 'user_hwids',
+ sa.Column('id', app.db.compiles_types.SqliteCompatibleBigInteger(), autoincrement=True, nullable=False),
+ sa.Column('user_id', app.db.compiles_types.SqliteCompatibleBigInteger(), nullable=False),
+ sa.Column('hwid', sa.String(length=256), nullable=False),
+ sa.Column('device_os', sa.String(length=256), nullable=True),
+ sa.Column('os_version', sa.String(length=128), nullable=True),
+ sa.Column('device_model', sa.String(length=256), nullable=True),
+ sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
+ sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=False),
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_user_hwids_user_id_users'), ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id', name=op.f('pk_user_hwids')),
+ sa.UniqueConstraint('user_id', 'hwid', name=op.f('uq_user_hwids_user_id')),
+ )
+ with op.batch_alter_table('user_hwids', schema=None) as batch_op:
+ batch_op.create_index('ix_user_hwids_user_id', ['user_id'], unique=False)
+ batch_op.create_index('ix_user_hwids_hwid', ['hwid'], unique=False)
+ batch_op.create_index('ix_user_hwids_created_at', ['created_at'], unique=False)
+ batch_op.create_index('ix_user_hwids_last_used_at', ['last_used_at'], unique=False)
+
+ # Fixed MySQL JSON default: Add as nullable, update, then set NOT NULL
+ with op.batch_alter_table('settings', schema=None) as batch_op:
+ batch_op.add_column(sa.Column('hwid', sa.JSON(), nullable=True))
+
+ op.execute("UPDATE settings SET hwid = '{}'")
+
+ with op.batch_alter_table('settings', schema=None) as batch_op:
+ batch_op.alter_column('hwid', type_=sa.JSON(), nullable=False)
+
+ with op.batch_alter_table('user_subscription_updates', schema=None) as batch_op:
+ batch_op.add_column(sa.Column('hwid', sa.String(length=256), nullable=True))
+
+ with op.batch_alter_table('user_templates', schema=None) as batch_op:
+ batch_op.add_column(sa.Column('hwid_limit', sa.BigInteger(), nullable=True))
+
+ with op.batch_alter_table('users', schema=None) as batch_op:
+ batch_op.add_column(sa.Column('hwid_limit', sa.BigInteger(), nullable=True))
+
+
+def downgrade() -> None:
+ with op.batch_alter_table('users', schema=None) as batch_op:
+ batch_op.drop_column('hwid_limit')
+
+ with op.batch_alter_table('user_templates', schema=None) as batch_op:
+ batch_op.drop_column('hwid_limit')
+
+ with op.batch_alter_table('user_subscription_updates', schema=None) as batch_op:
+ batch_op.drop_column('hwid')
+
+ with op.batch_alter_table('settings', schema=None) as batch_op:
+ batch_op.drop_column('hwid')
+
+ with op.batch_alter_table('user_hwids', schema=None) as batch_op:
+ batch_op.drop_index('ix_user_hwids_last_used_at')
+ batch_op.drop_index('ix_user_hwids_created_at')
+ batch_op.drop_index('ix_user_hwids_hwid')
+ batch_op.drop_index('ix_user_hwids_user_id')
+
+ op.drop_table('user_hwids')
diff --git a/app/db/models.py b/app/db/models.py
index 69c892889..f74bc01f7 100644
--- a/app/db/models.py
+++ b/app/db/models.py
@@ -162,8 +162,11 @@ class User(Base):
next_plan: Mapped[Optional["NextPlan"]] = relationship(
uselist=False, back_populates="user", cascade="all, delete-orphan", init=False
)
+ hwids: Mapped[List["UserHWID"]] = relationship(back_populates="user", cascade="all, delete-orphan", init=False)
groups: Mapped[List["Group"]] = relationship(secondary=users_groups_association, back_populates="users", init=False)
- proxy_settings: Mapped[Dict[str, Any]] = mapped_column(JSON(True), server_default=text("'{}'"), default=lambda: {})
+ proxy_settings: Mapped[Dict[str, Any]] = mapped_column(
+ JSON(True), server_default=text("'{}'"), default_factory=dict
+ )
status: Mapped[UserStatus] = mapped_column(SQLEnum(UserStatus), default=UserStatus.active)
used_traffic: Mapped[int] = mapped_column(BigInteger, default=0)
data_limit: Mapped[Optional[int]] = mapped_column(BigInteger, default=None)
@@ -179,6 +182,7 @@ class User(Base):
on_hold_expire_duration: Mapped[Optional[int]] = mapped_column(BigInteger, default=None)
on_hold_timeout: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None)
auto_delete_in_days: Mapped[Optional[int]] = mapped_column(default=None)
+ hwid_limit: Mapped[Optional[int]] = mapped_column(BigInteger, default=None)
edit_at: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None)
last_status_change: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None)
@@ -332,6 +336,30 @@ class UserSubscriptionUpdate(Base):
created_at: Mapped[dt] = mapped_column(DateTime(timezone=True), default_factory=lambda: dt.now(tz.utc), init=False)
user_agent: Mapped[str] = mapped_column(String(512))
ip: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, default=None)
+ hwid: Mapped[Optional[str]] = mapped_column(String(256), nullable=True, default=None)
+
+
+class UserHWID(Base):
+ __tablename__ = "user_hwids"
+ __table_args__ = (
+ UniqueConstraint("user_id", "hwid"),
+ Index("ix_user_hwids_user_id", "user_id"),
+ Index("ix_user_hwids_hwid", "hwid"),
+ Index("ix_user_hwids_created_at", "created_at"),
+ Index("ix_user_hwids_last_used_at", "last_used_at"),
+ )
+
+ id: Mapped[int] = id_column()
+ user_id: Mapped[int] = fk_id_column("users.id", ondelete="CASCADE")
+ user: Mapped["User"] = relationship(back_populates="hwids", init=False)
+ hwid: Mapped[str] = mapped_column(String(256), nullable=False)
+ device_os: Mapped[Optional[str]] = mapped_column(String(256), default=None)
+ os_version: Mapped[Optional[str]] = mapped_column(String(128), default=None)
+ device_model: Mapped[Optional[str]] = mapped_column(String(256), default=None)
+ created_at: Mapped[dt] = mapped_column(DateTime(timezone=True), default_factory=lambda: dt.now(tz.utc), init=False)
+ last_used_at: Mapped[dt] = mapped_column(
+ DateTime(timezone=True), default_factory=lambda: dt.now(tz.utc), init=False
+ )
template_group_association = Table(
@@ -378,6 +406,7 @@ class UserTemplate(Base):
)
groups: Mapped[List["Group"]] = relationship(secondary=template_group_association, back_populates="templates")
data_limit: Mapped[int] = mapped_column(BigInteger, default=0)
+ hwid_limit: Mapped[Optional[int]] = mapped_column(BigInteger, default=None)
expire_duration: Mapped[int] = mapped_column(BigInteger, default=0) # in seconds
on_hold_timeout: Mapped[Optional[int]] = mapped_column(default=None)
status: Mapped[UserStatusCreate] = mapped_column(SQLEnum(UserStatusCreate), default=UserStatusCreate.active)
@@ -790,4 +819,5 @@ class Settings(Base):
notification_settings: Mapped[dict] = mapped_column(JSON())
notification_enable: Mapped[dict] = mapped_column(JSON())
subscription: Mapped[dict] = mapped_column(JSON())
+ hwid: Mapped[dict] = mapped_column(JSON())
general: Mapped[dict] = mapped_column(JSON())
diff --git a/app/models/settings.py b/app/models/settings.py
index ba20305dd..55ca8fef9 100644
--- a/app/models/settings.py
+++ b/app/models/settings.py
@@ -285,6 +285,14 @@ def validate_recommended_apps(cls, v: list[Application]) -> list[Application]:
return v
+class HWIDSettings(BaseModel):
+ enabled: bool = Field(default=False)
+ forced: bool = Field(default=False)
+ fallback_limit: int = Field(default=0, ge=0)
+ min_limit: int = Field(default=0, ge=0)
+ max_limit: int = Field(default=0, ge=0)
+
+
class General(BaseModel):
default_flow: XTLSFlows = Field(default=XTLSFlows.NONE)
default_method: ShadowsocksMethods = Field(default=ShadowsocksMethods.CHACHA20_POLY1305)
@@ -297,6 +305,7 @@ class SettingsSchema(BaseModel):
notification_settings: NotificationSettings | None = Field(default=None)
notification_enable: NotificationEnable | None = Field(default=None)
subscription: Subscription | None = Field(default=None)
+ hwid: HWIDSettings | None = Field(default=None)
general: General | None = Field(default=None)
model_config = ConfigDict(from_attributes=True)
diff --git a/app/models/subscription.py b/app/models/subscription.py
index 51c589294..1f0e8cfef 100644
--- a/app/models/subscription.py
+++ b/app/models/subscription.py
@@ -285,3 +285,12 @@ def validate_datetimes(cls, value):
if not value:
return value
return fix_datetime_timezone(value)
+
+
+class SubscriptionHeaders(BaseModel):
+ x_hwid: str | None = Field(default=None, alias="X-HWID")
+ x_device_os: str | None = Field(default=None, alias="X-Device-OS")
+ x_ver_os: str | None = Field(default=None, alias="X-Ver-OS")
+ x_device_model: str | None = Field(default=None, alias="X-Device-Model")
+
+ model_config = {"populate_by_name": True}
diff --git a/app/models/user.py b/app/models/user.py
index 7649d5ce6..220eb1496 100644
--- a/app/models/user.py
+++ b/app/models/user.py
@@ -37,6 +37,7 @@ class User(BaseModel):
on_hold_timeout: dt | int | None = Field(default=None)
group_ids: list[int] | None = Field(default_factory=list)
auto_delete_in_days: int | None = Field(default=None)
+ hwid_limit: int | None = Field(default=None)
next_plan: NextPlanModel | None = Field(default=None)
@@ -318,6 +319,7 @@ class UserSubscriptionUpdateSchema(BaseModel):
created_at: dt
user_agent: str
ip: str | None = Field(default=None)
+ hwid: str | None = Field(default=None)
model_config = ConfigDict(from_attributes=True)
@@ -345,6 +347,22 @@ class UserSubscriptionUpdateChart(BaseModel):
segments: list[UserSubscriptionUpdateChartSegment] = Field(default_factory=list)
+class UserHWIDResponse(BaseModel):
+ id: int
+ hwid: str
+ device_os: str | None = None
+ os_version: str | None = None
+ device_model: str | None = None
+ created_at: dt
+ last_used_at: dt
+ model_config = ConfigDict(from_attributes=True)
+
+
+class UserHWIDListResponse(BaseModel):
+ hwids: list[UserHWIDResponse]
+ count: int
+
+
class RemoveUsersResponse(BaseModel):
users: list[str]
count: int
diff --git a/app/models/user_template.py b/app/models/user_template.py
index f6f6abdc4..b2c49cb75 100644
--- a/app/models/user_template.py
+++ b/app/models/user_template.py
@@ -22,6 +22,7 @@ def dict(self, *, no_obj=True, **kwargs):
class UserTemplate(BaseModel):
name: str | None = None
data_limit: int | None = Field(ge=0, default=None, description="data_limit can be 0 or greater")
+ hwid_limit: int | None = Field(default=None)
expire_duration: int | None = Field(
ge=0, default=None, description="expire_duration can be 0 or greater in seconds"
)
diff --git a/app/operation/hwid.py b/app/operation/hwid.py
new file mode 100644
index 000000000..3bde12520
--- /dev/null
+++ b/app/operation/hwid.py
@@ -0,0 +1,25 @@
+from app.db import AsyncSession
+from app.db.crud.hwid import delete_user_hwid, get_user_hwids, reset_user_hwids
+from app.models.admin import AdminDetails
+from app.models.user import UserHWIDListResponse, UserHWIDResponse
+from app.operation import BaseOperation
+
+
+class HWIDOperation(BaseOperation):
+ async def get_user_hwids(self, db: AsyncSession, user_id: int, admin: AdminDetails) -> UserHWIDListResponse:
+ db_user = await self.get_validated_user_by_id(db, user_id, admin)
+ hwids = await get_user_hwids(db, db_user.id)
+ hwid_responses = [UserHWIDResponse.model_validate(h) for h in hwids]
+ return UserHWIDListResponse(hwids=hwid_responses, count=len(hwid_responses))
+
+ async def delete_user_hwid(self, db: AsyncSession, user_id: int, hwid: str, admin: AdminDetails) -> dict:
+ db_user = await self.get_validated_user_by_id(db, user_id, admin)
+ deleted = await delete_user_hwid(db, db_user.id, hwid)
+ if not deleted:
+ await self.raise_error(message="HWID not found", code=404)
+ return {}
+
+ async def reset_user_hwids(self, db: AsyncSession, user_id: int, admin: AdminDetails) -> dict:
+ db_user = await self.get_validated_user_by_id(db, user_id, admin)
+ count = await reset_user_hwids(db, db_user.id)
+ return {"count": count}
diff --git a/app/operation/subscription.py b/app/operation/subscription.py
index 18b0005b1..6c937a90f 100644
--- a/app/operation/subscription.py
+++ b/app/operation/subscription.py
@@ -7,13 +7,19 @@
from app.db import AsyncSession
from app.db.crud.user import get_user_usages, user_sub_update
+from app.db.crud.hwid import (
+ get_user_hwid_by_value,
+ get_user_hwid_count,
+ register_user_hwid,
+ update_hwid_last_used,
+)
from app.db.models import User
from app.models.admin import AdminDetails
-from app.models.settings import Application, ConfigFormat, SubRule, Subscription as SubSettings
+from app.models.settings import Application, ConfigFormat, SubRule, Subscription as SubSettings, HWIDSettings
from app.models.stats import UserUsageStatsList
from app.models.subscription import SubscriptionUsageQuery
from app.models.user import SubscriptionUserResponse, UsersResponseWithInbounds
-from app.settings import subscription_settings
+from app.settings import subscription_settings, hwid_settings
from app.subscription.share import encode_title, generate_subscription, setup_format_variables
from app.templates import render_template
from config import template_settings, wireguard_settings
@@ -247,6 +253,41 @@ async def fetch_config(self, user: UsersResponseWithInbounds, client_type: Confi
config["media_type"],
)
+ async def validate_and_register_hwid(
+ self,
+ db: AsyncSession,
+ user_id: int,
+ user_hwid_limit: int | None,
+ x_hwid: str | None,
+ x_device_os: str | None,
+ x_ver_os: str | None,
+ x_device_model: str | None,
+ ):
+ hwid_conf: HWIDSettings = await hwid_settings()
+ if not hwid_conf.enabled:
+ return
+
+ if not x_hwid:
+ if hwid_conf.forced:
+ await self.raise_error(message="HWID header required", code=403)
+ return
+
+ existing_hwid = await get_user_hwid_by_value(db, user_id, x_hwid)
+ if existing_hwid:
+ await update_hwid_last_used(db, existing_hwid)
+ return
+
+ # It's a new HWID, check limit
+ limit = user_hwid_limit if user_hwid_limit is not None else hwid_conf.fallback_limit
+ if limit == 0:
+ pass # unlimited
+ else:
+ current_count = await get_user_hwid_count(db, user_id)
+ if current_count >= limit:
+ await self.raise_error(message="Device limit reached", code=403)
+
+ await register_user_hwid(db, user_id, x_hwid, x_device_os, x_ver_os, x_device_model)
+
async def user_subscription(
self,
db: AsyncSession,
@@ -255,6 +296,10 @@ async def user_subscription(
user_agent: str = "",
ip: str | None = None,
request_url: str = "",
+ x_hwid: str | None = None,
+ x_device_os: str | None = None,
+ x_ver_os: str | None = None,
+ x_device_model: str | None = None,
):
"""
Provides a subscription link based on the user agent (Clash, V2Ray, etc.).
@@ -264,6 +309,10 @@ async def user_subscription(
db_user = await self.get_validated_sub(db, token)
user = await self.validated_user(db_user)
+ await self.validate_and_register_hwid(
+ db, db_user.id, db_user.hwid_limit, x_hwid, x_device_os, x_ver_os, x_device_model
+ )
+
is_browser_request = "text/html" in accept_header
if not sub_settings.disable_sub_template and is_browser_request:
@@ -300,7 +349,7 @@ async def user_subscription(
await self.raise_error(message="Client not supported", code=406)
# Update user subscription info
- await user_sub_update(db, db_user.id, user_agent, ip=ip)
+ await user_sub_update(db, db_user.id, user_agent, ip=ip, hwid=x_hwid)
conf, media_type = await self.fetch_config(user, client_type)
# If disable_sub_template is True and it's a browser request, use inline to view instead of download
@@ -345,7 +394,16 @@ async def _get_rule_response_header_variables(
return format_variables
async def user_subscription_with_client_type(
- self, db: AsyncSession, token: str, client_type: ConfigFormat, request_url: str = "", accept_header: str = ""
+ self,
+ db: AsyncSession,
+ token: str,
+ client_type: ConfigFormat,
+ request_url: str = "",
+ accept_header: str = "",
+ x_hwid: str | None = None,
+ x_device_os: str | None = None,
+ x_ver_os: str | None = None,
+ x_device_model: str | None = None,
):
"""Provides a subscription link based on the specified client type (e.g., Clash, V2Ray)."""
sub_settings: SubSettings = await subscription_settings()
@@ -358,6 +416,10 @@ async def user_subscription_with_client_type(
db_user = await self.get_validated_sub(db, token=token)
user = await self.validated_user(db_user)
+ await self.validate_and_register_hwid(
+ db, db_user.id, db_user.hwid_limit, x_hwid, x_device_os, x_ver_os, x_device_model
+ )
+
response_headers = self.create_response_headers(
user, request_url, sub_settings, extension=client_config.get(client_type, {}).get("extension", "")
)
@@ -392,13 +454,21 @@ async def user_subscription_raw(
token: str,
update_user_agent: str = "",
ip: str | None = None,
+ x_hwid: str | None = None,
+ x_device_os: str | None = None,
+ x_ver_os: str | None = None,
+ x_device_model: str | None = None,
):
sub_settings: SubSettings = await subscription_settings()
db_user = await self.get_validated_sub(db, token)
user = await self.validated_user(db_user)
+ await self.validate_and_register_hwid(
+ db, db_user.id, db_user.hwid_limit, x_hwid, x_device_os, x_ver_os, x_device_model
+ )
+
if update_user_agent:
- await user_sub_update(db, db_user.id, update_user_agent, ip=ip)
+ await user_sub_update(db, db_user.id, update_user_agent, ip=ip, hwid=x_hwid)
links = []
if sub_settings.allow_browser_config:
diff --git a/app/operation/user.py b/app/operation/user.py
index 2a7813dff..5bbd7910c 100644
--- a/app/operation/user.py
+++ b/app/operation/user.py
@@ -90,7 +90,7 @@
)
from app.node.sync import remove_user as sync_remove_user, sync_user, sync_users
from app.operation import BaseOperation, OperatorType
-from app.settings import subscription_settings
+from app.settings import subscription_settings, hwid_settings
from app.utils.jwt import create_subscription_token
from app.utils.logger import get_logger
from app.utils.wireguard import (
@@ -246,14 +246,11 @@ async def _persist_bulk_users(
db_users = await create_users_bulk(db, users_to_create, groups, db_admin)
- subscription_urls: list[str] = []
+ users_list = []
for db_user in db_users:
- user: UserNotificationResponse = await self.update_user(db_user)
- asyncio.create_task(notification.create_user(user, admin))
- logger.info(f'New user "{db_user.username}" with id "{db_user.id}" added by admin "{admin.username}"')
- subscription_urls.append(user.subscription_url)
+ users_list.append(await self.validate_user(db_user))
- return subscription_urls
+ return [user.subscription_url for user in users_list]
async def validate_user(self, db_user: User, include_subscription_url: bool = True) -> UserNotificationResponse:
user = UserNotificationResponse.model_validate(db_user)
@@ -297,6 +294,17 @@ async def _prepare_user_proxy_settings(
await self.raise_error(message=str(exc), code=400, db=db)
async def create_user(self, db: AsyncSession, new_user: UserCreate, admin: AdminDetails) -> UserResponse:
+ hwid_conf = await hwid_settings()
+
+ if new_user.hwid_limit is None:
+ new_user.hwid_limit = hwid_conf.fallback_limit
+
+ if new_user.hwid_limit is not None and not admin.is_sudo:
+ if new_user.hwid_limit < hwid_conf.min_limit:
+ await self.raise_error(message=f"HWID limit cannot be less than {hwid_conf.min_limit}", code=400, db=db)
+ if hwid_conf.max_limit > 0 and (new_user.hwid_limit > hwid_conf.max_limit or new_user.hwid_limit == 0):
+ await self.raise_error(message=f"HWID limit cannot exceed {hwid_conf.max_limit}", code=400, db=db)
+
if new_user.next_plan is not None and new_user.next_plan.user_template_id is not None:
await self.get_validated_user_template(db, new_user.next_plan.user_template_id)
@@ -320,6 +328,26 @@ async def create_user(self, db: AsyncSession, new_user: UserCreate, admin: Admin
async def _modify_user(
self, db: AsyncSession, db_user: User, modified_user: UserModify, admin: AdminDetails
) -> UserResponse:
+ if modified_user.hwid_limit is not None and modified_user.hwid_limit > 0:
+ from app.db.crud.hwid import get_user_hwid_count
+
+ current_count = await get_user_hwid_count(db, db_user.id)
+ if current_count > modified_user.hwid_limit:
+ await self.raise_error(
+ message=f"Cannot lower HWID limit below current device count ({current_count}). Remove devices first.",
+ code=400,
+ db=db,
+ )
+
+ if modified_user.hwid_limit is not None and not admin.is_sudo:
+ hwid_conf = await hwid_settings()
+ if modified_user.hwid_limit < hwid_conf.min_limit:
+ await self.raise_error(message=f"HWID limit cannot be less than {hwid_conf.min_limit}", code=400, db=db)
+ if hwid_conf.max_limit > 0 and (
+ modified_user.hwid_limit > hwid_conf.max_limit or modified_user.hwid_limit == 0
+ ):
+ await self.raise_error(message=f"HWID limit cannot exceed {hwid_conf.max_limit}", code=400, db=db)
+
validated_groups = None
if modified_user.group_ids:
validated_groups = await self.validate_all_groups(db, modified_user)
@@ -925,6 +953,7 @@ def load_base_user_args(template: UserTemplate) -> dict:
"group_ids": template.group_ids,
"data_limit_reset_strategy": template.data_limit_reset_strategy,
"status": template.status,
+ "hwid_limit": template.hwid_limit,
}
if template.status == UserStatus.active:
diff --git a/app/routers/__init__.py b/app/routers/__init__.py
index 03df78fbe..3f547ceb5 100644
--- a/app/routers/__init__.py
+++ b/app/routers/__init__.py
@@ -1,6 +1,20 @@
from fastapi import APIRouter
-from . import admin, core, client_template, group, home, host, node, settings, subscription, system, user, user_template
+from . import (
+ admin,
+ core,
+ client_template,
+ group,
+ home,
+ host,
+ node,
+ settings,
+ subscription,
+ system,
+ user,
+ user_template,
+ hwid,
+)
api_router = APIRouter()
@@ -17,6 +31,7 @@
user.router,
subscription.router,
user_template.router,
+ hwid.router,
]
for router in routers:
diff --git a/app/routers/dependencies/__init__.py b/app/routers/dependencies/__init__.py
index e1c598a3b..4324c2fc7 100644
--- a/app/routers/dependencies/__init__.py
+++ b/app/routers/dependencies/__init__.py
@@ -10,7 +10,7 @@
get_node_stats_period_query,
get_node_usage_query,
)
-from .subscription import get_subscription_usage_query
+from .subscription import get_subscription_headers, get_subscription_usage_query
from .user import (
get_expired_users_query,
get_user_list_query,
@@ -43,6 +43,7 @@
"get_node_stats_period_query",
"get_node_usage_query",
# subscription
+ "get_subscription_headers",
"get_subscription_usage_query",
# user
"get_expired_users_query",
diff --git a/app/routers/dependencies/_common.py b/app/routers/dependencies/_common.py
index 3ac1a940c..b36ab0c62 100644
--- a/app/routers/dependencies/_common.py
+++ b/app/routers/dependencies/_common.py
@@ -2,7 +2,8 @@
from inspect import Parameter, Signature
from typing import Any, Callable, cast
-from fastapi import HTTPException, Query, status
+from fastapi import Header, HTTPException, Query, status
+from fastapi.params import Header as HeaderParam
from fastapi.params import Query as QueryParam
from pydantic import BaseModel, ValidationError
from pydantic_core import PydanticUndefined
@@ -68,3 +69,51 @@ def factory(**kwargs: Any) -> BaseModel:
factory_func.__signature__ = Signature(parameters)
factory_func.__name__ = f"{cls.__name__}_query_factory"
return cast(Callable[..., BaseModel], factory_func)
+
+
+def make_header_dependency(
+ cls: type[BaseModel], field_overrides: dict[str, object] | None = None
+) -> Callable[..., BaseModel]:
+ field_overrides = field_overrides or {}
+ parameters: list[Parameter] = []
+
+ for field_name, field_info in cls.model_fields.items():
+ annotation = field_info.annotation
+ if field_name in field_overrides:
+ override = field_overrides[field_name]
+ if isinstance(override, ParameterOverride):
+ annotation = override.annotation
+ default = override.default
+ else:
+ default = override
+ elif field_info.default_factory is not None:
+ default = field_info.get_default(call_default_factory=True)
+ elif field_info.default is PydanticUndefined:
+ default = Parameter.empty
+ else:
+ default = field_info.default
+
+ # Ensure everything is explicitly a Header parameter
+ if not isinstance(default, (HeaderParam, ParameterOverride)):
+ alias = field_info.alias if field_info.alias else field_name.replace("_", "-").title()
+ if default is Parameter.empty:
+ default = Header(..., alias=alias)
+ else:
+ default = Header(default, alias=alias)
+
+ parameters.append(
+ Parameter(
+ field_name,
+ Parameter.KEYWORD_ONLY,
+ default=default,
+ annotation=annotation,
+ )
+ )
+
+ def factory(**kwargs: Any) -> BaseModel:
+ return build_query(cls, **{key: value for key, value in kwargs.items() if value is not None})
+
+ factory_func = cast(Any, factory)
+ factory_func.__signature__ = Signature(parameters)
+ factory_func.__name__ = f"{cls.__name__}_header_factory"
+ return cast(Callable[..., BaseModel], factory_func)
diff --git a/app/routers/dependencies/subscription.py b/app/routers/dependencies/subscription.py
index a13b70db5..1aee9487f 100644
--- a/app/routers/dependencies/subscription.py
+++ b/app/routers/dependencies/subscription.py
@@ -1,9 +1,9 @@
from fastapi import Query
from app.models.stats import Period
-from app.models.subscription import SubscriptionUsageQuery
+from app.models.subscription import SubscriptionHeaders, SubscriptionUsageQuery
-from ._common import make_query_dependency
+from ._common import make_header_dependency, make_query_dependency
get_subscription_usage_query = make_query_dependency(
SubscriptionUsageQuery,
@@ -13,3 +13,5 @@
"end": Query(None, examples=["2024-01-31T23:59:59+03:30"]),
},
)
+
+get_subscription_headers = make_header_dependency(SubscriptionHeaders)
diff --git a/app/routers/hwid.py b/app/routers/hwid.py
new file mode 100644
index 000000000..b55f18f07
--- /dev/null
+++ b/app/routers/hwid.py
@@ -0,0 +1,44 @@
+from fastapi import APIRouter, Depends
+
+from app.db import AsyncSession, get_db
+from app.models.admin import AdminDetails
+from app.models.user import UserHWIDListResponse
+from app.operation import OperatorType
+from app.operation.hwid import HWIDOperation
+from app.utils import responses
+from .authentication import get_current
+
+hwid_operator = HWIDOperation(operator_type=OperatorType.API)
+router = APIRouter(tags=["User HWID"], prefix="/api/user", responses={401: responses._401})
+
+
+@router.get(
+ "/{user_id}/hwids",
+ response_model=UserHWIDListResponse,
+ responses={403: responses._403, 404: responses._404},
+)
+async def get_user_hwids(user_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current)):
+ """Get user's registered hardware IDs"""
+ return await hwid_operator.get_user_hwids(db, user_id=user_id, admin=admin)
+
+
+@router.delete(
+ "/{user_id}/hwids/{hwid}",
+ responses={403: responses._403, 404: responses._404},
+)
+async def delete_user_hwid(
+ user_id: int, hwid: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current)
+):
+ """Delete a specific hardware ID from user"""
+ return await hwid_operator.delete_user_hwid(db, user_id=user_id, hwid=hwid, admin=admin)
+
+
+@router.post(
+ "/{user_id}/hwids/reset",
+ responses={403: responses._403, 404: responses._404},
+)
+async def reset_user_hwids(
+ user_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current)
+):
+ """Delete all hardware IDs for user"""
+ return await hwid_operator.reset_user_hwids(db, user_id=user_id, admin=admin)
diff --git a/app/routers/subscription.py b/app/routers/subscription.py
index d9713fc11..4159030ca 100644
--- a/app/routers/subscription.py
+++ b/app/routers/subscription.py
@@ -9,7 +9,7 @@
from app.operation.subscription import SubscriptionOperation
from config import subscription_env_settings
-from .dependencies import get_subscription_usage_query
+from .dependencies import get_subscription_headers, get_subscription_usage_query
router = APIRouter(tags=["Subscription"], prefix=f"/{subscription_env_settings.path}")
subscription_operator = SubscriptionOperation(operator_type=OperatorType.API)
@@ -22,6 +22,7 @@ async def user_subscription(
token: str,
db: AsyncSession = Depends(get_db),
user_agent: str = Header(default=""),
+ headers=Depends(get_subscription_headers),
):
"""Provides a subscription link based on the user agent (Clash, V2Ray, etc.)."""
return await subscription_operator.user_subscription(
@@ -31,6 +32,7 @@ async def user_subscription(
user_agent=user_agent,
ip=request.client.host if request.client else None,
request_url=str(request.url),
+ **headers.model_dump(),
)
@@ -49,12 +51,14 @@ async def user_subscription_raw(
token: str,
db: AsyncSession = Depends(get_db),
update_user_agent: str = Header(default="", alias="X-Subscription-User-Agent"),
+ headers=Depends(get_subscription_headers),
):
return await subscription_operator.user_subscription_raw(
db,
token=token,
update_user_agent=update_user_agent,
ip=request.client.host if request.client else None,
+ **headers.model_dump(),
)
@@ -82,6 +86,7 @@ async def user_subscription_with_client_type(
token: str,
client_type: ConfigFormat,
db: AsyncSession = Depends(get_db),
+ headers=Depends(get_subscription_headers),
):
"""Provides a subscription link based on the specified client type (e.g., Clash, V2Ray)."""
return await subscription_operator.user_subscription_with_client_type(
@@ -90,4 +95,5 @@ async def user_subscription_with_client_type(
client_type=client_type,
request_url=str(request.url),
accept_header=request.headers.get("Accept", ""),
+ **headers.model_dump(),
)
diff --git a/app/settings/__init__.py b/app/settings/__init__.py
index 3bae33ad3..512db82f5 100644
--- a/app/settings/__init__.py
+++ b/app/settings/__init__.py
@@ -59,6 +59,15 @@ async def subscription_settings() -> settings.Subscription:
return validated_settings
+@cached()
+async def hwid_settings() -> settings.HWIDSettings:
+ async with GetDB() as db:
+ db_settings = await get_settings(db)
+
+ validated_settings = settings.HWIDSettings.model_validate(db_settings.hwid)
+ return validated_settings
+
+
@cached()
async def general_settings() -> settings.General:
async with GetDB() as db:
@@ -75,6 +84,7 @@ async def refresh_caches() -> None:
await notification_settings.cache.clear()
await notification_enable.cache.clear()
await subscription_settings.cache.clear()
+ await hwid_settings.cache.clear()
await general_settings.cache.clear()
diff --git a/app/utils/wireguard.py b/app/utils/wireguard.py
index 3e5b186a4..097bd691c 100644
--- a/app/utils/wireguard.py
+++ b/app/utils/wireguard.py
@@ -116,6 +116,9 @@ async def prepare_wireguard_proxy_settings(
if not wireguard_tags:
return proxy_settings
+ if not wireguard_settings.enabled:
+ return proxy_settings
+
if proxy_settings.wireguard.public_key and not proxy_settings.wireguard.private_key:
raise ValueError("wireguard private_key is required when user is assigned to a WireGuard interface")
diff --git a/dashboard/public/statics/locales/en.json b/dashboard/public/statics/locales/en.json
index cb887766b..a4b248e47 100644
--- a/dashboard/public/statics/locales/en.json
+++ b/dashboard/public/statics/locales/en.json
@@ -345,6 +345,45 @@
"resetToDefault": "Reset to Default",
"resetToDefaultSuccess": "Subscription rules reset to default"
},
+ "hwid": {
+ "title": "HWID",
+ "description": "Configure hardware ID registration and device limits",
+ "loadError": "Failed to load HWID settings",
+ "saveSuccess": "HWID settings saved successfully",
+ "saveFailed": "Failed to save HWID settings",
+ "cancelSuccess": "Changes cancelled and original HWID settings restored",
+ "policy": {
+ "title": "Device registration policy",
+ "description": "Control subscription access by registered hardware IDs."
+ },
+ "enabled": {
+ "title": "Enable HWID checks",
+ "description": "Register and enforce device IDs on subscription requests."
+ },
+ "forced": {
+ "title": "Require HWID header",
+ "description": "Reject subscription requests that do not send X-HWID."
+ },
+ "limits": {
+ "title": "Device limits",
+ "description": "Set the default device count and optional bounds for user HWID limits."
+ },
+ "fallbackLimit": {
+ "title": "Fallback limit",
+ "description": "Used when a user does not have an explicit HWID limit."
+ },
+ "minLimit": {
+ "title": "Minimum limit",
+ "description": "Lower bound applied to per-user limits. Use 0 to disable."
+ },
+ "maxLimit": {
+ "title": "Maximum limit",
+ "description": "Upper bound applied to per-user limits. Use 0 to disable."
+ },
+ "validation": {
+ "minMax": "Minimum limit cannot be greater than maximum limit"
+ }
+ },
"telegram": {
"title": "Telegram",
"description": "Configure Telegram bot integration and related settings for your system",
@@ -729,6 +768,8 @@
"prefix": "Username Prefix",
"suffix": "Username Suffix",
"dataLimit": "Data Limit",
+ "hwidLimit": "HWID Limit",
+ "hwidLimitPlaceholder": "Default, 0 = unlimited",
"expire": "Expire duration",
"onHoldTimeout": "OnHold Timeout",
"method": "Method",
@@ -1313,6 +1354,25 @@
"revokeUserSub.prompt": "Are you sure you want to revoke «{{username}}»'s subscription?",
"revokeUserSub.success": "{{username}}'s subscription has revoked successfully.",
"revokeUserSub.title": "Revoke User Subscription",
+ "hwids": {
+ "title": "Hardware IDs",
+ "description": "Manage this user's registered hardware IDs.",
+ "copy": "Copy hardware ID",
+ "copied": "Hardware ID copied",
+ "createdAt": "Created at",
+ "lastUsedAt": "Last used at",
+ "reset": "Reset all",
+ "loadFailed": "Failed to load hardware IDs",
+ "empty": "No hardware IDs have been registered yet",
+ "deleteTitle": "Remove hardware ID",
+ "deletePrompt": "This device will need to register again on its next subscription request.",
+ "deleteSuccess": "Hardware ID removed",
+ "deleteFailed": "Failed to remove hardware ID",
+ "resetTitle": "Reset all hardware IDs",
+ "resetPrompt": "All registered devices for this user will be removed.",
+ "resetSuccess": "All hardware IDs reset",
+ "resetFailed": "Failed to reset hardware IDs"
+ },
"subscriptionClients": {
"title": "Subscription Clients",
"viewAllClients": "View Clients",
@@ -1362,6 +1422,8 @@
"absolute": "Absolute",
"custom": "Custom",
"dataLimit": "Data Limit",
+ "hwidLimit": "HWID Limit",
+ "hwidLimitPlaceholder": "Fallback, 0 = unlimited",
"days": "Days",
"editUser": "Modify user",
"editUserTitle": "Modify user",
diff --git a/dashboard/public/statics/locales/fa.json b/dashboard/public/statics/locales/fa.json
index 22634902d..9d8744ef2 100644
--- a/dashboard/public/statics/locales/fa.json
+++ b/dashboard/public/statics/locales/fa.json
@@ -225,6 +225,45 @@
"resetToDefault": "بازگشت به پیشفرض",
"resetToDefaultSuccess": "قوانین اشتراک به پیشفرض بازنشانی شد"
},
+ "hwid": {
+ "title": "شناسه سختافزار",
+ "description": "پیکربندی ثبت شناسه سختافزار و محدودیت دستگاهها",
+ "loadError": "بارگیری تنظیمات شناسه سختافزار ناموفق بود",
+ "saveSuccess": "تنظیمات شناسه سختافزار با موفقیت ذخیره شد",
+ "saveFailed": "ذخیره تنظیمات شناسه سختافزار ناموفق بود",
+ "cancelSuccess": "تغییرات لغو شد و تنظیمات اصلی شناسه سختافزار بازیابی شد",
+ "policy": {
+ "title": "سیاست ثبت دستگاه",
+ "description": "دسترسی به اشتراک را بر اساس شناسههای سختافزاری ثبتشده کنترل کنید."
+ },
+ "enabled": {
+ "title": "فعالسازی بررسی شناسه سختافزار",
+ "description": "شناسه دستگاهها را هنگام درخواست اشتراک ثبت و اعمال کنید."
+ },
+ "forced": {
+ "title": "الزام هدر شناسه سختافزار",
+ "description": "درخواستهای اشتراکی را که هدر X-HWID ارسال نمیکنند رد کنید."
+ },
+ "limits": {
+ "title": "محدودیت دستگاهها",
+ "description": "تعداد پیشفرض دستگاهها و کرانهای اختیاری محدودیت شناسه سختافزار کاربران را تنظیم کنید."
+ },
+ "fallbackLimit": {
+ "title": "محدودیت پیشفرض",
+ "description": "زمانی استفاده میشود که کاربر محدودیت شناسه سختافزار اختصاصی ندارد."
+ },
+ "minLimit": {
+ "title": "حداقل محدودیت",
+ "description": "کران پایین برای محدودیتهای هر کاربر. برای غیرفعالسازی ۰ وارد کنید."
+ },
+ "maxLimit": {
+ "title": "حداکثر محدودیت",
+ "description": "کران بالا برای محدودیتهای هر کاربر. برای غیرفعالسازی ۰ وارد کنید."
+ },
+ "validation": {
+ "minMax": "حداقل محدودیت نمیتواند از حداکثر محدودیت بیشتر باشد"
+ }
+ },
"telegram": {
"title": "تلگرام",
"description": "پیکربندی ادغام ربات تلگرام و تنظیمات مرتبط برای سیستم شما",
@@ -593,6 +632,8 @@
"prefix": "پیشوند نام کاربری",
"suffix": "پسوند نام کاربری",
"dataLimit": "محدودیت داده",
+ "hwidLimit": "محدودیت شناسه سختافزار",
+ "hwidLimitPlaceholder": "پیشفرض، ۰ = نامحدود",
"expire": "مدت انقضا",
"onHoldTimeout": "زمان انتظار توقف",
"method": "روش",
@@ -1161,6 +1202,25 @@
"revokeUserSub.prompt": "آیا مطمئن هستید که می خواهید اشتراک «{{username}}» را بازنشانی کنید؟",
"revokeUserSub.success": "اشتراک {{username}} با موفقیت بازنشانی شد.",
"revokeUserSub.title": "بازنشانی اشتراک کاربر",
+ "hwids": {
+ "title": "شناسههای سختافزار",
+ "description": "شناسههای سختافزاری ثبتشده این کاربر را مدیریت کنید.",
+ "copy": "کپی شناسه سختافزار",
+ "copied": "شناسه سختافزار کپی شد",
+ "createdAt": "ایجاد شده",
+ "lastUsedAt": "آخرین استفاده",
+ "reset": "بازنشانی همه",
+ "loadFailed": "بارگیری شناسههای سختافزار ناموفق بود",
+ "empty": "هنوز شناسه سختافزاری ثبت نشده است",
+ "deleteTitle": "حذف شناسه سختافزار",
+ "deletePrompt": "این دستگاه در درخواست بعدی اشتراک باید دوباره ثبت شود.",
+ "deleteSuccess": "شناسه سختافزار حذف شد",
+ "deleteFailed": "حذف شناسه سختافزار ناموفق بود",
+ "resetTitle": "بازنشانی همه شناسههای سختافزار",
+ "resetPrompt": "همه دستگاههای ثبتشده این کاربر حذف میشوند.",
+ "resetSuccess": "همه شناسههای سختافزار بازنشانی شدند",
+ "resetFailed": "بازنشانی شناسههای سختافزار ناموفق بود"
+ },
"subscriptionClients": {
"title": "کلاینتهای اشتراک",
"viewAllClients": "مشاهده کلاینتها",
@@ -1209,6 +1269,8 @@
"userDialog.absolute": "مطلق",
"userDialog.custom": "انتخابی",
"userDialog.dataLimit": "حد مصرف داده",
+ "userDialog.hwidLimit": "محدودیت شناسه سختافزار",
+ "userDialog.hwidLimitPlaceholder": "پیشفرض، ۰ = نامحدود",
"userDialog.days": "روزها",
"userDialog.editUser": "ویرایش کاربر",
"userDialog.editUserTitle": "ویرایش کاربر",
@@ -2394,6 +2456,8 @@
"userDialog": {
"revokeSubscription": "بازنشانی اشتراک",
"usage": "مصرف",
+ "hwidLimit": "محدودیت شناسه سختافزار",
+ "hwidLimitPlaceholder": "پیشفرض، ۰ = نامحدود",
"deleteSuccess": "کاربر «{{name}}» با موفقیت حذف شد.",
"resetUsageSuccess": "مصرف کاربر «{{name}}» بازنشانی شد.",
"revokeSubSuccess": "اشتراک کاربر «{{name}}» بازنشانی شد.",
diff --git a/dashboard/public/statics/locales/ru.json b/dashboard/public/statics/locales/ru.json
index 2aa207312..09ca42aca 100644
--- a/dashboard/public/statics/locales/ru.json
+++ b/dashboard/public/statics/locales/ru.json
@@ -357,6 +357,45 @@
"resetToDefault": "Сбросить к умолчанию",
"resetToDefaultSuccess": "Правила подписки сброшены к умолчанию"
},
+ "hwid": {
+ "title": "Аппаратный ID",
+ "description": "Настройка регистрации аппаратных ID и лимитов устройств",
+ "loadError": "Не удалось загрузить настройки HWID",
+ "saveSuccess": "Настройки HWID успешно сохранены",
+ "saveFailed": "Не удалось сохранить настройки HWID",
+ "cancelSuccess": "Изменения отменены, исходные настройки HWID восстановлены",
+ "policy": {
+ "title": "Политика регистрации устройств",
+ "description": "Управляйте доступом к подписке по зарегистрированным аппаратным ID."
+ },
+ "enabled": {
+ "title": "Включить проверки HWID",
+ "description": "Регистрируйте и проверяйте ID устройств в запросах подписки."
+ },
+ "forced": {
+ "title": "Требовать заголовок HWID",
+ "description": "Отклонять запросы подписки без заголовка X-HWID."
+ },
+ "limits": {
+ "title": "Лимиты устройств",
+ "description": "Задайте число устройств по умолчанию и необязательные границы лимитов HWID для пользователей."
+ },
+ "fallbackLimit": {
+ "title": "Лимит по умолчанию",
+ "description": "Используется, когда у пользователя нет явного лимита HWID."
+ },
+ "minLimit": {
+ "title": "Минимальный лимит",
+ "description": "Нижняя граница для лимитов пользователей. Укажите 0, чтобы отключить."
+ },
+ "maxLimit": {
+ "title": "Максимальный лимит",
+ "description": "Верхняя граница для лимитов пользователей. Укажите 0, чтобы отключить."
+ },
+ "validation": {
+ "minMax": "Минимальный лимит не может быть больше максимального"
+ }
+ },
"telegram": {
"title": "Telegram",
"description": "Настройка интеграции Telegram бота и связанных настроек для вашей системы",
@@ -715,6 +754,8 @@
"prefix": "Префикс имени пользователя",
"suffix": "Суффикс имени пользователя",
"dataLimit": "Лимит данных",
+ "hwidLimit": "Лимит HWID",
+ "hwidLimitPlaceholder": "По умолчанию, 0 = без лимита",
"expire": "Срок действия",
"onHoldTimeout": "Тайм-аут при ожидании",
"method": "Метод",
@@ -941,6 +982,25 @@
"revokeUserSub.prompt": "Вы уверены, что хотите отозвать подписку для пользователя «{{username}}»?",
"revokeUserSub.success": "Подписка пользователя {{username}} успешно отозвана.",
"revokeUserSub.title": "Отозвать подписку пользователя",
+ "hwids": {
+ "title": "Аппаратные ID",
+ "description": "Управляйте зарегистрированными аппаратными ID этого пользователя.",
+ "copy": "Скопировать аппаратный ID",
+ "copied": "Аппаратный ID скопирован",
+ "createdAt": "Создан",
+ "lastUsedAt": "Последнее использование",
+ "reset": "Сбросить все",
+ "loadFailed": "Не удалось загрузить аппаратные ID",
+ "empty": "Аппаратные ID еще не зарегистрированы",
+ "deleteTitle": "Удалить аппаратный ID",
+ "deletePrompt": "Это устройство должно будет зарегистрироваться снова при следующем запросе подписки.",
+ "deleteSuccess": "Аппаратный ID удален",
+ "deleteFailed": "Не удалось удалить аппаратный ID",
+ "resetTitle": "Сбросить все аппаратные ID",
+ "resetPrompt": "Все зарегистрированные устройства этого пользователя будут удалены.",
+ "resetSuccess": "Все аппаратные ID сброшены",
+ "resetFailed": "Не удалось сбросить аппаратные ID"
+ },
"subscriptionClients": {
"title": "Клиенты подписки",
"viewAllClients": "Показать клиентов",
@@ -989,6 +1049,8 @@
"userDialog.absolute": "Абсолютно",
"userDialog.custom": "Пользовательский",
"userDialog.dataLimit": "Лимит трафика",
+ "userDialog.hwidLimit": "Лимит HWID",
+ "userDialog.hwidLimitPlaceholder": "По умолчанию, 0 = без лимита",
"userDialog.days": "Дни",
"userDialog.editUser": "Редактировать",
"userDialog.editUserTitle": "Редактировать пользователя",
@@ -2336,6 +2398,8 @@
"userDialog": {
"revokeSubscription": "Отозвать подписку",
"usage": "Использование",
+ "hwidLimit": "Лимит HWID",
+ "hwidLimitPlaceholder": "По умолчанию, 0 = без лимита",
"deleteSuccess": "Пользователь «{{name}}» был успешно удалён.",
"resetUsageSuccess": "Использование пользователя «{{name}}» было сброшено.",
"revokeSubSuccess": "Подписка пользователя «{{name}}» была отозвана.",
diff --git a/dashboard/public/statics/locales/zh.json b/dashboard/public/statics/locales/zh.json
index 1cbe2f072..6df4ec5a1 100644
--- a/dashboard/public/statics/locales/zh.json
+++ b/dashboard/public/statics/locales/zh.json
@@ -322,6 +322,45 @@
"resetToDefault": "重置为默认",
"resetToDefaultSuccess": "订阅规则已重置为默认"
},
+ "hwid": {
+ "title": "硬件 ID",
+ "description": "配置硬件 ID 注册和设备限制",
+ "loadError": "加载 HWID 设置失败",
+ "saveSuccess": "HWID 设置已保存",
+ "saveFailed": "保存 HWID 设置失败",
+ "cancelSuccess": "已取消更改并恢复原始 HWID 设置",
+ "policy": {
+ "title": "设备注册策略",
+ "description": "按已注册的硬件 ID 控制订阅访问。"
+ },
+ "enabled": {
+ "title": "启用 HWID 检查",
+ "description": "在订阅请求中注册并校验设备 ID。"
+ },
+ "forced": {
+ "title": "要求 HWID 请求头",
+ "description": "拒绝未发送 X-HWID 的订阅请求。"
+ },
+ "limits": {
+ "title": "设备限制",
+ "description": "设置默认设备数量,以及用户 HWID 限制的可选边界。"
+ },
+ "fallbackLimit": {
+ "title": "默认限制",
+ "description": "当用户没有明确的 HWID 限制时使用。"
+ },
+ "minLimit": {
+ "title": "最小限制",
+ "description": "应用到每个用户限制的下限。使用 0 可禁用。"
+ },
+ "maxLimit": {
+ "title": "最大限制",
+ "description": "应用到每个用户限制的上限。使用 0 可禁用。"
+ },
+ "validation": {
+ "minMax": "最小限制不能大于最大限制"
+ }
+ },
"telegram": {
"title": "Telegram",
"description": "配置 Telegram 机器人集成和系统相关设置",
@@ -729,6 +768,8 @@
"prefix": "用户名前缀",
"suffix": "用户名后缀",
"dataLimit": "数据限制",
+ "hwidLimit": "HWID 限制",
+ "hwidLimitPlaceholder": "默认,0 = 无限制",
"expire": "过期时间",
"onHoldTimeout": "挂起超时",
"method": "方法",
@@ -1295,6 +1336,25 @@
"revokeUserSub.prompt": "您确定要撤销用户 «{{username}}» 的订阅吗?",
"revokeUserSub.success": "成功撤销用户 {{username}} 的订阅。",
"revokeUserSub.title": "撤销用户订阅",
+ "hwids": {
+ "title": "硬件 ID",
+ "description": "管理此用户已注册的硬件 ID。",
+ "copy": "复制硬件 ID",
+ "copied": "硬件 ID 已复制",
+ "createdAt": "创建时间",
+ "lastUsedAt": "最后使用时间",
+ "reset": "全部重置",
+ "loadFailed": "加载硬件 ID 失败",
+ "empty": "尚未注册硬件 ID",
+ "deleteTitle": "删除硬件 ID",
+ "deletePrompt": "此设备需要在下次订阅请求时重新注册。",
+ "deleteSuccess": "硬件 ID 已删除",
+ "deleteFailed": "删除硬件 ID 失败",
+ "resetTitle": "重置所有硬件 ID",
+ "resetPrompt": "将删除此用户的所有已注册设备。",
+ "resetSuccess": "所有硬件 ID 已重置",
+ "resetFailed": "重置硬件 ID 失败"
+ },
"subscriptionClients": {
"title": "订阅客户端",
"viewAllClients": "查看客户端",
@@ -1343,6 +1403,8 @@
"userDialog.absolute": "选择范围",
"userDialog.custom": "自定义",
"userDialog.dataLimit": "流量限制",
+ "userDialog.hwidLimit": "HWID 限制",
+ "userDialog.hwidLimitPlaceholder": "备用,0 = 无限制",
"userDialog.days": "天",
"userDialog.editUser": "修改",
"userDialog.editUserTitle": "用户编辑",
@@ -2408,6 +2470,8 @@
"userDialog": {
"revokeSubscription": "吊销订阅",
"usage": "用量",
+ "hwidLimit": "HWID 限制",
+ "hwidLimitPlaceholder": "备用,0 = 无限制",
"deleteSuccess": "用户「{{name}}」已成功删除。",
"resetUsageSuccess": "用户「{{name}}」的用量已重置。",
"revokeSubSuccess": "用户「{{name}}」的订阅已撤销。",
diff --git a/dashboard/src/app/router.tsx b/dashboard/src/app/router.tsx
index 5ca6d663f..0bef294ee 100644
--- a/dashboard/src/app/router.tsx
+++ b/dashboard/src/app/router.tsx
@@ -29,6 +29,7 @@ const Settings = lazyWithChunkRecovery(() => import('../pages/_dashboard.setting
const CleanupSettings = lazyWithChunkRecovery(() => import('../pages/_dashboard.settings.cleanup'))
const DiscordSettings = lazyWithChunkRecovery(() => import('../pages/_dashboard.settings.discord'))
const GeneralSettings = lazyWithChunkRecovery(() => import('../pages/_dashboard.settings.general'))
+const HwidSettings = lazyWithChunkRecovery(() => import('../pages/_dashboard.settings.hwid'))
const NotificationSettings = lazyWithChunkRecovery(() => import('../pages/_dashboard.settings.notifications'))
const SubscriptionSettings = lazyWithChunkRecovery(() => import('../pages/_dashboard.settings.subscriptions'))
const TelegramSettings = lazyWithChunkRecovery(() => import('../pages/_dashboard.settings.telegram'))
@@ -248,6 +249,14 @@ export const router = createHashRouter([
),
},
+ {
+ path: '/settings/hwid',
+ element: (
+
+ {t('templates.hwidLimit', { defaultValue: 'HWID Limit' })}:{' '}
+
+ {template.hwid_limit === null || template.hwid_limit === undefined
+ ? t('default', { defaultValue: 'Default' })
+ : template.hwid_limit === 0
+ ?
{t('expire')}:
diff --git a/dashboard/src/features/templates/dialogs/user-template-modal.tsx b/dashboard/src/features/templates/dialogs/user-template-modal.tsx
index 30c763f9b..b1c2d72a3 100644
--- a/dashboard/src/features/templates/dialogs/user-template-modal.tsx
+++ b/dashboard/src/features/templates/dialogs/user-template-modal.tsx
@@ -176,10 +176,12 @@ export default function UserTemplateModal({ isDialogOpen, onOpenChange, form, ed
const status = values.status ?? UserStatusCreate.active
const normalizedDataLimitGb = Number(values.data_limit ?? 0)
const hasDataLimit = Number.isFinite(normalizedDataLimitGb) && normalizedDataLimitGb > 0
+ const normalizedHwidLimit = values.hwid_limit == null ? null : Number(values.hwid_limit)
// Build payload according to UserTemplateCreate interface
const submitData = {
name: values.name,
data_limit: hasDataLimit ? gbToBytes(normalizedDataLimitGb as any) : 0,
+ hwid_limit: normalizedHwidLimit == null ? null : Number.isFinite(normalizedHwidLimit) ? Math.floor(normalizedHwidLimit) : null,
expire_duration: values.expire_duration,
username_prefix: values.username_prefix || '',
username_suffix: values.username_suffix || '',
@@ -191,9 +193,9 @@ export default function UserTemplateModal({ isDialogOpen, onOpenChange, form, ed
extra_settings:
values.method || values.flow
? {
- method: values.method,
- flow: values.flow,
- }
+ method: values.method,
+ flow: values.flow,
+ }
: undefined,
}
@@ -228,6 +230,7 @@ export default function UserTemplateModal({ isDialogOpen, onOpenChange, form, ed
const fields = [
'name',
'data_limit',
+ 'hwid_limit',
'expire_duration',
'username_prefix',
'username_suffix',
@@ -400,6 +403,29 @@ export default function UserTemplateModal({ isDialogOpen, onOpenChange, form, ed
)}
/>
+
+
+ {item.hwid}
+
+ {item.hwid}
+
- {formatBytes(Math.round(field.value * 1024 * 1024 * 1024))}
-
+ {hwid}
+
diff --git a/dashboard/src/features/users/forms/user-form.ts b/dashboard/src/features/users/forms/user-form.ts
index 715d8f9aa..0a597b936 100644
--- a/dashboard/src/features/users/forms/user-form.ts
+++ b/dashboard/src/features/users/forms/user-form.ts
@@ -51,6 +51,7 @@ const userSharedSchemaShape = {
username: z.string().min(3, 'validation.minLength').max(128, 'validation.maxLength'),
group_ids: z.array(z.number()).min(1, { message: 'validation.required' }),
data_limit: z.number().min(0),
+ hwid_limit: z.number().min(0).nullable().optional(),
expire: z.union([z.string(), z.number(), z.null()]).optional(),
note: z.string().optional(),
proxy_settings: proxyTableInputSchema.optional(),
@@ -99,6 +100,7 @@ export const getDefaultUserForm = async () => {
username: '',
status: 'active',
data_limit: 0,
+ hwid_limit: undefined,
expire: '',
note: '',
group_ids: [],
diff --git a/dashboard/src/pages/_dashboard.bulk.create.tsx b/dashboard/src/pages/_dashboard.bulk.create.tsx
index 8464d51ed..8ae5ebafa 100644
--- a/dashboard/src/pages/_dashboard.bulk.create.tsx
+++ b/dashboard/src/pages/_dashboard.bulk.create.tsx
@@ -438,6 +438,18 @@ export default function BulkCreateUsersPage() {
{formatBytes(selectedTemplate.data_limit)}
{t('settings.hwid.loadError', { defaultValue: 'Failed to load HWID settings' })}