@le-space/rootfs 0.7.0 → 0.9.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.
- package/package.json +1 -1
- package/reference/orbitdb-relay/contract.json +6 -0
- package/reference/orbitdb-relay/rootfs/build-rootfs-image.sh +2 -0
- package/reference/orbitdb-relay/rootfs/build-rootfs.sh +1 -0
- package/reference/orbitdb-relay/rootfs/orbitdb-relay-autotls-refresh.py +88 -15
- package/reference/orbitdb-relay/rootfs/orbitdb-relay-autotls-refresh.timer +22 -0
- package/reference/uc-go-peer/rootfs/__pycache__/uc-go-peer-describe.cpython-312.pyc +0 -0
- package/reference/uc-go-peer/rootfs/uc-go-peer-configure.sh +17 -0
- package/reference/uc-go-peer/rootfs/uc-go-peer-setup-server.py +26 -0
package/package.json
CHANGED
|
@@ -245,6 +245,7 @@ case "${ROOTFS_PROFILE}" in
|
|
|
245
245
|
--copy-in "${SCRIPT_DIR}/orbitdb-relay-bootstrap-refresh.timer:/etc/systemd/system"
|
|
246
246
|
--copy-in "${SCRIPT_DIR}/orbitdb-relay-bootstrap-deregister.service:/etc/systemd/system"
|
|
247
247
|
--copy-in "${SCRIPT_DIR}/orbitdb-relay-autotls-refresh.service:/etc/systemd/system"
|
|
248
|
+
--copy-in "${SCRIPT_DIR}/orbitdb-relay-autotls-refresh.timer:/etc/systemd/system"
|
|
248
249
|
--copy-in "${SCRIPT_DIR}/orbitdb-relay-bootstrap.conf:/etc/systemd/system/orbitdb-relay.service.d"
|
|
249
250
|
--run-command "tar -xf /opt/$(basename "${APP_TAR}") -C /opt/orbitdb-relay"
|
|
250
251
|
--run-command "chmod 0755 /usr/local/sbin/orbitdb-relay-bootstrap.sh"
|
|
@@ -259,6 +260,7 @@ case "${ROOTFS_PROFILE}" in
|
|
|
259
260
|
|
|
260
261
|
orbitdb_customize_args+=(
|
|
261
262
|
--run-command "systemctl enable orbitdb-relay-bootstrap.service"
|
|
263
|
+
--run-command "systemctl enable orbitdb-relay-autotls-refresh.timer"
|
|
262
264
|
--run-command "systemctl enable orbitdb-relay.service"
|
|
263
265
|
--run-command "rm -f /opt/$(basename "${APP_TAR}")"
|
|
264
266
|
)
|
|
@@ -254,6 +254,7 @@ required_port_forwards_json() {
|
|
|
254
254
|
{ "port": 80, "tcp": true, "udp": false, "purpose": "Temporary setup endpoint" },
|
|
255
255
|
{ "port": 9090, "tcp": true, "udp": false, "purpose": "Metrics and health API" },
|
|
256
256
|
{ "port": 9091, "tcp": true, "udp": false, "purpose": "libp2p TCP" },
|
|
257
|
+
{ "port": 9092, "tcp": true, "udp": false, "purpose": "libp2p WebSocket and AutoTLS" },
|
|
257
258
|
{ "port": 443, "tcp": true, "udp": false, "purpose": "Caddy HTTPS and WSS proxy" },
|
|
258
259
|
{ "port": 9093, "tcp": false, "udp": true, "purpose": "WebRTC direct" },
|
|
259
260
|
{ "port": 9094, "tcp": false, "udp": true, "purpose": "QUIC" }
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
import json
|
|
3
3
|
import os
|
|
4
|
+
import socket
|
|
5
|
+
import ssl
|
|
4
6
|
import subprocess
|
|
5
7
|
import time
|
|
6
8
|
import urllib.error
|
|
@@ -24,6 +26,7 @@ CADDYFILE = os.environ.get("CADDYFILE", "/etc/caddy/Caddyfile")
|
|
|
24
26
|
METRICS_PORT = int(os.environ.get("METRICS_PORT", "9090"))
|
|
25
27
|
WAIT_TIMEOUT_SECONDS = int(os.environ.get("AUTOTLS_WAIT_TIMEOUT_SECONDS", "900"))
|
|
26
28
|
WAIT_INTERVAL_SECONDS = float(os.environ.get("AUTOTLS_WAIT_INTERVAL_SECONDS", "5"))
|
|
29
|
+
TLS_PROBE_TIMEOUT_SECONDS = float(os.environ.get("AUTOTLS_TLS_PROBE_TIMEOUT_SECONDS", "5"))
|
|
27
30
|
|
|
28
31
|
|
|
29
32
|
def fetch_json(path: str) -> dict:
|
|
@@ -98,6 +101,43 @@ def ipv6_domain(ipv6: str, zone: str) -> str:
|
|
|
98
101
|
return f"{subdomain}.{zone}"
|
|
99
102
|
|
|
100
103
|
|
|
104
|
+
def listener_presents_certificate(port: str, server_name: str) -> bool:
|
|
105
|
+
"""Does the local WebSocket listener actually terminate TLS?
|
|
106
|
+
|
|
107
|
+
This is the only question that matters before announcing a `/tls/...`
|
|
108
|
+
address, and until this existed nothing asked it. The previous gate read
|
|
109
|
+
`/multiaddrs` from the metrics endpoint and looked for a `/tls/ws` entry -
|
|
110
|
+
but that list is the *announce* list, which is what this script writes. It
|
|
111
|
+
confirmed its own output from the run before, so it passed on a relay whose
|
|
112
|
+
WebSocket port had never spoken TLS in its life.
|
|
113
|
+
|
|
114
|
+
The result on a live relay: `/ip4/.../tcp/52194/tls/sni/<host>/ws` announced
|
|
115
|
+
for a port that answers a plaintext handshake with `101 Switching
|
|
116
|
+
Protocols`. Browsers on an https origin got ERR_SSL_PROTOCOL_ERROR, and
|
|
117
|
+
every peer paid a TLS timeout on every connect
|
|
118
|
+
(see NiKrause/simple-todo#154).
|
|
119
|
+
|
|
120
|
+
Opening a socket cannot be fooled that way.
|
|
121
|
+
"""
|
|
122
|
+
context = ssl.create_default_context()
|
|
123
|
+
# Asking whether *a* certificate is served, not whether it validates: the
|
|
124
|
+
# probe runs against loopback while the certificate is issued for the public
|
|
125
|
+
# AutoTLS hostname, so a chain check would fail on a healthy relay.
|
|
126
|
+
context.check_hostname = False
|
|
127
|
+
context.verify_mode = ssl.CERT_NONE
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
with socket.create_connection(("127.0.0.1", int(port)), TLS_PROBE_TIMEOUT_SECONDS) as raw:
|
|
131
|
+
# SNI is not optional. Without `server_hostname` a name-based TLS
|
|
132
|
+
# server has nothing to select a certificate by and aborts the
|
|
133
|
+
# handshake - which made an earlier version of this probe report
|
|
134
|
+
# "no TLS" for Caddy on 443, a port that demonstrably serves one.
|
|
135
|
+
with context.wrap_socket(raw, server_hostname=server_name) as tls:
|
|
136
|
+
return tls.getpeercert(binary_form=True) is not None
|
|
137
|
+
except (OSError, ssl.SSLError, ValueError):
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
|
|
101
141
|
def wait_for_autotls_zone() -> str:
|
|
102
142
|
deadline = time.monotonic() + WAIT_TIMEOUT_SECONDS
|
|
103
143
|
last_error = "metrics endpoint never became ready"
|
|
@@ -105,17 +145,10 @@ def wait_for_autotls_zone() -> str:
|
|
|
105
145
|
while time.monotonic() < deadline:
|
|
106
146
|
try:
|
|
107
147
|
health = fetch_json("/health")
|
|
108
|
-
payload = fetch_json("/multiaddrs")
|
|
109
148
|
zone = health.get("autoTlsServingZone")
|
|
110
|
-
|
|
111
|
-
if (
|
|
112
|
-
isinstance(zone, str)
|
|
113
|
-
and zone
|
|
114
|
-
and isinstance(all_addrs, list)
|
|
115
|
-
and any(isinstance(addr, str) and "/tls/ws" in addr for addr in all_addrs)
|
|
116
|
-
):
|
|
149
|
+
if isinstance(zone, str) and zone:
|
|
117
150
|
return zone
|
|
118
|
-
last_error = "AutoTLS
|
|
151
|
+
last_error = "AutoTLS serving zone not published yet"
|
|
119
152
|
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as error:
|
|
120
153
|
last_error = str(error)
|
|
121
154
|
|
|
@@ -187,14 +220,40 @@ def main() -> None:
|
|
|
187
220
|
|
|
188
221
|
env_values = parse_env_file(ENV_FILE)
|
|
189
222
|
zone = wait_for_autotls_zone()
|
|
190
|
-
|
|
191
|
-
|
|
223
|
+
|
|
224
|
+
# Announce a TLS address only where something terminates TLS. The relay
|
|
225
|
+
# listens plaintext on RELAY_WS_PORT and Caddy terminates on 443; if AutoTLS
|
|
226
|
+
# has not taken over that listener, the libp2p.direct addresses would be a
|
|
227
|
+
# promise the port cannot keep. Caddy's own 443 address is written elsewhere
|
|
228
|
+
# and is unaffected by this - it keeps working either way.
|
|
229
|
+
internal_ws_port = env_values.get("RELAY_WS_PORT", "").strip()
|
|
230
|
+
autotls_hostname = metrics_https_public_host(env_values, zone)
|
|
231
|
+
autotls_serving = bool(internal_ws_port and autotls_hostname) and listener_presents_certificate(
|
|
232
|
+
internal_ws_port, autotls_hostname
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
if autotls_serving:
|
|
236
|
+
secure_addrs = build_secure_addrs(env_values, zone)
|
|
237
|
+
else:
|
|
238
|
+
secure_addrs = []
|
|
239
|
+
print(
|
|
240
|
+
f"AutoTLS is not terminating TLS on 127.0.0.1:{internal_ws_port or '<unset>'} - "
|
|
241
|
+
"skipping the libp2p.direct announce addresses. Peers keep the Caddy "
|
|
242
|
+
"address on 443, which is served by a separate process."
|
|
243
|
+
)
|
|
244
|
+
exact_hosts = secure_hosts(env_values, zone) if autotls_serving else []
|
|
192
245
|
metrics_host = metrics_https_public_host(env_values, zone)
|
|
193
246
|
current_value = env_values.get("VITE_APPEND_ANNOUNCE", "")
|
|
194
247
|
current_metrics_host = env_values.get("METRICS_HTTPS_PUBLIC_HOST", "").strip()
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
248
|
+
previous = [normalize_addr(addr) for addr in current_value.split(",") if addr.strip()]
|
|
249
|
+
|
|
250
|
+
# Drop stale libp2p.direct entries when the probe says they are not served.
|
|
251
|
+
# Without this the first bad run leaves them in the file forever - which is
|
|
252
|
+
# how the live relay kept announcing a plaintext port across restarts.
|
|
253
|
+
if not autotls_serving:
|
|
254
|
+
previous = [addr for addr in previous if "libp2p.direct" not in addr]
|
|
255
|
+
|
|
256
|
+
merged = dedupe(previous + secure_addrs)
|
|
198
257
|
announce_changed = current_value.split(",") != merged
|
|
199
258
|
metrics_host_changed = bool(metrics_host) and current_metrics_host != metrics_host
|
|
200
259
|
|
|
@@ -223,7 +282,21 @@ def main() -> None:
|
|
|
223
282
|
elif os.path.exists(AUTOTLS_CADDY_READY_FILE):
|
|
224
283
|
os.remove(AUTOTLS_CADDY_READY_FILE)
|
|
225
284
|
|
|
226
|
-
|
|
285
|
+
# The ready file is what stops this unit from running again
|
|
286
|
+
# (ConditionPathExists=! in the unit), so it may only be written once the
|
|
287
|
+
# AutoTLS addresses are actually announced. Writing it unconditionally is
|
|
288
|
+
# what made a single early probe final: AutoTLS needs a minute or two to
|
|
289
|
+
# fetch its certificate, the probe correctly found a plaintext port before
|
|
290
|
+
# that, marked itself done - and the certificate that arrived afterwards was
|
|
291
|
+
# never announced. The timer beside this unit retries until it is here.
|
|
292
|
+
if autotls_serving:
|
|
293
|
+
open(AUTOTLS_READY_FILE, "a", encoding="utf-8").close()
|
|
294
|
+
else:
|
|
295
|
+
print(
|
|
296
|
+
"Not marking AutoTLS ready - no TLS on the listener yet. "
|
|
297
|
+
f"{os.path.basename(AUTOTLS_READY_FILE)} stays absent so the timer retries."
|
|
298
|
+
)
|
|
299
|
+
|
|
227
300
|
print(f"Updated VITE_APPEND_ANNOUNCE={announce_value}")
|
|
228
301
|
|
|
229
302
|
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
# AutoTLS fetches its certificate a minute or two after the relay starts, and
|
|
3
|
+
# the refresh unit runs seconds after it. Without a retry the probe sees a
|
|
4
|
+
# plaintext listener, correctly declines to announce a TLS address - and never
|
|
5
|
+
# looks again, so a certificate that arrives later is never advertised.
|
|
6
|
+
#
|
|
7
|
+
# The unit's own ConditionPathExists=! on the ready file is the stop condition:
|
|
8
|
+
# once the addresses are announced, every later firing is skipped by systemd
|
|
9
|
+
# before python starts. This timer therefore costs nothing after success.
|
|
10
|
+
Description=Retry the AutoTLS announce refresh for orbitdb-relay until it succeeds
|
|
11
|
+
After=orbitdb-relay.service
|
|
12
|
+
|
|
13
|
+
[Timer]
|
|
14
|
+
OnBootSec=2min
|
|
15
|
+
OnUnitActiveSec=2min
|
|
16
|
+
# Certificates can take a while on a busy ACME endpoint, and the relay is
|
|
17
|
+
# usable over Caddy on 443 throughout - so retrying patiently costs nothing.
|
|
18
|
+
AccuracySec=15s
|
|
19
|
+
Unit=orbitdb-relay-autotls-refresh.service
|
|
20
|
+
|
|
21
|
+
[Install]
|
|
22
|
+
WantedBy=timers.target
|
|
Binary file
|
|
@@ -83,6 +83,23 @@ https://${proxy_hostname} {
|
|
|
83
83
|
reverse_proxy http://127.0.0.1:80
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
# The address and readiness documents live on the setup server (:80). A PWA
|
|
87
|
+
# served over HTTPS cannot fetch that port directly -- the browser blocks it
|
|
88
|
+
# as mixed content -- so they have to be reachable through this proxy.
|
|
89
|
+
# Named handles must precede the catch-all, which swallows everything else
|
|
90
|
+
# into the WebSocket backend.
|
|
91
|
+
handle /describe {
|
|
92
|
+
reverse_proxy http://127.0.0.1:80
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
handle /multiaddrs {
|
|
96
|
+
reverse_proxy http://127.0.0.1:80
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
handle /health {
|
|
100
|
+
reverse_proxy http://127.0.0.1:80
|
|
101
|
+
}
|
|
102
|
+
|
|
86
103
|
handle {
|
|
87
104
|
reverse_proxy http://127.0.0.1:${WS_BACKEND_PORT}
|
|
88
105
|
}
|
|
@@ -476,6 +476,15 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
476
476
|
self._handle_metadata()
|
|
477
477
|
return
|
|
478
478
|
|
|
479
|
+
# orbitdb-relay serves its address document at /multiaddrs, so consumers
|
|
480
|
+
# look for one path on every profile. /metadata already carries exactly
|
|
481
|
+
# that document -- it runs the same describe script -- it was just named
|
|
482
|
+
# after the deploy flow that first needed it and reachable only on the
|
|
483
|
+
# plain-HTTP setup port. Expose the same payload under the shared names.
|
|
484
|
+
if self._request_path() in ("/describe", "/multiaddrs"):
|
|
485
|
+
self._handle_describe()
|
|
486
|
+
return
|
|
487
|
+
|
|
479
488
|
if self._request_path() not in ("/", "/health"):
|
|
480
489
|
self._send_json(404, {"status": "not-found"})
|
|
481
490
|
return
|
|
@@ -486,6 +495,23 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
486
495
|
status, payload = _metadata_status_payload()
|
|
487
496
|
self._send_json(status, payload)
|
|
488
497
|
|
|
498
|
+
def _handle_describe(self) -> None:
|
|
499
|
+
status, payload = _metadata_status_payload()
|
|
500
|
+
if status != 200:
|
|
501
|
+
self._send_json(status, payload)
|
|
502
|
+
return
|
|
503
|
+
# Unwrap the `metadata` envelope: a consumer asking for the address
|
|
504
|
+
# document wants the document, not the readiness wrapper around it.
|
|
505
|
+
metadata = payload.get("metadata")
|
|
506
|
+
if not isinstance(metadata, dict):
|
|
507
|
+
self._send_json(500, {"status": "error", "error": "describe payload missing"})
|
|
508
|
+
return
|
|
509
|
+
document = dict(metadata)
|
|
510
|
+
document["profile"] = "uc-go-peer"
|
|
511
|
+
if payload.get("stale"):
|
|
512
|
+
document["stale"] = True
|
|
513
|
+
self._send_json(200, document)
|
|
514
|
+
|
|
489
515
|
def do_POST(self) -> None: # noqa: N802
|
|
490
516
|
if self._request_path() != "/configure":
|
|
491
517
|
self._send_json(404, {"status": "not-found"})
|