tescmd 0.1.2__py3-none-any.whl → 0.3.1__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.
- tescmd/__init__.py +1 -1
- tescmd/api/client.py +49 -5
- tescmd/api/command.py +1 -1
- tescmd/api/errors.py +13 -0
- tescmd/api/signed_command.py +19 -14
- tescmd/api/vehicle.py +19 -1
- tescmd/auth/oauth.py +5 -1
- tescmd/auth/server.py +6 -1
- tescmd/auth/token_store.py +8 -1
- tescmd/cache/response_cache.py +11 -3
- tescmd/cli/_client.py +142 -20
- tescmd/cli/_options.py +2 -4
- tescmd/cli/auth.py +121 -11
- tescmd/cli/energy.py +2 -0
- tescmd/cli/key.py +149 -14
- tescmd/cli/main.py +70 -7
- tescmd/cli/mcp_cmd.py +153 -0
- tescmd/cli/nav.py +3 -1
- tescmd/cli/openclaw.py +169 -0
- tescmd/cli/security.py +7 -1
- tescmd/cli/serve.py +923 -0
- tescmd/cli/setup.py +244 -25
- tescmd/cli/sharing.py +2 -0
- tescmd/cli/status.py +1 -1
- tescmd/cli/trunk.py +8 -17
- tescmd/cli/user.py +16 -1
- tescmd/cli/vehicle.py +156 -20
- tescmd/crypto/__init__.py +3 -1
- tescmd/crypto/ecdh.py +9 -0
- tescmd/crypto/schnorr.py +191 -0
- tescmd/deploy/github_pages.py +8 -0
- tescmd/deploy/tailscale_serve.py +154 -0
- tescmd/mcp/__init__.py +7 -0
- tescmd/mcp/server.py +648 -0
- tescmd/models/__init__.py +0 -2
- tescmd/models/auth.py +24 -2
- tescmd/models/config.py +1 -0
- tescmd/models/energy.py +0 -9
- tescmd/openclaw/__init__.py +23 -0
- tescmd/openclaw/bridge.py +330 -0
- tescmd/openclaw/config.py +167 -0
- tescmd/openclaw/dispatcher.py +522 -0
- tescmd/openclaw/emitter.py +175 -0
- tescmd/openclaw/filters.py +123 -0
- tescmd/openclaw/gateway.py +687 -0
- tescmd/openclaw/telemetry_store.py +53 -0
- tescmd/output/rich_output.py +46 -14
- tescmd/protocol/commands.py +2 -2
- tescmd/protocol/encoder.py +16 -13
- tescmd/protocol/payloads.py +132 -11
- tescmd/protocol/session.py +18 -8
- tescmd/protocol/signer.py +3 -17
- tescmd/telemetry/__init__.py +28 -0
- tescmd/telemetry/cache_sink.py +154 -0
- tescmd/telemetry/csv_sink.py +180 -0
- tescmd/telemetry/dashboard.py +227 -0
- tescmd/telemetry/decoder.py +284 -0
- tescmd/telemetry/fanout.py +49 -0
- tescmd/telemetry/fields.py +427 -0
- tescmd/telemetry/flatbuf.py +162 -0
- tescmd/telemetry/mapper.py +239 -0
- tescmd/telemetry/protos/__init__.py +4 -0
- tescmd/telemetry/protos/vehicle_alert.proto +31 -0
- tescmd/telemetry/protos/vehicle_alert_pb2.py +42 -0
- tescmd/telemetry/protos/vehicle_alert_pb2.pyi +44 -0
- tescmd/telemetry/protos/vehicle_connectivity.proto +23 -0
- tescmd/telemetry/protos/vehicle_connectivity_pb2.py +40 -0
- tescmd/telemetry/protos/vehicle_connectivity_pb2.pyi +33 -0
- tescmd/telemetry/protos/vehicle_data.proto +768 -0
- tescmd/telemetry/protos/vehicle_data_pb2.py +136 -0
- tescmd/telemetry/protos/vehicle_data_pb2.pyi +1336 -0
- tescmd/telemetry/protos/vehicle_error.proto +23 -0
- tescmd/telemetry/protos/vehicle_error_pb2.py +44 -0
- tescmd/telemetry/protos/vehicle_error_pb2.pyi +39 -0
- tescmd/telemetry/protos/vehicle_metric.proto +22 -0
- tescmd/telemetry/protos/vehicle_metric_pb2.py +44 -0
- tescmd/telemetry/protos/vehicle_metric_pb2.pyi +37 -0
- tescmd/telemetry/server.py +300 -0
- tescmd/telemetry/setup.py +468 -0
- tescmd/telemetry/tailscale.py +300 -0
- tescmd/telemetry/tui.py +1716 -0
- tescmd/triggers/__init__.py +18 -0
- tescmd/triggers/manager.py +264 -0
- tescmd/triggers/models.py +93 -0
- {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/METADATA +125 -40
- tescmd-0.3.1.dist-info/RECORD +128 -0
- tescmd-0.1.2.dist-info/RECORD +0 -81
- {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/WHEEL +0 -0
- {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/entry_points.txt +0 -0
- {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Tailscale integration for Fleet Telemetry Funnel setup.
|
|
2
|
+
|
|
3
|
+
Manages Tailscale presence checking, Funnel start/stop, and TLS
|
|
4
|
+
certificate retrieval — all via CLI subprocess (Funnel has no API).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import shutil
|
|
13
|
+
import sys
|
|
14
|
+
import tempfile
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from tescmd.api.errors import TailscaleError
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
_SUBPROCESS_TIMEOUT = 15 # seconds
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TailscaleManager:
|
|
26
|
+
"""Manages Tailscale for Fleet Telemetry Funnel setup."""
|
|
27
|
+
|
|
28
|
+
_port: int | None
|
|
29
|
+
_funnel_started: bool
|
|
30
|
+
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
self._port = None
|
|
33
|
+
self._funnel_started = False
|
|
34
|
+
|
|
35
|
+
# ------------------------------------------------------------------
|
|
36
|
+
# Checks
|
|
37
|
+
# ------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
async def check_available(self) -> None:
|
|
40
|
+
"""Verify ``tailscale`` CLI binary is on PATH.
|
|
41
|
+
|
|
42
|
+
Raises :class:`TailscaleError` with install guidance if not found.
|
|
43
|
+
"""
|
|
44
|
+
if shutil.which("tailscale") is None:
|
|
45
|
+
platform = sys.platform
|
|
46
|
+
if platform == "darwin":
|
|
47
|
+
hint = "Install via: brew install tailscale"
|
|
48
|
+
elif platform == "linux":
|
|
49
|
+
hint = "Install via: curl -fsSL https://tailscale.com/install.sh | sh"
|
|
50
|
+
else:
|
|
51
|
+
hint = "Install from https://tailscale.com/download"
|
|
52
|
+
raise TailscaleError(f"Tailscale CLI not found on PATH. {hint}")
|
|
53
|
+
|
|
54
|
+
async def check_running(self) -> dict[str, Any]:
|
|
55
|
+
"""Verify the Tailscale daemon is running and authenticated.
|
|
56
|
+
|
|
57
|
+
Returns the parsed ``tailscale status --json`` output.
|
|
58
|
+
Raises :class:`TailscaleError` if the daemon is not running.
|
|
59
|
+
"""
|
|
60
|
+
returncode, stdout, stderr = await self._run("tailscale", "status", "--json")
|
|
61
|
+
if returncode != 0:
|
|
62
|
+
raise TailscaleError(
|
|
63
|
+
f"Tailscale is not running or not authenticated: {stderr.strip()}"
|
|
64
|
+
)
|
|
65
|
+
try:
|
|
66
|
+
status: dict[str, Any] = json.loads(stdout)
|
|
67
|
+
except json.JSONDecodeError as exc:
|
|
68
|
+
raise TailscaleError(f"Failed to parse tailscale status JSON: {exc}") from exc
|
|
69
|
+
|
|
70
|
+
backend_state = status.get("BackendState", "")
|
|
71
|
+
if backend_state != "Running":
|
|
72
|
+
raise TailscaleError(
|
|
73
|
+
f"Tailscale backend state is '{backend_state}', expected 'Running'. "
|
|
74
|
+
"Run 'tailscale up' to authenticate."
|
|
75
|
+
)
|
|
76
|
+
return status
|
|
77
|
+
|
|
78
|
+
async def get_hostname(self) -> str:
|
|
79
|
+
"""Extract the machine DNS name from ``tailscale status --json``.
|
|
80
|
+
|
|
81
|
+
Returns e.g. ``'machine.tailnet.ts.net'`` (trailing dot stripped).
|
|
82
|
+
"""
|
|
83
|
+
status = await self.check_running()
|
|
84
|
+
|
|
85
|
+
# Self node info
|
|
86
|
+
self_node = status.get("Self", {})
|
|
87
|
+
dns_name: str = self_node.get("DNSName", "")
|
|
88
|
+
if not dns_name:
|
|
89
|
+
raise TailscaleError(
|
|
90
|
+
"Could not determine Tailscale hostname. "
|
|
91
|
+
"Ensure MagicDNS is enabled in your tailnet."
|
|
92
|
+
)
|
|
93
|
+
return dns_name.rstrip(".")
|
|
94
|
+
|
|
95
|
+
async def check_funnel_available(self) -> bool:
|
|
96
|
+
"""Check if Funnel is enabled in tailnet ACL.
|
|
97
|
+
|
|
98
|
+
Runs ``tailscale funnel status`` to probe availability.
|
|
99
|
+
Returns False (without raising) if Funnel is not available.
|
|
100
|
+
"""
|
|
101
|
+
try:
|
|
102
|
+
returncode, _stdout, _stderr = await self._run(
|
|
103
|
+
"tailscale",
|
|
104
|
+
"funnel",
|
|
105
|
+
"status",
|
|
106
|
+
)
|
|
107
|
+
return returncode == 0
|
|
108
|
+
except TailscaleError:
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
# Serve management (static file hosting)
|
|
113
|
+
# ------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
async def start_serve(self, path: str, target: str | Path) -> None:
|
|
116
|
+
"""Serve a local directory at a URL path prefix.
|
|
117
|
+
|
|
118
|
+
Runs: ``tailscale serve --bg --set-path <path> <target>``
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
path: URL path prefix (e.g. ``/.well-known/``).
|
|
122
|
+
target: Local directory to serve.
|
|
123
|
+
"""
|
|
124
|
+
returncode, stdout, stderr = await self._run(
|
|
125
|
+
"tailscale",
|
|
126
|
+
"serve",
|
|
127
|
+
"--bg",
|
|
128
|
+
"--set-path",
|
|
129
|
+
path,
|
|
130
|
+
str(target),
|
|
131
|
+
)
|
|
132
|
+
if returncode != 0:
|
|
133
|
+
msg = stderr.strip() or stdout.strip()
|
|
134
|
+
raise TailscaleError(f"Failed to start Tailscale serve: {msg}")
|
|
135
|
+
logger.info("Tailscale serve started: %s -> %s", path, target)
|
|
136
|
+
|
|
137
|
+
async def stop_serve(self, path: str) -> None:
|
|
138
|
+
"""Remove a serve handler for a path.
|
|
139
|
+
|
|
140
|
+
Runs: ``tailscale serve --bg --set-path <path> off``
|
|
141
|
+
"""
|
|
142
|
+
returncode, stdout, stderr = await self._run(
|
|
143
|
+
"tailscale",
|
|
144
|
+
"serve",
|
|
145
|
+
"--bg",
|
|
146
|
+
"--set-path",
|
|
147
|
+
path,
|
|
148
|
+
"off",
|
|
149
|
+
)
|
|
150
|
+
if returncode != 0:
|
|
151
|
+
logger.warning(
|
|
152
|
+
"Failed to stop Tailscale serve for %s (may already be stopped): %s",
|
|
153
|
+
path,
|
|
154
|
+
stderr.strip() or stdout.strip(),
|
|
155
|
+
)
|
|
156
|
+
logger.info("Tailscale serve stopped for path: %s", path)
|
|
157
|
+
|
|
158
|
+
async def enable_funnel(self) -> None:
|
|
159
|
+
"""Enable Funnel on port 443 (expose all serve handlers publicly).
|
|
160
|
+
|
|
161
|
+
Runs: ``tailscale funnel --bg 443``
|
|
162
|
+
"""
|
|
163
|
+
returncode, stdout, stderr = await self._run(
|
|
164
|
+
"tailscale",
|
|
165
|
+
"funnel",
|
|
166
|
+
"--bg",
|
|
167
|
+
"443",
|
|
168
|
+
)
|
|
169
|
+
if returncode != 0:
|
|
170
|
+
msg = stderr.strip() or stdout.strip()
|
|
171
|
+
raise TailscaleError(f"Failed to enable Tailscale Funnel: {msg}")
|
|
172
|
+
logger.info("Tailscale Funnel enabled on port 443")
|
|
173
|
+
|
|
174
|
+
# ------------------------------------------------------------------
|
|
175
|
+
# Funnel management (port proxying for telemetry)
|
|
176
|
+
# ------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
async def start_funnel(self, port: int) -> str:
|
|
179
|
+
"""Start Tailscale Funnel proxying to a local port.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
port: Local port the WebSocket server is listening on.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
The public HTTPS URL (e.g. ``https://machine.tailnet.ts.net``).
|
|
186
|
+
|
|
187
|
+
Raises:
|
|
188
|
+
TailscaleError: If Funnel cannot be started.
|
|
189
|
+
"""
|
|
190
|
+
hostname = await self.get_hostname()
|
|
191
|
+
|
|
192
|
+
# tailscale funnel --bg <port> starts background proxy on 443
|
|
193
|
+
returncode, stdout, stderr = await self._run(
|
|
194
|
+
"tailscale",
|
|
195
|
+
"funnel",
|
|
196
|
+
"--bg",
|
|
197
|
+
str(port),
|
|
198
|
+
)
|
|
199
|
+
if returncode != 0:
|
|
200
|
+
msg = stderr.strip() or stdout.strip()
|
|
201
|
+
raise TailscaleError(f"Failed to start Tailscale Funnel: {msg}")
|
|
202
|
+
|
|
203
|
+
self._port = port
|
|
204
|
+
self._funnel_started = True
|
|
205
|
+
url = f"https://{hostname}"
|
|
206
|
+
logger.info("Tailscale Funnel started: %s -> localhost:%d", url, port)
|
|
207
|
+
return url
|
|
208
|
+
|
|
209
|
+
async def stop_funnel(self) -> None:
|
|
210
|
+
"""Stop Tailscale Funnel. Idempotent — safe to call if not started."""
|
|
211
|
+
if not self._funnel_started:
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
returncode, stdout, stderr = await self._run(
|
|
215
|
+
"tailscale",
|
|
216
|
+
"funnel",
|
|
217
|
+
"--bg",
|
|
218
|
+
"off",
|
|
219
|
+
)
|
|
220
|
+
if returncode != 0:
|
|
221
|
+
logger.warning(
|
|
222
|
+
"Failed to stop Tailscale Funnel (may already be stopped): %s",
|
|
223
|
+
stderr.strip() or stdout.strip(),
|
|
224
|
+
)
|
|
225
|
+
self._funnel_started = False
|
|
226
|
+
self._port = None
|
|
227
|
+
logger.info("Tailscale Funnel stopped")
|
|
228
|
+
|
|
229
|
+
async def get_cert_pem(self) -> str:
|
|
230
|
+
"""Retrieve the TLS certificate chain for the Funnel hostname.
|
|
231
|
+
|
|
232
|
+
Uses ``tailscale cert`` to fetch/renew the cert, then reads the
|
|
233
|
+
PEM file. Returns the PEM string for the Fleet Telemetry config
|
|
234
|
+
``ca`` field.
|
|
235
|
+
"""
|
|
236
|
+
hostname = await self.get_hostname()
|
|
237
|
+
|
|
238
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
239
|
+
cert_path = Path(tmpdir) / f"{hostname}.crt"
|
|
240
|
+
key_path = Path(tmpdir) / f"{hostname}.key"
|
|
241
|
+
|
|
242
|
+
returncode, stdout, stderr = await self._run(
|
|
243
|
+
"tailscale",
|
|
244
|
+
"cert",
|
|
245
|
+
"--cert-file",
|
|
246
|
+
str(cert_path),
|
|
247
|
+
"--key-file",
|
|
248
|
+
str(key_path),
|
|
249
|
+
hostname,
|
|
250
|
+
timeout=60, # cert provisioning involves ACME/Let's Encrypt
|
|
251
|
+
)
|
|
252
|
+
if returncode != 0:
|
|
253
|
+
msg = stderr.strip() or stdout.strip()
|
|
254
|
+
raise TailscaleError(f"Failed to get Tailscale cert: {msg}")
|
|
255
|
+
|
|
256
|
+
if not cert_path.exists():
|
|
257
|
+
raise TailscaleError(f"tailscale cert succeeded but {cert_path} not found")
|
|
258
|
+
|
|
259
|
+
return cert_path.read_text()
|
|
260
|
+
|
|
261
|
+
# ------------------------------------------------------------------
|
|
262
|
+
# Internal helpers
|
|
263
|
+
# ------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
@staticmethod
|
|
266
|
+
async def _run(*args: str, timeout: int | None = None) -> tuple[int, str, str]:
|
|
267
|
+
"""Run a subprocess with timeout.
|
|
268
|
+
|
|
269
|
+
Returns ``(returncode, stdout, stderr)``.
|
|
270
|
+
|
|
271
|
+
Uses ``asyncio.create_subprocess_exec`` which passes arguments
|
|
272
|
+
directly to the OS without shell interpretation (no injection risk).
|
|
273
|
+
|
|
274
|
+
*timeout* overrides the default ``_SUBPROCESS_TIMEOUT`` when a
|
|
275
|
+
command is known to be slow (e.g. certificate provisioning).
|
|
276
|
+
"""
|
|
277
|
+
effective_timeout = timeout if timeout is not None else _SUBPROCESS_TIMEOUT
|
|
278
|
+
logger.debug("Running: %s", " ".join(args))
|
|
279
|
+
try:
|
|
280
|
+
proc = await asyncio.create_subprocess_exec(
|
|
281
|
+
*args,
|
|
282
|
+
stdout=asyncio.subprocess.PIPE,
|
|
283
|
+
stderr=asyncio.subprocess.PIPE,
|
|
284
|
+
)
|
|
285
|
+
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
286
|
+
proc.communicate(), timeout=effective_timeout
|
|
287
|
+
)
|
|
288
|
+
except FileNotFoundError as exc:
|
|
289
|
+
raise TailscaleError(f"Command not found: {args[0]}") from exc
|
|
290
|
+
except TimeoutError as exc:
|
|
291
|
+
raise TailscaleError(
|
|
292
|
+
f"Command timed out after {effective_timeout}s: {' '.join(args)}"
|
|
293
|
+
) from exc
|
|
294
|
+
|
|
295
|
+
assert proc.returncode is not None
|
|
296
|
+
return (
|
|
297
|
+
proc.returncode,
|
|
298
|
+
stdout_bytes.decode(errors="replace"),
|
|
299
|
+
stderr_bytes.decode(errors="replace"),
|
|
300
|
+
)
|