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
13 changes: 9 additions & 4 deletions app/core/hosts.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from app.db import GetDB
from app.db.crud.host import get_host_by_id, get_hosts, upsert_inbounds
from app.db.models import ProxyHostSecurity
from app.models.host import BaseHost, TransportSettings, WireGuardHostOverrides
from app.models.host import BaseHost, FinalMask, TransportSettings, WireGuardHostOverrides
from app.models.subscription import (
GRPCTransportConfig,
KCPTransportConfig,
Expand Down Expand Up @@ -187,8 +187,13 @@ async def _prepare_subscription_inbound_data(
if inbound_flow == "none":
inbound_flow = ""

finalmask = inbound_config.get("finalmask")
finalmask_link = json.dumps(finalmask, separators=(",", ":")) if finalmask else None
final_mask_settings = host.final_mask_settings if host.final_mask_settings else inbound_config.get("finalmask")
finalmask_link = None
fms = final_mask_settings
if final_mask_settings:
if isinstance(final_mask_settings, FinalMask):
fms = final_mask_settings.model_dump(by_alias=True)
finalmask_link = json.dumps(fms, separators=(",", ":"))
Comment on lines +194 to +196

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== app/core/hosts.py outline ==\n'
ast-grep outline app/core/hosts.py --view expanded || true

printf '\n== locate FinalMask definition ==\n'
rg -n "class FinalMask|quic_params|quicParams|model_dump\\(" app -S || true

Repository: PasarGuard/panel

Length of output: 11503


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== app/core/hosts.py around finalmask_link ==\n'
sed -n '170,210p' app/core/hosts.py | cat -n

printf '\n== app/models/host.py around FinalMask ==\n'
sed -n '250,275p' app/models/host.py | cat -n

printf '\n== app/subscription/base.py around finalmask read ==\n'
sed -n '165,195p' app/subscription/base.py | cat -n

Repository: PasarGuard/panel

Length of output: 5236


Dump FinalMask with aliases here.

FinalMask.quic_params serializes as quic_params by default, while the inbound finalmask shape uses quicParams. This makes host-generated finalmask_link inconsistent with the data readers expect, and the QUIC params can be dropped on the round trip.

🧰 Tools
🪛 ast-grep (0.44.0)

[info] 194-194: use jsonify instead of json.dumps for JSON output
Context: json.dumps(fms, separators=(",", ":"))
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/core/hosts.py` around lines 193 - 195, The FinalMask serialization in
hosts.py is producing the wrong field names for finalmask_link, so QUIC params
can be lost on round trip. Update the FinalMask handling in the finalmask_link
निर्माण path to dump with aliases when final_mask_settings is a FinalMask, so
FinalMask.quic_params is emitted as quicParams and matches the inbound finalmask
shape expected by readers.


Comment thread
ImMohammad20000 marked this conversation as resolved.
# Network comes from inbound, NOT from checking which transport exists on host!
# Host can have ALL transport configs, inbound determines which one is used
Expand Down Expand Up @@ -388,7 +393,7 @@ async def _prepare_subscription_inbound_data(
use_sni_as_host=host.use_sni_as_host,
fragment_settings=host.fragment_settings.model_dump() if host.fragment_settings else None,
noise_settings=host.noise_settings.model_dump() if host.noise_settings else None,
finalmask=finalmask,
finalmask=final_mask_settings,
finalmask_link=finalmask_link,
priority=host.priority,
status=list(host.status) if host.status else None,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""add final_mask_settings to hosts table

Revision ID: f976bfcf4738
Revises: b6c9d0e1f2a3
Create Date: 2026-05-02 13:46:21.008567

"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = 'f976bfcf4738'
down_revision = 'b6c9d0e1f2a3'
branch_labels = None
depends_on = None
Comment thread
ImMohammad20000 marked this conversation as resolved.


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('hosts', schema=None) as batch_op:
batch_op.add_column(sa.Column('final_mask_settings', sa.JSON(none_as_null=True), nullable=True))

# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('hosts', schema=None) as batch_op:
batch_op.drop_column('final_mask_settings')

# ### end Alembic commands ###
1 change: 1 addition & 0 deletions app/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,7 @@ 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)
final_mask_settings: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON(none_as_null=True), default=None)


class System(Base, IdMixin):
Expand Down
213 changes: 207 additions & 6 deletions app/models/host.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from enum import Enum
from ipaddress import ip_network
from typing import Any

from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator

from app.db.models import ProxyHostALPN, ProxyHostFingerprint, ProxyHostSecurity, UserStatus

Expand Down Expand Up @@ -34,9 +35,21 @@ class ECHQueryStrategy(str, Enum):


class XrayFragmentSettings(BaseModel):
model_config = ConfigDict(extra="allow", populate_by_name=True)

packets: str = Field(pattern=r"^(:?tlshello|[\d-]{1,16})$")
length: str = Field(pattern=r"^[\d-]{1,16}$")
interval: str = Field(pattern=r"^[\d-]{1,16}$")
interval: str = Field(pattern=r"^[\d-]{1,16}$", serialization_alias="delay")
max_split: str | None = Field(default=None, alias="maxSplit")

@model_validator(mode="before")
@classmethod
def delay_to_interval(cls, value):
if isinstance(value, dict) and "delay" in value:
value = {**value}
delay = value.pop("delay")
value.setdefault("interval", delay)
return value


class SingBoxFragmentSettings(BaseModel):
Expand All @@ -51,17 +64,204 @@ class FragmentSettings(BaseModel):


class XrayNoiseSettings(BaseModel):
type: str = Field(pattern=r"^(:?rand|str|base64|hex)$")
packet: str
delay: str = Field(pattern=r"^\d{1,16}(-\d{1,16})?$")
type: str = Field(pattern=r"^$|^(:?rand|array|str|base64|hex)$")
packet: str | list[int] | None = Field(default=None)
delay: str | int | None = Field(default=None)
apply_to: str = Field(default="ip", pattern=r"ip|ipv4|ipv6")
rand_range: str | None = Field(default=None, pattern=r"^\d{1,16}(-\d{1,16})?$")
rand: int | str | None = Field(default=None)
rand_range: str | None = Field(default=None, alias="randRange", pattern=r"^\d{1,16}(-\d{1,16})?$")

model_config = ConfigDict(extra="allow", populate_by_name=True)


class NoiseSettings(BaseModel):
xray: list[XrayNoiseSettings] | None = Field(default=None)


class FinalMaskBaseModel(BaseModel):
model_config = ConfigDict(extra="allow", populate_by_name=True, use_enum_values=True)


class FinalMaskTcpType(str, Enum):
header_custom = "header-custom"
fragment = "fragment"
sudoku = "sudoku"


class FinalMaskUdpType(str, Enum):
header_custom = "header-custom"
header_dns = "header-dns"
header_dtls = "header-dtls"
header_srtp = "header-srtp"
header_utp = "header-utp"
header_wechat = "header-wechat"
header_wireguard = "header-wireguard"
mkcp_original = "mkcp-original"
mkcp_aes128gcm = "mkcp-aes128gcm"
noise = "noise"
salamander = "salamander"
sudoku = "sudoku"
xdns = "xdns"
xicmp = "xicmp"


class FinalMaskQuicCongestion(str, Enum):
reno = "reno"
bbr = "bbr"
brutal = "brutal"
force_brutal = "force-brutal"


class FinalMaskTcpHeaderCustomSettings(FinalMaskBaseModel):
clients: list[list[XrayNoiseSettings]] | None = Field(default=None)
servers: list[list[XrayNoiseSettings]] | None = Field(default=None)
errors: list[list[XrayNoiseSettings]] | None = Field(default=None)


class FinalMaskUdpHeaderCustomSettings(FinalMaskBaseModel):
client: list[XrayNoiseSettings] | None = Field(default=None)
server: list[XrayNoiseSettings] | None = Field(default=None)


class FinalMaskPasswordSettings(FinalMaskBaseModel):
password: str | None = Field(default=None)


class FinalMaskSudokuSettings(FinalMaskPasswordSettings):
ascii: str | None = Field(default=None)
custom_table: str | None = Field(default=None, alias="customTable")
custom_tables: list[str] | None = Field(default=None, alias="customTables")
padding_min: int | None = Field(default=None, alias="paddingMin")
padding_max: int | None = Field(default=None, alias="paddingMax")


class FinalMaskDomainSettings(FinalMaskBaseModel):
domain: str | None = Field(default=None)


class FinalMaskXicmpSettings(FinalMaskBaseModel):
listen_ip: str | None = Field(default=None, alias="listenIp")
id: int | None = Field(default=None)


class FinalMaskNoiseSettings(FinalMaskBaseModel):
reset: int | None = Field(default=None)
noise: list[XrayNoiseSettings] | None = Field(default=None)


class FinalMaskUdpHop(FinalMaskBaseModel):
ports: str | None = Field(default=None)
interval: str | int | None = Field(default=None)


class FinalMaskQuicParams(FinalMaskBaseModel):
congestion: FinalMaskQuicCongestion | None = Field(default=None)
debug: bool | None = Field(default=None)
brutal_up: str | int | None = Field(default=None, alias="brutalUp")
brutal_down: str | int | None = Field(default=None, alias="brutalDown")
udp_hop: FinalMaskUdpHop | None = Field(default=None, alias="udpHop")
init_stream_receive_window: int | None = Field(default=None, alias="initStreamReceiveWindow")
max_stream_receive_window: int | None = Field(default=None, alias="maxStreamReceiveWindow")
init_connection_receive_window: int | None = Field(default=None, alias="initConnectionReceiveWindow")
max_connection_receive_window: int | None = Field(default=None, alias="maxConnectionReceiveWindow")
max_idle_timeout: int | None = Field(default=None, alias="maxIdleTimeout")
keep_alive_period: int | None = Field(default=None, alias="keepAlivePeriod")
disable_path_mtu_discovery: bool | None = Field(default=None, alias="disablePathMTUDiscovery")
max_incoming_streams: int | None = Field(default=None, alias="maxIncomingStreams")


FinalMaskTcpSettings = (
FinalMaskTcpHeaderCustomSettings | XrayFragmentSettings | FinalMaskSudokuSettings | dict[str, Any]
)
FinalMaskUdpSettings = (
FinalMaskUdpHeaderCustomSettings
| FinalMaskPasswordSettings
| FinalMaskSudokuSettings
| FinalMaskDomainSettings
| FinalMaskXicmpSettings
| FinalMaskNoiseSettings
| dict[str, Any]
)
Comment thread
ImMohammad20000 marked this conversation as resolved.


FINAL_MASK_TCP_SETTINGS_MODELS = {
FinalMaskTcpType.header_custom: FinalMaskTcpHeaderCustomSettings,
FinalMaskTcpType.fragment: XrayFragmentSettings,
FinalMaskTcpType.sudoku: FinalMaskSudokuSettings,
}

FINAL_MASK_UDP_SETTINGS_MODELS = {
FinalMaskUdpType.header_custom: FinalMaskUdpHeaderCustomSettings,
FinalMaskUdpType.header_dns: FinalMaskDomainSettings,
FinalMaskUdpType.header_dtls: FinalMaskPasswordSettings,
FinalMaskUdpType.header_srtp: FinalMaskPasswordSettings,
FinalMaskUdpType.header_utp: FinalMaskPasswordSettings,
FinalMaskUdpType.header_wechat: FinalMaskPasswordSettings,
FinalMaskUdpType.header_wireguard: FinalMaskPasswordSettings,
FinalMaskUdpType.mkcp_original: FinalMaskPasswordSettings,
FinalMaskUdpType.mkcp_aes128gcm: FinalMaskPasswordSettings,
FinalMaskUdpType.noise: FinalMaskNoiseSettings,
FinalMaskUdpType.salamander: FinalMaskPasswordSettings,
FinalMaskUdpType.sudoku: FinalMaskSudokuSettings,
FinalMaskUdpType.xdns: FinalMaskDomainSettings,
FinalMaskUdpType.xicmp: FinalMaskXicmpSettings,
}


def _dispatch_final_mask_settings(
value: Any,
type_enum: type[Enum],
settings_models: dict[Enum, type[BaseModel]],
):
if not isinstance(value, dict):
return value

settings = value.get("settings")
if not isinstance(settings, dict):
return value

layer_type = value.get("type")
if layer_type is None:
return value

try:
layer_type = type_enum(layer_type)
except ValueError:
return value

value = {**value}
settings_model = settings_models.get(layer_type)
if settings_model is not None:
value["settings"] = settings_model.model_validate(settings)
return value


class FinalMaskTcpLayer(FinalMaskBaseModel):
type: FinalMaskTcpType
settings: FinalMaskTcpSettings = Field(default_factory=dict)

@model_validator(mode="before")
@classmethod
def parse_settings(cls, value):
return _dispatch_final_mask_settings(value, FinalMaskTcpType, FINAL_MASK_TCP_SETTINGS_MODELS)


class FinalMaskUdpLayer(FinalMaskBaseModel):
type: FinalMaskUdpType
settings: FinalMaskUdpSettings = Field(default_factory=dict)

@model_validator(mode="before")
@classmethod
def parse_settings(cls, value):
return _dispatch_final_mask_settings(value, FinalMaskUdpType, FINAL_MASK_UDP_SETTINGS_MODELS)


class FinalMask(FinalMaskBaseModel):
tcp: list[FinalMaskTcpLayer] | None = Field(default=None)
udp: list[FinalMaskUdpLayer] | None = Field(default=None)
quic_params: FinalMaskQuicParams | None = Field(default=None, alias="quicParams")


class XMuxSettings(BaseModel):
max_concurrency: str | None = Field(None, pattern=r"^\d{1,16}(-\d{1,16})?$", serialization_alias="maxConcurrency")
max_connections: str | None = Field(None, pattern=r"^\d{1,16}(-\d{1,16})?$", serialization_alias="maxConnections")
Expand Down Expand Up @@ -317,6 +517,7 @@ 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
final_mask_settings: FinalMask | None = None

model_config = ConfigDict(from_attributes=True)

Expand Down
3 changes: 2 additions & 1 deletion app/models/subscription.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from pydantic import BaseModel, Field, computed_field, field_validator

from app.models.host import FinalMask
from app.models.stats import Period
from app.utils.helpers import fix_datetime_timezone

Expand Down Expand Up @@ -278,7 +279,7 @@ class SubscriptionInboundData(BaseModel):
# Fragment and noise settings
fragment_settings: dict[str, Any] | None = Field(None)
noise_settings: dict[str, Any] | None = Field(None)
finalmask: dict[str, Any] | None = Field(None)
finalmask: FinalMask | dict[str, Any] | None = Field(None)
finalmask_link: str | None = Field(None)

# Priority and status
Expand Down
Loading