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
4 changes: 2 additions & 2 deletions app/core/hosts.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ async def _prepare_subscription_inbound_data(
if inbound_flow == "none":
inbound_flow = ""

finalmask = inbound_config.get("finalmask")
final_mask_settings = host.final_mask_settings if host.final_mask_settings else inbound_config.get("finalmask")

# 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 @@ -372,7 +372,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,
priority=host.priority,
status=list(host.status) if host.status else None,
subscription_templates=host.subscription_templates.model_dump(exclude_none=True)
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: b7d9e1a2c3f4
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 = 'af2d644dda44'
branch_labels = None
depends_on = None
Comment on lines +1 to +16
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check that af2d644dda44 exists and is the (single) head before this migration is added.
fd -t f -e py . app/db/migrations/versions | xargs rg -nP "^(revision|down_revision)\s*="

Repository: PasarGuard/panel

Length of output: 25777


Update docstring Revises: to match down_revision.

The header docstring says Revises: b7d9e1a2c3f4, but down_revision = 'af2d644dda44'. Alembic only uses the variable, so the migration runs correctly, but the docstring mismatch is confusing for anyone reviewing the migration history.

📝 Proposed fix
-"""add final_mask_settings to hosts table
-
-Revision ID: f976bfcf4738
-Revises: b7d9e1a2c3f4
-Create Date: 2026-05-02 13:46:21.008567
-
-"""
+"""add final_mask_settings to hosts table
+
+Revision ID: f976bfcf4738
+Revises: af2d644dda44
+Create Date: 2026-05-02 13:46:21.008567
+
+"""
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"""add final_mask_settings to hosts table
Revision ID: f976bfcf4738
Revises: b7d9e1a2c3f4
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 = 'af2d644dda44'
branch_labels = None
depends_on = None
"""add final_mask_settings to hosts table
Revision ID: f976bfcf4738
Revises: af2d644dda44
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 = 'af2d644dda44'
branch_labels = None
depends_on = None
🤖 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/db/migrations/versions/f976bfcf4738_add_final_mask_settings_to_hosts_table.py`
around lines 1 - 16, The migration header docstring's "Revises:" value doesn't
match the down_revision variable; update the docstring line `Revises:
b7d9e1a2c3f4` to `Revises: af2d644dda44` so it matches the `down_revision =
'af2d644dda44'` (identify the migration by revision = 'f976bfcf4738') — only
change the docstring header to keep history consistent.



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 @@ -507,6 +507,7 @@ class ProxyHost(Base):
)
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):
Expand Down
183 changes: 177 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,174 @@ 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 on lines +173 to +184
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🌐 Web query:

Pydantic v2 smart union resolution order with multiple models that have extra=allow

💡 Result:

In Pydantic v2, non-discriminated Union validation defaults to union_mode='smart' [1][2]. In smart mode, Pydantic evaluates all union members (attempting them left-to-right) and then picks the “best” match using these metrics: 1) It scores each successful member match by the number of valid fields set (for models/dataclasses/typed dicts; nested field counts bubble up) [1][2]. 2) After all members are evaluated, the member with the highest “valid fields set” count wins [1][2]. 3) If tied on “valid fields set”, it uses an “exactness” score as a tiebreaker [1][2]. So, “resolution order” in smart mode is not purely left-to-right; left-to-right is only the evaluation order, while the final selection is driven primarily by fields-set count (then exactness) [1][2]. What changes when union members use extra='allow'? - There is a known interaction/shortcoming in smart union matching when union members have no required fields and use extra='allow' (or extra='ignore'): models can receive points from extra fields, leading to unintuitive member selection [3]. - Pydantic describes that it fixed the extra='ignore' case via pydantic-core, but the extra='allow' case required further handling / a follow-up PR discussion [3]. - A pydantic-core change discussed improving union validation decisions based on extra behavior exists (context around tie-breaking and adding an extra-related metric) [4]. Practical takeaway - If you have multiple models in a Union that might both successfully validate the same input (especially when they have overlapping structure and allow extra fields), smart mode will prefer the member that results in the highest count of “valid fields set” (and only then exactness) [1][2], but extra='allow' can skew that selection in ways that were considered unintuitive and were actively worked on [3][4]. - For fully predictable selection when you have a “tag” field, use discriminated unions (Field(discriminator=...)), which validate only one member instead of doing best-match scoring [5]. If you share a minimal repro (Union type + the two/three models + the exact input dict), I can tell you which member smart mode will pick given your field definitions and extra='allow' behavior, and whether you can/should switch to discriminators or left_to_right to lock it down.

Citations:


🏁 Script executed:

cd app/models && cat -n host.py | sed -n '173,184p'

Repository: PasarGuard/panel

Length of output: 509


🏁 Script executed:

cd app/models && cat -n host.py | sed -n '37,90p'

Repository: PasarGuard/panel

Length of output: 2311


🏁 Script executed:

cd app/models && cat -n host.py | sed -n '187,226p'

Repository: PasarGuard/panel

Length of output: 1982


🏁 Script executed:

cd app/models && cat -n host.py | sed -n '1,35p'

Repository: PasarGuard/panel

Length of output: 1031


🏁 Script executed:

cd app/models && rg "class FinalMaskUdpType" -A 15

Repository: PasarGuard/panel

Length of output: 646


🏁 Script executed:

cd app/models && rg "class FinalMask(TcpHeaderCustomSettings|UdpHeaderCustomSettings|PasswordSettings|DomainSettings|XicmpSettings|NoiseSettings|SudokuSettings)" -A 5

Repository: PasarGuard/panel

Length of output: 1776


🏁 Script executed:

cd app/models && fd . --type f -name "host.py" -exec wc -l {} \;

Repository: PasarGuard/panel

Length of output: 291


🏁 Script executed:

cd app/models && sed -n '90,172p' host.py | cat -n

Repository: PasarGuard/panel

Length of output: 3896


🏁 Script executed:

cd app/models && rg "header_dtls|header_srtp|header_utp|header_wechat|header_wireguard|mkcp_original" --type py

Repository: PasarGuard/panel

Length of output: 316


🏁 Script executed:

cd app/models && python3 << 'EOF'
# Analyze field requirements for union members
import re

fields = {
    "FinalMaskTcpHeaderCustomSettings": {"clients": "optional", "servers": "optional", "errors": "optional"},
    "XrayFragmentSettings": {"packets": "required", "length": "required", "interval": "required", "max_split": "optional"},
    "FinalMaskSudokuSettings": {"password": "optional", "ascii": "optional", "custom_table": "optional", "custom_tables": "optional", "padding_min": "optional", "padding_max": "optional"},
    "FinalMaskUdpHeaderCustomSettings": {"client": "optional", "server": "optional"},
    "FinalMaskPasswordSettings": {"password": "optional"},
    "FinalMaskDomainSettings": {"domain": "optional"},
    "FinalMaskXicmpSettings": {"listen_ip": "optional", "id": "optional"},
    "FinalMaskNoiseSettings": {"reset": "optional", "noise": "optional"},
}

print("Union members field analysis:")
for model, model_fields in fields.items():
    required = [f for f, req in model_fields.items() if req == "required"]
    optional = [f for f, req in model_fields.items() if req == "optional"]
    print(f"\n{model}:")
    print(f"  Required: {required if required else 'None'}")
    print(f"  Optional: {optional}")
    print(f"  Has required fields: {bool(required)}")
EOF

Repository: PasarGuard/panel

Length of output: 1021


🏁 Script executed:

cd app && rg "FinalMaskLayer\|parse_settings" --type py -A 2 -B 2

Repository: PasarGuard/panel

Length of output: 42


🏁 Script executed:

cd app && grep -r "header-dtls\|header-srtp\|header-utp\|header-wechat\|header-wireguard\|mkcp-original" --include="*.py" --include="*.json" | head -20

Repository: PasarGuard/panel

Length of output: 358


🏁 Script executed:

cd app && rg "FinalMaskUdpLayer|FinalMaskTcpLayer" -B 5 -A 10 --type py | head -100

Repository: PasarGuard/panel

Length of output: 2404


🏁 Script executed:

cd app && find . -type f \( -name "*.py" -o -name "*.json" \) -exec grep -l "FinalMask" {} \; | head -10

Repository: PasarGuard/panel

Length of output: 149


🏁 Script executed:

cd app && rg "\.get\(FinalMask" -A 3 -B 3 --type py

Repository: PasarGuard/panel

Length of output: 964


FinalMask*Settings unions are fragile due to union validation occurring before type-aware re-dispatch.

FinalMaskUdpSettings and FinalMaskTcpSettings are unions of models that all inherit extra="allow". During initial validation, Pydantic v2 uses smart-mode union resolution, which ranks candidates by field-set count rather than evaluation order; since all UDP members are entirely optional and allow extra fields, the union cannot reliably distinguish between FinalMaskUdpHeaderCustomSettings, FinalMaskPasswordSettings, FinalMaskDomainSettings, etc. The result is coerced into whichever union member scores highest by Pydantic's ranking, regardless of the actual type field.

Because parse_settings runs in mode="after", this union coercion is already complete by the time the validator checks if not isinstance(self.settings, dict): return self. If the union picked the wrong model, that early-exit suppresses any re-dispatch.

Additionally, six FinalMaskUdpType enum values — header_dtls, header_srtp, header_utp, header_wechat, header_wireguard, mkcp_original — have no entry in the dispatch dict. For these types, settings_model remains None and parse_settings skips validation entirely, leaving settings as whatever Pydantic's union coercion produced.

A more robust approach is to use mode="before" to re-dispatch based on the type field before union coercion happens:

♻️ Suggested approach
 class FinalMaskUdpLayer(FinalMaskBaseModel):
     type: FinalMaskUdpType
-    settings: FinalMaskUdpSettings = Field(default_factory=dict)
+    settings: dict[str, Any] | FinalMaskUdpSettings = Field(default_factory=dict)
 
-    `@model_validator`(mode="after")
-    def parse_settings(self):
-        if not isinstance(self.settings, dict):
-            return self
+    `@model_validator`(mode="before")
+    `@classmethod`
+    def parse_settings(cls, values):
+        if not isinstance(values, dict):
+            return values
+        settings = values.get("settings")
+        if not isinstance(settings, dict):
+            return values
+        type_value = values.get("type")
+        try:
+            udp_type = FinalMaskUdpType(type_value)
+        except ValueError:
+            return values
+        settings_model = {
+            FinalMaskUdpType.header_custom: FinalMaskUdpHeaderCustomSettings,
+            FinalMaskUdpType.header_dns: FinalMaskDomainSettings,
+            FinalMaskUdpType.mkcp_aes128gcm: FinalMaskPasswordSettings,
+            FinalMaskUdpType.noise: FinalMaskNoiseSettings,
+            FinalMaskUdpType.salamander: FinalMaskPasswordSettings,
+            FinalMaskUdpType.sudoku: FinalMaskSudokuSettings,
+            FinalMaskUdpType.xdns: FinalMaskDomainSettings,
+            FinalMaskUdpType.xicmp: FinalMaskXicmpSettings,
+        }.get(udp_type)
+        if settings_model is not None:
+            values["settings"] = settings_model.model_validate(settings)
+        return values

Apply the analogous mode="before" rewrite to FinalMaskTcpLayer. Also consider adding explicit handlers (or a fallback dict path) for the unmapped UDP types.

🤖 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/models/host.py` around lines 173 - 184, The union types
FinalMaskTcpSettings and FinalMaskUdpSettings are being prematurely coerced by
Pydantic v2 smart-mode union resolution before your parse_settings validator can
re-dispatch by type, so change parse_settings to run with mode="before"
(re-dispatch on the raw dict using the "type" field) for both the
FinalMaskUdpLayer and FinalMaskTcpLayer validators to select the correct
settings_model before any union validation; also update the dispatch dict used
in parse_settings to include explicit handlers for the six missing
FinalMaskUdpType values (header_dtls, header_srtp, header_utp, header_wechat,
header_wireguard, mkcp_original) or add a clear fallback that maps them to
dict[str, Any] so settings are validated/kept correctly instead of relying on
union coercion.



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

@model_validator(mode="after")
def parse_settings(self):
if not isinstance(self.settings, dict):
return self

settings_model = {
FinalMaskTcpType.header_custom: FinalMaskTcpHeaderCustomSettings,
FinalMaskTcpType.fragment: XrayFragmentSettings,
FinalMaskTcpType.sudoku: FinalMaskSudokuSettings,
}.get(FinalMaskTcpType(self.type))
self.settings = settings_model.model_validate(self.settings)
return self


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

@model_validator(mode="after")
def parse_settings(self):
if not isinstance(self.settings, dict):
return self

settings_model = {
FinalMaskUdpType.header_custom: FinalMaskUdpHeaderCustomSettings,
FinalMaskUdpType.header_dns: FinalMaskDomainSettings,
FinalMaskUdpType.mkcp_aes128gcm: FinalMaskPasswordSettings,
FinalMaskUdpType.noise: FinalMaskNoiseSettings,
FinalMaskUdpType.salamander: FinalMaskPasswordSettings,
FinalMaskUdpType.sudoku: FinalMaskSudokuSettings,
FinalMaskUdpType.xdns: FinalMaskDomainSettings,
FinalMaskUdpType.xicmp: FinalMaskXicmpSettings,
}.get(FinalMaskUdpType(self.type))
if settings_model is not None:
self.settings = settings_model.model_validate(self.settings)
return self


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 | int | None = Field(
None, pattern=r"^\d{1,16}(-\d{1,16})?$", serialization_alias="maxConcurrency"
Expand Down Expand Up @@ -293,6 +463,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 @@ -8,6 +8,7 @@
from typing import Any

from pydantic import BaseModel, Field, computed_field
from app.models.host import FinalMask


class TLSConfig(BaseModel):
Expand Down Expand Up @@ -258,7 +259,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)

# Priority and status
priority: int = Field(0)
Expand Down
6 changes: 5 additions & 1 deletion app/subscription/links.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,11 @@
def _apply_finalmask(self, payload: dict, protocol: str, inbound: SubscriptionInboundData):
"""Apply finalMask for vmess if needed"""
if inbound.finalmask:
payload["fm"] = json.dumps(inbound.finalmask)
if isinstance(inbound.finalmask, FinalMask):

Check failure on line 170 in app/subscription/links.py

View workflow job for this annotation

GitHub Actions / ruff

ruff (F821)

app/subscription/links.py:170:46: F821 Undefined name `FinalMask`

Check failure on line 170 in app/subscription/links.py

View workflow job for this annotation

GitHub Actions / ruff

ruff (F821)

app/subscription/links.py:170:46: F821 Undefined name `FinalMask`
finalmask = inbound.finalmask.model_dump()
else:
finalmask = inbound.finalmask
payload["fm"] = json.dumps(finalmask)

def _transport_tcp(self, payload: dict, protocol: str, config: TCPTransportConfig, path: str):
"""Handle tcp/raw/http transport - only gets TCP config"""
Expand Down
9 changes: 6 additions & 3 deletions app/subscription/xray.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
TLSConfig,
WebSocketTransportConfig,
XHTTPTransportConfig,
FinalMask,
)
from app.templates import render_template_string
from app.utils.helpers import UUIDEncoder
Expand Down Expand Up @@ -467,9 +468,11 @@ def _build_shadowsocks(self, address: str, inbound: SubscriptionInboundData, set
}

if inbound.finalmask is not None:
outbound["streamSettings"] = self._stream_setting_config(
network=inbound.network, finalmask=inbound.finalmask
)
if isinstance(inbound.finalmask, FinalMask):
finalmask = inbound.finalmask.model_dump()
else:
finalmask = inbound.finalmask
outbound["streamSettings"] = self._stream_setting_config(network=inbound.network, finalmask=finalmask)

return self._normalize_and_remove_none_values(outbound)

Expand Down
Loading