devconfig-gen-singbox 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,76 @@
1
+ """Internal data structures for the sing-box provider.
2
+
3
+ These types are deliberately small and immutable. They carry the *normalized*
4
+ input (already validated by :mod:`schema`) so that variant plugins only deal
5
+ with their own domain fields and never with raw, untrusted mappings.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Mapping, Optional, Sequence
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class ProtocolConfig:
16
+ """One entry of ``network.protocols`` after normalization.
17
+
18
+ ``raw`` is the whole protocol mapping (including ``auth`` / ``tls`` /
19
+ variant sections). ``path`` is the dotted location used for diagnostics,
20
+ for example ``network.protocols.0``.
21
+ """
22
+
23
+ type: str
24
+ enabled: bool
25
+ port: Optional[int]
26
+ raw: Mapping[str, Any] = field(default_factory=dict)
27
+ path: str = "network.protocols.0"
28
+
29
+ def section(self, name: str) -> Mapping[str, Any]:
30
+ """Return a nested mapping section (``auth`` / ``tls`` / ...)."""
31
+
32
+ value = self.raw.get(name)
33
+ return value if isinstance(value, Mapping) else {}
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class NetworkConfig:
38
+ """Normalized ``network`` section."""
39
+
40
+ domain_root: str = ""
41
+ subdomain_prefixes: Mapping[str, str] = field(default_factory=dict)
42
+ tunnel_mode: str = "proxy"
43
+ protocols: Sequence[ProtocolConfig] = ()
44
+ routing: Mapping[str, Any] = field(default_factory=dict)
45
+ dns: Mapping[str, Any] = field(default_factory=dict)
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class ClientConfig:
50
+ """Normalized ``client`` section."""
51
+
52
+ server_ip: Optional[str] = None
53
+ fingerprint: str = "chrome"
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class BuildContext:
58
+ """Everything a plugin needs to build its pieces.
59
+
60
+ Plugins receive this instead of reaching into the raw context, which keeps
61
+ them free of side effects and of knowledge about the surrounding schema.
62
+ """
63
+
64
+ network: NetworkConfig
65
+ client: ClientConfig
66
+ hosts: Mapping[str, str]
67
+ options: Mapping[str, Any] = field(default_factory=dict)
68
+
69
+ def host(self, key: str) -> str:
70
+ """Return the FQDN for a plugin's ``host_key``."""
71
+
72
+ return self.hosts.get(key, "")
73
+
74
+ @property
75
+ def fingerprint(self) -> str:
76
+ return self.client.fingerprint or "chrome"
@@ -0,0 +1,33 @@
1
+ """Variant plugin registry.
2
+
3
+ Adding a protocol means adding one module here plus one line in
4
+ ``_PLUGIN_CLASSES``; nothing in ``provider.py`` / ``schema.py`` / ``route.py``
5
+ changes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Optional
11
+
12
+ from .anytls import AnyTlsPlugin
13
+ from .hysteria2 import Hysteria2Plugin
14
+ from .tuic import TuicPlugin
15
+
16
+ _PLUGIN_CLASSES = (AnyTlsPlugin, TuicPlugin, Hysteria2Plugin)
17
+
18
+ PLUGINS = {cls.protocol_type: cls() for cls in _PLUGIN_CLASSES}
19
+
20
+
21
+ def get_plugin(protocol_type: str) -> Optional[object]:
22
+ """Return the plugin for ``protocol_type`` or ``None`` when unknown."""
23
+
24
+ return PLUGINS.get(str(protocol_type).strip().lower())
25
+
26
+
27
+ def protocol_types() -> tuple:
28
+ """Return the registered protocol type names in registry order."""
29
+
30
+ return tuple(PLUGINS)
31
+
32
+
33
+ __all__ = ["PLUGINS", "get_plugin", "protocol_types"]
@@ -0,0 +1,129 @@
1
+ """AnyTLS (REALITY) variant plugin.
2
+
3
+ All AnyTLS/REALITY field names live here. Upstream sing-box renames are
4
+ absorbed by editing this module only.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from urllib.parse import urlencode
10
+
11
+ from ..models import BuildContext, ProtocolConfig
12
+ from .base import (
13
+ domain_resolver,
14
+ optional_integer,
15
+ optional_string,
16
+ require_string,
17
+ )
18
+
19
+ DEFAULT_PORT = 23244
20
+ DEFAULT_DECOY_SERVER = "www.cloudflare.com"
21
+ DEFAULT_DECOY_PORT = 443
22
+
23
+ PADDING_SCHEME = [
24
+ "stop=8",
25
+ "0=30-30",
26
+ "1=100-400",
27
+ "2=400-500,c,500-1000,c,500-1000,c,500-1000,c,500-1000",
28
+ "3=9-9,500-1000",
29
+ "4=500-1000",
30
+ "5=500-1000",
31
+ "6=500-1000",
32
+ "7=500-1000",
33
+ ]
34
+
35
+
36
+ class AnyTlsPlugin:
37
+ protocol_type = "anytls"
38
+ host_key = "reality"
39
+ inbound_tag = "anytls-in"
40
+ outbound_tag = "anytls-out"
41
+ default_port = DEFAULT_PORT
42
+ sniff_inbound = True
43
+
44
+ def validate(self, cfg: ProtocolConfig, errors: list) -> None:
45
+ require_string(cfg, "auth.password", errors)
46
+ require_string(cfg, "reality.private_key", errors)
47
+ require_string(cfg, "reality.public_key", errors)
48
+ require_string(cfg, "reality.short_id", errors)
49
+ optional_string(cfg, "reality.decoy_server", errors, default=DEFAULT_DECOY_SERVER)
50
+ optional_integer(
51
+ cfg, "reality.decoy_port", errors, default=DEFAULT_DECOY_PORT, minimum=1, maximum=65535
52
+ )
53
+
54
+ def _port(self, cfg: ProtocolConfig) -> int:
55
+ return cfg.port if cfg.port is not None else DEFAULT_PORT
56
+
57
+ def _decoy(self, cfg: ProtocolConfig) -> tuple:
58
+ server = optional_string(cfg, "reality.decoy_server", [], default=DEFAULT_DECOY_SERVER)
59
+ port = optional_integer(
60
+ cfg, "reality.decoy_port", [], default=DEFAULT_DECOY_PORT, minimum=1, maximum=65535
61
+ )
62
+ return server, port
63
+
64
+ def build_server_inbound(self, cfg: ProtocolConfig, ctx: BuildContext) -> dict:
65
+ password = require_string(cfg, "auth.password", [], default="")
66
+ private_key = require_string(cfg, "reality.private_key", [], default="")
67
+ short_id = require_string(cfg, "reality.short_id", [], default="")
68
+ decoy_server, decoy_port = self._decoy(cfg)
69
+ return {
70
+ "type": "anytls",
71
+ "tag": self.inbound_tag,
72
+ "listen": "::",
73
+ "listen_port": self._port(cfg),
74
+ "users": [{"name": "user", "password": password}],
75
+ "padding_scheme": list(PADDING_SCHEME),
76
+ "tls": {
77
+ "enabled": True,
78
+ "server_name": decoy_server,
79
+ "reality": {
80
+ "enabled": True,
81
+ "handshake": {
82
+ "server": decoy_server,
83
+ "server_port": decoy_port,
84
+ },
85
+ "private_key": private_key,
86
+ "short_id": short_id,
87
+ },
88
+ },
89
+ }
90
+
91
+ def build_client_outbound(self, cfg: ProtocolConfig, ctx: BuildContext) -> dict:
92
+ password = require_string(cfg, "auth.password", [], default="")
93
+ public_key = require_string(cfg, "reality.public_key", [], default="")
94
+ short_id = require_string(cfg, "reality.short_id", [], default="")
95
+ decoy_server, _ = self._decoy(cfg)
96
+ return {
97
+ "type": "anytls",
98
+ "tag": self.outbound_tag,
99
+ "server": ctx.host(self.host_key),
100
+ "domain_resolver": domain_resolver(),
101
+ "server_port": self._port(cfg),
102
+ "tls": {
103
+ "enabled": True,
104
+ "server_name": decoy_server,
105
+ "utls": {"enabled": True, "fingerprint": ctx.fingerprint},
106
+ "reality": {
107
+ "enabled": True,
108
+ "public_key": public_key,
109
+ "short_id": short_id,
110
+ },
111
+ },
112
+ "password": password,
113
+ }
114
+
115
+ def build_share_link(self, cfg: ProtocolConfig, ctx: BuildContext) -> str:
116
+ password = require_string(cfg, "auth.password", [], default="")
117
+ public_key = require_string(cfg, "reality.public_key", [], default="")
118
+ short_id = require_string(cfg, "reality.short_id", [], default="")
119
+ decoy_server, _ = self._decoy(cfg)
120
+ params = urlencode(
121
+ {
122
+ "security": "reality",
123
+ "sni": decoy_server,
124
+ "fp": ctx.fingerprint,
125
+ "pbk": public_key,
126
+ "sid": short_id,
127
+ }
128
+ )
129
+ return f"anytls://{password}@{ctx.host(self.host_key)}:{self._port(cfg)}?{params}#AnyTLS"
@@ -0,0 +1,117 @@
1
+ """Plugin contract and shared helpers for sing-box protocol variants.
2
+
3
+ A variant plugin owns *all* domain detail for one protocol: the exact inbound
4
+ and outbound field names, the share-link format, and the variant-specific
5
+ validation rules. The rest of the provider (``provider.py`` / ``schema.py`` /
6
+ ``route.py``) never mentions a variant name, so an upstream sing-box change is
7
+ absorbed by editing a single file here plus the registry.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Mapping, Optional, Protocol
13
+
14
+ from ....validation import expect_integer, expect_string
15
+ from ..models import BuildContext, ProtocolConfig
16
+
17
+
18
+ class ProtocolPlugin(Protocol):
19
+ """Structural contract every variant plugin implements."""
20
+
21
+ protocol_type: str
22
+ host_key: str
23
+ inbound_tag: str
24
+ outbound_tag: str
25
+ default_port: int
26
+
27
+ def validate(self, cfg: ProtocolConfig, errors: list) -> None:
28
+ ...
29
+
30
+ def build_server_inbound(self, cfg: ProtocolConfig, ctx: BuildContext) -> dict:
31
+ ...
32
+
33
+ def build_client_outbound(self, cfg: ProtocolConfig, ctx: BuildContext) -> dict:
34
+ ...
35
+
36
+ def build_share_link(self, cfg: ProtocolConfig, ctx: BuildContext) -> str:
37
+ ...
38
+
39
+
40
+ def dig(source: Mapping[str, Any], path: str) -> Any:
41
+ """Read a dotted path from a mapping, returning ``None`` when absent."""
42
+
43
+ current: Any = source
44
+ for part in path.split("."):
45
+ if not isinstance(current, Mapping):
46
+ return None
47
+ current = current.get(part)
48
+ return current
49
+
50
+
51
+ def field_path(cfg: ProtocolConfig, relative: str) -> str:
52
+ return f"{cfg.path}.{relative}" if relative else cfg.path
53
+
54
+
55
+ def require_string(
56
+ cfg: ProtocolConfig,
57
+ relative: str,
58
+ errors: list,
59
+ *,
60
+ default: Optional[str] = None,
61
+ ) -> Optional[str]:
62
+ """Validate a required string field, falling back to ``default``."""
63
+
64
+ value = dig(cfg.raw, relative)
65
+ if value is None and default is not None:
66
+ return default
67
+ result = expect_string(value, field_path(cfg, relative), errors)
68
+ return result if result is not None else default
69
+
70
+
71
+ def optional_string(
72
+ cfg: ProtocolConfig,
73
+ relative: str,
74
+ errors: list,
75
+ *,
76
+ default: Optional[str] = None,
77
+ ) -> Optional[str]:
78
+ """Validate a string field only when it is present."""
79
+
80
+ value = dig(cfg.raw, relative)
81
+ if value is None:
82
+ return default
83
+ result = expect_string(value, field_path(cfg, relative), errors)
84
+ return result if result is not None else default
85
+
86
+
87
+ def optional_integer(
88
+ cfg: ProtocolConfig,
89
+ relative: str,
90
+ errors: list,
91
+ *,
92
+ default: Optional[int] = None,
93
+ minimum: Optional[int] = None,
94
+ maximum: Optional[int] = None,
95
+ ) -> Optional[int]:
96
+ """Validate an integer field only when it is present."""
97
+
98
+ value = dig(cfg.raw, relative)
99
+ if value is None:
100
+ return default
101
+ result = expect_integer(
102
+ value,
103
+ field_path(cfg, relative),
104
+ errors,
105
+ minimum=minimum,
106
+ maximum=maximum,
107
+ )
108
+ return result if result is not None else default
109
+
110
+
111
+ DIRECT_DNS_TAG = "dns-direct"
112
+
113
+
114
+ def domain_resolver(server_tag: str = DIRECT_DNS_TAG) -> dict:
115
+ """Domain resolver shared by every client outbound."""
116
+
117
+ return {"server": server_tag, "strategy": "prefer_ipv4"}
@@ -0,0 +1,110 @@
1
+ """Hysteria2 variant plugin.
2
+
3
+ All Hysteria2 field names live here. Upstream sing-box renames are absorbed by
4
+ editing this module only.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from urllib.parse import urlencode
10
+
11
+ from ..models import BuildContext, ProtocolConfig
12
+ from .base import domain_resolver, optional_integer, optional_string, require_string
13
+
14
+ DEFAULT_PORT = 7443
15
+ DEFAULT_MASQUERADE = "https://www.cloudflare.com"
16
+ DEFAULT_SERVER_UP_MBPS = 500
17
+ DEFAULT_SERVER_DOWN_MBPS = 500
18
+ DEFAULT_CLIENT_UP_MBPS = 50
19
+ DEFAULT_CLIENT_DOWN_MBPS = 200
20
+
21
+
22
+ class Hysteria2Plugin:
23
+ protocol_type = "hysteria2"
24
+ host_key = "hy2"
25
+ inbound_tag = "hy2-in"
26
+ outbound_tag = "hy2-out"
27
+ default_port = DEFAULT_PORT
28
+ sniff_inbound = False
29
+
30
+ def validate(self, cfg: ProtocolConfig, errors: list) -> None:
31
+ require_string(cfg, "auth.password", errors)
32
+ require_string(cfg, "auth.obfs_password", errors)
33
+ require_string(cfg, "tls.cert_path", errors)
34
+ require_string(cfg, "tls.key_path", errors)
35
+ optional_integer(cfg, "bandwidth.up_mbps", errors, minimum=1)
36
+ optional_integer(cfg, "bandwidth.down_mbps", errors, minimum=1)
37
+ optional_string(cfg, "masquerade", errors, default=DEFAULT_MASQUERADE)
38
+
39
+ def _port(self, cfg: ProtocolConfig) -> int:
40
+ return cfg.port if cfg.port is not None else DEFAULT_PORT
41
+
42
+ def _obfs(self, cfg: ProtocolConfig) -> dict:
43
+ return {
44
+ "type": "salamander",
45
+ "password": require_string(cfg, "auth.obfs_password", [], default=""),
46
+ }
47
+
48
+ def build_server_inbound(self, cfg: ProtocolConfig, ctx: BuildContext) -> dict:
49
+ up = optional_integer(cfg, "bandwidth.up_mbps", [], default=DEFAULT_SERVER_UP_MBPS, minimum=1)
50
+ down = optional_integer(
51
+ cfg, "bandwidth.down_mbps", [], default=DEFAULT_SERVER_DOWN_MBPS, minimum=1
52
+ )
53
+ cert_path = require_string(cfg, "tls.cert_path", [], default="")
54
+ key_path = require_string(cfg, "tls.key_path", [], default="")
55
+ masquerade = optional_string(cfg, "masquerade", [], default=DEFAULT_MASQUERADE)
56
+ return {
57
+ "type": "hysteria2",
58
+ "tag": self.inbound_tag,
59
+ "listen": "::",
60
+ "listen_port": self._port(cfg),
61
+ "users": [{"password": require_string(cfg, "auth.password", [], default="")}],
62
+ "ignore_client_bandwidth": False,
63
+ "up_mbps": up,
64
+ "down_mbps": down,
65
+ "obfs": self._obfs(cfg),
66
+ "masquerade": masquerade,
67
+ "tls": {
68
+ "enabled": True,
69
+ "server_name": ctx.host(self.host_key),
70
+ "alpn": ["h3"],
71
+ "certificate_path": cert_path,
72
+ "key_path": key_path,
73
+ },
74
+ }
75
+
76
+ def build_client_outbound(self, cfg: ProtocolConfig, ctx: BuildContext) -> dict:
77
+ up = optional_integer(cfg, "bandwidth.up_mbps", [], default=DEFAULT_CLIENT_UP_MBPS, minimum=1)
78
+ down = optional_integer(
79
+ cfg, "bandwidth.down_mbps", [], default=DEFAULT_CLIENT_DOWN_MBPS, minimum=1
80
+ )
81
+ return {
82
+ "type": "hysteria2",
83
+ "tag": self.outbound_tag,
84
+ "server": ctx.host(self.host_key),
85
+ "domain_resolver": domain_resolver(),
86
+ "server_port": self._port(cfg),
87
+ "up_mbps": up,
88
+ "down_mbps": down,
89
+ "obfs": self._obfs(cfg),
90
+ "password": require_string(cfg, "auth.password", [], default=""),
91
+ "tls": {
92
+ "enabled": True,
93
+ "server_name": ctx.host(self.host_key),
94
+ "alpn": ["h3"],
95
+ },
96
+ }
97
+
98
+ def build_share_link(self, cfg: ProtocolConfig, ctx: BuildContext) -> str:
99
+ host = ctx.host(self.host_key)
100
+ params = urlencode(
101
+ {
102
+ "obfs": "salamander",
103
+ "obfs-password": require_string(cfg, "auth.obfs_password", [], default=""),
104
+ "sni": host,
105
+ }
106
+ )
107
+ return (
108
+ f"hy2://{require_string(cfg, 'auth.password', [], default='')}"
109
+ f"@{host}:{self._port(cfg)}?{params}#Hysteria2"
110
+ )
@@ -0,0 +1,90 @@
1
+ """TUIC variant plugin.
2
+
3
+ All TUIC field names live here. Upstream sing-box renames are absorbed by
4
+ editing this module only.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from urllib.parse import urlencode
10
+
11
+ from ..models import BuildContext, ProtocolConfig
12
+ from .base import domain_resolver, require_string
13
+
14
+ DEFAULT_PORT = 9443
15
+
16
+
17
+ class TuicPlugin:
18
+ protocol_type = "tuic"
19
+ host_key = "tuic"
20
+ inbound_tag = "tuic-in"
21
+ outbound_tag = "tuic-out"
22
+ default_port = DEFAULT_PORT
23
+ sniff_inbound = False
24
+
25
+ def validate(self, cfg: ProtocolConfig, errors: list) -> None:
26
+ require_string(cfg, "auth.uuid", errors)
27
+ require_string(cfg, "auth.password", errors)
28
+ require_string(cfg, "tls.cert_path", errors)
29
+ require_string(cfg, "tls.key_path", errors)
30
+
31
+ def _port(self, cfg: ProtocolConfig) -> int:
32
+ return cfg.port if cfg.port is not None else DEFAULT_PORT
33
+
34
+ def build_server_inbound(self, cfg: ProtocolConfig, ctx: BuildContext) -> dict:
35
+ uuid = require_string(cfg, "auth.uuid", [], default="")
36
+ password = require_string(cfg, "auth.password", [], default="")
37
+ cert_path = require_string(cfg, "tls.cert_path", [], default="")
38
+ key_path = require_string(cfg, "tls.key_path", [], default="")
39
+ return {
40
+ "type": "tuic",
41
+ "tag": self.inbound_tag,
42
+ "listen": "::",
43
+ "listen_port": self._port(cfg),
44
+ "users": [{"uuid": uuid, "password": password}],
45
+ "congestion_control": "bbr",
46
+ "zero_rtt_handshake": False,
47
+ "heartbeat": "10s",
48
+ "tls": {
49
+ "enabled": True,
50
+ "server_name": ctx.host(self.host_key),
51
+ "alpn": ["h3"],
52
+ "certificate_path": cert_path,
53
+ "key_path": key_path,
54
+ },
55
+ }
56
+
57
+ def build_client_outbound(self, cfg: ProtocolConfig, ctx: BuildContext) -> dict:
58
+ uuid = require_string(cfg, "auth.uuid", [], default="")
59
+ password = require_string(cfg, "auth.password", [], default="")
60
+ return {
61
+ "type": "tuic",
62
+ "tag": self.outbound_tag,
63
+ "server": ctx.host(self.host_key),
64
+ "domain_resolver": domain_resolver(),
65
+ "server_port": self._port(cfg),
66
+ "uuid": uuid,
67
+ "password": password,
68
+ "congestion_control": "bbr",
69
+ "udp_relay_mode": "native",
70
+ "zero_rtt_handshake": False,
71
+ "heartbeat": "10s",
72
+ "tls": {
73
+ "enabled": True,
74
+ "server_name": ctx.host(self.host_key),
75
+ "alpn": ["h3"],
76
+ },
77
+ }
78
+
79
+ def build_share_link(self, cfg: ProtocolConfig, ctx: BuildContext) -> str:
80
+ uuid = require_string(cfg, "auth.uuid", [], default="")
81
+ password = require_string(cfg, "auth.password", [], default="")
82
+ host = ctx.host(self.host_key)
83
+ params = urlencode(
84
+ {
85
+ "congestion_control": "bbr",
86
+ "udp_relay_mode": "quic",
87
+ "sni": host,
88
+ }
89
+ )
90
+ return f"tuic://{uuid}:{password}@{host}:{self._port(cfg)}?{params}#TUIC"