blockmachine 0.2.1__tar.gz → 0.3.2__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: blockmachine
3
- Version: 0.2.1
3
+ Version: 0.3.2
4
4
  Summary: Blockmachine CLI - Miner and validator operator interface
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/taostat/blockmachine
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: blockmachine
3
- Version: 0.2.1
3
+ Version: 0.3.2
4
4
  Summary: Blockmachine CLI - Miner and validator operator interface
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/taostat/blockmachine
@@ -1,9 +1,17 @@
1
1
  """Miner commands: node registration, secrets, and pricing"""
2
2
 
3
3
  import getpass
4
+ import hashlib
5
+ import ipaddress
6
+ import json
7
+ import logging
8
+ import socket
9
+ import ssl
4
10
  import time
5
- from typing import Optional
11
+ from typing import Callable, Optional
12
+ from urllib.parse import urlparse
6
13
 
14
+ import httpx
7
15
  import typer
8
16
  from rich.console import Console
9
17
  from rich.table import Table
@@ -22,6 +30,7 @@ app.add_typer(secret_app, name="secret")
22
30
  app.add_typer(price_app, name="price")
23
31
 
24
32
  console = Console()
33
+ logger = logging.getLogger(__name__)
25
34
 
26
35
  CHAIN = "tao"
27
36
 
@@ -89,6 +98,61 @@ def logout() -> None:
89
98
  do_logout()
90
99
 
91
100
 
101
+ # ── TLS ──────────────────────────────────────────────────────────
102
+
103
+
104
+ def _test_tls(hostname: str, port: int) -> tuple[bool, Optional[str], str]:
105
+ """Test TLS handshake. Return (ok, fingerprint, message)."""
106
+ try:
107
+ ctx = ssl.create_default_context()
108
+ ctx.check_hostname = False
109
+ ctx.verify_mode = ssl.CERT_NONE
110
+ with socket.create_connection((hostname, port), timeout=10) as sock:
111
+ with ctx.wrap_socket(sock, server_hostname=hostname) as tls:
112
+ der = tls.getpeercert(binary_form=True)
113
+ fp = hashlib.sha256(der).hexdigest() if der else None
114
+ return True, fp, "OK"
115
+ except Exception as e:
116
+ return False, None, str(e)
117
+
118
+
119
+ def _bracket_ipv6(hostname: str) -> str:
120
+ """Wrap bare IPv6 addresses in brackets for use in URLs."""
121
+ try:
122
+ addr = ipaddress.ip_address(hostname)
123
+ if addr.version == 6:
124
+ return f"[{hostname}]"
125
+ except ValueError:
126
+ pass
127
+ return hostname
128
+
129
+
130
+ def _is_ip_endpoint(endpoint: str) -> bool:
131
+ """Return True if the endpoint hostname is an IP address (not a domain)."""
132
+ hostname = urlparse(endpoint).hostname
133
+ if not hostname:
134
+ return False
135
+ try:
136
+ ipaddress.ip_address(hostname)
137
+ return True
138
+ except ValueError:
139
+ return False
140
+
141
+
142
+ def _get_cert_fingerprint(endpoint: str) -> Optional[str]:
143
+ """Fetch the SHA256 fingerprint of the TLS leaf certificate."""
144
+ parsed = urlparse(endpoint)
145
+ hostname = parsed.hostname
146
+ port = parsed.port or 443
147
+ if not hostname:
148
+ return None
149
+ ok, fingerprint, _ = _test_tls(hostname, port)
150
+ if not ok:
151
+ logger.warning("Could not fetch TLS certificate from %s", endpoint)
152
+ return None
153
+ return fingerprint
154
+
155
+
92
156
  # ── Node CRUD ────────────────────────────────────────────────────
93
157
 
94
158
 
@@ -114,9 +178,22 @@ def add(
114
178
  console.print("[red]Secret must be at least 16 characters[/red]")
115
179
  raise typer.Exit(1)
116
180
 
181
+ fp = None
182
+ if _is_ip_endpoint(endpoint):
183
+ fp = _get_cert_fingerprint(endpoint)
184
+ if fp:
185
+ console.print(f"[dim]TLS fingerprint (pinned): {fp}[/dim]")
186
+ else:
187
+ console.print(
188
+ "[yellow]Warning: Could not fetch TLS certificate from endpoint.[/yellow]\n"
189
+ "[yellow]The gateway will not be able to verify this miner's identity.[/yellow]"
190
+ )
191
+ else:
192
+ console.print("[dim]Domain endpoint — using standard CA verification[/dim]")
193
+
117
194
  config = load_config()
118
195
  with RegistryClient(config) as client:
119
- node_id = _create_node(client, endpoint, alias)
196
+ node_id = _create_node(client, endpoint, alias, cert_fingerprint=fp)
120
197
  _set_node_secret(client, node_id, secret)
121
198
  _set_node_price(client, node_id, price)
122
199
 
@@ -133,12 +210,17 @@ def _default_alias(endpoint: str) -> str:
133
210
  return f"tao-{host.replace('.', '-')}"
134
211
 
135
212
 
136
- def _create_node(client: RegistryClient, endpoint: str, alias: str) -> str:
213
+ def _create_node(
214
+ client: RegistryClient,
215
+ endpoint: str,
216
+ alias: str,
217
+ cert_fingerprint: Optional[str] = None,
218
+ ) -> str:
137
219
  """Register a node and return its ID."""
138
- response = client.post(
139
- "/nodes",
140
- json={"endpoint": endpoint, "chain": CHAIN, "alias": alias},
141
- )
220
+ payload: dict = {"endpoint": endpoint, "chain": CHAIN, "alias": alias}
221
+ if cert_fingerprint is not None:
222
+ payload["cert_fingerprint"] = cert_fingerprint
223
+ response = client.post("/nodes", json=payload)
142
224
  if response.status_code == 201:
143
225
  data = response.json()
144
226
  console.print(f"[green]Node registered:[/green] {data['id'][:8]}")
@@ -273,23 +355,100 @@ def show(
273
355
  node_id = _resolve_node(client, alias)
274
356
  response = client.get(f"/nodes/{node_id}")
275
357
 
276
- if response.status_code == 404:
277
- console.print(f"[red]Node not found:[/red] {alias}")
278
- raise typer.Exit(1)
279
- if response.status_code != 200:
280
- console.print(f"[red]Error:[/red] {response.status_code} - {response.text}")
281
- raise typer.Exit(1)
358
+ if response.status_code == 404:
359
+ console.print(f"[red]Node not found:[/red] {alias}")
360
+ raise typer.Exit(1)
361
+ if response.status_code != 200:
362
+ console.print(f"[red]Error:[/red] {response.status_code} - {response.text}")
363
+ raise typer.Exit(1)
282
364
 
283
- node = response.json()
284
- console.print(f"[bold]ID:[/bold] {node['id']}")
285
- console.print(f"[bold]Alias:[/bold] {node.get('alias') or '-'}")
286
- console.print(f"[bold]Chain:[/bold] {node['chain']}")
287
- console.print(f"[bold]Status:[/bold] {node['status']}")
288
- console.print(f"[bold]Endpoint:[/bold] {node['endpoint']}")
289
- console.print(f"[bold]Created:[/bold] {format_timestamp(node['created_at'])}")
290
- console.print(
291
- f"[bold]Last Seen:[/bold] {format_timestamp(node.get('last_seen_at'))}"
292
- )
365
+ node = response.json()
366
+ console.print(f"[bold]ID:[/bold] {node['id']}")
367
+ console.print(f"[bold]Alias:[/bold] {node.get('alias') or '-'}")
368
+ console.print(f"[bold]Chain:[/bold] {node['chain']}")
369
+ console.print(f"[bold]Status:[/bold] {node['status']}")
370
+ console.print(f"[bold]Endpoint:[/bold] {node['endpoint']}")
371
+ console.print(f"[bold]Created:[/bold] {format_timestamp(node['created_at'])}")
372
+ console.print(
373
+ f"[bold]Last Seen:[/bold] {format_timestamp(node.get('last_seen_at'))}"
374
+ )
375
+
376
+ m_resp = client.get(f"/nodes/{node_id}/metrics")
377
+ if m_resp.status_code == 200:
378
+ m = m_resp.json()
379
+ if m.get("available"):
380
+ console.print()
381
+ console.print("[bold]Routing Metrics[/bold]")
382
+ _print_metrics(m)
383
+
384
+
385
+ def _fmt_success_rate(v: float) -> str:
386
+ pct = v * 100
387
+ color = "green" if pct >= 99 else "yellow" if pct >= 95 else "red"
388
+ return f"[{color}]{pct:.1f}%[/{color}]"
389
+
390
+
391
+ _METRIC_FIELDS: list[tuple[str, str, Callable]] = [
392
+ ("request_count", "Requests", lambda v: f"{v:,}"),
393
+ ("success_rate", "Success Rate", _fmt_success_rate),
394
+ ("p95_ms", "P95 Latency", lambda v: f"{v:.0f} ms"),
395
+ ("latency_score", "Latency Score", lambda v: f"{v:.4f}"),
396
+ ("routing_score", "Routing Score", lambda v: f"{v:.4f}"),
397
+ ("traffic_pct", "Traffic Share", lambda v: f"{v:.1f}%"),
398
+ ("reliability", "Reliability", lambda v: f"{v:.4f}"),
399
+ ("epochs_good", "Epochs Good", str),
400
+ ("bid", "Bid", lambda v: f"${v:.6f}"),
401
+ ("cohort_median", "Cohort Median", lambda v: f"${v:.6f}"),
402
+ ("price_factor", "Price Factor", lambda v: f"{v:.4f}"),
403
+ ]
404
+
405
+
406
+ def _print_metrics(m: dict) -> None:
407
+ """Print routing metrics from a metrics API response."""
408
+ table = Table(show_header=False, box=None, padding=(0, 2))
409
+ table.add_column("Key", style="bold")
410
+ table.add_column("Value")
411
+
412
+ for key, label, fmt in _METRIC_FIELDS:
413
+ val = m.get(key)
414
+ if val is not None:
415
+ table.add_row(label, fmt(val))
416
+
417
+ console.print(table)
418
+
419
+
420
+ @app.command("metrics")
421
+ def metrics(
422
+ alias: str = typer.Argument(None, help="Node alias or ID"),
423
+ ) -> None:
424
+ """Show routing metrics for a miner node."""
425
+ alias = _get_alias(alias)
426
+ config = load_config()
427
+
428
+ with RegistryClient(config) as client:
429
+ node_id = _resolve_node(client, alias)
430
+ response = client.get(f"/nodes/{node_id}/metrics")
431
+
432
+ if response.status_code == 503:
433
+ console.print(
434
+ "[yellow]Metrics not available (scoring not enabled)[/yellow]"
435
+ )
436
+ raise typer.Exit(1)
437
+ if response.status_code != 200:
438
+ console.print(f"[red]Error:[/red] {response.status_code} - {response.text}")
439
+ raise typer.Exit(1)
440
+
441
+ m = response.json()
442
+ if not m.get("available"):
443
+ console.print(
444
+ f"[yellow]No metrics available for {alias}[/yellow]\n"
445
+ "[dim]The gateway may not have routed traffic to this"
446
+ " node yet.[/dim]"
447
+ )
448
+ return
449
+
450
+ console.print(f"[bold]Routing Metrics: {alias}[/bold]\n")
451
+ _print_metrics(m)
293
452
 
294
453
 
295
454
  @app.command("rm")
@@ -365,6 +524,157 @@ def update(
365
524
  raise typer.Exit(1)
366
525
 
367
526
 
527
+ # ── Test ─────────────────────────────────────────────────────────
528
+
529
+
530
+ def _test_health(hostname: str) -> tuple[bool, str]:
531
+ """Test HTTP health endpoint on port 80."""
532
+ try:
533
+ r = httpx.get(
534
+ f"http://{_bracket_ipv6(hostname)}/health",
535
+ timeout=10,
536
+ follow_redirects=True,
537
+ )
538
+ if r.status_code == 200:
539
+ return True, "OK"
540
+ return False, f"HTTP {r.status_code}"
541
+ except Exception as e:
542
+ return False, str(e)
543
+
544
+
545
+ def _test_rpc(
546
+ hostname: str, port: int, secret: str
547
+ ) -> tuple[bool, Optional[float], str]:
548
+ """Send system_health RPC over HTTPS. Return (ok, latency_ms, message)."""
549
+ url = f"https://{_bracket_ipv6(hostname)}:{port}"
550
+ payload = {
551
+ "jsonrpc": "2.0",
552
+ "method": "system_health",
553
+ "params": [],
554
+ "id": 1,
555
+ }
556
+ try:
557
+ start = time.monotonic()
558
+ r = httpx.post(
559
+ url,
560
+ json=payload,
561
+ headers={"Authorization": f"Bearer {secret}"},
562
+ timeout=10,
563
+ verify=False,
564
+ )
565
+ latency = (time.monotonic() - start) * 1000
566
+ if r.status_code == 401:
567
+ return False, None, "Authentication failed (secret mismatch)"
568
+ if r.status_code != 200:
569
+ return False, None, f"HTTP {r.status_code}"
570
+ data = r.json()
571
+ if "result" in data:
572
+ result = data["result"]
573
+ peers = result.get("peers", "?")
574
+ syncing = result.get("isSyncing", False)
575
+ status = "syncing" if syncing else "synced"
576
+ return True, latency, f"peers={peers}, {status}"
577
+ error = data.get("error", {}).get("message", json.dumps(data))
578
+ return False, latency, f"RPC error: {error}"
579
+ except Exception as e:
580
+ return False, None, str(e)
581
+
582
+
583
+ def _fetch_active_secret(client: RegistryClient, node_id: str) -> Optional[str]:
584
+ """Fetch the decrypted active secret from the registry."""
585
+ response = client.get(f"/nodes/{node_id}/secret")
586
+ if response.status_code != 200:
587
+ return None
588
+ for s in response.json().get("secrets", []):
589
+ if s.get("state") == "active" and s.get("secret"):
590
+ return s["secret"]
591
+ return None
592
+
593
+
594
+ @app.command("test")
595
+ def test(
596
+ alias: Optional[str] = typer.Argument(None, help="Node alias or ID"),
597
+ endpoint: Optional[str] = typer.Option(
598
+ None, "--endpoint", "-e", help="Endpoint URL (skip registry lookup)"
599
+ ),
600
+ secret: Optional[str] = typer.Option(
601
+ None, "--secret", help="Bearer token (overrides registry)"
602
+ ),
603
+ ) -> None:
604
+ """Test connectivity to a miner node (TLS, health, auth, RPC).
605
+
606
+ For registered nodes, fetches the secret from the registry
607
+ automatically. Use --endpoint and --secret for pre-registration
608
+ testing.
609
+ """
610
+ if not endpoint:
611
+ alias = _get_alias(alias)
612
+ config = load_config()
613
+ with RegistryClient(config) as client:
614
+ node_id = _resolve_node(client, alias)
615
+ response = client.get(f"/nodes/{node_id}")
616
+ if response.status_code != 200:
617
+ console.print(f"[red]Error:[/red] {response.status_code}")
618
+ raise typer.Exit(1)
619
+ node = response.json()
620
+ endpoint = node["endpoint"]
621
+ if not secret:
622
+ secret = _fetch_active_secret(client, node_id)
623
+ console.print(f"[bold]Alias:[/bold] {alias}")
624
+
625
+ parsed = urlparse(endpoint)
626
+ hostname = parsed.hostname
627
+ port = parsed.port or 443
628
+
629
+ if not hostname:
630
+ console.print(f"[red]Cannot parse endpoint:[/red] {endpoint}")
631
+ raise typer.Exit(1)
632
+
633
+ console.print(f"[bold]Endpoint:[/bold] {endpoint}\n")
634
+
635
+ all_ok = True
636
+
637
+ # 1. TLS
638
+ console.print("TLS handshake... ", end="")
639
+ tls_ok, fingerprint, tls_msg = _test_tls(hostname, port)
640
+ if tls_ok:
641
+ is_ip = _is_ip_endpoint(endpoint)
642
+ mode = "pinned" if is_ip else "CA-verified"
643
+ fp_short = fingerprint[:16] + "..." if fingerprint else "none"
644
+ console.print(f"[green]OK[/green] ({mode}, {fp_short})")
645
+ else:
646
+ console.print(f"[red]FAIL[/red] {tls_msg}")
647
+ all_ok = False
648
+
649
+ # 2. Health
650
+ console.print("Health check... ", end="")
651
+ health_ok, health_msg = _test_health(hostname)
652
+ if health_ok:
653
+ console.print("[green]OK[/green]")
654
+ else:
655
+ console.print(f"[red]FAIL[/red] {health_msg}")
656
+ all_ok = False
657
+
658
+ # 3. RPC with auth (only if secret provided)
659
+ if secret:
660
+ console.print("RPC query... ", end="")
661
+ rpc_ok, latency, rpc_msg = _test_rpc(hostname, port, secret)
662
+ if rpc_ok:
663
+ console.print(f"[green]OK[/green] ({rpc_msg}, {latency:.0f}ms)")
664
+ else:
665
+ console.print(f"[red]FAIL[/red] {rpc_msg}")
666
+ all_ok = False
667
+ else:
668
+ console.print("[dim]Pass --secret to also test auth and RPC[/dim]")
669
+
670
+ console.print()
671
+ if all_ok:
672
+ console.print("[green]All checks passed[/green]")
673
+ else:
674
+ console.print("[red]Some checks failed[/red]")
675
+ raise typer.Exit(1)
676
+
677
+
368
678
  # ── Snapshots ────────────────────────────────────────────────────
369
679
 
370
680
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "blockmachine"
7
- version = "0.2.1"
7
+ version = "0.3.2"
8
8
  description = "Blockmachine CLI - Miner and validator operator interface"
9
9
  requires-python = ">=3.10"
10
10
  license = "MIT"
@@ -20,7 +20,7 @@ MAINNET = NetworkConfig(
20
20
 
21
21
  TESTNET = NetworkConfig(
22
22
  auth_url="https://test-auth.taostats.io",
23
- api_url="http://blockmachine-registry-service.blockmachine-testnet.blockmachine-dev",
23
+ api_url="https://testnet-registry.blockmachine.io",
24
24
  netuid=417,
25
25
  )
26
26
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes