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,362 @@
1
+ """Context schema definition and structural validation for sing-box.
2
+
3
+ Validation is path-aware and collects *all* problems in one pass as
4
+ :class:`~devconfig_gen.models.Diagnostic` values with dotted paths such as
5
+ ``network.protocols.0.auth.password``. Variant-specific field checks are
6
+ delegated to the plugins; this module only owns the neutral structure.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import Any, List, Mapping, Optional, Tuple
13
+
14
+ from ...models import Diagnostic
15
+ from ...validation import expect_enum, expect_integer, expect_mapping, expect_string
16
+ from .models import ClientConfig, NetworkConfig, ProtocolConfig
17
+ from .plugins import get_plugin, protocol_types
18
+ from .route import REQUIRED_BUCKETS
19
+
20
+ TUNNEL_MODES = ("none", "proxy", "tun")
21
+ TARGETS = ("server", "client", "both")
22
+ OUTPUT_FORMATS = ("json", "yaml")
23
+ RULES_SOURCES = ("embedded", "custom")
24
+
25
+ DEFAULT_TUNNEL_MODE = "proxy"
26
+ DEFAULT_TARGET = "both"
27
+ DEFAULT_FORMAT = "json"
28
+ DEFAULT_FINGERPRINT = "chrome"
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class ParsedContext:
33
+ """A normalized request context plus the resolved protocol hosts."""
34
+
35
+ network: NetworkConfig
36
+ client: ClientConfig
37
+ options: Mapping[str, Any]
38
+ hosts: Mapping[str, str]
39
+
40
+ def enabled_protocols(self) -> Tuple[ProtocolConfig, ...]:
41
+ return tuple(cfg for cfg in self.network.protocols if cfg.enabled)
42
+
43
+
44
+ def parse(
45
+ context: Any, options: Optional[Mapping[str, Any]] = None
46
+ ) -> Tuple[ParsedContext, List[Diagnostic]]:
47
+ """Normalize and validate a request context.
48
+
49
+ Always returns a best-effort :class:`ParsedContext`; callers must check the
50
+ returned diagnostics before generating artifacts.
51
+ """
52
+
53
+ diagnostics: List[Diagnostic] = []
54
+ if context is None:
55
+ context = {}
56
+ if not isinstance(context, Mapping):
57
+ diagnostics.append(
58
+ Diagnostic("", f"context must be a mapping at the document root, got {_describe(context)}")
59
+ )
60
+ context = {}
61
+
62
+ merged_options = _parse_options(context, options, diagnostics)
63
+
64
+ if "network" not in context:
65
+ diagnostics.append(Diagnostic("network", "network is required"))
66
+ network_raw: Mapping = {}
67
+ else:
68
+ network_raw = expect_mapping(context["network"], "network", diagnostics) or {}
69
+
70
+ network = _parse_network(network_raw, diagnostics)
71
+ client = _parse_client(context.get("client"), diagnostics)
72
+ hosts = _build_hosts(network, diagnostics)
73
+
74
+ parsed = ParsedContext(network=network, client=client, options=merged_options, hosts=hosts)
75
+ return parsed, diagnostics
76
+
77
+
78
+ def _parse_options(
79
+ context: Mapping, options: Optional[Mapping[str, Any]], diagnostics: List[Diagnostic]
80
+ ) -> dict:
81
+ merged: dict = {}
82
+ context_options = context.get("options")
83
+ if context_options is not None:
84
+ if isinstance(context_options, Mapping):
85
+ merged.update(context_options)
86
+ else:
87
+ diagnostics.append(
88
+ Diagnostic(
89
+ "options",
90
+ f"options must be a mapping, got {_describe(context_options)}",
91
+ )
92
+ )
93
+ if options:
94
+ merged.update(options)
95
+
96
+ target = DEFAULT_TARGET
97
+ if "target" in merged:
98
+ resolved = expect_enum(merged["target"], "options.target", diagnostics, allowed=TARGETS)
99
+ if resolved:
100
+ target = resolved
101
+ fmt = DEFAULT_FORMAT
102
+ if "format" in merged:
103
+ resolved = expect_enum(merged["format"], "options.format", diagnostics, allowed=OUTPUT_FORMATS)
104
+ if resolved:
105
+ fmt = resolved
106
+ merged["target"] = target
107
+ merged["format"] = fmt
108
+ return merged
109
+
110
+
111
+ def _parse_network(raw: Mapping, diagnostics: List[Diagnostic]) -> NetworkConfig:
112
+ domain_root = ""
113
+ if "domain_root" not in raw:
114
+ diagnostics.append(Diagnostic("network.domain_root", "domain_root is required"))
115
+ else:
116
+ domain_root = expect_string(raw.get("domain_root"), "network.domain_root", diagnostics) or ""
117
+
118
+ tunnel_mode = DEFAULT_TUNNEL_MODE
119
+ if "tunnel_mode" not in raw:
120
+ diagnostics.append(Diagnostic("network.tunnel_mode", "tunnel_mode is required"))
121
+ else:
122
+ resolved = expect_enum(
123
+ raw.get("tunnel_mode"), "network.tunnel_mode", diagnostics, allowed=TUNNEL_MODES
124
+ )
125
+ if resolved:
126
+ tunnel_mode = resolved
127
+
128
+ return NetworkConfig(
129
+ domain_root=domain_root,
130
+ subdomain_prefixes=_parse_prefixes(raw.get("subdomain_prefixes"), diagnostics),
131
+ tunnel_mode=tunnel_mode,
132
+ protocols=_parse_protocols(raw.get("protocols"), diagnostics),
133
+ routing=_parse_routing(raw.get("routing"), diagnostics),
134
+ dns=_parse_dns(raw.get("dns"), diagnostics),
135
+ )
136
+
137
+
138
+ def _parse_prefixes(value: Any, diagnostics: List[Diagnostic]) -> dict:
139
+ if value is None:
140
+ diagnostics.append(Diagnostic("network.subdomain_prefixes", "subdomain_prefixes is required"))
141
+ return {}
142
+ mapping = expect_mapping(value, "network.subdomain_prefixes", diagnostics)
143
+ if mapping is None:
144
+ return {}
145
+ prefixes: dict = {}
146
+ for key, item in mapping.items():
147
+ text = expect_string(item, f"network.subdomain_prefixes.{key}", diagnostics)
148
+ if text is not None:
149
+ prefixes[str(key)] = text
150
+ return prefixes
151
+
152
+
153
+ def _parse_protocols(value: Any, diagnostics: List[Diagnostic]) -> List[ProtocolConfig]:
154
+ if value is None:
155
+ diagnostics.append(Diagnostic("network.protocols", "protocols is required"))
156
+ return []
157
+ if not isinstance(value, list):
158
+ diagnostics.append(
159
+ Diagnostic("network.protocols", f"protocols must be a list, got {_describe(value)}")
160
+ )
161
+ return []
162
+ if not value:
163
+ diagnostics.append(Diagnostic("network.protocols", "protocols must contain at least one entry"))
164
+ return []
165
+
166
+ protocols: List[ProtocolConfig] = []
167
+ for index, item in enumerate(value):
168
+ path = f"network.protocols.{index}"
169
+ if not isinstance(item, Mapping):
170
+ diagnostics.append(Diagnostic(path, f"protocol must be a mapping, got {_describe(item)}"))
171
+ continue
172
+ resolved_type = expect_enum(
173
+ item.get("type"), f"{path}.type", diagnostics, allowed=protocol_types()
174
+ )
175
+ enabled = True
176
+ if "enabled" in item:
177
+ raw_enabled = item["enabled"]
178
+ if not isinstance(raw_enabled, bool):
179
+ diagnostics.append(
180
+ Diagnostic(
181
+ f"{path}.enabled",
182
+ f"enabled must be a boolean, got {_describe(raw_enabled)}",
183
+ )
184
+ )
185
+ else:
186
+ enabled = raw_enabled
187
+ port = None
188
+ if "port" in item:
189
+ port = expect_integer(item["port"], f"{path}.port", diagnostics, minimum=1, maximum=65535)
190
+
191
+ protocols.append(
192
+ ProtocolConfig(
193
+ type=resolved_type or str(item.get("type") or "").strip().lower(),
194
+ enabled=enabled,
195
+ port=port,
196
+ raw=dict(item),
197
+ path=path,
198
+ )
199
+ )
200
+
201
+ enabled = [cfg for cfg in protocols if cfg.enabled]
202
+ if protocols and not enabled:
203
+ diagnostics.append(Diagnostic("network.protocols", "at least one protocol must be enabled"))
204
+ for cfg in enabled:
205
+ plugin = get_plugin(cfg.type)
206
+ if plugin is not None:
207
+ plugin.validate(cfg, diagnostics)
208
+ return protocols
209
+
210
+
211
+ def _parse_routing(value: Any, diagnostics: List[Diagnostic]) -> dict:
212
+ if value is None:
213
+ return {}
214
+ mapping = expect_mapping(value, "network.routing", diagnostics)
215
+ if mapping is None:
216
+ return {}
217
+ result = dict(mapping)
218
+
219
+ if "rules_source" in mapping:
220
+ resolved = expect_enum(
221
+ mapping["rules_source"],
222
+ "network.routing.rules_source",
223
+ diagnostics,
224
+ allowed=RULES_SOURCES,
225
+ )
226
+ if resolved:
227
+ result["rules_source"] = resolved
228
+
229
+ if "geoip_cn" in mapping:
230
+ geoip = mapping["geoip_cn"]
231
+ if not isinstance(geoip, bool):
232
+ diagnostics.append(
233
+ Diagnostic(
234
+ "network.routing.geoip_cn",
235
+ f"geoip_cn must be a boolean, got {_describe(geoip)}",
236
+ )
237
+ )
238
+ else:
239
+ result["geoip_cn"] = geoip
240
+
241
+ if "custom_rules" in mapping:
242
+ custom = mapping["custom_rules"]
243
+ if not isinstance(custom, Mapping):
244
+ diagnostics.append(
245
+ Diagnostic(
246
+ "network.routing.custom_rules",
247
+ f"custom_rules must be a mapping, got {_describe(custom)}",
248
+ )
249
+ )
250
+ else:
251
+ result["custom_rules"] = _parse_custom_rules(custom, diagnostics)
252
+
253
+ return result
254
+
255
+
256
+ def _parse_custom_rules(custom: Mapping, diagnostics: List[Diagnostic]) -> dict:
257
+ cleaned: dict = {}
258
+ for key, items in custom.items():
259
+ path = f"network.routing.custom_rules.{key}"
260
+ if key not in REQUIRED_BUCKETS:
261
+ diagnostics.append(
262
+ Diagnostic(
263
+ path,
264
+ f"unknown rule bucket {key!r}; expected one of {', '.join(REQUIRED_BUCKETS)}",
265
+ )
266
+ )
267
+ continue
268
+ if not isinstance(items, list):
269
+ diagnostics.append(Diagnostic(path, f"custom rule bucket must be a list, got {_describe(items)}"))
270
+ continue
271
+ cleaned[key] = list(items)
272
+ return cleaned
273
+
274
+
275
+ def _parse_dns(value: Any, diagnostics: List[Diagnostic]) -> dict:
276
+ if value is None:
277
+ return {}
278
+ mapping = expect_mapping(value, "network.dns", diagnostics)
279
+ if mapping is None:
280
+ return {}
281
+ result = dict(mapping)
282
+
283
+ if "direct_servers" in mapping:
284
+ servers = mapping["direct_servers"]
285
+ if not isinstance(servers, list) or not servers:
286
+ diagnostics.append(
287
+ Diagnostic(
288
+ "network.dns.direct_servers",
289
+ f"direct_servers must be a non-empty list, got {_describe(servers)}",
290
+ )
291
+ )
292
+ else:
293
+ cleaned: list = []
294
+ for index, item in enumerate(servers):
295
+ text = expect_string(item, f"network.dns.direct_servers.{index}", diagnostics)
296
+ if text is not None:
297
+ cleaned.append(text)
298
+ result["direct_servers"] = cleaned
299
+
300
+ if "remote_server" in mapping:
301
+ text = expect_string(mapping["remote_server"], "network.dns.remote_server", diagnostics)
302
+ if text is not None:
303
+ result["remote_server"] = text
304
+
305
+ return result
306
+
307
+
308
+ def _parse_client(value: Any, diagnostics: List[Diagnostic]) -> ClientConfig:
309
+ if value is None:
310
+ return ClientConfig()
311
+ mapping = expect_mapping(value, "client", diagnostics)
312
+ if mapping is None:
313
+ return ClientConfig()
314
+
315
+ server_ip = None
316
+ if "server_ip" in mapping:
317
+ server_ip = expect_string(mapping["server_ip"], "client.server_ip", diagnostics)
318
+
319
+ fingerprint = DEFAULT_FINGERPRINT
320
+ if "fingerprint" in mapping:
321
+ resolved = expect_string(mapping["fingerprint"], "client.fingerprint", diagnostics)
322
+ if resolved:
323
+ fingerprint = resolved
324
+
325
+ return ClientConfig(server_ip=server_ip, fingerprint=fingerprint)
326
+
327
+
328
+ def _build_hosts(network: NetworkConfig, diagnostics: List[Diagnostic]) -> dict:
329
+ if not network.domain_root:
330
+ return {}
331
+ root = network.domain_root.strip().lower().rstrip(".")
332
+ hosts: dict = {}
333
+ for cfg in network.protocols:
334
+ if not cfg.enabled:
335
+ continue
336
+ plugin = get_plugin(cfg.type)
337
+ if plugin is None:
338
+ continue
339
+ key = plugin.host_key
340
+ if key in hosts:
341
+ continue
342
+ prefix = network.subdomain_prefixes.get(key)
343
+ if not prefix:
344
+ diagnostics.append(
345
+ Diagnostic(
346
+ f"network.subdomain_prefixes.{key}",
347
+ f"{key} subdomain prefix is required",
348
+ )
349
+ )
350
+ continue
351
+ hosts[key] = f"{prefix}.{root}"
352
+ return hosts
353
+
354
+
355
+ def _describe(value: Any) -> str:
356
+ if isinstance(value, str):
357
+ return f"string {value!r}"
358
+ if value is None:
359
+ return "null"
360
+ if isinstance(value, bool):
361
+ return f"boolean {value}"
362
+ return f"{type(value).__name__} {value!r}"
devconfig_gen/py.typed ADDED
File without changes
@@ -0,0 +1,39 @@
1
+ """Provider registration and lookup."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Dict, Iterable
6
+
7
+ from .models import ConfigProvider
8
+ from .providers import CustomProvider, EnvProvider, JsonProvider
9
+ from .providers.singbox import SingBoxProvider
10
+
11
+
12
+ class ProviderRegistry:
13
+ def __init__(self, providers: Iterable[ConfigProvider] = ()):
14
+ self._providers: Dict[str, ConfigProvider] = {}
15
+ for provider in providers:
16
+ self.register(provider)
17
+
18
+ def register(self, provider: ConfigProvider) -> None:
19
+ name = str(provider.name).strip().lower()
20
+ if not name or name != provider.name:
21
+ raise ValueError("provider names must be non-empty lowercase strings")
22
+ if name in self._providers:
23
+ raise ValueError(f"provider already registered: {name}")
24
+ self._providers[name] = provider
25
+
26
+ def get(self, name: str) -> ConfigProvider:
27
+ try:
28
+ return self._providers[str(name).strip().lower()]
29
+ except KeyError as exc:
30
+ available = ", ".join(self.names()) or "none"
31
+ raise ValueError(f"unknown provider {name!r}; available: {available}") from exc
32
+
33
+ def names(self):
34
+ return tuple(sorted(self._providers))
35
+
36
+
37
+ default_registry = ProviderRegistry(
38
+ (CustomProvider(), JsonProvider(), EnvProvider(), SingBoxProvider())
39
+ )
@@ -0,0 +1,148 @@
1
+ """Reusable, path-aware validation helpers.
2
+
3
+ Providers use these primitives to collect every problem in one pass instead of
4
+ failing on the first field. Each helper appends a :class:`Diagnostic` to the
5
+ supplied list. The rendered message uses the leaf field name so it reads
6
+ naturally, for example ``port must be between 1 and 65535, got 99999``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Mapping, Optional, Sequence
12
+
13
+ from .models import Diagnostic
14
+
15
+
16
+ class ValidationError(ValueError):
17
+ """Raised when one or more validation errors are present."""
18
+
19
+ def __init__(self, diagnostics: Sequence[Any]):
20
+ self.diagnostics = tuple(_as_diagnostic(item) for item in diagnostics)
21
+ self.errors = tuple(item.message for item in self.diagnostics)
22
+ super().__init__("; ".join(self.errors))
23
+
24
+
25
+ def _as_diagnostic(value: Any) -> Diagnostic:
26
+ if isinstance(value, Diagnostic):
27
+ return value
28
+ return Diagnostic(field="", message=str(value))
29
+
30
+
31
+ def _leaf(path: str) -> str:
32
+ return path.rsplit(".", 1)[-1] if path else path
33
+
34
+
35
+ def is_mapping(value: Any) -> bool:
36
+ return isinstance(value, Mapping)
37
+
38
+
39
+ def is_integer(value: Any) -> bool:
40
+ return isinstance(value, int) and not isinstance(value, bool)
41
+
42
+
43
+ def expect_mapping(value: Any, path: str, errors: list) -> Optional[Mapping]:
44
+ if not is_mapping(value):
45
+ errors.append(
46
+ Diagnostic(path, f"{_leaf(path)} must be a mapping, got {_describe(value)}")
47
+ )
48
+ return None
49
+ return value
50
+
51
+
52
+ def expect_string(value: Any, path: str, errors: list, *, allow_empty: bool = False) -> Optional[str]:
53
+ leaf = _leaf(path)
54
+ if not isinstance(value, str):
55
+ errors.append(Diagnostic(path, f"{leaf} must be a string, got {_describe(value)}"))
56
+ return None
57
+ text = value.strip()
58
+ if not text and not allow_empty:
59
+ errors.append(Diagnostic(path, f"{leaf} must not be empty"))
60
+ return None
61
+ return text
62
+
63
+
64
+ def expect_integer(
65
+ value: Any,
66
+ path: str,
67
+ errors: list,
68
+ *,
69
+ minimum: Optional[int] = None,
70
+ maximum: Optional[int] = None,
71
+ ) -> Optional[int]:
72
+ leaf = _leaf(path)
73
+ if not is_integer(value):
74
+ errors.append(Diagnostic(path, f"{leaf} must be an integer, got {_describe(value)}"))
75
+ return None
76
+ if minimum is not None and maximum is not None:
77
+ if value < minimum or value > maximum:
78
+ errors.append(
79
+ Diagnostic(path, f"{leaf} must be between {minimum} and {maximum}, got {value}")
80
+ )
81
+ return None
82
+ elif minimum is not None and value < minimum:
83
+ errors.append(Diagnostic(path, f"{leaf} must be >= {minimum}, got {value}"))
84
+ return None
85
+ elif maximum is not None and value > maximum:
86
+ errors.append(Diagnostic(path, f"{leaf} must be <= {maximum}, got {value}"))
87
+ return None
88
+ return value
89
+
90
+
91
+ def expect_enum(
92
+ value: Any,
93
+ path: str,
94
+ errors: list,
95
+ *,
96
+ allowed: Sequence[str],
97
+ case_insensitive: bool = True,
98
+ ) -> Optional[str]:
99
+ leaf = _leaf(path)
100
+ options = ", ".join(str(option) for option in allowed)
101
+ if not isinstance(value, str):
102
+ errors.append(
103
+ Diagnostic(path, f"{leaf} must be one of {options}, got {_describe(value)}")
104
+ )
105
+ return None
106
+ candidate = value.strip()
107
+ if case_insensitive:
108
+ for option in allowed:
109
+ if candidate.lower() == option.lower():
110
+ return option
111
+ elif candidate in allowed:
112
+ return candidate
113
+ errors.append(Diagnostic(path, f"{leaf} must be one of {options}, got {value!r}"))
114
+ return None
115
+
116
+
117
+ def expect_string_mapping(value: Any, path: str, errors: list) -> Optional[dict]:
118
+ if not is_mapping(value):
119
+ errors.append(
120
+ Diagnostic(path, f"{_leaf(path)} must be a mapping of labels, got {_describe(value)}")
121
+ )
122
+ return None
123
+ normalized = {}
124
+ for key, item in value.items():
125
+ key_text = str(key).strip()
126
+ if not key_text:
127
+ errors.append(Diagnostic(path, "label keys must not be empty"))
128
+ continue
129
+ if isinstance(item, (dict, list, tuple)):
130
+ errors.append(
131
+ Diagnostic(
132
+ f"{path}.{key_text}",
133
+ f"labels.{key_text} must be a scalar value, got {_describe(item)}",
134
+ )
135
+ )
136
+ continue
137
+ normalized[key_text] = "" if item is None else str(item)
138
+ return normalized
139
+
140
+
141
+ def _describe(value: Any) -> str:
142
+ if isinstance(value, str):
143
+ return f"string {value!r}"
144
+ if value is None:
145
+ return "null"
146
+ if isinstance(value, bool):
147
+ return f"boolean {value}"
148
+ return f"{type(value).__name__} {value!r}"