diff --git a/Dockerfile b/Dockerfile index 7f6e66e3f..25a8680e6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,5 +44,6 @@ COPY healthcheck.sh /code/healthcheck.sh RUN chmod +x /code/healthcheck.sh RUN chmod +x /code/start.sh +RUN sed -i 's/\r$//' /code/start.sh /code/healthcheck.sh /usr/bin/pasarguard-cli /usr/bin/pasarguard-tui ENTRYPOINT ["/code/start.sh"] \ No newline at end of file diff --git a/app/db/crud/core.py b/app/db/crud/core.py index d51e34b66..2b9feb1f6 100644 --- a/app/db/crud/core.py +++ b/app/db/crud/core.py @@ -151,6 +151,7 @@ async def get_cores_simple( sort_list.extend(s.value) else: sort_list.append(s.value) + # Deterministic tie-breaker for stable pagination (MySQL especially). sort_list.append(CoreConfig.id.asc()) stmt = stmt.order_by(*sort_list) else: diff --git a/app/db/crud/hwid.py b/app/db/crud/hwid.py new file mode 100644 index 000000000..1d892eb15 --- /dev/null +++ b/app/db/crud/hwid.py @@ -0,0 +1,308 @@ +from __future__ import annotations + +import hashlib +import hmac +import threading +from contextlib import nullcontext +from dataclasses import dataclass +from datetime import datetime, timezone + +from sqlalchemy import delete, desc, func, select, text, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import HWIDUserDevice, User +from config import HWID_HASH_SALT + +_HWID_MAX_LEN = 256 +_DEVICE_OS_MAX_LEN = 64 +_OS_VERSION_MAX_LEN = 64 +_DEVICE_MODEL_MAX_LEN = 128 +_USER_AGENT_MAX_LEN = 512 +_REQUEST_IP_MAX_LEN = 64 +_sqlite_user_hwid_locks: dict[int, threading.Lock] = {} +_sqlite_user_hwid_locks_guard = threading.Lock() + + +def _clamp(value: str | None, max_len: int) -> str | None: + if not value: + return None + return value.strip()[:max_len] or None + + +def normalize_hwid(value: str) -> str: + return value.strip() + + +def hash_hwid(hwid: str) -> str: + # Expects normalized HWID input. + digest = hmac.new(HWID_HASH_SALT.encode("utf-8"), hwid.encode("utf-8"), hashlib.sha256).hexdigest() + return digest + + +def _get_sqlite_user_lock(user_id: int) -> threading.Lock: + with _sqlite_user_hwid_locks_guard: + lock = _sqlite_user_hwid_locks.get(user_id) + if lock is None: + lock = threading.Lock() + _sqlite_user_hwid_locks[user_id] = lock + return lock + + +@dataclass +class HWIDDecision: + allowed: bool + max_devices_reached: bool = False + missing_hwid: bool = False + + +async def _acquire_user_hwid_lock(db: AsyncSession, user_id: int) -> None: + dialect = db.get_bind().dialect.name + if dialect == "sqlite": + # SQLite uses database-level write locks; avoid extra dummy writes that + # can trigger "database is locked" under concurrent test cleanup paths. + return + + if dialect == "postgresql": + # Transaction-scoped lock that is auto-released on commit/rollback. + await db.execute(text("SELECT pg_advisory_xact_lock(:lock_key)"), {"lock_key": int(user_id)}) + return + + # Use transaction-scoped row lock for MySQL/MariaDB and other dialects. + await db.execute(select(User.id).where(User.id == user_id).with_for_update()) + + +async def _release_user_hwid_lock(db: AsyncSession, user_id: int) -> None: + return + + +async def enforce_hwid_device_limit( + db: AsyncSession, + *, + user: User, + hwid: str | None, + device_os: str | None, + os_version: str | None, + device_model: str | None, + user_agent: str | None, + request_ip: str | None, + limit: int, +) -> HWIDDecision: + if not hwid: + return HWIDDecision(allowed=False, missing_hwid=True) + + normalized_hwid = normalize_hwid(hwid) + if not normalized_hwid or len(normalized_hwid) > _HWID_MAX_LEN: + return HWIDDecision(allowed=False, missing_hwid=True) + + hwid_hash = hash_hwid(normalized_hwid) + now = datetime.now(timezone.utc) + had_transaction = db.in_transaction() + tx_ctx = nullcontext() if had_transaction else db.begin() + sqlite_lock_ctx = ( + _get_sqlite_user_lock(user.id) if db.get_bind().dialect.name == "sqlite" else nullcontext() + ) + decision = HWIDDecision(allowed=True) + try: + async with tx_ctx: + with sqlite_lock_ctx: + await _acquire_user_hwid_lock(db, user.id) + existing = ( + await db.execute( + select(HWIDUserDevice).where(HWIDUserDevice.user_id == user.id, HWIDUserDevice.hwid_hash == hwid_hash) + ) + ).scalar_one_or_none() + + if existing: + existing.last_seen_at = now + existing.device_os = _clamp(device_os, _DEVICE_OS_MAX_LEN) + existing.os_version = _clamp(os_version, _OS_VERSION_MAX_LEN) + existing.device_model = _clamp(device_model, _DEVICE_MODEL_MAX_LEN) + existing.user_agent = _clamp(user_agent, _USER_AGENT_MAX_LEN) + existing.request_ip = _clamp(request_ip, _REQUEST_IP_MAX_LEN) + existing.updated_at = now + decision = HWIDDecision(allowed=True) + else: + lock_count_stmt = select(HWIDUserDevice.id).where(HWIDUserDevice.user_id == user.id) + if db.get_bind().dialect.name != "sqlite": + # Use a locking read so concurrent transactions observe current rows, + # not a stale repeatable-read snapshot. + lock_count_stmt = lock_count_stmt.with_for_update() + existing_count = len((await db.execute(lock_count_stmt)).scalars().all()) + if existing_count >= limit: + decision = HWIDDecision(allowed=False, max_devices_reached=True) + else: + db.add( + HWIDUserDevice( + user_id=user.id, + hwid_hash=hwid_hash, + device_os=_clamp(device_os, _DEVICE_OS_MAX_LEN), + os_version=_clamp(os_version, _OS_VERSION_MAX_LEN), + device_model=_clamp(device_model, _DEVICE_MODEL_MAX_LEN), + user_agent=_clamp(user_agent, _USER_AGENT_MAX_LEN), + request_ip=_clamp(request_ip, _REQUEST_IP_MAX_LEN), + updated_at=now, + ) + ) + decision = HWIDDecision(allowed=True) + finally: + await _release_user_hwid_lock(db, user.id) + return decision + + +async def list_hwid_devices( + db: AsyncSession, *, offset: int = 0, limit: int = 50, user_id: int | None = None +) -> tuple[list[dict], int]: + stmt = select(HWIDUserDevice, User.username).join(User, User.id == HWIDUserDevice.user_id, isouter=True) + if user_id is not None: + stmt = stmt.where(HWIDUserDevice.user_id == user_id) + count_stmt = select(func.count(HWIDUserDevice.id)) + if user_id is not None: + count_stmt = count_stmt.where(HWIDUserDevice.user_id == user_id) + count = int((await db.execute(count_stmt)).scalar() or 0) + stmt = stmt.order_by(desc(HWIDUserDevice.last_seen_at)).offset(offset).limit(limit) + rows = (await db.execute(stmt)).all() + items: list[dict] = [] + for device, username in rows: + items.append( + { + "id": device.id, + "user_id": device.user_id, + "username": username, + "hwid_hash": device.hwid_hash, + "device_os": device.device_os, + "os_version": device.os_version, + "device_model": device.device_model, + "user_agent": device.user_agent, + "request_ip": device.request_ip, + "first_seen_at": device.first_seen_at, + "last_seen_at": device.last_seen_at, + "created_at": device.created_at, + "updated_at": device.updated_at, + } + ) + return items, count + + +async def add_hwid_device( + db: AsyncSession, + *, + user_id: int, + hwid: str, + device_os: str | None = None, + os_version: str | None = None, + device_model: str | None = None, + user_agent: str | None = None, + request_ip: str | None = None, +) -> tuple[dict, bool]: + normalized_hwid = normalize_hwid(hwid) + if not normalized_hwid or len(normalized_hwid) > _HWID_MAX_LEN: + raise ValueError("Invalid HWID") + + hwid_hash = hash_hwid(normalized_hwid) + now = datetime.now(timezone.utc) + had_transaction = db.in_transaction() + tx_ctx = nullcontext() if had_transaction else db.begin() + sqlite_lock_ctx = _get_sqlite_user_lock(user_id) if db.get_bind().dialect.name == "sqlite" else nullcontext() + try: + async with tx_ctx: + with sqlite_lock_ctx: + await _acquire_user_hwid_lock(db, user_id) + existing = ( + await db.execute( + select(HWIDUserDevice).where(HWIDUserDevice.user_id == user_id, HWIDUserDevice.hwid_hash == hwid_hash) + ) + ).scalar_one_or_none() + + if existing: + existing.last_seen_at = now + existing.device_os = _clamp(device_os, _DEVICE_OS_MAX_LEN) + existing.os_version = _clamp(os_version, _OS_VERSION_MAX_LEN) + existing.device_model = _clamp(device_model, _DEVICE_MODEL_MAX_LEN) + existing.user_agent = _clamp(user_agent, _USER_AGENT_MAX_LEN) + existing.request_ip = _clamp(request_ip, _REQUEST_IP_MAX_LEN) + existing.updated_at = now + device = existing + created = False + else: + device = HWIDUserDevice( + user_id=user_id, + hwid_hash=hwid_hash, + device_os=_clamp(device_os, _DEVICE_OS_MAX_LEN), + os_version=_clamp(os_version, _OS_VERSION_MAX_LEN), + device_model=_clamp(device_model, _DEVICE_MODEL_MAX_LEN), + user_agent=_clamp(user_agent, _USER_AGENT_MAX_LEN), + request_ip=_clamp(request_ip, _REQUEST_IP_MAX_LEN), + updated_at=now, + ) + db.add(device) + await db.flush() + created = True + + username = ( + await db.execute(select(User.username).where(User.id == user_id)) + ).scalar_one_or_none() + finally: + await _release_user_hwid_lock(db, user_id) + await db.commit() + return ( + { + "id": device.id, + "user_id": device.user_id, + "username": username, + "hwid_hash": device.hwid_hash, + "device_os": device.device_os, + "os_version": device.os_version, + "device_model": device.device_model, + "user_agent": device.user_agent, + "request_ip": device.request_ip, + "first_seen_at": device.first_seen_at, + "last_seen_at": device.last_seen_at, + "created_at": device.created_at, + "updated_at": device.updated_at, + }, + created, + ) + + +async def delete_hwid_device(db: AsyncSession, *, user_id: int, hwid_hash: str) -> int: + tx_ctx = nullcontext() if db.in_transaction() else db.begin() + async with tx_ctx: + result = await db.execute( + delete(HWIDUserDevice).where(HWIDUserDevice.user_id == user_id, HWIDUserDevice.hwid_hash == hwid_hash) + ) + await db.commit() + return int(result.rowcount or 0) + + +async def delete_all_hwid_devices(db: AsyncSession, *, user_id: int) -> int: + tx_ctx = nullcontext() if db.in_transaction() else db.begin() + async with tx_ctx: + result = await db.execute(delete(HWIDUserDevice).where(HWIDUserDevice.user_id == user_id)) + await db.commit() + return int(result.rowcount or 0) + + +async def get_hwid_stats(db: AsyncSession) -> dict[str, int]: + total_devices = int((await db.execute(select(func.count(HWIDUserDevice.id)))).scalar() or 0) + users_with_devices = int( + ( + await db.execute( + select(func.count(func.distinct(HWIDUserDevice.user_id))) + ) + ).scalar() + or 0 + ) + return {"total_devices": total_devices, "users_with_devices": users_with_devices} + + +async def get_hwid_top_users(db: AsyncSession, *, limit: int = 20) -> list[dict]: + stmt = ( + select(HWIDUserDevice.user_id, User.username, func.count(HWIDUserDevice.id).label("devices_count")) + .join(User, User.id == HWIDUserDevice.user_id, isouter=True) + .group_by(HWIDUserDevice.user_id, User.username) + .order_by(desc("devices_count")) + .limit(max(1, limit)) + ) + rows = (await db.execute(stmt)).all() + return [{"user_id": user_id, "username": username or "", "devices_count": int(devices_count or 0)} for user_id, username, devices_count in rows] + diff --git a/app/db/crud/user.py b/app/db/crud/user.py index 60be8ec70..7525346d1 100644 --- a/app/db/crud/user.py +++ b/app/db/crud/user.py @@ -14,6 +14,7 @@ Admin, DataLimitResetStrategy, Group, + HWIDUserDevice, NextPlan, NodeUserUsage, NotificationReminder, @@ -821,6 +822,7 @@ async def _delete_user_dependencies(db: AsyncSession, user_ids: list[int]): if not user_ids: return + await db.execute(delete(HWIDUserDevice).where(HWIDUserDevice.user_id.in_(user_ids))) await db.execute(users_groups_association.delete().where(users_groups_association.c.user_id.in_(user_ids))) @@ -933,6 +935,12 @@ 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_device_limit is not None: + db_user.hwid_device_limit = modify.hwid_device_limit + + if modify.hwid_limit_disabled is not None: + db_user.hwid_limit_disabled = modify.hwid_limit_disabled + if modify.next_plan is not None: db_user.next_plan = NextPlan( user_id=db_user.id, diff --git a/app/db/migrations/env.py b/app/db/migrations/env.py index bb99eabd6..95a58ea72 100644 --- a/app/db/migrations/env.py +++ b/app/db/migrations/env.py @@ -31,6 +31,21 @@ # ... etc. +def _compare_type(_context, _inspected_column, metadata_column, _inspected_type, _metadata_type): + """Skip INTEGER vs BIGINT drift for HWID PK/FK. + + Migration b2c3d4e5f6a7 mirrors ``users.id`` SQL type per dialect (INTEGER on SQLite, + BIGINT on PostgreSQL/MySQL). Models use ``BigInteger`` so runtime values fit all DBs; + SQLite still reflects INTEGER, which would otherwise fail ``alembic check``. + """ + if metadata_column.table.name == "hwid_user_devices" and metadata_column.name in ( + "id", + "user_id", + ): + return False + return None + + def run_migrations_offline() -> None: """Run migrations in 'offline' mode. @@ -50,12 +65,20 @@ def run_migrations_offline() -> None: literal_binds=True, render_as_batch=True, dialect_opts={"paramstyle": "named"}, + compare_type=_compare_type, ) with context.begin_transaction(): context.run_migrations() + + def do_run_migrations(connection: Connection) -> None: - context.configure(connection=connection, target_metadata=target_metadata, render_as_batch=True) + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=True, + compare_type=_compare_type, + ) with context.begin_transaction(): context.run_migrations() diff --git a/app/db/migrations/versions/a5790b474dfe_merge_hwid_device_limit_and_node_proxy_.py b/app/db/migrations/versions/a5790b474dfe_merge_hwid_device_limit_and_node_proxy_.py new file mode 100644 index 000000000..4d5df5a99 --- /dev/null +++ b/app/db/migrations/versions/a5790b474dfe_merge_hwid_device_limit_and_node_proxy_.py @@ -0,0 +1,24 @@ +"""merge hwid device limit and node proxy url heads + +Revision ID: a5790b474dfe +Revises: af2d644dda44, b2c3d4e5f6a7 +Create Date: 2026-05-03 11:29:59.130761 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a5790b474dfe' +down_revision = ('af2d644dda44', 'b2c3d4e5f6a7') +branch_labels = None +depends_on = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/app/db/migrations/versions/b2c3d4e5f6a7_add_hwid_device_limit.py b/app/db/migrations/versions/b2c3d4e5f6a7_add_hwid_device_limit.py new file mode 100644 index 000000000..1d1aa2bdf --- /dev/null +++ b/app/db/migrations/versions/b2c3d4e5f6a7_add_hwid_device_limit.py @@ -0,0 +1,81 @@ +"""add hwid device limit + +Revision ID: b2c3d4e5f6a7 +Revises: a1b2c3d4e5f6 +Create Date: 2026-04-30 22:00:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect +from sqlalchemy.dialects import mysql + + +# revision identifiers, used by Alembic. +revision = "b2c3d4e5f6a7" +down_revision = "a1b2c3d4e5f6" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + bind = op.get_bind() + dialect_name = bind.dialect.name + users_columns = {col["name"]: col for col in inspect(bind).get_columns("users")} + users_id_type = users_columns.get("id", {}).get("type") + users_id_type_repr = str(users_id_type).upper() if users_id_type is not None else "" + users_id_is_unsigned = bool(getattr(users_id_type, "unsigned", False)) + if "BIGINT" in users_id_type_repr: + if dialect_name == "mysql" and (users_id_is_unsigned or "UNSIGNED" in users_id_type_repr): + user_id_column_type = mysql.BIGINT(unsigned=True) + else: + user_id_column_type = sa.BigInteger() + else: + user_id_column_type = sa.Integer() + + with op.batch_alter_table("users") as batch_op: + batch_op.add_column(sa.Column("hwid_device_limit", sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column("hwid_limit_disabled", sa.Boolean(), nullable=False, server_default="0")) + batch_op.create_check_constraint( + "ck_users_hwid_device_limit_non_negative", + "hwid_device_limit IS NULL OR hwid_device_limit >= 0", + ) + + op.create_table( + "hwid_user_devices", + sa.Column("id", user_id_column_type, nullable=False), + sa.Column("user_id", user_id_column_type, nullable=False), + sa.Column("hwid_hash", sa.String(length=128), nullable=False), + sa.Column("device_os", sa.String(length=64), nullable=True), + sa.Column("os_version", sa.String(length=64), nullable=True), + sa.Column("device_model", sa.String(length=128), nullable=True), + sa.Column("user_agent", sa.String(length=512), nullable=True), + sa.Column("request_ip", sa.String(length=64), nullable=True), + sa.Column("first_seen_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id", "hwid_hash"), + ) + if dialect_name == "mysql": + # Ensure table collation/engine defaults don't interfere with FK creation. + op.execute(sa.text("ALTER TABLE hwid_user_devices ENGINE=InnoDB")) + op.create_index("ix_hwid_user_devices_user_id_last_seen_at", "hwid_user_devices", ["user_id", "last_seen_at"]) + op.create_index("ix_hwid_user_devices_hwid_hash", "hwid_user_devices", ["hwid_hash"]) + op.create_index("ix_hwid_user_devices_last_seen_at", "hwid_user_devices", ["last_seen_at"]) + + +def downgrade() -> None: + op.drop_index("ix_hwid_user_devices_last_seen_at", table_name="hwid_user_devices") + op.drop_index("ix_hwid_user_devices_hwid_hash", table_name="hwid_user_devices") + op.drop_index("ix_hwid_user_devices_user_id_last_seen_at", table_name="hwid_user_devices") + op.drop_table("hwid_user_devices") + + with op.batch_alter_table("users") as batch_op: + batch_op.drop_constraint("ck_users_hwid_device_limit_non_negative", type_="check") + batch_op.drop_column("hwid_limit_disabled") + batch_op.drop_column("hwid_device_limit") + diff --git a/app/db/models.py b/app/db/models.py index 4ea4bba0b..7df47a134 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -157,6 +157,9 @@ class User(Base): subscription_updates: Mapped[List["UserSubscriptionUpdate"]] = relationship( back_populates="user", cascade="all, delete-orphan", init=False ) + hwid_devices: Mapped[List["HWIDUserDevice"]] = relationship( + back_populates="user", cascade="all, delete-orphan", init=False + ) usage_logs: Mapped[List["UserUsageResetLogs"]] = relationship(back_populates="user", init=False) admin: Mapped["Admin"] = relationship(back_populates="users", init=False) next_plan: Mapped[Optional["NextPlan"]] = relationship( @@ -179,6 +182,8 @@ 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_device_limit: Mapped[Optional[int]] = mapped_column(default=None) + hwid_limit_disabled: Mapped[bool] = mapped_column(default=False, server_default="0") 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) @@ -333,6 +338,30 @@ class UserSubscriptionUpdate(Base): user_agent: Mapped[str] = mapped_column(String(512)) +class HWIDUserDevice(Base): + __tablename__ = "hwid_user_devices" + __table_args__ = ( + UniqueConstraint("user_id", "hwid_hash"), + Index("ix_hwid_user_devices_user_id_last_seen_at", "user_id", "last_seen_at"), + Index("ix_hwid_user_devices_hwid_hash", "hwid_hash"), + Index("ix_hwid_user_devices_last_seen_at", "last_seen_at"), + ) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False, autoincrement=True) + user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id", ondelete="CASCADE")) + user: Mapped["User"] = relationship(back_populates="hwid_devices", init=False) + hwid_hash: Mapped[str] = mapped_column(String(128)) + device_os: Mapped[Optional[str]] = mapped_column(String(64), default=None) + os_version: Mapped[Optional[str]] = mapped_column(String(64), default=None) + device_model: Mapped[Optional[str]] = mapped_column(String(128), default=None) + user_agent: Mapped[Optional[str]] = mapped_column(String(512), default=None) + request_ip: Mapped[Optional[str]] = mapped_column(String(64), default=None) + first_seen_at: Mapped[dt] = mapped_column(DateTime(timezone=True), default_factory=lambda: dt.now(tz.utc), init=False) + last_seen_at: Mapped[dt] = mapped_column(DateTime(timezone=True), default_factory=lambda: dt.now(tz.utc), init=False) + created_at: Mapped[dt] = mapped_column(DateTime(timezone=True), default_factory=lambda: dt.now(tz.utc), init=False) + updated_at: Mapped[dt] = mapped_column(DateTime(timezone=True), default_factory=lambda: dt.now(tz.utc)) + + template_group_association = Table( "template_group_association", Base.metadata, diff --git a/app/models/hwid.py b/app/models/hwid.py new file mode 100644 index 000000000..c0420d733 --- /dev/null +++ b/app/models/hwid.py @@ -0,0 +1,50 @@ +from datetime import datetime as dt + +from pydantic import BaseModel, ConfigDict, Field + + +class HWIDDeviceResponse(BaseModel): + id: int + user_id: int + username: str | None = None + hwid_hash: str + device_os: str | None = None + os_version: str | None = None + device_model: str | None = None + user_agent: str | None = None + request_ip: str | None = None + first_seen_at: dt + last_seen_at: dt + created_at: dt + updated_at: dt + model_config = ConfigDict(from_attributes=True) + + +class HWIDDeviceListResponse(BaseModel): + items: list[HWIDDeviceResponse] = Field(default_factory=list) + total: int = 0 + + +class HWIDStatsResponse(BaseModel): + total_devices: int + users_with_devices: int + + +class HWIDDeleteRequest(BaseModel): + user_id: int = Field(ge=1) + hwid_hash: str = Field(min_length=1, max_length=128) + + +class HWIDDeleteAllRequest(BaseModel): + user_id: int = Field(ge=1) + + +class HWIDAddRequest(BaseModel): + user_id: int = Field(ge=1) + hwid: str = Field(min_length=1, max_length=256) + device_os: str | None = Field(default=None, max_length=64) + os_version: str | None = Field(default=None, max_length=64) + device_model: str | None = Field(default=None, max_length=128) + user_agent: str | None = Field(default=None, max_length=512) + request_ip: str | None = Field(default=None, max_length=64) + diff --git a/app/models/settings.py b/app/models/settings.py index ba20305dd..f7cf1c71e 100644 --- a/app/models/settings.py +++ b/app/models/settings.py @@ -269,6 +269,8 @@ class Subscription(BaseModel): allow_browser_config: bool = Field(default=True) disable_sub_template: bool = Field(default=False) randomize_order: bool = Field(default=False) + hwid_device_limit_enabled: bool = Field(default=False) + hwid_fallback_device_limit: int = Field(default=0, ge=0) @field_validator("applications") @classmethod diff --git a/app/models/user.py b/app/models/user.py index fdfdc67b0..bdb02a850 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -35,6 +35,8 @@ 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_device_limit: int | None = Field(default=None, ge=0) + hwid_limit_disabled: bool | None = Field(default=None) next_plan: NextPlanModel | None = Field(default=None) diff --git a/app/operation/admin.py b/app/operation/admin.py index c1b70b274..4f9431469 100644 --- a/app/operation/admin.py +++ b/app/operation/admin.py @@ -42,11 +42,18 @@ from app.operation import BaseOperation, OperatorType from app.operation.user import UserOperation from app.utils.logger import get_logger +from config import TESTING logger = get_logger("admin-operation") class AdminOperation(BaseOperation): + @staticmethod + def _schedule_notification(task) -> None: + if TESTING: + return + asyncio.create_task(task) + @staticmethod def _is_non_blocking_sync_operator(operator_type: OperatorType) -> bool: return operator_type in (OperatorType.API, OperatorType.WEB) @@ -71,7 +78,7 @@ async def create_admin(self, db: AsyncSession, new_admin: AdminCreate, admin: Ad if self.operator_type != OperatorType.CLI: logger.info(f'New admin "{db_admin.username}" with id "{db_admin.id}" added by admin "{admin.username}"') new_admin = AdminDetails.model_validate(db_admin) - asyncio.create_task(notification.create_admin(new_admin, admin.username)) + self._schedule_notification(notification.create_admin(new_admin, admin.username)) return db_admin @@ -119,7 +126,7 @@ async def _modify_admin( ) modified_admin = AdminDetails.model_validate(db_admin) - asyncio.create_task(notification.modify_admin(modified_admin, current_admin.username)) + self._schedule_notification(notification.modify_admin(modified_admin, current_admin.username)) return modified_admin async def modify_admin_by_id( @@ -150,7 +157,7 @@ async def _remove_admin(self, db: AsyncSession, db_admin: Admin, current_admin: logger.info( f'Admin "{db_admin.username}" with id "{db_admin.id}" deleted by admin "{current_admin.username}"' ) - asyncio.create_task(notification.remove_admin(db_admin.username, current_admin.username)) + self._schedule_notification(notification.remove_admin(db_admin.username, current_admin.username)) async def remove_admin_by_id(self, db: AsyncSession, admin_id: int, current_admin: AdminDetails | None = None): db_admin = await self.get_validated_admin_by_id(db, admin_id) @@ -313,7 +320,7 @@ async def _remove_all_users_for_admin(self, db: AsyncSession, db_admin: Admin, a await sync_remove_user(user) for user in serialized_users: - asyncio.create_task(notification.remove_user(user, admin)) + self._schedule_notification(notification.remove_user(user, admin)) logger.info( f'Admin "{admin.username}" deleted {len(serialized_users)} users belonging to admin "{target_username}"' @@ -340,7 +347,7 @@ async def _reset_admin_usage(self, db: AsyncSession, db_admin: Admin, admin: Adm logger.info(f'Admin "{db_admin.username}" usage has been reset by admin "{admin.username}"') reseted_admin_details = AdminDetails.model_validate(db_admin) - asyncio.create_task(notification.admin_usage_reset(reseted_admin_details, admin.username)) + self._schedule_notification(notification.admin_usage_reset(reseted_admin_details, admin.username)) return reseted_admin_details @@ -453,7 +460,7 @@ async def bulk_remove_admins( if self.operator_type != OperatorType.CLI: for username in usernames: logger.info(f'Admin "{username}" deleted by admin "{admin.username}"') - asyncio.create_task(notification.remove_admin(username, admin.username)) + self._schedule_notification(notification.remove_admin(username, admin.username)) return RemoveAdminsResponse(admins=usernames, count=len(db_admins)) @@ -521,7 +528,7 @@ async def bulk_set_admins_disabled( for db_admin in admins_to_update: modified_admin = AdminDetails.model_validate(db_admin) - asyncio.create_task(notification.modify_admin(modified_admin, current_admin.username)) + self._schedule_notification(notification.modify_admin(modified_admin, current_admin.username)) logger.info( f'Admin "{db_admin.username}" bulk {"disabled" if is_disabled else "enabled"} by admin "{current_admin.username}"' ) @@ -536,7 +543,7 @@ async def bulk_reset_admins_usage( for db_admin in db_admins: db_admin = await reset_admin_usage(db, db_admin=db_admin) reseted_admin = AdminDetails.model_validate(db_admin) - asyncio.create_task(notification.admin_usage_reset(reseted_admin, admin.username)) + self._schedule_notification(notification.admin_usage_reset(reseted_admin, admin.username)) logger.info(f'Admin "{db_admin.username}" usage has been reset by admin "{admin.username}"') return self._build_bulk_action_response(db_admins) diff --git a/app/operation/hwid.py b/app/operation/hwid.py new file mode 100644 index 000000000..f23da57de --- /dev/null +++ b/app/operation/hwid.py @@ -0,0 +1,89 @@ +from app.db import AsyncSession +from app.db.crud.hwid import ( + HWIDDecision, + add_hwid_device, + delete_all_hwid_devices, + delete_hwid_device, + enforce_hwid_device_limit, + get_hwid_top_users, + get_hwid_stats, + list_hwid_devices, +) +from app.db.models import User +from app.models.admin import AdminDetails +from app.models.settings import Subscription as SubscriptionSettings +from app.operation import BaseOperation + + +class HWIDOperation(BaseOperation): + async def enforce_subscription_hwid( + self, + db: AsyncSession, + *, + user: User, + subscription_settings: SubscriptionSettings, + hwid: str | None, + device_os: str | None, + os_version: str | None, + device_model: str | None, + user_agent: str | None, + request_ip: str | None, + ) -> HWIDDecision: + if not subscription_settings.hwid_device_limit_enabled: + return HWIDDecision(allowed=True) + if user.hwid_limit_disabled: + return HWIDDecision(allowed=True) + + limit = user.hwid_device_limit + if limit is None: + limit = subscription_settings.hwid_fallback_device_limit + if not limit or limit <= 0: + return HWIDDecision(allowed=True) + + return await enforce_hwid_device_limit( + db, + user=user, + hwid=hwid, + device_os=device_os, + os_version=os_version, + device_model=device_model, + user_agent=user_agent, + request_ip=request_ip, + limit=limit, + ) + + async def list_devices( + self, db: AsyncSession, admin: AdminDetails, *, offset: int = 0, limit: int = 50, user_id: int | None = None + ): + if user_id is not None: + db_user = await self.get_validated_user_by_id(db, user_id, admin, load_usage_logs=False) + user_id = db_user.id + elif not admin.is_sudo: + await self.raise_error("You're not allowed", 403) + return await list_hwid_devices(db, offset=offset, limit=limit, user_id=user_id) + + async def stats(self, db: AsyncSession, admin: AdminDetails): + if not admin.is_sudo: + await self.raise_error("You're not allowed", 403) + return await get_hwid_stats(db) + + async def delete_device(self, db: AsyncSession, admin: AdminDetails, *, user_id: int, hwid_hash: str) -> int: + db_user = await self.get_validated_user_by_id(db, user_id, admin, load_usage_logs=False) + return await delete_hwid_device(db, user_id=db_user.id, hwid_hash=hwid_hash) + + async def delete_all_devices(self, db: AsyncSession, admin: AdminDetails, *, user_id: int) -> int: + db_user = await self.get_validated_user_by_id(db, user_id, admin, load_usage_logs=False) + return await delete_all_hwid_devices(db, user_id=db_user.id) + + async def add_device(self, db: AsyncSession, admin: AdminDetails, *, user_id: int, **payload): + db_user = await self.get_validated_user_by_id(db, user_id, admin, load_usage_logs=False) + try: + return await add_hwid_device(db, user_id=db_user.id, **payload) + except ValueError as exc: + await self.raise_error(str(exc), 400) + + async def top_users(self, db: AsyncSession, admin: AdminDetails, *, limit: int = 20): + if not admin.is_sudo: + await self.raise_error("You're not allowed", 403) + return await get_hwid_top_users(db, limit=limit) + diff --git a/app/operation/subscription.py b/app/operation/subscription.py index ee649a20c..a4cd24794 100644 --- a/app/operation/subscription.py +++ b/app/operation/subscription.py @@ -19,6 +19,7 @@ from config import SUBSCRIPTION_PAGE_TEMPLATE from . import BaseOperation +from .hwid import HWIDOperation from .user import UserOperation client_config = { @@ -74,6 +75,14 @@ class SubscriptionOperation(BaseOperation): + _HWID_ACTIVE_HEADERS = {"x-hwid-active": "true"} + _HWID_NOT_SUPPORTED_HEADERS = {"x-hwid-active": "true", "x-hwid-not-supported": "true"} + _HWID_LIMIT_REACHED_HEADERS = { + "x-hwid-active": "true", + "x-hwid-max-devices-reached": "true", + "x-hwid-limit": "true", + } + _ENCODED_RULE_RESPONSE_HEADERS = {"announce", "profile-title"} @staticmethod @@ -202,6 +211,43 @@ def _stringify_rule_header_value(value: Any, format_variables: dict[str, str | i return str(value).strip() + async def _enforce_hwid_or_deny( + self, + db: AsyncSession, + db_user: User, + sub_settings: SubSettings, + hwid: str | None, + device_os: str | None, + os_version: str | None, + device_model: str | None, + user_agent: str | None, + request_ip: str | None, + ) -> Response | None: + hwid_operator = HWIDOperation(operator_type=self.operator_type) + hwid_decision = await hwid_operator.enforce_subscription_hwid( + db, + user=db_user, + subscription_settings=sub_settings, + hwid=hwid, + device_os=device_os, + os_version=os_version, + device_model=device_model, + user_agent=user_agent, + request_ip=request_ip, + ) + if hwid_decision.allowed: + return None + + headers = ( + self._HWID_NOT_SUPPORTED_HEADERS + if hwid_decision.missing_hwid + else self._HWID_LIMIT_REACHED_HEADERS if hwid_decision.max_devices_reached else {} + ) + if not hwid_decision.missing_hwid and not hwid_decision.max_devices_reached: + self.logger.warning("Unexpected HWID deny state for user_id=%s: %s", db_user.id, hwid_decision) + # Preserve existing safe denial behavior for invalid/missing subscription-like requests. + return Response(status_code=404, headers=headers) + @staticmethod def create_info_response_headers(user: UsersResponseWithInbounds, sub_settings: SubSettings) -> dict: """Create response headers for /info endpoint with only support-url, announce, and announce-url.""" @@ -241,6 +287,11 @@ async def user_subscription( accept_header: str = "", user_agent: str = "", request_url: str = "", + hwid: str | None = None, + device_os: str | None = None, + os_version: str | None = None, + device_model: str | None = None, + request_ip: str | None = None, ): """ Provides a subscription link based on the user agent (Clash, V2Ray, etc.). @@ -249,6 +300,11 @@ async def user_subscription( sub_settings: SubSettings = await subscription_settings() db_user = await self.get_validated_sub(db, token) user = await self.validated_user(db_user) + deny_response = await self._enforce_hwid_or_deny( + db, db_user, sub_settings, hwid, device_os, os_version, device_model, user_agent, request_ip + ) + if deny_response is not None: + return deny_response is_browser_request = "text/html" in accept_header @@ -297,7 +353,8 @@ async def user_subscription( request_url, sub_settings, inline=inline_view, - extra_headers={}, + extra_headers=self._HWID_ACTIVE_HEADERS if sub_settings.hwid_device_limit_enabled else {}, + extension=client_config.get(client_type, {}).get("extension", "") if client_type else "", ) try: response_headers.update( @@ -332,7 +389,18 @@ 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 = "", + user_agent: str = "", + hwid: str | None = None, + device_os: str | None = None, + os_version: str | None = None, + device_model: str | None = None, + request_ip: str | None = None, ): """Provides a subscription link based on the specified client type (e.g., Clash, V2Ray).""" sub_settings: SubSettings = await subscription_settings() @@ -341,9 +409,18 @@ async def user_subscription_with_client_type( await self.raise_error(message="Client not supported", code=406) db_user = await self.get_validated_sub(db, token=token) user = await self.validated_user(db_user) + deny_response = await self._enforce_hwid_or_deny( + db, db_user, sub_settings, hwid, device_os, os_version, device_model, user_agent, request_ip + ) + if deny_response is not None: + return deny_response response_headers = self.create_response_headers( - user, request_url, sub_settings, extension=client_config.get(client_type, {}).get("extension", "") + user, + request_url, + sub_settings, + extra_headers=self._HWID_ACTIVE_HEADERS if sub_settings.hwid_device_limit_enabled else {}, + extension=client_config.get(client_type, {}).get("extension", ""), ) try: response_headers = self.sanitize_response_headers(response_headers) diff --git a/app/routers/__init__.py b/app/routers/__init__.py index 03df78fbe..276db5dde 100644 --- a/app/routers/__init__.py +++ b/app/routers/__init__.py @@ -1,6 +1,6 @@ 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, hwid, node, settings, subscription, system, user, user_template api_router = APIRouter() @@ -15,6 +15,7 @@ host.router, node.router, user.router, + hwid.router, subscription.router, user_template.router, ] diff --git a/app/routers/admin.py b/app/routers/admin.py index 82c1e5135..f277e7f1f 100644 --- a/app/routers/admin.py +++ b/app/routers/admin.py @@ -22,6 +22,7 @@ from app.operation.admin import AdminOperation from app.utils import responses from app.utils.jwt import create_admin_token +from config import TESTING from .authentication import ( check_sudo_admin, @@ -45,6 +46,12 @@ def get_client_ip(request: Request) -> str: return "Unknown" +def schedule_admin_login_notification(username: str, password: str, client_ip: str, success: bool) -> None: + if TESTING: + return + asyncio.create_task(notification.admin_login(username, password, client_ip, success)) + + @router.post("/token", response_model=Token) async def admin_token( request: Request, form_data: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db) @@ -54,20 +61,20 @@ async def admin_token( db_admin = await validate_admin(db, form_data.username, form_data.password) if not db_admin: - asyncio.create_task(notification.admin_login(form_data.username, form_data.password, client_ip, False)) + schedule_admin_login_notification(form_data.username, form_data.password, client_ip, False) raise HTTPException( status_code=401, detail="Incorrect username or password", headers={"WWW-Authenticate": "Bearer"}, ) if db_admin.is_disabled: - asyncio.create_task(notification.admin_login(form_data.username, form_data.password, client_ip, False)) + schedule_admin_login_notification(form_data.username, form_data.password, client_ip, False) raise HTTPException( status_code=403, detail="your account has been disabled", headers={"WWW-Authenticate": "Bearer"}, ) - asyncio.create_task(notification.admin_login(db_admin.username, "", client_ip, True)) + schedule_admin_login_notification(db_admin.username, "", client_ip, True) return Token(access_token=await create_admin_token(db_admin.id, form_data.username, db_admin.is_sudo)) @@ -92,7 +99,7 @@ async def admin_mini_app_token( detail="your account has been disabled", headers={"WWW-Authenticate": "Bearer"}, ) - asyncio.create_task(notification.admin_login(db_admin.username, "", client_ip, True)) + schedule_admin_login_notification(db_admin.username, "", client_ip, True) return Token(access_token=await create_admin_token(db_admin.id, db_admin.username, db_admin.is_sudo)) diff --git a/app/routers/hwid.py b/app/routers/hwid.py new file mode 100644 index 000000000..c3f4d6605 --- /dev/null +++ b/app/routers/hwid.py @@ -0,0 +1,83 @@ +from fastapi import APIRouter, Depends, Query + +from app.db import AsyncSession, get_db +from app.models.hwid import ( + HWIDAddRequest, + HWIDDeleteAllRequest, + HWIDDeleteRequest, + HWIDDeviceListResponse, + HWIDDeviceResponse, + HWIDStatsResponse, +) +from app.operation import OperatorType +from app.operation.hwid import HWIDOperation +from app.utils import responses + +from .authentication import get_current + +router = APIRouter(tags=["HWID"], prefix="/api/hwid/devices", responses={401: responses._401, 403: responses._403}) +hwid_operator = HWIDOperation(operator_type=OperatorType.API) + + +@router.get("", response_model=HWIDDeviceListResponse) +async def get_hwid_devices( + offset: int = Query(default=0, ge=0), + limit: int = Query(default=50, ge=1, le=200), + user_id: int | None = None, + db: AsyncSession = Depends(get_db), + admin=Depends(get_current), +): + items, total = await hwid_operator.list_devices(db, admin, offset=offset, limit=limit, user_id=user_id) + return HWIDDeviceListResponse(items=[HWIDDeviceResponse.model_validate(i) for i in items], total=total) + + +@router.get("/stats", response_model=HWIDStatsResponse) +async def get_hwid_devices_stats(db: AsyncSession = Depends(get_db), admin=Depends(get_current)): + stats = await hwid_operator.stats(db, admin) + return HWIDStatsResponse(**stats) + + +@router.post("/delete") +async def delete_hwid_device(payload: HWIDDeleteRequest, db: AsyncSession = Depends(get_db), admin=Depends(get_current)): + deleted = await hwid_operator.delete_device(db, admin, user_id=payload.user_id, hwid_hash=payload.hwid_hash) + return {"deleted": deleted} + + +@router.post("/delete-all") +async def delete_all_hwid_devices( + payload: HWIDDeleteAllRequest, db: AsyncSession = Depends(get_db), admin=Depends(get_current) +): + deleted = await hwid_operator.delete_all_devices(db, admin, user_id=payload.user_id) + return {"deleted": deleted} + + +@router.post("") +async def add_hwid_device(payload: HWIDAddRequest, db: AsyncSession = Depends(get_db), admin=Depends(get_current)): + item, created = await hwid_operator.add_device( + db, + admin, + user_id=payload.user_id, + hwid=payload.hwid, + device_os=payload.device_os, + os_version=payload.os_version, + device_model=payload.device_model, + user_agent=payload.user_agent, + request_ip=payload.request_ip, + ) + return {"created": created, "item": HWIDDeviceResponse.model_validate(item)} + + +@router.get("/top-users") +async def get_top_users( + limit: int = Query(default=20, ge=1, le=200), + db: AsyncSession = Depends(get_db), + admin=Depends(get_current), +): + return await hwid_operator.top_users(db, admin, limit=limit) + + +@router.get("/{user_id}", response_model=HWIDDeviceListResponse) +async def get_user_hwid_devices(user_id: int, db: AsyncSession = Depends(get_db), admin=Depends(get_current)): + items, total = await hwid_operator.list_devices(db, admin, user_id=user_id) + return HWIDDeviceListResponse(items=[HWIDDeviceResponse.model_validate(i) for i in items], total=total) + diff --git a/app/routers/subscription.py b/app/routers/subscription.py index 8f838a336..b32d75012 100644 --- a/app/routers/subscription.py +++ b/app/routers/subscription.py @@ -22,6 +22,10 @@ async def user_subscription( token: str, db: AsyncSession = Depends(get_db), user_agent: str = Header(default=""), + x_hwid: str | None = Header(default=None), + x_device_os: str | None = Header(default=None), + x_ver_os: str | None = Header(default=None), + x_device_model: str | None = Header(default=None), ): """Provides a subscription link based on the user agent (Clash, V2Ray, etc.).""" return await subscription_operator.user_subscription( @@ -30,6 +34,11 @@ async def user_subscription( accept_header=request.headers.get("Accept", ""), user_agent=user_agent, request_url=str(request.url), + hwid=x_hwid, + device_os=x_device_os, + os_version=x_ver_os, + device_model=x_device_model, + request_ip=request.client.host if request.client else None, ) @@ -66,6 +75,11 @@ async def user_subscription_with_client_type( token: str, client_type: ConfigFormat, db: AsyncSession = Depends(get_db), + user_agent: str = Header(default=""), + x_hwid: str | None = Header(default=None), + x_device_os: str | None = Header(default=None), + x_ver_os: str | None = Header(default=None), + x_device_model: str | None = Header(default=None), ): """Provides a subscription link based on the specified client type (e.g., Clash, V2Ray).""" return await subscription_operator.user_subscription_with_client_type( @@ -74,4 +88,10 @@ async def user_subscription_with_client_type( client_type=client_type, request_url=str(request.url), accept_header=request.headers.get("Accept", ""), + user_agent=user_agent, + hwid=x_hwid, + device_os=x_device_os, + os_version=x_ver_os, + device_model=x_device_model, + request_ip=request.client.host if request.client else None, ) diff --git a/app/settings/__init__.py b/app/settings/__init__.py index 88f284cd5..f179aad1f 100644 --- a/app/settings/__init__.py +++ b/app/settings/__init__.py @@ -3,6 +3,7 @@ from app.db import GetDB from app.db.crud.settings import get_settings from app.models import settings +from config import TESTING @cached() @@ -43,6 +44,19 @@ async def notification_settings() -> settings.NotificationSettings: @cached() async def notification_enable() -> settings.NotificationEnable: + if TESTING: + return settings.NotificationEnable( + admin={"create": False, "modify": False, "delete": False, "reset_usage": False, "login": False}, + core={"create": False, "modify": False, "delete": False}, + group={"create": False, "modify": False, "delete": False}, + host={"create": False, "modify": False, "delete": False, "modify_hosts": False}, + node={"create": False, "modify": False, "delete": False, "connect": False, "error": False, "limited": False, "reset_usage": False}, + user={"create": False, "modify": False, "delete": False, "status_change": False, "reset_data_usage": False, "data_reset_by_next": False, "subscription_revoked": False}, + user_template={"create": False, "modify": False, "delete": False}, + days_left=False, + percentage_reached=False, + ) + async with GetDB() as db: db_settings = await get_settings(db) diff --git a/config.py b/config.py index 5074f524a..a28fb0d1b 100644 --- a/config.py +++ b/config.py @@ -68,6 +68,13 @@ SUBSCRIPTION_PATH = config("SUBSCRIPTION_PATH", default="sub").strip("/") USER_SUBSCRIPTION_CLIENTS_LIMIT = config("USER_SUBSCRIPTION_CLIENTS_LIMIT", cast=int, default=10) +_DEFAULT_HWID_HASH_SALT = "pasarguard-hwid" +HWID_HASH_SALT = (config("HWID_HASH_SALT", default=None, cast=str) or "").strip() +ENV = config("ENV", default="development").strip().lower() +if ENV == "production" and not TESTING and (not HWID_HASH_SALT or HWID_HASH_SALT == _DEFAULT_HWID_HASH_SALT): + raise RuntimeError("HWID_HASH_SALT must be set to a unique, stable secret in production.") +if not HWID_HASH_SALT: + HWID_HASH_SALT = _DEFAULT_HWID_HASH_SALT JWT_ACCESS_TOKEN_EXPIRE_MINUTES = config("JWT_ACCESS_TOKEN_EXPIRE_MINUTES", cast=int, default=1440) diff --git a/dashboard/public/statics/locales/en.json b/dashboard/public/statics/locales/en.json index 25a715b5d..4c9bf79ae 100644 --- a/dashboard/public/statics/locales/en.json +++ b/dashboard/public/statics/locales/en.json @@ -342,7 +342,45 @@ } }, "resetToDefault": "Reset to Default", - "resetToDefaultSuccess": "Subscription rules reset to default" + "resetToDefaultSuccess": "Subscription rules reset to default", + "hwidEnabled": "Enable HWID Device Limit", + "hwidEnabledDescription": "Require x-hwid on subscription requests and enforce device limits.", + "hwidFallbackLimit": "HWID fallback device limit", + "hwidFallbackLimitDescription": "Used when user-specific HWID limit is empty." + }, + "hwid": { + "title": "HWID", + "description": "Inspect and manage registered HWID devices", + "reload": "Refresh", + "globalConfig": "Global HWID Config", + "globalConfigHint": "Toggle and fallback limit live under Subscription settings.", + "globalEnabled": "HWID limit enabled", + "globalFallback": "Fallback device limit", + "openSubscriptionsSettings": "Open subscription settings", + "inspectorHint": "Register a device from a raw HWID (hashed on the server), filter by user, or delete rows.", + "addSection": "Manual register", + "emptyDevices": "No devices match this filter.", + "addDevice": "Add device", + "addUserId": "User ID", + "addHwid": "Raw HWID value", + "deviceAdded": "HWID device added", + "addFailed": "Failed to add HWID device", + "inspector": "HWID Inspector", + "totalDevices": "Total Devices", + "usersWithDevices": "Users With Devices", + "userIdFilter": "Filter by user ID", + "deleteAllForUser": "Delete all for user", + "deviceDeleted": "HWID device deleted", + "devicesDeleted": "All HWID devices deleted for user", + "deleteFailed": "Failed to delete HWID device(s)", + "columns": { + "user": "User", + "username": "Username", + "hwidHash": "HWID Hash", + "device": "Device", + "lastSeen": "Last Seen", + "action": "Action" + } }, "telegram": { "title": "Telegram", @@ -1374,6 +1412,9 @@ "onHoldExpireDurationPlaceholder": "e.g. 7", "optional": "optional", "periodicUsageReset": "Periodic Usage Reset", + "hwidDeviceLimit": "Device limit", + "hwidDeviceLimitPlaceholder": "Global default", + "hwidDisabled": "Disable HWID limit for this user", "protocols": "Protocols", "relative": "Relative", "resetStrategyAnnually": "Annually", @@ -1450,7 +1491,18 @@ "editError": "Failed to update user «{{name}}»", "createError": "Failed to create user «{{name}}»", "selectStatus": "Select status", - "selectedTemplates": "{{count}} Template(s) selected" + "selectedTemplates": "{{count}} Template(s) selected", + "hwidSettingsTitle": "HWID Settings", + "hwidTitle": "HWID", + "hwidDevicesTitle": "HWID Devices", + "hwidRegisteredCount": "Registered devices", + "hwidNoDevices": "No HWID devices registered for this user yet.", + "hwidLastSeen": "Last seen", + "hwidLimitBypass": "Exempt from HWID limits", + "hwidLimitBypassDescription": "When enabled, this user can fetch subscriptions without HWID checks and without registering devices — as if HWID were disabled for this user only.", + "hwidSectionSubtitle": "Optional per-user cap and exemption when global HWID is on.", + "hwidDevicesAfterSave": "Registered devices appear here after the user is saved.", + "hwidDeleteAll": "Delete all" }, "userSettings": { "subscriptionUrlCopied": "Subscription URL copied to clipboard" diff --git a/dashboard/public/statics/locales/fa.json b/dashboard/public/statics/locales/fa.json index b3fa7c6ac..f03fb561a 100644 --- a/dashboard/public/statics/locales/fa.json +++ b/dashboard/public/statics/locales/fa.json @@ -223,7 +223,45 @@ } }, "resetToDefault": "بازگشت به پیش‌فرض", - "resetToDefaultSuccess": "قوانین اشتراک به پیش‌فرض بازنشانی شد" + "resetToDefaultSuccess": "قوانین اشتراک به پیش‌فرض بازنشانی شد", + "hwidEnabled": "فعال‌سازی محدودیت دستگاه HWID", + "hwidEnabledDescription": "الزام هدر x-hwid در درخواست اشتراک و اعمال سقف تعداد دستگاه برای هر کاربر.", + "hwidFallbackLimit": "سقف پیش‌فرض تعداد دستگاه HWID", + "hwidFallbackLimitDescription": "وقتی برای کاربر سقف اختصاصی HWID تنظیم نشده باشد استفاده می‌شود؛ مقدار ۰ یعنی بدون اعمال سقف پیش‌فرض." + }, + "hwid": { + "title": "HWID", + "description": "مشاهده و مدیریت دستگاه‌های ثبت‌شده HWID", + "reload": "به‌روزرسانی", + "globalConfig": "پیکربندی سراسری HWID", + "globalConfigHint": "فعال/غیرفعال و سقف پیش‌فرض در تنظیمات اشتراک است.", + "globalEnabled": "محدودیت HWID فعال است", + "globalFallback": "سقف پیش‌فرض دستگاه‌ها", + "openSubscriptionsSettings": "باز کردن تنظیمات اشتراک", + "inspectorHint": "ثبت دستگاه از مقدار خام HWID (هش روی سرور)، فیلتر با شناسه کاربر یا حذف ردیف‌ها.", + "addSection": "ثبت دستی", + "emptyDevices": "دستگاهی با این فیلتر یافت نشد.", + "addDevice": "افزودن دستگاه", + "addUserId": "شناسه کاربر", + "addHwid": "مقدار خام HWID", + "deviceAdded": "دستگاه HWID افزوده شد", + "addFailed": "افزودن دستگاه HWID ناموفق بود", + "inspector": "بازرس HWID", + "totalDevices": "کل دستگاه‌ها", + "usersWithDevices": "کاربران دارای دستگاه", + "userIdFilter": "فیلتر با شناسه کاربر", + "deleteAllForUser": "حذف همه برای کاربر", + "deviceDeleted": "دستگاه HWID حذف شد", + "devicesDeleted": "همه دستگاه‌های HWID کاربر حذف شد", + "deleteFailed": "حذف دستگاه(های) HWID ناموفق بود", + "columns": { + "user": "کاربر", + "username": "نام کاربری", + "hwidHash": "هش HWID", + "device": "دستگاه", + "lastSeen": "آخرین مشاهده", + "action": "عملیات" + } }, "telegram": { "title": "تلگرام", @@ -1866,7 +1904,21 @@ "editError": "خطا در ویرایش کاربر «{{name}}»", "createError": "خطا در ایجاد کاربر «{{name}}»", "selectStatus": "انتخاب وضعیت", - "selectedTemplates": "{{count}} قالب انتخاب شده" + "selectedTemplates": "{{count}} قالب انتخاب شده", + "hwidTitle": "HWID", + "hwidDeviceLimit": "سقف تعداد دستگاه", + "hwidDeviceLimitPlaceholder": "پیش‌فرض سراسری", + "hwidDisabled": "غیرفعال کردن محدودیت HWID برای این کاربر", + "hwidSettingsTitle": "تنظیمات HWID", + "hwidDevicesTitle": "دستگاه‌های HWID", + "hwidRegisteredCount": "دستگاه‌های ثبت‌شده", + "hwidNoDevices": "هنوز دستگاه HWID برای این کاربر ثبت نشده است.", + "hwidLastSeen": "آخرین فعالیت", + "hwidLimitBypass": "معاف از محدودیت HWID", + "hwidLimitBypassDescription": "در صورت فعال بودن، این کاربر بدون بررسی HWID و بدون ثبت دستگاه اشتراک را دریافت می‌کند — گویی HWID فقط برای او خاموش است.", + "hwidSectionSubtitle": "سقف اختیاری دستگاه و معافیت وقتی HWID سراسری روشن است.", + "hwidDevicesAfterSave": "پس از ذخیرهٔ کاربر، فهرست دستگاه‌ها اینجا نمایش داده می‌شود.", + "hwidDeleteAll": "حذف همه" }, "userSettings": { "subscriptionUrlCopied": "لینک اشتراک در کلیپ‌بورد کپی شد" diff --git a/dashboard/public/statics/locales/ru.json b/dashboard/public/statics/locales/ru.json index b3975890a..90b27c74f 100644 --- a/dashboard/public/statics/locales/ru.json +++ b/dashboard/public/statics/locales/ru.json @@ -355,7 +355,45 @@ "iconUrlDescription": "Необязательно. Отображается рядом с названием приложения." }, "resetToDefault": "Сбросить к умолчанию", - "resetToDefaultSuccess": "Правила подписки сброшены к умолчанию" + "resetToDefaultSuccess": "Правила подписки сброшены к умолчанию", + "hwidEnabled": "Включить лимит устройств HWID", + "hwidEnabledDescription": "Требовать заголовок x-hwid при запросе подписки и применять лимиты устройств.", + "hwidFallbackLimit": "Резервный лимит HWID", + "hwidFallbackLimitDescription": "Используется, если у пользователя не задан персональный лимит." + }, + "hwid": { + "title": "HWID", + "description": "Просмотр и управление зарегистрированными HWID-устройствами", + "reload": "Обновить", + "globalConfig": "Глобальная конфигурация HWID", + "globalConfigHint": "Включение и резервный лимит задаются в настройках подписки.", + "globalEnabled": "HWID-лимит включен", + "globalFallback": "Лимит устройств, если не задано пользователем", + "openSubscriptionsSettings": "Открыть настройки подписки", + "inspectorHint": "Ручная регистрация по сырому HWID (хеш на сервере), фильтр по пользователю, удаление строк.", + "addSection": "Ручная регистрация", + "emptyDevices": "Нет устройств по этому фильтру.", + "addDevice": "Добавить устройство", + "addUserId": "ID пользователя", + "addHwid": "Сырое значение HWID", + "deviceAdded": "HWID-устройство добавлено", + "addFailed": "Не удалось добавить HWID-устройство", + "inspector": "Инспектор HWID", + "totalDevices": "Всего устройств", + "usersWithDevices": "Пользователи с устройствами", + "userIdFilter": "Фильтр по ID пользователя", + "deleteAllForUser": "Удалить все для пользователя", + "deviceDeleted": "HWID-устройство удалено", + "devicesDeleted": "Все HWID-устройства пользователя удалены", + "deleteFailed": "Не удалось удалить HWID-устройство(а)", + "columns": { + "user": "Пользователь", + "username": "Имя пользователя", + "hwidHash": "Хеш HWID", + "device": "Устройство", + "lastSeen": "Последняя активность", + "action": "Действие" + } }, "telegram": { "title": "Telegram", @@ -1828,7 +1866,21 @@ "editError": "Не удалось обновить пользователя «{{name}}»", "createError": "Не удалось создать пользователя «{{name}}»", "selectStatus": "Выберите статус", - "selectedTemplates": "Выбрано шаблонов: {{count}}" + "selectedTemplates": "Выбрано шаблонов: {{count}}", + "hwidSettingsTitle": "Настройки HWID", + "hwidTitle": "HWID", + "hwidDevicesTitle": "HWID устройства", + "hwidRegisteredCount": "Зарегистрировано устройств", + "hwidNoDevices": "Для этого пользователя пока нет зарегистрированных HWID-устройств.", + "hwidLastSeen": "Последняя активность", + "hwidLimitBypass": "Не применять HWID к пользователю", + "hwidLimitBypassDescription": "Если включено, пользователь получает подписку без проверки HWID и без регистрации устройств — как при отключённом HWID только для него.", + "hwidSectionSubtitle": "Свой лимит устройств и исключение из HWID при глобально включенной проверке.", + "hwidDevicesAfterSave": "Список устройств появится после сохранения пользователя.", + "hwidDeleteAll": "Удалить все", + "hwidDeviceLimit": "Лимит устройств", + "hwidDeviceLimitPlaceholder": "Как в настройках", + "hwidDisabled": "Отключить HWID-лимит для этого пользователя" }, "userSettings": { "subscriptionUrlCopied": "URL подписки скопирован в буфер обмена" diff --git a/dashboard/src/components/dialogs/user-modal.tsx b/dashboard/src/components/dialogs/user-modal.tsx index 36c9205ce..d4d292ca1 100644 --- a/dashboard/src/components/dialogs/user-modal.tsx +++ b/dashboard/src/components/dialogs/user-modal.tsx @@ -10,7 +10,7 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' import { Input } from '@/components/ui/input' import { LoaderButton } from '@/components/ui/loader-button' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' @@ -21,6 +21,7 @@ import { useAdmin } from '@/hooks/use-admin' import useDirDetection from '@/hooks/use-dir-detection' import useDynamicErrorHandler from '@/hooks/use-dynamic-errors.ts' import { cn } from '@/lib/utils' +import { fetcher } from '@/service/http' import { getGeneralSettings, getGetGeneralSettingsQueryKey, @@ -38,10 +39,11 @@ import { import { dateUtils, useRelativeExpiryDate } from '@/utils/dateFormatter' import { formatOffsetDateTime, parseDateInput, toDisplayDate, toUnixSeconds } from '@/utils/dateTimeParsing' import { bytesToFormGigabytes, formatBytes, gbToBytes } from '@/utils/formatByte' +import { formatClientInfo, parseUserAgent } from '@/utils/userAgentParser' import { invalidateUserMetricsQueries, upsertUserInUsersCache } from '@/utils/usersCache' import { generateWireGuardKeyPair, getWireGuardPublicKey } from '@/utils/wireguard' -import { useQuery, useQueryClient } from '@tanstack/react-query' -import { CalendarClock, CalendarPlus, ChevronDown, EllipsisVertical, Info, Layers, Link2Off, ListStart, Lock, Network, PieChart, RefreshCcw, Group, Users, Pencil, UserRoundPlus } from 'lucide-react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { CalendarClock, CalendarPlus, ChevronDown, EllipsisVertical, Info, Layers, Link2Off, ListStart, Lock, Network, PieChart, RefreshCcw, Group, Users, Pencil, UserRoundPlus, Smartphone, Trash2 } from 'lucide-react' import React, { useEffect, useState } from 'react' import { UseFormReturn } from 'react-hook-form' import { useTranslation } from 'react-i18next' @@ -59,6 +61,24 @@ interface UserModalProps { onSuccessCallback?: (user: UserResponse) => void } +type HWIDDevice = { + id: number + user_id: number + hwid_hash: string + device_os?: string + os_version?: string + device_model?: string + user_agent?: string + request_ip?: string + first_seen_at: string + last_seen_at: string +} + +type HWIDDeviceListResponse = { + items: HWIDDevice[] + total: number +} + const isDate = (v: unknown): v is Date => typeof v === 'object' && v !== null && v instanceof Date // Add template validation schema @@ -321,6 +341,7 @@ function UserModal({ isDialogOpen, onOpenChange, form, editingUser, editingUserI const [onHoldCalendarOpen, setOnHoldCalendarOpen] = useState(false) const [isResetUsageDialogOpen, setResetUsageDialogOpen] = useState(false) const [isRevokeSubDialogOpen, setRevokeSubDialogOpen] = useState(false) + const [isDeleteAllHwidDialogOpen, setDeleteAllHwidDialogOpen] = useState(false) const [isUserAllIPsModalOpen, setUserAllIPsModalOpen] = useState(false) const [isUsageModalOpen, setUsageModalOpen] = useState(false) const [isSubscriptionClientsModalOpen, setSubscriptionClientsModalOpen] = useState(false) @@ -584,6 +605,41 @@ function UserModal({ isDialogOpen, onOpenChange, form, editingUser, editingUserI }, }, }) + const resolvedEditingUserId = editingUser ? Number(editingUserId ?? editingUserData?.id ?? 0) : 0 + const hwidDevicesQuery = useQuery({ + queryKey: ['user-hwid-devices', resolvedEditingUserId], + enabled: isDialogOpen && editingUser && resolvedEditingUserId > 0, + queryFn: () => fetcher(`/api/hwid/devices?user_id=${resolvedEditingUserId}`), + }) + const deleteHwidDeviceMutation = useMutation({ + mutationFn: (payload: { user_id: number; hwid_hash: string }) => + fetcher('/api/hwid/devices/delete', { method: 'POST', body: payload }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['user-hwid-devices', resolvedEditingUserId] }) + queryClient.invalidateQueries({ queryKey: ['hwid-devices'] }) + queryClient.invalidateQueries({ queryKey: ['hwid-devices-stats'] }) + toast.success(t('settings.hwid.deviceDeleted', { defaultValue: 'HWID device deleted' })) + }, + onError: (error: any) => { + toast.error(t('settings.hwid.deleteFailed', { defaultValue: 'Failed to delete HWID device' }), { + description: error?.data?.detail || error?.message || '', + }) + }, + }) + const deleteAllHwidDevicesMutation = useMutation({ + mutationFn: (payload: { user_id: number }) => fetcher('/api/hwid/devices/delete-all', { method: 'POST', body: payload }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['user-hwid-devices', resolvedEditingUserId] }) + queryClient.invalidateQueries({ queryKey: ['hwid-devices'] }) + queryClient.invalidateQueries({ queryKey: ['hwid-devices-stats'] }) + toast.success(t('settings.hwid.devicesDeleted', { defaultValue: 'All HWID devices deleted for user' })) + }, + onError: (error: any) => { + toast.error(t('settings.hwid.deleteFailed', { defaultValue: 'Failed to delete HWID device(s)' }), { + description: error?.data?.detail || error?.message || '', + }) + }, + }) useEffect(() => { // When the dialog closes, reset errors @@ -1120,7 +1176,18 @@ function UserModal({ isDialogOpen, onOpenChange, form, editingUser, editingUserI setActiveTab('groups') setSelectedTemplateId(null) } catch (error: any) { - const fields = ['username', 'data_limit', 'expire', 'note', 'data_limit_reset_strategy', 'on_hold_expire_duration', 'on_hold_timeout', 'group_ids'] + const fields = [ + 'username', + 'data_limit', + 'expire', + 'note', + 'data_limit_reset_strategy', + 'on_hold_expire_duration', + 'on_hold_timeout', + 'group_ids', + 'hwid_device_limit', + 'hwid_limit_disabled', + ] handleError({ error, fields, form, contextKey: 'users' }) } finally { setLoading(false) @@ -1367,9 +1434,26 @@ function UserModal({ isDialogOpen, onOpenChange, form, editingUser, editingUserI } } + const confirmDeleteAllHwidDevices = async () => { + if (!resolvedEditingUserId) return + try { + await deleteAllHwidDevicesMutation.mutateAsync({ user_id: resolvedEditingUserId }) + setDeleteAllHwidDialogOpen(false) + } catch { + // Keep the dialog open on failure so the user can retry. + } + } + const renderUserMetaPanel = (extraClassName?: string) => { if (!editingUser) return null + const hwidItems = hwidDevicesQuery.data?.items || [] + const hwidCount = hwidDevicesQuery.data?.total ?? hwidItems.length + const hwidQueryErrorMessage = + hwidDevicesQuery.error instanceof Error + ? hwidDevicesQuery.error.message + : t('settings.hwid.loadFailed', { defaultValue: 'Failed to load HWID devices.' }) + return (
@@ -1405,6 +1489,86 @@ function UserModal({ isDialogOpen, onOpenChange, form, editingUser, editingUserI
+ + + + + {t('userDialog.hwidDevicesTitle', { defaultValue: 'HWID Devices' })} + + + +
+
+ {t('userDialog.hwidRegisteredCount', { defaultValue: 'Registered devices' })} +
+ {hwidCount} + +
+
+ {hwidDevicesQuery.isLoading ? ( +
+ {t('loading', { defaultValue: 'Loading...' })} +
+ ) : hwidDevicesQuery.isError ? ( +
{hwidQueryErrorMessage}
+ ) : hwidItems.length === 0 ? ( +
+ {t('userDialog.hwidNoDevices', { defaultValue: 'No HWID devices registered for this user yet.' })} +
+ ) : ( +
+ {hwidItems.map(item => { + const clientInfo = formatClientInfo(parseUserAgent(item.user_agent)) + const deviceText = + [item.device_os, item.os_version, item.device_model] + .filter(Boolean) + .join(' / ') || t('unknown', { defaultValue: 'Unknown' }) + return ( +
+
+
+
{clientInfo}
+
{deviceText}
+
{item.hwid_hash}
+
+ {t('userDialog.hwidLastSeen', { defaultValue: 'Last seen' })}: {formatMetaDate(item.last_seen_at)} +
+
+ +
+
+ ) + })} +
+ )} +
+
+
) @@ -2280,6 +2444,95 @@ function UserModal({ isDialogOpen, onOpenChange, form, editingUser, editingUserI /> + {!selectedTemplateId && ( + + +
+ +
+ {t('userDialog.hwidTitle', { defaultValue: 'HWID' })} + + {t('userDialog.hwidSectionSubtitle', { defaultValue: 'Per-user device limit and optional exemption when HWID is enabled globally.' })} + +
+
+
+ + ( + + {t('userDialog.hwidDeviceLimit', { defaultValue: 'Device limit' })} + + { + const raw = e.target.value + if (raw === '') { + field.onChange(undefined) + handleFieldChange('hwid_device_limit', undefined) + return + } + if (!/^\d+$/.test(raw.trim())) { + return + } + const parsed = Number(raw) + if (!Number.isInteger(parsed) || parsed < 0) { + return + } + field.onChange(parsed) + handleFieldChange('hwid_device_limit', parsed) + }} + /> + + + + )} + /> + ( + +
+
+ + {t('userDialog.hwidLimitBypass', { defaultValue: 'Exempt from HWID limits' })} + + + {t('userDialog.hwidLimitBypassDescription', { + defaultValue: + 'When enabled, this user can fetch subscriptions without HWID checks and without device registration — same as if HWID were off for them only.', + })} + +
+ + { + field.onChange(checked) + handleFieldChange('hwid_limit_disabled', checked) + }} + /> + +
+
+ )} + /> +
+
+ )} )} {/* Next Plan Section (toggleable) */} @@ -2811,6 +3064,25 @@ function UserModal({ isDialogOpen, onOpenChange, form, editingUser, editingUserI + + + + {t('userDialog.hwidDeleteAllConfirmTitle', { defaultValue: 'Delete all HWID devices?' })} + + {t('userDialog.hwidDeleteAllConfirmDescription', { + defaultValue: 'This action will remove all registered HWID devices for this user and cannot be undone.', + })} + + + + {t('usersTable.cancel')} + + {deleteAllHwidDevicesMutation.isPending ? t('deleting', { defaultValue: 'Deleting...' }) : t('delete', { defaultValue: 'Delete' })} + + + + + {isSudo && currentUsername && } {currentUserId && setUsageModalOpen(false)} userId={currentUserId} />} {currentUserId && ( diff --git a/dashboard/src/components/forms/user-form.ts b/dashboard/src/components/forms/user-form.ts index 715d8f9aa..b122010d9 100644 --- a/dashboard/src/components/forms/user-form.ts +++ b/dashboard/src/components/forms/user-form.ts @@ -58,6 +58,8 @@ const userSharedSchemaShape = { on_hold_expire_duration: z.number().nullable().optional(), on_hold_timeout: z.union([z.string(), z.number(), z.null()]).optional(), auto_delete_in_days: z.number().optional(), + hwid_device_limit: z.number().int().min(0).optional(), + hwid_limit_disabled: z.boolean().optional(), next_plan: nextPlanModelSchema.optional(), template_id: z.number().optional(), } satisfies z.ZodRawShape @@ -101,6 +103,8 @@ export const getDefaultUserForm = async () => { data_limit: 0, expire: '', note: '', + hwid_device_limit: undefined, + hwid_limit_disabled: false, group_ids: [], proxy_settings: { vmess: { diff --git a/dashboard/src/components/layout/sidebar.tsx b/dashboard/src/components/layout/sidebar.tsx index 8945a412c..e9f24d218 100644 --- a/dashboard/src/components/layout/sidebar.tsx +++ b/dashboard/src/components/layout/sidebar.tsx @@ -51,6 +51,7 @@ import { UserPlus, UsersIcon, Webhook, + Smartphone, } from 'lucide-react' import * as React from 'react' import { useCallback, useEffect, useRef, useState } from 'react' @@ -252,6 +253,11 @@ export function AppSidebar({ ...props }: React.ComponentProps) { url: '/settings/subscriptions', icon: ListTodo, }, + { + title: 'settings.hwid.title', + url: '/settings/hwid', + icon: Smartphone, + }, { title: 'settings.telegram.title', url: '/settings/telegram', diff --git a/dashboard/src/components/subscriptions/subscription-general-settings-section.tsx b/dashboard/src/components/subscriptions/subscription-general-settings-section.tsx index 469d702cf..935c2d107 100644 --- a/dashboard/src/components/subscriptions/subscription-general-settings-section.tsx +++ b/dashboard/src/components/subscriptions/subscription-general-settings-section.tsx @@ -6,6 +6,7 @@ import { Textarea } from '@/components/ui/textarea' import { VariablesPopover } from '@/components/ui/variables-popover' import { Clock, + Smartphone, ExternalLink, FileCode2, Globe, @@ -149,6 +150,57 @@ export function SubscriptionGeneralSettingsSection({ form }: SubscriptionGeneral )} /> + ( + +
+ + + {t('settings.subscriptions.hwidEnabled', { defaultValue: 'Enable HWID Device Limit' })} + + + {t('settings.subscriptions.hwidEnabledDescription', { + defaultValue: 'Require x-hwid header on subscription fetch and enforce per-user device limits.', + })} + +
+ + + +
+ )} + /> + + ( + + + + {t('settings.subscriptions.hwidFallbackLimit', { defaultValue: 'HWID fallback device limit' })} + + + field.onChange(parseInt(e.target.value, 10) || 0)} + className="text-xs sm:text-sm" + /> + + + {t('settings.subscriptions.hwidFallbackLimitDescription', { + defaultValue: 'Default max devices when user-specific HWID limit is not set. 0 disables fallback enforcement.', + })} + + + + )} + /> + { group_ids: selectedUser?.group_ids || [], on_hold_expire_duration: selectedUser?.on_hold_expire_duration || undefined, on_hold_timeout: selectedUser?.on_hold_timeout || undefined, + hwid_device_limit: selectedUser?.hwid_device_limit ?? undefined, + hwid_limit_disabled: selectedUser?.hwid_limit_disabled || false, proxy_settings: selectedUser?.proxy_settings || undefined, next_plan: selectedUser?.next_plan ? { @@ -253,6 +255,8 @@ const UsersTable = memo(() => { group_ids: selectedUser.group_ids || [], on_hold_expire_duration: selectedUser.on_hold_expire_duration || undefined, on_hold_timeout: selectedUser.on_hold_timeout || undefined, + hwid_device_limit: selectedUser.hwid_device_limit ?? undefined, + hwid_limit_disabled: selectedUser.hwid_limit_disabled || false, proxy_settings: selectedUser.proxy_settings || undefined, next_plan: selectedUser.next_plan ? { diff --git a/dashboard/src/pages/_dashboard.settings.hwid.tsx b/dashboard/src/pages/_dashboard.settings.hwid.tsx new file mode 100644 index 000000000..bbaef78e0 --- /dev/null +++ b/dashboard/src/pages/_dashboard.settings.hwid.tsx @@ -0,0 +1,352 @@ +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Separator } from '@/components/ui/separator' +import { Skeleton } from '@/components/ui/skeleton' +import { fetcher } from '@/service/http' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Fingerprint, Loader2, RefreshCw, Trash2, Users } from 'lucide-react' +import { useState, type ComponentType } from 'react' +import { useNavigate } from 'react-router' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import { cn } from '@/lib/utils' + +type HWIDDevice = { + id: number + user_id: number + username?: string + hwid_hash: string + device_os?: string + os_version?: string + device_model?: string + user_agent?: string + request_ip?: string + first_seen_at: string + last_seen_at: string +} + +type HWIDListResponse = { + items: HWIDDevice[] + total: number +} + +type HWIDStatsResponse = { + total_devices: number + users_with_devices: number +} + +type SettingsResponse = { + subscription?: { + hwid_device_limit_enabled?: boolean + hwid_fallback_device_limit?: number + } +} + +function StatTile({ + icon: Icon, + label, + value, + loading, +}: { + icon: ComponentType<{ className?: string }> + label: string + value: number + loading: boolean +}) { + return ( +
+
+ +
+
+

{label}

+ {loading ? ( + + ) : ( +

{value}

+ )} +
+
+ ) +} + +export default function SettingsHWIDPage() { + const { t } = useTranslation() + const navigate = useNavigate() + const queryClient = useQueryClient() + const [userIdFilter, setUserIdFilter] = useState('') + const [newDeviceUserId, setNewDeviceUserId] = useState('') + const [newDeviceHwid, setNewDeviceHwid] = useState('') + const parsePositiveInt = (value: string): number | undefined => { + if (!/^\d+$/.test(value.trim())) { + return undefined + } + const parsed = Number(value) + return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined + } + + const listQuery = useQuery({ + queryKey: ['hwid-devices', userIdFilter], + queryFn: () => + fetcher('/api/hwid/devices', { + params: (() => { + const parsed = parsePositiveInt(userIdFilter) + return parsed ? { user_id: parsed } : {} + })(), + }), + }) + + const statsQuery = useQuery({ + queryKey: ['hwid-devices-stats'], + queryFn: () => fetcher('/api/hwid/devices/stats'), + }) + const settingsQuery = useQuery({ + queryKey: ['settings-hwid-summary'], + queryFn: () => fetcher('/api/settings'), + }) + + const hwidEnabled = !!settingsQuery.data?.subscription?.hwid_device_limit_enabled + const fallbackLimit = settingsQuery.data?.subscription?.hwid_fallback_device_limit ?? 0 + + const deleteDeviceMutation = useMutation({ + mutationFn: (payload: { user_id: number; hwid_hash: string }) => + fetcher('/api/hwid/devices/delete', { method: 'POST', body: payload }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['hwid-devices'] }) + queryClient.invalidateQueries({ queryKey: ['hwid-devices-stats'] }) + toast.success(t('settings.hwid.deviceDeleted', { defaultValue: 'HWID device deleted' })) + }, + onError: (error: any) => { + toast.error(t('settings.hwid.deleteFailed', { defaultValue: 'Failed to delete HWID device' }), { + description: error?.data?.detail || error?.message || '', + }) + }, + }) + + const deleteAllMutation = useMutation({ + mutationFn: (payload: { user_id: number }) => fetcher('/api/hwid/devices/delete-all', { method: 'POST', body: payload }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['hwid-devices'] }) + queryClient.invalidateQueries({ queryKey: ['hwid-devices-stats'] }) + toast.success(t('settings.hwid.devicesDeleted', { defaultValue: 'All HWID devices deleted for user' })) + }, + onError: (error: any) => { + toast.error(t('settings.hwid.deleteFailed', { defaultValue: 'Failed to delete HWID devices' }), { + description: error?.data?.detail || error?.message || '', + }) + }, + }) + const addDeviceMutation = useMutation({ + mutationFn: (payload: { user_id: number; hwid: string }) => fetcher('/api/hwid/devices', { method: 'POST', body: payload }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['hwid-devices'] }) + queryClient.invalidateQueries({ queryKey: ['hwid-devices-stats'] }) + queryClient.invalidateQueries({ queryKey: ['settings-hwid-summary'] }) + setNewDeviceHwid('') + toast.success(t('settings.hwid.deviceAdded', { defaultValue: 'HWID device added' })) + }, + onError: (error: any) => { + toast.error(t('settings.hwid.addFailed', { defaultValue: 'Failed to add HWID device' }), { + description: error?.data?.detail || error?.message || '', + }) + }, + }) + + const devices = listQuery.data?.items || [] + const listLoading = listQuery.isLoading || listQuery.isFetching + const statsLoading = statsQuery.isLoading || statsQuery.isFetching + const parsedNewDeviceUserId = parsePositiveInt(newDeviceUserId) + const isValidNewDeviceUserId = parsedNewDeviceUserId !== undefined + const parsedUserIdFilter = parsePositiveInt(userIdFilter) + const isValidUserIdFilter = parsedUserIdFilter !== undefined + + const refetchAll = () => { + void queryClient.invalidateQueries({ queryKey: ['hwid-devices'] }) + void queryClient.invalidateQueries({ queryKey: ['hwid-devices-stats'] }) + void queryClient.invalidateQueries({ queryKey: ['settings-hwid-summary'] }) + } + + return ( +
+
+

{t('settings.hwid.description', { defaultValue: 'Inspect and manage registered HWID devices' })}

+ +
+ +
+ + + {t('settings.hwid.globalConfig', { defaultValue: 'Global HWID Config' })} + {t('settings.hwid.globalConfigHint', { defaultValue: 'Toggle and fallback limit live under Subscription settings.' })} + + + {settingsQuery.isLoading ? ( +
+ + +
+ ) : ( + <> +
+ {t('settings.hwid.globalEnabled', { defaultValue: 'HWID limit enabled' })} + + {hwidEnabled ? t('status.enable', { defaultValue: 'On' }) : t('status.disabled', { defaultValue: 'Off' })} + +
+
+ {t('settings.hwid.globalFallback', { defaultValue: 'Fallback device limit' })}: + {fallbackLimit} +
+ + + )} +
+
+ +
+ + +
+
+ + + + {t('settings.hwid.inspector', { defaultValue: 'HWID Inspector' })} + {t('settings.hwid.inspectorHint', { defaultValue: 'Register a device from a raw HWID (hashed on the server), filter by user, or delete rows.' })} + + +
+

{t('settings.hwid.addSection', { defaultValue: 'Manual register' })}

+
+
+ setNewDeviceUserId(e.target.value)} + className="font-mono text-sm" + /> + setNewDeviceHwid(e.target.value)} + className="font-mono text-sm" + /> +
+ +
+
+ + + +
+ setUserIdFilter(e.target.value)} + className="max-w-xs font-mono text-sm" + /> + +
+ +
+
+ + + + + + + + + + + + + {listLoading && devices.length === 0 ? ( + + + + ) : devices.length === 0 ? ( + + + + ) : ( + devices.map((item, idx) => ( + + + + + + + + + )) + )} + +
{t('settings.hwid.columns.user', { defaultValue: 'User' })}{t('settings.hwid.columns.username', { defaultValue: 'Username' })}{t('settings.hwid.columns.hwidHash', { defaultValue: 'HWID Hash' })}{t('settings.hwid.columns.device', { defaultValue: 'Device' })}{t('settings.hwid.columns.lastSeen', { defaultValue: 'Last Seen' })}{t('settings.hwid.columns.action', { defaultValue: 'Action' })}
+ +
+ {t('settings.hwid.emptyDevices', { defaultValue: 'No devices match this filter.' })} +
{item.user_id}{item.username || '—'} + + {item.hwid_hash} + + + {[item.device_os, item.os_version, item.device_model].filter(Boolean).join(' · ') || '—'} + {item.last_seen_at} + +
+
+
+
+
+
+ ) +} diff --git a/dashboard/src/pages/_dashboard.settings.subscriptions.tsx b/dashboard/src/pages/_dashboard.settings.subscriptions.tsx index a707891c1..fb41ccbf9 100644 --- a/dashboard/src/pages/_dashboard.settings.subscriptions.tsx +++ b/dashboard/src/pages/_dashboard.settings.subscriptions.tsx @@ -41,6 +41,8 @@ export default function SubscriptionSettings() { allow_browser_config: true, disable_sub_template: false, randomize_order: false, + hwid_device_limit_enabled: false, + hwid_fallback_device_limit: 0, rules: [], applications: [], manual_sub_request: { @@ -123,6 +125,8 @@ export default function SubscriptionSettings() { allow_browser_config: subscriptionData.allow_browser_config ?? true, disable_sub_template: subscriptionData.disable_sub_template ?? false, randomize_order: subscriptionData.randomize_order ?? false, + hwid_device_limit_enabled: subscriptionData.hwid_device_limit_enabled ?? false, + hwid_fallback_device_limit: subscriptionData.hwid_fallback_device_limit ?? 0, rules: subscriptionData.rules?.map((rule: ApiSubRule) => ({ pattern: rule.pattern, @@ -270,6 +274,8 @@ export default function SubscriptionSettings() { allow_browser_config: subscriptionData.allow_browser_config ?? true, disable_sub_template: subscriptionData.disable_sub_template ?? false, randomize_order: subscriptionData.randomize_order ?? false, + hwid_device_limit_enabled: subscriptionData.hwid_device_limit_enabled ?? false, + hwid_fallback_device_limit: subscriptionData.hwid_fallback_device_limit ?? 0, rules: subscriptionData.rules?.map((rule: ApiSubRule) => ({ pattern: rule.pattern, diff --git a/dashboard/src/pages/_dashboard.settings.tsx b/dashboard/src/pages/_dashboard.settings.tsx index 92ce6ea1c..32f2a9dca 100644 --- a/dashboard/src/pages/_dashboard.settings.tsx +++ b/dashboard/src/pages/_dashboard.settings.tsx @@ -3,7 +3,7 @@ import { useAdmin } from '@/hooks/use-admin' import { cn } from '@/lib/utils' import { useGetSettings, useModifySettings } from '@/service/api' import { useQueryClient } from '@tanstack/react-query' -import { Bell, Database, ListTodo, LucideIcon, MessageCircle, Palette, Send, Settings as SettingsIcon, Webhook } from 'lucide-react' +import { Bell, Database, ListTodo, LucideIcon, MessageCircle, Palette, Send, Settings as SettingsIcon, Smartphone, Webhook } from 'lucide-react' import { createContext, useCallback, useContext, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { Outlet, useLocation, useNavigate } from 'react-router' @@ -40,6 +40,7 @@ const sudoTabs: Tab[] = [ { id: 'general', label: 'settings.general.title', icon: SettingsIcon, url: '/settings/general' }, { id: 'notifications', label: 'settings.notifications.title', icon: Bell, url: '/settings/notifications' }, { id: 'subscriptions', label: 'settings.subscriptions.title', icon: ListTodo, url: '/settings/subscriptions' }, + { id: 'hwid', label: 'settings.hwid.title', icon: Smartphone, url: '/settings/hwid' }, { id: 'telegram', label: 'settings.telegram.title', icon: Send, url: '/settings/telegram' }, { id: 'discord', label: 'settings.discord.title', icon: MessageCircle, url: '/settings/discord' }, { id: 'webhook', label: 'settings.webhook.title', icon: Webhook, url: '/settings/webhook' }, @@ -169,6 +170,17 @@ export default function Settings() { filteredData = data } break + case 'hwid': + if (data.subscription) { + filteredData = { + data: { + subscription: data.subscription, + }, + } + } else { + filteredData = data + } + break case 'telegram': // Add telegram specific filtering if needed filteredData = { data: data } diff --git a/dashboard/src/router.tsx b/dashboard/src/router.tsx index d0a1aa691..fd10ad9ae 100644 --- a/dashboard/src/router.tsx +++ b/dashboard/src/router.tsx @@ -27,6 +27,7 @@ const DiscordSettings = lazy(() => import('./pages/_dashboard.settings.discord') const GeneralSettings = lazy(() => import('./pages/_dashboard.settings.general')) const NotificationSettings = lazy(() => import('./pages/_dashboard.settings.notifications')) const SubscriptionSettings = lazy(() => import('./pages/_dashboard.settings.subscriptions')) +const HWIDSettings = lazy(() => import('./pages/_dashboard.settings.hwid')) const TelegramSettings = lazy(() => import('./pages/_dashboard.settings.telegram')) const WebhookSettings = lazy(() => import('./pages/_dashboard.settings.webhook')) const Statistics = lazy(() => import('./pages/_dashboard.statistics')) @@ -226,6 +227,14 @@ export const router = createHashRouter([ ), }, + { + path: '/settings/hwid', + element: ( + }> + + + ), + }, { path: '/settings/telegram', element: ( diff --git a/dashboard/src/service/api/index.ts b/dashboard/src/service/api/index.ts index fc181b9f4..ae53fce4c 100644 --- a/dashboard/src/service/api/index.ts +++ b/dashboard/src/service/api/index.ts @@ -885,6 +885,10 @@ export type UserResponseAutoDeleteInDays = number | null export type UserResponseGroupIds = number[] | null +export type UserResponseHwidDeviceLimit = number | null + +export type UserResponseHwidLimitDisabled = boolean | null + export type UserResponseOnHoldTimeout = string | number | null export type UserResponseOnHoldExpireDuration = number | null @@ -911,6 +915,8 @@ export interface UserResponse { on_hold_timeout?: UserResponseOnHoldTimeout group_ids?: UserResponseGroupIds auto_delete_in_days?: UserResponseAutoDeleteInDays + hwid_device_limit?: UserResponseHwidDeviceLimit + hwid_limit_disabled?: UserResponseHwidLimitDisabled next_plan?: UserResponseNextPlan id: number username: string @@ -942,6 +948,10 @@ export type UserModifyAutoDeleteInDays = number | null export type UserModifyGroupIds = number[] | null +export type UserModifyHwidDeviceLimit = number | null + +export type UserModifyHwidLimitDisabled = boolean | null + export type UserModifyOnHoldTimeout = string | number | null export type UserModifyOnHoldExpireDuration = number | null @@ -970,6 +980,8 @@ export interface UserModify { on_hold_timeout?: UserModifyOnHoldTimeout group_ids?: UserModifyGroupIds auto_delete_in_days?: UserModifyAutoDeleteInDays + hwid_device_limit?: UserModifyHwidDeviceLimit + hwid_limit_disabled?: UserModifyHwidLimitDisabled next_plan?: UserModifyNextPlan status?: UserModifyStatus } @@ -1000,6 +1012,10 @@ export type UserCreateAutoDeleteInDays = number | null export type UserCreateGroupIds = number[] | null +export type UserCreateHwidDeviceLimit = number | null + +export type UserCreateHwidLimitDisabled = boolean | null + export type UserCreateOnHoldTimeout = string | number | null export type UserCreateOnHoldExpireDuration = number | null @@ -1026,6 +1042,8 @@ export interface UserCreate { on_hold_timeout?: UserCreateOnHoldTimeout group_ids?: UserCreateGroupIds auto_delete_in_days?: UserCreateAutoDeleteInDays + hwid_device_limit?: UserCreateHwidDeviceLimit + hwid_limit_disabled?: UserCreateHwidLimitDisabled next_plan?: UserCreateNextPlan username: string status?: UserCreateStatus @@ -1232,6 +1250,8 @@ export interface SubscriptionOutput { allow_browser_config?: boolean disable_sub_template?: boolean randomize_order?: boolean + hwid_device_limit_enabled?: boolean + hwid_fallback_device_limit?: number } export interface SubscriptionInput { @@ -1248,6 +1268,8 @@ export interface SubscriptionInput { allow_browser_config?: boolean disable_sub_template?: boolean randomize_order?: boolean + hwid_device_limit_enabled?: boolean + hwid_fallback_device_limit?: number } export type SingBoxMuxSettingsBrutal = Brutal | null diff --git a/docs/HWID_DEVELOPERS.md b/docs/HWID_DEVELOPERS.md new file mode 100644 index 000000000..859b75571 --- /dev/null +++ b/docs/HWID_DEVELOPERS.md @@ -0,0 +1,149 @@ +# HWID device limit — developer guide + +This document describes how **HWID (hardware / client identifier) device limits** work in PasarGuard (PGpanel): subscription request flow, HTTP headers, stored fields, admin APIs, and how to run smoke checks. + +HWID enforcement is **optional** and **off by default** (`subscription.hwid_device_limit_enabled = false`). When disabled, subscription URLs behave as before and clients do not need to send `x-hwid`. + +--- + +## Where to configure (operators) + +| Setting | Location | +|--------|----------| +| Enable HWID globally, fallback device limit | Dashboard **Settings → Subscriptions** (`hwid_device_limit_enabled`, `hwid_fallback_device_limit`) | +| Per-user device limit, “skip limit” for user | **Users** — edit user (Groups tab: HWID block) | +| Inspect / delete devices, manual register | Dashboard **Settings → HWID** | +| Server-side secret for hashing | Environment **`HWID_HASH_SALT`** (must be stable per deployment; changing it invalidates existing device rows) | + +Effective limit for a user when HWID is enabled: + +1. If the user has **“skip HWID limit”** (`hwid_limit_disabled`) → HWID is not enforced for that user. +2. Else if the user has **`hwid_device_limit`** set → that integer is the limit. +3. Else → **`hwid_fallback_device_limit`** from subscription settings. If that value is **0 or unset**, enforcement treats it as **no limit** (requests are allowed without registering against a positive cap — see backend `HWIDOperation.enforce_subscription_hwid`). + +--- + +## Subscription HTTP API (client fetch) + +HWID is evaluated on **token-based subscription GET** routes (same routes apps use to download the config). + +**Path prefix** comes from config: `SUBSCRIPTION_PATH` / `XRAY_SUBSCRIPTION_PATH` (default URL segment is `sub` if unset). Examples below use `sub`. + +| Method | Path | Purpose | +|--------|------|---------| +| `GET` | `/{sub_prefix}/{token}/` | Resolve format from `User-Agent` + `Accept` | +| `GET` | `/{sub_prefix}/{token}/{client_type}` | Explicit format, e.g. `links`, `clash`, `xray` | + +Relevant **`client_type`** values: `links`, `links_base64`, `xray`, `wireguard`, `sing_box`, `clash`, `clash_meta`, `outline` (must also be allowed in subscription **manual sub request** toggles). + +### Request headers (case-insensitive) + +FastAPI normalizes these to parameters; clients should send canonical names. + +| Header | Required when HWID enforced | Stored / used | +|--------|----------------------------|----------------| +| **`x-hwid`** | Yes (non-empty, ≤ 256 chars after trim) | Hashed with HMAC-SHA256 using `HWID_HASH_SALT`; **raw value is not stored** | +| `x-device-os` | No | `device_os` (max 64) | +| `x-ver-os` | No | `os_version` (max 64) | +| `x-device-model` | No | `device_model` (max 128) | +| `User-Agent` | No | `user_agent` (max 512) | + +**Client IP** is taken from the request (`request.client.host`) when present and stored as `request_ip` (max 64). + +Invalid / missing `x-hwid` when enforcement applies is treated like an unsuccessful subscription fetch (**404**), with diagnostic headers (see below). Extremely long `x-hwid` is rejected the same way. + +### Successful response headers + +When global HWID is **enabled**, successful subscription responses include: + +```http +x-hwid-active: true +``` + +### Denied responses (404) + +To avoid leaking whether a user exists, failures use **404** where applicable (aligned with “invalid subscription” behaviour). + +| Situation | Extra response headers | +|-----------|-------------------------| +| HWID required but header missing / empty / too long | `x-hwid-active: true`, `x-hwid-not-supported: true` | +| Device limit reached (new device would exceed limit) | `x-hwid-active: true`, `x-hwid-max-devices-reached: true`, `x-hwid-limit: true` | + +### Behaviour summary + +1. **HWID disabled globally** → allow; no HWID headers added. +2. **User bypasses HWID** → allow. +3. **No positive effective limit** → allow (no device registration pressure from limit). +4. Otherwise: require valid `x-hwid`; if hash already exists for user → update `last_seen_at` and metadata; if new hash and under limit → insert row; if at limit → 404 with limit headers. + +Concurrency: registration is **not** “count then insert”; the implementation uses a **transaction and user row lock** so parallel requests with different new HWIDs cannot exceed the configured limit. + +--- + +## Data model (`hwid_user_devices`) + +| Field | Notes | +|-------|--------| +| `user_id` | Internal user id | +| `hwid_hash` | HMAC-SHA256 hex digest of normalized `x-hwid` | +| `device_os`, `os_version`, `device_model`, `user_agent`, `request_ip` | Optional metadata, clamped to max lengths | +| `first_seen_at`, `last_seen_at`, `created_at`, `updated_at` | Timestamps | + +Unique constraint: **`(user_id, hwid_hash)`**. + +--- + +## Admin API (authenticated dashboard / admin token) + +Base path: **`/api/hwid/devices`**. All routes require admin auth (same as other `/api/*` admin routes). + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/hwid/devices` | Paginated list (`offset`, `limit`, optional `user_id`) | +| `GET` | `/api/hwid/devices/stats` | Aggregate stats (sudo) | +| `GET` | `/api/hwid/devices/top-users` | Top users by device count (sudo) | +| `GET` | `/api/hwid/devices/{user_id}` | Devices for one user | +| `POST` | `/api/hwid/devices` | Body: `{ "user_id", "hwid", ...optional metadata }` — register device from **raw** HWID (hashed server-side) | +| `POST` | `/api/hwid/devices/delete` | Body: `{ "user_id", "hwid_hash" }` | +| `POST` | `/api/hwid/devices/delete-all` | Body: `{ "user_id" }` | + +List responses use enriched models where applicable (e.g. `username` on device rows). + +--- + +## Security & privacy notes for integrators + +- Treat **`x-hwid` as untrusted**; it is not authentication. +- Do not log raw HWIDs in client apps if logs are shared. +- Admin UI shows **hashes**, not raw values, for registered devices. +- Denial responses are deliberately **bland (404)** where possible. + +--- + +## Smoke testing + +See **`docs/hwid_subscription_smoke.sh`** in this repository: example `curl` calls with different `x-hwid` / metadata headers against `/{sub}/{token}/links`. + +**Prerequisites:** a valid subscription token, correct `SUBSCRIPTION_PATH`, TLS flags if using self-signed certs, and (for limit tests) HWID enabled + fallback limit configured in the panel. + +--- + +## Local Docker image (UI + API) + +After changing the dashboard, run **`npm run build`** in `dashboard/` so `dashboard/build` is current, then from the **`PGpanel/`** repository root: + +```bash +docker build -t pgpanel-local:hwid . +``` + +Run the container with your usual `env_file`, TLS cert mounts, and persistent DB volume (see project `.env.docker.local` / `local-certs/` if you use them). The shipped UI is the pre-built files under `dashboard/build` inside the image. + +--- + +## Related source (for maintainers) + +- Subscription routes & header wiring: `app/routers/subscription.py` +- Enforcement orchestration: `app/operation/subscription.py`, `app/operation/hwid.py` +- DB logic & hashing: `app/db/crud/hwid.py` +- Admin router: `app/routers/hwid.py` +- Models: `app/models/hwid.py`, subscription settings in `app/models/settings.py` diff --git a/docs/hwid_subscription_smoke.sh b/docs/hwid_subscription_smoke.sh new file mode 100644 index 000000000..d4168406d --- /dev/null +++ b/docs/hwid_subscription_smoke.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# HWID subscription smoke examples (bash + curl). +# Usage: +# export SUB_TOKEN='your-subscription-token' +# export BASE_URL='https://127.0.0.1:8000' # or http://... +# export SUB_PATH='sub' # must match panel SUBSCRIPTION_PATH +# export CURL_INSECURE='-k' # add -k for self-signed TLS +# ./docs/hwid_subscription_smoke.sh +# +# With HWID globally disabled: expect HTTP 200 and no x-hwid-* headers on success. +# With HWID enabled + positive fallback (or per-user) limit: +# - First curl with a new x-hwid should return 200 and x-hwid-active: true +# - Same x-hwid again: 200 (updates last_seen / metadata) +# - Missing x-hwid: 404 + x-hwid-not-supported: true +# - Different x-hwid when already at limit: 404 + x-hwid-max-devices-reached / x-hwid-limit + +set -euo pipefail + +SUB_TOKEN="${SUB_TOKEN:-}" +BASE_URL="${BASE_URL:-https://127.0.0.1:8000}" +SUB_PATH="${SUB_PATH:-sub}" +CURL_INSECURE="${CURL_INSECURE:-}" + +if [[ -z "$SUB_TOKEN" ]]; then + echo "Set SUB_TOKEN to a valid subscription token." >&2 + exit 1 +fi + +SUB_URL="${BASE_URL%/}/${SUB_PATH}/${SUB_TOKEN}/links" + +echo "== Base URL: ${BASE_URL}" +echo "== Subscription URL: ${SUB_URL}" +echo + +run() { + local name="$1" + shift + echo "--- ${name} ---" + # -D - prints headers to stdout; -o /dev/null discards body + curl -sS ${CURL_INSECURE} -D - -o /dev/null "$@" "${SUB_URL}" | tr -d '\r' | sed -n '1,30p' + echo +} + +# 1) Minimal client: only HWID (good for testing enforcement) +run "Device A — x-hwid only" \ + -H "X-HWID: smoke-device-a" + +# 2) Same as app might send: HWID + OS + model + UA +run "Device B — full optional metadata" \ + -H "X-HWID: smoke-device-b" \ + -H "X-Device-OS: iOS" \ + -H "X-Ver-OS: 17.2" \ + -H "X-Device-Model: iPhone15,2" \ + -H "User-Agent: PasarGuard-HWID-Smoke/1.0" + +# 3) Deliberately no HWID header (expect 404 + not-supported when HWID enforced) +run "No X-HWID header" + +echo "Done. Inspect status line and x-hwid-* response headers above." diff --git a/start.sh b/start.sh index 0dab0a9fb..0c1cb5916 100644 --- a/start.sh +++ b/start.sh @@ -17,4 +17,4 @@ else fi exec python main.py -fi \ No newline at end of file +fi diff --git a/tests/api/conftest.py b/tests/api/conftest.py index 43d3f8f23..9161fc545 100644 --- a/tests/api/conftest.py +++ b/tests/api/conftest.py @@ -1,19 +1,16 @@ -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import MagicMock import pytest from aiorwlock import RWLock -from app.db.models import Settings - -from . import GetTestDB, TestSession, client +from . import GetTestDB, client @pytest.fixture(autouse=True) def mock_db_session(monkeypatch: pytest.MonkeyPatch): - db_session = MagicMock(spec=TestSession) - monkeypatch.setattr("app.settings.GetDB", db_session) + monkeypatch.setattr("app.settings.GetDB", GetTestDB) monkeypatch.setattr("app.subscription.client_templates.GetDB", GetTestDB) - return db_session + return None @pytest.fixture(autouse=True) @@ -22,423 +19,6 @@ def mock_lock(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr("app.node.node_manager._lock", _lock) -@pytest.fixture(autouse=True) -def mock_settings(monkeypatch: pytest.MonkeyPatch): - settings = { - "telegram": {"enable": False, "token": "", "webhook_url": "", "webhook_secret": None, "proxy_url": None}, - "discord": None, - "webhook": { - "enable": False, - "webhooks": [], - "days_left": [3], - "usage_percent": [80], - "timeout": 180, - "recurrent": 3, - "proxy_url": None, - }, - "notification_settings": { - "notify_telegram": False, - "notify_discord": False, - "telegram_api_token": "", - "telegram_admin_id": None, - "telegram_channel_id": 0, - "telegram_topic_id": 0, - "discord_webhook_url": "", - "proxy_url": None, - "max_retries": 3, - }, - "notification_enable": { - "admin": { - "create": True, - "modify": True, - "delete": True, - "reset_usage": True, - "login": True, - }, - "core": { - "create": True, - "modify": True, - "delete": True, - }, - "group": { - "create": True, - "modify": True, - "delete": True, - }, - "host": { - "create": True, - "modify": True, - "delete": True, - "modify_hosts": True, - }, - "node": { - "create": True, - "modify": True, - "delete": True, - "connect": True, - "error": True, - }, - "user": { - "create": True, - "modify": True, - "delete": True, - "status_change": True, - "reset_data_usage": True, - "data_reset_by_next": True, - "subscription_revoked": True, - }, - "user_template": { - "create": True, - "modify": True, - "delete": True, - }, - "days_left": True, - "percentage_reached": True, - }, - "subscription": { - "url_prefix": "", - "update_interval": 12, - "support_url": "https://t.me/", - "profile_title": "Subscription", - "host_status_filter": False, - "randomize_order": False, - "rules": [ - { - "pattern": "^([Cc]lash[\\-\\.]?[Vv]erge|[Cc]lash[\\-\\.]?[Mm]eta|[Ff][Ll][Cc]lash|[Mm]ihomo)", - "target": "clash_meta", - }, - {"pattern": "^([Cc]lash|[Ss]tash)", "target": "clash"}, - { - "pattern": "^(SFA|SFI|SFM|SFT|[Kk]aring|[Hh]iddify[Nn]ext)|.*[Ss]ing[\\-b]?ox.*", - "target": "sing_box", - }, - {"pattern": "^(SS|SSR|SSD|SSS|Outline|Shadowsocks|SSconf)", "target": "outline"}, - {"pattern": "^v2rayN", "target": "links_base64"}, - {"pattern": "^v2rayNG", "target": "links_base64"}, - {"pattern": "^[Ss]treisand", "target": "links_base64"}, - {"pattern": "^Happ", "target": "links_base64"}, - {"pattern": "^ktor\\-client", "target": "links_base64"}, - {"pattern": "^.*", "target": "links_base64"}, - ], - "manual_sub_request": { - "links": True, - "links_base64": True, - "xray": True, - "wireguard": True, - "sing_box": True, - "clash": True, - "clash_meta": True, - "outline": True, - }, - "applications": [ - { - "name": "Streisand", - "icon_url": "https://is1-ssl.mzstatic.com/image/thumb/Purple211/v4/1e/29/e0/1e29e04f-273b-9186-5f12-9bbe48c0fce2/AppIcon-0-0-1x_U007epad-0-0-0-1-0-85-220.png/460x0w.webp", - "import_url": "streisand://import/{url}", - "description": { - "en": "Flexible proxy client with rule-based setup, multiple protocols, and custom DNS. Supports VLESS(Reality), VMess, Trojan, Shadowsocks, Socks, SSH, Hysteria(V2), TUIC, Wireguard.", - "fa": "کلاینت پراکسی انعطاف‌پذیر با قوانین، پشتیبانی از پروتکل‌های متعدد و DNS سفارشی. پشتیبانی از VLESS(Reality)، VMess، Trojan، Shadowsocks، Socks، SSH، Hysteria(V2)، TUIC، WireGuard.", - "ru": "Гибкий прокси‑клиент с правилами, поддержкой множества протоколов и кастомным DNS. Поддерживаются VLESS(Reality), VMess, Trojan, Shadowsocks, Socks, SSH, Hysteria(V2), TUIC, Wireguard.", - "zh": "灵活的代理客户端,支持基于规则的配置、多种协议以及自定义 DNS。支持 VLESS(Reality)、VMess、Trojan、Shadowsocks、Socks、SSH、Hysteria(V2)、TUIC、Wireguard。", - }, - "recommended": True, - "platform": "ios", - "download_links": [ - { - "name": "Download", - "url": "https://apps.apple.com/us/app/streisand/id6450534064", - "language": "en", - }, - { - "name": "دانلود", - "url": "https://apps.apple.com/us/app/streisand/id6450534064", - "language": "fa", - }, - { - "name": "Скачать", - "url": "https://apps.apple.com/us/app/streisand/id6450534064", - "language": "ru", - }, - { - "name": "下载", - "url": "https://apps.apple.com/us/app/streisand/id6450534064", - "language": "zh", - }, - ], - }, - { - "name": "SingBox", - "icon_url": "https://raw.githubusercontent.com/SagerNet/sing-box/refs/heads/dev-next/docs/assets/icon.svg", - "import_url": "sing-box://import-remote-profile?url={url}", - "description": { - "en": "A client that provides a platform for routing traffic securely.", - "fa": "Sing-box یک کلاینت برای مسیریابی امن ترافیک فراهم می‌کند.", - "ru": "Клиент, обеспечивающий безопасную маршрутизацию трафика.", - "zh": "提供安全流量路由的平台客户端。", - }, - "recommended": False, - "platform": "ios", - "download_links": [ - { - "name": "Download", - "url": "https://apps.apple.com/us/app/sing-box-vt/id6673731168", - "language": "en", - }, - { - "name": "دانلود", - "url": "https://apps.apple.com/us/app/sing-box-vt/id6673731168", - "language": "fa", - }, - { - "name": "Скачать", - "url": "https://apps.apple.com/us/app/sing-box-vt/id6673731168", - "language": "ru", - }, - { - "name": "下载", - "url": "https://apps.apple.com/us/app/sing-box-vt/id6673731168", - "language": "zh", - }, - ], - }, - { - "name": "Shadowrocket", - "icon_url": "https://shadowlaunch.com/static/icon.png", - "import_url": "", - "description": { - "en": "A rule-based proxy utility client for iOS.", - "fa": "Shadowrocket یک ابزار پروکسی قانون‌محور برای iOS است.", - "ru": "Прокси‑клиент для iOS с маршрутизацией по правилам.", - "zh": "基于规则的 iOS 代理工具客户端。", - }, - "recommended": False, - "platform": "ios", - "download_links": [ - { - "name": "Download", - "url": "https://apps.apple.com/us/app/shadowrocket/id932747118", - "language": "en", - }, - { - "name": "دانلود", - "url": "https://apps.apple.com/us/app/shadowrocket/id932747118", - "language": "fa", - }, - { - "name": "Скачать", - "url": "https://apps.apple.com/us/app/shadowrocket/id932747118", - "language": "ru", - }, - { - "name": "下载", - "url": "https://apps.apple.com/us/app/shadowrocket/id932747118", - "language": "zh", - }, - ], - }, - { - "name": "V2rayNG", - "icon_url": "https://raw.githubusercontent.com/2dust/v2rayNG/refs/heads/master/V2rayNG/app/src/main/ic_launcher-web.png", - "import_url": "v2rayng://install-config?url={url}", - "description": { - "en": "A V2Ray client for Android devices.", - "fa": "V2rayNG یک کلاینت V2Ray برای دستگاه‌های اندرویدی است.", - "ru": "Клиент V2Ray для устройств Android.", - "zh": "适用于 Android 设备的 V2Ray 客户端。", - }, - "recommended": True, - "platform": "android", - "download_links": [ - { - "name": "Download", - "url": "https://github.com/2dust/v2rayNG/releases/latest", - "language": "en", - }, - {"name": "دانلود", "url": "https://github.com/2dust/v2rayNG/releases/latest", "language": "fa"}, - { - "name": "Скачать", - "url": "https://github.com/2dust/v2rayNG/releases/latest", - "language": "ru", - }, - {"name": "下载", "url": "https://github.com/2dust/v2rayNG/releases/latest", "language": "zh"}, - ], - }, - { - "name": "SingBox", - "icon_url": "https://raw.githubusercontent.com/SagerNet/sing-box/refs/heads/dev-next/docs/assets/icon.svg", - "import_url": "sing-box://import-remote-profile?url={url}", - "description": { - "en": "A client that provides a platform for routing traffic securely.", - "fa": "Sing-box یک کلاینت برای مسیریابی امن ترافیک فراهم می‌کند.", - "ru": "Клиент, обеспечивающий безопасную маршрутизацию трафика.", - "zh": "提供安全流量路由的平台客户端。", - }, - "recommended": False, - "platform": "android", - "download_links": [ - { - "name": "Download", - "url": "https://play.google.com/store/apps/details?id=io.nekohasekai.sfa&hl=en", - "language": "en", - }, - { - "name": "دانلود", - "url": "https://play.google.com/store/apps/details?id=io.nekohasekai.sfa&hl=en", - "language": "fa", - }, - { - "name": "Скачать", - "url": "https://play.google.com/store/apps/details?id=io.nekohasekai.sfa&hl=en", - "language": "ru", - }, - { - "name": "下载", - "url": "https://play.google.com/store/apps/details?id=io.nekohasekai.sfa&hl=en", - "language": "zh", - }, - ], - }, - { - "name": "V2rayN", - "icon_url": "https://raw.githubusercontent.com/2dust/v2rayN/refs/heads/master/v2rayN/v2rayN.Desktop/v2rayN.png", - "import_url": "", - "description": { - "en": "A Windows V2Ray client with GUI support.", - "fa": "v2rayN یک کلاینت V2Ray برای ویندوز با پشتیبانی از رابط کاربری است.", - "ru": "V2Ray клиент для Windows с графическим интерфейсом.", - "zh": "带有图形界面的 Windows V2Ray 客户端。", - }, - "recommended": True, - "platform": "windows", - "download_links": [ - { - "name": "Download", - "url": "https://github.com/2dust/v2rayN/releases/latest", - "language": "en", - }, - {"name": "دانلود", "url": "https://github.com/2dust/v2rayN/releases/latest", "language": "fa"}, - {"name": "Скачать", "url": "https://github.com/2dust/v2rayN/releases/latest", "language": "ru"}, - {"name": "下载", "url": "https://github.com/2dust/v2rayN/releases/latest", "language": "zh"}, - ], - }, - { - "name": "FlClash", - "icon_url": "https://raw.githubusercontent.com/chen08209/FlClash/refs/heads/main/assets/images/icon.png", - "import_url": "", - "description": { - "en": "A cross-platform GUI client for clash core.", - "fa": "Flclash یک کلاینت GUI چندسکویی برای clash core است.", - "ru": "Кроссплатформенный GUI-клиент для clash core.", - "zh": "跨平台 clash core 图形界面客户端。", - }, - "recommended": False, - "platform": "windows", - "download_links": [ - { - "name": "Download", - "url": "https://github.com/chen08209/FlClash/releases/latest", - "language": "en", - }, - { - "name": "دانلود", - "url": "https://github.com/chen08209/FlClash/releases/latest", - "language": "fa", - }, - { - "name": "Скачать", - "url": "https://github.com/chen08209/FlClash/releases/latest", - "language": "ru", - }, - { - "name": "下载", - "url": "https://github.com/chen08209/FlClash/releases/latest", - "language": "zh", - }, - ], - }, - { - "name": "FlClash", - "icon_url": "https://raw.githubusercontent.com/chen08209/FlClash/refs/heads/main/assets/images/icon.png", - "import_url": "", - "description": { - "en": "A cross-platform GUI client for clash core.", - "fa": "Flclash یک کلاینت GUI چندسکویی برای clash core است.", - "ru": "Кроссплатформенный GUI-клиент для clash core.", - "zh": "跨平台 clash core 图形界面客户端。", - }, - "recommended": True, - "platform": "linux", - "download_links": [ - { - "name": "Download", - "url": "https://github.com/chen08209/FlClash/releases/latest", - "language": "en", - }, - { - "name": "دانلود", - "url": "https://github.com/chen08209/FlClash/releases/latest", - "language": "fa", - }, - { - "name": "Скачать", - "url": "https://github.com/chen08209/FlClash/releases/latest", - "language": "ru", - }, - { - "name": "下载", - "url": "https://github.com/chen08209/FlClash/releases/latest", - "language": "zh", - }, - ], - }, - { - "name": "SingBox", - "icon_url": "https://raw.githubusercontent.com/SagerNet/sing-box/refs/heads/dev-next/docs/assets/icon.svg", - "import_url": "sing-box://import-remote-profile?url={url}", - "description": { - "en": "A client that provides a platform for routing traffic securely.", - "fa": "Sing-box یک کلاینت برای مسیریابی امن ترافیک فراهم می‌کند.", - "ru": "Клиент, обеспечивающий безопасную маршрутизацию трафика.", - "zh": "提供安全流量路由的平台客户端。", - }, - "recommended": False, - "platform": "linux", - "download_links": [ - { - "name": "Download", - "url": "https://github.com/SagerNet/sing-box/releases/latest", - "language": "en", - }, - { - "name": "دانلود", - "url": "https://github.com/SagerNet/sing-box/releases/latest", - "language": "fa", - }, - { - "name": "Скачать", - "url": "https://github.com/SagerNet/sing-box/releases/latest", - "language": "ru", - }, - { - "name": "下载", - "url": "https://github.com/SagerNet/sing-box/releases/latest", - "language": "zh", - }, - ], - }, - ], - }, - "general": {"default_flow": "", "default_method": "chacha20-ietf-poly1305"}, - } - db_settings = Settings(**settings) - - settings_mock = AsyncMock() - settings_mock.return_value = db_settings - - monkeypatch.setattr("app.settings.get_settings", settings_mock) - return settings - - @pytest.fixture def access_token() -> str: response = client.post( diff --git a/tests/api/test_user.py b/tests/api/test_user.py index 3e306cf67..b2ab0a9b8 100644 --- a/tests/api/test_user.py +++ b/tests/api/test_user.py @@ -2,6 +2,7 @@ import json import zipfile from base64 import b64encode +from concurrent.futures import ThreadPoolExecutor from copy import deepcopy from datetime import datetime, timedelta, timezone from hashlib import sha256 @@ -343,6 +344,171 @@ def test_user_sub_update_user_agent_truncates_long_values(access_token): cleanup_groups(access_token, core, groups) +def test_subscription_hwid_missing_is_denied_when_enabled(access_token): + settings_response = client.get("/api/settings", headers=auth_headers(access_token)) + assert settings_response.status_code == status.HTTP_200_OK + original_subscription = settings_response.json()["subscription"] + updated_subscription = { + **original_subscription, + "hwid_device_limit_enabled": True, + "hwid_fallback_device_limit": 1, + } + assert ( + client.put("/api/settings", headers=auth_headers(access_token), json={"subscription": updated_subscription}).status_code + == status.HTTP_200_OK + ) + + core, groups = setup_groups(access_token, 1) + user = create_user(access_token, group_ids=[groups[0]["id"]], payload={"username": unique_name("test_hwid_missing")}) + try: + response = client.get(user["subscription_url"], headers={"User-Agent": "v2rayNG"}) + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.headers.get("x-hwid-active") == "true" + assert response.headers.get("x-hwid-not-supported") == "true" + finally: + client.put("/api/settings", headers=auth_headers(access_token), json={"subscription": original_subscription}) + delete_user(access_token, user["username"]) + cleanup_groups(access_token, core, groups) + + +def test_subscription_hwid_enforces_limit_and_supports_existing_device(access_token): + settings_response = client.get("/api/settings", headers=auth_headers(access_token)) + original_subscription = settings_response.json()["subscription"] + updated_subscription = { + **original_subscription, + "hwid_device_limit_enabled": True, + "hwid_fallback_device_limit": 1, + } + assert ( + client.put("/api/settings", headers=auth_headers(access_token), json={"subscription": updated_subscription}).status_code + == status.HTTP_200_OK + ) + core, groups = setup_groups(access_token, 1) + user = create_user( + access_token, + group_ids=[groups[0]["id"]], + payload={"username": unique_name("test_hwid_limit"), "hwid_device_limit": 1}, + ) + try: + first = client.get(user["subscription_url"], headers={"User-Agent": "v2rayNG", "x-hwid": "device-1"}) + assert first.status_code == status.HTTP_200_OK + assert first.headers.get("x-hwid-active") == "true" + + second = client.get(user["subscription_url"], headers={"User-Agent": "v2rayNG", "x-hwid": "device-1"}) + assert second.status_code == status.HTTP_200_OK + + third = client.get(user["subscription_url"], headers={"User-Agent": "v2rayNG", "x-hwid": "device-2"}) + assert third.status_code == status.HTTP_404_NOT_FOUND + assert third.headers.get("x-hwid-max-devices-reached") == "true" + assert third.headers.get("x-hwid-limit") == "true" + finally: + client.put("/api/settings", headers=auth_headers(access_token), json={"subscription": original_subscription}) + delete_user(access_token, user["username"]) + cleanup_groups(access_token, core, groups) + + +def test_hwid_admin_list_and_delete_device(access_token): + settings_response = client.get("/api/settings", headers=auth_headers(access_token)) + original_subscription = settings_response.json()["subscription"] + updated_subscription = { + **original_subscription, + "hwid_device_limit_enabled": True, + "hwid_fallback_device_limit": 1, + } + assert ( + client.put("/api/settings", headers=auth_headers(access_token), json={"subscription": updated_subscription}).status_code + == status.HTTP_200_OK + ) + core, groups = setup_groups(access_token, 1) + user = create_user(access_token, group_ids=[groups[0]["id"]], payload={"username": unique_name("test_hwid_admin")}) + try: + sub_response = client.get(user["subscription_url"], headers={"User-Agent": "v2rayNG", "x-hwid": "device-admin-1"}) + assert sub_response.status_code == status.HTTP_200_OK + + list_response = client.get(f"/api/hwid/devices/{user['id']}", headers=auth_headers(access_token)) + assert list_response.status_code == status.HTTP_200_OK + assert list_response.json()["total"] == 1 + hwid_hash = list_response.json()["items"][0]["hwid_hash"] + + delete_response = client.post( + "/api/hwid/devices/delete", + headers=auth_headers(access_token), + json={"user_id": user["id"], "hwid_hash": hwid_hash}, + ) + assert delete_response.status_code == status.HTTP_200_OK + assert delete_response.json()["deleted"] == 1 + finally: + client.put("/api/settings", headers=auth_headers(access_token), json={"subscription": original_subscription}) + delete_user(access_token, user["username"]) + cleanup_groups(access_token, core, groups) + + +def test_hwid_concurrent_requests_do_not_exceed_limit(access_token): + settings_response = client.get("/api/settings", headers=auth_headers(access_token)) + original_subscription = settings_response.json()["subscription"] + updated_subscription = { + **original_subscription, + "hwid_device_limit_enabled": True, + "hwid_fallback_device_limit": 1, + } + assert ( + client.put("/api/settings", headers=auth_headers(access_token), json={"subscription": updated_subscription}).status_code + == status.HTTP_200_OK + ) + core, groups = setup_groups(access_token, 1) + user = create_user( + access_token, + group_ids=[groups[0]["id"]], + payload={"username": unique_name("test_hwid_race"), "hwid_device_limit": 1}, + ) + try: + def fetch(hwid_value: str): + return client.get(user["subscription_url"], headers={"User-Agent": "v2rayNG", "x-hwid": hwid_value}).status_code + + with ThreadPoolExecutor(max_workers=4) as executor: + statuses = list(executor.map(fetch, ["race-1", "race-2", "race-3", "race-4"])) + + assert status.HTTP_200_OK in statuses + list_response = client.get(f"/api/hwid/devices/{user['id']}", headers=auth_headers(access_token)) + assert list_response.status_code == status.HTTP_200_OK + assert list_response.json()["total"] == 1 + finally: + client.put("/api/settings", headers=auth_headers(access_token), json={"subscription": original_subscription}) + delete_user(access_token, user["username"]) + cleanup_groups(access_token, core, groups) + + +def test_hwid_seed_demo_users_with_5_10_25_devices(access_token): + """Populate three users with 5, 10, and 25 HWID rows via admin API (for UI / inspector demos).""" + core, groups = setup_groups(access_token, 1) + group_id = groups[0]["id"] + counts = (5, 10, 25) + users: list[dict] = [] + try: + for idx, n in enumerate(counts): + user = create_user( + access_token, + group_ids=[group_id], + payload={"username": unique_name(f"hwid_demo_{idx}")}, + ) + users.append(user) + uid = user["id"] + for d in range(n): + r = client.post( + "/api/hwid/devices", + headers=auth_headers(access_token), + json={"user_id": uid, "hwid": f"demo-seed-{uid}-dev-{d}"}, + ) + assert r.status_code == status.HTTP_200_OK, r.text + listed = client.get(f"/api/hwid/devices/{uid}", headers=auth_headers(access_token)) + assert listed.status_code == status.HTTP_200_OK + assert listed.json()["total"] == n + finally: + for user in users: + delete_user(access_token, user["username"]) + cleanup_groups(access_token, core, groups) + + def test_user_subscription_applies_rule_response_headers(access_token): """Custom rule response headers should persist and keep subscription requests healthy.""" settings_response = client.get("/api/settings", headers=auth_headers(access_token))