@le-space/rootfs 0.9.5 → 0.9.7

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.9.5",
3
+ "version": "0.9.7",
4
4
  "description": "Shared rootfs contract parsing, reference profile assets, and build helpers.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -5,6 +5,7 @@ import ipaddress
5
5
  import json
6
6
  import os
7
7
  import subprocess
8
+ import sys
8
9
  import threading
9
10
  import time
10
11
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -92,6 +93,28 @@ def _parse_env_file(path: str) -> dict[str, str]:
92
93
  return values
93
94
 
94
95
 
96
+ def _log(message: str) -> None:
97
+ """Say it once, on stderr, where journalctl picks it up.
98
+
99
+ This file had no logging and the HTTP handler silenced its own, so a relay
100
+ that never configured itself left a journal with one "Started" line in it.
101
+ That silence is what made "the guest never acknowledged" an unexplainable
102
+ failure rather than a readable one.
103
+ """
104
+ print(f"[bootstrap] {message}", file=sys.stderr, flush=True)
105
+
106
+
107
+ _LOGGED_ONCE: set[str] = set()
108
+
109
+
110
+ def _log_once(key: str, message: str) -> None:
111
+ """For states that repeat every few seconds and are worth saying once."""
112
+ if key in _LOGGED_ONCE:
113
+ return
114
+ _LOGGED_ONCE.add(key)
115
+ _log(message)
116
+
117
+
95
118
  def _extract_guest_bootstrap_locator() -> tuple[str | None, str | None]:
96
119
  """Read the deployment locator the deployer left in our SSH key.
97
120
 
@@ -112,9 +135,15 @@ def _extract_guest_bootstrap_locator() -> tuple[str | None, str | None]:
112
135
  deployment_token = deployment_token.strip()
113
136
  if owner_address and deployment_token:
114
137
  return owner_address, deployment_token
115
- except (FileNotFoundError, ValueError):
138
+ except (FileNotFoundError, ValueError) as error:
139
+ _log_once("locator-error", f"cannot read {AUTHORIZED_KEYS_FILE}: {error!r}")
116
140
  return None, None
117
141
 
142
+ _log_once(
143
+ "locator-missing",
144
+ f"no aleph-bootstrap-config marker in {AUTHORIZED_KEYS_FILE}; "
145
+ "nothing to poll for, the relay will stay unconfigured",
146
+ )
118
147
  return None, None
119
148
 
120
149
 
@@ -374,6 +403,10 @@ def _load_vm_bootstrap_record() -> tuple[str | None, str | None, dict | None]:
374
403
 
375
404
 
376
405
  def _poll_bootstrap_config_loop() -> None:
406
+ _log(
407
+ f"waiting for bootstrap config, polling every {BOOTSTRAP_CONFIG_POLL_SECONDS}s "
408
+ f"against {ALEPH_API_HOST}"
409
+ )
377
410
  while not os.path.exists(READY_FILE):
378
411
  _, deployment_token, record = _load_vm_bootstrap_record()
379
412
  if deployment_token and isinstance(record, dict):
@@ -381,12 +414,20 @@ def _poll_bootstrap_config_loop() -> None:
381
414
  if status == "pending":
382
415
  try:
383
416
  args = _build_configure_args_from_record(record)
384
- except ValueError:
417
+ except ValueError as error:
418
+ # The record arrived and was unusable — which used to look
419
+ # exactly like never having received one.
420
+ _log_once("record-invalid", f"record cannot be turned into a config: {error}")
385
421
  time.sleep(BOOTSTRAP_CONFIG_POLL_SECONDS)
386
422
  continue
387
- ok, _ = _run_configure_process(args, record)
423
+ _log("record found and pending, configuring the relay now")
424
+ ok, detail = _run_configure_process(args, record)
388
425
  if ok:
426
+ _log("relay configured, setup endpoint is done")
389
427
  return
428
+ _log(f"configure failed, will retry: {detail}")
429
+ else:
430
+ _log_once(f"status-{status}", f"record status is {status!r}, not acting on it")
390
431
  time.sleep(BOOTSTRAP_CONFIG_POLL_SECONDS)
391
432
 
392
433
 
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env python3
2
2
  import os
3
3
  import re
4
+ import sys
4
5
  import subprocess
5
6
  import time
6
7
  from typing import Iterable
@@ -93,6 +94,57 @@ def wait_for_exact_hosts(ws_port: str) -> tuple[str, list[str], list[str]]:
93
94
  raise RuntimeError(last_error)
94
95
 
95
96
 
97
+ def resolve_external_ws_port(env_values: dict[str, str], ws_port: str) -> str:
98
+ """Port the AutoTLS websocket should be *announced* on.
99
+
100
+ `ws_port` is where the relay actually listens inside the guest. On Aleph
101
+ every port is published under a different number, so announcing the
102
+ internal one hands out an address the internet cannot reach.
103
+ """
104
+ external = env_values.get("EXTERNAL_RELAY_WS_PORT", "").strip()
105
+ if external:
106
+ return external
107
+ print(
108
+ f"[autotls] EXTERNAL_RELAY_WS_PORT is missing from the environment file; "
109
+ f"announcing the backend port {ws_port}, which is unreachable from "
110
+ f"outside the guest whenever the port is mapped",
111
+ file=sys.stderr,
112
+ )
113
+ return ws_port
114
+
115
+
116
+ def build_announce_addrs(
117
+ existing_announce: Iterable[str],
118
+ logged_addrs: Iterable[str],
119
+ ws_port: str,
120
+ external_ws_port: str,
121
+ proxy_hostname: str = "",
122
+ ) -> list[str]:
123
+ """Announce list for the concrete AutoTLS hostnames.
124
+
125
+ The relay logs its listeners on the internal port, which is what the log
126
+ scan had to match to find them. Re-announcing those strings verbatim was
127
+ the defect: `uc-go-peer-configure.sh` translates the port for every other
128
+ family, so tcp, quic, webtransport and webrtc-direct all advertised the
129
+ mapped port while the websocket — rewritten here, later — kept the
130
+ internal one. Measured on a live relay: `/tcp/9097/tls/sni/…/ws` was
131
+ announced and filtered, while the same listener answered on `/tcp/24013`.
132
+ Browsers dropped the address, `direct-wss` was left without a candidate,
133
+ and js-peer refused to bake a bootstrap list it could not verify.
134
+ """
135
+ wildcard_filtered = [entry for entry in existing_announce if "/tls/sni/*." not in entry]
136
+ exact_announces = [
137
+ addr.replace(f"/tcp/{ws_port}/", f"/tcp/{external_ws_port}/", 1)
138
+ for addr in logged_addrs
139
+ ]
140
+
141
+ if proxy_hostname:
142
+ exact_announces.append(f"/dns4/{proxy_hostname}/tcp/443/tls/ws")
143
+ exact_announces.append(f"/dns6/{proxy_hostname}/tcp/443/tls/ws")
144
+
145
+ return dedupe(list(wildcard_filtered) + exact_announces)
146
+
147
+
96
148
  def main() -> None:
97
149
  if not os.path.exists(READY_FILE):
98
150
  raise SystemExit(f"missing ready file: {READY_FILE}")
@@ -109,14 +161,13 @@ def main() -> None:
109
161
  raise RuntimeError("no AutoTLS websocket hostnames found in logs")
110
162
 
111
163
  existing = [entry.strip() for entry in env_values.get("LIBP2P_ANNOUNCE_ADDRS", "").split(",") if entry.strip()]
112
- wildcard_filtered = [entry for entry in existing if "/tls/sni/*." not in entry]
113
- exact_announces: list[str] = list(exact_logged_addrs)
114
-
115
- if proxy_hostname:
116
- exact_announces.append(f"/dns4/{proxy_hostname}/tcp/443/tls/ws")
117
- exact_announces.append(f"/dns6/{proxy_hostname}/tcp/443/tls/ws")
118
-
119
- merged = dedupe(wildcard_filtered + exact_announces)
164
+ merged = build_announce_addrs(
165
+ existing,
166
+ exact_logged_addrs,
167
+ ws_port,
168
+ resolve_external_ws_port(env_values, ws_port),
169
+ proxy_hostname,
170
+ )
120
171
  announce_value = ",".join(merged)
121
172
  write_env_var(ENV_FILE, "LIBP2P_ANNOUNCE_ADDRS", announce_value)
122
173
  if zone:
@@ -5,6 +5,7 @@ import ipaddress
5
5
  import json
6
6
  import os
7
7
  import subprocess
8
+ import sys
8
9
  import threading
9
10
  import time
10
11
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -85,6 +86,29 @@ def _validate_proxy_hostname(value: object) -> str | None:
85
86
  return parsed.hostname
86
87
 
87
88
 
89
+ def _log(message: str) -> None:
90
+ """Say it once, on stderr, where journalctl picks it up.
91
+
92
+ This file had no logging at all, and the HTTP handler silenced its own.
93
+ That is how a relay can sit for hours doing nothing while `journalctl -u
94
+ uc-go-peer-bootstrap` shows a single "Started" line — which is what it did
95
+ on 2026-09-02, leaving no way to tell whether the guest had found its
96
+ locator, asked Aleph and been told nothing, or never looked.
97
+ """
98
+ print(f"[bootstrap] {message}", file=sys.stderr, flush=True)
99
+
100
+
101
+ _LOGGED_ONCE: set[str] = set()
102
+
103
+
104
+ def _log_once(key: str, message: str) -> None:
105
+ """For states that repeat every few seconds and are worth saying once."""
106
+ if key in _LOGGED_ONCE:
107
+ return
108
+ _LOGGED_ONCE.add(key)
109
+ _log(message)
110
+
111
+
88
112
  def _extract_guest_bootstrap_locator() -> tuple[str | None, str | None]:
89
113
  try:
90
114
  with open(AUTHORIZED_KEYS_FILE, encoding="utf-8") as handle:
@@ -98,10 +122,25 @@ def _extract_guest_bootstrap_locator() -> tuple[str | None, str | None]:
98
122
  owner_address = owner_address.strip()
99
123
  deployment_token = deployment_token.strip()
100
124
  if owner_address and deployment_token:
125
+ _log_once(
126
+ "locator",
127
+ f"locator found in authorized_keys: owner={owner_address} "
128
+ f"token={deployment_token}",
129
+ )
101
130
  return owner_address, deployment_token
102
- except (FileNotFoundError, ValueError):
131
+ except (FileNotFoundError, ValueError) as error:
132
+ _log_once("locator-error", f"cannot read {AUTHORIZED_KEYS_FILE}: {error!r}")
103
133
  return None, None
104
134
 
135
+ # Without this the guest can never find its record, and every deployment
136
+ # spends its full config window before failing over. The browser appends
137
+ # the marker to the SSH key it puts in the instance message; if it is not
138
+ # here, either no key was configured or something stripped the comment.
139
+ _log_once(
140
+ "locator-missing",
141
+ f"no aleph-bootstrap-config marker in {AUTHORIZED_KEYS_FILE}; "
142
+ "nothing to poll for, the relay will stay unconfigured",
143
+ )
105
144
  return None, None
106
145
 
107
146
 
@@ -415,10 +454,13 @@ def _load_vm_bootstrap_record() -> tuple[str | None, str | None, dict | None]:
415
454
  with urlopen(request_url, timeout=10) as response:
416
455
  payload = json.loads(response.read().decode("utf-8") or "{}")
417
456
  except HTTPError as error:
418
- if error.code == 404:
419
- return owner_address, deployment_token, None
457
+ # A 404 is ordinary early on: the browser publishes the aggregate only
458
+ # once the runtime is up. Anything else is worth naming.
459
+ key = "aggregate-404" if error.code == 404 else f"aggregate-{error.code}"
460
+ _log_once(key, f"aggregate query returned HTTP {error.code} for {owner_address}")
420
461
  return owner_address, deployment_token, None
421
- except (URLError, json.JSONDecodeError, TimeoutError):
462
+ except (URLError, json.JSONDecodeError, TimeoutError) as error:
463
+ _log_once("aggregate-unreachable", f"aggregate query failed: {error!r}")
422
464
  return owner_address, deployment_token, None
423
465
 
424
466
  aggregate = payload.get("data", {}).get(BOOTSTRAP_CONFIG_KEY, {})
@@ -426,25 +468,47 @@ def _load_vm_bootstrap_record() -> tuple[str | None, str | None, dict | None]:
426
468
  return owner_address, deployment_token, None
427
469
 
428
470
  record = aggregate.get(deployment_token)
429
- return owner_address, deployment_token, record if isinstance(record, dict) else None
471
+ if not isinstance(record, dict):
472
+ _log_once(
473
+ "record-missing",
474
+ f"aggregate has no record for token {deployment_token} "
475
+ f"({len(aggregate)} record(s) present)",
476
+ )
477
+ return owner_address, deployment_token, None
478
+
479
+ return owner_address, deployment_token, record
430
480
 
431
481
 
432
482
  def _poll_bootstrap_config_loop() -> None:
483
+ _log(
484
+ f"waiting for bootstrap config, polling every {BOOTSTRAP_CONFIG_POLL_SECONDS}s "
485
+ f"against {ALEPH_API_HOST}"
486
+ )
433
487
  while not os.path.exists(READY_FILE):
434
488
  _, deployment_token, record = _load_vm_bootstrap_record()
435
489
  if deployment_token and isinstance(record, dict):
436
490
  status = str(record.get("status") or "").strip().lower()
437
491
  if status != "pending":
492
+ # Somebody else's turn, or already done. Worth saying once per
493
+ # status: a record stuck on "applied" while the relay is down
494
+ # means something very different from no record at all.
495
+ _log_once(f"status-{status}", f"record status is {status!r}, not acting on it")
438
496
  time.sleep(BOOTSTRAP_CONFIG_POLL_SECONDS)
439
497
  continue
440
498
  try:
441
499
  args = _build_configure_args(record)
442
- except ValueError:
500
+ except ValueError as error:
501
+ # The record arrived and was unusable. Before this, that was
502
+ # indistinguishable from never having received one.
503
+ _log_once("record-invalid", f"record cannot be turned into a config: {error}")
443
504
  time.sleep(BOOTSTRAP_CONFIG_POLL_SECONDS)
444
505
  continue
445
- ok, _ = _run_configure_process(args, record)
506
+ _log("record found and pending, configuring the relay now")
507
+ ok, detail = _run_configure_process(args, record)
446
508
  if ok:
509
+ _log("relay configured, setup endpoint is done")
447
510
  return
511
+ _log(f"configure failed, will retry: {detail}")
448
512
  time.sleep(BOOTSTRAP_CONFIG_POLL_SECONDS)
449
513
 
450
514