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.
Files changed (90) hide show
  1. tescmd/__init__.py +1 -1
  2. tescmd/api/client.py +49 -5
  3. tescmd/api/command.py +1 -1
  4. tescmd/api/errors.py +13 -0
  5. tescmd/api/signed_command.py +19 -14
  6. tescmd/api/vehicle.py +19 -1
  7. tescmd/auth/oauth.py +5 -1
  8. tescmd/auth/server.py +6 -1
  9. tescmd/auth/token_store.py +8 -1
  10. tescmd/cache/response_cache.py +11 -3
  11. tescmd/cli/_client.py +142 -20
  12. tescmd/cli/_options.py +2 -4
  13. tescmd/cli/auth.py +121 -11
  14. tescmd/cli/energy.py +2 -0
  15. tescmd/cli/key.py +149 -14
  16. tescmd/cli/main.py +70 -7
  17. tescmd/cli/mcp_cmd.py +153 -0
  18. tescmd/cli/nav.py +3 -1
  19. tescmd/cli/openclaw.py +169 -0
  20. tescmd/cli/security.py +7 -1
  21. tescmd/cli/serve.py +923 -0
  22. tescmd/cli/setup.py +244 -25
  23. tescmd/cli/sharing.py +2 -0
  24. tescmd/cli/status.py +1 -1
  25. tescmd/cli/trunk.py +8 -17
  26. tescmd/cli/user.py +16 -1
  27. tescmd/cli/vehicle.py +156 -20
  28. tescmd/crypto/__init__.py +3 -1
  29. tescmd/crypto/ecdh.py +9 -0
  30. tescmd/crypto/schnorr.py +191 -0
  31. tescmd/deploy/github_pages.py +8 -0
  32. tescmd/deploy/tailscale_serve.py +154 -0
  33. tescmd/mcp/__init__.py +7 -0
  34. tescmd/mcp/server.py +648 -0
  35. tescmd/models/__init__.py +0 -2
  36. tescmd/models/auth.py +24 -2
  37. tescmd/models/config.py +1 -0
  38. tescmd/models/energy.py +0 -9
  39. tescmd/openclaw/__init__.py +23 -0
  40. tescmd/openclaw/bridge.py +330 -0
  41. tescmd/openclaw/config.py +167 -0
  42. tescmd/openclaw/dispatcher.py +522 -0
  43. tescmd/openclaw/emitter.py +175 -0
  44. tescmd/openclaw/filters.py +123 -0
  45. tescmd/openclaw/gateway.py +687 -0
  46. tescmd/openclaw/telemetry_store.py +53 -0
  47. tescmd/output/rich_output.py +46 -14
  48. tescmd/protocol/commands.py +2 -2
  49. tescmd/protocol/encoder.py +16 -13
  50. tescmd/protocol/payloads.py +132 -11
  51. tescmd/protocol/session.py +18 -8
  52. tescmd/protocol/signer.py +3 -17
  53. tescmd/telemetry/__init__.py +28 -0
  54. tescmd/telemetry/cache_sink.py +154 -0
  55. tescmd/telemetry/csv_sink.py +180 -0
  56. tescmd/telemetry/dashboard.py +227 -0
  57. tescmd/telemetry/decoder.py +284 -0
  58. tescmd/telemetry/fanout.py +49 -0
  59. tescmd/telemetry/fields.py +427 -0
  60. tescmd/telemetry/flatbuf.py +162 -0
  61. tescmd/telemetry/mapper.py +239 -0
  62. tescmd/telemetry/protos/__init__.py +4 -0
  63. tescmd/telemetry/protos/vehicle_alert.proto +31 -0
  64. tescmd/telemetry/protos/vehicle_alert_pb2.py +42 -0
  65. tescmd/telemetry/protos/vehicle_alert_pb2.pyi +44 -0
  66. tescmd/telemetry/protos/vehicle_connectivity.proto +23 -0
  67. tescmd/telemetry/protos/vehicle_connectivity_pb2.py +40 -0
  68. tescmd/telemetry/protos/vehicle_connectivity_pb2.pyi +33 -0
  69. tescmd/telemetry/protos/vehicle_data.proto +768 -0
  70. tescmd/telemetry/protos/vehicle_data_pb2.py +136 -0
  71. tescmd/telemetry/protos/vehicle_data_pb2.pyi +1336 -0
  72. tescmd/telemetry/protos/vehicle_error.proto +23 -0
  73. tescmd/telemetry/protos/vehicle_error_pb2.py +44 -0
  74. tescmd/telemetry/protos/vehicle_error_pb2.pyi +39 -0
  75. tescmd/telemetry/protos/vehicle_metric.proto +22 -0
  76. tescmd/telemetry/protos/vehicle_metric_pb2.py +44 -0
  77. tescmd/telemetry/protos/vehicle_metric_pb2.pyi +37 -0
  78. tescmd/telemetry/server.py +300 -0
  79. tescmd/telemetry/setup.py +468 -0
  80. tescmd/telemetry/tailscale.py +300 -0
  81. tescmd/telemetry/tui.py +1716 -0
  82. tescmd/triggers/__init__.py +18 -0
  83. tescmd/triggers/manager.py +264 -0
  84. tescmd/triggers/models.py +93 -0
  85. {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/METADATA +125 -40
  86. tescmd-0.3.1.dist-info/RECORD +128 -0
  87. tescmd-0.1.2.dist-info/RECORD +0 -81
  88. {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/WHEEL +0 -0
  89. {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/entry_points.txt +0 -0
  90. {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
+ )