aisc-cli 0.1.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.
- aisc/VERSION +1 -0
- aisc/__init__.py +49 -0
- aisc/__main__.py +6 -0
- aisc/adapters/__init__.py +1 -0
- aisc/adapters/config_reader.py +215 -0
- aisc/adapters/container_registry.py +718 -0
- aisc/adapters/data_root_store.py +296 -0
- aisc/adapters/docker_.py +1183 -0
- aisc/adapters/docker_gateway.py +1244 -0
- aisc/adapters/docker_sdk_backed.py +274 -0
- aisc/adapters/maintenance_lock.py +97 -0
- aisc/adapters/state_file.py +184 -0
- aisc/adapters/system.py +101 -0
- aisc/adapters/windows_config_reader.py +249 -0
- aisc/adapters/workspace_lease_store.py +189 -0
- aisc/application/__init__.py +1 -0
- aisc/application/artifact.py +265 -0
- aisc/application/bundle_fetch.py +836 -0
- aisc/application/cc_switch_provider.py +245 -0
- aisc/application/cc_switch_resolver.py +363 -0
- aisc/application/config_service.py +452 -0
- aisc/application/conversation.py +516 -0
- aisc/application/data_migration.py +440 -0
- aisc/application/data_root.py +275 -0
- aisc/application/docker_lifecycle.py +659 -0
- aisc/application/doctor.py +821 -0
- aisc/application/legacy_scan.py +252 -0
- aisc/application/network_subscription.py +642 -0
- aisc/application/profile_service.py +105 -0
- aisc/application/provider.py +92 -0
- aisc/application/repair.py +387 -0
- aisc/application/resources.py +270 -0
- aisc/application/runtime.py +1826 -0
- aisc/application/session.py +320 -0
- aisc/application/tool_versions.py +210 -0
- aisc/application/toolchain.py +91 -0
- aisc/application/update.py +286 -0
- aisc/application/usage.py +305 -0
- aisc/application/version.py +133 -0
- aisc/application/web_gateway.py +459 -0
- aisc/application/workspace_reconcile.py +318 -0
- aisc/applog.py +103 -0
- aisc/cli/__init__.py +1 -0
- aisc/cli/build_progress_parser.py +217 -0
- aisc/cli/commands/__init__.py +1 -0
- aisc/cli/commands/agents.py +117 -0
- aisc/cli/commands/artifact.py +210 -0
- aisc/cli/commands/build.py +566 -0
- aisc/cli/commands/bundle.py +107 -0
- aisc/cli/commands/cc_switch.py +158 -0
- aisc/cli/commands/config.py +104 -0
- aisc/cli/commands/container.py +787 -0
- aisc/cli/commands/conversation.py +72 -0
- aisc/cli/commands/data_root.py +84 -0
- aisc/cli/commands/logs.py +89 -0
- aisc/cli/commands/network.py +132 -0
- aisc/cli/commands/profile.py +52 -0
- aisc/cli/commands/provider.py +49 -0
- aisc/cli/commands/run.py +494 -0
- aisc/cli/commands/runs.py +125 -0
- aisc/cli/commands/runtime.py +450 -0
- aisc/cli/commands/serve.py +550 -0
- aisc/cli/commands/serve_fs.py +369 -0
- aisc/cli/commands/session.py +167 -0
- aisc/cli/commands/update.py +145 -0
- aisc/cli/commands/usage.py +74 -0
- aisc/cli/commands/wizard.py +130 -0
- aisc/cli/commands/workspaces.py +162 -0
- aisc/cli/help_i18n.py +385 -0
- aisc/cli/main.py +2926 -0
- aisc/cli/output.py +239 -0
- aisc/cli/tutorial.py +109 -0
- aisc/domain/__init__.py +1 -0
- aisc/domain/artifacts.py +279 -0
- aisc/domain/cc_switch_release.py +303 -0
- aisc/domain/config.py +64 -0
- aisc/domain/data_migration.py +259 -0
- aisc/domain/data_root.py +124 -0
- aisc/domain/docker_ownership.py +148 -0
- aisc/domain/gateway.py +168 -0
- aisc/domain/models.py +674 -0
- aisc/domain/toolchain.py +72 -0
- aisc/domain/web_services.py +355 -0
- aisc/domain/workspace_lease.py +139 -0
- aisc/schemas/__init__.py +1 -0
- aisc/schemas/config_schema.py +145 -0
- aisc_cli-0.1.0.dist-info/METADATA +420 -0
- aisc_cli-0.1.0.dist-info/RECORD +92 -0
- aisc_cli-0.1.0.dist-info/WHEEL +5 -0
- aisc_cli-0.1.0.dist-info/entry_points.txt +2 -0
- aisc_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- aisc_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
"""mihomo subscription management — application layer (IDEA-2, D1-D4).
|
|
2
|
+
|
|
3
|
+
Storage (Stage 7 data root; the file below is what gets ro-mounted at
|
|
4
|
+
``/etc/mihomo/config.yaml`` — the container's ``mihomo-build-config.js`` is
|
|
5
|
+
the sole conversion authority, so the raw subscription is stored verbatim)::
|
|
6
|
+
|
|
7
|
+
<data-root>/config/mihomo/subscription.yaml raw subscription file
|
|
8
|
+
<data-root>/config/network-subscription.json snapshot (full URL for refresh)
|
|
9
|
+
|
|
10
|
+
Policies (02-data-contracts.md §1-§4, §6):
|
|
11
|
+
- the full URL only ever lives in the snapshot file under the data root;
|
|
12
|
+
envelopes, logs and diagnostics only ever see ``mask_url`` output;
|
|
13
|
+
- ``subscription-userinfo`` response header is parsed tolerantly
|
|
14
|
+
(``total=0`` = unlimited, ``expire`` may be absent, garbage pairs skipped);
|
|
15
|
+
- downloads use an injectable stdlib-urllib transport with a clash-family
|
|
16
|
+
User-Agent (providers gate payload format by UA); TLS-handshake kills are
|
|
17
|
+
classified as ``..._TLS_REJECTED`` so the UI can guide manual content
|
|
18
|
+
import (D4 — fingerprint-filtering providers drop script-stack handshakes);
|
|
19
|
+
- legacy ``<aisc_root>/.claude/mihomo/config.yaml`` is adopted once (copied,
|
|
20
|
+
never moved) when the data-root target is absent.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import hashlib
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import ssl
|
|
30
|
+
import urllib.error
|
|
31
|
+
import urllib.request
|
|
32
|
+
from datetime import datetime
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any, Callable, Dict, Mapping, Optional, Tuple
|
|
35
|
+
from urllib.parse import urlsplit
|
|
36
|
+
|
|
37
|
+
from aisc.domain.models import CliError
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
SUBSCRIPTION_SCHEMA = "aisc.network-subscription/v1"
|
|
41
|
+
|
|
42
|
+
#: Clash-family UA: subscription panels gate payload format on it (browser
|
|
43
|
+
#: UAs get the HTML panel; clash clients get the config payload).
|
|
44
|
+
USER_AGENT = "clash-verge/v2.2.0 (aisc)"
|
|
45
|
+
|
|
46
|
+
DEFAULT_TIMEOUT_S = 30.0
|
|
47
|
+
MAX_URL_LENGTH = 2048
|
|
48
|
+
|
|
49
|
+
ERROR_FETCH = "AISC_ERR_NETWORK_SUBSCRIPTION_FETCH"
|
|
50
|
+
ERROR_HTTP = "AISC_ERR_NETWORK_SUBSCRIPTION_HTTP"
|
|
51
|
+
ERROR_INVALID_URL = "AISC_ERR_NETWORK_SUBSCRIPTION_INVALID_URL"
|
|
52
|
+
ERROR_NOT_CONFIGURED = "AISC_ERR_NETWORK_SUBSCRIPTION_NOT_CONFIGURED"
|
|
53
|
+
ERROR_EMPTY = "AISC_ERR_NETWORK_SUBSCRIPTION_EMPTY"
|
|
54
|
+
ERROR_TLS_REJECTED = "AISC_ERR_NETWORK_SUBSCRIPTION_TLS_REJECTED"
|
|
55
|
+
|
|
56
|
+
#: Injectable transport: (url, headers, timeout) -> (status, headers, body).
|
|
57
|
+
#: Headers keys are lowercased; body is the raw response bytes. ``HTTPError``
|
|
58
|
+
#: is returned as a normal 4xx/5xx result; transport-level failures raise.
|
|
59
|
+
Transport = Callable[[str, Dict[str, str], float], Tuple[int, Dict[str, str], bytes]]
|
|
60
|
+
|
|
61
|
+
_USERINFO_KEYS = ("upload", "download", "total", "expire")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def default_transport(url: str, headers: Dict[str, str], timeout: float) -> Tuple[int, Dict[str, str], bytes]:
|
|
65
|
+
"""stdlib urllib transport (mirrors ``cc_switch_resolver.default_transport``
|
|
66
|
+
but returns raw bytes — the subscription payload is not JSON).
|
|
67
|
+
|
|
68
|
+
``HTTPError`` surfaces as a normal 4xx/5xx result; transport-level
|
|
69
|
+
failures (``URLError``/``OSError``) propagate raw — ``_fetch`` owns the
|
|
70
|
+
classification so custom transports behave identically.
|
|
71
|
+
"""
|
|
72
|
+
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, **headers})
|
|
73
|
+
try:
|
|
74
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
75
|
+
return int(resp.status), {k.lower(): v for k, v in resp.headers.items()}, resp.read()
|
|
76
|
+
except urllib.error.HTTPError as exc:
|
|
77
|
+
raw = exc.read() if exc.fp else b""
|
|
78
|
+
return int(exc.code), {k.lower(): v for k, v in exc.headers.items()}, raw or b""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _classify_transport_error(exc: urllib.error.URLError) -> CliError:
|
|
82
|
+
"""Map a transport-level failure onto a stable code.
|
|
83
|
+
|
|
84
|
+
Handshake-phase kills (server closes right after ClientHello — the
|
|
85
|
+
fingerprint-filtering behaviour probed in 2a §6.1) surface as
|
|
86
|
+
``ssl.SSLError``-family reasons or hard connection resets; those get
|
|
87
|
+
``TLS_REJECTED`` so the UI can suggest content import (D4). Everything
|
|
88
|
+
else (unreachable, timeouts) is a plain fetch failure.
|
|
89
|
+
"""
|
|
90
|
+
reason = exc.reason
|
|
91
|
+
if isinstance(reason, ssl.SSLError):
|
|
92
|
+
return CliError(
|
|
93
|
+
message=(
|
|
94
|
+
"subscription source rejected the connection during TLS "
|
|
95
|
+
"handshake (client fingerprint filtering). Import the config "
|
|
96
|
+
"content manually instead ('aisc network subscription "
|
|
97
|
+
"import-file', stdin = full subscription content)."
|
|
98
|
+
),
|
|
99
|
+
exit_code=1,
|
|
100
|
+
error_code=ERROR_TLS_REJECTED,
|
|
101
|
+
)
|
|
102
|
+
if isinstance(reason, ConnectionResetError):
|
|
103
|
+
return CliError(
|
|
104
|
+
message=(
|
|
105
|
+
"subscription source reset the connection during TLS "
|
|
106
|
+
"handshake. If it persists, import the config content "
|
|
107
|
+
"manually ('aisc network subscription import-file')."
|
|
108
|
+
),
|
|
109
|
+
exit_code=1,
|
|
110
|
+
error_code=ERROR_TLS_REJECTED,
|
|
111
|
+
)
|
|
112
|
+
return CliError(
|
|
113
|
+
message=(
|
|
114
|
+
f"subscription source unreachable: {reason}. Check network/proxy "
|
|
115
|
+
"availability, or import the config content manually "
|
|
116
|
+
"('aisc network subscription import-file')."
|
|
117
|
+
),
|
|
118
|
+
exit_code=1,
|
|
119
|
+
error_code=ERROR_FETCH,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
# Paths
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
def subscription_config_path(env: Optional[Mapping[str, str]] = None) -> Path:
|
|
128
|
+
"""``<data-root>/config/mihomo/subscription.yaml`` (does not create it)."""
|
|
129
|
+
from aisc.application.data_root import shared_root
|
|
130
|
+
|
|
131
|
+
return shared_root(env) / "config" / "mihomo" / "subscription.yaml"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _snapshot_path(env: Optional[Mapping[str, str]] = None) -> Path:
|
|
135
|
+
from aisc.application.data_root import shared_root
|
|
136
|
+
|
|
137
|
+
return shared_root(env) / "config" / "network-subscription.json"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _legacy_config_path(legacy_root: Optional[Path]) -> Optional[Path]:
|
|
141
|
+
if legacy_root is not None:
|
|
142
|
+
return Path(legacy_root) / ".claude" / "mihomo" / "config.yaml"
|
|
143
|
+
from aisc.application.resources import locate_aisc_root, _RootSourceError
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
root = locate_aisc_root()
|
|
147
|
+
except _RootSourceError:
|
|
148
|
+
return None
|
|
149
|
+
if root is None:
|
|
150
|
+
return None
|
|
151
|
+
return Path(root) / ".claude" / "mihomo" / "config.yaml"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def resolve_subscription_config_path(
|
|
155
|
+
explicit: Optional[str] = None,
|
|
156
|
+
*,
|
|
157
|
+
env: Optional[Mapping[str, str]] = None,
|
|
158
|
+
legacy_root: Optional[Path] = None,
|
|
159
|
+
adopt_legacy: bool = True,
|
|
160
|
+
) -> Optional[str]:
|
|
161
|
+
"""Resolve which host file a ``network=proxy`` run should mount.
|
|
162
|
+
|
|
163
|
+
Order: explicit path (returned verbatim — callers validate) → data-root
|
|
164
|
+
subscription → legacy ``<aisc_root>/.claude/mihomo/config.yaml``. With
|
|
165
|
+
``adopt_legacy=True`` the legacy file is *copied* into the data root once
|
|
166
|
+
(idempotent, source untouched); with ``adopt_legacy=False`` the legacy
|
|
167
|
+
path itself is returned (read-only callers must stay side-effect free).
|
|
168
|
+
Returns ``None`` when nothing exists (TUN without config stays valid).
|
|
169
|
+
"""
|
|
170
|
+
if explicit:
|
|
171
|
+
return str(explicit)
|
|
172
|
+
target = subscription_config_path(env)
|
|
173
|
+
if target.is_file():
|
|
174
|
+
return str(target)
|
|
175
|
+
legacy = _legacy_config_path(legacy_root)
|
|
176
|
+
if legacy is not None and legacy.is_file():
|
|
177
|
+
if not adopt_legacy:
|
|
178
|
+
return str(legacy)
|
|
179
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
tmp = target.with_name(f".{target.name}.adopt.{os.getpid()}")
|
|
181
|
+
tmp.write_bytes(legacy.read_bytes())
|
|
182
|
+
os.replace(tmp, target)
|
|
183
|
+
return str(target)
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
# Parsing / masking
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
def parse_userinfo(value: str) -> Optional[Dict[str, int]]:
|
|
192
|
+
"""Parse a ``subscription-userinfo`` header value.
|
|
193
|
+
|
|
194
|
+
``upload=..; download=..; total=..; expire=..`` — semicolon-separated
|
|
195
|
+
``k=v`` pairs, whitespace tolerated, unknown keys and malformed pairs
|
|
196
|
+
skipped. Returns ``None`` when nothing usable is present. Values are raw
|
|
197
|
+
non-negative integers (bytes / unix seconds); presentation converts.
|
|
198
|
+
"""
|
|
199
|
+
if not value:
|
|
200
|
+
return None
|
|
201
|
+
out: Dict[str, int] = {}
|
|
202
|
+
for pair in value.split(";"):
|
|
203
|
+
pair = pair.strip()
|
|
204
|
+
if not pair or "=" not in pair:
|
|
205
|
+
continue
|
|
206
|
+
key, _, raw = pair.partition("=")
|
|
207
|
+
key = key.strip().lower()
|
|
208
|
+
raw = raw.strip()
|
|
209
|
+
if key not in _USERINFO_KEYS or not raw.isdigit():
|
|
210
|
+
continue
|
|
211
|
+
out[key] = int(raw)
|
|
212
|
+
return out or None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ---------------------------------------------------------------------------
|
|
216
|
+
# Node-name usage fallback (挂账②): many airports send NO userinfo header but
|
|
217
|
+
# embed the plan facts as FAKE PROXY NODES ("已用流量:4.03 GB", "剩余流量:
|
|
218
|
+
# 9999995.97 GB", "套餐总量:10000000 GB", "套餐到期:永久有效"). When the
|
|
219
|
+
# header is absent, the raw subscription text is scanned for these patterns
|
|
220
|
+
# and the result is mapped onto the header's userinfo shape (upload+download
|
|
221
|
+
# = used ⇒ everything lands in `download`; derivations fill the gaps).
|
|
222
|
+
# ---------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
_UNIT_MULT = {
|
|
225
|
+
"B": 1, "KB": 1_000, "MB": 1_000_000, "GB": 1_000_000_000, "TB": 10**12,
|
|
226
|
+
"KIB": 1024, "MIB": 1024**2, "GIB": 1024**3, "TIB": 1024**4,
|
|
227
|
+
}
|
|
228
|
+
_TRAFFIC_VALUE = r"([0-9][0-9.,]*)\s*(B|KB|MB|GB|TB|KiB|MiB|GiB|TiB)"
|
|
229
|
+
_NODE_USAGE_PATTERNS = {
|
|
230
|
+
"used": re.compile(r"(?:已用|已使用|使用量)[^::\n]{0,6}[::]\s*" + _TRAFFIC_VALUE, re.I),
|
|
231
|
+
"remaining": re.compile(r"(?:剩余|可用|余量)[^::\n]{0,6}[::]\s*" + _TRAFFIC_VALUE, re.I),
|
|
232
|
+
"total": re.compile(r"(?:总流量|套餐总量|流量总量|总量)[^::\n]{0,6}[::]\s*" + _TRAFFIC_VALUE, re.I),
|
|
233
|
+
}
|
|
234
|
+
_EXPIRE_LINE_RE = re.compile(r"(?:到期|过期|有效期|expire)[^::\n]{0,6}[::]\s*([^||,,\n\r]{1,40})", re.I)
|
|
235
|
+
_PERMANENT_RE = re.compile(r"永久|不过期|长期|never", re.I)
|
|
236
|
+
_EXPIRE_DATE_FORMATS = ("%Y-%m-%d %H:%M:%S", "%Y/%m/%d %H:%M:%S",
|
|
237
|
+
"%Y-%m-%d %H:%M", "%Y/%m/%d %H:%M",
|
|
238
|
+
"%Y-%m-%d", "%Y/%m/%d", "%Y.%m.%d")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _traffic_to_bytes(value: str, unit: str) -> Optional[int]:
|
|
242
|
+
try:
|
|
243
|
+
return int(float(value.replace(",", "")) * _UNIT_MULT[unit.upper()])
|
|
244
|
+
except (ValueError, KeyError, OverflowError):
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def parse_node_name_userinfo(text: str) -> Optional[Dict[str, int]]:
|
|
249
|
+
"""Extract plan usage from fake-node names in the subscription text.
|
|
250
|
+
|
|
251
|
+
Returns the header-shaped dict (``download`` carries the used bytes) or
|
|
252
|
+
``None`` when nothing recognizable is present. Derivations: any one of
|
|
253
|
+
used/total missing is filled from the other two when possible; a
|
|
254
|
+
permanent-plan keyword omits ``expire``; a parseable date becomes an
|
|
255
|
+
epoch."""
|
|
256
|
+
found: Dict[str, int] = {}
|
|
257
|
+
for name, pattern in _NODE_USAGE_PATTERNS.items():
|
|
258
|
+
m = pattern.search(text)
|
|
259
|
+
if m:
|
|
260
|
+
amount = _traffic_to_bytes(m.group(1), m.group(2))
|
|
261
|
+
if amount is not None:
|
|
262
|
+
found[name] = amount
|
|
263
|
+
used = found.get("used")
|
|
264
|
+
remaining = found.get("remaining")
|
|
265
|
+
total = found.get("total")
|
|
266
|
+
if total is None and used is not None and remaining is not None:
|
|
267
|
+
total = used + remaining
|
|
268
|
+
elif used is None and total is not None and remaining is not None:
|
|
269
|
+
used = total - remaining
|
|
270
|
+
if used is None or (total is None and remaining is None):
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
out: Dict[str, int] = {"upload": 0, "download": used}
|
|
274
|
+
if total is not None:
|
|
275
|
+
out["total"] = total
|
|
276
|
+
m = _EXPIRE_LINE_RE.search(text)
|
|
277
|
+
if m:
|
|
278
|
+
raw = m.group(1).strip()
|
|
279
|
+
if not _PERMANENT_RE.search(raw):
|
|
280
|
+
for fmt in _EXPIRE_DATE_FORMATS:
|
|
281
|
+
try:
|
|
282
|
+
out["expire"] = int(datetime.strptime(raw, fmt).timestamp())
|
|
283
|
+
break
|
|
284
|
+
except ValueError:
|
|
285
|
+
continue
|
|
286
|
+
return out
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def mask_url(url: str) -> Optional[str]:
|
|
290
|
+
"""Deterministic display form: scheme + host survive, path truncated,
|
|
291
|
+
query replaced by ``****``, fragment dropped. ``None`` for garbage."""
|
|
292
|
+
try:
|
|
293
|
+
parts = urlsplit(url.strip())
|
|
294
|
+
except ValueError:
|
|
295
|
+
return None
|
|
296
|
+
if not parts.scheme or not parts.netloc:
|
|
297
|
+
return None
|
|
298
|
+
path = parts.path or "/"
|
|
299
|
+
if len(path) > 8:
|
|
300
|
+
path = path[:8] + "…"
|
|
301
|
+
masked = f"{parts.scheme}://{parts.netloc}{path}"
|
|
302
|
+
if parts.query:
|
|
303
|
+
masked += "?****"
|
|
304
|
+
return masked
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _validate_url(url: str) -> str:
|
|
308
|
+
url = url.strip()
|
|
309
|
+
if not url or len(url) > MAX_URL_LENGTH:
|
|
310
|
+
raise CliError(
|
|
311
|
+
message="subscription URL is empty or longer than "
|
|
312
|
+
f"{MAX_URL_LENGTH} characters",
|
|
313
|
+
exit_code=2,
|
|
314
|
+
error_code=ERROR_INVALID_URL,
|
|
315
|
+
)
|
|
316
|
+
parts = urlsplit(url)
|
|
317
|
+
if parts.scheme not in ("http", "https") or not parts.netloc:
|
|
318
|
+
raise CliError(
|
|
319
|
+
message="subscription URL must be an absolute http(s) URL",
|
|
320
|
+
exit_code=2,
|
|
321
|
+
error_code=ERROR_INVALID_URL,
|
|
322
|
+
)
|
|
323
|
+
return url
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# ---------------------------------------------------------------------------
|
|
327
|
+
# Storage
|
|
328
|
+
# ---------------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
def _atomic_write(path: Path, payload: bytes) -> None:
|
|
331
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
332
|
+
tmp = path.with_name(f".{path.name}.tmp.{os.getpid()}")
|
|
333
|
+
tmp.write_bytes(payload)
|
|
334
|
+
os.replace(tmp, path)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _sha256_hex(data: bytes) -> str:
|
|
338
|
+
return f"sha256:{hashlib.sha256(data).hexdigest()}"
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _load_snapshot(env: Optional[Mapping[str, str]] = None) -> Optional[Dict[str, Any]]:
|
|
342
|
+
try:
|
|
343
|
+
raw = _snapshot_path(env).read_text(encoding="utf-8")
|
|
344
|
+
except OSError:
|
|
345
|
+
return None
|
|
346
|
+
try:
|
|
347
|
+
data = json.loads(raw)
|
|
348
|
+
except json.JSONDecodeError:
|
|
349
|
+
return None
|
|
350
|
+
return data if isinstance(data, dict) else None
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _envelope_data(
|
|
354
|
+
*,
|
|
355
|
+
configured: bool,
|
|
356
|
+
source: Optional[str],
|
|
357
|
+
url_masked: Optional[str],
|
|
358
|
+
fetched_at: Optional[str],
|
|
359
|
+
config_sha256: Optional[str],
|
|
360
|
+
has_config_file: bool,
|
|
361
|
+
userinfo: Optional[Dict[str, int]],
|
|
362
|
+
userinfo_source: Optional[str] = None,
|
|
363
|
+
config_path: Optional[str] = None,
|
|
364
|
+
) -> Dict[str, Any]:
|
|
365
|
+
data: Dict[str, Any] = {
|
|
366
|
+
"configured": configured,
|
|
367
|
+
"source": source,
|
|
368
|
+
"url_masked": url_masked,
|
|
369
|
+
"fetched_at": fetched_at,
|
|
370
|
+
"config_sha256": config_sha256,
|
|
371
|
+
"has_config_file": has_config_file,
|
|
372
|
+
"userinfo": userinfo,
|
|
373
|
+
}
|
|
374
|
+
if userinfo_source is not None:
|
|
375
|
+
data["userinfo_source"] = userinfo_source
|
|
376
|
+
if config_path is not None:
|
|
377
|
+
data["config_path"] = config_path
|
|
378
|
+
return data
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
# ---------------------------------------------------------------------------
|
|
382
|
+
# Operations (envelope data per 02-data-contracts.md §4)
|
|
383
|
+
# ---------------------------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
def _fetch(
|
|
386
|
+
url: str,
|
|
387
|
+
*,
|
|
388
|
+
transport: Transport,
|
|
389
|
+
timeout: float,
|
|
390
|
+
) -> Tuple[bytes, Dict[str, str]]:
|
|
391
|
+
"""Fetch with one retry on transient failures (4xx never retries)."""
|
|
392
|
+
attempts = 2
|
|
393
|
+
last_error: Optional[CliError] = None
|
|
394
|
+
for attempt in range(attempts):
|
|
395
|
+
err: Optional[CliError] = None
|
|
396
|
+
try:
|
|
397
|
+
status, headers, body = transport(url, {"User-Agent": USER_AGENT}, timeout)
|
|
398
|
+
except urllib.error.URLError as exc:
|
|
399
|
+
err = _classify_transport_error(exc)
|
|
400
|
+
except CliError as exc:
|
|
401
|
+
err = exc
|
|
402
|
+
except (TimeoutError, OSError) as exc:
|
|
403
|
+
err = CliError(
|
|
404
|
+
message=f"subscription download failed: {exc}",
|
|
405
|
+
exit_code=1,
|
|
406
|
+
error_code=ERROR_FETCH,
|
|
407
|
+
)
|
|
408
|
+
if err is not None:
|
|
409
|
+
# TLS_REJECTED is deterministic — retrying only burns the budget.
|
|
410
|
+
if err.error_code == ERROR_TLS_REJECTED or attempt == attempts - 1:
|
|
411
|
+
raise err
|
|
412
|
+
last_error = err
|
|
413
|
+
continue
|
|
414
|
+
if 200 <= status < 300:
|
|
415
|
+
if not body:
|
|
416
|
+
raise CliError(
|
|
417
|
+
message="subscription source returned an empty body",
|
|
418
|
+
exit_code=1,
|
|
419
|
+
error_code=ERROR_EMPTY,
|
|
420
|
+
)
|
|
421
|
+
return body, headers
|
|
422
|
+
if 400 <= status < 500:
|
|
423
|
+
raise CliError(
|
|
424
|
+
message=f"subscription source returned HTTP {status}",
|
|
425
|
+
exit_code=1,
|
|
426
|
+
error_code=ERROR_HTTP,
|
|
427
|
+
data={"http_status": status},
|
|
428
|
+
)
|
|
429
|
+
# 5xx (or unexpected): retry once, then fail as FETCH.
|
|
430
|
+
last_error = CliError(
|
|
431
|
+
message=f"subscription source returned HTTP {status}",
|
|
432
|
+
exit_code=1,
|
|
433
|
+
error_code=ERROR_FETCH,
|
|
434
|
+
data={"http_status": status},
|
|
435
|
+
)
|
|
436
|
+
assert last_error is not None
|
|
437
|
+
raise last_error
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def import_subscription(
|
|
441
|
+
url: str,
|
|
442
|
+
*,
|
|
443
|
+
transport: Optional[Transport] = None,
|
|
444
|
+
timeout: float = DEFAULT_TIMEOUT_S,
|
|
445
|
+
env: Optional[Mapping[str, str]] = None,
|
|
446
|
+
) -> Dict[str, Any]:
|
|
447
|
+
"""Download a subscription, store it, return the envelope data.
|
|
448
|
+
|
|
449
|
+
The URL is a credential — callers must pass it via stdin, never argv.
|
|
450
|
+
``transport`` resolves lazily so tests can patch
|
|
451
|
+
``network_subscription.default_transport`` at module level.
|
|
452
|
+
"""
|
|
453
|
+
transport = transport or default_transport
|
|
454
|
+
url = _validate_url(url)
|
|
455
|
+
body, headers = _fetch(url, transport=transport, timeout=timeout)
|
|
456
|
+
userinfo = parse_userinfo(headers.get("subscription-userinfo", ""))
|
|
457
|
+
userinfo_source = "header" if userinfo else None
|
|
458
|
+
if userinfo is None:
|
|
459
|
+
# 挂账②: airports without the header embed the facts as fake nodes.
|
|
460
|
+
userinfo = parse_node_name_userinfo(body.decode("utf-8", "replace"))
|
|
461
|
+
if userinfo is not None:
|
|
462
|
+
userinfo_source = "node-names"
|
|
463
|
+
return _persist(
|
|
464
|
+
url=url, body=body, userinfo=userinfo, userinfo_source=userinfo_source,
|
|
465
|
+
source="download", env=env,
|
|
466
|
+
fetched_at=datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def import_subscription_content(
|
|
471
|
+
content: bytes,
|
|
472
|
+
*,
|
|
473
|
+
env: Optional[Mapping[str, str]] = None,
|
|
474
|
+
) -> Dict[str, Any]:
|
|
475
|
+
"""Store manually supplied subscription content (D4 fallback for
|
|
476
|
+
fingerprint-filtering sources). No URL — and no header either, so the
|
|
477
|
+
node-name fallback is the only usage source."""
|
|
478
|
+
if not content or not content.strip():
|
|
479
|
+
raise CliError(
|
|
480
|
+
message="subscription content is empty",
|
|
481
|
+
exit_code=2,
|
|
482
|
+
error_code=ERROR_EMPTY,
|
|
483
|
+
)
|
|
484
|
+
userinfo = parse_node_name_userinfo(content.decode("utf-8", "replace"))
|
|
485
|
+
return _persist(
|
|
486
|
+
url=None, body=content, userinfo=userinfo,
|
|
487
|
+
userinfo_source="node-names" if userinfo else None,
|
|
488
|
+
source="manual", env=env,
|
|
489
|
+
fetched_at=datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _persist(
|
|
494
|
+
*,
|
|
495
|
+
url: Optional[str],
|
|
496
|
+
body: bytes,
|
|
497
|
+
userinfo: Optional[Dict[str, int]],
|
|
498
|
+
userinfo_source: Optional[str],
|
|
499
|
+
source: str,
|
|
500
|
+
env: Optional[Mapping[str, str]],
|
|
501
|
+
fetched_at: str,
|
|
502
|
+
) -> Dict[str, Any]:
|
|
503
|
+
config_path = subscription_config_path(env)
|
|
504
|
+
_atomic_write(config_path, body)
|
|
505
|
+
snapshot: Dict[str, Any] = {
|
|
506
|
+
"schema": SUBSCRIPTION_SCHEMA,
|
|
507
|
+
"url": url,
|
|
508
|
+
"source": source,
|
|
509
|
+
"fetched_at": fetched_at,
|
|
510
|
+
"config_sha256": _sha256_hex(body),
|
|
511
|
+
"userinfo": userinfo,
|
|
512
|
+
"userinfo_source": userinfo_source,
|
|
513
|
+
}
|
|
514
|
+
_atomic_write(
|
|
515
|
+
_snapshot_path(env),
|
|
516
|
+
json.dumps(snapshot, ensure_ascii=False, indent=2).encode("utf-8"),
|
|
517
|
+
)
|
|
518
|
+
return _envelope_data(
|
|
519
|
+
configured=True,
|
|
520
|
+
source=source,
|
|
521
|
+
url_masked=mask_url(url) if url else None,
|
|
522
|
+
fetched_at=fetched_at,
|
|
523
|
+
config_sha256=snapshot["config_sha256"],
|
|
524
|
+
has_config_file=True,
|
|
525
|
+
userinfo=userinfo,
|
|
526
|
+
userinfo_source=userinfo_source,
|
|
527
|
+
config_path=str(config_path),
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def store_downloaded(
|
|
532
|
+
url: Optional[str],
|
|
533
|
+
content: bytes,
|
|
534
|
+
userinfo_header: str = "",
|
|
535
|
+
*,
|
|
536
|
+
env: Optional[Mapping[str, str]] = None,
|
|
537
|
+
) -> Dict[str, Any]:
|
|
538
|
+
"""Persist a Rust-side download (挂账① transport split).
|
|
539
|
+
|
|
540
|
+
The Workbench's reqwest downloader (the clash-verge stack — the only
|
|
541
|
+
non-browser transport passing airport TLS-fingerprint walls) hands the
|
|
542
|
+
raw body and the captured ``subscription-userinfo`` header here; every
|
|
543
|
+
persistence/parsing rule stays in this module (snapshot, masking,
|
|
544
|
+
node-name fallback)."""
|
|
545
|
+
if not content or not content.strip():
|
|
546
|
+
raise CliError(
|
|
547
|
+
message="subscription content is empty",
|
|
548
|
+
exit_code=2,
|
|
549
|
+
error_code=ERROR_EMPTY,
|
|
550
|
+
)
|
|
551
|
+
userinfo = parse_userinfo(userinfo_header)
|
|
552
|
+
userinfo_source = "header" if userinfo else None
|
|
553
|
+
if userinfo is None:
|
|
554
|
+
userinfo = parse_node_name_userinfo(content.decode("utf-8", "replace"))
|
|
555
|
+
if userinfo is not None:
|
|
556
|
+
userinfo_source = "node-names"
|
|
557
|
+
return _persist(
|
|
558
|
+
url=url, body=content, userinfo=userinfo, userinfo_source=userinfo_source,
|
|
559
|
+
source="download", env=env,
|
|
560
|
+
fetched_at=datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def refresh_subscription(
|
|
565
|
+
*,
|
|
566
|
+
transport: Optional[Transport] = None,
|
|
567
|
+
timeout: float = DEFAULT_TIMEOUT_S,
|
|
568
|
+
env: Optional[Mapping[str, str]] = None,
|
|
569
|
+
) -> Dict[str, Any]:
|
|
570
|
+
"""Re-fetch the stored subscription URL."""
|
|
571
|
+
transport = transport or default_transport
|
|
572
|
+
snapshot = _load_snapshot(env)
|
|
573
|
+
url = (snapshot or {}).get("url")
|
|
574
|
+
if not url or not isinstance(url, str):
|
|
575
|
+
raise CliError(
|
|
576
|
+
message="no subscription URL stored (import one first, or use "
|
|
577
|
+
"import-file for manually supplied content)",
|
|
578
|
+
exit_code=1,
|
|
579
|
+
error_code=ERROR_NOT_CONFIGURED,
|
|
580
|
+
)
|
|
581
|
+
return import_subscription(url, transport=transport, timeout=timeout, env=env)
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def show_subscription(
|
|
585
|
+
*,
|
|
586
|
+
env: Optional[Mapping[str, str]] = None,
|
|
587
|
+
legacy_root: Optional[Path] = None,
|
|
588
|
+
) -> Dict[str, Any]:
|
|
589
|
+
"""Secret-free snapshot of the current subscription state (no fetch)."""
|
|
590
|
+
# One-time legacy adoption so an existing CLI-wizard config keeps working
|
|
591
|
+
# from the data root without a manual copy.
|
|
592
|
+
resolve_subscription_config_path(env=env, legacy_root=legacy_root, adopt_legacy=True)
|
|
593
|
+
|
|
594
|
+
config_path = subscription_config_path(env)
|
|
595
|
+
snapshot = _load_snapshot(env) or {}
|
|
596
|
+
if not config_path.is_file():
|
|
597
|
+
return _envelope_data(
|
|
598
|
+
configured=False, source=None, url_masked=None, fetched_at=None,
|
|
599
|
+
config_sha256=None, has_config_file=False, userinfo=None,
|
|
600
|
+
)
|
|
601
|
+
try:
|
|
602
|
+
sha = _sha256_hex(config_path.read_bytes())
|
|
603
|
+
except OSError:
|
|
604
|
+
sha = None
|
|
605
|
+
userinfo = snapshot.get("userinfo")
|
|
606
|
+
if not isinstance(userinfo, dict):
|
|
607
|
+
userinfo = None
|
|
608
|
+
userinfo_source = snapshot.get("userinfo_source")
|
|
609
|
+
if userinfo_source not in ("header", "node-names"):
|
|
610
|
+
userinfo_source = None
|
|
611
|
+
url = snapshot.get("url")
|
|
612
|
+
return _envelope_data(
|
|
613
|
+
configured=True,
|
|
614
|
+
source=snapshot.get("source") or "manual",
|
|
615
|
+
url_masked=mask_url(url) if isinstance(url, str) else None,
|
|
616
|
+
fetched_at=snapshot.get("fetched_at"),
|
|
617
|
+
config_sha256=sha,
|
|
618
|
+
has_config_file=True,
|
|
619
|
+
userinfo=userinfo,
|
|
620
|
+
userinfo_source=userinfo_source,
|
|
621
|
+
config_path=str(config_path),
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def clear_subscription(
|
|
626
|
+
*,
|
|
627
|
+
env: Optional[Mapping[str, str]] = None,
|
|
628
|
+
) -> Dict[str, Any]:
|
|
629
|
+
"""Remove the stored subscription (config file + snapshot). The legacy
|
|
630
|
+
repo-root file, if any, is deliberately left untouched."""
|
|
631
|
+
for path in (subscription_config_path(env), _snapshot_path(env)):
|
|
632
|
+
try:
|
|
633
|
+
path.unlink()
|
|
634
|
+
except FileNotFoundError:
|
|
635
|
+
pass
|
|
636
|
+
except OSError as exc:
|
|
637
|
+
raise CliError(
|
|
638
|
+
message=f"failed to remove {path}: {exc}",
|
|
639
|
+
exit_code=1,
|
|
640
|
+
error_code="AISC_ERR_GENERAL",
|
|
641
|
+
) from exc
|
|
642
|
+
return {"configured": False}
|