@le-space/rootfs 0.1.52 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,165 @@
1
+ #!/usr/bin/env python3
2
+ import json
3
+ import os
4
+ import time
5
+ import urllib.error
6
+ import urllib.request
7
+
8
+
9
+ ENV_FILE = os.environ.get("ENV_FILE", "/etc/default/orbitdb-relay-pinner")
10
+ METRICS_PORT = int(os.environ.get("METRICS_PORT", "9090"))
11
+ WAIT_TIMEOUT_SECONDS = int(os.environ.get("DESCRIBE_WAIT_TIMEOUT_SECONDS", "240"))
12
+ WAIT_INTERVAL_SECONDS = float(os.environ.get("DESCRIBE_WAIT_INTERVAL_SECONDS", "2"))
13
+ AUTOTLS_EXTRA_WAIT_SECONDS = int(os.environ.get("DESCRIBE_AUTOTLS_EXTRA_WAIT_SECONDS", "120"))
14
+
15
+
16
+ def parse_env_file(path: str) -> dict[str, str]:
17
+ values: dict[str, str] = {}
18
+ if not os.path.exists(path):
19
+ return values
20
+
21
+ with open(path, encoding="utf-8") as handle:
22
+ for line in handle:
23
+ stripped = line.strip()
24
+ if not stripped or stripped.startswith("#") or "=" not in stripped:
25
+ continue
26
+ key, value = stripped.split("=", 1)
27
+ values[key.strip()] = value.strip()
28
+ return values
29
+
30
+
31
+ def fetch_json(path: str) -> dict:
32
+ url = f"http://127.0.0.1:{METRICS_PORT}{path}"
33
+ with urllib.request.urlopen(url, timeout=5) as response:
34
+ return json.loads(response.read().decode("utf-8"))
35
+
36
+
37
+ def dedupe(values: list[str]) -> list[str]:
38
+ seen: set[str] = set()
39
+ result: list[str] = []
40
+ for value in values:
41
+ if value and value not in seen:
42
+ seen.add(value)
43
+ result.append(value)
44
+ return result
45
+
46
+
47
+ def normalize_multiaddrs(payload: dict) -> list[str]:
48
+ values = payload.get("all")
49
+ if not isinstance(values, list):
50
+ return []
51
+ return [entry for entry in values if isinstance(entry, str) and entry.strip()]
52
+
53
+
54
+ def build_grouped_multiaddrs(env_values: dict[str, str], all_multiaddrs: list[str], peer_id: str) -> dict[str, list[str]]:
55
+ proxy_hostname = env_values.get("PROXY_HOSTNAME", "").strip().lower()
56
+ direct_tcp_multiaddrs = [addr for addr in all_multiaddrs if "/tcp/" in addr and "/ws" not in addr]
57
+ autotls_wss_multiaddrs = [
58
+ addr
59
+ for addr in all_multiaddrs
60
+ if addr.endswith("/ws/p2p/" + peer_id) and "/tls/ws" in addr and proxy_hostname not in addr.lower()
61
+ ]
62
+ plain_ws_multiaddrs = [
63
+ addr for addr in all_multiaddrs if addr.endswith("/ws/p2p/" + peer_id) and "/tls/ws" not in addr
64
+ ]
65
+ proxy_wss_multiaddrs: list[str] = []
66
+ if proxy_hostname:
67
+ proxy_wss_multiaddrs = [
68
+ f"/dns4/{proxy_hostname}/tcp/443/tls/ws/p2p/{peer_id}",
69
+ f"/dns6/{proxy_hostname}/tcp/443/tls/ws/p2p/{peer_id}",
70
+ ]
71
+ quic_multiaddrs = [addr for addr in all_multiaddrs if "/quic-v1" in addr and "/webtransport" not in addr]
72
+ webtransport_multiaddrs = [addr for addr in all_multiaddrs if "/webtransport" in addr]
73
+ webrtc_direct_multiaddrs = [addr for addr in all_multiaddrs if "/webrtc-direct" in addr]
74
+
75
+ browser_bootstrap_multiaddrs = dedupe(
76
+ proxy_wss_multiaddrs
77
+ + autotls_wss_multiaddrs
78
+ + plain_ws_multiaddrs
79
+ + webtransport_multiaddrs
80
+ + webrtc_direct_multiaddrs
81
+ )
82
+ probe_multiaddrs = dedupe(
83
+ direct_tcp_multiaddrs
84
+ + proxy_wss_multiaddrs
85
+ + autotls_wss_multiaddrs
86
+ + quic_multiaddrs
87
+ + webtransport_multiaddrs
88
+ )
89
+
90
+ return {
91
+ "direct_tcp_multiaddrs": dedupe(direct_tcp_multiaddrs),
92
+ "autotls_wss_multiaddrs": dedupe(autotls_wss_multiaddrs),
93
+ "proxy_wss_multiaddrs": dedupe(proxy_wss_multiaddrs),
94
+ "plain_ws_multiaddrs": dedupe(plain_ws_multiaddrs),
95
+ "quic_multiaddrs": dedupe(quic_multiaddrs),
96
+ "webtransport_multiaddrs": dedupe(webtransport_multiaddrs),
97
+ "webrtc_direct_multiaddrs": dedupe(webrtc_direct_multiaddrs),
98
+ "browser_bootstrap_multiaddrs": browser_bootstrap_multiaddrs,
99
+ "probe_multiaddrs": probe_multiaddrs,
100
+ }
101
+
102
+
103
+ def main() -> None:
104
+ started_at = time.monotonic()
105
+ deadline = started_at + WAIT_TIMEOUT_SECONDS
106
+ health: dict = {}
107
+ multiaddrs_payload: dict = {}
108
+ grouped = {
109
+ "direct_tcp_multiaddrs": [],
110
+ "autotls_wss_multiaddrs": [],
111
+ "proxy_wss_multiaddrs": [],
112
+ "plain_ws_multiaddrs": [],
113
+ "quic_multiaddrs": [],
114
+ "webtransport_multiaddrs": [],
115
+ "webrtc_direct_multiaddrs": [],
116
+ "browser_bootstrap_multiaddrs": [],
117
+ "probe_multiaddrs": [],
118
+ }
119
+
120
+ while time.monotonic() < deadline:
121
+ try:
122
+ env_values = parse_env_file(ENV_FILE)
123
+ health = fetch_json("/health")
124
+ multiaddrs_payload = fetch_json("/multiaddrs")
125
+ peer_id = health.get("peerId") or multiaddrs_payload.get("peerId")
126
+ if not isinstance(peer_id, str) or not peer_id.strip():
127
+ time.sleep(WAIT_INTERVAL_SECONDS)
128
+ continue
129
+
130
+ all_multiaddrs = normalize_multiaddrs(multiaddrs_payload)
131
+ grouped = build_grouped_multiaddrs(env_values, all_multiaddrs, peer_id)
132
+ proxy_hostname = env_values.get("PROXY_HOSTNAME", "").strip()
133
+ if grouped["autotls_wss_multiaddrs"]:
134
+ break
135
+ if proxy_hostname and grouped["proxy_wss_multiaddrs"] and time.monotonic() - started_at >= AUTOTLS_EXTRA_WAIT_SECONDS:
136
+ break
137
+ if not proxy_hostname and time.monotonic() - started_at >= AUTOTLS_EXTRA_WAIT_SECONDS:
138
+ break
139
+ except (urllib.error.URLError, TimeoutError, json.JSONDecodeError):
140
+ pass
141
+
142
+ time.sleep(WAIT_INTERVAL_SECONDS)
143
+
144
+ peer_id = health.get("peerId") or multiaddrs_payload.get("peerId")
145
+ if not isinstance(peer_id, str) or not peer_id.strip():
146
+ raise SystemExit("unable to discover orbitdb relay peer ID from metrics endpoints")
147
+
148
+ env_values = parse_env_file(ENV_FILE)
149
+ payload = {
150
+ "peer_id": peer_id,
151
+ "announce_addrs": [
152
+ entry.strip()
153
+ for entry in env_values.get("VITE_APPEND_ANNOUNCE", "").split(",")
154
+ if entry.strip()
155
+ ],
156
+ "listening_addrs": normalize_multiaddrs(multiaddrs_payload),
157
+ "auto_tls_serving_zone": health.get("autoTlsServingZone"),
158
+ "metrics_https": health.get("metricsHttps"),
159
+ **grouped,
160
+ }
161
+ print(json.dumps(payload))
162
+
163
+
164
+ if __name__ == "__main__":
165
+ main()
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env python3
2
+ import ipaddress
3
+ import json
4
+ import os
5
+ import subprocess
6
+ import threading
7
+ import time
8
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
9
+ from urllib.parse import urlsplit
10
+
11
+
12
+ ENV_FILE = os.environ.get("ENV_FILE", "/etc/default/orbitdb-relay-pinner")
13
+ READY_FILE = os.environ.get("READY_FILE", "/etc/default/orbitdb-relay-pinner.ready")
14
+ SERVICE_NAME = os.environ.get("SERVICE_NAME", "orbitdb-relay-pinner.service")
15
+ BOOTSTRAP_SERVICE = os.environ.get("BOOTSTRAP_SERVICE", "orbitdb-relay-pinner-bootstrap.service")
16
+ CONFIGURE_SCRIPT = "/usr/local/sbin/orbitdb-relay-pinner-configure.sh"
17
+ DESCRIBE_SCRIPT = "/usr/local/sbin/orbitdb-relay-pinner-describe.py"
18
+ METADATA_FILE = os.environ.get("METADATA_FILE", "/run/orbitdb-relay-pinner-setup-metadata.json")
19
+ METADATA_ERROR_FILE = os.environ.get("METADATA_ERROR_FILE", "/run/orbitdb-relay-pinner-setup-metadata.error")
20
+
21
+
22
+ def _cors_headers(handler: BaseHTTPRequestHandler) -> None:
23
+ handler.send_header("Access-Control-Allow-Origin", "*")
24
+ handler.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
25
+ handler.send_header("Access-Control-Allow-Headers", "content-type")
26
+
27
+
28
+ def _validate_port(value: object, field_name: str) -> str:
29
+ if not isinstance(value, int) or value < 1 or value > 65535:
30
+ raise ValueError(f"{field_name} must be an integer TCP/UDP port between 1 and 65535")
31
+ return str(value)
32
+
33
+
34
+ def _validate_proxy_hostname(value: object) -> str | None:
35
+ if value is None:
36
+ return None
37
+
38
+ if not isinstance(value, str):
39
+ raise ValueError("proxy_url must be a string when provided")
40
+
41
+ candidate = value.strip()
42
+ if not candidate:
43
+ return None
44
+
45
+ parsed = urlsplit(candidate if "://" in candidate else f"https://{candidate}")
46
+ if not parsed.hostname:
47
+ raise ValueError("proxy_url must include a valid hostname")
48
+
49
+ return parsed.hostname
50
+
51
+
52
+ class Handler(BaseHTTPRequestHandler):
53
+ server_version = "OrbitdbRelaySetup/1.0"
54
+
55
+ def _request_path(self) -> str:
56
+ return urlsplit(self.path).path
57
+
58
+ def _send_json(self, status: int, payload: dict) -> None:
59
+ body = json.dumps(payload).encode("utf-8")
60
+ self.send_response(status)
61
+ _cors_headers(self)
62
+ self.send_header("Content-Type", "application/json")
63
+ self.send_header("Content-Length", str(len(body)))
64
+ self.end_headers()
65
+ self.wfile.write(body)
66
+
67
+ def log_message(self, format: str, *args) -> None: # noqa: A003
68
+ return
69
+
70
+ def do_OPTIONS(self) -> None: # noqa: N802
71
+ self.send_response(204)
72
+ _cors_headers(self)
73
+ self.end_headers()
74
+
75
+ def do_GET(self) -> None: # noqa: N802
76
+ if self._request_path() == "/metadata":
77
+ self._handle_metadata()
78
+ return
79
+
80
+ if self._request_path() not in ("/", "/health"):
81
+ self._send_json(404, {"status": "not-found"})
82
+ return
83
+
84
+ self._send_json(
85
+ 200,
86
+ {
87
+ "status": "waiting-for-port-mapping",
88
+ "ready": os.path.exists(READY_FILE),
89
+ "env_file": ENV_FILE,
90
+ "metadata_ready": os.path.exists(METADATA_FILE),
91
+ },
92
+ )
93
+
94
+ def _handle_metadata(self) -> None:
95
+ if os.path.exists(METADATA_FILE):
96
+ with open(METADATA_FILE, encoding="utf-8") as handle:
97
+ metadata = json.load(handle)
98
+ self._send_json(200, {"status": "ready", "metadata": metadata})
99
+ threading.Thread(target=self.server.shutdown, daemon=True).start() # type: ignore[arg-type]
100
+ threading.Thread(target=_stop_bootstrap_service, daemon=True).start()
101
+ return
102
+
103
+ if os.path.exists(METADATA_ERROR_FILE):
104
+ with open(METADATA_ERROR_FILE, encoding="utf-8") as handle:
105
+ error_message = handle.read().strip() or "metadata generation failed"
106
+ self._send_json(500, {"status": "error", "error": error_message})
107
+ threading.Thread(target=self.server.shutdown, daemon=True).start() # type: ignore[arg-type]
108
+ threading.Thread(target=_stop_bootstrap_service, daemon=True).start()
109
+ return
110
+
111
+ self._send_json(202, {"status": "pending"})
112
+
113
+ def do_POST(self) -> None: # noqa: N802
114
+ if self._request_path() != "/configure":
115
+ self._send_json(404, {"status": "not-found"})
116
+ return
117
+
118
+ try:
119
+ content_length = int(self.headers.get("Content-Length", "0"))
120
+ except ValueError:
121
+ self._send_json(400, {"status": "bad-request", "error": "Invalid Content-Length"})
122
+ return
123
+
124
+ try:
125
+ payload = json.loads(self.rfile.read(content_length).decode("utf-8") or "{}")
126
+ except json.JSONDecodeError as error:
127
+ self._send_json(400, {"status": "bad-request", "error": f"Invalid JSON body: {error}"})
128
+ return
129
+
130
+ try:
131
+ public_ipv4 = str(ipaddress.ip_address(payload.get("public_ipv4")))
132
+ public_ipv6 = payload.get("public_ipv6")
133
+ if public_ipv6 is not None:
134
+ public_ipv6 = str(ipaddress.ip_address(public_ipv6))
135
+ tcp_port = _validate_port(payload.get("tcp_port"), "tcp_port")
136
+ ws_port = _validate_port(payload.get("ws_port"), "ws_port")
137
+ proxy_hostname = _validate_proxy_hostname(payload.get("proxy_url"))
138
+ metrics_port = payload.get("metrics_port")
139
+ metrics_https_port = payload.get("metrics_https_port")
140
+ webrtc_port = payload.get("webrtc_port")
141
+ quic_port = payload.get("quic_port")
142
+ args = [
143
+ CONFIGURE_SCRIPT,
144
+ "--public-ipv4",
145
+ public_ipv4,
146
+ "--tcp-port",
147
+ tcp_port,
148
+ "--ws-port",
149
+ ws_port,
150
+ ]
151
+ if proxy_hostname is not None:
152
+ args.extend(["--proxy-hostname", proxy_hostname])
153
+ if public_ipv6 is not None:
154
+ args.extend(["--public-ipv6", public_ipv6])
155
+ if metrics_port is not None:
156
+ args.extend(["--metrics-port", _validate_port(metrics_port, "metrics_port")])
157
+ if metrics_https_port is not None:
158
+ args.extend(
159
+ [
160
+ "--metrics-https-port",
161
+ _validate_port(metrics_https_port, "metrics_https_port"),
162
+ ]
163
+ )
164
+ if webrtc_port is not None:
165
+ args.extend(["--webrtc-port", _validate_port(webrtc_port, "webrtc_port")])
166
+ if quic_port is not None:
167
+ args.extend(["--quic-port", _validate_port(quic_port, "quic_port")])
168
+ except ValueError as error:
169
+ self._send_json(400, {"status": "bad-request", "error": str(error)})
170
+ return
171
+
172
+ try:
173
+ result = subprocess.run(
174
+ args,
175
+ check=True,
176
+ capture_output=True,
177
+ text=True,
178
+ )
179
+ except subprocess.CalledProcessError as error:
180
+ self._send_json(
181
+ 500,
182
+ {
183
+ "status": "error",
184
+ "error": error.stderr.strip() or error.stdout.strip() or str(error),
185
+ },
186
+ )
187
+ return
188
+
189
+ _clear_metadata_state()
190
+ threading.Thread(target=_generate_metadata_files, daemon=True).start()
191
+
192
+ self._send_json(
193
+ 200,
194
+ {
195
+ "status": "configured",
196
+ "stdout": result.stdout.strip(),
197
+ "metadata_pending": True,
198
+ },
199
+ )
200
+
201
+
202
+ def _stop_bootstrap_service() -> None:
203
+ time.sleep(1)
204
+ subprocess.run(["systemctl", "stop", BOOTSTRAP_SERVICE], check=False)
205
+
206
+
207
+ def _clear_metadata_state() -> None:
208
+ for path in (METADATA_FILE, METADATA_ERROR_FILE):
209
+ try:
210
+ os.remove(path)
211
+ except FileNotFoundError:
212
+ pass
213
+
214
+
215
+ def _generate_metadata_files() -> None:
216
+ try:
217
+ describe = subprocess.run(
218
+ [DESCRIBE_SCRIPT],
219
+ check=True,
220
+ capture_output=True,
221
+ text=True,
222
+ )
223
+ payload = json.loads(describe.stdout.strip() or "{}")
224
+ with open(METADATA_FILE, "w", encoding="utf-8") as handle:
225
+ json.dump(payload, handle)
226
+ except (subprocess.CalledProcessError, json.JSONDecodeError) as error:
227
+ with open(METADATA_ERROR_FILE, "w", encoding="utf-8") as handle:
228
+ handle.write(str(error))
229
+
230
+
231
+ def main() -> None:
232
+ server = ThreadingHTTPServer(("0.0.0.0", 80), Handler)
233
+ server.serve_forever()
234
+
235
+
236
+ if __name__ == "__main__":
237
+ main()
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env python3
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import shlex
8
+ import sys
9
+ from pathlib import Path
10
+
11
+
12
+ def shell_assign(name: str, value: str) -> str:
13
+ return f"{name}={shlex.quote(value)}"
14
+
15
+
16
+ def main() -> int:
17
+ parser = argparse.ArgumentParser(description="Read a relay rootfs contract and emit shell exports.")
18
+ parser.add_argument("contract", type=Path, help="Path to the rootfs contract JSON file")
19
+ args = parser.parse_args()
20
+
21
+ try:
22
+ payload = json.loads(args.contract.read_text())
23
+ except FileNotFoundError:
24
+ print(f"Missing rootfs contract: {args.contract}", file=sys.stderr)
25
+ return 1
26
+ except json.JSONDecodeError as exc:
27
+ print(f"Invalid rootfs contract JSON in {args.contract}: {exc}", file=sys.stderr)
28
+ return 1
29
+
30
+ rootfs = payload.get("rootfs") or {}
31
+ services = payload.get("services") or {}
32
+ source = payload.get("source") or {}
33
+ manifest = payload.get("manifest") or {}
34
+ ports = payload.get("ports") or []
35
+
36
+ profile = rootfs.get("profile")
37
+ install_mode = rootfs.get("installMode")
38
+
39
+ if not isinstance(profile, str) or not profile.strip():
40
+ print("Rootfs contract is missing rootfs.profile", file=sys.stderr)
41
+ return 1
42
+ if not isinstance(install_mode, str) or not install_mode.strip():
43
+ print("Rootfs contract is missing rootfs.installMode", file=sys.stderr)
44
+ return 1
45
+ if not isinstance(ports, list):
46
+ print("Rootfs contract field ports must be a list", file=sys.stderr)
47
+ return 1
48
+
49
+ lines = [
50
+ shell_assign("ROOTFS_CONTRACT_PATH", str(args.contract.resolve())),
51
+ shell_assign("ROOTFS_CONTRACT_ID", str(payload.get("id", ""))),
52
+ shell_assign("ROOTFS_CONTRACT_PROFILE", profile.strip()),
53
+ shell_assign("ROOTFS_CONTRACT_INSTALL_MODE", install_mode.strip()),
54
+ shell_assign("ROOTFS_CONTRACT_SOURCE_SUBDIRECTORY", str(source.get("subdirectory", ""))),
55
+ shell_assign("ROOTFS_CONTRACT_INSTALL_DIR", str(rootfs.get("installDir", ""))),
56
+ shell_assign("ROOTFS_CONTRACT_BINARY_PATH", str(rootfs.get("binaryPath", "/usr/local/bin/universal-chat-go"))),
57
+ shell_assign("ROOTFS_CONTRACT_DATA_DIR", str(rootfs.get("dataDir", ""))),
58
+ shell_assign("ROOTFS_CONTRACT_ENV_FILE", str(rootfs.get("envFile", ""))),
59
+ shell_assign("ROOTFS_CONTRACT_MAIN_SERVICE", str(services.get("main", ""))),
60
+ shell_assign("ROOTFS_CONTRACT_BOOTSTRAP_SERVICE", str(services.get("bootstrap", ""))),
61
+ shell_assign("ROOTFS_CONTRACT_AUTOTLS_SERVICE", str(services.get("autotlsRefresh", ""))),
62
+ shell_assign("ROOTFS_CONTRACT_MANIFEST_COPY_TARGET", str(manifest.get("copyTarget", ""))),
63
+ shell_assign("ROOTFS_CONTRACT_MANIFEST_NOTES", str(manifest.get("notes", ""))),
64
+ shell_assign("ROOTFS_CONTRACT_PORT_FORWARDS_JSON", json.dumps(ports, separators=(",", ":"))),
65
+ ]
66
+
67
+ print("\n".join(lines))
68
+ return 0
69
+
70
+
71
+ if __name__ == "__main__":
72
+ raise SystemExit(main())