@le-space/rootfs 0.6.35 → 0.6.37

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/index.d.ts CHANGED
@@ -115,6 +115,8 @@ interface RootfsManifest {
115
115
  version: string;
116
116
  rootfsInstallStrategy: string;
117
117
  requiresBootstrapNetwork: boolean;
118
+ /** Guest fetches its bootstrap config from the Aleph aggregate itself. */
119
+ supportsBootstrapConfigAggregate?: boolean;
118
120
  bootstrapSummary: string;
119
121
  rootfsSourceSizeBytes?: number;
120
122
  requiredPortForwards: RootfsContract["ports"];
package/index.js CHANGED
@@ -351,6 +351,9 @@ function createRootfsManifest(plan, contract, options = {}) {
351
351
  if (contract.rootfs.profile === "playwright-runner") {
352
352
  manifest.playwrightVersion = "1.61.1";
353
353
  }
354
+ if (contract.rootfs.profile === "uc-go-peer" || contract.rootfs.profile === "orbitdb-relay") {
355
+ manifest.supportsBootstrapConfigAggregate = true;
356
+ }
354
357
  if (typeof options.rootfsSourceSizeBytes === "number" && Number.isFinite(options.rootfsSourceSizeBytes) && options.rootfsSourceSizeBytes > 0) {
355
358
  manifest.rootfsSourceSizeBytes = options.rootfsSourceSizeBytes;
356
359
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@le-space/rootfs",
3
- "version": "0.6.35",
3
+ "version": "0.6.37",
4
4
  "description": "Shared rootfs contract parsing, reference profile assets, and build helpers.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -129,6 +129,13 @@ def build_secure_addrs(env_values: dict[str, str], zone: str) -> list[str]:
129
129
  if not ws_port:
130
130
  raise RuntimeError("missing EXTERNAL_RELAY_WS_PORT in environment file")
131
131
 
132
+ # IPv4 is NAT/port-mapped, so it is advertised on the externally assigned
133
+ # host port. IPv6 is globally routed with no NAT on the Aleph VM — reachable
134
+ # directly on the internal listen port, while the mapped host port is closed
135
+ # over IPv6. Advertise the IPv6 libp2p.direct AutoTLS addresses on the
136
+ # internal WS port so AutoNAT/AutoTLS dial-backs actually reach the relay.
137
+ ws_port_ipv6 = env_values.get("RELAY_WS_PORT", "").strip() or ws_port
138
+
132
139
  addrs: list[str] = []
133
140
 
134
141
  public_ipv4 = env_values.get("PUBLIC_IPV4", "").strip()
@@ -140,8 +147,8 @@ def build_secure_addrs(env_values: dict[str, str], zone: str) -> list[str]:
140
147
  public_ipv6 = env_values.get("PUBLIC_IPV6", "").strip()
141
148
  if public_ipv6:
142
149
  host6 = ipv6_domain(public_ipv6, zone)
143
- addrs.append(f"/ip6/{public_ipv6}/tcp/{ws_port}/tls/sni/{host6}/ws")
144
- addrs.append(f"/dns6/{host6}/tcp/{ws_port}/tls/ws")
150
+ addrs.append(f"/ip6/{public_ipv6}/tcp/{ws_port_ipv6}/tls/sni/{host6}/ws")
151
+ addrs.append(f"/dns6/{host6}/tcp/{ws_port_ipv6}/tls/ws")
145
152
 
146
153
  if not addrs:
147
154
  raise RuntimeError("missing PUBLIC_IPV4/PUBLIC_IPV6 in environment file")
@@ -117,6 +117,25 @@ write_env_var "RELAY_WS_PORT" "9092"
117
117
  write_env_var "RELAY_WEBRTC_PORT" "9093"
118
118
  write_env_var "RELAY_QUIC_PORT" "9094"
119
119
  write_env_var "RELAY_DISABLE_WEBRTC" "1"
120
- write_env_var "RELAY_DISABLE_BOOTSTRAP" "1"
120
+ # Bootstrap MUST stay enabled. The relay needs public IPFS/DHT peers so libp2p
121
+ # AutoNAT can confirm a public address — the precondition for AutoTLS to fetch
122
+ # the `*.libp2p.direct` certificate. With bootstrap disabled the relay comes up
123
+ # isolated (0 peers, empty DHT routing table), AutoNAT never confirms, and
124
+ # AutoTLS silently stops serving certs ("no public addresses"), so browsers can
125
+ # only reach it through the Caddy `2n6.me` proxy. RELAY_DISABLE_BOOTSTRAP=1 is
126
+ # for tests / deliberately isolated deployments only.
127
+ write_env_var "RELAY_DISABLE_BOOTSTRAP" "0"
128
+ # The kad-DHT must stay OFF on these 2 GB Aleph VMs. With it enabled the relay
129
+ # announces itself into the public DHT and is then flooded with inbound
130
+ # WebSocket connections: sockets pile up in CLOSE-WAIT (hundreds within
131
+ # minutes), the single-threaded Node event loop saturates, and the process
132
+ # hangs — /health, metrics and every libp2p transport stop responding while the
133
+ # VM, Caddy and the TCP listeners still look healthy. `connectionManager
134
+ # .maxConnections` does not prevent this: it bounds established connections,
135
+ # not the inbound churn. Bootstrap above stays enabled, which gives AutoNAT the
136
+ # handful of public peers it needs to confirm an address so AutoTLS can still
137
+ # fetch its `*.libp2p.direct` certificate. Set RELAY_DISABLE_DHT=0 only on a
138
+ # host that can absorb full DHT server load.
139
+ write_env_var "RELAY_DISABLE_DHT" "1"
121
140
  write_env_var "ENABLE_GENERAL_LOGS" "1"
122
141
  write_env_var "DEBUG" "'libp2p:websockets:listener'"
@@ -13,6 +13,17 @@ CADDYFILE="${CADDYFILE:-/etc/caddy/Caddyfile}"
13
13
  CADDY_UPSTREAM_WSS_PORT="${CADDY_UPSTREAM_WSS_PORT:-9092}"
14
14
  CADDY_UPSTREAM_HOST="${CADDY_UPSTREAM_HOST:-127.0.0.1}"
15
15
  CADDY_UPSTREAM_METRICS_PORT="${CADDY_UPSTREAM_METRICS_PORT:-9090}"
16
+ # Internal libp2p listen ports. On the Aleph VM, IPv4 is NAT/port-mapped (so it
17
+ # is announced on the externally assigned host ports), but IPv6 is globally
18
+ # routed with NO NAT — the VM is reachable directly on the real listen ports,
19
+ # and the mapped host ports are closed over IPv6. IPv6 must therefore be
20
+ # announced on these internal ports, otherwise AutoNAT/AutoTLS dial-backs to the
21
+ # IPv6 address hit a closed mapped port, the address is never confirmed, and
22
+ # `*.libp2p.direct` AutoTLS never provisions a certificate over IPv6.
23
+ RELAY_TCP_LISTEN_PORT="${RELAY_TCP_LISTEN_PORT:-9091}"
24
+ RELAY_WS_LISTEN_PORT="${RELAY_WS_LISTEN_PORT:-${CADDY_UPSTREAM_WSS_PORT}}"
25
+ RELAY_WEBRTC_LISTEN_PORT="${RELAY_WEBRTC_LISTEN_PORT:-9093}"
26
+ RELAY_QUIC_LISTEN_PORT="${RELAY_QUIC_LISTEN_PORT:-9094}"
16
27
  AUTOTLS_REFRESH_SERVICE="${AUTOTLS_REFRESH_SERVICE:-orbitdb-relay-autotls-refresh.service}"
17
28
  BOOTSTRAP_REFRESH_TIMER="${BOOTSTRAP_REFRESH_TIMER:-orbitdb-relay-bootstrap-refresh.timer}"
18
29
  BOOTSTRAP_REFRESH_SERVICE="${BOOTSTRAP_REFRESH_SERVICE:-orbitdb-relay-bootstrap-refresh.service}"
@@ -29,6 +40,7 @@ QUIC_PORT=""
29
40
  BOOTSTRAP_PUBLISHER_PRIVATE_KEY=""
30
41
  BOOTSTRAP_PUBLISHER_LIBP2P_IDENTITY_HEX=""
31
42
  BOOTSTRAP_OWNER_PRIVATE_KEY=""
43
+ BOOTSTRAP_OWNER_ADDRESS=""
32
44
  BOOTSTRAP_OWNER_AUTHORIZATION_B64=""
33
45
  BOOTSTRAP_REGISTRATION_ID=""
34
46
  START_SERVICE=1
@@ -49,6 +61,7 @@ Usage:
49
61
  [--bootstrap-publisher-private-key <hex>] \
50
62
  [--bootstrap-publisher-libp2p-identity-hex <hex>] \
51
63
  [--bootstrap-owner-private-key <hex>] \
64
+ [--bootstrap-owner-address <0x…>] \
52
65
  [--bootstrap-owner-authorization-b64 <base64>] \
53
66
  [--bootstrap-registration-id <id>] \
54
67
  [--no-start]
@@ -58,6 +71,33 @@ marks the relay as ready, and optionally starts the relay plus Caddy services.
58
71
  EOF
59
72
  }
60
73
 
74
+ current_bootstrap_publisher_private_key() {
75
+ local existing
76
+ existing="$(grep -E '^[#[:space:]]*ALEPH_BOOTSTRAP_PUBLISHER_PRIVATE_KEY=' "${ENV_FILE}" 2>/dev/null | tail -n1 | cut -d= -f2- || true)"
77
+ if [ -n "${existing}" ]; then
78
+ printf '%s\n' "${existing}"
79
+ return
80
+ fi
81
+ printf '\n'
82
+ }
83
+
84
+ # The bootstrap publisher key signs this relay's own Aleph registrations, so
85
+ # it never has to leave the guest. Generating it here means the deployer does
86
+ # not have to send a secret to the plain-HTTP setup endpoint, which runs
87
+ # before Caddy/TLS exists and would otherwise put the key on the wire in the
88
+ # clear. An already-provisioned key is reused so the relay keeps its identity
89
+ # across reconfigurations.
90
+ generate_bootstrap_publisher_private_key() {
91
+ while true; do
92
+ local candidate
93
+ candidate="$(openssl rand -hex 32)"
94
+ if [ -n "${candidate}" ] && [ "${candidate}" != "0000000000000000000000000000000000000000000000000000000000000000" ]; then
95
+ printf '0x%s\n' "${candidate}"
96
+ return
97
+ fi
98
+ done
99
+ }
100
+
61
101
  write_env_var() {
62
102
  local key="$1"
63
103
  local value="$2"
@@ -165,6 +205,10 @@ while [ "$#" -gt 0 ]; do
165
205
  BOOTSTRAP_OWNER_PRIVATE_KEY="${2:-}"
166
206
  shift 2
167
207
  ;;
208
+ --bootstrap-owner-address)
209
+ BOOTSTRAP_OWNER_ADDRESS="${2:-}"
210
+ shift 2
211
+ ;;
168
212
  --bootstrap-owner-authorization-b64)
169
213
  BOOTSTRAP_OWNER_AUTHORIZATION_B64="${2:-}"
170
214
  shift 2
@@ -203,7 +247,7 @@ announce=(
203
247
 
204
248
  if [ -n "${PUBLIC_IPV6}" ]; then
205
249
  announce+=(
206
- "/ip6/${PUBLIC_IPV6}/tcp/${TCP_PORT}"
250
+ "/ip6/${PUBLIC_IPV6}/tcp/${RELAY_TCP_LISTEN_PORT}"
207
251
  )
208
252
  fi
209
253
 
@@ -214,20 +258,20 @@ fi
214
258
 
215
259
  announce+=("/ip4/${PUBLIC_IPV4}/tcp/${WS_PORT}/ws")
216
260
  if [ -n "${PUBLIC_IPV6}" ]; then
217
- announce+=("/ip6/${PUBLIC_IPV6}/tcp/${WS_PORT}/ws")
261
+ announce+=("/ip6/${PUBLIC_IPV6}/tcp/${RELAY_WS_LISTEN_PORT}/ws")
218
262
  fi
219
263
 
220
264
  if [ -n "${WEBRTC_PORT}" ]; then
221
265
  announce+=("/ip4/${PUBLIC_IPV4}/udp/${WEBRTC_PORT}/webrtc-direct")
222
266
  if [ -n "${PUBLIC_IPV6}" ]; then
223
- announce+=("/ip6/${PUBLIC_IPV6}/udp/${WEBRTC_PORT}/webrtc-direct")
267
+ announce+=("/ip6/${PUBLIC_IPV6}/udp/${RELAY_WEBRTC_LISTEN_PORT}/webrtc-direct")
224
268
  fi
225
269
  fi
226
270
 
227
271
  if [ -n "${QUIC_PORT}" ]; then
228
272
  announce+=("/ip4/${PUBLIC_IPV4}/udp/${QUIC_PORT}/quic-v1")
229
273
  if [ -n "${PUBLIC_IPV6}" ]; then
230
- announce+=("/ip6/${PUBLIC_IPV6}/udp/${QUIC_PORT}/quic-v1")
274
+ announce+=("/ip6/${PUBLIC_IPV6}/udp/${RELAY_QUIC_LISTEN_PORT}/quic-v1")
231
275
  fi
232
276
  fi
233
277
 
@@ -256,6 +300,12 @@ fi
256
300
  if [ -n "${QUIC_PORT}" ]; then
257
301
  write_env_var "EXTERNAL_RELAY_QUIC_PORT" "${QUIC_PORT}"
258
302
  fi
303
+ if [ -z "${BOOTSTRAP_PUBLISHER_PRIVATE_KEY}" ]; then
304
+ BOOTSTRAP_PUBLISHER_PRIVATE_KEY="$(current_bootstrap_publisher_private_key)"
305
+ fi
306
+ if [ -z "${BOOTSTRAP_PUBLISHER_PRIVATE_KEY}" ]; then
307
+ BOOTSTRAP_PUBLISHER_PRIVATE_KEY="$(generate_bootstrap_publisher_private_key)"
308
+ fi
259
309
  if [ -n "${BOOTSTRAP_PUBLISHER_PRIVATE_KEY}" ]; then
260
310
  write_env_var "ALEPH_BOOTSTRAP_PUBLISHER_PRIVATE_KEY" "${BOOTSTRAP_PUBLISHER_PRIVATE_KEY}"
261
311
  fi
@@ -265,6 +315,9 @@ fi
265
315
  if [ -n "${BOOTSTRAP_OWNER_PRIVATE_KEY}" ]; then
266
316
  write_env_var "ALEPH_BOOTSTRAP_OWNER_PRIVATE_KEY" "${BOOTSTRAP_OWNER_PRIVATE_KEY}"
267
317
  fi
318
+ if [ -n "${BOOTSTRAP_OWNER_ADDRESS}" ]; then
319
+ write_env_var "ALEPH_BOOTSTRAP_OWNER_ADDRESS" "${BOOTSTRAP_OWNER_ADDRESS}"
320
+ fi
268
321
  if [ -n "${BOOTSTRAP_OWNER_AUTHORIZATION_B64}" ]; then
269
322
  write_env_var "ALEPH_BOOTSTRAP_OWNER_AUTHORIZATION_B64" "${BOOTSTRAP_OWNER_AUTHORIZATION_B64}"
270
323
  fi
@@ -51,6 +51,29 @@ def normalize_multiaddrs(payload: dict) -> list[str]:
51
51
  return [entry for entry in values if isinstance(entry, str) and entry.strip()]
52
52
 
53
53
 
54
+ def bootstrap_publisher_address(env_values: dict[str, str]) -> str | None:
55
+ """Address of the key this relay signs its own Aleph registrations with.
56
+
57
+ The guest generates that key itself, so the deployer cannot know the
58
+ address up front. Reporting it here lets the deployer bind an owner
59
+ authorization to the relay's real publisher identity instead of having to
60
+ send the key to the guest over the plain-HTTP setup endpoint.
61
+ """
62
+ private_key = env_values.get("ALEPH_BOOTSTRAP_PUBLISHER_PRIVATE_KEY", "").strip()
63
+ if not private_key:
64
+ return None
65
+
66
+ try:
67
+ from eth_account import Account
68
+ except ImportError:
69
+ return None
70
+
71
+ try:
72
+ return Account.from_key(private_key).address
73
+ except (ValueError, TypeError):
74
+ return None
75
+
76
+
54
77
  def build_grouped_multiaddrs(env_values: dict[str, str], all_multiaddrs: list[str], peer_id: str) -> dict[str, list[str]]:
55
78
  proxy_hostname = env_values.get("PROXY_HOSTNAME", "").strip().lower()
56
79
  direct_tcp_multiaddrs = [addr for addr in all_multiaddrs if "/tcp/" in addr and "/ws" not in addr]
@@ -148,6 +171,7 @@ def main() -> None:
148
171
  env_values = parse_env_file(ENV_FILE)
149
172
  payload = {
150
173
  "peer_id": peer_id,
174
+ "bootstrap_publisher_address": bootstrap_publisher_address(env_values),
151
175
  "announce_addrs": [
152
176
  entry.strip()
153
177
  for entry in env_values.get("VITE_APPEND_ANNOUNCE", "").split(",")
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env python3
2
+ import base64
3
+ import hashlib
2
4
  import ipaddress
3
5
  import json
4
6
  import os
@@ -6,7 +8,9 @@ import subprocess
6
8
  import threading
7
9
  import time
8
10
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
9
- from urllib.parse import urlsplit
11
+ from urllib.error import HTTPError, URLError
12
+ from urllib.parse import urlencode, urlsplit
13
+ from urllib.request import Request, urlopen
10
14
 
11
15
 
12
16
  ENV_FILE = os.environ.get("ENV_FILE", "/etc/default/orbitdb-relay")
@@ -18,6 +22,26 @@ DESCRIBE_SCRIPT = "/usr/local/sbin/orbitdb-relay-describe.py"
18
22
  METADATA_FILE = os.environ.get("METADATA_FILE", "/run/orbitdb-relay-setup-metadata.json")
19
23
  METADATA_ERROR_FILE = os.environ.get("METADATA_ERROR_FILE", "/run/orbitdb-relay-setup-metadata.error")
20
24
 
25
+ # Guest-side bootstrap config handoff.
26
+ #
27
+ # A browser served over HTTPS cannot POST to this server: it listens on plain
28
+ # HTTP (it has to exist before Caddy/TLS does), so the request is blocked as
29
+ # mixed content. Instead the deployer publishes the configuration into a
30
+ # public Aleph aggregate and puts a locator into the VM's SSH key; the guest
31
+ # reads that locator, fetches its own configuration over HTTPS, applies it,
32
+ # and publishes a signed acknowledgement the deployer waits for.
33
+ AUTHORIZED_KEYS_FILE = os.environ.get("AUTHORIZED_KEYS_FILE", "/root/.ssh/authorized_keys")
34
+ ALEPH_API_HOST = os.environ.get("ALEPH_API_HOST", "https://api.aleph.im")
35
+ BOOTSTRAP_CONFIG_KEY = os.environ.get("BOOTSTRAP_CONFIG_KEY", "vm-bootstrap-config")
36
+ BOOTSTRAP_CONFIG_POLL_SECONDS = float(os.environ.get("BOOTSTRAP_CONFIG_POLL_SECONDS", "5"))
37
+ BOOTSTRAP_CONFIG_SIGNAL_REF = os.environ.get("BOOTSTRAP_CONFIG_SIGNAL_REF", "vm-bootstrap-config")
38
+ BOOTSTRAP_CONFIG_SIGNAL_POST_TYPE = os.environ.get(
39
+ "BOOTSTRAP_CONFIG_SIGNAL_POST_TYPE", "vm-bootstrap-config-signal"
40
+ )
41
+ BOOTSTRAP_CHANNEL = os.environ.get("ALEPH_BOOTSTRAP_CHANNEL", "simple-todo")
42
+
43
+ CONFIGURE_LOCK = threading.Lock()
44
+
21
45
 
22
46
  def _cors_headers(handler: BaseHTTPRequestHandler) -> None:
23
47
  handler.send_header("Access-Control-Allow-Origin", "*")
@@ -49,6 +73,323 @@ def _validate_proxy_hostname(value: object) -> str | None:
49
73
  return parsed.hostname
50
74
 
51
75
 
76
+ def _json_dumps(payload: object) -> str:
77
+ return json.dumps(payload, separators=(",", ":"))
78
+
79
+
80
+ def _parse_env_file(path: str) -> dict[str, str]:
81
+ values: dict[str, str] = {}
82
+ if not os.path.exists(path):
83
+ return values
84
+
85
+ with open(path, encoding="utf-8") as handle:
86
+ for line in handle:
87
+ stripped = line.strip()
88
+ if not stripped or stripped.startswith("#") or "=" not in stripped:
89
+ continue
90
+ key, value = stripped.split("=", 1)
91
+ values[key.strip()] = value.strip()
92
+ return values
93
+
94
+
95
+ def _extract_guest_bootstrap_locator() -> tuple[str | None, str | None]:
96
+ """Read the deployment locator the deployer left in our SSH key.
97
+
98
+ The Aleph INSTANCE message carries an SSH public key with an extra
99
+ `aleph-bootstrap-config:<owner>:<token>` part, so it lands in the guest's
100
+ authorized_keys and gives us everything needed to find our own record.
101
+ """
102
+ try:
103
+ with open(AUTHORIZED_KEYS_FILE, encoding="utf-8") as handle:
104
+ for raw_line in handle:
105
+ line = raw_line.strip()
106
+ if not line or line.startswith("#"):
107
+ continue
108
+ for part in line.split():
109
+ if part.startswith("aleph-bootstrap-config:"):
110
+ _, owner_address, deployment_token = part.split(":", 2)
111
+ owner_address = owner_address.strip()
112
+ deployment_token = deployment_token.strip()
113
+ if owner_address and deployment_token:
114
+ return owner_address, deployment_token
115
+ except (FileNotFoundError, ValueError):
116
+ return None, None
117
+
118
+ return None, None
119
+
120
+
121
+ def _address_from_private_key(private_key: str) -> str:
122
+ from eth_account import Account
123
+
124
+ return Account.from_key(private_key).address
125
+
126
+
127
+ def _sign_personal_message(private_key: str, payload: str) -> str:
128
+ from eth_account import Account
129
+ from eth_account.messages import encode_defunct
130
+
131
+ signed = Account.sign_message(encode_defunct(text=payload), private_key=private_key)
132
+ signature = signed.signature.hex()
133
+ return signature if signature.startswith("0x") else f"0x{signature}"
134
+
135
+
136
+ def _signature_payload(chain: str, sender: str, message_type: str, item_hash: str) -> str:
137
+ return "\n".join([chain, sender, message_type, item_hash])
138
+
139
+
140
+ def _broadcast_aleph_message(api_host: str, message: dict[str, object]) -> None:
141
+ body = json.dumps({"message": message, "sync": True}).encode("utf-8")
142
+ request = Request(
143
+ f"{api_host.rstrip('/')}/api/v0/messages",
144
+ data=body,
145
+ headers={"content-type": "application/json"},
146
+ method="POST",
147
+ )
148
+ with urlopen(request, timeout=30) as response:
149
+ response.read()
150
+
151
+
152
+ def _fresh_metadata_payload() -> tuple[dict | None, str | None]:
153
+ if os.path.exists(METADATA_FILE):
154
+ try:
155
+ with open(METADATA_FILE, encoding="utf-8") as handle:
156
+ return json.load(handle), None
157
+ except (OSError, json.JSONDecodeError) as error:
158
+ return None, str(error)
159
+ if os.path.exists(METADATA_ERROR_FILE):
160
+ with open(METADATA_ERROR_FILE, encoding="utf-8") as handle:
161
+ return None, handle.read().strip() or "metadata generation failed"
162
+ return None, None
163
+
164
+
165
+ def _build_configure_args_from_record(record: dict) -> list[str]:
166
+ """Map an Aleph bootstrap config record onto configure.sh arguments.
167
+
168
+ Deliberately mirrors the /configure payload mapping, minus every secret:
169
+ the record lives in a *public* aggregate, so it may only carry network
170
+ facts, the registration id, the owner address and a signed authorization.
171
+ """
172
+ runtime = record.get("runtime") if isinstance(record.get("runtime"), dict) else {}
173
+ bootstrap = record.get("bootstrap") if isinstance(record.get("bootstrap"), dict) else {}
174
+
175
+ public_ipv4 = str(runtime.get("publicIpv4") or "").strip()
176
+ if not public_ipv4:
177
+ raise ValueError("bootstrap config is missing runtime.publicIpv4")
178
+
179
+ mapped = runtime.get("mappedPorts") if isinstance(runtime.get("mappedPorts"), dict) else {}
180
+
181
+ def host_port(*container_ports: str) -> int | None:
182
+ for container_port in container_ports:
183
+ entry = mapped.get(container_port)
184
+ if isinstance(entry, dict):
185
+ host = entry.get("host")
186
+ if isinstance(host, int) and host > 0:
187
+ return host
188
+ return None
189
+
190
+ tcp_port = host_port("9091")
191
+ ws_port = host_port("9092", "443")
192
+ if not tcp_port or not ws_port:
193
+ raise ValueError("bootstrap config is missing the mapped TCP/WS ports")
194
+
195
+ args = [
196
+ CONFIGURE_SCRIPT,
197
+ "--public-ipv4",
198
+ public_ipv4,
199
+ "--tcp-port",
200
+ str(tcp_port),
201
+ "--ws-port",
202
+ str(ws_port),
203
+ ]
204
+
205
+ public_ipv6 = str(runtime.get("publicIpv6") or "").strip()
206
+ if public_ipv6:
207
+ args.extend(["--public-ipv6", public_ipv6])
208
+
209
+ proxy_hostname = _validate_proxy_hostname(runtime.get("proxyUrl"))
210
+ if proxy_hostname:
211
+ args.extend(["--proxy-hostname", proxy_hostname])
212
+
213
+ for flag, container_ports in (
214
+ ("--metrics-port", ("9090",)),
215
+ ("--metrics-https-port", ("443",)),
216
+ ("--webrtc-port", ("9093",)),
217
+ ("--quic-port", ("9094",)),
218
+ ):
219
+ value = host_port(*container_ports)
220
+ if value:
221
+ args.extend([flag, str(value)])
222
+
223
+ registration_id = str(bootstrap.get("registrationId") or "").strip()
224
+ if registration_id:
225
+ args.extend(["--bootstrap-registration-id", registration_id])
226
+
227
+ owner_address = str(record.get("ownerAddress") or "").strip()
228
+ if owner_address:
229
+ args.extend(["--bootstrap-owner-address", owner_address])
230
+
231
+ owner_authorization = str(bootstrap.get("ownerAuthorizationBase64") or "").strip()
232
+ if owner_authorization:
233
+ args.extend(["--bootstrap-owner-authorization-b64", owner_authorization])
234
+
235
+ return args
236
+
237
+
238
+ def _run_configure_process(args: list[str], bootstrap_record: dict | None = None) -> tuple[bool, str]:
239
+ with CONFIGURE_LOCK:
240
+ if os.path.exists(READY_FILE):
241
+ return True, "ready"
242
+ try:
243
+ result = subprocess.run(args, check=True, capture_output=True, text=True)
244
+ except subprocess.CalledProcessError as error:
245
+ return False, error.stderr.strip() or error.stdout.strip() or str(error)
246
+
247
+ _clear_metadata_state()
248
+ _generate_metadata_files()
249
+
250
+ if isinstance(bootstrap_record, dict):
251
+ try:
252
+ _publish_vm_bootstrap_config_signal(bootstrap_record)
253
+ except Exception as error: # pragma: no cover - runtime error path
254
+ return False, f"configured relay but failed to publish bootstrap signal: {error}"
255
+ return True, result.stdout.strip()
256
+
257
+
258
+ def _publish_vm_bootstrap_config_signal(record: dict) -> None:
259
+ """Tell the deployer we applied the config; it blocks until this appears."""
260
+ env_values = _parse_env_file(ENV_FILE)
261
+ publisher_private_key = env_values.get("ALEPH_BOOTSTRAP_PUBLISHER_PRIVATE_KEY", "").strip()
262
+ if not publisher_private_key:
263
+ raise RuntimeError("missing ALEPH_BOOTSTRAP_PUBLISHER_PRIVATE_KEY")
264
+
265
+ owner_authorization = None
266
+ owner_authorization_b64 = env_values.get("ALEPH_BOOTSTRAP_OWNER_AUTHORIZATION_B64", "").strip()
267
+ if owner_authorization_b64:
268
+ try:
269
+ parsed = json.loads(base64.b64decode(owner_authorization_b64).decode("utf-8"))
270
+ if isinstance(parsed, dict):
271
+ owner_authorization = parsed
272
+ except (ValueError, json.JSONDecodeError):
273
+ owner_authorization = None
274
+
275
+ owner_address = str(record.get("ownerAddress") or "").strip()
276
+ deployment_token = str(record.get("deploymentToken") or "").strip()
277
+ profile = str(record.get("profile") or "").strip()
278
+ instance_item_hash = str(record.get("instanceItemHash") or "").strip()
279
+ if not deployment_token or not profile or not instance_item_hash:
280
+ raise RuntimeError("bootstrap config signal is missing deployment metadata")
281
+
282
+ metadata, metadata_error = _fresh_metadata_payload()
283
+ if not metadata:
284
+ raise RuntimeError(
285
+ f"bootstrap config signal metadata is not ready: {metadata_error or 'unavailable'}"
286
+ )
287
+
288
+ peer_id = metadata.get("peer_id")
289
+ probe_multiaddrs = metadata.get("probe_multiaddrs")
290
+ browser_bootstrap_multiaddrs = metadata.get("browser_bootstrap_multiaddrs")
291
+ if not isinstance(peer_id, str) or not peer_id.strip():
292
+ raise RuntimeError("bootstrap config signal is missing peer_id")
293
+ if not isinstance(probe_multiaddrs, list) or not any(
294
+ isinstance(entry, str) and entry.strip() for entry in probe_multiaddrs
295
+ ):
296
+ raise RuntimeError("bootstrap config signal is missing probe_multiaddrs")
297
+ if not isinstance(browser_bootstrap_multiaddrs, list):
298
+ browser_bootstrap_multiaddrs = []
299
+
300
+ publisher_address = _address_from_private_key(publisher_private_key)
301
+ now_ms = int(time.time() * 1000)
302
+ content = {
303
+ "type": BOOTSTRAP_CONFIG_SIGNAL_POST_TYPE,
304
+ "address": publisher_address,
305
+ "ref": BOOTSTRAP_CONFIG_SIGNAL_REF,
306
+ "content": {
307
+ "deploymentToken": deployment_token,
308
+ "status": "applied",
309
+ "profile": profile,
310
+ "ownerAddress": owner_address,
311
+ "instanceItemHash": instance_item_hash,
312
+ "updatedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now_ms / 1000)),
313
+ "publisherAddress": publisher_address,
314
+ "authorization": owner_authorization,
315
+ "peerId": peer_id.strip(),
316
+ "probeMultiaddrs": [
317
+ entry.strip()
318
+ for entry in probe_multiaddrs
319
+ if isinstance(entry, str) and entry.strip()
320
+ ],
321
+ "browserBootstrapMultiaddrs": [
322
+ entry.strip()
323
+ for entry in browser_bootstrap_multiaddrs
324
+ if isinstance(entry, str) and entry.strip()
325
+ ],
326
+ },
327
+ "time": now_ms,
328
+ }
329
+ item_content = _json_dumps(content)
330
+ message = {
331
+ "channel": BOOTSTRAP_CHANNEL,
332
+ "sender": publisher_address,
333
+ "chain": "ETH",
334
+ "type": "POST",
335
+ "time": now_ms / 1000,
336
+ "item_type": "inline",
337
+ "item_content": item_content,
338
+ "item_hash": hashlib.sha256(item_content.encode("utf-8")).hexdigest(),
339
+ }
340
+ message["signature"] = _sign_personal_message(
341
+ publisher_private_key,
342
+ _signature_payload(
343
+ str(message["chain"]),
344
+ str(message["sender"]),
345
+ str(message["type"]),
346
+ str(message["item_hash"]),
347
+ ),
348
+ )
349
+ _broadcast_aleph_message(ALEPH_API_HOST, message)
350
+
351
+
352
+ def _load_vm_bootstrap_record() -> tuple[str | None, str | None, dict | None]:
353
+ owner_address, deployment_token = _extract_guest_bootstrap_locator()
354
+ if not owner_address or not deployment_token:
355
+ return owner_address, deployment_token, None
356
+
357
+ request_url = (
358
+ f"{ALEPH_API_HOST.rstrip('/')}/api/v0/aggregates/{owner_address}.json?"
359
+ f"{urlencode({'keys': BOOTSTRAP_CONFIG_KEY})}"
360
+ )
361
+
362
+ try:
363
+ with urlopen(request_url, timeout=10) as response:
364
+ payload = json.loads(response.read().decode("utf-8") or "{}")
365
+ except (HTTPError, URLError, json.JSONDecodeError, TimeoutError):
366
+ return owner_address, deployment_token, None
367
+
368
+ aggregate = payload.get("data", {}).get(BOOTSTRAP_CONFIG_KEY, {})
369
+ if not isinstance(aggregate, dict):
370
+ return owner_address, deployment_token, None
371
+
372
+ record = aggregate.get(deployment_token)
373
+ return owner_address, deployment_token, record if isinstance(record, dict) else None
374
+
375
+
376
+ def _poll_bootstrap_config_loop() -> None:
377
+ while not os.path.exists(READY_FILE):
378
+ _, deployment_token, record = _load_vm_bootstrap_record()
379
+ if deployment_token and isinstance(record, dict):
380
+ status = str(record.get("status") or "").strip().lower()
381
+ if status == "pending":
382
+ try:
383
+ args = _build_configure_args_from_record(record)
384
+ except ValueError:
385
+ time.sleep(BOOTSTRAP_CONFIG_POLL_SECONDS)
386
+ continue
387
+ ok, _ = _run_configure_process(args, record)
388
+ if ok:
389
+ return
390
+ time.sleep(BOOTSTRAP_CONFIG_POLL_SECONDS)
391
+
392
+
52
393
  class Handler(BaseHTTPRequestHandler):
53
394
  server_version = "OrbitdbRelaySetup/1.0"
54
395
 
@@ -143,7 +484,11 @@ class Handler(BaseHTTPRequestHandler):
143
484
  bootstrap_publisher_libp2p_identity_hex = payload.get(
144
485
  "bootstrap_publisher_libp2p_identity_hex"
145
486
  )
146
- bootstrap_owner_private_key = payload.get("bootstrap_owner_private_key")
487
+ # NOTE: `bootstrap_owner_private_key` is deliberately NOT accepted
488
+ # here. This endpoint is plain HTTP (it runs before Caddy/TLS
489
+ # exists), so anything sent to it crosses the network in the
490
+ # clear. The owner only ever needs to hand the guest a *signed
491
+ # authorization*, never the key itself.
147
492
  bootstrap_owner_authorization_b64 = payload.get("bootstrap_owner_authorization_b64")
148
493
  bootstrap_registration_id = payload.get("bootstrap_registration_id")
149
494
  no_start = bool(payload.get("no_start"))
@@ -182,8 +527,6 @@ class Handler(BaseHTTPRequestHandler):
182
527
  str(bootstrap_publisher_libp2p_identity_hex),
183
528
  ]
184
529
  )
185
- if bootstrap_owner_private_key is not None:
186
- args.extend(["--bootstrap-owner-private-key", str(bootstrap_owner_private_key)])
187
530
  if bootstrap_owner_authorization_b64 is not None:
188
531
  args.extend(
189
532
  ["--bootstrap-owner-authorization-b64", str(bootstrap_owner_authorization_b64)]
@@ -256,6 +599,13 @@ def _generate_metadata_files() -> None:
256
599
 
257
600
 
258
601
  def main() -> None:
602
+ # Poll for our own Aleph bootstrap config alongside the HTTP listener, so
603
+ # a deployer that cannot reach this plain-HTTP endpoint (any page served
604
+ # over HTTPS) can hand the configuration over through Aleph instead. The
605
+ # first path to succeed wins; the other becomes a no-op once READY_FILE
606
+ # exists and CONFIGURE_LOCK serialises them.
607
+ threading.Thread(target=_poll_bootstrap_config_loop, daemon=True).start()
608
+
259
609
  server = ThreadingHTTPServer(("0.0.0.0", 80), Handler)
260
610
  server.serve_forever()
261
611