@le-space/rootfs 0.6.41 → 0.6.42

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@le-space/rootfs",
3
- "version": "0.6.41",
3
+ "version": "0.6.42",
4
4
  "description": "Shared rootfs contract parsing, reference profile assets, and build helpers.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -22,6 +22,20 @@ except ImportError as error: # pragma: no cover - runtime dependency
22
22
 
23
23
  ENV_FILE = os.environ.get("ENV_FILE", "/etc/default/uc-go-peer")
24
24
  DESCRIBE_SCRIPT = os.environ.get("DESCRIBE_SCRIPT", "/usr/local/sbin/uc-go-peer-describe.py")
25
+ # Port of the orbitdb-relay cadence fix (relay-button #75): the initial
26
+ # registration publish is triggered once by configure.sh, while the refresh
27
+ # timer's first firing is 20 minutes away — a one-shot publish that catches
28
+ # the peer before a browser-dialable address exists leaves consumers a dead
29
+ # zone. Retry describe+publish until a secure browser address exists or the
30
+ # deadline passes (then publish whatever is available, the old behaviour).
31
+ # The go peer usually announces certhash-bearing webtransport/webrtc-direct
32
+ # addresses right at startup, so the first attempt normally succeeds.
33
+ PUBLISH_RETRY_TIMEOUT_SECONDS = int(
34
+ os.environ.get("BOOTSTRAP_PUBLISH_RETRY_TIMEOUT_SECONDS", "900")
35
+ )
36
+ PUBLISH_RETRY_INTERVAL_SECONDS = int(
37
+ os.environ.get("BOOTSTRAP_PUBLISH_RETRY_INTERVAL_SECONDS", "15")
38
+ )
25
39
  DEFAULT_API_HOST = os.environ.get("ALEPH_BOOTSTRAP_API_HOST", "https://api.aleph.im")
26
40
  DEFAULT_CHANNEL = os.environ.get("ALEPH_BOOTSTRAP_CHANNEL", "simple-todo")
27
41
  DEFAULT_REF = os.environ.get("ALEPH_BOOTSTRAP_REF", "simple-todo-bootstrap")
@@ -75,6 +89,20 @@ def is_browser_dialable(addr: str) -> bool:
75
89
  return "/ws" in normalized or "/wss" in normalized
76
90
 
77
91
 
92
+ def is_secure_browser_dialable(addr: str) -> bool:
93
+ """Mirror of the consumer-side address policy (testkit/app): a secure
94
+ websocket, or webtransport/webrtc-direct carrying a certhash. Plain /ws
95
+ does not count — an HTTPS page cannot dial it."""
96
+ normalized = addr.lower()
97
+ if "/tls/ws" in normalized or "/wss" in normalized:
98
+ return True
99
+ if (
100
+ "/webtransport" in normalized or "/webrtc-direct" in normalized
101
+ ) and "/certhash/" in normalized:
102
+ return True
103
+ return False
104
+
105
+
78
106
  def is_public_multiaddr(addr: str) -> bool:
79
107
  parts = split_multiaddr(addr)
80
108
  if not parts:
@@ -567,6 +595,47 @@ def main() -> None:
567
595
  print(json_dumps({"status": "skipped", "reason": "missing publisher key"}))
568
596
  return
569
597
 
598
+ deadline = time.monotonic() + PUBLISH_RETRY_TIMEOUT_SECONDS
599
+ result: dict[str, object] | None = None
600
+ while True:
601
+ result = attempt_publish(
602
+ env_values,
603
+ publisher_private_key,
604
+ registration_id,
605
+ require_secure_browser_dialable=True,
606
+ )
607
+ if result is not None:
608
+ break
609
+ if time.monotonic() >= deadline:
610
+ # Deadline passed without a secure browser address — publish what
611
+ # exists anyway (probe addresses still serve the Actions path).
612
+ result = attempt_publish(
613
+ env_values,
614
+ publisher_private_key,
615
+ registration_id,
616
+ require_secure_browser_dialable=False,
617
+ )
618
+ break
619
+ time.sleep(PUBLISH_RETRY_INTERVAL_SECONDS)
620
+
621
+ if result is None:
622
+ print(json_dumps({"status": "skipped", "reason": "no public browser multiaddrs"}))
623
+ return
624
+ print(json_dumps(result))
625
+
626
+
627
+ def attempt_publish(
628
+ env_values: dict[str, str],
629
+ publisher_private_key: str,
630
+ registration_id: str | None,
631
+ require_secure_browser_dialable: bool,
632
+ ) -> dict[str, object] | None:
633
+ """One describe→publish cycle. Returns the result record when a
634
+ registration was published, or None when the caller should retry (no
635
+ public addresses yet, or — while `require_secure_browser_dialable` — none
636
+ of them is dialable from an HTTPS page). Nothing is published on the
637
+ retry path: an early registration without a browser-dialable address
638
+ makes consumers resolve too soon and fail."""
570
639
  describe = subprocess.run([DESCRIBE_SCRIPT], check=True, capture_output=True, text=True)
571
640
  metadata = json.loads(describe.stdout.strip() or "{}")
572
641
  peer_id = metadata.get("peer_id")
@@ -592,10 +661,16 @@ def main() -> None:
592
661
  )
593
662
  api_host = env_values.get("ALEPH_BOOTSTRAP_API_HOST", DEFAULT_API_HOST).strip() or DEFAULT_API_HOST
594
663
  published_multiaddrs = select_compact_multiaddrs(browser_multiaddrs or multiaddrs)
664
+ # Intentionally left empty: consumers read content.multiaddrs (fallback),
665
+ # and the relayProof signature is byte-order-sensitive — populating
666
+ # browserMultiaddrs would change the signed payload shape.
595
667
  published_browser_multiaddrs: list[str] = []
596
668
  if not published_multiaddrs:
597
- print(json_dumps({"status": "skipped", "reason": "no public browser multiaddrs"}))
598
- return
669
+ return None
670
+ if require_secure_browser_dialable and not any(
671
+ is_secure_browser_dialable(addr) for addr in published_multiaddrs
672
+ ):
673
+ return None
599
674
 
600
675
  owner_authorization = load_owner_authorization(
601
676
  env_values,
@@ -658,24 +733,20 @@ def main() -> None:
658
733
  api_host, publisher_address, publisher_private_key, previous_hashes, channel
659
734
  )
660
735
 
661
- print(
662
- json_dumps(
663
- {
664
- "status": "published",
665
- "httpStatus": http_status,
666
- "itemHash": unsigned_message["item_hash"],
667
- "sender": publisher_address,
668
- "peerId": peer_id,
669
- "publishedMultiaddrs": published_multiaddrs,
670
- "publishedBrowserMultiaddrs": published_browser_multiaddrs or published_multiaddrs,
671
- "forgottenHashes": previous_hashes,
672
- "forgetStaleOlderThanSeconds": STALE_RECORD_MAX_AGE_SECONDS,
673
- "ownerAuthorizationPresent": owner_authorization is not None,
674
- "forgetResponse": forget_response[1] if forget_response else None,
675
- "response": response,
676
- }
677
- )
678
- )
736
+ return {
737
+ "status": "published",
738
+ "httpStatus": http_status,
739
+ "itemHash": unsigned_message["item_hash"],
740
+ "sender": publisher_address,
741
+ "peerId": peer_id,
742
+ "publishedMultiaddrs": published_multiaddrs,
743
+ "publishedBrowserMultiaddrs": published_browser_multiaddrs or published_multiaddrs,
744
+ "forgottenHashes": previous_hashes,
745
+ "forgetStaleOlderThanSeconds": STALE_RECORD_MAX_AGE_SECONDS,
746
+ "ownerAuthorizationPresent": owner_authorization is not None,
747
+ "forgetResponse": forget_response[1] if forget_response else None,
748
+ "response": response,
749
+ }
679
750
 
680
751
 
681
752
  if __name__ == "__main__":
@@ -4,16 +4,58 @@ from __future__ import annotations
4
4
  import json
5
5
  import os
6
6
  import re
7
+ import socket
8
+ import ssl
7
9
  import subprocess
8
10
  import time
9
11
 
10
12
 
11
13
  ENV_FILE = os.environ.get("ENV_FILE", "/etc/default/uc-go-peer")
12
14
  SERVICE_NAME = os.environ.get("SERVICE_NAME", "uc-go-peer.service")
15
+ CADDY_HTTPS_PORT = int(os.environ.get("CADDY_HTTPS_PORT", "443"))
13
16
  WAIT_TIMEOUT_SECONDS = int(os.environ.get("DESCRIBE_WAIT_TIMEOUT_SECONDS", "240"))
14
17
  WAIT_INTERVAL_SECONDS = float(os.environ.get("DESCRIBE_WAIT_INTERVAL_SECONDS", "2"))
15
18
  AUTOTLS_EXTRA_WAIT_SECONDS = int(os.environ.get("DESCRIBE_AUTOTLS_EXTRA_WAIT_SECONDS", "120"))
16
19
 
20
+
21
+ def tls_endpoint_serves(hostname: str, port: int) -> bool:
22
+ """True only once the local listener on `port` serves a publicly-trusted
23
+ TLS certificate for `hostname`.
24
+
25
+ Port of the orbitdb-relay fix (relay-button #74/#75): announcing a wss
26
+ address before its Let's Encrypt certificate actually serves makes
27
+ browsers fail the TLS handshake with net::ERR_SSL_PROTOCOL_ERROR. The
28
+ handshake runs against the local listener with the public hostname as
29
+ SNI — local, so hairpin-NAT to the VM's own public IP is irrelevant, and
30
+ CA-validated, so self-signed placeholders are rejected.
31
+ """
32
+ if not hostname:
33
+ return False
34
+ context = ssl.create_default_context()
35
+ try:
36
+ with socket.create_connection(("127.0.0.1", port), timeout=8) as raw:
37
+ with context.wrap_socket(raw, server_hostname=hostname) as tls:
38
+ return bool(tls.getpeercert())
39
+ except (ssl.SSLError, OSError, ValueError):
40
+ return False
41
+
42
+
43
+ def caddy_cert_serves(hostname: str) -> bool:
44
+ return tls_endpoint_serves(hostname, CADDY_HTTPS_PORT)
45
+
46
+
47
+ def autotls_cert_serves(autotls_addrs: list[str]) -> bool:
48
+ """Verify the AutoTLS *.libp2p.direct certificate actually serves. Host
49
+ AND port come from the multiaddr — TLS is terminated on the announced
50
+ port; every candidate is tried until one verifies."""
51
+ for addr in autotls_addrs:
52
+ host_match = re.search(r"/dns[46]/([^/]+)/", addr) or re.search(r"/sni/([^/]+)/", addr)
53
+ port_match = re.search(r"/tcp/(\d+)/", addr)
54
+ if host_match and port_match:
55
+ if tls_endpoint_serves(host_match.group(1), int(port_match.group(1))):
56
+ return True
57
+ return False
58
+
17
59
  PEER_ID_PATTERNS = [
18
60
  re.compile(r"PeerID:\s+(\S+)"),
19
61
  re.compile(r"Host created with PeerID:\s+(\S+)"),
@@ -221,9 +263,42 @@ def main() -> None:
221
263
 
222
264
  grouped = build_probe_multiaddrs(env_values, peer_id, listening_addrs)
223
265
  proxy_hostname = env_values.get("PROXY_HOSTNAME", "").strip()
224
- if grouped["autotls_wss_multiaddrs"]:
225
- break
226
- if proxy_hostname and grouped["proxy_wss_multiaddrs"] and time.monotonic() - started_at >= AUTOTLS_EXTRA_WAIT_SECONDS:
266
+
267
+ # Symmetric certificate gate (port of relay-button #74/#75): both wss
268
+ # paths — Caddy/2n6 proxy and libp2p AutoTLS — are only advertised
269
+ # once a CA-validated local TLS handshake proves their certificate
270
+ # serves; breaking on the mere presence of AutoTLS multiaddrs made
271
+ # the outcome a per-VM ACME race. certhash-bearing webtransport /
272
+ # webrtc-direct addresses need no ACME (the hash IS the
273
+ # authentication), so they count as browser-dialable immediately and
274
+ # end the wait on their own — unverified wss addresses join the
275
+ # registration on a later refresh cycle once their cert is live.
276
+ proxy_cert_ready = bool(
277
+ proxy_hostname
278
+ and grouped["proxy_wss_multiaddrs"]
279
+ and caddy_cert_serves(proxy_hostname)
280
+ )
281
+ autotls_cert_ready = bool(
282
+ grouped["autotls_wss_multiaddrs"]
283
+ and autotls_cert_serves(grouped["autotls_wss_multiaddrs"])
284
+ )
285
+ certhash_ready = bool(
286
+ grouped["webtransport_multiaddrs"] or grouped["webrtc_direct_multiaddrs"]
287
+ )
288
+
289
+ unverified: set[str] = set()
290
+ if grouped["proxy_wss_multiaddrs"] and not proxy_cert_ready:
291
+ unverified.update(grouped["proxy_wss_multiaddrs"])
292
+ if grouped["autotls_wss_multiaddrs"] and not autotls_cert_ready:
293
+ unverified.update(grouped["autotls_wss_multiaddrs"])
294
+ if unverified:
295
+ grouped["browser_bootstrap_multiaddrs"] = [
296
+ addr
297
+ for addr in grouped["browser_bootstrap_multiaddrs"]
298
+ if addr not in unverified
299
+ ]
300
+
301
+ if proxy_cert_ready or autotls_cert_ready or certhash_ready:
227
302
  break
228
303
  if not proxy_hostname and time.monotonic() - started_at >= AUTOTLS_EXTRA_WAIT_SECONDS:
229
304
  break