tescmd 0.3.1__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/auth/oauth.py +10 -0
- tescmd/cli/auth.py +212 -145
- tescmd/cli/key.py +6 -7
- tescmd/cli/setup.py +134 -56
- tescmd/deploy/github_pages.py +13 -2
- tescmd/deploy/tailscale_serve.py +96 -8
- tescmd/openclaw/dispatcher.py +9 -2
- tescmd/openclaw/gateway.py +13 -0
- tescmd/telemetry/tailscale.py +78 -16
- tescmd-0.4.0.dist-info/METADATA +300 -0
- {tescmd-0.3.1.dist-info → tescmd-0.4.0.dist-info}/RECORD +15 -15
- tescmd-0.3.1.dist-info/METADATA +0 -543
- {tescmd-0.3.1.dist-info → tescmd-0.4.0.dist-info}/WHEEL +0 -0
- {tescmd-0.3.1.dist-info → tescmd-0.4.0.dist-info}/entry_points.txt +0 -0
- {tescmd-0.3.1.dist-info → tescmd-0.4.0.dist-info}/licenses/LICENSE +0 -0
tescmd/cli/setup.py
CHANGED
|
@@ -29,10 +29,11 @@ TIER_FULL = "full"
|
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
@click.command("setup")
|
|
32
|
+
@click.option("--force", is_flag=True, help="Reconfigure everything from scratch.")
|
|
32
33
|
@global_options
|
|
33
|
-
def setup_cmd(app_ctx: AppContext) -> None:
|
|
34
|
+
def setup_cmd(app_ctx: AppContext, force: bool) -> None:
|
|
34
35
|
"""Interactive setup wizard for first-time configuration."""
|
|
35
|
-
run_async(_cmd_setup(app_ctx))
|
|
36
|
+
run_async(_cmd_setup(app_ctx, force=force))
|
|
36
37
|
|
|
37
38
|
|
|
38
39
|
# ---------------------------------------------------------------------------
|
|
@@ -40,29 +41,35 @@ def setup_cmd(app_ctx: AppContext) -> None:
|
|
|
40
41
|
# ---------------------------------------------------------------------------
|
|
41
42
|
|
|
42
43
|
|
|
43
|
-
async def _cmd_setup(app_ctx: AppContext) -> None:
|
|
44
|
+
async def _cmd_setup(app_ctx: AppContext, *, force: bool = False) -> None:
|
|
44
45
|
"""Run the tiered onboarding wizard."""
|
|
45
46
|
formatter = app_ctx.formatter
|
|
46
47
|
settings = AppSettings()
|
|
47
48
|
|
|
48
49
|
# Phase 0: Welcome + tier selection
|
|
49
|
-
tier = _prompt_tier(formatter, settings)
|
|
50
|
+
tier = _prompt_tier(formatter, settings, force=force)
|
|
50
51
|
if not tier:
|
|
51
52
|
return
|
|
52
53
|
|
|
53
54
|
# Phase 1: Domain setup via GitHub Pages (must happen before developer
|
|
54
55
|
# portal because Tesla requires the Allowed Origin URL to match the
|
|
55
56
|
# registration domain)
|
|
56
|
-
domain = _domain_setup(formatter, settings)
|
|
57
|
+
domain = _domain_setup(formatter, settings, force=force)
|
|
57
58
|
if not domain:
|
|
58
59
|
return
|
|
59
60
|
|
|
60
61
|
# Re-read settings after potential .env changes
|
|
61
62
|
settings = AppSettings()
|
|
62
63
|
|
|
64
|
+
# Early check: warn if remote key differs from local
|
|
65
|
+
if tier == TIER_FULL:
|
|
66
|
+
_check_key_mismatch(formatter, settings, domain)
|
|
67
|
+
|
|
63
68
|
# Phase 2: Developer portal walkthrough (credentials — uses domain for
|
|
64
69
|
# the Allowed Origin URL instructions)
|
|
65
|
-
client_id, client_secret = _developer_portal_setup(
|
|
70
|
+
client_id, client_secret = _developer_portal_setup(
|
|
71
|
+
formatter, app_ctx, settings, domain=domain, force=force, tier=tier
|
|
72
|
+
)
|
|
66
73
|
if not client_id:
|
|
67
74
|
return
|
|
68
75
|
|
|
@@ -71,17 +78,17 @@ async def _cmd_setup(app_ctx: AppContext) -> None:
|
|
|
71
78
|
|
|
72
79
|
# Phase 3: Key generation + deployment (full tier only)
|
|
73
80
|
if tier == TIER_FULL:
|
|
74
|
-
_key_setup(formatter, settings, domain)
|
|
81
|
+
_key_setup(formatter, settings, domain, force=force)
|
|
75
82
|
|
|
76
|
-
# Phase
|
|
77
|
-
if tier == TIER_FULL:
|
|
78
|
-
await _enrollment_step(formatter, app_ctx, settings)
|
|
79
|
-
|
|
80
|
-
# Phase 4: Fleet API partner registration
|
|
83
|
+
# Phase 4: Fleet API partner registration (closes out the setup phases)
|
|
81
84
|
await _registration_step(formatter, app_ctx, settings, client_id, client_secret, domain)
|
|
82
85
|
|
|
83
86
|
# Phase 5: OAuth login
|
|
84
|
-
await _oauth_login_step(formatter, app_ctx, settings, client_id, client_secret)
|
|
87
|
+
await _oauth_login_step(formatter, app_ctx, settings, client_id, client_secret, force=force)
|
|
88
|
+
|
|
89
|
+
# Phase 3.5: Key enrollment (full tier only — after login)
|
|
90
|
+
if tier == TIER_FULL:
|
|
91
|
+
await _enrollment_step(formatter, app_ctx, settings)
|
|
85
92
|
|
|
86
93
|
# Phase 6: Summary
|
|
87
94
|
_print_next_steps(formatter, tier)
|
|
@@ -92,13 +99,13 @@ async def _cmd_setup(app_ctx: AppContext) -> None:
|
|
|
92
99
|
# ---------------------------------------------------------------------------
|
|
93
100
|
|
|
94
101
|
|
|
95
|
-
def _prompt_tier(formatter: OutputFormatter, settings: AppSettings) -> str:
|
|
102
|
+
def _prompt_tier(formatter: OutputFormatter, settings: AppSettings, *, force: bool = False) -> str:
|
|
96
103
|
"""Ask the user which tier they want and persist the choice."""
|
|
97
104
|
info = formatter.rich.info
|
|
98
105
|
|
|
99
106
|
# If already configured, offer to keep or change
|
|
100
107
|
existing_tier = settings.setup_tier
|
|
101
|
-
if existing_tier in (TIER_READONLY, TIER_FULL):
|
|
108
|
+
if existing_tier in (TIER_READONLY, TIER_FULL) and not force:
|
|
102
109
|
info(f"Setup tier: [cyan]{existing_tier}[/cyan] (previously configured)")
|
|
103
110
|
info("")
|
|
104
111
|
|
|
@@ -157,6 +164,47 @@ def _prompt_tier(formatter: OutputFormatter, settings: AppSettings) -> str:
|
|
|
157
164
|
return tier
|
|
158
165
|
|
|
159
166
|
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
# Key mismatch warning (between Phase 1 and Phase 2)
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _check_key_mismatch(
|
|
173
|
+
formatter: OutputFormatter,
|
|
174
|
+
settings: AppSettings,
|
|
175
|
+
domain: str,
|
|
176
|
+
) -> None:
|
|
177
|
+
"""Warn early if the remote public key differs from the local key."""
|
|
178
|
+
info = formatter.rich.info
|
|
179
|
+
key_dir = Path(settings.config_dir).expanduser() / "keys"
|
|
180
|
+
|
|
181
|
+
from tescmd.crypto.keys import has_key_pair, load_public_key_pem
|
|
182
|
+
|
|
183
|
+
if not has_key_pair(key_dir):
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
pem = load_public_key_pem(key_dir)
|
|
187
|
+
|
|
188
|
+
# Fetch remote key (method-aware)
|
|
189
|
+
if settings.hosting_method == "tailscale":
|
|
190
|
+
from tescmd.deploy.tailscale_serve import fetch_tailscale_key_pem, get_key_url
|
|
191
|
+
|
|
192
|
+
url = get_key_url(domain)
|
|
193
|
+
remote_pem = fetch_tailscale_key_pem(domain)
|
|
194
|
+
else:
|
|
195
|
+
from tescmd.deploy.github_pages import fetch_key_pem, get_key_url
|
|
196
|
+
|
|
197
|
+
url = get_key_url(domain)
|
|
198
|
+
remote_pem = fetch_key_pem(domain)
|
|
199
|
+
|
|
200
|
+
if remote_pem is not None and remote_pem != pem.strip():
|
|
201
|
+
info("[yellow]The public key on your domain differs from your local key.[/yellow]")
|
|
202
|
+
info(f" Remote: {url}")
|
|
203
|
+
info(" This can happen after regenerating your key pair.")
|
|
204
|
+
info(" The key will be redeployed in Phase 3.")
|
|
205
|
+
info("")
|
|
206
|
+
|
|
207
|
+
|
|
160
208
|
# ---------------------------------------------------------------------------
|
|
161
209
|
# Phase 2: Developer portal walkthrough
|
|
162
210
|
# ---------------------------------------------------------------------------
|
|
@@ -168,6 +216,8 @@ def _developer_portal_setup(
|
|
|
168
216
|
settings: AppSettings,
|
|
169
217
|
*,
|
|
170
218
|
domain: str = "",
|
|
219
|
+
force: bool = False,
|
|
220
|
+
tier: str = "",
|
|
171
221
|
) -> tuple[str, str]:
|
|
172
222
|
"""Walk through Tesla Developer Portal setup if credentials are missing."""
|
|
173
223
|
info = formatter.rich.info
|
|
@@ -175,7 +225,7 @@ def _developer_portal_setup(
|
|
|
175
225
|
client_id = settings.client_id
|
|
176
226
|
client_secret = settings.client_secret
|
|
177
227
|
|
|
178
|
-
if client_id:
|
|
228
|
+
if client_id and not force:
|
|
179
229
|
info(f"Client ID: [cyan]{client_id[:8]}...[/cyan] (already configured)")
|
|
180
230
|
return (client_id, client_secret or "")
|
|
181
231
|
|
|
@@ -196,7 +246,13 @@ def _developer_portal_setup(
|
|
|
196
246
|
from tescmd.cli.auth import _interactive_setup
|
|
197
247
|
|
|
198
248
|
return _interactive_setup(
|
|
199
|
-
formatter,
|
|
249
|
+
formatter,
|
|
250
|
+
port,
|
|
251
|
+
redirect_uri,
|
|
252
|
+
domain=domain,
|
|
253
|
+
tailscale_hostname=ts_hostname,
|
|
254
|
+
full_tier=(tier == TIER_FULL),
|
|
255
|
+
force=force,
|
|
200
256
|
)
|
|
201
257
|
|
|
202
258
|
|
|
@@ -205,11 +261,13 @@ def _developer_portal_setup(
|
|
|
205
261
|
# ---------------------------------------------------------------------------
|
|
206
262
|
|
|
207
263
|
|
|
208
|
-
def _domain_setup(
|
|
264
|
+
def _domain_setup(
|
|
265
|
+
formatter: OutputFormatter, settings: AppSettings, *, force: bool = False
|
|
266
|
+
) -> str:
|
|
209
267
|
"""Set up a domain via GitHub Pages, Tailscale Funnel, or manual entry."""
|
|
210
268
|
info = formatter.rich.info
|
|
211
269
|
|
|
212
|
-
if settings.domain:
|
|
270
|
+
if settings.domain and not force:
|
|
213
271
|
info(f"Domain: [cyan]{settings.domain}[/cyan] (already configured)")
|
|
214
272
|
info("")
|
|
215
273
|
return settings.domain
|
|
@@ -256,10 +314,10 @@ def _automated_domain_setup(formatter: OutputFormatter, settings: AppSettings) -
|
|
|
256
314
|
info(f"GitHub CLI detected. Logged in as [cyan]{username}[/cyan].")
|
|
257
315
|
info(f"Suggested domain: [cyan]{suggested_domain}[/cyan]")
|
|
258
316
|
info("")
|
|
259
|
-
info("[dim]Note: GitHub Pages provides always-on key hosting
|
|
260
|
-
info("[dim]
|
|
261
|
-
info("[dim]
|
|
262
|
-
info("[dim]
|
|
317
|
+
info("[dim]Note: GitHub Pages provides always-on key hosting. For Fleet[/dim]")
|
|
318
|
+
info("[dim]Telemetry streaming, Tailscale will also be used alongside[/dim]")
|
|
319
|
+
info("[dim]GitHub Pages — just add your Tailscale hostname as an extra[/dim]")
|
|
320
|
+
info("[dim]Allowed Origin URL in the developer portal.[/dim]")
|
|
263
321
|
info("")
|
|
264
322
|
|
|
265
323
|
try:
|
|
@@ -350,7 +408,9 @@ def _manual_domain_setup(formatter: OutputFormatter) -> str:
|
|
|
350
408
|
# ---------------------------------------------------------------------------
|
|
351
409
|
|
|
352
410
|
|
|
353
|
-
def _key_setup(
|
|
411
|
+
def _key_setup(
|
|
412
|
+
formatter: OutputFormatter, settings: AppSettings, domain: str, *, force: bool = False
|
|
413
|
+
) -> None:
|
|
354
414
|
"""Generate keys and deploy via the configured hosting method (full tier only)."""
|
|
355
415
|
info = formatter.rich.info
|
|
356
416
|
|
|
@@ -365,12 +425,12 @@ def _key_setup(formatter: OutputFormatter, settings: AppSettings, domain: str) -
|
|
|
365
425
|
has_key_pair,
|
|
366
426
|
)
|
|
367
427
|
|
|
368
|
-
# Generate keys if needed
|
|
369
|
-
if has_key_pair(key_dir):
|
|
428
|
+
# Generate keys if needed (force → overwrite existing keys)
|
|
429
|
+
if has_key_pair(key_dir) and not force:
|
|
370
430
|
info(f"Key pair: [cyan]exists[/cyan] (fingerprint: {get_key_fingerprint(key_dir)})")
|
|
371
431
|
else:
|
|
372
432
|
info("Generating EC P-256 key pair...")
|
|
373
|
-
generate_ec_key_pair(key_dir)
|
|
433
|
+
generate_ec_key_pair(key_dir, overwrite=force)
|
|
374
434
|
info("[green]Key pair generated.[/green]")
|
|
375
435
|
info(f" Fingerprint: {get_key_fingerprint(key_dir)}")
|
|
376
436
|
|
|
@@ -380,9 +440,9 @@ def _key_setup(formatter: OutputFormatter, settings: AppSettings, domain: str) -
|
|
|
380
440
|
hosting = settings.hosting_method
|
|
381
441
|
|
|
382
442
|
if hosting == "tailscale":
|
|
383
|
-
_deploy_key_tailscale(formatter, settings, key_dir, domain)
|
|
443
|
+
_deploy_key_tailscale(formatter, settings, key_dir, domain, force=force)
|
|
384
444
|
else:
|
|
385
|
-
_deploy_key_github(formatter, settings, key_dir, domain)
|
|
445
|
+
_deploy_key_github(formatter, settings, key_dir, domain, force=force)
|
|
386
446
|
|
|
387
447
|
|
|
388
448
|
def _deploy_key_tailscale(
|
|
@@ -390,6 +450,8 @@ def _deploy_key_tailscale(
|
|
|
390
450
|
settings: AppSettings,
|
|
391
451
|
key_dir: Path,
|
|
392
452
|
domain: str,
|
|
453
|
+
*,
|
|
454
|
+
force: bool = False,
|
|
393
455
|
) -> None:
|
|
394
456
|
"""Deploy key via Tailscale Funnel."""
|
|
395
457
|
info = formatter.rich.info
|
|
@@ -404,7 +466,7 @@ def _deploy_key_tailscale(
|
|
|
404
466
|
)
|
|
405
467
|
|
|
406
468
|
# Check if key is already deployed
|
|
407
|
-
if run_async(validate_tailscale_key_url(domain)):
|
|
469
|
+
if run_async(validate_tailscale_key_url(domain)) and not force:
|
|
408
470
|
info(f"Public key: [green]already accessible[/green] at {get_key_url(domain)}")
|
|
409
471
|
info("")
|
|
410
472
|
return
|
|
@@ -434,6 +496,8 @@ def _deploy_key_github(
|
|
|
434
496
|
settings: AppSettings,
|
|
435
497
|
key_dir: Path,
|
|
436
498
|
domain: str,
|
|
499
|
+
*,
|
|
500
|
+
force: bool = False,
|
|
437
501
|
) -> None:
|
|
438
502
|
"""Deploy key via GitHub Pages."""
|
|
439
503
|
info = formatter.rich.info
|
|
@@ -441,10 +505,10 @@ def _deploy_key_github(
|
|
|
441
505
|
from tescmd.crypto.keys import load_public_key_pem
|
|
442
506
|
from tescmd.deploy.github_pages import (
|
|
443
507
|
deploy_public_key,
|
|
508
|
+
fetch_key_pem,
|
|
444
509
|
get_key_url,
|
|
445
510
|
is_gh_authenticated,
|
|
446
511
|
is_gh_available,
|
|
447
|
-
validate_key_url,
|
|
448
512
|
wait_for_pages_deployment,
|
|
449
513
|
)
|
|
450
514
|
|
|
@@ -461,14 +525,33 @@ def _deploy_key_github(
|
|
|
461
525
|
info("")
|
|
462
526
|
return
|
|
463
527
|
|
|
464
|
-
#
|
|
465
|
-
|
|
466
|
-
|
|
528
|
+
# Compare remote key to local key
|
|
529
|
+
pem = load_public_key_pem(key_dir)
|
|
530
|
+
remote_pem = fetch_key_pem(domain)
|
|
531
|
+
|
|
532
|
+
if remote_pem is not None and remote_pem == pem.strip() and not force:
|
|
533
|
+
info(f"Public key: [green]matches GitHub[/green] at {get_key_url(domain)}")
|
|
467
534
|
info("")
|
|
468
535
|
return
|
|
469
536
|
|
|
470
|
-
|
|
471
|
-
|
|
537
|
+
if remote_pem is not None:
|
|
538
|
+
# Remote key exists but differs — confirm before overwriting
|
|
539
|
+
info("[yellow]The public key on GitHub differs from your local key.[/yellow]")
|
|
540
|
+
info(f" Remote: {get_key_url(domain)}")
|
|
541
|
+
info(" This can happen after regenerating your key pair.")
|
|
542
|
+
info("")
|
|
543
|
+
try:
|
|
544
|
+
answer = input("Update GitHub with the local key? [y/N] ").strip()
|
|
545
|
+
except (EOFError, KeyboardInterrupt):
|
|
546
|
+
answer = "n"
|
|
547
|
+
if answer.lower() != "y":
|
|
548
|
+
info("[dim]Skipped — GitHub key left unchanged.[/dim]")
|
|
549
|
+
info("")
|
|
550
|
+
return
|
|
551
|
+
info("Updating public key on GitHub Pages...")
|
|
552
|
+
else:
|
|
553
|
+
info("Deploying public key to GitHub Pages...")
|
|
554
|
+
|
|
472
555
|
deploy_public_key(pem, github_repo)
|
|
473
556
|
|
|
474
557
|
info("[green]Key committed and pushed.[/green]")
|
|
@@ -550,34 +633,24 @@ async def _enrollment_step(
|
|
|
550
633
|
info(f" Public key: [green]accessible[/green] at {key_url}")
|
|
551
634
|
info("")
|
|
552
635
|
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
info("")
|
|
557
|
-
return
|
|
558
|
-
|
|
559
|
-
if answer.lower() not in ("n", "no"):
|
|
560
|
-
info("")
|
|
561
|
-
info(f" Opening [link={enroll_url}]{enroll_url}[/link]…")
|
|
562
|
-
webbrowser.open(enroll_url)
|
|
563
|
-
info("")
|
|
636
|
+
info(f" Opening [link={enroll_url}]{enroll_url}[/link]…")
|
|
637
|
+
webbrowser.open(enroll_url)
|
|
638
|
+
info("")
|
|
564
639
|
|
|
565
640
|
info(" " + "━" * 49)
|
|
566
641
|
info(" [bold yellow]ACTION REQUIRED: Add virtual key in the Tesla app[/bold yellow]")
|
|
567
642
|
info("")
|
|
568
|
-
info(
|
|
569
|
-
info("")
|
|
570
|
-
info(" 1. Open the URL above [bold]on your phone[/bold]")
|
|
643
|
+
info(" 1. Scan the QR code with your phone (iOS / Android)")
|
|
571
644
|
info(" 2. Tap [bold]Finish Setup[/bold] on the web page")
|
|
572
645
|
info(" 3. The Tesla app shows an [bold]Add Virtual Key[/bold] prompt")
|
|
573
646
|
info(" 4. Approve it")
|
|
574
647
|
info("")
|
|
575
|
-
info(
|
|
576
|
-
|
|
648
|
+
info(
|
|
649
|
+
" [dim]If the prompt doesn't appear, force-quit the Tesla app,"
|
|
650
|
+
" scan the QR code again, and tap Finish Setup.[/dim]"
|
|
651
|
+
)
|
|
577
652
|
info(" " + "━" * 49)
|
|
578
653
|
info("")
|
|
579
|
-
info(" After approving, try: [cyan]tescmd charge status --wake[/cyan]")
|
|
580
|
-
info("")
|
|
581
654
|
|
|
582
655
|
|
|
583
656
|
# ---------------------------------------------------------------------------
|
|
@@ -626,7 +699,10 @@ async def _registration_step(
|
|
|
626
699
|
domain=domain,
|
|
627
700
|
region=region,
|
|
628
701
|
)
|
|
629
|
-
|
|
702
|
+
if _result.get("already_registered"):
|
|
703
|
+
info("[green]Already registered — no action needed.[/green]")
|
|
704
|
+
else:
|
|
705
|
+
info("[green]Registration successful.[/green]")
|
|
630
706
|
except Exception as exc:
|
|
631
707
|
status_code = getattr(exc, "status_code", None)
|
|
632
708
|
exc_text = str(exc)
|
|
@@ -895,6 +971,8 @@ async def _oauth_login_step(
|
|
|
895
971
|
settings: AppSettings,
|
|
896
972
|
client_id: str,
|
|
897
973
|
client_secret: str,
|
|
974
|
+
*,
|
|
975
|
+
force: bool = False,
|
|
898
976
|
) -> None:
|
|
899
977
|
"""Run the OAuth2 login flow."""
|
|
900
978
|
info = formatter.rich.info
|
|
@@ -908,7 +986,7 @@ async def _oauth_login_step(
|
|
|
908
986
|
config_dir=settings.config_dir,
|
|
909
987
|
)
|
|
910
988
|
|
|
911
|
-
if store.has_token:
|
|
989
|
+
if store.has_token and not force:
|
|
912
990
|
# Check whether the stored scopes cover what we need.
|
|
913
991
|
# A readonly→full upgrade requires vehicle_cmds + vehicle_charging_cmds
|
|
914
992
|
# that the original readonly token may not have.
|
tescmd/deploy/github_pages.py
CHANGED
|
@@ -223,12 +223,23 @@ def get_key_url(domain: str) -> str:
|
|
|
223
223
|
|
|
224
224
|
def validate_key_url(domain: str) -> bool:
|
|
225
225
|
"""Return True if the public key is accessible at the expected URL."""
|
|
226
|
+
return fetch_key_pem(domain) is not None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def fetch_key_pem(domain: str) -> str | None:
|
|
230
|
+
"""Fetch the public key PEM from the hosted ``.well-known`` URL.
|
|
231
|
+
|
|
232
|
+
Returns the PEM string (stripped), or ``None`` if the key is not
|
|
233
|
+
accessible or does not look like a PEM public key.
|
|
234
|
+
"""
|
|
226
235
|
url = get_key_url(domain)
|
|
227
236
|
try:
|
|
228
237
|
resp = httpx.get(url, follow_redirects=True, timeout=10)
|
|
229
|
-
|
|
238
|
+
if resp.status_code == 200 and "BEGIN PUBLIC KEY" in resp.text:
|
|
239
|
+
return resp.text.strip()
|
|
230
240
|
except httpx.HTTPError:
|
|
231
|
-
|
|
241
|
+
pass
|
|
242
|
+
return None
|
|
232
243
|
|
|
233
244
|
|
|
234
245
|
def wait_for_pages_deployment(
|
tescmd/deploy/tailscale_serve.py
CHANGED
|
@@ -9,7 +9,9 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import asyncio
|
|
11
11
|
import logging
|
|
12
|
+
import threading
|
|
12
13
|
import time
|
|
14
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
13
15
|
from pathlib import Path
|
|
14
16
|
|
|
15
17
|
import httpx
|
|
@@ -27,6 +29,71 @@ DEFAULT_DEPLOY_TIMEOUT = 60 # seconds (faster than GitHub Pages)
|
|
|
27
29
|
POLL_INTERVAL = 3 # seconds
|
|
28
30
|
|
|
29
31
|
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# In-process key server (used by interactive setup)
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _KeyRequestHandler(BaseHTTPRequestHandler):
|
|
38
|
+
"""Serve the root (200 OK) and the ``.well-known`` PEM path."""
|
|
39
|
+
|
|
40
|
+
server: KeyServer # type: ignore[assignment]
|
|
41
|
+
|
|
42
|
+
def do_GET(self) -> None:
|
|
43
|
+
if self.path == "/":
|
|
44
|
+
self._respond(200, "")
|
|
45
|
+
elif self.path == f"/{WELL_KNOWN_PATH}":
|
|
46
|
+
self._respond(
|
|
47
|
+
200,
|
|
48
|
+
self.server.pem_content,
|
|
49
|
+
content_type="application/x-pem-file",
|
|
50
|
+
)
|
|
51
|
+
else:
|
|
52
|
+
self._respond(404, "Not found")
|
|
53
|
+
|
|
54
|
+
def _respond(
|
|
55
|
+
self,
|
|
56
|
+
status: int,
|
|
57
|
+
body: str,
|
|
58
|
+
content_type: str = "text/html; charset=utf-8",
|
|
59
|
+
) -> None:
|
|
60
|
+
encoded = body.encode()
|
|
61
|
+
self.send_response(status)
|
|
62
|
+
self.send_header("Content-Type", content_type)
|
|
63
|
+
self.send_header("Content-Length", str(len(encoded)))
|
|
64
|
+
self.end_headers()
|
|
65
|
+
self.wfile.write(encoded)
|
|
66
|
+
|
|
67
|
+
def log_message(self, format: str, *args: object) -> None:
|
|
68
|
+
"""Silence default stderr logging."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class KeyServer(HTTPServer):
|
|
72
|
+
"""Ephemeral HTTP server that serves a PEM public key.
|
|
73
|
+
|
|
74
|
+
Runs in a daemon thread so the main process can continue interacting
|
|
75
|
+
with the user. Tailscale Funnel proxies external HTTPS traffic to
|
|
76
|
+
this local server.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self, pem_content: str, port: int) -> None:
|
|
80
|
+
super().__init__(("127.0.0.1", port), _KeyRequestHandler)
|
|
81
|
+
self.pem_content = pem_content
|
|
82
|
+
self._thread: threading.Thread | None = None
|
|
83
|
+
|
|
84
|
+
def start(self) -> None:
|
|
85
|
+
"""Start serving in a background daemon thread."""
|
|
86
|
+
self._thread = threading.Thread(target=self.serve_forever, daemon=True)
|
|
87
|
+
self._thread.start()
|
|
88
|
+
|
|
89
|
+
def stop(self) -> None:
|
|
90
|
+
"""Shut down the server and wait for the thread to exit."""
|
|
91
|
+
self.shutdown()
|
|
92
|
+
self.server_close()
|
|
93
|
+
if self._thread is not None:
|
|
94
|
+
self._thread.join(timeout=5)
|
|
95
|
+
|
|
96
|
+
|
|
30
97
|
# ---------------------------------------------------------------------------
|
|
31
98
|
# Key file management
|
|
32
99
|
# ---------------------------------------------------------------------------
|
|
@@ -46,6 +113,7 @@ async def deploy_public_key_tailscale(
|
|
|
46
113
|
key_path = base / WELL_KNOWN_PATH
|
|
47
114
|
key_path.parent.mkdir(parents=True, exist_ok=True)
|
|
48
115
|
key_path.write_text(public_key_pem)
|
|
116
|
+
|
|
49
117
|
logger.info("Public key written to %s", key_path)
|
|
50
118
|
return key_path
|
|
51
119
|
|
|
@@ -56,7 +124,11 @@ async def deploy_public_key_tailscale(
|
|
|
56
124
|
|
|
57
125
|
|
|
58
126
|
async def start_key_serving(serve_dir: Path | None = None) -> str:
|
|
59
|
-
"""Start ``tailscale serve`` for ``.well-known
|
|
127
|
+
"""Start ``tailscale serve`` with Funnel for ``.well-known``.
|
|
128
|
+
|
|
129
|
+
Uses a single ``tailscale serve --bg --funnel --set-path / <dir>``
|
|
130
|
+
command so that the static-file handler and public Funnel access are
|
|
131
|
+
configured atomically on HTTPS port 443.
|
|
60
132
|
|
|
61
133
|
Returns the public hostname (e.g. ``machine.tailnet.ts.net``).
|
|
62
134
|
|
|
@@ -75,20 +147,20 @@ async def start_key_serving(serve_dir: Path | None = None) -> str:
|
|
|
75
147
|
await ts.check_available()
|
|
76
148
|
hostname = await ts.get_hostname()
|
|
77
149
|
|
|
78
|
-
# Serve the
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
#
|
|
82
|
-
await ts.
|
|
150
|
+
# Serve the entire base directory at / with Funnel enabled so that:
|
|
151
|
+
# - The origin URL (https://host/) returns 200
|
|
152
|
+
# - The key at /.well-known/appspecific/com.tesla.3p.public-key.pem is reachable
|
|
153
|
+
# Tesla verifies both during Developer Portal app configuration.
|
|
154
|
+
await ts.start_serve("/", str(base), funnel=True)
|
|
83
155
|
|
|
84
156
|
logger.info("Key serving started at https://%s/%s", hostname, WELL_KNOWN_PATH)
|
|
85
157
|
return hostname
|
|
86
158
|
|
|
87
159
|
|
|
88
160
|
async def stop_key_serving() -> None:
|
|
89
|
-
"""Remove the
|
|
161
|
+
"""Remove the key-serving handler."""
|
|
90
162
|
ts = TailscaleManager()
|
|
91
|
-
await ts.stop_serve("
|
|
163
|
+
await ts.stop_serve("/")
|
|
92
164
|
logger.info("Key serving stopped")
|
|
93
165
|
|
|
94
166
|
|
|
@@ -121,6 +193,22 @@ def get_key_url(hostname: str) -> str:
|
|
|
121
193
|
return f"https://{hostname}/{WELL_KNOWN_PATH}"
|
|
122
194
|
|
|
123
195
|
|
|
196
|
+
def fetch_tailscale_key_pem(hostname: str) -> str | None:
|
|
197
|
+
"""Fetch the public key PEM from a Tailscale Funnel ``.well-known`` URL.
|
|
198
|
+
|
|
199
|
+
Returns the PEM string (stripped), or ``None`` if the key is not
|
|
200
|
+
accessible or does not look like a PEM public key.
|
|
201
|
+
"""
|
|
202
|
+
url = get_key_url(hostname)
|
|
203
|
+
try:
|
|
204
|
+
resp = httpx.get(url, follow_redirects=True, timeout=10)
|
|
205
|
+
if resp.status_code == 200 and "BEGIN PUBLIC KEY" in resp.text:
|
|
206
|
+
return resp.text.strip()
|
|
207
|
+
except httpx.HTTPError:
|
|
208
|
+
pass
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
|
|
124
212
|
async def validate_tailscale_key_url(hostname: str) -> bool:
|
|
125
213
|
"""HTTP GET to verify key is accessible.
|
|
126
214
|
|
tescmd/openclaw/dispatcher.py
CHANGED
|
@@ -424,10 +424,17 @@ class CommandDispatcher:
|
|
|
424
424
|
|
|
425
425
|
Accepts both OpenClaw-style (``door.lock``) and API-style
|
|
426
426
|
(``door_lock``) method names via :data:`_METHOD_ALIASES`.
|
|
427
|
+
|
|
428
|
+
The target method can be specified as ``method`` or ``command``
|
|
429
|
+
(the latter mirrors the gateway protocol's field name).
|
|
427
430
|
"""
|
|
428
|
-
|
|
431
|
+
raw = params.get("method", "") or params.get("command", "")
|
|
432
|
+
# Normalize: bots may send a list like ["door.lock"] instead of a string
|
|
433
|
+
if isinstance(raw, list):
|
|
434
|
+
raw = raw[0] if raw else ""
|
|
435
|
+
method = str(raw).strip() if raw else ""
|
|
429
436
|
if not method:
|
|
430
|
-
raise ValueError("system.run requires 'method' parameter")
|
|
437
|
+
raise ValueError("system.run requires 'method' (or 'command') parameter")
|
|
431
438
|
resolved = _METHOD_ALIASES.get(method, method)
|
|
432
439
|
if resolved == "system.run":
|
|
433
440
|
raise ValueError("system.run cannot invoke itself")
|
tescmd/openclaw/gateway.py
CHANGED
|
@@ -280,8 +280,21 @@ class GatewayClient:
|
|
|
280
280
|
so gateways that enforce authentication at the transport layer
|
|
281
281
|
accept the connection before the OpenClaw handshake begins.
|
|
282
282
|
|
|
283
|
+
If a receive loop is already running (e.g. from a previous
|
|
284
|
+
connection or a concurrent reconnect attempt), it is cancelled
|
|
285
|
+
before establishing the new connection to prevent duplicate
|
|
286
|
+
``recv()`` calls on the same WebSocket.
|
|
287
|
+
|
|
283
288
|
Raises :class:`GatewayConnectionError` on failure.
|
|
284
289
|
"""
|
|
290
|
+
import contextlib
|
|
291
|
+
|
|
292
|
+
if self._recv_task is not None and not self._recv_task.done():
|
|
293
|
+
self._recv_task.cancel()
|
|
294
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
295
|
+
await self._recv_task
|
|
296
|
+
self._recv_task = None
|
|
297
|
+
|
|
285
298
|
await self._establish_connection()
|
|
286
299
|
|
|
287
300
|
if self._on_request is not None:
|