tescmd 0.2.0__py3-none-any.whl → 0.4.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.
- tescmd/__init__.py +1 -1
- tescmd/api/client.py +41 -4
- tescmd/api/command.py +1 -1
- tescmd/api/errors.py +5 -0
- tescmd/api/signed_command.py +19 -14
- tescmd/auth/oauth.py +15 -1
- tescmd/auth/server.py +6 -1
- tescmd/auth/token_store.py +8 -1
- tescmd/cache/response_cache.py +8 -1
- tescmd/cli/_client.py +142 -20
- tescmd/cli/_options.py +2 -4
- tescmd/cli/auth.py +255 -106
- tescmd/cli/energy.py +2 -0
- tescmd/cli/key.py +6 -7
- tescmd/cli/main.py +27 -8
- 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 +147 -58
- 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 +135 -462
- tescmd/deploy/github_pages.py +21 -2
- tescmd/deploy/tailscale_serve.py +96 -8
- tescmd/mcp/__init__.py +7 -0
- tescmd/mcp/server.py +648 -0
- tescmd/models/auth.py +5 -2
- tescmd/openclaw/__init__.py +23 -0
- tescmd/openclaw/bridge.py +330 -0
- tescmd/openclaw/config.py +167 -0
- tescmd/openclaw/dispatcher.py +529 -0
- tescmd/openclaw/emitter.py +175 -0
- tescmd/openclaw/filters.py +123 -0
- tescmd/openclaw/gateway.py +700 -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 +8 -5
- tescmd/protocol/signer.py +3 -17
- tescmd/telemetry/__init__.py +9 -0
- tescmd/telemetry/cache_sink.py +154 -0
- tescmd/telemetry/csv_sink.py +180 -0
- tescmd/telemetry/dashboard.py +4 -4
- tescmd/telemetry/fanout.py +49 -0
- tescmd/telemetry/fields.py +308 -129
- tescmd/telemetry/mapper.py +239 -0
- tescmd/telemetry/server.py +26 -19
- tescmd/telemetry/setup.py +468 -0
- tescmd/telemetry/tailscale.py +78 -16
- 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.4.0.dist-info/METADATA +300 -0
- {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/RECORD +64 -42
- tescmd-0.2.0.dist-info/METADATA +0 -495
- {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/WHEEL +0 -0
- {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/entry_points.txt +0 -0
- {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
"""Reusable telemetry session lifecycle.
|
|
2
|
+
|
|
3
|
+
Encapsulates the full setup and teardown sequence shared by the telemetry
|
|
4
|
+
stream command and the OpenClaw bridge:
|
|
5
|
+
|
|
6
|
+
server start → tunnel → partner re-registration → fleet config → yield → cleanup
|
|
7
|
+
|
|
8
|
+
Usage::
|
|
9
|
+
|
|
10
|
+
async with telemetry_session(app_ctx, vin, ...) as session:
|
|
11
|
+
# session.server is running, fleet config is active
|
|
12
|
+
await some_blocking_loop()
|
|
13
|
+
# cleanup is guaranteed (config delete → domain restore → funnel stop → server stop)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import logging
|
|
20
|
+
from contextlib import asynccontextmanager
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import TYPE_CHECKING, Any
|
|
24
|
+
|
|
25
|
+
from tescmd.api.errors import TunnelError
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
29
|
+
|
|
30
|
+
from tescmd.cli.main import AppContext
|
|
31
|
+
from tescmd.output.formatter import OutputFormatter
|
|
32
|
+
from tescmd.telemetry.decoder import TelemetryFrame
|
|
33
|
+
from tescmd.telemetry.server import TelemetryServer
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class TelemetrySession:
|
|
40
|
+
"""Active telemetry session state exposed to callers."""
|
|
41
|
+
|
|
42
|
+
server: TelemetryServer | None
|
|
43
|
+
tunnel_url: str
|
|
44
|
+
hostname: str
|
|
45
|
+
vin: str
|
|
46
|
+
port: int
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def _noop_stop() -> None:
|
|
50
|
+
"""No-op tunnel cleanup (used when tunnel wasn't started yet)."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def _setup_tunnel(
|
|
54
|
+
*,
|
|
55
|
+
port: int,
|
|
56
|
+
formatter: OutputFormatter,
|
|
57
|
+
) -> tuple[str, str, str, Callable[[], Awaitable[None]]]:
|
|
58
|
+
"""Start Tailscale Funnel and return ``(url, hostname, ca_pem, stop_fn)``."""
|
|
59
|
+
from tescmd.telemetry.tailscale import TailscaleManager
|
|
60
|
+
|
|
61
|
+
ts = TailscaleManager()
|
|
62
|
+
await ts.check_available()
|
|
63
|
+
await ts.check_running()
|
|
64
|
+
|
|
65
|
+
url = await ts.start_funnel(port)
|
|
66
|
+
if formatter.format != "json":
|
|
67
|
+
formatter.rich.info(f"Tailscale Funnel active: {url}")
|
|
68
|
+
|
|
69
|
+
hostname = await ts.get_hostname()
|
|
70
|
+
ca_pem = await ts.get_cert_pem()
|
|
71
|
+
return url, hostname, ca_pem, ts.stop_funnel
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def _register_partner_domain(
|
|
75
|
+
*,
|
|
76
|
+
hostname: str,
|
|
77
|
+
settings: Any,
|
|
78
|
+
app_ctx: AppContext,
|
|
79
|
+
formatter: OutputFormatter,
|
|
80
|
+
interactive: bool,
|
|
81
|
+
) -> str | None:
|
|
82
|
+
"""Re-register partner domain if tunnel hostname differs from registered.
|
|
83
|
+
|
|
84
|
+
Returns the original partner domain if it was changed (for restore on
|
|
85
|
+
cleanup), or ``None`` if no change was needed.
|
|
86
|
+
"""
|
|
87
|
+
from tescmd.api.errors import AuthError
|
|
88
|
+
from tescmd.auth.oauth import register_partner_account
|
|
89
|
+
|
|
90
|
+
registered_domain = (settings.domain or "").lower().rstrip(".")
|
|
91
|
+
tunnel_host = hostname.lower().rstrip(".")
|
|
92
|
+
|
|
93
|
+
if tunnel_host == registered_domain:
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
if not settings.client_id or not settings.client_secret:
|
|
97
|
+
raise TunnelError(
|
|
98
|
+
"Client credentials required for partner domain "
|
|
99
|
+
"re-registration. Ensure TESLA_CLIENT_ID and "
|
|
100
|
+
"TESLA_CLIENT_SECRET are set."
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
reg_client_id = settings.client_id
|
|
104
|
+
reg_client_secret = settings.client_secret
|
|
105
|
+
region = app_ctx.region or settings.region
|
|
106
|
+
if formatter.format != "json":
|
|
107
|
+
formatter.rich.info(f"Re-registering partner domain: {hostname}")
|
|
108
|
+
|
|
109
|
+
async def _try_register() -> None:
|
|
110
|
+
await register_partner_account(
|
|
111
|
+
client_id=reg_client_id,
|
|
112
|
+
client_secret=reg_client_secret,
|
|
113
|
+
domain=hostname,
|
|
114
|
+
region=region,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
max_retries = 12
|
|
118
|
+
for attempt in range(max_retries):
|
|
119
|
+
try:
|
|
120
|
+
await _try_register()
|
|
121
|
+
if attempt > 0 and formatter.format != "json":
|
|
122
|
+
formatter.rich.info("[green]Tunnel is reachable — registration succeeded.[/green]")
|
|
123
|
+
break
|
|
124
|
+
except AuthError as exc:
|
|
125
|
+
status = getattr(exc, "status_code", None)
|
|
126
|
+
|
|
127
|
+
# 424 = key download failed — likely tunnel propagation delay
|
|
128
|
+
# 429 = rate limit / "Other update in progress" — transient
|
|
129
|
+
if status in (424, 429) and attempt < max_retries - 1:
|
|
130
|
+
reason = "tunnel propagation" if status == 424 else "rate limit"
|
|
131
|
+
if formatter.format != "json":
|
|
132
|
+
formatter.rich.info(
|
|
133
|
+
f"[yellow]Waiting for {reason} "
|
|
134
|
+
f"(HTTP {status})... "
|
|
135
|
+
f"({attempt + 1}/{max_retries})[/yellow]"
|
|
136
|
+
)
|
|
137
|
+
await asyncio.sleep(5)
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
if status not in (412, 424, 429):
|
|
141
|
+
raise TunnelError(f"Partner re-registration failed for {hostname}: {exc}") from exc
|
|
142
|
+
|
|
143
|
+
# 412 or exhausted 424/429 retries — need user intervention
|
|
144
|
+
if not interactive or formatter.format == "json":
|
|
145
|
+
if status == 429:
|
|
146
|
+
raise TunnelError(
|
|
147
|
+
f"Partner re-registration rate-limited for "
|
|
148
|
+
f"{hostname}. Tesla returned 'Other update in "
|
|
149
|
+
f"progress'. Wait a minute and try again."
|
|
150
|
+
) from exc
|
|
151
|
+
if status == 412:
|
|
152
|
+
raise TunnelError(
|
|
153
|
+
f"Add https://{hostname} as an Allowed Origin "
|
|
154
|
+
f"URL in your Tesla Developer Portal app, "
|
|
155
|
+
f"then try again."
|
|
156
|
+
) from exc
|
|
157
|
+
raise TunnelError(
|
|
158
|
+
f"Tesla could not fetch the public key from "
|
|
159
|
+
f"https://{hostname}. Verify the tunnel is "
|
|
160
|
+
f"accessible and try again."
|
|
161
|
+
) from exc
|
|
162
|
+
|
|
163
|
+
formatter.rich.info("")
|
|
164
|
+
if status == 412:
|
|
165
|
+
formatter.rich.info(
|
|
166
|
+
"[yellow]Tesla requires the tunnel domain as an Allowed Origin URL.[/yellow]"
|
|
167
|
+
)
|
|
168
|
+
else:
|
|
169
|
+
formatter.rich.info(
|
|
170
|
+
"[yellow]Tesla could not reach the tunnel to "
|
|
171
|
+
"verify the public key (HTTP 424).[/yellow]"
|
|
172
|
+
)
|
|
173
|
+
formatter.rich.info("")
|
|
174
|
+
formatter.rich.info(" 1. Open your Tesla Developer app:")
|
|
175
|
+
formatter.rich.info(" [cyan]https://developer.tesla.com[/cyan]")
|
|
176
|
+
formatter.rich.info(" 2. Add this as an Allowed Origin URL:")
|
|
177
|
+
formatter.rich.info(f" [cyan]https://{hostname}[/cyan]")
|
|
178
|
+
formatter.rich.info(" 3. Save the changes")
|
|
179
|
+
formatter.rich.info("")
|
|
180
|
+
|
|
181
|
+
# Wait for user to fix, then retry
|
|
182
|
+
while True:
|
|
183
|
+
formatter.rich.info("Press [bold]Enter[/bold] when done (or Ctrl+C to cancel)...")
|
|
184
|
+
await asyncio.get_event_loop().run_in_executor(None, input)
|
|
185
|
+
try:
|
|
186
|
+
await _try_register()
|
|
187
|
+
formatter.rich.info("[green]Registration succeeded![/green]")
|
|
188
|
+
break
|
|
189
|
+
except AuthError as retry_exc:
|
|
190
|
+
retry_status = getattr(retry_exc, "status_code", None)
|
|
191
|
+
if retry_status in (412, 424):
|
|
192
|
+
formatter.rich.info(
|
|
193
|
+
f"[yellow]Tesla returned HTTP "
|
|
194
|
+
f"{retry_status}. There is a propagation "
|
|
195
|
+
f"delay on Tesla's end after adding an "
|
|
196
|
+
f"Allowed Origin URL — this can take up "
|
|
197
|
+
f"to 5 minutes.[/yellow]"
|
|
198
|
+
)
|
|
199
|
+
formatter.rich.info(
|
|
200
|
+
"Press [bold]Enter[/bold] to retry, or "
|
|
201
|
+
"wait and try again (Ctrl+C to cancel)..."
|
|
202
|
+
)
|
|
203
|
+
continue
|
|
204
|
+
raise TunnelError(
|
|
205
|
+
f"Partner re-registration failed: {retry_exc}"
|
|
206
|
+
) from retry_exc
|
|
207
|
+
break # registration succeeded in the inner loop
|
|
208
|
+
|
|
209
|
+
domain: str | None = settings.domain
|
|
210
|
+
return domain
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
async def _create_fleet_config(
|
|
214
|
+
*,
|
|
215
|
+
api: Any,
|
|
216
|
+
client: Any,
|
|
217
|
+
vin: str,
|
|
218
|
+
hostname: str,
|
|
219
|
+
ca_pem: str,
|
|
220
|
+
field_config: dict[str, Any],
|
|
221
|
+
key_dir: Path,
|
|
222
|
+
settings: Any,
|
|
223
|
+
app_ctx: AppContext,
|
|
224
|
+
formatter: OutputFormatter,
|
|
225
|
+
interactive: bool,
|
|
226
|
+
) -> None:
|
|
227
|
+
"""Sign and create the fleet telemetry configuration."""
|
|
228
|
+
from tescmd.api.errors import MissingScopesError
|
|
229
|
+
from tescmd.crypto.keys import load_private_key
|
|
230
|
+
from tescmd.crypto.schnorr import sign_fleet_telemetry_config
|
|
231
|
+
|
|
232
|
+
inner_config: dict[str, object] = {
|
|
233
|
+
"hostname": hostname,
|
|
234
|
+
"port": 443, # Tailscale Funnel terminates TLS on 443
|
|
235
|
+
"ca": ca_pem,
|
|
236
|
+
"fields": field_config,
|
|
237
|
+
"alert_types": ["service"],
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private_key = load_private_key(key_dir)
|
|
241
|
+
jws_token = sign_fleet_telemetry_config(private_key, inner_config)
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
await api.fleet_telemetry_config_create_jws(vins=[vin], token=jws_token)
|
|
245
|
+
except MissingScopesError:
|
|
246
|
+
if not interactive or formatter.format == "json":
|
|
247
|
+
raise TunnelError(
|
|
248
|
+
"Your OAuth token is missing required scopes for "
|
|
249
|
+
"telemetry streaming. Run:\n"
|
|
250
|
+
" 1. tescmd auth register (restore partner domain)\n"
|
|
251
|
+
" 2. tescmd auth login (obtain token with updated scopes)\n"
|
|
252
|
+
"Then retry the stream command."
|
|
253
|
+
) from None
|
|
254
|
+
|
|
255
|
+
from tescmd.auth.oauth import login_flow
|
|
256
|
+
from tescmd.auth.token_store import TokenStore
|
|
257
|
+
from tescmd.models.auth import DEFAULT_SCOPES
|
|
258
|
+
|
|
259
|
+
formatter.rich.info("")
|
|
260
|
+
formatter.rich.info(
|
|
261
|
+
"[yellow]Token is missing required scopes — re-authenticating...[/yellow]"
|
|
262
|
+
)
|
|
263
|
+
formatter.rich.info("Opening your browser to sign in to Tesla...")
|
|
264
|
+
formatter.rich.info(
|
|
265
|
+
"When prompted, click [cyan]Select All[/cyan] and then"
|
|
266
|
+
" [cyan]Allow[/cyan] to grant tescmd access."
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
from tescmd.models.auth import DEFAULT_PORT
|
|
270
|
+
|
|
271
|
+
login_port = DEFAULT_PORT
|
|
272
|
+
login_redirect = f"http://localhost:{login_port}/callback"
|
|
273
|
+
login_store = TokenStore(
|
|
274
|
+
profile=app_ctx.profile,
|
|
275
|
+
token_file=settings.token_file,
|
|
276
|
+
config_dir=settings.config_dir,
|
|
277
|
+
)
|
|
278
|
+
token_data = await login_flow(
|
|
279
|
+
client_id=settings.client_id or "",
|
|
280
|
+
client_secret=settings.client_secret,
|
|
281
|
+
redirect_uri=login_redirect,
|
|
282
|
+
scopes=DEFAULT_SCOPES,
|
|
283
|
+
port=login_port,
|
|
284
|
+
token_store=login_store,
|
|
285
|
+
region=app_ctx.region or settings.region,
|
|
286
|
+
)
|
|
287
|
+
client.update_token(token_data.access_token)
|
|
288
|
+
formatter.rich.info("[green]Login successful — retrying config...[/green]")
|
|
289
|
+
await api.fleet_telemetry_config_create_jws(vins=[vin], token=jws_token)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@asynccontextmanager
|
|
293
|
+
async def telemetry_session(
|
|
294
|
+
app_ctx: AppContext,
|
|
295
|
+
vin: str,
|
|
296
|
+
port: int,
|
|
297
|
+
field_config: dict[str, Any],
|
|
298
|
+
on_frame: Callable[[TelemetryFrame], Awaitable[None]],
|
|
299
|
+
*,
|
|
300
|
+
interactive: bool = True,
|
|
301
|
+
skip_server: bool = False,
|
|
302
|
+
) -> AsyncIterator[TelemetrySession]:
|
|
303
|
+
"""Shared lifecycle for telemetry consumers.
|
|
304
|
+
|
|
305
|
+
Manages: server start → tunnel → partner registration → fleet config
|
|
306
|
+
→ yield ``TelemetrySession`` → cleanup (config delete → domain restore
|
|
307
|
+
→ funnel stop → server stop → client close).
|
|
308
|
+
|
|
309
|
+
Parameters
|
|
310
|
+
----------
|
|
311
|
+
app_ctx:
|
|
312
|
+
CLI application context.
|
|
313
|
+
vin:
|
|
314
|
+
Vehicle VIN to stream telemetry from.
|
|
315
|
+
port:
|
|
316
|
+
Local port for the Tailscale Funnel target. When *skip_server*
|
|
317
|
+
is ``True`` the caller handles the listener on this port.
|
|
318
|
+
field_config:
|
|
319
|
+
Resolved field configuration mapping field IDs to intervals.
|
|
320
|
+
on_frame:
|
|
321
|
+
Async callback invoked for each decoded telemetry frame.
|
|
322
|
+
interactive:
|
|
323
|
+
If ``False``, skip interactive prompts (headless bridge mode).
|
|
324
|
+
Errors that would normally prompt user input are raised instead.
|
|
325
|
+
skip_server:
|
|
326
|
+
If ``True``, skip starting the built-in ``TelemetryServer``.
|
|
327
|
+
Use this when the WebSocket handler is mounted on an external
|
|
328
|
+
ASGI app (e.g. the MCP Starlette app) that shares the same port.
|
|
329
|
+
"""
|
|
330
|
+
from tescmd.crypto.keys import load_public_key_pem
|
|
331
|
+
from tescmd.models.config import AppSettings
|
|
332
|
+
from tescmd.telemetry.decoder import TelemetryDecoder
|
|
333
|
+
from tescmd.telemetry.server import TelemetryServer
|
|
334
|
+
|
|
335
|
+
formatter = app_ctx.formatter
|
|
336
|
+
|
|
337
|
+
_settings = AppSettings()
|
|
338
|
+
key_dir = Path(_settings.config_dir).expanduser() / "keys"
|
|
339
|
+
public_key_pem = load_public_key_pem(key_dir)
|
|
340
|
+
|
|
341
|
+
# Build API client
|
|
342
|
+
from tescmd.cli._client import get_vehicle_api
|
|
343
|
+
|
|
344
|
+
client, api = get_vehicle_api(app_ctx)
|
|
345
|
+
decoder = TelemetryDecoder()
|
|
346
|
+
|
|
347
|
+
server: TelemetryServer | None = None
|
|
348
|
+
if not skip_server:
|
|
349
|
+
server = TelemetryServer(
|
|
350
|
+
port=port, decoder=decoder, on_frame=on_frame, public_key_pem=public_key_pem
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
config_created = False
|
|
354
|
+
stop_tunnel: Callable[[], Awaitable[None]] = _noop_stop
|
|
355
|
+
original_partner_domain: str | None = None
|
|
356
|
+
tunnel_url = ""
|
|
357
|
+
hostname = ""
|
|
358
|
+
|
|
359
|
+
try:
|
|
360
|
+
if server is not None:
|
|
361
|
+
await server.start()
|
|
362
|
+
|
|
363
|
+
if not skip_server and formatter.format != "json":
|
|
364
|
+
formatter.rich.info(f"WebSocket server listening on port {port}")
|
|
365
|
+
|
|
366
|
+
tunnel_url, hostname, ca_pem, stop_tunnel = await _setup_tunnel(
|
|
367
|
+
port=port,
|
|
368
|
+
formatter=formatter,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
# Re-register partner domain if tunnel hostname differs
|
|
372
|
+
original_partner_domain = await _register_partner_domain(
|
|
373
|
+
hostname=hostname,
|
|
374
|
+
settings=_settings,
|
|
375
|
+
app_ctx=app_ctx,
|
|
376
|
+
formatter=formatter,
|
|
377
|
+
interactive=interactive,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
# Create fleet telemetry config
|
|
381
|
+
await _create_fleet_config(
|
|
382
|
+
api=api,
|
|
383
|
+
client=client,
|
|
384
|
+
vin=vin,
|
|
385
|
+
hostname=hostname,
|
|
386
|
+
ca_pem=ca_pem,
|
|
387
|
+
field_config=field_config,
|
|
388
|
+
key_dir=key_dir,
|
|
389
|
+
settings=_settings,
|
|
390
|
+
app_ctx=app_ctx,
|
|
391
|
+
formatter=formatter,
|
|
392
|
+
interactive=interactive,
|
|
393
|
+
)
|
|
394
|
+
config_created = True
|
|
395
|
+
|
|
396
|
+
if formatter.format != "json":
|
|
397
|
+
formatter.rich.info(f"Fleet telemetry configured for VIN {vin}")
|
|
398
|
+
formatter.rich.info("")
|
|
399
|
+
|
|
400
|
+
yield TelemetrySession(
|
|
401
|
+
server=server,
|
|
402
|
+
tunnel_url=tunnel_url,
|
|
403
|
+
hostname=hostname,
|
|
404
|
+
vin=vin,
|
|
405
|
+
port=port,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
finally:
|
|
409
|
+
# Cleanup in reverse order — each tolerates failure.
|
|
410
|
+
# Suppress noisy library loggers so shutdown messages stay clean.
|
|
411
|
+
for _logger_name in ("httpx", "httpcore", "websockets", "mcp"):
|
|
412
|
+
logging.getLogger(_logger_name).setLevel(logging.WARNING)
|
|
413
|
+
|
|
414
|
+
is_rich = formatter.format != "json"
|
|
415
|
+
|
|
416
|
+
if config_created:
|
|
417
|
+
if is_rich:
|
|
418
|
+
formatter.rich.info("[dim]Removing fleet telemetry config...[/dim]")
|
|
419
|
+
try:
|
|
420
|
+
await api.fleet_telemetry_config_delete(vin)
|
|
421
|
+
except Exception:
|
|
422
|
+
if is_rich:
|
|
423
|
+
formatter.rich.info(
|
|
424
|
+
"[yellow]Warning: failed to remove telemetry config."
|
|
425
|
+
" It may expire or can be removed manually.[/yellow]"
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
if original_partner_domain is not None:
|
|
429
|
+
if is_rich:
|
|
430
|
+
formatter.rich.info(
|
|
431
|
+
f"[dim]Restoring partner domain to {original_partner_domain}...[/dim]"
|
|
432
|
+
)
|
|
433
|
+
try:
|
|
434
|
+
from tescmd.auth.oauth import register_partner_account
|
|
435
|
+
|
|
436
|
+
assert _settings.client_id is not None
|
|
437
|
+
assert _settings.client_secret is not None
|
|
438
|
+
await register_partner_account(
|
|
439
|
+
client_id=_settings.client_id,
|
|
440
|
+
client_secret=_settings.client_secret,
|
|
441
|
+
domain=original_partner_domain,
|
|
442
|
+
region=app_ctx.region or _settings.region,
|
|
443
|
+
)
|
|
444
|
+
except Exception:
|
|
445
|
+
msg = (
|
|
446
|
+
f"Failed to restore partner domain to {original_partner_domain}. "
|
|
447
|
+
"Run 'tescmd auth register' to fix this manually."
|
|
448
|
+
)
|
|
449
|
+
logger.warning(msg)
|
|
450
|
+
if is_rich:
|
|
451
|
+
formatter.rich.info(f"[yellow]Warning: {msg}[/yellow]")
|
|
452
|
+
|
|
453
|
+
if is_rich:
|
|
454
|
+
formatter.rich.info("[dim]Stopping tunnel...[/dim]")
|
|
455
|
+
import contextlib
|
|
456
|
+
|
|
457
|
+
with contextlib.suppress(Exception):
|
|
458
|
+
await stop_tunnel()
|
|
459
|
+
|
|
460
|
+
if server is not None:
|
|
461
|
+
if is_rich:
|
|
462
|
+
formatter.rich.info("[dim]Stopping server...[/dim]")
|
|
463
|
+
with contextlib.suppress(Exception):
|
|
464
|
+
await server.stop()
|
|
465
|
+
|
|
466
|
+
await client.close()
|
|
467
|
+
if is_rich:
|
|
468
|
+
formatter.rich.info("[green]Stream stopped.[/green]")
|
tescmd/telemetry/tailscale.py
CHANGED
|
@@ -112,27 +112,86 @@ class TailscaleManager:
|
|
|
112
112
|
# Serve management (static file hosting)
|
|
113
113
|
# ------------------------------------------------------------------
|
|
114
114
|
|
|
115
|
-
async def start_serve(
|
|
115
|
+
async def start_serve(
|
|
116
|
+
self,
|
|
117
|
+
path: str,
|
|
118
|
+
target: str | Path,
|
|
119
|
+
*,
|
|
120
|
+
port: int = 443,
|
|
121
|
+
funnel: bool = False,
|
|
122
|
+
) -> None:
|
|
116
123
|
"""Serve a local directory at a URL path prefix.
|
|
117
124
|
|
|
118
|
-
Runs: ``tailscale serve --bg --set-path <path> <target>``
|
|
125
|
+
Runs: ``tailscale serve --bg [--https=<port>] [--funnel] --set-path <path> <target>``
|
|
126
|
+
|
|
127
|
+
When *port* differs from 443 the ``--https=<port>`` flag is added so
|
|
128
|
+
Tailscale listens on the requested HTTPS port.
|
|
119
129
|
|
|
120
130
|
Args:
|
|
121
131
|
path: URL path prefix (e.g. ``/.well-known/``).
|
|
122
132
|
target: Local directory to serve.
|
|
133
|
+
port: HTTPS port to serve on (default ``443``).
|
|
134
|
+
funnel: Also enable Funnel (public access) for this handler.
|
|
123
135
|
"""
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
"
|
|
127
|
-
|
|
128
|
-
"--
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
)
|
|
136
|
+
cmd: list[str] = ["tailscale", "serve", "--bg"]
|
|
137
|
+
if port != 443:
|
|
138
|
+
cmd.append(f"--https={port}")
|
|
139
|
+
if funnel:
|
|
140
|
+
cmd.append("--funnel")
|
|
141
|
+
cmd.extend(["--set-path", path, str(target)])
|
|
142
|
+
|
|
143
|
+
returncode, stdout, stderr = await self._run(*cmd)
|
|
132
144
|
if returncode != 0:
|
|
133
145
|
msg = stderr.strip() or stdout.strip()
|
|
134
146
|
raise TailscaleError(f"Failed to start Tailscale serve: {msg}")
|
|
135
|
-
logger.info("Tailscale serve started: %s -> %s", path, target)
|
|
147
|
+
logger.info("Tailscale serve started: %s -> %s (port %d)", path, target, port)
|
|
148
|
+
|
|
149
|
+
async def start_proxy(self, local_port: int, *, https_port: int = 443) -> None:
|
|
150
|
+
"""Reverse-proxy an HTTPS port to a local HTTP server.
|
|
151
|
+
|
|
152
|
+
Runs: ``tailscale serve --bg [--https=<https_port>] http://127.0.0.1:<local_port>``
|
|
153
|
+
|
|
154
|
+
Unlike :meth:`start_serve` (which serves static files via
|
|
155
|
+
``--set-path``), this sets up a reverse proxy so Tailscale
|
|
156
|
+
forwards traffic to a local HTTP server.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
local_port: Port of the local HTTP server to proxy to.
|
|
160
|
+
https_port: Public-facing HTTPS port (default ``443``).
|
|
161
|
+
"""
|
|
162
|
+
cmd: list[str] = ["tailscale", "serve", "--bg"]
|
|
163
|
+
if https_port != 443:
|
|
164
|
+
cmd.append(f"--https={https_port}")
|
|
165
|
+
cmd.append(f"http://127.0.0.1:{local_port}")
|
|
166
|
+
|
|
167
|
+
returncode, stdout, stderr = await self._run(*cmd)
|
|
168
|
+
if returncode != 0:
|
|
169
|
+
msg = stderr.strip() or stdout.strip()
|
|
170
|
+
raise TailscaleError(f"Failed to start Tailscale proxy: {msg}")
|
|
171
|
+
logger.info(
|
|
172
|
+
"Tailscale proxy started: https port %d -> http://127.0.0.1:%d",
|
|
173
|
+
https_port,
|
|
174
|
+
local_port,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
async def stop_proxy(self, *, https_port: int = 443) -> None:
|
|
178
|
+
"""Remove a reverse-proxy serve configuration for an HTTPS port.
|
|
179
|
+
|
|
180
|
+
Runs: ``tailscale serve --bg [--https=<https_port>] off``
|
|
181
|
+
"""
|
|
182
|
+
cmd: list[str] = ["tailscale", "serve", "--bg"]
|
|
183
|
+
if https_port != 443:
|
|
184
|
+
cmd.append(f"--https={https_port}")
|
|
185
|
+
cmd.append("off")
|
|
186
|
+
|
|
187
|
+
returncode, stdout, stderr = await self._run(*cmd)
|
|
188
|
+
if returncode != 0:
|
|
189
|
+
logger.warning(
|
|
190
|
+
"Failed to stop Tailscale proxy on port %d (may already be stopped): %s",
|
|
191
|
+
https_port,
|
|
192
|
+
stderr.strip() or stdout.strip(),
|
|
193
|
+
)
|
|
194
|
+
logger.info("Tailscale proxy stopped for HTTPS port %d", https_port)
|
|
136
195
|
|
|
137
196
|
async def stop_serve(self, path: str) -> None:
|
|
138
197
|
"""Remove a serve handler for a path.
|
|
@@ -155,21 +214,24 @@ class TailscaleManager:
|
|
|
155
214
|
)
|
|
156
215
|
logger.info("Tailscale serve stopped for path: %s", path)
|
|
157
216
|
|
|
158
|
-
async def enable_funnel(self) -> None:
|
|
159
|
-
"""Enable Funnel on port
|
|
217
|
+
async def enable_funnel(self, port: int = 443) -> None:
|
|
218
|
+
"""Enable Funnel on the given *port* (expose all serve handlers publicly).
|
|
160
219
|
|
|
161
|
-
Runs: ``tailscale funnel --bg
|
|
220
|
+
Runs: ``tailscale funnel --bg <port>``
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
port: HTTPS port to expose via Funnel (default ``443``).
|
|
162
224
|
"""
|
|
163
225
|
returncode, stdout, stderr = await self._run(
|
|
164
226
|
"tailscale",
|
|
165
227
|
"funnel",
|
|
166
228
|
"--bg",
|
|
167
|
-
|
|
229
|
+
str(port),
|
|
168
230
|
)
|
|
169
231
|
if returncode != 0:
|
|
170
232
|
msg = stderr.strip() or stdout.strip()
|
|
171
233
|
raise TailscaleError(f"Failed to enable Tailscale Funnel: {msg}")
|
|
172
|
-
logger.info("Tailscale Funnel enabled on port
|
|
234
|
+
logger.info("Tailscale Funnel enabled on port %d", port)
|
|
173
235
|
|
|
174
236
|
# ------------------------------------------------------------------
|
|
175
237
|
# Funnel management (port proxying for telemetry)
|