@le-space/rootfs 0.7.0 → 0.9.0

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.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Shared rootfs contract parsing, reference profile assets, and build helpers.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -44,6 +44,12 @@
44
44
  "udp": false,
45
45
  "purpose": "libp2p TCP"
46
46
  },
47
+ {
48
+ "port": 9092,
49
+ "tcp": true,
50
+ "udp": false,
51
+ "purpose": "libp2p WebSocket and AutoTLS"
52
+ },
47
53
  {
48
54
  "port": 443,
49
55
  "tcp": true,
@@ -245,6 +245,7 @@ case "${ROOTFS_PROFILE}" in
245
245
  --copy-in "${SCRIPT_DIR}/orbitdb-relay-bootstrap-refresh.timer:/etc/systemd/system"
246
246
  --copy-in "${SCRIPT_DIR}/orbitdb-relay-bootstrap-deregister.service:/etc/systemd/system"
247
247
  --copy-in "${SCRIPT_DIR}/orbitdb-relay-autotls-refresh.service:/etc/systemd/system"
248
+ --copy-in "${SCRIPT_DIR}/orbitdb-relay-autotls-refresh.timer:/etc/systemd/system"
248
249
  --copy-in "${SCRIPT_DIR}/orbitdb-relay-bootstrap.conf:/etc/systemd/system/orbitdb-relay.service.d"
249
250
  --run-command "tar -xf /opt/$(basename "${APP_TAR}") -C /opt/orbitdb-relay"
250
251
  --run-command "chmod 0755 /usr/local/sbin/orbitdb-relay-bootstrap.sh"
@@ -259,6 +260,7 @@ case "${ROOTFS_PROFILE}" in
259
260
 
260
261
  orbitdb_customize_args+=(
261
262
  --run-command "systemctl enable orbitdb-relay-bootstrap.service"
263
+ --run-command "systemctl enable orbitdb-relay-autotls-refresh.timer"
262
264
  --run-command "systemctl enable orbitdb-relay.service"
263
265
  --run-command "rm -f /opt/$(basename "${APP_TAR}")"
264
266
  )
@@ -254,6 +254,7 @@ required_port_forwards_json() {
254
254
  { "port": 80, "tcp": true, "udp": false, "purpose": "Temporary setup endpoint" },
255
255
  { "port": 9090, "tcp": true, "udp": false, "purpose": "Metrics and health API" },
256
256
  { "port": 9091, "tcp": true, "udp": false, "purpose": "libp2p TCP" },
257
+ { "port": 9092, "tcp": true, "udp": false, "purpose": "libp2p WebSocket and AutoTLS" },
257
258
  { "port": 443, "tcp": true, "udp": false, "purpose": "Caddy HTTPS and WSS proxy" },
258
259
  { "port": 9093, "tcp": false, "udp": true, "purpose": "WebRTC direct" },
259
260
  { "port": 9094, "tcp": false, "udp": true, "purpose": "QUIC" }
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env python3
2
2
  import json
3
3
  import os
4
+ import socket
5
+ import ssl
4
6
  import subprocess
5
7
  import time
6
8
  import urllib.error
@@ -24,6 +26,7 @@ CADDYFILE = os.environ.get("CADDYFILE", "/etc/caddy/Caddyfile")
24
26
  METRICS_PORT = int(os.environ.get("METRICS_PORT", "9090"))
25
27
  WAIT_TIMEOUT_SECONDS = int(os.environ.get("AUTOTLS_WAIT_TIMEOUT_SECONDS", "900"))
26
28
  WAIT_INTERVAL_SECONDS = float(os.environ.get("AUTOTLS_WAIT_INTERVAL_SECONDS", "5"))
29
+ TLS_PROBE_TIMEOUT_SECONDS = float(os.environ.get("AUTOTLS_TLS_PROBE_TIMEOUT_SECONDS", "5"))
27
30
 
28
31
 
29
32
  def fetch_json(path: str) -> dict:
@@ -98,6 +101,43 @@ def ipv6_domain(ipv6: str, zone: str) -> str:
98
101
  return f"{subdomain}.{zone}"
99
102
 
100
103
 
104
+ def listener_presents_certificate(port: str, server_name: str) -> bool:
105
+ """Does the local WebSocket listener actually terminate TLS?
106
+
107
+ This is the only question that matters before announcing a `/tls/...`
108
+ address, and until this existed nothing asked it. The previous gate read
109
+ `/multiaddrs` from the metrics endpoint and looked for a `/tls/ws` entry -
110
+ but that list is the *announce* list, which is what this script writes. It
111
+ confirmed its own output from the run before, so it passed on a relay whose
112
+ WebSocket port had never spoken TLS in its life.
113
+
114
+ The result on a live relay: `/ip4/.../tcp/52194/tls/sni/<host>/ws` announced
115
+ for a port that answers a plaintext handshake with `101 Switching
116
+ Protocols`. Browsers on an https origin got ERR_SSL_PROTOCOL_ERROR, and
117
+ every peer paid a TLS timeout on every connect
118
+ (see NiKrause/simple-todo#154).
119
+
120
+ Opening a socket cannot be fooled that way.
121
+ """
122
+ context = ssl.create_default_context()
123
+ # Asking whether *a* certificate is served, not whether it validates: the
124
+ # probe runs against loopback while the certificate is issued for the public
125
+ # AutoTLS hostname, so a chain check would fail on a healthy relay.
126
+ context.check_hostname = False
127
+ context.verify_mode = ssl.CERT_NONE
128
+
129
+ try:
130
+ with socket.create_connection(("127.0.0.1", int(port)), TLS_PROBE_TIMEOUT_SECONDS) as raw:
131
+ # SNI is not optional. Without `server_hostname` a name-based TLS
132
+ # server has nothing to select a certificate by and aborts the
133
+ # handshake - which made an earlier version of this probe report
134
+ # "no TLS" for Caddy on 443, a port that demonstrably serves one.
135
+ with context.wrap_socket(raw, server_hostname=server_name) as tls:
136
+ return tls.getpeercert(binary_form=True) is not None
137
+ except (OSError, ssl.SSLError, ValueError):
138
+ return False
139
+
140
+
101
141
  def wait_for_autotls_zone() -> str:
102
142
  deadline = time.monotonic() + WAIT_TIMEOUT_SECONDS
103
143
  last_error = "metrics endpoint never became ready"
@@ -105,17 +145,10 @@ def wait_for_autotls_zone() -> str:
105
145
  while time.monotonic() < deadline:
106
146
  try:
107
147
  health = fetch_json("/health")
108
- payload = fetch_json("/multiaddrs")
109
148
  zone = health.get("autoTlsServingZone")
110
- all_addrs = payload.get("all")
111
- if (
112
- isinstance(zone, str)
113
- and zone
114
- and isinstance(all_addrs, list)
115
- and any(isinstance(addr, str) and "/tls/ws" in addr for addr in all_addrs)
116
- ):
149
+ if isinstance(zone, str) and zone:
117
150
  return zone
118
- last_error = "AutoTLS secure websocket multiaddrs not advertised yet"
151
+ last_error = "AutoTLS serving zone not published yet"
119
152
  except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as error:
120
153
  last_error = str(error)
121
154
 
@@ -187,14 +220,40 @@ def main() -> None:
187
220
 
188
221
  env_values = parse_env_file(ENV_FILE)
189
222
  zone = wait_for_autotls_zone()
190
- secure_addrs = build_secure_addrs(env_values, zone)
191
- exact_hosts = secure_hosts(env_values, zone)
223
+
224
+ # Announce a TLS address only where something terminates TLS. The relay
225
+ # listens plaintext on RELAY_WS_PORT and Caddy terminates on 443; if AutoTLS
226
+ # has not taken over that listener, the libp2p.direct addresses would be a
227
+ # promise the port cannot keep. Caddy's own 443 address is written elsewhere
228
+ # and is unaffected by this - it keeps working either way.
229
+ internal_ws_port = env_values.get("RELAY_WS_PORT", "").strip()
230
+ autotls_hostname = metrics_https_public_host(env_values, zone)
231
+ autotls_serving = bool(internal_ws_port and autotls_hostname) and listener_presents_certificate(
232
+ internal_ws_port, autotls_hostname
233
+ )
234
+
235
+ if autotls_serving:
236
+ secure_addrs = build_secure_addrs(env_values, zone)
237
+ else:
238
+ secure_addrs = []
239
+ print(
240
+ f"AutoTLS is not terminating TLS on 127.0.0.1:{internal_ws_port or '<unset>'} - "
241
+ "skipping the libp2p.direct announce addresses. Peers keep the Caddy "
242
+ "address on 443, which is served by a separate process."
243
+ )
244
+ exact_hosts = secure_hosts(env_values, zone) if autotls_serving else []
192
245
  metrics_host = metrics_https_public_host(env_values, zone)
193
246
  current_value = env_values.get("VITE_APPEND_ANNOUNCE", "")
194
247
  current_metrics_host = env_values.get("METRICS_HTTPS_PUBLIC_HOST", "").strip()
195
- merged = dedupe(
196
- [normalize_addr(addr) for addr in current_value.split(",") if addr.strip()] + secure_addrs
197
- )
248
+ previous = [normalize_addr(addr) for addr in current_value.split(",") if addr.strip()]
249
+
250
+ # Drop stale libp2p.direct entries when the probe says they are not served.
251
+ # Without this the first bad run leaves them in the file forever - which is
252
+ # how the live relay kept announcing a plaintext port across restarts.
253
+ if not autotls_serving:
254
+ previous = [addr for addr in previous if "libp2p.direct" not in addr]
255
+
256
+ merged = dedupe(previous + secure_addrs)
198
257
  announce_changed = current_value.split(",") != merged
199
258
  metrics_host_changed = bool(metrics_host) and current_metrics_host != metrics_host
200
259
 
@@ -223,7 +282,21 @@ def main() -> None:
223
282
  elif os.path.exists(AUTOTLS_CADDY_READY_FILE):
224
283
  os.remove(AUTOTLS_CADDY_READY_FILE)
225
284
 
226
- open(AUTOTLS_READY_FILE, "a", encoding="utf-8").close()
285
+ # The ready file is what stops this unit from running again
286
+ # (ConditionPathExists=! in the unit), so it may only be written once the
287
+ # AutoTLS addresses are actually announced. Writing it unconditionally is
288
+ # what made a single early probe final: AutoTLS needs a minute or two to
289
+ # fetch its certificate, the probe correctly found a plaintext port before
290
+ # that, marked itself done - and the certificate that arrived afterwards was
291
+ # never announced. The timer beside this unit retries until it is here.
292
+ if autotls_serving:
293
+ open(AUTOTLS_READY_FILE, "a", encoding="utf-8").close()
294
+ else:
295
+ print(
296
+ "Not marking AutoTLS ready - no TLS on the listener yet. "
297
+ f"{os.path.basename(AUTOTLS_READY_FILE)} stays absent so the timer retries."
298
+ )
299
+
227
300
  print(f"Updated VITE_APPEND_ANNOUNCE={announce_value}")
228
301
 
229
302
 
@@ -0,0 +1,22 @@
1
+ [Unit]
2
+ # AutoTLS fetches its certificate a minute or two after the relay starts, and
3
+ # the refresh unit runs seconds after it. Without a retry the probe sees a
4
+ # plaintext listener, correctly declines to announce a TLS address - and never
5
+ # looks again, so a certificate that arrives later is never advertised.
6
+ #
7
+ # The unit's own ConditionPathExists=! on the ready file is the stop condition:
8
+ # once the addresses are announced, every later firing is skipped by systemd
9
+ # before python starts. This timer therefore costs nothing after success.
10
+ Description=Retry the AutoTLS announce refresh for orbitdb-relay until it succeeds
11
+ After=orbitdb-relay.service
12
+
13
+ [Timer]
14
+ OnBootSec=2min
15
+ OnUnitActiveSec=2min
16
+ # Certificates can take a while on a busy ACME endpoint, and the relay is
17
+ # usable over Caddy on 443 throughout - so retrying patiently costs nothing.
18
+ AccuracySec=15s
19
+ Unit=orbitdb-relay-autotls-refresh.service
20
+
21
+ [Install]
22
+ WantedBy=timers.target
@@ -83,6 +83,23 @@ https://${proxy_hostname} {
83
83
  reverse_proxy http://127.0.0.1:80
84
84
  }
85
85
 
86
+ # The address and readiness documents live on the setup server (:80). A PWA
87
+ # served over HTTPS cannot fetch that port directly -- the browser blocks it
88
+ # as mixed content -- so they have to be reachable through this proxy.
89
+ # Named handles must precede the catch-all, which swallows everything else
90
+ # into the WebSocket backend.
91
+ handle /describe {
92
+ reverse_proxy http://127.0.0.1:80
93
+ }
94
+
95
+ handle /multiaddrs {
96
+ reverse_proxy http://127.0.0.1:80
97
+ }
98
+
99
+ handle /health {
100
+ reverse_proxy http://127.0.0.1:80
101
+ }
102
+
86
103
  handle {
87
104
  reverse_proxy http://127.0.0.1:${WS_BACKEND_PORT}
88
105
  }
@@ -476,6 +476,15 @@ class Handler(BaseHTTPRequestHandler):
476
476
  self._handle_metadata()
477
477
  return
478
478
 
479
+ # orbitdb-relay serves its address document at /multiaddrs, so consumers
480
+ # look for one path on every profile. /metadata already carries exactly
481
+ # that document -- it runs the same describe script -- it was just named
482
+ # after the deploy flow that first needed it and reachable only on the
483
+ # plain-HTTP setup port. Expose the same payload under the shared names.
484
+ if self._request_path() in ("/describe", "/multiaddrs"):
485
+ self._handle_describe()
486
+ return
487
+
479
488
  if self._request_path() not in ("/", "/health"):
480
489
  self._send_json(404, {"status": "not-found"})
481
490
  return
@@ -486,6 +495,23 @@ class Handler(BaseHTTPRequestHandler):
486
495
  status, payload = _metadata_status_payload()
487
496
  self._send_json(status, payload)
488
497
 
498
+ def _handle_describe(self) -> None:
499
+ status, payload = _metadata_status_payload()
500
+ if status != 200:
501
+ self._send_json(status, payload)
502
+ return
503
+ # Unwrap the `metadata` envelope: a consumer asking for the address
504
+ # document wants the document, not the readiness wrapper around it.
505
+ metadata = payload.get("metadata")
506
+ if not isinstance(metadata, dict):
507
+ self._send_json(500, {"status": "error", "error": "describe payload missing"})
508
+ return
509
+ document = dict(metadata)
510
+ document["profile"] = "uc-go-peer"
511
+ if payload.get("stale"):
512
+ document["stale"] = True
513
+ self._send_json(200, document)
514
+
489
515
  def do_POST(self) -> None: # noqa: N802
490
516
  if self._request_path() != "/configure":
491
517
  self._send_json(404, {"status": "not-found"})