@le-space/rootfs 0.1.4 → 0.1.6

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,195 @@
1
+ #!/usr/bin/env python3
2
+ import json
3
+ import os
4
+ import re
5
+ import subprocess
6
+ import time
7
+
8
+
9
+ ENV_FILE = os.environ.get("ENV_FILE", "/etc/default/uc-go-peer")
10
+ SERVICE_NAME = os.environ.get("SERVICE_NAME", "uc-go-peer.service")
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
+ PEER_ID_PATTERNS = [
16
+ re.compile(r"PeerID:\s+(\S+)"),
17
+ re.compile(r"Host created with PeerID:\s+(\S+)"),
18
+ ]
19
+ LISTENING_PATTERN = re.compile(r"Listening on:\s+(\S+)/p2p/(\S+)")
20
+
21
+
22
+ def parse_env_file(path: str) -> dict[str, str]:
23
+ values: dict[str, str] = {}
24
+ if not os.path.exists(path):
25
+ return values
26
+
27
+ with open(path, encoding="utf-8") as handle:
28
+ for line in handle:
29
+ stripped = line.strip()
30
+ if not stripped or stripped.startswith("#") or "=" not in stripped:
31
+ continue
32
+ key, value = stripped.split("=", 1)
33
+ values[key.strip()] = value.strip()
34
+ return values
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 append_peer_id(addr: str, peer_id: str) -> str:
48
+ return addr if "/p2p/" in addr else f"{addr}/p2p/{peer_id}"
49
+
50
+
51
+ def parse_logs() -> tuple[str | None, list[str]]:
52
+ result = subprocess.run(
53
+ ["journalctl", "-u", SERVICE_NAME, "-n", "500", "--no-pager"],
54
+ capture_output=True,
55
+ text=True,
56
+ check=False,
57
+ )
58
+ output = result.stdout or ""
59
+
60
+ peer_id = None
61
+ for pattern in PEER_ID_PATTERNS:
62
+ match = pattern.search(output)
63
+ if match:
64
+ peer_id = match.group(1)
65
+ break
66
+
67
+ listening_addrs = []
68
+ for addr, logged_peer_id in LISTENING_PATTERN.findall(output):
69
+ if peer_id is None:
70
+ peer_id = logged_peer_id
71
+ listening_addrs.append(addr)
72
+
73
+ return peer_id, dedupe(listening_addrs)
74
+
75
+
76
+ def build_probe_multiaddrs(env_values: dict[str, str], peer_id: str, listening_addrs: list[str]) -> dict[str, list[str]]:
77
+ announce_addrs = [
78
+ entry.strip()
79
+ for entry in env_values.get("LIBP2P_ANNOUNCE_ADDRS", "").split(",")
80
+ if entry.strip()
81
+ ]
82
+ probe_multiaddrs: list[str] = []
83
+ direct_tcp_multiaddrs: list[str] = []
84
+ autotls_multiaddrs: list[str] = []
85
+ proxy_multiaddrs: list[str] = []
86
+ webtransport_multiaddrs: list[str] = []
87
+ webrtc_direct_multiaddrs: list[str] = []
88
+
89
+ for addr in announce_addrs:
90
+ if "/tcp/" in addr and "/tls/" not in addr and "/ws" not in addr:
91
+ direct_tcp_multiaddrs.append(append_peer_id(addr, peer_id))
92
+
93
+ ws_port = env_values.get("EXTERNAL_RELAY_WS_PORT", "").strip()
94
+ for addr in listening_addrs:
95
+ if "/tls/" not in addr or not addr.endswith("/ws"):
96
+ continue
97
+
98
+ dns_match = re.search(r"/dns[46]/([^/]+)/tcp/(\d+)/tls/ws$", addr)
99
+ if dns_match:
100
+ host = dns_match.group(1)
101
+ autotls_multiaddrs.append(f"/dns4/{host}/tcp/{ws_port or dns_match.group(2)}/tls/ws/p2p/{peer_id}")
102
+ autotls_multiaddrs.append(f"/dns6/{host}/tcp/{ws_port or dns_match.group(2)}/tls/ws/p2p/{peer_id}")
103
+ continue
104
+
105
+ sni_match = re.search(r"/tls/sni/([^/]+)/ws$", addr)
106
+ if sni_match:
107
+ host = sni_match.group(1)
108
+ if ws_port:
109
+ autotls_multiaddrs.append(f"/dns4/{host}/tcp/{ws_port}/tls/ws/p2p/{peer_id}")
110
+ autotls_multiaddrs.append(f"/dns6/{host}/tcp/{ws_port}/tls/ws/p2p/{peer_id}")
111
+
112
+ proxy_hostname = env_values.get("PROXY_HOSTNAME", "").strip()
113
+ if proxy_hostname:
114
+ proxy_multiaddrs.append(f"/dns4/{proxy_hostname}/tcp/443/tls/ws/p2p/{peer_id}")
115
+ proxy_multiaddrs.append(f"/dns6/{proxy_hostname}/tcp/443/tls/ws/p2p/{peer_id}")
116
+
117
+ public_ipv4 = env_values.get("PUBLIC_IPV4", "").strip()
118
+ public_ipv6 = env_values.get("PUBLIC_IPV6", "").strip()
119
+ udp_port = env_values.get("EXTERNAL_RELAY_UDP_PORT", "").strip()
120
+ if udp_port:
121
+ if public_ipv4:
122
+ webtransport_multiaddrs.append(f"/ip4/{public_ipv4}/udp/{udp_port}/quic-v1/webtransport/p2p/{peer_id}")
123
+ webrtc_direct_multiaddrs.append(f"/ip4/{public_ipv4}/udp/{udp_port}/webrtc-direct/p2p/{peer_id}")
124
+ if public_ipv6:
125
+ webtransport_multiaddrs.append(f"/ip6/{public_ipv6}/udp/{udp_port}/quic-v1/webtransport/p2p/{peer_id}")
126
+ webrtc_direct_multiaddrs.append(f"/ip6/{public_ipv6}/udp/{udp_port}/webrtc-direct/p2p/{peer_id}")
127
+
128
+ probe_multiaddrs.extend(direct_tcp_multiaddrs)
129
+ probe_multiaddrs.extend(autotls_multiaddrs)
130
+ probe_multiaddrs.extend(proxy_multiaddrs)
131
+
132
+ return {
133
+ "direct_tcp_multiaddrs": dedupe(direct_tcp_multiaddrs),
134
+ "autotls_wss_multiaddrs": dedupe(autotls_multiaddrs),
135
+ "proxy_wss_multiaddrs": dedupe(proxy_multiaddrs),
136
+ "webtransport_multiaddrs": dedupe(webtransport_multiaddrs),
137
+ "webrtc_direct_multiaddrs": dedupe(webrtc_direct_multiaddrs),
138
+ "browser_bootstrap_multiaddrs": dedupe(
139
+ autotls_multiaddrs + proxy_multiaddrs + webtransport_multiaddrs + webrtc_direct_multiaddrs
140
+ ),
141
+ "probe_multiaddrs": dedupe(probe_multiaddrs),
142
+ }
143
+
144
+
145
+ def main() -> None:
146
+ started_at = time.monotonic()
147
+ deadline = time.monotonic() + WAIT_TIMEOUT_SECONDS
148
+ peer_id = None
149
+ listening_addrs: list[str] = []
150
+ grouped = {
151
+ "direct_tcp_multiaddrs": [],
152
+ "autotls_wss_multiaddrs": [],
153
+ "proxy_wss_multiaddrs": [],
154
+ "webtransport_multiaddrs": [],
155
+ "webrtc_direct_multiaddrs": [],
156
+ "browser_bootstrap_multiaddrs": [],
157
+ "probe_multiaddrs": [],
158
+ }
159
+
160
+ while time.monotonic() < deadline:
161
+ env_values = parse_env_file(ENV_FILE)
162
+ peer_id, listening_addrs = parse_logs()
163
+ if not peer_id:
164
+ time.sleep(WAIT_INTERVAL_SECONDS)
165
+ continue
166
+
167
+ grouped = build_probe_multiaddrs(env_values, peer_id, listening_addrs)
168
+ proxy_hostname = env_values.get("PROXY_HOSTNAME", "").strip()
169
+ if grouped["autotls_wss_multiaddrs"]:
170
+ break
171
+ if proxy_hostname and grouped["proxy_wss_multiaddrs"] and time.monotonic() - started_at >= AUTOTLS_EXTRA_WAIT_SECONDS:
172
+ break
173
+ if not proxy_hostname and time.monotonic() - started_at >= AUTOTLS_EXTRA_WAIT_SECONDS:
174
+ break
175
+ time.sleep(WAIT_INTERVAL_SECONDS)
176
+
177
+ if not peer_id:
178
+ raise SystemExit("unable to discover relay peer ID from service logs")
179
+
180
+ env_values = parse_env_file(ENV_FILE)
181
+ payload = {
182
+ "peer_id": peer_id,
183
+ "announce_addrs": [
184
+ entry.strip()
185
+ for entry in env_values.get("LIBP2P_ANNOUNCE_ADDRS", "").split(",")
186
+ if entry.strip()
187
+ ],
188
+ "listening_addrs": listening_addrs,
189
+ **grouped,
190
+ }
191
+ print(json.dumps(payload))
192
+
193
+
194
+ if __name__ == "__main__":
195
+ main()
@@ -0,0 +1,221 @@
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/uc-go-peer")
13
+ READY_FILE = os.environ.get("READY_FILE", "/etc/default/uc-go-peer.ready")
14
+ CONFIGURE_SCRIPT = "/usr/local/sbin/uc-go-peer-configure.sh"
15
+ DESCRIBE_SCRIPT = "/usr/local/sbin/uc-go-peer-describe.py"
16
+ BOOTSTRAP_SERVICE = os.environ.get("BOOTSTRAP_SERVICE", "uc-go-peer-bootstrap.service")
17
+ METADATA_FILE = os.environ.get("METADATA_FILE", "/run/uc-go-peer-setup-metadata.json")
18
+ METADATA_ERROR_FILE = os.environ.get("METADATA_ERROR_FILE", "/run/uc-go-peer-setup-metadata.error")
19
+
20
+
21
+ def _cors_headers(handler: BaseHTTPRequestHandler) -> None:
22
+ handler.send_header("Access-Control-Allow-Origin", "*")
23
+ handler.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
24
+ handler.send_header("Access-Control-Allow-Headers", "content-type")
25
+
26
+
27
+ def _validate_port(value: object, field_name: str) -> str:
28
+ if not isinstance(value, int) or value < 1 or value > 65535:
29
+ raise ValueError(f"{field_name} must be an integer TCP/UDP port between 1 and 65535")
30
+ return str(value)
31
+
32
+
33
+ def _validate_proxy_hostname(value: object) -> str | None:
34
+ if value is None:
35
+ return None
36
+ if not isinstance(value, str):
37
+ raise ValueError("proxy_url must be a string when provided")
38
+
39
+ candidate = value.strip()
40
+ if not candidate:
41
+ return None
42
+
43
+ parsed = urlsplit(candidate if "://" in candidate else f"https://{candidate}")
44
+ if not parsed.hostname:
45
+ raise ValueError("proxy_url must include a valid hostname")
46
+ return parsed.hostname
47
+
48
+
49
+ class Handler(BaseHTTPRequestHandler):
50
+ server_version = "UcGoPeerSetup/1.0"
51
+
52
+ def _request_path(self) -> str:
53
+ return urlsplit(self.path).path
54
+
55
+ def _send_json(self, status: int, payload: dict) -> None:
56
+ body = json.dumps(payload).encode("utf-8")
57
+ self.send_response(status)
58
+ _cors_headers(self)
59
+ self.send_header("Content-Type", "application/json")
60
+ self.send_header("Content-Length", str(len(body)))
61
+ self.end_headers()
62
+ self.wfile.write(body)
63
+
64
+ def log_message(self, format: str, *args) -> None: # noqa: A003
65
+ return
66
+
67
+ def do_OPTIONS(self) -> None: # noqa: N802
68
+ self.send_response(204)
69
+ _cors_headers(self)
70
+ self.end_headers()
71
+
72
+ def do_GET(self) -> None: # noqa: N802
73
+ if self._request_path() == "/metadata":
74
+ self._handle_metadata()
75
+ return
76
+
77
+ if self._request_path() not in ("/", "/health"):
78
+ self._send_json(404, {"status": "not-found"})
79
+ return
80
+
81
+ self._send_json(
82
+ 200,
83
+ {
84
+ "status": "waiting-for-port-mapping",
85
+ "ready": os.path.exists(READY_FILE),
86
+ "env_file": ENV_FILE,
87
+ "metadata_ready": os.path.exists(METADATA_FILE),
88
+ },
89
+ )
90
+
91
+ def _handle_metadata(self) -> None:
92
+ if os.path.exists(METADATA_FILE):
93
+ with open(METADATA_FILE, encoding="utf-8") as handle:
94
+ metadata = json.load(handle)
95
+ self._send_json(200, {"status": "ready", "metadata": metadata})
96
+ threading.Thread(target=self.server.shutdown, daemon=True).start() # type: ignore[arg-type]
97
+ threading.Thread(target=_stop_bootstrap_service, daemon=True).start()
98
+ return
99
+
100
+ if os.path.exists(METADATA_ERROR_FILE):
101
+ with open(METADATA_ERROR_FILE, encoding="utf-8") as handle:
102
+ error_message = handle.read().strip() or "metadata generation failed"
103
+ self._send_json(500, {"status": "error", "error": error_message})
104
+ threading.Thread(target=self.server.shutdown, daemon=True).start() # type: ignore[arg-type]
105
+ threading.Thread(target=_stop_bootstrap_service, daemon=True).start()
106
+ return
107
+
108
+ self._send_json(202, {"status": "pending"})
109
+
110
+ def do_POST(self) -> None: # noqa: N802
111
+ if self._request_path() != "/configure":
112
+ self._send_json(404, {"status": "not-found"})
113
+ return
114
+
115
+ try:
116
+ content_length = int(self.headers.get("Content-Length", "0"))
117
+ except ValueError:
118
+ self._send_json(400, {"status": "bad-request", "error": "Invalid Content-Length"})
119
+ return
120
+
121
+ try:
122
+ payload = json.loads(self.rfile.read(content_length).decode("utf-8") or "{}")
123
+ except json.JSONDecodeError as error:
124
+ self._send_json(400, {"status": "bad-request", "error": f"Invalid JSON body: {error}"})
125
+ return
126
+
127
+ try:
128
+ public_ipv4 = str(ipaddress.ip_address(payload.get("public_ipv4")))
129
+ public_ipv6 = payload.get("public_ipv6")
130
+ if public_ipv6 is not None:
131
+ public_ipv6 = str(ipaddress.ip_address(public_ipv6))
132
+ proxy_hostname = _validate_proxy_hostname(payload.get("proxy_url"))
133
+ tcp_port = payload.get("tcp_port")
134
+ ws_port = payload.get("ws_port")
135
+ udp_port = payload.get("udp_port")
136
+ quic_port = payload.get("quic_port")
137
+ webrtc_port = payload.get("webrtc_port")
138
+ args = [
139
+ CONFIGURE_SCRIPT,
140
+ "--public-ipv4",
141
+ public_ipv4,
142
+ ]
143
+ if tcp_port is not None:
144
+ args.extend(["--tcp-port", _validate_port(tcp_port, "tcp_port")])
145
+ if ws_port is not None:
146
+ args.extend(["--ws-port", _validate_port(ws_port, "ws_port")])
147
+ if proxy_hostname is not None:
148
+ args.extend(["--proxy-hostname", proxy_hostname])
149
+ if public_ipv6 is not None:
150
+ args.extend(["--public-ipv6", public_ipv6])
151
+ udp_candidate = udp_port if udp_port is not None else quic_port
152
+ if udp_candidate is None:
153
+ udp_candidate = webrtc_port
154
+ if udp_candidate is not None:
155
+ args.extend(["--udp-port", _validate_port(udp_candidate, "udp_port")])
156
+ except ValueError as error:
157
+ self._send_json(400, {"status": "bad-request", "error": str(error)})
158
+ return
159
+
160
+ try:
161
+ result = subprocess.run(args, check=True, capture_output=True, text=True)
162
+ except subprocess.CalledProcessError as error:
163
+ self._send_json(
164
+ 500,
165
+ {
166
+ "status": "error",
167
+ "error": error.stderr.strip() or error.stdout.strip() or str(error),
168
+ },
169
+ )
170
+ return
171
+
172
+ _clear_metadata_state()
173
+ threading.Thread(target=_generate_metadata_files, daemon=True).start()
174
+
175
+ self._send_json(
176
+ 200,
177
+ {
178
+ "status": "configured",
179
+ "stdout": result.stdout.strip(),
180
+ "metadata_pending": True,
181
+ },
182
+ )
183
+
184
+
185
+ def _stop_bootstrap_service() -> None:
186
+ # Give the HTTP response a brief head start, then stop the temporary setup service.
187
+ time.sleep(1)
188
+ subprocess.run(["systemctl", "stop", BOOTSTRAP_SERVICE], check=False)
189
+
190
+
191
+ def _clear_metadata_state() -> None:
192
+ for path in (METADATA_FILE, METADATA_ERROR_FILE):
193
+ try:
194
+ os.remove(path)
195
+ except FileNotFoundError:
196
+ pass
197
+
198
+
199
+ def _generate_metadata_files() -> None:
200
+ try:
201
+ describe = subprocess.run(
202
+ [DESCRIBE_SCRIPT],
203
+ check=True,
204
+ capture_output=True,
205
+ text=True,
206
+ )
207
+ payload = json.loads(describe.stdout.strip() or "{}")
208
+ with open(METADATA_FILE, "w", encoding="utf-8") as handle:
209
+ json.dump(payload, handle)
210
+ except (subprocess.CalledProcessError, json.JSONDecodeError) as error:
211
+ with open(METADATA_ERROR_FILE, "w", encoding="utf-8") as handle:
212
+ handle.write(str(error))
213
+
214
+
215
+ def main() -> None:
216
+ server = ThreadingHTTPServer(("0.0.0.0", 80), Handler)
217
+ server.serve_forever()
218
+
219
+
220
+ if __name__ == "__main__":
221
+ main()
@@ -0,0 +1,19 @@
1
+ [Unit]
2
+ Description=universal-connectivity go-peer relay node
3
+ Wants=network-online.target
4
+ After=network-online.target
5
+ ConditionPathExists=/etc/default/uc-go-peer.ready
6
+
7
+ [Service]
8
+ Type=simple
9
+ User=uc-go-peer
10
+ Group=uc-go-peer
11
+ WorkingDirectory=/var/lib/uc-go-peer
12
+ EnvironmentFile=-/etc/default/uc-go-peer
13
+ ExecStart=/bin/sh -lc 'exec /usr/local/bin/universal-chat-go -headless -identity "${GO_PEER_IDENTITY_PATH:-/var/lib/uc-go-peer/identity.key}" -port "${GO_PEER_TCP_PORT:-9095}" -ws-port "${GO_PEER_WS_PORT:-9096}" -wss-port "${GO_PEER_WSS_PORT:-9097}"'
14
+ Restart=always
15
+ RestartSec=5
16
+ NoNewPrivileges=true
17
+
18
+ [Install]
19
+ WantedBy=multi-user.target