@le-space/rootfs 0.6.40 → 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.40",
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",
@@ -24,6 +24,20 @@ ENV_FILE = os.environ.get("ENV_FILE", "/etc/default/orbitdb-relay")
24
24
  DESCRIBE_SCRIPT = os.environ.get(
25
25
  "DESCRIBE_SCRIPT", "/usr/local/sbin/orbitdb-relay-describe.py"
26
26
  )
27
+ # The initial registration publish (triggered once by configure.sh) must not
28
+ # give up before the browser path is dialable: Caddy's ACME can only complete
29
+ # after the 2n6 DNS record propagates publicly (observed up to ~6-10 min under
30
+ # load), while a single describe run waits at most its own 240 s window. The
31
+ # next timer firing is 20 min away, so a one-shot publish leaves a dead zone
32
+ # in which the registration advertises no browser-dialable address. Retry
33
+ # describe+publish until a secure browser address exists or this deadline
34
+ # passes (then publish whatever is available, matching the old behaviour).
35
+ PUBLISH_RETRY_TIMEOUT_SECONDS = int(
36
+ os.environ.get("BOOTSTRAP_PUBLISH_RETRY_TIMEOUT_SECONDS", "900")
37
+ )
38
+ PUBLISH_RETRY_INTERVAL_SECONDS = int(
39
+ os.environ.get("BOOTSTRAP_PUBLISH_RETRY_INTERVAL_SECONDS", "15")
40
+ )
27
41
  DEFAULT_API_HOST = os.environ.get("ALEPH_BOOTSTRAP_API_HOST", "https://api.aleph.im")
28
42
  DEFAULT_CHANNEL = os.environ.get("ALEPH_BOOTSTRAP_CHANNEL", "simple-todo")
29
43
  DEFAULT_REF = os.environ.get("ALEPH_BOOTSTRAP_REF", "simple-todo-bootstrap")
@@ -78,6 +92,20 @@ def is_browser_dialable(addr: str) -> bool:
78
92
  )
79
93
 
80
94
 
95
+ def is_secure_browser_dialable(addr: str) -> bool:
96
+ """Mirror of the consumer-side address policy (testkit/app): a secure
97
+ websocket, or webtransport/webrtc-direct carrying a certhash. Plain /ws
98
+ does not count — an HTTPS page cannot dial it."""
99
+ normalized = addr.lower()
100
+ if "/tls/ws" in normalized or "/wss" in normalized:
101
+ return True
102
+ if (
103
+ "/webtransport" in normalized or "/webrtc-direct" in normalized
104
+ ) and "/certhash/" in normalized:
105
+ return True
106
+ return False
107
+
108
+
81
109
  def is_public_multiaddr(addr: str) -> bool:
82
110
  parts = split_multiaddr(addr)
83
111
  if not parts:
@@ -603,6 +631,62 @@ def main() -> None:
603
631
  )
604
632
  return
605
633
 
634
+ deadline = time.monotonic() + PUBLISH_RETRY_TIMEOUT_SECONDS
635
+ result: dict[str, object] | None = None
636
+ while True:
637
+ result = attempt_publish(
638
+ env_values,
639
+ publisher_private_key,
640
+ publisher_address,
641
+ registration_id,
642
+ channel,
643
+ ref,
644
+ post_type,
645
+ api_host,
646
+ require_secure_browser_dialable=True,
647
+ )
648
+ if result is not None:
649
+ break
650
+ if time.monotonic() >= deadline:
651
+ # Deadline passed without a secure browser address — publish what
652
+ # exists anyway (probe addresses still serve the Actions path).
653
+ result = attempt_publish(
654
+ env_values,
655
+ publisher_private_key,
656
+ publisher_address,
657
+ registration_id,
658
+ channel,
659
+ ref,
660
+ post_type,
661
+ api_host,
662
+ require_secure_browser_dialable=False,
663
+ )
664
+ break
665
+ time.sleep(PUBLISH_RETRY_INTERVAL_SECONDS)
666
+
667
+ if result is None:
668
+ print(json_dumps({"status": "skipped", "reason": "no public browser multiaddrs"}))
669
+ return
670
+ print(json_dumps(result))
671
+
672
+
673
+ def attempt_publish(
674
+ env_values: dict[str, str],
675
+ publisher_private_key: str,
676
+ publisher_address: str,
677
+ registration_id: str | None,
678
+ channel: str,
679
+ ref: str,
680
+ post_type: str,
681
+ api_host: str,
682
+ require_secure_browser_dialable: bool,
683
+ ) -> dict[str, object] | None:
684
+ """One describe→publish cycle. Returns the result record when a
685
+ registration was published, or None when the caller should retry (no
686
+ public addresses yet, or — while `require_secure_browser_dialable` — none
687
+ of them is dialable from an HTTPS page). Nothing is published on the
688
+ retry path: an early registration without a browser-dialable address
689
+ makes consumers resolve too soon and fail."""
606
690
  describe = subprocess.run([DESCRIBE_SCRIPT], check=True, capture_output=True, text=True)
607
691
  metadata = json.loads(describe.stdout.strip() or "{}")
608
692
  peer_id = metadata.get("peer_id")
@@ -614,14 +698,22 @@ def main() -> None:
614
698
  metadata.get("browser_bootstrap_multiaddrs") or [], browser_only=True
615
699
  )
616
700
 
701
+ now_ms = int(time.time() * 1000)
617
702
  now_seconds = now_ms / 1000
618
703
  profile = env_values.get("ALEPH_BOOTSTRAP_PROFILE", DEFAULT_PROFILE).strip() or DEFAULT_PROFILE
619
704
  version = env_values.get("ALEPH_BOOTSTRAP_VERSION", "").strip() or None
620
705
  published_multiaddrs = select_compact_multiaddrs(browser_multiaddrs or multiaddrs)
706
+ # Intentionally left empty: consumers read content.multiaddrs (fallback),
707
+ # and the relayProof signature is byte-order-sensitive — populating
708
+ # browserMultiaddrs would change the signed payload shape. Cadence, not
709
+ # wire format, is what this retry loop fixes.
621
710
  published_browser_multiaddrs: list[str] = []
622
711
  if not published_multiaddrs:
623
- print(json_dumps({"status": "skipped", "reason": "no public browser multiaddrs"}))
624
- return
712
+ return None
713
+ if require_secure_browser_dialable and not any(
714
+ is_secure_browser_dialable(addr) for addr in published_multiaddrs
715
+ ):
716
+ return None
625
717
 
626
718
  owner_authorization = load_owner_authorization(
627
719
  env_values,
@@ -684,24 +776,20 @@ def main() -> None:
684
776
  api_host, publisher_address, publisher_private_key, previous_hashes, channel
685
777
  )
686
778
 
687
- print(
688
- json_dumps(
689
- {
690
- "status": "published",
691
- "httpStatus": http_status,
692
- "itemHash": unsigned_message["item_hash"],
693
- "sender": publisher_address,
694
- "peerId": peer_id,
695
- "publishedMultiaddrs": published_multiaddrs,
696
- "publishedBrowserMultiaddrs": published_browser_multiaddrs or published_multiaddrs,
697
- "forgottenHashes": previous_hashes,
698
- "forgetStaleOlderThanSeconds": STALE_RECORD_MAX_AGE_SECONDS,
699
- "ownerAuthorizationPresent": owner_authorization is not None,
700
- "forgetResponse": forget_response[1] if forget_response else None,
701
- "response": response,
702
- }
703
- )
704
- )
779
+ return {
780
+ "status": "published",
781
+ "httpStatus": http_status,
782
+ "itemHash": unsigned_message["item_hash"],
783
+ "sender": publisher_address,
784
+ "peerId": peer_id,
785
+ "publishedMultiaddrs": published_multiaddrs,
786
+ "publishedBrowserMultiaddrs": published_browser_multiaddrs or published_multiaddrs,
787
+ "forgottenHashes": previous_hashes,
788
+ "forgetStaleOlderThanSeconds": STALE_RECORD_MAX_AGE_SECONDS,
789
+ "ownerAuthorizationPresent": owner_authorization is not None,
790
+ "forgetResponse": forget_response[1] if forget_response else None,
791
+ "response": response,
792
+ }
705
793
 
706
794
 
707
795
  if __name__ == "__main__":
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env python3
2
2
  import json
3
3
  import os
4
+ import re
4
5
  import socket
5
6
  import ssl
6
7
  import time
@@ -16,33 +17,58 @@ WAIT_INTERVAL_SECONDS = float(os.environ.get("DESCRIBE_WAIT_INTERVAL_SECONDS", "
16
17
  AUTOTLS_EXTRA_WAIT_SECONDS = int(os.environ.get("DESCRIBE_AUTOTLS_EXTRA_WAIT_SECONDS", "120"))
17
18
 
18
19
 
19
- def caddy_cert_serves(hostname: str) -> bool:
20
- """True only once Caddy actually serves a publicly-trusted TLS certificate
21
- for `hostname`.
22
-
23
- Mode-B failure: describe.py used to advertise the 2n6 proxy-wss address as
24
- soon as PROXY_HOSTNAME was configured, but Caddy acquires the Let's Encrypt
25
- certificate asynchronously *after* it (re)starts (the caddy-ready file only
26
- marks the restart). Browsers dialing before the cert is live got
27
- net::ERR_SSL_PROTOCOL_ERROR. Verify the real browser-facing certificate with
28
- a TLS handshake to the local Caddy (127.0.0.1:443) using the public
29
- hostname as SNI — done locally so hairpin-NAT to the VM's own public IP is
30
- irrelevant, and validated against the system CA store so Caddy's internal
31
- self-signed fallback (served before ACME completes) is rejected.
20
+ def tls_endpoint_serves(hostname: str, port: int) -> bool:
21
+ """True only once the local listener on `port` serves a publicly-trusted
22
+ TLS certificate for `hostname`.
23
+
24
+ The recurring failure shape ("advertise before the certificate serves"):
25
+ an address is announced as browser-dialable while its Let's Encrypt cert
26
+ is still being acquired, so browsers dialing it fail the TLS handshake
27
+ with net::ERR_SSL_PROTOCOL_ERROR. This bit twice — first for Caddy's 2n6
28
+ proxy (cert acquired asynchronously after the caddy restart), then for
29
+ libp2p AutoTLS (multiaddrs appear before the *.libp2p.direct cert is
30
+ live). Verify the real browser-facing certificate with a TLS handshake to
31
+ the local listener using the public hostname as SNI — done locally so
32
+ hairpin-NAT to the VM's own public IP is irrelevant, and validated
33
+ against the system CA store so any self-signed placeholder (Caddy's
34
+ pre-ACME fallback) is rejected.
32
35
  """
33
36
  if not hostname:
34
37
  return False
35
38
  context = ssl.create_default_context()
36
39
  try:
37
- with socket.create_connection(("127.0.0.1", CADDY_HTTPS_PORT), timeout=8) as raw:
40
+ with socket.create_connection(("127.0.0.1", port), timeout=8) as raw:
38
41
  with context.wrap_socket(raw, server_hostname=hostname) as tls:
39
- # A validated peer cert here means the handshake a browser will
40
- # perform to wss://<hostname>:443 succeeds too.
42
+ # A validated peer cert here means the handshake a browser
43
+ # will perform against this hostname succeeds too.
41
44
  return bool(tls.getpeercert())
42
45
  except (ssl.SSLError, OSError, ValueError):
43
46
  return False
44
47
 
45
48
 
49
+ def caddy_cert_serves(hostname: str) -> bool:
50
+ return tls_endpoint_serves(hostname, CADDY_HTTPS_PORT)
51
+
52
+
53
+ def autotls_cert_serves(autotls_addrs: list[str]) -> bool:
54
+ """Verify the AutoTLS *.libp2p.direct certificate actually serves.
55
+
56
+ The libp2p WebSocket listener terminates the AutoTLS certificate itself,
57
+ so a CA-validated handshake to 127.0.0.1:<port> with the libp2p.direct
58
+ hostname as SNI proves what a browser's wss:// dial will experience. Both
59
+ the hostname AND the port must come from the multiaddr — the TLS is
60
+ served on the announced port (e.g. .../tcp/24007/tls/ws/...), NOT on the
61
+ plain-ws upstream Caddy proxies to.
62
+ """
63
+ for addr in autotls_addrs:
64
+ host_match = re.search(r"/dns[46]/([^/]+)/", addr) or re.search(r"/sni/([^/]+)/", addr)
65
+ port_match = re.search(r"/tcp/(\d+)/", addr)
66
+ if host_match and port_match:
67
+ if tls_endpoint_serves(host_match.group(1), int(port_match.group(1))):
68
+ return True
69
+ return False
70
+
71
+
46
72
  def parse_env_file(path: str) -> dict[str, str]:
47
73
  values: dict[str, str] = {}
48
74
  if not os.path.exists(path):
@@ -184,27 +210,38 @@ def main() -> None:
184
210
  grouped = build_grouped_multiaddrs(env_values, all_multiaddrs, peer_id)
185
211
  proxy_hostname = env_values.get("PROXY_HOSTNAME", "").strip()
186
212
 
187
- # Only treat the 2n6 proxy-wss address as browser-dialable once
188
- # Caddy actually serves a valid certificate for it. Until then, drop
189
- # the synthetic (not-yet-serving) proxy address so the relay never
190
- # advertises an endpoint that would fail a browser's TLS handshake.
213
+ # Symmetric certificate gate for BOTH browser paths. Whichever of
214
+ # Caddy/2n6 and libp2p AutoTLS finishes its ACME first varies per
215
+ # VM; breaking on the mere *presence* of AutoTLS multiaddrs made
216
+ # the outcome a per-VM coin flip — a fast AutoTLS zone produced an
217
+ # acknowledgement containing only libp2p.direct addresses whose
218
+ # cert was not serving yet (ERR_SSL_PROTOCOL_ERROR in browsers),
219
+ # while the verifiable 2n6 address never made it in. Only
220
+ # cert-verified addresses are advertised, and the wait ends as
221
+ # soon as at least one verified browser path exists.
191
222
  proxy_cert_ready = bool(
192
223
  proxy_hostname
193
224
  and grouped["proxy_wss_multiaddrs"]
194
225
  and caddy_cert_serves(proxy_hostname)
195
226
  )
227
+ autotls_cert_ready = bool(
228
+ grouped["autotls_wss_multiaddrs"]
229
+ and autotls_cert_serves(grouped["autotls_wss_multiaddrs"])
230
+ )
231
+
232
+ unverified: set[str] = set()
196
233
  if grouped["proxy_wss_multiaddrs"] and not proxy_cert_ready:
197
- proxy_set = set(grouped["proxy_wss_multiaddrs"])
234
+ unverified.update(grouped["proxy_wss_multiaddrs"])
235
+ if grouped["autotls_wss_multiaddrs"] and not autotls_cert_ready:
236
+ unverified.update(grouped["autotls_wss_multiaddrs"])
237
+ if unverified:
198
238
  grouped["browser_bootstrap_multiaddrs"] = [
199
- addr for addr in grouped["browser_bootstrap_multiaddrs"] if addr not in proxy_set
239
+ addr
240
+ for addr in grouped["browser_bootstrap_multiaddrs"]
241
+ if addr not in unverified
200
242
  ]
201
243
 
202
- # Break as soon as a genuinely dialable browser address exists:
203
- # AutoTLS libp2p.direct (only announced once its cert is live) or a
204
- # cert-verified 2n6 proxy address.
205
- if grouped["autotls_wss_multiaddrs"]:
206
- break
207
- if proxy_cert_ready:
244
+ if proxy_cert_ready or autotls_cert_ready:
208
245
  break
209
246
  if not proxy_hostname and time.monotonic() - started_at >= AUTOTLS_EXTRA_WAIT_SECONDS:
210
247
  break
@@ -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