@le-space/rootfs 0.3.0 → 0.3.3

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.
@@ -14,6 +14,7 @@ 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
16
  AUTOTLS_REFRESH_SERVICE="${AUTOTLS_REFRESH_SERVICE:-orbitdb-relay-pinner-autotls-refresh.service}"
17
+ BOOTSTRAP_REFRESH_TIMER="${BOOTSTRAP_REFRESH_TIMER:-orbitdb-relay-pinner-bootstrap-refresh.timer}"
17
18
  PUBLIC_IPV4=""
18
19
  PUBLIC_IPV6=""
19
20
  TCP_PORT=""
@@ -23,6 +24,11 @@ METRICS_PORT=""
23
24
  METRICS_HTTPS_PORT=""
24
25
  WEBRTC_PORT=""
25
26
  QUIC_PORT=""
27
+ BOOTSTRAP_PUBLISHER_PRIVATE_KEY=""
28
+ BOOTSTRAP_PUBLISHER_LIBP2P_IDENTITY_HEX=""
29
+ BOOTSTRAP_OWNER_PRIVATE_KEY=""
30
+ BOOTSTRAP_OWNER_AUTHORIZATION_B64=""
31
+ BOOTSTRAP_REGISTRATION_ID=""
26
32
  START_SERVICE=1
27
33
 
28
34
  usage() {
@@ -38,6 +44,11 @@ Usage:
38
44
  [--metrics-https-port <host-port>] \
39
45
  [--webrtc-port <host-port>] \
40
46
  [--quic-port <host-port>] \
47
+ [--bootstrap-publisher-private-key <hex>] \
48
+ [--bootstrap-publisher-libp2p-identity-hex <hex>] \
49
+ [--bootstrap-owner-private-key <hex>] \
50
+ [--bootstrap-owner-authorization-b64 <base64>] \
51
+ [--bootstrap-registration-id <id>] \
41
52
  [--no-start]
42
53
 
43
54
  Writes VITE_APPEND_ANNOUNCE for the externally assigned Aleph host ports,
@@ -134,6 +145,26 @@ while [ "$#" -gt 0 ]; do
134
145
  QUIC_PORT="${2:-}"
135
146
  shift 2
136
147
  ;;
148
+ --bootstrap-publisher-private-key)
149
+ BOOTSTRAP_PUBLISHER_PRIVATE_KEY="${2:-}"
150
+ shift 2
151
+ ;;
152
+ --bootstrap-publisher-libp2p-identity-hex)
153
+ BOOTSTRAP_PUBLISHER_LIBP2P_IDENTITY_HEX="${2:-}"
154
+ shift 2
155
+ ;;
156
+ --bootstrap-owner-private-key)
157
+ BOOTSTRAP_OWNER_PRIVATE_KEY="${2:-}"
158
+ shift 2
159
+ ;;
160
+ --bootstrap-owner-authorization-b64)
161
+ BOOTSTRAP_OWNER_AUTHORIZATION_B64="${2:-}"
162
+ shift 2
163
+ ;;
164
+ --bootstrap-registration-id)
165
+ BOOTSTRAP_REGISTRATION_ID="${2:-}"
166
+ shift 2
167
+ ;;
137
168
  --no-start)
138
169
  START_SERVICE=0
139
170
  shift
@@ -217,6 +248,22 @@ fi
217
248
  if [ -n "${QUIC_PORT}" ]; then
218
249
  write_env_var "EXTERNAL_RELAY_QUIC_PORT" "${QUIC_PORT}"
219
250
  fi
251
+ if [ -n "${BOOTSTRAP_PUBLISHER_PRIVATE_KEY}" ]; then
252
+ write_env_var "ALEPH_BOOTSTRAP_PUBLISHER_PRIVATE_KEY" "${BOOTSTRAP_PUBLISHER_PRIVATE_KEY}"
253
+ fi
254
+ if [ -n "${BOOTSTRAP_PUBLISHER_LIBP2P_IDENTITY_HEX}" ]; then
255
+ write_env_var "RELAY_PRIV_KEY" "${BOOTSTRAP_PUBLISHER_LIBP2P_IDENTITY_HEX}"
256
+ fi
257
+ if [ -n "${BOOTSTRAP_OWNER_PRIVATE_KEY}" ]; then
258
+ write_env_var "ALEPH_BOOTSTRAP_OWNER_PRIVATE_KEY" "${BOOTSTRAP_OWNER_PRIVATE_KEY}"
259
+ fi
260
+ if [ -n "${BOOTSTRAP_OWNER_AUTHORIZATION_B64}" ]; then
261
+ write_env_var "ALEPH_BOOTSTRAP_OWNER_AUTHORIZATION_B64" "${BOOTSTRAP_OWNER_AUTHORIZATION_B64}"
262
+ fi
263
+ if [ -n "${BOOTSTRAP_REGISTRATION_ID}" ]; then
264
+ write_env_var "ALEPH_BOOTSTRAP_REGISTRATION_ID" "${BOOTSTRAP_REGISTRATION_ID}"
265
+ fi
266
+ write_env_var "ALEPH_BOOTSTRAP_PROFILE" "orbitdb-relay-pinner"
220
267
  touch "${READY_FILE}"
221
268
 
222
269
  if [ "${START_SERVICE}" -eq 1 ]; then
@@ -224,6 +271,8 @@ if [ "${START_SERVICE}" -eq 1 ]; then
224
271
  systemctl enable "${SERVICE_NAME}"
225
272
  systemctl restart "${SERVICE_NAME}"
226
273
  systemctl enable "${AUTOTLS_REFRESH_SERVICE}"
274
+ systemctl enable "${BOOTSTRAP_REFRESH_TIMER}"
275
+ systemctl start "${BOOTSTRAP_REFRESH_TIMER}"
227
276
  if [ -n "${PROXY_HOSTNAME}" ]; then
228
277
  write_caddyfile "${PROXY_HOSTNAME}"
229
278
  touch "${CADDY_READY_FILE}"
@@ -139,6 +139,14 @@ class Handler(BaseHTTPRequestHandler):
139
139
  metrics_https_port = payload.get("metrics_https_port")
140
140
  webrtc_port = payload.get("webrtc_port")
141
141
  quic_port = payload.get("quic_port")
142
+ bootstrap_publisher_private_key = payload.get("bootstrap_publisher_private_key")
143
+ bootstrap_publisher_libp2p_identity_hex = payload.get(
144
+ "bootstrap_publisher_libp2p_identity_hex"
145
+ )
146
+ bootstrap_owner_private_key = payload.get("bootstrap_owner_private_key")
147
+ bootstrap_owner_authorization_b64 = payload.get("bootstrap_owner_authorization_b64")
148
+ bootstrap_registration_id = payload.get("bootstrap_registration_id")
149
+ no_start = bool(payload.get("no_start"))
142
150
  args = [
143
151
  CONFIGURE_SCRIPT,
144
152
  "--public-ipv4",
@@ -165,6 +173,25 @@ class Handler(BaseHTTPRequestHandler):
165
173
  args.extend(["--webrtc-port", _validate_port(webrtc_port, "webrtc_port")])
166
174
  if quic_port is not None:
167
175
  args.extend(["--quic-port", _validate_port(quic_port, "quic_port")])
176
+ if bootstrap_publisher_private_key is not None:
177
+ args.extend(["--bootstrap-publisher-private-key", str(bootstrap_publisher_private_key)])
178
+ if bootstrap_publisher_libp2p_identity_hex is not None:
179
+ args.extend(
180
+ [
181
+ "--bootstrap-publisher-libp2p-identity-hex",
182
+ str(bootstrap_publisher_libp2p_identity_hex),
183
+ ]
184
+ )
185
+ if bootstrap_owner_private_key is not None:
186
+ args.extend(["--bootstrap-owner-private-key", str(bootstrap_owner_private_key)])
187
+ if bootstrap_owner_authorization_b64 is not None:
188
+ args.extend(
189
+ ["--bootstrap-owner-authorization-b64", str(bootstrap_owner_authorization_b64)]
190
+ )
191
+ if bootstrap_registration_id is not None:
192
+ args.extend(["--bootstrap-registration-id", str(bootstrap_registration_id)])
193
+ if no_start:
194
+ args.append("--no-start")
168
195
  except ValueError as error:
169
196
  self._send_json(400, {"status": "bad-request", "error": str(error)})
170
197
  return
@@ -85,15 +85,19 @@ virt-customize \
85
85
  --copy-in "${APP_BINARY}:${GUEST_APP_DIR}" \
86
86
  --copy-in "${SCRIPT_DIR}/uc-go-peer-bootstrap.sh:/usr/local/sbin" \
87
87
  --copy-in "${SCRIPT_DIR}/uc-go-peer-configure.sh:/usr/local/sbin" \
88
+ --copy-in "${SCRIPT_DIR}/uc-go-peer-bootstrap-refresh.py:/usr/local/sbin" \
88
89
  --copy-in "${SCRIPT_DIR}/uc-go-peer-autotls-refresh.py:/usr/local/sbin" \
89
90
  --copy-in "${SCRIPT_DIR}/uc-go-peer-describe.py:/usr/local/sbin" \
90
91
  --copy-in "${SCRIPT_DIR}/uc-go-peer-setup-server.py:/usr/local/sbin" \
91
92
  --copy-in "${SCRIPT_DIR}/uc-go-peer-bootstrap.service:/etc/systemd/system" \
93
+ --copy-in "${SCRIPT_DIR}/uc-go-peer-bootstrap-refresh.service:/etc/systemd/system" \
94
+ --copy-in "${SCRIPT_DIR}/uc-go-peer-bootstrap-refresh.timer:/etc/systemd/system" \
92
95
  --copy-in "${SCRIPT_DIR}/uc-go-peer-autotls-refresh.service:/etc/systemd/system" \
93
96
  --copy-in "${SCRIPT_DIR}/uc-go-peer.service:/etc/systemd/system" \
94
97
  --run-command "chmod 0755 ${ROOTFS_CONTRACT_BINARY_PATH}" \
95
98
  --run-command "chmod 0755 /usr/local/sbin/uc-go-peer-bootstrap.sh" \
96
99
  --run-command "chmod 0755 /usr/local/sbin/uc-go-peer-configure.sh" \
100
+ --run-command "chmod 0755 /usr/local/sbin/uc-go-peer-bootstrap-refresh.py" \
97
101
  --run-command "chmod 0755 /usr/local/sbin/uc-go-peer-autotls-refresh.py" \
98
102
  --run-command "chmod 0755 /usr/local/sbin/uc-go-peer-describe.py" \
99
103
  --run-command "chmod 0755 /usr/local/sbin/uc-go-peer-setup-server.py" \
@@ -0,0 +1,544 @@
1
+ #!/usr/bin/env python3
2
+ import base64
3
+ import hashlib
4
+ import ipaddress
5
+ import json
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ import time
10
+ import urllib.error
11
+ import urllib.parse
12
+ import urllib.request
13
+
14
+ try:
15
+ from eth_account import Account
16
+ from eth_account.messages import encode_defunct
17
+ except ImportError as error: # pragma: no cover - runtime dependency
18
+ raise SystemExit(
19
+ "eth-account is required for guest-side bootstrap refresh publishing"
20
+ ) from error
21
+
22
+
23
+ ENV_FILE = os.environ.get("ENV_FILE", "/etc/default/uc-go-peer")
24
+ DESCRIBE_SCRIPT = os.environ.get("DESCRIBE_SCRIPT", "/usr/local/sbin/uc-go-peer-describe.py")
25
+ DEFAULT_API_HOST = os.environ.get("ALEPH_BOOTSTRAP_API_HOST", "https://api2.aleph.im")
26
+ DEFAULT_CHANNEL = os.environ.get("ALEPH_BOOTSTRAP_CHANNEL", "simple-todo")
27
+ DEFAULT_REF = os.environ.get("ALEPH_BOOTSTRAP_REF", "simple-todo-bootstrap")
28
+ DEFAULT_POST_TYPE = os.environ.get("ALEPH_BOOTSTRAP_POST_TYPE", "relay-bootstrap")
29
+ DEFAULT_PROFILE = os.environ.get("ALEPH_BOOTSTRAP_PROFILE", "uc-go-peer")
30
+ MAX_PREVIOUS_PAGES = int(os.environ.get("ALEPH_BOOTSTRAP_MAX_PREVIOUS_PAGES", "5"))
31
+ PAGINATION = int(os.environ.get("ALEPH_BOOTSTRAP_PAGINATION", "50"))
32
+
33
+
34
+ def parse_env_file(path: str) -> dict[str, str]:
35
+ values: dict[str, str] = {}
36
+ if not os.path.exists(path):
37
+ return values
38
+
39
+ with open(path, encoding="utf-8") as handle:
40
+ for line in handle:
41
+ stripped = line.strip()
42
+ if not stripped or stripped.startswith("#") or "=" not in stripped:
43
+ continue
44
+ key, value = stripped.split("=", 1)
45
+ values[key.strip()] = value.strip()
46
+ return values
47
+
48
+
49
+ def dedupe(values: list[str]) -> list[str]:
50
+ seen: set[str] = set()
51
+ result: list[str] = []
52
+ for value in values:
53
+ if value and value not in seen:
54
+ seen.add(value)
55
+ result.append(value)
56
+ return result
57
+
58
+
59
+ def split_multiaddr(addr: str) -> list[str]:
60
+ return [part for part in addr.split("/") if part]
61
+
62
+
63
+ def has_peer_id(addr: str) -> bool:
64
+ return "p2p" in split_multiaddr(addr)
65
+
66
+
67
+ def is_browser_dialable(addr: str) -> bool:
68
+ normalized = addr.lower()
69
+ return any(
70
+ token in normalized
71
+ for token in ("/ws", "/wss", "/webtransport", "/webrtc-direct")
72
+ )
73
+
74
+
75
+ def is_public_multiaddr(addr: str) -> bool:
76
+ parts = split_multiaddr(addr)
77
+ if not parts:
78
+ return False
79
+
80
+ try:
81
+ for index, token in enumerate(parts[:-1]):
82
+ value = parts[index + 1]
83
+ if token == "ip4" and ipaddress.ip_address(value).is_private:
84
+ return False
85
+ if token == "ip6":
86
+ ip = ipaddress.ip_address(value)
87
+ if ip.is_loopback or ip.is_private or ip.is_link_local:
88
+ return False
89
+ if token in ("dns", "dns4", "dns6"):
90
+ host = value.strip().lower()
91
+ if host == "localhost" or host.endswith(".localhost") or host.endswith(".local"):
92
+ return False
93
+ except ValueError:
94
+ return False
95
+
96
+ return has_peer_id(addr)
97
+
98
+
99
+ def filter_public_multiaddrs(values: list[str], browser_only: bool = False) -> list[str]:
100
+ filtered: list[str] = []
101
+ for value in values:
102
+ if not isinstance(value, str):
103
+ continue
104
+ candidate = value.strip()
105
+ if not candidate or not is_public_multiaddr(candidate):
106
+ continue
107
+ if browser_only and not is_browser_dialable(candidate):
108
+ continue
109
+ filtered.append(candidate)
110
+ return dedupe(filtered)
111
+
112
+
113
+ def json_dumps(payload: object) -> str:
114
+ return json.dumps(payload, separators=(",", ":"))
115
+
116
+
117
+ def sign_personal_message(private_key: str, payload: str) -> str:
118
+ message = encode_defunct(text=payload)
119
+ signed = Account.sign_message(message, private_key=private_key)
120
+ return signed.signature.hex()
121
+
122
+
123
+ def address_from_private_key(private_key: str) -> str:
124
+ return Account.from_key(private_key).address
125
+
126
+
127
+ def signature_payload(chain: str, sender: str, message_type: str, item_hash: str) -> str:
128
+ return "\n".join([chain, sender, message_type, item_hash])
129
+
130
+
131
+ def relay_authorization_payload(
132
+ owner_address: str,
133
+ publisher_address: str,
134
+ peer_id: str,
135
+ registration_id: str | None,
136
+ profile: str | None,
137
+ version: str | None,
138
+ issued_at: int,
139
+ ) -> dict[str, object]:
140
+ return {
141
+ "ownerAddress": owner_address,
142
+ "publisherAddress": publisher_address,
143
+ "peerId": peer_id,
144
+ "registrationId": registration_id,
145
+ "profile": profile,
146
+ "version": version,
147
+ "instanceItemHash": None,
148
+ "issuedAt": issued_at,
149
+ "expiresAt": None,
150
+ }
151
+
152
+
153
+ def relay_proof_payload(
154
+ peer_id: str,
155
+ multiaddrs: list[str],
156
+ browser_multiaddrs: list[str],
157
+ registration_id: str | None,
158
+ profile: str | None,
159
+ version: str | None,
160
+ updated_at: int,
161
+ ) -> dict[str, object]:
162
+ payload: dict[str, object] = {
163
+ "peerId": peer_id,
164
+ "multiaddrs": dedupe(multiaddrs),
165
+ "registrationId": registration_id,
166
+ "profile": profile,
167
+ "version": version,
168
+ "updatedAt": updated_at,
169
+ }
170
+ if browser_multiaddrs:
171
+ payload["browserMultiaddrs"] = dedupe(browser_multiaddrs)
172
+ return payload
173
+
174
+
175
+ def load_owner_authorization(
176
+ env_values: dict[str, str],
177
+ publisher_address: str,
178
+ peer_id: str,
179
+ registration_id: str | None,
180
+ profile: str | None,
181
+ version: str | None,
182
+ issued_at: int,
183
+ ) -> dict[str, object] | None:
184
+ encoded = env_values.get("ALEPH_BOOTSTRAP_OWNER_AUTHORIZATION_B64", "").strip()
185
+ if encoded:
186
+ decoded = base64.b64decode(encoded).decode("utf-8")
187
+ payload = json.loads(decoded)
188
+ if isinstance(payload, dict):
189
+ return payload
190
+
191
+ owner_private_key = env_values.get("ALEPH_BOOTSTRAP_OWNER_PRIVATE_KEY", "").strip()
192
+ if not owner_private_key:
193
+ return None
194
+
195
+ owner_address = address_from_private_key(owner_private_key)
196
+ payload = relay_authorization_payload(
197
+ owner_address,
198
+ publisher_address,
199
+ peer_id,
200
+ registration_id,
201
+ profile,
202
+ version,
203
+ issued_at,
204
+ )
205
+ return {
206
+ "scheme": "personal_sign",
207
+ "payload": payload,
208
+ "signature": sign_personal_message(owner_private_key, json_dumps(payload)),
209
+ }
210
+
211
+
212
+ def post_json(url: str, body: dict[str, object]) -> tuple[int, dict]:
213
+ data = json_dumps(body).encode("utf-8")
214
+ request = urllib.request.Request(
215
+ url,
216
+ data=data,
217
+ headers={"content-type": "application/json"},
218
+ method="POST",
219
+ )
220
+ try:
221
+ with urllib.request.urlopen(request, timeout=30) as response:
222
+ payload = response.read().decode("utf-8")
223
+ return response.status, json.loads(payload or "{}")
224
+ except urllib.error.HTTPError as error:
225
+ payload = error.read().decode("utf-8")
226
+ try:
227
+ return error.code, json.loads(payload or "{}")
228
+ except json.JSONDecodeError:
229
+ return error.code, {"details": payload}
230
+
231
+
232
+ def get_json(url: str) -> dict:
233
+ with urllib.request.urlopen(url, timeout=30) as response:
234
+ return json.loads(response.read().decode("utf-8") or "{}")
235
+
236
+
237
+ def build_post_content(
238
+ sender: str,
239
+ peer_id: str,
240
+ multiaddrs: list[str],
241
+ browser_multiaddrs: list[str],
242
+ registration_id: str | None,
243
+ profile: str | None,
244
+ version: str | None,
245
+ owner_authorization: dict[str, object] | None,
246
+ relay_proof: dict[str, object],
247
+ now_ms: int,
248
+ ref: str,
249
+ post_type: str,
250
+ ) -> dict[str, object]:
251
+ content = {
252
+ "peerId": peer_id,
253
+ "multiaddrs": multiaddrs,
254
+ "browserMultiaddrs": browser_multiaddrs if browser_multiaddrs else None,
255
+ "registrationId": registration_id,
256
+ "profile": profile,
257
+ "version": version,
258
+ "ownerAddress": (
259
+ owner_authorization.get("payload", {}).get("ownerAddress")
260
+ if isinstance(owner_authorization, dict)
261
+ else None
262
+ ),
263
+ "publisherAddress": sender,
264
+ "authorization": owner_authorization,
265
+ "relayProof": relay_proof,
266
+ "updatedAt": now_ms,
267
+ }
268
+ return {
269
+ "type": post_type,
270
+ "address": sender,
271
+ "ref": ref,
272
+ "content": content,
273
+ "time": now_ms,
274
+ }
275
+
276
+
277
+ def build_unsigned_message(sender: str, item_content: str, now_seconds: float, channel: str) -> dict[str, object]:
278
+ item_hash = hashlib.sha256(item_content.encode("utf-8")).hexdigest()
279
+ return {
280
+ "channel": channel,
281
+ "sender": sender,
282
+ "chain": "ETH",
283
+ "type": "POST",
284
+ "time": now_seconds,
285
+ "item_type": "inline",
286
+ "item_content": item_content,
287
+ "item_hash": item_hash,
288
+ }
289
+
290
+
291
+ def sign_aleph_message(unsigned_message: dict[str, object], private_key: str) -> dict[str, object]:
292
+ signed = dict(unsigned_message)
293
+ signed["signature"] = sign_personal_message(
294
+ private_key,
295
+ signature_payload(
296
+ str(unsigned_message["chain"]),
297
+ str(unsigned_message["sender"]),
298
+ str(unsigned_message["type"]),
299
+ str(unsigned_message["item_hash"]),
300
+ ),
301
+ )
302
+ return signed
303
+
304
+
305
+ def broadcast_aleph_message(api_host: str, message: dict[str, object]) -> tuple[int, dict]:
306
+ url = urllib.parse.urljoin(api_host.rstrip("/") + "/", "api/v0/messages")
307
+ attempts = [
308
+ {"sync": True, "message": message},
309
+ {**message, "sync": True},
310
+ dict(message),
311
+ ]
312
+ last: tuple[int, dict] | None = None
313
+ for attempt in attempts:
314
+ last = post_json(url, attempt)
315
+ if 200 <= last[0] < 300:
316
+ return last
317
+ if last is None:
318
+ raise RuntimeError("Aleph broadcast failed before any attempt was made")
319
+ raise RuntimeError(f"Aleph broadcast failed: {last[0]} {json_dumps(last[1])}")
320
+
321
+
322
+ def parse_post_record(entry: object) -> dict[str, object] | None:
323
+ if not isinstance(entry, dict):
324
+ return None
325
+ item_hash = entry.get("item_hash") or entry.get("hash")
326
+ if not isinstance(item_hash, str) or not item_hash:
327
+ return None
328
+
329
+ sender = entry.get("address") or entry.get("sender")
330
+ item_content = entry.get("item_content")
331
+ if isinstance(item_content, str):
332
+ try:
333
+ item_content = json.loads(item_content)
334
+ except json.JSONDecodeError:
335
+ item_content = None
336
+
337
+ if not isinstance(item_content, dict):
338
+ return None
339
+
340
+ content = item_content.get("content")
341
+ if not isinstance(content, dict):
342
+ return None
343
+
344
+ return {
345
+ "item_hash": item_hash,
346
+ "sender": sender,
347
+ "registration_id": content.get("registrationId"),
348
+ }
349
+
350
+
351
+ def fetch_previous_hashes(
352
+ api_host: str,
353
+ channel: str,
354
+ ref: str,
355
+ post_type: str,
356
+ sender: str,
357
+ registration_id: str | None,
358
+ current_item_hash: str,
359
+ ) -> list[str]:
360
+ if not registration_id:
361
+ return []
362
+
363
+ found: list[str] = []
364
+ for page in range(1, MAX_PREVIOUS_PAGES + 1):
365
+ url = (
366
+ f"{api_host.rstrip('/')}/api/v0/posts.json?"
367
+ f"channels={urllib.parse.quote(channel)}&"
368
+ f"refs={urllib.parse.quote(ref)}&"
369
+ f"types={urllib.parse.quote(post_type)}&"
370
+ f"pagination={PAGINATION}&page={page}"
371
+ )
372
+ payload = get_json(url)
373
+ posts = payload.get("posts")
374
+ if not isinstance(posts, list):
375
+ break
376
+
377
+ for entry in posts:
378
+ parsed = parse_post_record(entry)
379
+ if parsed is None:
380
+ continue
381
+ if str(parsed["sender"]).lower() != sender.lower():
382
+ continue
383
+ if parsed["registration_id"] != registration_id:
384
+ continue
385
+ if parsed["item_hash"] == current_item_hash:
386
+ continue
387
+ found.append(str(parsed["item_hash"]))
388
+
389
+ if len(posts) < PAGINATION:
390
+ break
391
+
392
+ return dedupe(found)
393
+
394
+
395
+ def broadcast_forget(
396
+ api_host: str,
397
+ sender: str,
398
+ private_key: str,
399
+ hashes: list[str],
400
+ channel: str,
401
+ ) -> tuple[int, dict] | None:
402
+ if not hashes:
403
+ return None
404
+
405
+ now_seconds = time.time()
406
+ item_content = json_dumps(
407
+ {
408
+ "address": sender,
409
+ "time": now_seconds,
410
+ "hashes": hashes,
411
+ "aggregates": [],
412
+ "reason": f"Replace older relay bootstrap records for {sender}",
413
+ }
414
+ )
415
+ unsigned = {
416
+ "sender": sender,
417
+ "chain": "ETH",
418
+ "type": "FORGET",
419
+ "item_hash": hashlib.sha256(item_content.encode("utf-8")).hexdigest(),
420
+ "item_type": "inline",
421
+ "item_content": item_content,
422
+ "time": now_seconds,
423
+ "channel": channel,
424
+ }
425
+ return broadcast_aleph_message(api_host, sign_aleph_message(unsigned, private_key))
426
+
427
+
428
+ def main() -> None:
429
+ env_values = parse_env_file(ENV_FILE)
430
+ publisher_private_key = env_values.get("ALEPH_BOOTSTRAP_PUBLISHER_PRIVATE_KEY", "").strip()
431
+ registration_id = env_values.get("ALEPH_BOOTSTRAP_REGISTRATION_ID", "").strip() or None
432
+ if not publisher_private_key:
433
+ print(json_dumps({"status": "skipped", "reason": "missing publisher key"}))
434
+ return
435
+
436
+ describe = subprocess.run([DESCRIBE_SCRIPT], check=True, capture_output=True, text=True)
437
+ metadata = json.loads(describe.stdout.strip() or "{}")
438
+ peer_id = metadata.get("peer_id")
439
+ if not isinstance(peer_id, str) or not peer_id.strip():
440
+ raise SystemExit("unable to discover relay peer ID from describe script")
441
+
442
+ multiaddrs = filter_public_multiaddrs(metadata.get("probe_multiaddrs") or [])
443
+ browser_multiaddrs = filter_public_multiaddrs(
444
+ metadata.get("browser_bootstrap_multiaddrs") or [], browser_only=True
445
+ )
446
+ if not multiaddrs:
447
+ print(json_dumps({"status": "skipped", "reason": "no public multiaddrs"}))
448
+ return
449
+
450
+ publisher_address = address_from_private_key(publisher_private_key)
451
+ now_ms = int(time.time() * 1000)
452
+ now_seconds = now_ms / 1000
453
+ profile = env_values.get("ALEPH_BOOTSTRAP_PROFILE", DEFAULT_PROFILE).strip() or DEFAULT_PROFILE
454
+ version = env_values.get("ALEPH_BOOTSTRAP_VERSION", "").strip() or None
455
+ channel = env_values.get("ALEPH_BOOTSTRAP_CHANNEL", DEFAULT_CHANNEL).strip() or DEFAULT_CHANNEL
456
+ ref = env_values.get("ALEPH_BOOTSTRAP_REF", DEFAULT_REF).strip() or DEFAULT_REF
457
+ post_type = env_values.get("ALEPH_BOOTSTRAP_POST_TYPE", DEFAULT_POST_TYPE).strip() or DEFAULT_POST_TYPE
458
+ api_host = env_values.get("ALEPH_BOOTSTRAP_API_HOST", DEFAULT_API_HOST).strip() or DEFAULT_API_HOST
459
+
460
+ owner_authorization = load_owner_authorization(
461
+ env_values,
462
+ publisher_address,
463
+ peer_id,
464
+ registration_id,
465
+ profile,
466
+ version,
467
+ now_ms,
468
+ )
469
+ if owner_authorization is None:
470
+ print(json_dumps({"status": "skipped", "reason": "missing owner authorization"}))
471
+ return
472
+
473
+ proof_payload = relay_proof_payload(
474
+ peer_id,
475
+ multiaddrs,
476
+ browser_multiaddrs,
477
+ registration_id,
478
+ profile,
479
+ version,
480
+ now_ms,
481
+ )
482
+ relay_proof = {
483
+ "scheme": "personal_sign",
484
+ "payload": proof_payload,
485
+ "signature": sign_personal_message(publisher_private_key, json_dumps(proof_payload)),
486
+ }
487
+ post_content = build_post_content(
488
+ publisher_address,
489
+ peer_id,
490
+ multiaddrs,
491
+ browser_multiaddrs,
492
+ registration_id,
493
+ profile,
494
+ version,
495
+ owner_authorization,
496
+ relay_proof,
497
+ now_ms,
498
+ ref,
499
+ post_type,
500
+ )
501
+ item_content = json_dumps(post_content)
502
+ unsigned_message = build_unsigned_message(
503
+ publisher_address, item_content, now_seconds, channel
504
+ )
505
+ signed_message = sign_aleph_message(unsigned_message, publisher_private_key)
506
+ http_status, response = broadcast_aleph_message(api_host, signed_message)
507
+
508
+ previous_hashes = fetch_previous_hashes(
509
+ api_host,
510
+ channel,
511
+ ref,
512
+ post_type,
513
+ publisher_address,
514
+ registration_id,
515
+ str(unsigned_message["item_hash"]),
516
+ )
517
+ forget_response = broadcast_forget(
518
+ api_host, publisher_address, publisher_private_key, previous_hashes, channel
519
+ )
520
+
521
+ print(
522
+ json_dumps(
523
+ {
524
+ "status": "published",
525
+ "httpStatus": http_status,
526
+ "itemHash": unsigned_message["item_hash"],
527
+ "sender": publisher_address,
528
+ "peerId": peer_id,
529
+ "publishedMultiaddrs": multiaddrs,
530
+ "publishedBrowserMultiaddrs": browser_multiaddrs,
531
+ "forgottenHashes": previous_hashes,
532
+ "forgetResponse": forget_response[1] if forget_response else None,
533
+ "response": response,
534
+ }
535
+ )
536
+ )
537
+
538
+
539
+ if __name__ == "__main__":
540
+ try:
541
+ main()
542
+ except Exception as error: # pragma: no cover - runtime error path
543
+ print(json_dumps({"status": "error", "error": str(error)}), file=sys.stderr)
544
+ raise