-
Notifications
You must be signed in to change notification settings - Fork 112
feat: Add final_mask_settings to hosts #451
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
5e6a9ca
c30326a
9b08386
f42615b
e5c0ea1
9cb6db0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
|
|
||
|
|
||
| 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 ### | ||
| 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 | ||
|
|
||
|
|
@@ -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): | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 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 15Repository: PasarGuard/panel Length of output: 646 🏁 Script executed: cd app/models && rg "class FinalMask(TcpHeaderCustomSettings|UdpHeaderCustomSettings|PasswordSettings|DomainSettings|XicmpSettings|NoiseSettings|SudokuSettings)" -A 5Repository: 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 -nRepository: 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 pyRepository: 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)}")
EOFRepository: PasarGuard/panel Length of output: 1021 🏁 Script executed: cd app && rg "FinalMaskLayer\|parse_settings" --type py -A 2 -B 2Repository: 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 -20Repository: PasarGuard/panel Length of output: 358 🏁 Script executed: cd app && rg "FinalMaskUdpLayer|FinalMaskTcpLayer" -B 5 -A 10 --type py | head -100Repository: PasarGuard/panel Length of output: 2404 🏁 Script executed: cd app && find . -type f \( -name "*.py" -o -name "*.json" \) -exec grep -l "FinalMask" {} \; | head -10Repository: PasarGuard/panel Length of output: 149 🏁 Script executed: cd app && rg "\.get\(FinalMask" -A 3 -B 3 --type pyRepository: PasarGuard/panel Length of output: 964 FinalMask*Settings unions are fragile due to union validation occurring before type-aware re-dispatch.
Because Additionally, six A more robust approach is to use ♻️ 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 valuesApply the analogous 🤖 Prompt for AI Agents |
||
|
|
||
|
|
||
| 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" | ||
|
|
@@ -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) | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: PasarGuard/panel
Length of output: 25777
Update docstring
Revises:to matchdown_revision.The header docstring says
Revises: b7d9e1a2c3f4, butdown_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
📝 Committable suggestion
🤖 Prompt for AI Agents