diff --git a/app/core/hosts.py b/app/core/hosts.py index 2a1bc7b7..6ecbf9de 100644 --- a/app/core/hosts.py +++ b/app/core/hosts.py @@ -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 @@ -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) diff --git a/app/db/migrations/versions/f976bfcf4738_add_final_mask_settings_to_hosts_table.py b/app/db/migrations/versions/f976bfcf4738_add_final_mask_settings_to_hosts_table.py new file mode 100644 index 00000000..04f2a5c2 --- /dev/null +++ b/app/db/migrations/versions/f976bfcf4738_add_final_mask_settings_to_hosts_table.py @@ -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 ### diff --git a/app/db/models.py b/app/db/models.py index 4ea4bba0..7046f272 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -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): diff --git a/app/models/host.py b/app/models/host.py index e3314309..973ccd80 100644 --- a/app/models/host.py +++ b/app/models/host.py @@ -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] +) + + +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) diff --git a/app/models/subscription.py b/app/models/subscription.py index 79775ac4..0e9ccaf4 100644 --- a/app/models/subscription.py +++ b/app/models/subscription.py @@ -8,6 +8,7 @@ from typing import Any from pydantic import BaseModel, Field, computed_field +from app.models.host import FinalMask class TLSConfig(BaseModel): @@ -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) diff --git a/app/subscription/links.py b/app/subscription/links.py index 490f8be7..998696eb 100644 --- a/app/subscription/links.py +++ b/app/subscription/links.py @@ -167,7 +167,11 @@ def _transport_kcp(self, payload: dict, protocol: str, config: KCPTransportConfi 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): + 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""" diff --git a/app/subscription/xray.py b/app/subscription/xray.py index 9432780e..7717a258 100644 --- a/app/subscription/xray.py +++ b/app/subscription/xray.py @@ -10,6 +10,7 @@ TLSConfig, WebSocketTransportConfig, XHTTPTransportConfig, + FinalMask, ) from app.templates import render_template_string from app.utils.helpers import UUIDEncoder @@ -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)