tescmd 0.1.2__py3-none-any.whl → 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tescmd/__init__.py +1 -1
- tescmd/api/client.py +49 -5
- tescmd/api/command.py +1 -1
- tescmd/api/errors.py +13 -0
- tescmd/api/signed_command.py +19 -14
- tescmd/api/vehicle.py +19 -1
- tescmd/auth/oauth.py +5 -1
- tescmd/auth/server.py +6 -1
- tescmd/auth/token_store.py +8 -1
- tescmd/cache/response_cache.py +11 -3
- tescmd/cli/_client.py +142 -20
- tescmd/cli/_options.py +2 -4
- tescmd/cli/auth.py +121 -11
- tescmd/cli/energy.py +2 -0
- tescmd/cli/key.py +149 -14
- tescmd/cli/main.py +70 -7
- tescmd/cli/mcp_cmd.py +153 -0
- tescmd/cli/nav.py +3 -1
- tescmd/cli/openclaw.py +169 -0
- tescmd/cli/security.py +7 -1
- tescmd/cli/serve.py +923 -0
- tescmd/cli/setup.py +244 -25
- tescmd/cli/sharing.py +2 -0
- tescmd/cli/status.py +1 -1
- tescmd/cli/trunk.py +8 -17
- tescmd/cli/user.py +16 -1
- tescmd/cli/vehicle.py +156 -20
- tescmd/crypto/__init__.py +3 -1
- tescmd/crypto/ecdh.py +9 -0
- tescmd/crypto/schnorr.py +191 -0
- tescmd/deploy/github_pages.py +8 -0
- tescmd/deploy/tailscale_serve.py +154 -0
- tescmd/mcp/__init__.py +7 -0
- tescmd/mcp/server.py +648 -0
- tescmd/models/__init__.py +0 -2
- tescmd/models/auth.py +24 -2
- tescmd/models/config.py +1 -0
- tescmd/models/energy.py +0 -9
- tescmd/openclaw/__init__.py +23 -0
- tescmd/openclaw/bridge.py +330 -0
- tescmd/openclaw/config.py +167 -0
- tescmd/openclaw/dispatcher.py +522 -0
- tescmd/openclaw/emitter.py +175 -0
- tescmd/openclaw/filters.py +123 -0
- tescmd/openclaw/gateway.py +687 -0
- tescmd/openclaw/telemetry_store.py +53 -0
- tescmd/output/rich_output.py +46 -14
- tescmd/protocol/commands.py +2 -2
- tescmd/protocol/encoder.py +16 -13
- tescmd/protocol/payloads.py +132 -11
- tescmd/protocol/session.py +18 -8
- tescmd/protocol/signer.py +3 -17
- tescmd/telemetry/__init__.py +28 -0
- tescmd/telemetry/cache_sink.py +154 -0
- tescmd/telemetry/csv_sink.py +180 -0
- tescmd/telemetry/dashboard.py +227 -0
- tescmd/telemetry/decoder.py +284 -0
- tescmd/telemetry/fanout.py +49 -0
- tescmd/telemetry/fields.py +427 -0
- tescmd/telemetry/flatbuf.py +162 -0
- tescmd/telemetry/mapper.py +239 -0
- tescmd/telemetry/protos/__init__.py +4 -0
- tescmd/telemetry/protos/vehicle_alert.proto +31 -0
- tescmd/telemetry/protos/vehicle_alert_pb2.py +42 -0
- tescmd/telemetry/protos/vehicle_alert_pb2.pyi +44 -0
- tescmd/telemetry/protos/vehicle_connectivity.proto +23 -0
- tescmd/telemetry/protos/vehicle_connectivity_pb2.py +40 -0
- tescmd/telemetry/protos/vehicle_connectivity_pb2.pyi +33 -0
- tescmd/telemetry/protos/vehicle_data.proto +768 -0
- tescmd/telemetry/protos/vehicle_data_pb2.py +136 -0
- tescmd/telemetry/protos/vehicle_data_pb2.pyi +1336 -0
- tescmd/telemetry/protos/vehicle_error.proto +23 -0
- tescmd/telemetry/protos/vehicle_error_pb2.py +44 -0
- tescmd/telemetry/protos/vehicle_error_pb2.pyi +39 -0
- tescmd/telemetry/protos/vehicle_metric.proto +22 -0
- tescmd/telemetry/protos/vehicle_metric_pb2.py +44 -0
- tescmd/telemetry/protos/vehicle_metric_pb2.pyi +37 -0
- tescmd/telemetry/server.py +300 -0
- tescmd/telemetry/setup.py +468 -0
- tescmd/telemetry/tailscale.py +300 -0
- tescmd/telemetry/tui.py +1716 -0
- tescmd/triggers/__init__.py +18 -0
- tescmd/triggers/manager.py +264 -0
- tescmd/triggers/models.py +93 -0
- {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/METADATA +125 -40
- tescmd-0.3.1.dist-info/RECORD +128 -0
- tescmd-0.1.2.dist-info/RECORD +0 -81
- {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/WHEEL +0 -0
- {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/entry_points.txt +0 -0
- {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/licenses/LICENSE +0 -0
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=["
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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":
|
|
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
|
|
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
|
|
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:
|
tescmd/crypto/schnorr.py
ADDED
|
@@ -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}"
|
tescmd/deploy/github_pages.py
CHANGED
|
@@ -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
|