@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 +2 -0
- package/index.js +3 -0
- package/package.json +1 -1
- package/reference/orbitdb-relay/rootfs/__pycache__/orbitdb-relay-autotls-refresh.cpython-314.pyc +0 -0
- package/reference/orbitdb-relay/rootfs/orbitdb-relay-autotls-refresh.py +9 -2
- package/reference/orbitdb-relay/rootfs/orbitdb-relay-bootstrap.sh +20 -1
- package/reference/orbitdb-relay/rootfs/orbitdb-relay-configure.sh +57 -4
- package/reference/orbitdb-relay/rootfs/orbitdb-relay-describe.py +24 -0
- package/reference/orbitdb-relay/rootfs/orbitdb-relay-setup-server.py +354 -4
- package/reference/uc-go-peer/rootfs/__pycache__/uc-go-peer-describe.cpython-312.pyc +0 -0
- package/reference/uc-go-peer/rootfs/__pycache__/uc-go-peer-describe.cpython-314.pyc +0 -0
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
package/reference/orbitdb-relay/rootfs/__pycache__/orbitdb-relay-autotls-refresh.cpython-314.pyc
ADDED
|
Binary file
|
|
@@ -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/{
|
|
144
|
-
addrs.append(f"/dns6/{host6}/tcp/{
|
|
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
|
-
|
|
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/${
|
|
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/${
|
|
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/${
|
|
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/${
|
|
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.
|
|
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
|
|
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
|
|
|
Binary file
|