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.
Files changed (65) hide show
  1. tescmd/__init__.py +1 -1
  2. tescmd/api/client.py +41 -4
  3. tescmd/api/command.py +1 -1
  4. tescmd/api/errors.py +5 -0
  5. tescmd/api/signed_command.py +19 -14
  6. tescmd/auth/oauth.py +15 -1
  7. tescmd/auth/server.py +6 -1
  8. tescmd/auth/token_store.py +8 -1
  9. tescmd/cache/response_cache.py +8 -1
  10. tescmd/cli/_client.py +142 -20
  11. tescmd/cli/_options.py +2 -4
  12. tescmd/cli/auth.py +255 -106
  13. tescmd/cli/energy.py +2 -0
  14. tescmd/cli/key.py +6 -7
  15. tescmd/cli/main.py +27 -8
  16. tescmd/cli/mcp_cmd.py +153 -0
  17. tescmd/cli/nav.py +3 -1
  18. tescmd/cli/openclaw.py +169 -0
  19. tescmd/cli/security.py +7 -1
  20. tescmd/cli/serve.py +923 -0
  21. tescmd/cli/setup.py +147 -58
  22. tescmd/cli/sharing.py +2 -0
  23. tescmd/cli/status.py +1 -1
  24. tescmd/cli/trunk.py +8 -17
  25. tescmd/cli/user.py +16 -1
  26. tescmd/cli/vehicle.py +135 -462
  27. tescmd/deploy/github_pages.py +21 -2
  28. tescmd/deploy/tailscale_serve.py +96 -8
  29. tescmd/mcp/__init__.py +7 -0
  30. tescmd/mcp/server.py +648 -0
  31. tescmd/models/auth.py +5 -2
  32. tescmd/openclaw/__init__.py +23 -0
  33. tescmd/openclaw/bridge.py +330 -0
  34. tescmd/openclaw/config.py +167 -0
  35. tescmd/openclaw/dispatcher.py +529 -0
  36. tescmd/openclaw/emitter.py +175 -0
  37. tescmd/openclaw/filters.py +123 -0
  38. tescmd/openclaw/gateway.py +700 -0
  39. tescmd/openclaw/telemetry_store.py +53 -0
  40. tescmd/output/rich_output.py +46 -14
  41. tescmd/protocol/commands.py +2 -2
  42. tescmd/protocol/encoder.py +16 -13
  43. tescmd/protocol/payloads.py +132 -11
  44. tescmd/protocol/session.py +8 -5
  45. tescmd/protocol/signer.py +3 -17
  46. tescmd/telemetry/__init__.py +9 -0
  47. tescmd/telemetry/cache_sink.py +154 -0
  48. tescmd/telemetry/csv_sink.py +180 -0
  49. tescmd/telemetry/dashboard.py +4 -4
  50. tescmd/telemetry/fanout.py +49 -0
  51. tescmd/telemetry/fields.py +308 -129
  52. tescmd/telemetry/mapper.py +239 -0
  53. tescmd/telemetry/server.py +26 -19
  54. tescmd/telemetry/setup.py +468 -0
  55. tescmd/telemetry/tailscale.py +78 -16
  56. tescmd/telemetry/tui.py +1716 -0
  57. tescmd/triggers/__init__.py +18 -0
  58. tescmd/triggers/manager.py +264 -0
  59. tescmd/triggers/models.py +93 -0
  60. tescmd-0.4.0.dist-info/METADATA +300 -0
  61. {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/RECORD +64 -42
  62. tescmd-0.2.0.dist-info/METADATA +0 -495
  63. {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/WHEEL +0 -0
  64. {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/entry_points.txt +0 -0
  65. {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]")
@@ -112,27 +112,86 @@ class TailscaleManager:
112
112
  # Serve management (static file hosting)
113
113
  # ------------------------------------------------------------------
114
114
 
115
- async def start_serve(self, path: str, target: str | Path) -> None:
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
- returncode, stdout, stderr = await self._run(
125
- "tailscale",
126
- "serve",
127
- "--bg",
128
- "--set-path",
129
- path,
130
- str(target),
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 443 (expose all serve handlers publicly).
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 443``
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
- "443",
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 443")
234
+ logger.info("Tailscale Funnel enabled on port %d", port)
173
235
 
174
236
  # ------------------------------------------------------------------
175
237
  # Funnel management (port proxying for telemetry)