llmt-cli 0.2.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.
- llmt/__init__.py +7 -0
- llmt/api.py +138 -0
- llmt/cli.py +562 -0
- llmt/clients.py +1316 -0
- llmt/data/manifest-pubkey.json +7 -0
- llmt/doctor.py +970 -0
- llmt/doctorcmd.py +142 -0
- llmt/download.py +321 -0
- llmt/errors.py +132 -0
- llmt/layout.py +676 -0
- llmt/manifest.py +314 -0
- llmt/matcher.py +358 -0
- llmt/models.py +130 -0
- llmt/output.py +51 -0
- llmt/permissions.py +154 -0
- llmt/scancmd.py +556 -0
- llmt/scanner.py +328 -0
- llmt/telemetry/__init__.py +15 -0
- llmt/telemetry/artifacts.py +217 -0
- llmt/telemetry/beat.py +221 -0
- llmt/telemetry/cmd.py +358 -0
- llmt/telemetry/collect.py +252 -0
- llmt/telemetry/config.py +237 -0
- llmt/telemetry/consent.py +57 -0
- llmt/telemetry/errors.py +41 -0
- llmt/telemetry/wire.py +194 -0
- llmt/verify.py +129 -0
- llmt_cli-0.2.0.dist-info/METADATA +307 -0
- llmt_cli-0.2.0.dist-info/RECORD +33 -0
- llmt_cli-0.2.0.dist-info/WHEEL +5 -0
- llmt_cli-0.2.0.dist-info/entry_points.txt +2 -0
- llmt_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- llmt_cli-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Read torrents from a BitTorrent client for a telemetry heartbeat.
|
|
2
|
+
|
|
3
|
+
Additive, read-only wrappers over the frozen ``clients.py`` adapters (mirrors
|
|
4
|
+
the doctor sources). The CRITICAL correctness requirement is HYBRID KEYING
|
|
5
|
+
(H3): qBittorrent 4.6 keys a hybrid (v1+v2) torrent by its TRUNCATED v2
|
|
6
|
+
infohash. A v1-only report silently credits nothing. We therefore read BOTH
|
|
7
|
+
``infohash_v1`` and ``infohash_v2`` and send BOTH, so the server matches
|
|
8
|
+
whichever it keyed on.
|
|
9
|
+
|
|
10
|
+
Only infohashes / name / state / progress ever leave the machine. Local file
|
|
11
|
+
paths (``content_path``/``files``) are collected solely to answer possession
|
|
12
|
+
challenges locally and are NEVER put on the wire.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
import requests
|
|
23
|
+
|
|
24
|
+
from ..clients import ClientError, QBittorrentClient, TransmissionClient
|
|
25
|
+
|
|
26
|
+
_HEX40_RE = re.compile(r"^[0-9a-f]{40}$")
|
|
27
|
+
_HEX64_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
28
|
+
|
|
29
|
+
#: The frozen wire state vocabulary (contract (b)1).
|
|
30
|
+
WIRE_STATES = ("seeding", "downloading", "stalled", "paused", "error")
|
|
31
|
+
|
|
32
|
+
#: Bound on files enumerated per torrent when resolving local content (defence
|
|
33
|
+
#: against a pathological content dir); mirrors doctor's MAX_LAYOUT_WALK.
|
|
34
|
+
MAX_CONTENT_FILES = 256
|
|
35
|
+
|
|
36
|
+
#: Bound on a torrent name from an untrusted local client (anti-OOM).
|
|
37
|
+
MAX_NAME_LEN = 1024
|
|
38
|
+
|
|
39
|
+
# qBittorrent raw state -> frozen wire state. Unmapped -> "stalled" (never
|
|
40
|
+
# fabricates "seeding"): honest "present but not clearly progressing".
|
|
41
|
+
_QBT_STATE = {
|
|
42
|
+
"uploading": "seeding", "forcedUP": "seeding",
|
|
43
|
+
"stalledUP": "stalled", "stalledDL": "stalled",
|
|
44
|
+
"queuedUP": "stalled", "queuedDL": "stalled",
|
|
45
|
+
"pausedUP": "paused", "pausedDL": "paused",
|
|
46
|
+
"stoppedUP": "paused", "stoppedDL": "paused",
|
|
47
|
+
"downloading": "downloading", "forcedDL": "downloading",
|
|
48
|
+
"metaDL": "downloading", "forcedMetaDL": "downloading",
|
|
49
|
+
"allocating": "downloading", "checkingUP": "downloading",
|
|
50
|
+
"checkingDL": "downloading", "checkingResumeData": "downloading",
|
|
51
|
+
"moving": "downloading",
|
|
52
|
+
"error": "error", "missingFiles": "error",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# Transmission status int -> frozen wire state.
|
|
56
|
+
_TR_STATE = {0: "paused", 1: "downloading", 2: "downloading",
|
|
57
|
+
3: "stalled", 4: "downloading", 5: "stalled", 6: "seeding"}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class CollectedTorrent:
|
|
62
|
+
"""One torrent as observed locally. ``content_path``/``local_files`` are
|
|
63
|
+
for LOCAL challenge resolution only and are never serialized to the wire."""
|
|
64
|
+
infohash_v1: Optional[str]
|
|
65
|
+
infohash_v2: Optional[str]
|
|
66
|
+
name: Optional[str]
|
|
67
|
+
state: str
|
|
68
|
+
progress: float
|
|
69
|
+
content_path: str = ""
|
|
70
|
+
save_path: str = ""
|
|
71
|
+
local_files: list = field(default_factory=list)
|
|
72
|
+
|
|
73
|
+
def has_key(self) -> bool:
|
|
74
|
+
return bool(self.infohash_v1 or self.infohash_v2 or self.name)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _bounded_name(value: object) -> Optional[str]:
|
|
78
|
+
if not isinstance(value, str):
|
|
79
|
+
return None
|
|
80
|
+
return value[:MAX_NAME_LEN]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _hex(value: object, *, length: int) -> Optional[str]:
|
|
84
|
+
if not isinstance(value, str):
|
|
85
|
+
return None
|
|
86
|
+
v = value.strip().lower()
|
|
87
|
+
if length == 40 and _HEX40_RE.match(v):
|
|
88
|
+
return v
|
|
89
|
+
if length == 64 and _HEX64_RE.match(v):
|
|
90
|
+
return v
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _clamp_progress(value: object) -> float:
|
|
95
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
96
|
+
return 0.0
|
|
97
|
+
p = float(value)
|
|
98
|
+
if p < 0.0:
|
|
99
|
+
return 0.0
|
|
100
|
+
if p > 1.0:
|
|
101
|
+
return 1.0
|
|
102
|
+
return p
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _local_files(content_path: str, cap: int = MAX_CONTENT_FILES) -> list:
|
|
106
|
+
"""Enumerate regular files under ``content_path`` (a local path). Symlinks
|
|
107
|
+
are never followed. Returns [] if the path is not present locally."""
|
|
108
|
+
if not content_path:
|
|
109
|
+
return []
|
|
110
|
+
try:
|
|
111
|
+
if os.path.islink(content_path):
|
|
112
|
+
return []
|
|
113
|
+
if os.path.isfile(content_path):
|
|
114
|
+
return [content_path]
|
|
115
|
+
if not os.path.isdir(content_path):
|
|
116
|
+
return []
|
|
117
|
+
except OSError:
|
|
118
|
+
return []
|
|
119
|
+
out: list = []
|
|
120
|
+
for dirpath, dirnames, filenames in os.walk(content_path, followlinks=False):
|
|
121
|
+
for fn in sorted(filenames):
|
|
122
|
+
fp = os.path.join(dirpath, fn)
|
|
123
|
+
try:
|
|
124
|
+
if os.path.islink(fp) or not os.path.isfile(fp):
|
|
125
|
+
continue
|
|
126
|
+
except OSError:
|
|
127
|
+
continue
|
|
128
|
+
out.append(fp)
|
|
129
|
+
if len(out) >= cap:
|
|
130
|
+
return out
|
|
131
|
+
return out
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# --------------------------------------------------------------------------- #
|
|
135
|
+
# qBittorrent
|
|
136
|
+
# --------------------------------------------------------------------------- #
|
|
137
|
+
|
|
138
|
+
class QbtTelemetrySource:
|
|
139
|
+
kind = "qbittorrent"
|
|
140
|
+
|
|
141
|
+
def __init__(self, client: QBittorrentClient):
|
|
142
|
+
self.client = client
|
|
143
|
+
|
|
144
|
+
def _get(self, path: str, params: Optional[dict] = None):
|
|
145
|
+
c = self.client
|
|
146
|
+
r = c.session.get(f"{c.url}{path}", params=params or {},
|
|
147
|
+
headers=c._headers(), timeout=c.timeout)
|
|
148
|
+
if r.status_code != 200:
|
|
149
|
+
raise ClientError(f"qBittorrent GET {path} failed "
|
|
150
|
+
f"(HTTP {r.status_code}).")
|
|
151
|
+
return r
|
|
152
|
+
|
|
153
|
+
def collect(self, max_torrents: Optional[int] = None) -> list:
|
|
154
|
+
self.client._login()
|
|
155
|
+
rows = self._get("/api/v2/torrents/info").json()
|
|
156
|
+
if not isinstance(rows, list):
|
|
157
|
+
return []
|
|
158
|
+
out: list = []
|
|
159
|
+
for t in rows:
|
|
160
|
+
if not isinstance(t, dict):
|
|
161
|
+
continue
|
|
162
|
+
# Cap DURING collection: a hostile client returning 10^6 rows must
|
|
163
|
+
# not trigger 10^6 os.walk()s or unbounded memory. Stop at the cap.
|
|
164
|
+
if max_torrents is not None and len(out) >= max_torrents:
|
|
165
|
+
break
|
|
166
|
+
out.append(self._one(t))
|
|
167
|
+
return out
|
|
168
|
+
|
|
169
|
+
def _one(self, t: dict) -> CollectedTorrent:
|
|
170
|
+
# Prefer the explicit v1/v2 fields (qBt 4.4+). Both are populated for a
|
|
171
|
+
# HYBRID torrent -> we report BOTH (the H3 fix). Fall back to the legacy
|
|
172
|
+
# `hash` (client key) only if neither explicit field is a valid hex.
|
|
173
|
+
v1 = _hex(t.get("infohash_v1"), length=40)
|
|
174
|
+
v2 = _hex(t.get("infohash_v2"), length=64)
|
|
175
|
+
if v1 is None and v2 is None:
|
|
176
|
+
legacy = _hex(t.get("hash"), length=40) or _hex(t.get("hash"),
|
|
177
|
+
length=64)
|
|
178
|
+
if legacy and len(legacy) == 40:
|
|
179
|
+
v1 = legacy
|
|
180
|
+
elif legacy:
|
|
181
|
+
v2 = legacy
|
|
182
|
+
raw = t.get("state") if isinstance(t.get("state"), str) else ""
|
|
183
|
+
content_path = str(t.get("content_path") or "")
|
|
184
|
+
save_path = str(t.get("save_path") or "")
|
|
185
|
+
return CollectedTorrent(
|
|
186
|
+
infohash_v1=v1, infohash_v2=v2,
|
|
187
|
+
name=_bounded_name(t.get("name")),
|
|
188
|
+
state=_QBT_STATE.get(raw, "stalled"),
|
|
189
|
+
progress=_clamp_progress(t.get("progress")),
|
|
190
|
+
content_path=content_path, save_path=save_path,
|
|
191
|
+
local_files=_local_files(content_path),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# --------------------------------------------------------------------------- #
|
|
196
|
+
# Transmission
|
|
197
|
+
# --------------------------------------------------------------------------- #
|
|
198
|
+
|
|
199
|
+
class TransmissionTelemetrySource:
|
|
200
|
+
kind = "transmission"
|
|
201
|
+
|
|
202
|
+
_FIELDS = ["name", "hashString", "status", "error", "errorString",
|
|
203
|
+
"percentDone", "downloadDir", "files"]
|
|
204
|
+
|
|
205
|
+
def __init__(self, client: TransmissionClient):
|
|
206
|
+
self.client = client
|
|
207
|
+
|
|
208
|
+
def collect(self, max_torrents: Optional[int] = None) -> list:
|
|
209
|
+
args = self.client._rpc("torrent-get", {"fields": self._FIELDS}) \
|
|
210
|
+
.get("arguments") or {}
|
|
211
|
+
tor = args.get("torrents") or []
|
|
212
|
+
out: list = []
|
|
213
|
+
for t in tor:
|
|
214
|
+
if not isinstance(t, dict):
|
|
215
|
+
continue
|
|
216
|
+
if max_torrents is not None and len(out) >= max_torrents:
|
|
217
|
+
break
|
|
218
|
+
out.append(self._one(t))
|
|
219
|
+
return out
|
|
220
|
+
|
|
221
|
+
def _one(self, t: dict) -> CollectedTorrent:
|
|
222
|
+
# Transmission exposes the v1 infohash as `hashString`. It does not
|
|
223
|
+
# surface a v2 infohash over the standard RPC, so v2 stays None (we
|
|
224
|
+
# never fabricate one); the name is still sent as a match key.
|
|
225
|
+
v1 = _hex(t.get("hashString"), length=40)
|
|
226
|
+
status = t.get("status")
|
|
227
|
+
state = _TR_STATE.get(status, "stalled") if isinstance(status, int) \
|
|
228
|
+
else "stalled"
|
|
229
|
+
err = t.get("error")
|
|
230
|
+
if isinstance(err, int) and err != 0:
|
|
231
|
+
state = "error"
|
|
232
|
+
name = str(t.get("name") or "")[:MAX_NAME_LEN]
|
|
233
|
+
ddir = str(t.get("downloadDir") or "")
|
|
234
|
+
content_path = os.path.join(ddir, name) if (ddir and name) else ""
|
|
235
|
+
return CollectedTorrent(
|
|
236
|
+
infohash_v1=v1, infohash_v2=None,
|
|
237
|
+
name=(name or None),
|
|
238
|
+
state=state,
|
|
239
|
+
progress=_clamp_progress(t.get("percentDone")),
|
|
240
|
+
content_path=content_path, save_path=ddir,
|
|
241
|
+
local_files=_local_files(content_path),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def build_source(client):
|
|
246
|
+
"""Wrap a frozen client adapter in a read-only telemetry source, or return
|
|
247
|
+
None when no client is configured (a Fallback/None selection)."""
|
|
248
|
+
if isinstance(client, QBittorrentClient):
|
|
249
|
+
return QbtTelemetrySource(client)
|
|
250
|
+
if isinstance(client, TransmissionClient):
|
|
251
|
+
return TransmissionTelemetrySource(client)
|
|
252
|
+
return None
|
llmt/telemetry/config.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Telemetry config + token storage under the llmt home dir.
|
|
2
|
+
|
|
3
|
+
Precedent: the existing CLI has no config file (see docs/PERMISSIONS.md); this
|
|
4
|
+
module introduces the first one, kept under ``~/.llmt/`` (overridable with the
|
|
5
|
+
``LLMT_HOME`` env var, which the test-suite uses to stay hermetic). All keys
|
|
6
|
+
live under a single ``telemetry`` table with the frozen defaults below.
|
|
7
|
+
|
|
8
|
+
The token is stored in its OWN file (never in config.json), mode 0600, and is
|
|
9
|
+
never logged or printed back after it is saved.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import stat
|
|
17
|
+
import sys
|
|
18
|
+
from typing import Any, Optional
|
|
19
|
+
|
|
20
|
+
# --------------------------------------------------------------------------- #
|
|
21
|
+
# Frozen defaults (WP-14.4-T contract). Every value is config-driven.
|
|
22
|
+
# --------------------------------------------------------------------------- #
|
|
23
|
+
|
|
24
|
+
#: Site base (NOT the /api base): the agent namespace is endpoint + /agent/v1.
|
|
25
|
+
DEFAULT_ENDPOINT = "https://llmtorrents.com"
|
|
26
|
+
DEFAULT_INTERVAL_MINUTES = 15
|
|
27
|
+
DEFAULT_MAX_TORRENTS = 500
|
|
28
|
+
DEFAULT_TOKEN_FILENAME = "agent-token"
|
|
29
|
+
DEFAULT_CHALLENGE_CHUNK = 1 * 1024 * 1024 # 1 MiB streaming read for range hashing
|
|
30
|
+
DEFAULT_MAX_RETRY_AFTER = 3600 # clamp a hostile 429 retry_after (seconds)
|
|
31
|
+
|
|
32
|
+
TELEMETRY_DEFAULTS: dict[str, Any] = {
|
|
33
|
+
"enabled": False,
|
|
34
|
+
"endpoint": DEFAULT_ENDPOINT,
|
|
35
|
+
"interval_minutes": DEFAULT_INTERVAL_MINUTES,
|
|
36
|
+
"max_torrents_per_heartbeat": DEFAULT_MAX_TORRENTS,
|
|
37
|
+
"token_path": None, # None -> <home>/agent-token (resolved lazily)
|
|
38
|
+
"challenge_chunk_size": DEFAULT_CHALLENGE_CHUNK,
|
|
39
|
+
"max_retry_after_seconds": DEFAULT_MAX_RETRY_AFTER,
|
|
40
|
+
"report_artifacts": True,
|
|
41
|
+
"last_beat": None,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def llmt_home() -> str:
|
|
46
|
+
"""Resolve the llmt home dir. ``LLMT_HOME`` overrides (tests use this)."""
|
|
47
|
+
override = os.environ.get("LLMT_HOME")
|
|
48
|
+
if override:
|
|
49
|
+
return os.path.abspath(os.path.expanduser(override))
|
|
50
|
+
return os.path.join(os.path.expanduser("~"), ".llmt")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def config_path() -> str:
|
|
54
|
+
return os.path.join(llmt_home(), "config.json")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _ensure_home() -> str:
|
|
58
|
+
home = llmt_home()
|
|
59
|
+
os.makedirs(home, exist_ok=True)
|
|
60
|
+
try:
|
|
61
|
+
os.chmod(home, 0o700)
|
|
62
|
+
except OSError:
|
|
63
|
+
pass
|
|
64
|
+
return home
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# --------------------------------------------------------------------------- #
|
|
68
|
+
# Config load / save (JSON; stdlib only, py3.9-safe)
|
|
69
|
+
# --------------------------------------------------------------------------- #
|
|
70
|
+
|
|
71
|
+
def load_config() -> dict[str, Any]:
|
|
72
|
+
"""Return the full config dict with telemetry defaults merged in.
|
|
73
|
+
|
|
74
|
+
A missing or unreadable/corrupt config file yields defaults (never fatal).
|
|
75
|
+
"""
|
|
76
|
+
cfg: dict[str, Any] = {}
|
|
77
|
+
try:
|
|
78
|
+
with open(config_path(), "r", encoding="utf-8") as fh:
|
|
79
|
+
loaded = json.load(fh)
|
|
80
|
+
if isinstance(loaded, dict):
|
|
81
|
+
cfg = loaded
|
|
82
|
+
except (OSError, ValueError):
|
|
83
|
+
cfg = {}
|
|
84
|
+
tele = cfg.get("telemetry")
|
|
85
|
+
merged = dict(TELEMETRY_DEFAULTS)
|
|
86
|
+
if isinstance(tele, dict):
|
|
87
|
+
for k, v in tele.items():
|
|
88
|
+
merged[k] = v
|
|
89
|
+
# Env overrides (never persisted): endpoint only, following the api-base
|
|
90
|
+
# precedent of --api-base / LLMT_API_BASE.
|
|
91
|
+
env_ep = os.environ.get("LLMT_TELEMETRY_ENDPOINT")
|
|
92
|
+
if env_ep:
|
|
93
|
+
merged["endpoint"] = env_ep
|
|
94
|
+
cfg["telemetry"] = merged
|
|
95
|
+
return cfg
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def save_config(cfg: dict[str, Any]) -> None:
|
|
99
|
+
_ensure_home()
|
|
100
|
+
path = config_path()
|
|
101
|
+
tmp = path + ".tmp"
|
|
102
|
+
# Never persist an env-only endpoint override back to disk.
|
|
103
|
+
to_write = dict(cfg)
|
|
104
|
+
tele = dict(to_write.get("telemetry") or {})
|
|
105
|
+
if os.environ.get("LLMT_TELEMETRY_ENDPOINT") and \
|
|
106
|
+
tele.get("endpoint") == os.environ["LLMT_TELEMETRY_ENDPOINT"]:
|
|
107
|
+
# Restore whatever was on disk (or the default) rather than pinning env.
|
|
108
|
+
tele["endpoint"] = _disk_endpoint()
|
|
109
|
+
to_write["telemetry"] = tele
|
|
110
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
111
|
+
json.dump(to_write, fh, indent=2, sort_keys=True)
|
|
112
|
+
os.replace(tmp, path)
|
|
113
|
+
try:
|
|
114
|
+
os.chmod(path, 0o600)
|
|
115
|
+
except OSError:
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _disk_endpoint() -> str:
|
|
120
|
+
try:
|
|
121
|
+
with open(config_path(), "r", encoding="utf-8") as fh:
|
|
122
|
+
loaded = json.load(fh)
|
|
123
|
+
ep = (loaded.get("telemetry") or {}).get("endpoint")
|
|
124
|
+
if isinstance(ep, str) and ep:
|
|
125
|
+
return ep
|
|
126
|
+
except (OSError, ValueError, AttributeError):
|
|
127
|
+
pass
|
|
128
|
+
return DEFAULT_ENDPOINT
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def telemetry_cfg() -> dict[str, Any]:
|
|
132
|
+
return load_config()["telemetry"]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def set_enabled(enabled: bool) -> None:
|
|
136
|
+
cfg = load_config()
|
|
137
|
+
cfg["telemetry"]["enabled"] = bool(enabled)
|
|
138
|
+
save_config(cfg)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def record_last_beat(result: dict[str, Any]) -> None:
|
|
142
|
+
cfg = load_config()
|
|
143
|
+
cfg["telemetry"]["last_beat"] = result
|
|
144
|
+
save_config(cfg)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# --------------------------------------------------------------------------- #
|
|
148
|
+
# Token storage (own file, 0600, never logged / never printed)
|
|
149
|
+
# --------------------------------------------------------------------------- #
|
|
150
|
+
|
|
151
|
+
def token_path(cfg: Optional[dict[str, Any]] = None) -> str:
|
|
152
|
+
tele = (cfg or load_config())["telemetry"]
|
|
153
|
+
tp = tele.get("token_path")
|
|
154
|
+
if isinstance(tp, str) and tp:
|
|
155
|
+
return os.path.abspath(os.path.expanduser(tp))
|
|
156
|
+
return os.path.join(llmt_home(), DEFAULT_TOKEN_FILENAME)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def save_token(token: str, cfg: Optional[dict[str, Any]] = None) -> str:
|
|
160
|
+
"""Persist the token to its file with mode 0600. Returns the path.
|
|
161
|
+
|
|
162
|
+
Uses O_CREAT|O_WRONLY|O_TRUNC with mode 0600 so the secret is never briefly
|
|
163
|
+
world-readable between create and chmod.
|
|
164
|
+
"""
|
|
165
|
+
token = (token or "").strip()
|
|
166
|
+
if not token:
|
|
167
|
+
raise ValueError("refusing to store an empty telemetry token")
|
|
168
|
+
_ensure_home()
|
|
169
|
+
path = token_path(cfg)
|
|
170
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
171
|
+
fd = os.open(path, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o600)
|
|
172
|
+
try:
|
|
173
|
+
os.write(fd, token.encode("utf-8"))
|
|
174
|
+
finally:
|
|
175
|
+
os.close(fd)
|
|
176
|
+
try:
|
|
177
|
+
os.chmod(path, 0o600) # tighten even if the file pre-existed
|
|
178
|
+
except OSError:
|
|
179
|
+
pass
|
|
180
|
+
return path
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _enforce_token_mode(path: str) -> None:
|
|
184
|
+
"""The token file must be 0600 on READ as well as write (contract). A
|
|
185
|
+
pre-existing loose-perm file is repaired to 0600 (with a one-line stderr
|
|
186
|
+
warning); if it cannot be tightened, refuse to use it."""
|
|
187
|
+
try:
|
|
188
|
+
st = os.stat(path)
|
|
189
|
+
except OSError:
|
|
190
|
+
return
|
|
191
|
+
if stat.S_IMODE(st.st_mode) & 0o077:
|
|
192
|
+
try:
|
|
193
|
+
os.chmod(path, 0o600)
|
|
194
|
+
except OSError as exc:
|
|
195
|
+
from .errors import TelemetryConfigError
|
|
196
|
+
raise TelemetryConfigError(
|
|
197
|
+
f"telemetry token file {path} has insecure permissions and "
|
|
198
|
+
f"could not be tightened to 0600: {exc}. Fix it "
|
|
199
|
+
f"(chmod 600) or re-run `llmt telemetry enable`.") from exc
|
|
200
|
+
print(f"warning: tightened telemetry token file {path} to mode 0600 "
|
|
201
|
+
f"(it was group/world-accessible).", file=sys.stderr)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def load_token(cfg: Optional[dict[str, Any]] = None) -> Optional[str]:
|
|
205
|
+
path = token_path(cfg)
|
|
206
|
+
if not os.path.exists(path):
|
|
207
|
+
return None
|
|
208
|
+
_enforce_token_mode(path) # verify/repair 0600 before reading the secret
|
|
209
|
+
try:
|
|
210
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
211
|
+
tok = fh.read().strip()
|
|
212
|
+
return tok or None
|
|
213
|
+
except OSError:
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def token_present(cfg: Optional[dict[str, Any]] = None) -> bool:
|
|
218
|
+
# Presence only (does not trigger perm-repair / raise) so `status` is safe.
|
|
219
|
+
return os.path.exists(token_path(cfg))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def delete_token(cfg: Optional[dict[str, Any]] = None) -> bool:
|
|
223
|
+
"""Remove the stored token. Returns True if a file was deleted, False if
|
|
224
|
+
none existed. A real removal failure is NEVER swallowed (it would falsely
|
|
225
|
+
report the token as gone while it persists) — it raises so the caller can
|
|
226
|
+
report it and exit non-zero."""
|
|
227
|
+
path = token_path(cfg)
|
|
228
|
+
try:
|
|
229
|
+
os.remove(path)
|
|
230
|
+
return True
|
|
231
|
+
except FileNotFoundError:
|
|
232
|
+
return False
|
|
233
|
+
except OSError as exc:
|
|
234
|
+
from .errors import TelemetryConfigError
|
|
235
|
+
raise TelemetryConfigError(
|
|
236
|
+
f"could not delete telemetry token file {path}: {exc}. "
|
|
237
|
+
f"The token may still be present — remove it manually.") from exc
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""The exact, user-visible consent statement for telemetry (DR-27).
|
|
2
|
+
|
|
3
|
+
``llmt telemetry enable`` prints this and requires explicit confirmation
|
|
4
|
+
(interactive y/N, or ``--yes`` for scripts) before any token is stored or any
|
|
5
|
+
heartbeat is ever sent. It states EXACTLY what is sent and what is NEVER sent.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
#: What a heartbeat / challenge answer contains — enumerated for the user.
|
|
11
|
+
SENDS = [
|
|
12
|
+
"For each torrent your client is seeding/downloading: its BitTorrent "
|
|
13
|
+
"infohash(es) (v1 and/or v2), the torrent name, its state "
|
|
14
|
+
"(seeding/downloading/stalled/paused/error) and progress (0..1).",
|
|
15
|
+
"The SHA-256 of artifacts you are seeding (the same public digests the "
|
|
16
|
+
"catalog already publishes).",
|
|
17
|
+
"When the server issues a possession challenge: the SHA-256 of a "
|
|
18
|
+
"server-chosen byte range of one artifact file (salt || bytes), proving "
|
|
19
|
+
"you hold the bytes without uploading them.",
|
|
20
|
+
"Your telemetry token, as an Authorization: Bearer header.",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
#: What is NEVER sent — the privacy floor.
|
|
24
|
+
NEVER = [
|
|
25
|
+
"Local file paths, filenames, or directory listings.",
|
|
26
|
+
"Anything from `llmt scan` (scan roots, scanned paths, or scan results).",
|
|
27
|
+
"Hardware, OS, network, username, or any machine fingerprint.",
|
|
28
|
+
"The raw contents of your files (only fixed-size range hashes leave).",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
STATEMENT_VERSION = 1
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def consent_text() -> str:
|
|
35
|
+
out = [
|
|
36
|
+
"llmt telemetry — explicit opt-in consent",
|
|
37
|
+
"=" * 40,
|
|
38
|
+
"",
|
|
39
|
+
"Telemetry is OFF by default. Enabling it lets the LLM Torrents site "
|
|
40
|
+
"credit you for the artifacts you seed. It is fully revocable at any "
|
|
41
|
+
"time with `llmt telemetry disable` (which also deletes the stored "
|
|
42
|
+
"token).",
|
|
43
|
+
"",
|
|
44
|
+
"WHAT IS SENT (only while enabled, only to your configured endpoint):",
|
|
45
|
+
]
|
|
46
|
+
for s in SENDS:
|
|
47
|
+
out.append(f" * {s}")
|
|
48
|
+
out.append("")
|
|
49
|
+
out.append("WHAT IS NEVER SENT:")
|
|
50
|
+
for s in NEVER:
|
|
51
|
+
out.append(f" * {s}")
|
|
52
|
+
out.append("")
|
|
53
|
+
out.append("View or delete the data the site holds about you on the "
|
|
54
|
+
"website (Account -> Telemetry); the CLI only sends and can "
|
|
55
|
+
"stop sending — it cannot delete server-side history.")
|
|
56
|
+
out.append("")
|
|
57
|
+
return "\n".join(out)
|
llmt/telemetry/errors.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Typed telemetry errors — telemetry-local so ``errors.py`` stays untouched.
|
|
2
|
+
|
|
3
|
+
All subclass :class:`llmt.errors.LlmtError` so ``cli.main`` prints one clean
|
|
4
|
+
line and exits non-zero (never a traceback). 429 rate-limiting deliberately
|
|
5
|
+
REUSES the existing :class:`llmt.errors.RateLimitedError` (exit 5) so the CLI's
|
|
6
|
+
single Retry-After path handles it identically to the read API.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from ..errors import LlmtError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TelemetryError(LlmtError):
|
|
15
|
+
"""Base for telemetry failures that are not one of the specific kinds."""
|
|
16
|
+
|
|
17
|
+
exit_code = 20
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TelemetryConsentError(TelemetryError):
|
|
21
|
+
"""Telemetry is disabled / no consent — NEVER touches the network."""
|
|
22
|
+
|
|
23
|
+
exit_code = 20
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TelemetryConfigError(TelemetryError):
|
|
27
|
+
"""Missing token / bad endpoint / unusable local config."""
|
|
28
|
+
|
|
29
|
+
exit_code = 23
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TelemetryAuthError(TelemetryError):
|
|
33
|
+
"""401 — missing/unknown/revoked token."""
|
|
34
|
+
|
|
35
|
+
exit_code = 21
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TelemetryScopeError(TelemetryError):
|
|
39
|
+
"""403 — the token lacks the ``telemetry:write`` scope."""
|
|
40
|
+
|
|
41
|
+
exit_code = 22
|