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
tescmd/cli/vehicle.py CHANGED
@@ -4,6 +4,8 @@ from __future__ import annotations
4
4
 
5
5
  import asyncio
6
6
  import contextlib
7
+ import logging
8
+ import os
7
9
  from typing import TYPE_CHECKING
8
10
 
9
11
  import click
@@ -22,6 +24,10 @@ from tescmd.cli._client import (
22
24
  require_vin,
23
25
  )
24
26
  from tescmd.cli._options import global_options
27
+ from tescmd.models.sharing import ShareDriverInfo
28
+ from tescmd.models.vehicle import NearbyChargingSites, Vehicle
29
+
30
+ logger = logging.getLogger(__name__)
25
31
 
26
32
  if TYPE_CHECKING:
27
33
  from tescmd.cli.main import AppContext
@@ -60,6 +66,7 @@ async def _cmd_list(app_ctx: AppContext) -> None:
60
66
  endpoint="vehicle.list",
61
67
  fetch=lambda: api.list_vehicles(),
62
68
  ttl=TTL_SLOW,
69
+ model_class=Vehicle,
63
70
  )
64
71
  finally:
65
72
  await client.close()
@@ -90,6 +97,7 @@ async def _cmd_get(app_ctx: AppContext, vin_positional: str | None) -> None:
90
97
  endpoint="vehicle.get",
91
98
  fetch=lambda: api.get_vehicle(vin),
92
99
  ttl=TTL_DEFAULT,
100
+ model_class=Vehicle,
93
101
  )
94
102
  finally:
95
103
  await client.close()
@@ -174,7 +182,7 @@ async def _cmd_location(app_ctx: AppContext, vin_positional: str | None) -> None
174
182
  vin = require_vin(vin_positional, app_ctx.vin)
175
183
  client, api = get_vehicle_api(app_ctx)
176
184
  try:
177
- vdata = await cached_vehicle_data(app_ctx, api, vin, endpoints=["drive_state"])
185
+ vdata = await cached_vehicle_data(app_ctx, api, vin, endpoints=["location_data"])
178
186
  finally:
179
187
  await client.close()
180
188
 
@@ -322,6 +330,7 @@ async def _cmd_nearby_chargers(app_ctx: AppContext, vin_positional: str | None)
322
330
  endpoint="vehicle.nearby-chargers",
323
331
  fetch=lambda: api.nearby_charging_sites(vin),
324
332
  ttl=TTL_FAST,
333
+ model_class=NearbyChargingSites,
325
334
  )
326
335
  finally:
327
336
  await client.close()
@@ -361,9 +370,12 @@ async def _cmd_alerts(app_ctx: AppContext, vin_positional: str | None) -> None:
361
370
  else:
362
371
  if alerts:
363
372
  for alert in alerts:
364
- name = alert.get("name", "Unknown")
365
- ts = alert.get("time", "")
366
- formatter.rich.info(f" {name} [dim]{ts}[/dim]")
373
+ if isinstance(alert, dict):
374
+ name = alert.get("name", "Unknown")
375
+ ts = alert.get("time", "")
376
+ formatter.rich.info(f" {name} [dim]{ts}[/dim]")
377
+ else:
378
+ formatter.rich.info(f" {alert}")
367
379
  else:
368
380
  formatter.rich.info("[dim]No recent alerts.[/dim]")
369
381
 
@@ -448,6 +460,7 @@ async def _cmd_drivers(app_ctx: AppContext, vin_positional: str | None) -> None:
448
460
  endpoint="vehicle.drivers",
449
461
  fetch=lambda: api.list_drivers(vin),
450
462
  ttl=TTL_SLOW,
463
+ model_class=ShareDriverInfo,
451
464
  )
452
465
  finally:
453
466
  await client.close()
@@ -456,39 +469,98 @@ async def _cmd_drivers(app_ctx: AppContext, vin_positional: str | None) -> None:
456
469
  formatter.output(drivers, command="vehicle.drivers")
457
470
  else:
458
471
  if drivers:
472
+ formatter.rich.info("[bold]Drivers[/bold]")
459
473
  for d in drivers:
460
- email = (d.get("email") if isinstance(d, dict) else d.email) or "unknown"
461
- status = (d.get("status") if isinstance(d, dict) else d.status) or ""
462
- formatter.rich.info(f" {email} [dim]{status}[/dim]")
474
+ row = d if isinstance(d, dict) else d.model_dump(exclude_none=True)
475
+ user_id = row.get("share_user_id", "")
476
+ email = row.get("email") or "(no email)"
477
+ status = row.get("status") or ""
478
+ parts = [f" {email}"]
479
+ if status:
480
+ parts.append(f"[dim]{status}[/dim]")
481
+ if user_id:
482
+ parts.append(f"[dim](id: {user_id})[/dim]")
483
+ formatter.rich.info(" ".join(parts))
463
484
  else:
464
485
  formatter.rich.info("[dim]No drivers found.[/dim]")
465
486
 
466
487
 
467
488
  @vehicle_group.command("calendar")
468
489
  @click.argument("vin_positional", required=False, default=None, metavar="VIN")
469
- @click.argument("calendar_data")
490
+ @click.option(
491
+ "--file",
492
+ "-f",
493
+ "calendar_file",
494
+ type=click.File("r"),
495
+ default=None,
496
+ help="Read calendar JSON from file (use '-' for stdin)",
497
+ )
498
+ @click.argument("calendar_data", required=False, default=None)
470
499
  @global_options
471
- def calendar_cmd(app_ctx: AppContext, vin_positional: str | None, calendar_data: str) -> None:
500
+ def calendar_cmd(
501
+ app_ctx: AppContext,
502
+ vin_positional: str | None,
503
+ calendar_file: click.utils.LazyFile | None,
504
+ calendar_data: str | None,
505
+ ) -> None:
472
506
  """Send calendar entries to the vehicle.
473
507
 
474
- CALENDAR_DATA should be a JSON string of calendar entries.
508
+ Pass calendar JSON as an argument, via --file, or piped through stdin.
509
+
510
+ \b
511
+ Example JSON:
512
+ {
513
+ "calendars": [{
514
+ "name": "Work",
515
+ "color": "#4285F4",
516
+ "events": [{
517
+ "name": "Team Standup",
518
+ "location": "1 Market St, San Francisco, CA",
519
+ "start": 1738573200000,
520
+ "end": 1738576800000,
521
+ "all_day": false,
522
+ "organizer": "alice@example.com"
523
+ }]
524
+ }],
525
+ "phone_name": "My Phone",
526
+ "uuid": "abc-123"
527
+ }
528
+
529
+ \b
530
+ Usage:
531
+ tescmd vehicle calendar --file cal.json
532
+ tescmd vehicle calendar '{"calendars": [...]}'
533
+ cat cal.json | tescmd vehicle calendar --file -
475
534
  """
535
+ import sys
536
+
537
+ if calendar_file is not None:
538
+ data = calendar_file.read()
539
+ elif calendar_data is not None:
540
+ data = calendar_data
541
+ elif not sys.stdin.isatty():
542
+ data = sys.stdin.read()
543
+ else:
544
+ raise click.UsageError(
545
+ "No calendar data provided.\n\n"
546
+ "Usage:\n"
547
+ " tescmd vehicle calendar --file cal.json\n"
548
+ " tescmd vehicle calendar '{\"calendars\": [...]}'\n"
549
+ " cat cal.json | tescmd vehicle calendar --file -\n\n"
550
+ "Run --help for a full JSON example."
551
+ )
552
+
476
553
  run_async(
477
554
  execute_command(
478
555
  app_ctx,
479
556
  vin_positional,
480
557
  "upcoming_calendar_entries",
481
558
  "vehicle.calendar",
482
- body={"calendar_data": calendar_data},
559
+ body={"calendar_data": data.strip()},
483
560
  )
484
561
  )
485
562
 
486
563
 
487
- # ---------------------------------------------------------------------------
488
- # Power management commands
489
- # ---------------------------------------------------------------------------
490
-
491
-
492
564
  # ---------------------------------------------------------------------------
493
565
  # Extended vehicle data endpoints
494
566
  # ---------------------------------------------------------------------------
@@ -615,13 +687,14 @@ async def _cmd_specs(app_ctx: AppContext, vin_positional: str | None) -> None:
615
687
 
616
688
 
617
689
  @vehicle_group.command("warranty")
690
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
618
691
  @global_options
619
- def warranty_cmd(app_ctx: AppContext) -> None:
620
- """Fetch warranty details for the account."""
621
- run_async(_cmd_warranty(app_ctx))
692
+ def warranty_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
693
+ """Fetch warranty details for the vehicle."""
694
+ run_async(_cmd_warranty(app_ctx, vin_positional))
622
695
 
623
696
 
624
- async def _cmd_warranty(app_ctx: AppContext) -> None:
697
+ async def _cmd_warranty(app_ctx: AppContext, vin_positional: str | None) -> None:
625
698
  formatter = app_ctx.formatter
626
699
  client, api = get_vehicle_api(app_ctx)
627
700
  try:
@@ -794,6 +867,69 @@ async def _cmd_telemetry_errors(app_ctx: AppContext, vin_positional: str | None)
794
867
  formatter.rich.telemetry_errors(data)
795
868
 
796
869
 
870
+ @telemetry_group.command("stream")
871
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
872
+ @click.option(
873
+ "--port",
874
+ "telemetry_port",
875
+ type=int,
876
+ default=None,
877
+ help="Local server port (random if omitted)",
878
+ )
879
+ @click.option("--fields", default="default", help="Field preset or comma-separated names")
880
+ @click.option("--interval", type=int, default=None, help="Override interval for all fields")
881
+ @click.option("--no-log", is_flag=True, default=False, help="Disable CSV telemetry log")
882
+ @click.option(
883
+ "--legacy-dashboard",
884
+ is_flag=True,
885
+ default=False,
886
+ help="Use legacy Rich Live dashboard",
887
+ )
888
+ @global_options
889
+ def telemetry_stream_cmd(
890
+ app_ctx: AppContext,
891
+ vin_positional: str | None,
892
+ telemetry_port: int | None,
893
+ fields: str,
894
+ interval: int | None,
895
+ no_log: bool,
896
+ legacy_dashboard: bool,
897
+ ) -> None:
898
+ """Stream real-time telemetry via Tailscale Funnel.
899
+
900
+ Alias for ``tescmd serve --no-mcp``. Starts a local WebSocket server,
901
+ exposes it via Tailscale Funnel, configures the vehicle to push
902
+ telemetry, and displays a full-screen TUI dashboard (TTY) or JSONL
903
+ stream (piped). A CSV log is written by default.
904
+
905
+ Requires Tailscale with Funnel enabled.
906
+ """
907
+ from tescmd.cli.serve import _cmd_serve
908
+
909
+ run_async(
910
+ _cmd_serve(
911
+ app_ctx,
912
+ vin_positional=vin_positional,
913
+ transport="streamable-http",
914
+ mcp_port=int(os.environ.get("TESCMD_MCP_PORT", "8080")),
915
+ telemetry_port=telemetry_port,
916
+ fields_spec=fields,
917
+ interval_override=interval,
918
+ no_telemetry=False,
919
+ no_mcp=True,
920
+ no_log=no_log,
921
+ legacy_dashboard=legacy_dashboard,
922
+ openclaw_url=None,
923
+ openclaw_token=None,
924
+ openclaw_config_path=None,
925
+ dry_run=False,
926
+ tailscale=False,
927
+ client_id="",
928
+ client_secret="",
929
+ )
930
+ )
931
+
932
+
797
933
  # ---------------------------------------------------------------------------
798
934
  # Power management commands
799
935
  # ---------------------------------------------------------------------------
tescmd/crypto/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- """EC key management for Tesla Fleet API command signing."""
1
+ """EC key management and signing for Tesla Fleet API."""
2
2
 
3
3
  from tescmd.crypto.keys import (
4
4
  generate_ec_key_pair,
@@ -8,6 +8,7 @@ from tescmd.crypto.keys import (
8
8
  load_private_key,
9
9
  load_public_key_pem,
10
10
  )
11
+ from tescmd.crypto.schnorr import sign_fleet_telemetry_config
11
12
 
12
13
  __all__ = [
13
14
  "generate_ec_key_pair",
@@ -16,4 +17,5 @@ __all__ = [
16
17
  "has_key_pair",
17
18
  "load_private_key",
18
19
  "load_public_key_pem",
20
+ "sign_fleet_telemetry_config",
19
21
  ]
tescmd/crypto/ecdh.py CHANGED
@@ -13,6 +13,15 @@ def derive_session_key(
13
13
  ) -> bytes:
14
14
  """Derive a 16-byte session key via ECDH + SHA-1 truncation.
15
15
 
16
+ .. note::
17
+
18
+ SHA-1 is required by Tesla's Vehicle Command Protocol — the
19
+ vehicle firmware expects ``SHA1(shared_secret)[:16]``. SHA-1's
20
+ known collision weaknesses do not affect this usage (only
21
+ preimage resistance matters for key derivation, and SHA-1
22
+ remains preimage-resistant). See ``vehicle-command`` Go SDK
23
+ reference implementation.
24
+
16
25
  Parameters
17
26
  ----------
18
27
  private_key:
@@ -0,0 +1,191 @@
1
+ """Schnorr signatures over NIST P-256 for Tesla Fleet Telemetry JWS.
2
+
3
+ Implements the ``Tesla.SS256`` signing algorithm used by the Vehicle
4
+ Command HTTP Proxy to sign fleet telemetry configurations. This lets
5
+ tescmd call the ``fleet_telemetry_config_jws`` endpoint directly,
6
+ without requiring the Go-based ``tesla-http-proxy`` binary.
7
+
8
+ Algorithm reference: github.com/teslamotors/vehicle-command
9
+ internal/schnorr/sign.go — Sign()
10
+ internal/schnorr/schnorr.go — challenge(), Verify()
11
+ internal/authentication/jwt.go — SignMessage()
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import hashlib
18
+ import hmac
19
+ import json
20
+ import struct
21
+ from typing import Any
22
+
23
+ from cryptography.hazmat.primitives.asymmetric import ec
24
+ from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # P-256 curve constants
28
+ # ---------------------------------------------------------------------------
29
+
30
+ P256_ORDER = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551
31
+
32
+ _P256_GX = 0x6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296
33
+ _P256_GY = 0x4FE342E2FE1A7F9B8EE7EB4A7C0F9E162BCE33576B315ECECBB6406837BF51F5
34
+
35
+ # Generator point G in uncompressed X9.62 form (04 || X || Y).
36
+ P256_GENERATOR_BYTES = b"\x04" + _P256_GX.to_bytes(32, "big") + _P256_GY.to_bytes(32, "big")
37
+
38
+ _SCALAR_LEN = 32
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Low-level Schnorr primitives
43
+ # ---------------------------------------------------------------------------
44
+
45
+
46
+ def _deterministic_nonce(scalar: bytes, message_hash: bytes) -> bytes:
47
+ """RFC 6979 deterministic nonce generation for P-256 / SHA-256.
48
+
49
+ Mirrors ``DeterministicNonce`` in the Go SDK's ``internal/schnorr/sign.go``.
50
+ """
51
+ # Reduce message hash mod n
52
+ h1_int = int.from_bytes(message_hash, "big") % P256_ORDER
53
+ h1 = h1_int.to_bytes(32, "big")
54
+
55
+ # HMAC-DRBG initialisation (RFC 6979 S3.2 steps a-d)
56
+ k_mac = b"\x00" * 32
57
+ v = b"\x01" * 32
58
+
59
+ # Step d
60
+ k_mac = hmac.new(k_mac, v + b"\x00" + scalar + h1, hashlib.sha256).digest()
61
+ # Step e
62
+ v = hmac.new(k_mac, v, hashlib.sha256).digest()
63
+ # Step f
64
+ k_mac = hmac.new(k_mac, v + b"\x01" + scalar + h1, hashlib.sha256).digest()
65
+ # Step g
66
+ v = hmac.new(k_mac, v, hashlib.sha256).digest()
67
+
68
+ # Step h — generate candidates
69
+ while True:
70
+ v = hmac.new(k_mac, v, hashlib.sha256).digest()
71
+ nonce_int = int.from_bytes(v, "big")
72
+ if 0 < nonce_int < P256_ORDER:
73
+ return v
74
+
75
+ # Retry per spec
76
+ k_mac = hmac.new(k_mac, v + b"\x00", hashlib.sha256).digest()
77
+ v = hmac.new(k_mac, v, hashlib.sha256).digest()
78
+
79
+
80
+ def _write_length_value(h: Any, data: bytes) -> None:
81
+ """Write a 4-byte big-endian length prefix then the data into *h*."""
82
+ h.update(struct.pack(">I", len(data)))
83
+ h.update(data)
84
+
85
+
86
+ def _challenge(
87
+ public_nonce_uncompressed: bytes,
88
+ sender_public_uncompressed: bytes,
89
+ message: bytes,
90
+ ) -> bytes:
91
+ """Compute the Schnorr challenge hash.
92
+
93
+ ``SHA-256(len(G) || G || len(R) || R || len(P) || P || len(m) || m)``
94
+
95
+ Mirrors ``challenge()`` in ``internal/schnorr/schnorr.go``.
96
+ """
97
+ h = hashlib.sha256()
98
+ _write_length_value(h, P256_GENERATOR_BYTES)
99
+ _write_length_value(h, public_nonce_uncompressed)
100
+ _write_length_value(h, sender_public_uncompressed)
101
+ _write_length_value(h, message)
102
+ return h.digest()
103
+
104
+
105
+ def schnorr_sign(
106
+ private_key: ec.EllipticCurvePrivateKey,
107
+ message: bytes,
108
+ ) -> bytes:
109
+ """Produce a 96-byte Tesla Schnorr/P-256 signature.
110
+
111
+ Returns ``nonce_X (32) || nonce_Y (32) || r (32)``.
112
+ """
113
+ # Private scalar bytes (big-endian, zero-padded to 32 bytes)
114
+ priv_numbers = private_key.private_numbers()
115
+ scalar = priv_numbers.private_value.to_bytes(_SCALAR_LEN, "big")
116
+
117
+ # Public key (uncompressed X9.62)
118
+ sender_public = private_key.public_key().public_bytes(
119
+ Encoding.X962, PublicFormat.UncompressedPoint
120
+ )
121
+
122
+ # 1. Deterministic nonce
123
+ digest = hashlib.sha256(message).digest()
124
+ nonce_bytes = _deterministic_nonce(scalar, digest)
125
+ nonce_int = int.from_bytes(nonce_bytes, "big")
126
+
127
+ # 2. Nonce public key (k·G)
128
+ nonce_key = ec.derive_private_key(nonce_int, ec.SECP256R1())
129
+ nonce_public = nonce_key.public_key().public_bytes(
130
+ Encoding.X962, PublicFormat.UncompressedPoint
131
+ ) # 65 bytes: 04 || X || Y
132
+
133
+ # 3. Challenge c = H(G, R, P, m)
134
+ c_bytes = _challenge(nonce_public, sender_public, message)
135
+ c_int = int.from_bytes(c_bytes, "big")
136
+
137
+ # 4. Response r = k - x*c (mod n)
138
+ x_int = priv_numbers.private_value
139
+ r_int = (nonce_int - x_int * c_int) % P256_ORDER
140
+
141
+ # 5. Signature = nonce_X || nonce_Y || r (strip 0x04 prefix)
142
+ sig = nonce_public[1:] + r_int.to_bytes(_SCALAR_LEN, "big")
143
+ assert len(sig) == 3 * _SCALAR_LEN
144
+ return sig
145
+
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # JWS token construction (Tesla.SS256)
149
+ # ---------------------------------------------------------------------------
150
+
151
+ _JWS_HEADER = {"alg": "Tesla.SS256", "typ": "JWT"}
152
+
153
+
154
+ def _b64url(data: bytes) -> str:
155
+ """Base64url-encode without padding (per RFC 7515)."""
156
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
157
+
158
+
159
+ def sign_fleet_telemetry_config(
160
+ private_key: ec.EllipticCurvePrivateKey,
161
+ config: dict[str, Any],
162
+ ) -> str:
163
+ """Create a JWS token for a fleet telemetry config payload.
164
+
165
+ The *config* dict (hostname, ca, fields, alert_types) is signed with the
166
+ Tesla.SS256 algorithm. ``iss`` and ``aud`` claims are set automatically.
167
+
168
+ Returns the compact JWS string for the ``fleet_telemetry_config_jws``
169
+ endpoint's ``token`` field.
170
+ """
171
+ # Build claims — shallow copy so we don't mutate the caller's dict
172
+ claims: dict[str, Any] = dict(config)
173
+
174
+ # iss = base64(uncompressed public key bytes)
175
+ pub_bytes = private_key.public_key().public_bytes(
176
+ Encoding.X962, PublicFormat.UncompressedPoint
177
+ )
178
+ claims["iss"] = base64.standard_b64encode(pub_bytes).decode("ascii")
179
+ claims["aud"] = "com.tesla.fleet.TelemetryClient"
180
+
181
+ # Serialise header & payload
182
+ header_b64 = _b64url(json.dumps(_JWS_HEADER, separators=(",", ":")).encode())
183
+ payload_b64 = _b64url(json.dumps(claims, separators=(",", ":")).encode())
184
+
185
+ signing_input = f"{header_b64}.{payload_b64}".encode("ascii")
186
+
187
+ # Sign
188
+ sig = schnorr_sign(private_key, signing_input)
189
+ sig_b64 = _b64url(sig)
190
+
191
+ return f"{header_b64}.{payload_b64}.{sig_b64}"
@@ -27,8 +27,15 @@ POLL_INTERVAL = 5 # seconds
27
27
  # ---------------------------------------------------------------------------
28
28
 
29
29
 
30
+ def _check_tool(name: str) -> None:
31
+ """Raise FileNotFoundError if *name* is not on PATH."""
32
+ if shutil.which(name) is None:
33
+ raise FileNotFoundError(f"Required tool {name!r} is not installed or not on PATH")
34
+
35
+
30
36
  def _run_gh(args: list[str], *, check: bool = True) -> subprocess.CompletedProcess[str]:
31
37
  """Run a ``gh`` CLI command and return the result."""
38
+ _check_tool("gh")
32
39
  return subprocess.run(
33
40
  ["gh", *args],
34
41
  capture_output=True,
@@ -44,6 +51,7 @@ def _run_git(
44
51
  check: bool = True,
45
52
  ) -> subprocess.CompletedProcess[str]:
46
53
  """Run a ``git`` command and return the result."""
54
+ _check_tool("git")
47
55
  return subprocess.run(
48
56
  ["git", *args],
49
57
  capture_output=True,
@@ -0,0 +1,154 @@
1
+ """Tailscale Funnel deployment helpers for Tesla Fleet API public keys.
2
+
3
+ Serves the public key at the Tesla-required ``.well-known`` path via
4
+ Tailscale's ``serve`` + ``funnel`` commands. All Tailscale interaction
5
+ goes through :class:`~tescmd.telemetry.tailscale.TailscaleManager`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import logging
12
+ import time
13
+ from pathlib import Path
14
+
15
+ import httpx
16
+
17
+ from tescmd.api.errors import TailscaleError
18
+ from tescmd.telemetry.tailscale import TailscaleManager
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ WELL_KNOWN_PATH = ".well-known/appspecific/com.tesla.3p.public-key.pem"
23
+ DEFAULT_SERVE_DIR = Path("~/.config/tescmd/serve")
24
+
25
+ # Polling for deployment validation
26
+ DEFAULT_DEPLOY_TIMEOUT = 60 # seconds (faster than GitHub Pages)
27
+ POLL_INTERVAL = 3 # seconds
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Key file management
32
+ # ---------------------------------------------------------------------------
33
+
34
+
35
+ async def deploy_public_key_tailscale(
36
+ public_key_pem: str,
37
+ serve_dir: Path | None = None,
38
+ ) -> Path:
39
+ """Write the PEM key into the serve directory structure.
40
+
41
+ Creates ``<serve_dir>/.well-known/appspecific/com.tesla.3p.public-key.pem``.
42
+
43
+ Returns the path to the written key file.
44
+ """
45
+ base = (serve_dir or DEFAULT_SERVE_DIR).expanduser()
46
+ key_path = base / WELL_KNOWN_PATH
47
+ key_path.parent.mkdir(parents=True, exist_ok=True)
48
+ key_path.write_text(public_key_pem)
49
+ logger.info("Public key written to %s", key_path)
50
+ return key_path
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Serve / Funnel lifecycle
55
+ # ---------------------------------------------------------------------------
56
+
57
+
58
+ async def start_key_serving(serve_dir: Path | None = None) -> str:
59
+ """Start ``tailscale serve`` for ``.well-known`` and enable Funnel.
60
+
61
+ Returns the public hostname (e.g. ``machine.tailnet.ts.net``).
62
+
63
+ Raises:
64
+ TailscaleError: If Tailscale is not ready or Funnel cannot start.
65
+ """
66
+ base = (serve_dir or DEFAULT_SERVE_DIR).expanduser()
67
+ well_known_dir = base / ".well-known"
68
+ if not well_known_dir.exists():
69
+ raise TailscaleError(
70
+ f"Serve directory not found: {well_known_dir}. "
71
+ "Run deploy_public_key_tailscale() first."
72
+ )
73
+
74
+ ts = TailscaleManager()
75
+ await ts.check_available()
76
+ hostname = await ts.get_hostname()
77
+
78
+ # Serve the .well-known directory at /.well-known/
79
+ await ts.start_serve("/.well-known/", str(well_known_dir))
80
+
81
+ # Enable Funnel to make it publicly accessible
82
+ await ts.enable_funnel()
83
+
84
+ logger.info("Key serving started at https://%s/%s", hostname, WELL_KNOWN_PATH)
85
+ return hostname
86
+
87
+
88
+ async def stop_key_serving() -> None:
89
+ """Remove the ``.well-known`` serve handler."""
90
+ ts = TailscaleManager()
91
+ await ts.stop_serve("/.well-known/")
92
+ logger.info("Key serving stopped")
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Readiness check
97
+ # ---------------------------------------------------------------------------
98
+
99
+
100
+ async def is_tailscale_serve_ready() -> bool:
101
+ """Quick check: CLI on PATH + daemon running + Funnel available.
102
+
103
+ Returns bool, never raises.
104
+ """
105
+ try:
106
+ ts = TailscaleManager()
107
+ await ts.check_available()
108
+ await ts.check_running()
109
+ return await ts.check_funnel_available()
110
+ except (TailscaleError, Exception):
111
+ return False
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # URL helpers and validation
116
+ # ---------------------------------------------------------------------------
117
+
118
+
119
+ def get_key_url(hostname: str) -> str:
120
+ """Return full URL to the public key."""
121
+ return f"https://{hostname}/{WELL_KNOWN_PATH}"
122
+
123
+
124
+ async def validate_tailscale_key_url(hostname: str) -> bool:
125
+ """HTTP GET to verify key is accessible.
126
+
127
+ Returns True if the key is reachable and contains PEM content.
128
+ """
129
+ url = get_key_url(hostname)
130
+ try:
131
+ async with httpx.AsyncClient() as client:
132
+ resp = await client.get(url, follow_redirects=True, timeout=10)
133
+ return resp.status_code == 200 and "BEGIN PUBLIC KEY" in resp.text
134
+ except httpx.HTTPError:
135
+ return False
136
+
137
+
138
+ async def wait_for_tailscale_deployment(
139
+ hostname: str,
140
+ *,
141
+ timeout: int = DEFAULT_DEPLOY_TIMEOUT,
142
+ ) -> bool:
143
+ """Poll key URL until accessible or *timeout* elapses.
144
+
145
+ Returns True if the key became accessible, False on timeout.
146
+ """
147
+ deadline = time.monotonic() + timeout
148
+
149
+ while time.monotonic() < deadline:
150
+ if await validate_tailscale_key_url(hostname):
151
+ return True
152
+ await asyncio.sleep(POLL_INTERVAL)
153
+
154
+ return False
tescmd/mcp/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """MCP (Model Context Protocol) server for tescmd."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from tescmd.mcp.server import create_mcp_server
6
+
7
+ __all__ = ["create_mcp_server"]