Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 62 additions & 10 deletions app/db/crud/host.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,20 @@
from sqlalchemy.dialects.mysql import insert as mysql_insert
from sqlalchemy import insert
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload

from app.db.models import ProxyHost, ProxyInbound
from app.models.host import CreateHost, HostListQuery
from app.db.models import HostTag, ProxyHost, ProxyInbound, hosts_tags_association
from app.models.host import CreateHost, HostListQuery, HostTagCreate, HostTagModify


async def resolve_host_tags(db: AsyncSession, tag_ids: list[int]) -> list[HostTag]:
"""Resolve a list of tag ids to HostTag rows, preserving the requested order."""
if not tag_ids:
return []
unique_ids = list(dict.fromkeys(tag_ids))
result = await db.execute(select(HostTag).where(HostTag.id.in_(unique_ids)))
by_id = {tag.id: tag for tag in result.scalars().all()}
return [by_id[tag_id] for tag_id in unique_ids if tag_id in by_id]


async def upsert_inbounds(db: AsyncSession, inbound_tags: list[str]) -> dict[str, ProxyInbound]:
Expand Down Expand Up @@ -121,7 +132,7 @@ async def get_hosts(db: AsyncSession, query: HostListQuery | None = None) -> lis
List[ProxyHost]: List of hosts sorted by the specified option.
"""
query = query or HostListQuery()
stmt = select(ProxyHost).order_by(ProxyHost.priority.asc())
stmt = select(ProxyHost).options(selectinload(ProxyHost.tags)).order_by(ProxyHost.priority.asc())

if query.ids:
stmt = stmt.where(ProxyHost.id.in_(query.ids))
Expand All @@ -145,7 +156,7 @@ async def get_host_by_id(db: AsyncSession, id: int) -> ProxyHost:
Returns:
ProxyHost: The host if found.
"""
stmt = select(ProxyHost).where(ProxyHost.id == id)
stmt = select(ProxyHost).options(selectinload(ProxyHost.tags)).where(ProxyHost.id == id)
result = await db.execute(stmt)
return result.scalar_one_or_none()

Expand All @@ -161,17 +172,17 @@ async def create_host(db: AsyncSession, new_host: CreateHost) -> ProxyHost:
Returns:
ProxyHost: The retrieved or newly created proxy host.
"""
db_host = ProxyHost(**new_host.model_dump(exclude={"inbound_tag", "id"}))
db_host = ProxyHost(**new_host.model_dump(exclude={"inbound_tag", "id", "tags", "tag_ids"}))
db_host.inbound = await get_or_create_inbound(db, new_host.inbound_tag)
db_host.tags = await resolve_host_tags(db, new_host.tag_ids)

db.add(db_host)
await db.commit()
await db.refresh(db_host)
return db_host
return await get_host_by_id(db, db_host.id)


async def modify_host(db: AsyncSession, db_host: ProxyHost, modified_host: CreateHost) -> ProxyHost:
host_data = modified_host.model_dump(exclude={"id", "inbound_tag"})
host_data = modified_host.model_dump(exclude={"id", "inbound_tag", "tags", "tag_ids"})

for key, value in host_data.items():
setattr(db_host, key, value)
Expand All @@ -181,9 +192,49 @@ async def modify_host(db: AsyncSession, db_host: ProxyHost, modified_host: Creat
else:
db_host.inbound = await get_or_create_inbound(db, modified_host.inbound_tag)

db_host.tags = await resolve_host_tags(db, modified_host.tag_ids)

await db.commit()
return await get_host_by_id(db, db_host.id)


async def get_host_tags(db: AsyncSession) -> list[HostTag]:
"""Return all host tags ordered by name."""
result = await db.execute(select(HostTag).order_by(HostTag.name.asc()))
return list(result.scalars().all())


async def get_host_tag_by_id(db: AsyncSession, id: int) -> HostTag | None:
result = await db.execute(select(HostTag).where(HostTag.id == id))
return result.scalar_one_or_none()


async def get_host_tag_by_name(db: AsyncSession, name: str) -> HostTag | None:
result = await db.execute(select(HostTag).where(HostTag.name == name))
return result.scalar_one_or_none()


async def create_host_tag(db: AsyncSession, new_tag: HostTagCreate) -> HostTag:
db_tag = HostTag(name=new_tag.name, color=new_tag.color.value)
db.add(db_tag)
await db.commit()
await db.refresh(db_tag)
return db_tag


async def modify_host_tag(db: AsyncSession, db_tag: HostTag, modified_tag: HostTagModify) -> HostTag:
if modified_tag.name is not None:
db_tag.name = modified_tag.name
if modified_tag.color is not None:
db_tag.color = modified_tag.color.value
await db.commit()
await db.refresh(db_tag)
return db_tag


async def remove_host_tag(db: AsyncSession, db_tag: HostTag) -> None:
await db.delete(db_tag)
await db.commit()
await db.refresh(db_host)
return db_host


async def remove_host(db: AsyncSession, db_host: ProxyHost) -> ProxyHost:
Expand Down Expand Up @@ -213,5 +264,6 @@ async def remove_hosts(db: AsyncSession, host_ids: list[int]) -> None:
if not host_ids:
return

await db.execute(delete(hosts_tags_association).where(hosts_tags_association.c.host_id.in_(host_ids)))
await db.execute(delete(ProxyHost).where(ProxyHost.id.in_(host_ids)))
await db.commit()
45 changes: 45 additions & 0 deletions app/db/migrations/versions/a821c186b426_add_host_tags.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""add host tags

Revision ID: a821c186b426
Revises: a3b4c5d6e7f8
Create Date: 2026-07-01 19:12:23.382022

"""

from alembic import op
import sqlalchemy as sa
import app.db.compiles_types


revision = "a821c186b426"
down_revision = "b6c9d0e1f2a3"
branch_labels = None
depends_on = None


def upgrade() -> None:
op.create_table(
"host_tags",
sa.Column("name", sa.String(length=64), nullable=False),
sa.Column("color", sa.String(length=32), nullable=False),
sa.Column("id", app.db.compiles_types.SqliteCompatibleBigInteger(), autoincrement=True, nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_host_tags")),
sa.UniqueConstraint("name", name=op.f("uq_host_tags_name")),
)
op.create_table(
"hosts_tags_association",
sa.Column("host_id", app.db.compiles_types.SqliteCompatibleBigInteger(), nullable=False),
sa.Column("tag_id", app.db.compiles_types.SqliteCompatibleBigInteger(), nullable=False),
sa.ForeignKeyConstraint(
["host_id"], ["hosts.id"], name=op.f("fk_hosts_tags_association_host_id_hosts"), ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["tag_id"], ["host_tags.id"], name=op.f("fk_hosts_tags_association_tag_id_host_tags"), ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("host_id", "tag_id", name=op.f("pk_hosts_tags_association")),
)


def downgrade() -> None:
op.drop_table("hosts_tags_association")
op.drop_table("host_tags")
23 changes: 23 additions & 0 deletions app/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ def fk_id_table_column(name: str, target: str, **column_kwargs: Any):
fk_id_table_column("groups_id", "groups.id", primary_key=True),
)

hosts_tags_association = Table(
"hosts_tags_association",
Base.metadata,
fk_id_table_column("host_id", "hosts.id", primary_key=True, ondelete="CASCADE"),
fk_id_table_column("tag_id", "host_tags.id", primary_key=True, ondelete="CASCADE"),
)


class AdminStatus(str, Enum):
active = "active"
Expand Down Expand Up @@ -574,6 +581,22 @@ class ProxyHost(Base, IdMixin):
)
wireguard_overrides: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON(none_as_null=True), default=None)
subscription_templates: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON(none_as_null=True), default=None)
tags: Mapped[List["HostTag"]] = relationship(
secondary=hosts_tags_association, back_populates="hosts", init=False, order_by="HostTag.id"
)

@property
def tag_ids(self) -> list[int]:
return [tag.id for tag in self.tags]


class HostTag(Base, IdMixin):
"""User-defined, reusable, color-coded label that can be attached to hosts."""

__tablename__ = "host_tags"
name: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
color: Mapped[str] = mapped_column(String(32), nullable=False)
hosts: Mapped[List["ProxyHost"]] = relationship(secondary=hosts_tags_association, back_populates="tags", init=False)


class System(Base, IdMixin):
Expand Down
53 changes: 53 additions & 0 deletions app/models/host.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,57 @@ def empty_str_to_none(cls, v):
return v


class HostTagColor(str, Enum):
"""Curated, theme-aware palette keys for host tags (rendered on the dashboard)."""

slate = "slate"
red = "red"
orange = "orange"
amber = "amber"
green = "green"
teal = "teal"
sky = "sky"
blue = "blue"
violet = "violet"
pink = "pink"


class HostTag(BaseModel):
id: int | None = Field(default=None)
name: str = Field(min_length=1, max_length=64)
color: HostTagColor

model_config = ConfigDict(from_attributes=True)


class HostTagCreate(BaseModel):
name: str = Field(min_length=1, max_length=64)
color: HostTagColor

@field_validator("name", mode="after")
@classmethod
def strip_name(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("Tag name cannot be empty")
return v


class HostTagModify(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=64)
color: HostTagColor | None = Field(default=None)

@field_validator("name", mode="after")
@classmethod
def strip_name(cls, v: str | None) -> str | None:
if v is None:
return v
v = v.strip()
if not v:
raise ValueError("Tag name cannot be empty")
return v


class BaseHost(BaseModel):
id: int | None = Field(default=None)
remark: str
Expand Down Expand Up @@ -317,6 +368,8 @@ class BaseHost(BaseModel):
verify_peer_cert_by_name: set[str] | None = Field(default_factory=set)
wireguard_overrides: WireGuardHostOverrides | None = None
subscription_templates: SubscriptionTemplates | None = None
tags: list[HostTag] = Field(default_factory=list)
tag_ids: list[int] = Field(default_factory=list)

model_config = ConfigDict(from_attributes=True)

Expand Down
67 changes: 67 additions & 0 deletions app/operation/host.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,38 @@
import asyncio

from sqlalchemy.exc import IntegrityError

from app.db import AsyncSession
from app.db.models import ProxyHost
from app.models.admin import AdminDetails
from app.models.client_template import ClientTemplateType
from app.db.models import HostTag as DBHostTag
from app.models.host import (
BaseHost,
BulkHostSelection,
BulkHostsActionResponse,
CreateHost,
HostListQuery,
HostTag,
HostTagCreate,
HostTagModify,
RemoveHostsResponse,
)
from app.operation import BaseOperation
from app.db.crud.host import (
create_host,
create_host_tag,
get_host_by_id,
get_host_tag_by_id,
get_host_tag_by_name,
get_host_tags,
get_hosts,
modify_host,
modify_host_tag,
remove_host,
remove_host_tag,
remove_hosts,
resolve_host_tags,
)
from app.core.hosts import host_manager
from app.utils.logger import get_logger
Expand Down Expand Up @@ -60,11 +73,23 @@ async def validate_ds_host(self, db: AsyncSession, host: CreateHost, host_id: in
):
return await self.raise_error("download host cannot have a download host", 400, db=db)

async def validate_host_tags(self, db: AsyncSession, tag_ids: list[int]) -> None:
"""Ensure every requested host-tag id actually exists."""
unique_ids = list(dict.fromkeys(tag_ids or []))
if not unique_ids:
return
resolved = await resolve_host_tags(db, unique_ids)
if len(resolved) != len(unique_ids):
found = {tag.id for tag in resolved}
missing = [tag_id for tag_id in unique_ids if tag_id not in found]
await self.raise_error(f"Host tag(s) not found: {missing}", 404, db=db)

async def create_host(self, db: AsyncSession, new_host: CreateHost, admin: AdminDetails) -> BaseHost:
await self.validate_subscription_templates(db, new_host)
await self.validate_ds_host(db, new_host)

await self.check_host_inbound_tags([new_host.inbound_tag])
await self.validate_host_tags(db, new_host.tag_ids)

db_host = await create_host(db, new_host)

Expand All @@ -85,6 +110,7 @@ async def modify_host(

if modified_host.inbound_tag:
await self.check_host_inbound_tags([modified_host.inbound_tag])
await self.validate_host_tags(db, modified_host.tag_ids)

db_host = await self.get_validated_host(db, host_id)

Expand All @@ -110,6 +136,47 @@ async def remove_host(self, db: AsyncSession, host_id: int, admin: AdminDetails)

await host_manager.remove_host(host.id)

async def get_validated_host_tag(self, db: AsyncSession, tag_id: int) -> DBHostTag:
db_tag = await get_host_tag_by_id(db, tag_id)
if not db_tag:
await self.raise_error("Host tag not found", 404, db=db)
return db_tag

async def get_host_tags(self, db: AsyncSession) -> list[HostTag]:
db_tags = await get_host_tags(db)
return [HostTag.model_validate(tag) for tag in db_tags]

async def create_host_tag(self, db: AsyncSession, new_tag: HostTagCreate, admin: AdminDetails) -> HostTag:
if await get_host_tag_by_name(db, new_tag.name):
await self.raise_error("A host tag with this name already exists", 409, db=db)
try:
db_tag = await create_host_tag(db, new_tag)
except IntegrityError:
await self.raise_error("A host tag with this name already exists", 409, db=db)
logger.info(f'Host tag "{db_tag.name}" created by admin "{admin.username}"')
return HostTag.model_validate(db_tag)

async def modify_host_tag(
self, db: AsyncSession, tag_id: int, modified_tag: HostTagModify, admin: AdminDetails
) -> HostTag:
db_tag = await self.get_validated_host_tag(db, tag_id)
if modified_tag.name and modified_tag.name != db_tag.name:
existing = await get_host_tag_by_name(db, modified_tag.name)
if existing and existing.id != db_tag.id:
await self.raise_error("A host tag with this name already exists", 409, db=db)
try:
db_tag = await modify_host_tag(db, db_tag, modified_tag)
except IntegrityError:
await self.raise_error("A host tag with this name already exists", 409, db=db)
logger.info(f'Host tag "{db_tag.name}" ({db_tag.id}) modified by admin "{admin.username}"')
return HostTag.model_validate(db_tag)

async def remove_host_tag(self, db: AsyncSession, tag_id: int, admin: AdminDetails) -> None:
db_tag = await self.get_validated_host_tag(db, tag_id)
name = db_tag.name
await remove_host_tag(db, db_tag)
logger.info(f'Host tag "{name}" ({tag_id}) deleted by admin "{admin.username}"')

async def modify_hosts(
self, db: AsyncSession, modified_hosts: list[CreateHost], admin: AdminDetails
) -> list[BaseHost]:
Expand Down
Loading