coda-node 0.1.0__py3-none-any.whl
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.
- coda_node/__init__.py +18 -0
- coda_node/errors.py +44 -0
- coda_node/py.typed +0 -0
- coda_node/server/__init__.py +9 -0
- coda_node/server/app.py +191 -0
- coda_node/server/auth.py +192 -0
- coda_node/server/cli.py +360 -0
- coda_node/server/config.py +300 -0
- coda_node/server/consumer.py +625 -0
- coda_node/server/daemon.py +261 -0
- coda_node/server/executor.py +200 -0
- coda_node/server/heartbeat.py +113 -0
- coda_node/server/ir.py +182 -0
- coda_node/server/qasm.py +377 -0
- coda_node/server/webhook.py +161 -0
- coda_node/vpn/__init__.py +49 -0
- coda_node/vpn/guard.py +424 -0
- coda_node/vpn/service.py +593 -0
- coda_node-0.1.0.dist-info/METADATA +348 -0
- coda_node-0.1.0.dist-info/RECORD +23 -0
- coda_node-0.1.0.dist-info/WHEEL +4 -0
- coda_node-0.1.0.dist-info/entry_points.txt +3 -0
- coda_node-0.1.0.dist-info/licenses/LICENSE +21 -0
coda_node/vpn/service.py
ADDED
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
"""Node provisioning, credential persistence, and OpenVPN management.
|
|
2
|
+
|
|
3
|
+
This module implements the full node and reconnect lifecycle:
|
|
4
|
+
|
|
5
|
+
1. **First run** -- the operator provides a one-time ``CODA_NODE_TOKEN``.
|
|
6
|
+
The runtime POSTs to ``/api/internal/qpu/connect`` with the token,
|
|
7
|
+
receives a bundle containing JWT credentials, Redis URL, API paths,
|
|
8
|
+
and optionally a VPN client profile.
|
|
9
|
+
2. **VPN setup** -- if the bundle includes a profile, the runtime
|
|
10
|
+
writes it to disk (after sanitizing dangerous directives), launches
|
|
11
|
+
an OpenVPN daemon, and polls for a tunnel interface.
|
|
12
|
+
3. **Persistence** -- credentials and config are written to disk with
|
|
13
|
+
``0600`` permissions so the next restart can reconnect without a
|
|
14
|
+
fresh token.
|
|
15
|
+
4. **Reconnect** -- on subsequent starts, the runtime reads persisted
|
|
16
|
+
state, brings up the VPN tunnel, and authenticates with its stored
|
|
17
|
+
JWT key.
|
|
18
|
+
|
|
19
|
+
Security notes:
|
|
20
|
+
|
|
21
|
+
* VPN profiles are scanned for shell-execution directives (``up``,
|
|
22
|
+
``down``, ``plugin``, etc.) and rejected if any are found.
|
|
23
|
+
* Private keys and config files are written with ``0600`` permissions
|
|
24
|
+
and validated on read.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import asyncio
|
|
30
|
+
import json
|
|
31
|
+
import logging
|
|
32
|
+
import os
|
|
33
|
+
import shutil
|
|
34
|
+
import signal
|
|
35
|
+
import socket
|
|
36
|
+
import subprocess
|
|
37
|
+
import tempfile
|
|
38
|
+
import time
|
|
39
|
+
import uuid
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
42
|
+
|
|
43
|
+
import httpx
|
|
44
|
+
|
|
45
|
+
if TYPE_CHECKING:
|
|
46
|
+
from coda_node.server.config import Settings
|
|
47
|
+
|
|
48
|
+
from coda_node.errors import NodeError
|
|
49
|
+
from coda_node.server.auth import sign_token
|
|
50
|
+
from coda_node.server.config import PERSISTED_CONFIG_PATH, PERSISTED_PRIVATE_KEY_PATH
|
|
51
|
+
|
|
52
|
+
logger = logging.getLogger(__name__)
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"OPENVPN_LOG_PATH",
|
|
56
|
+
"OPENVPN_PID_PATH",
|
|
57
|
+
"apply_node_bundle",
|
|
58
|
+
"connect_settings",
|
|
59
|
+
"ensure_persisted_vpn",
|
|
60
|
+
"fetch_node_bundle",
|
|
61
|
+
"fetch_reconnect_bundle",
|
|
62
|
+
"kill_openvpn_daemon",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
_RUNTIME_DIR = Path(tempfile.gettempdir())
|
|
66
|
+
OPENVPN_PID_PATH = _RUNTIME_DIR / "coda-node-openvpn.pid"
|
|
67
|
+
OPENVPN_LOG_PATH = _RUNTIME_DIR / "coda-node-openvpn.log"
|
|
68
|
+
|
|
69
|
+
_TUNNEL_POLL_INTERVAL = 1.0
|
|
70
|
+
_TUNNEL_TIMEOUT = 30.0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _machine_fingerprint() -> str:
|
|
74
|
+
"""Generate a stable machine identifier from hostname and MAC address."""
|
|
75
|
+
return f"{socket.gethostname()}-{uuid.getnode()}"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
_DANGEROUS_OVPN_DIRECTIVES = frozenset(
|
|
79
|
+
{
|
|
80
|
+
"script-security",
|
|
81
|
+
"up",
|
|
82
|
+
"down",
|
|
83
|
+
"client-connect",
|
|
84
|
+
"client-disconnect",
|
|
85
|
+
"learn-address",
|
|
86
|
+
"auth-user-pass-verify",
|
|
87
|
+
"tls-verify",
|
|
88
|
+
"ipchange",
|
|
89
|
+
"route-up",
|
|
90
|
+
"route-pre-down",
|
|
91
|
+
"plugin",
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _validate_vpn_profile(profile: str) -> None:
|
|
97
|
+
"""Reject OpenVPN profiles containing dangerous shell-execution directives."""
|
|
98
|
+
for lineno, line in enumerate(profile.splitlines(), 1):
|
|
99
|
+
directive = line.strip().split()[0].lower() if line.strip() else ""
|
|
100
|
+
if directive in _DANGEROUS_OVPN_DIRECTIVES:
|
|
101
|
+
raise NodeError(
|
|
102
|
+
f"Refusing OpenVPN profile: forbidden directive '{directive}' on line {lineno}"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _write_vpn_profile(path: str, profile: str) -> None:
|
|
107
|
+
"""Validate and write an OpenVPN profile to *path* with secure permissions."""
|
|
108
|
+
_validate_vpn_profile(profile)
|
|
109
|
+
if "reneg-sec" not in profile:
|
|
110
|
+
profile = profile.rstrip() + "\nreneg-sec 0\n"
|
|
111
|
+
profile_path = Path(path)
|
|
112
|
+
profile_path.parent.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
_write_secure_text(profile_path, profile)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _write_secure_text(path: Path, content: str) -> None:
|
|
117
|
+
"""Write *content* to *path* and restrict permissions to ``0600`` on POSIX."""
|
|
118
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
path.write_text(content)
|
|
120
|
+
if os.name != "nt":
|
|
121
|
+
path.chmod(0o600)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _persist_runtime_config(settings: Settings) -> None:
|
|
125
|
+
"""Write the private key and runtime config to disk for future reconnects."""
|
|
126
|
+
_write_secure_text(PERSISTED_PRIVATE_KEY_PATH, settings.jwt_private_key)
|
|
127
|
+
|
|
128
|
+
persisted = {
|
|
129
|
+
"qpu_id": settings.qpu_id,
|
|
130
|
+
"qpu_display_name": settings.qpu_display_name,
|
|
131
|
+
"native_gate_set": settings.native_gate_set,
|
|
132
|
+
"num_qubits": settings.num_qubits,
|
|
133
|
+
"jwt_key_id": settings.jwt_key_id,
|
|
134
|
+
"jwt_private_key_path": str(PERSISTED_PRIVATE_KEY_PATH),
|
|
135
|
+
"redis_url": settings.redis_url,
|
|
136
|
+
"webapp_url": settings.webapp_url,
|
|
137
|
+
"connect_path": settings.connect_path,
|
|
138
|
+
"heartbeat_path": settings.heartbeat_path,
|
|
139
|
+
"webhook_path": settings.webhook_path,
|
|
140
|
+
"vpn_required": settings.vpn_required,
|
|
141
|
+
"vpn_check_interval_sec": settings.vpn_check_interval_sec,
|
|
142
|
+
"vpn_interface_hint": settings.vpn_interface_hint,
|
|
143
|
+
"vpn_probe_targets": settings.vpn_probe_targets,
|
|
144
|
+
"node_auto_vpn": settings.node_auto_vpn,
|
|
145
|
+
"node_vpn_profile_path": settings.node_vpn_profile_path,
|
|
146
|
+
"node_machine_fingerprint": settings.node_machine_fingerprint,
|
|
147
|
+
}
|
|
148
|
+
_write_secure_text(PERSISTED_CONFIG_PATH, json.dumps(persisted, indent=2) + "\n")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _openvpn_binary() -> str | None:
|
|
152
|
+
"""Locate the ``openvpn`` binary on ``$PATH``."""
|
|
153
|
+
return shutil.which("openvpn") or shutil.which("openvpn.exe")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _start_openvpn(profile_path: str) -> None:
|
|
157
|
+
"""Launch an OpenVPN daemon in the background using *profile_path*."""
|
|
158
|
+
openvpn_bin = _openvpn_binary()
|
|
159
|
+
if openvpn_bin is None:
|
|
160
|
+
raise NodeError(
|
|
161
|
+
"openvpn binary not found. Install OpenVPN or disable node auto VPN."
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if os.name == "nt":
|
|
165
|
+
creationflags = 0
|
|
166
|
+
creationflags |= getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
|
167
|
+
creationflags |= getattr(subprocess, "DETACHED_PROCESS", 0)
|
|
168
|
+
creationflags |= getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
169
|
+
log_handle = OPENVPN_LOG_PATH.open("a", encoding="utf-8")
|
|
170
|
+
try:
|
|
171
|
+
process = subprocess.Popen(
|
|
172
|
+
[
|
|
173
|
+
openvpn_bin,
|
|
174
|
+
"--config",
|
|
175
|
+
profile_path,
|
|
176
|
+
"--log",
|
|
177
|
+
str(OPENVPN_LOG_PATH),
|
|
178
|
+
],
|
|
179
|
+
stdout=log_handle,
|
|
180
|
+
stderr=subprocess.STDOUT,
|
|
181
|
+
creationflags=creationflags,
|
|
182
|
+
)
|
|
183
|
+
finally:
|
|
184
|
+
log_handle.close()
|
|
185
|
+
OPENVPN_PID_PATH.write_text(f"{process.pid}\n")
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
result = subprocess.run(
|
|
189
|
+
[
|
|
190
|
+
openvpn_bin,
|
|
191
|
+
"--config",
|
|
192
|
+
profile_path,
|
|
193
|
+
"--daemon",
|
|
194
|
+
"--writepid",
|
|
195
|
+
str(OPENVPN_PID_PATH),
|
|
196
|
+
"--log",
|
|
197
|
+
str(OPENVPN_LOG_PATH),
|
|
198
|
+
],
|
|
199
|
+
capture_output=True,
|
|
200
|
+
text=True,
|
|
201
|
+
timeout=30,
|
|
202
|
+
check=False,
|
|
203
|
+
)
|
|
204
|
+
if result.returncode != 0:
|
|
205
|
+
raise NodeError(
|
|
206
|
+
f"Failed to start OpenVPN: {result.stderr.strip() or 'unknown openvpn error'}"
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _read_openvpn_log_tail(max_lines: int = 20) -> str:
|
|
211
|
+
"""Return the last *max_lines* of the OpenVPN log, or empty string on error."""
|
|
212
|
+
try:
|
|
213
|
+
lines = OPENVPN_LOG_PATH.read_text().splitlines()
|
|
214
|
+
except OSError:
|
|
215
|
+
return ""
|
|
216
|
+
return "\n".join(lines[-max_lines:])
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def kill_openvpn_daemon() -> bool:
|
|
220
|
+
"""Terminate the managed OpenVPN daemon and clean up its PID file.
|
|
221
|
+
|
|
222
|
+
Uses ``SIGTERM`` on POSIX and ``taskkill`` on Windows. Silently
|
|
223
|
+
succeeds if the process is already gone.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
``True`` if a process was found and signalled, ``False`` if no
|
|
227
|
+
PID file existed.
|
|
228
|
+
"""
|
|
229
|
+
if not OPENVPN_PID_PATH.exists():
|
|
230
|
+
return False
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
pid = int(OPENVPN_PID_PATH.read_text().strip())
|
|
234
|
+
if os.name == "nt":
|
|
235
|
+
subprocess.run(
|
|
236
|
+
["taskkill", "/PID", str(pid), "/T", "/F"],
|
|
237
|
+
capture_output=True,
|
|
238
|
+
text=True,
|
|
239
|
+
timeout=10,
|
|
240
|
+
check=False,
|
|
241
|
+
)
|
|
242
|
+
else:
|
|
243
|
+
os.kill(pid, signal.SIGTERM)
|
|
244
|
+
OPENVPN_PID_PATH.unlink(missing_ok=True)
|
|
245
|
+
return True
|
|
246
|
+
except (PermissionError, ProcessLookupError, ValueError, subprocess.TimeoutExpired):
|
|
247
|
+
OPENVPN_PID_PATH.unlink(missing_ok=True)
|
|
248
|
+
return False
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
async def _wait_for_tunnel(
|
|
252
|
+
hint: str | None = None,
|
|
253
|
+
timeout: float = _TUNNEL_TIMEOUT,
|
|
254
|
+
poll_interval: float = _TUNNEL_POLL_INTERVAL,
|
|
255
|
+
) -> str:
|
|
256
|
+
"""Poll until a VPN tunnel interface appears or *timeout* expires."""
|
|
257
|
+
from coda_node.vpn.guard import detect_tun_interface
|
|
258
|
+
|
|
259
|
+
deadline = time.monotonic() + timeout
|
|
260
|
+
while time.monotonic() < deadline:
|
|
261
|
+
iface = await asyncio.to_thread(detect_tun_interface, hint)
|
|
262
|
+
if iface is not None:
|
|
263
|
+
return iface
|
|
264
|
+
await asyncio.sleep(poll_interval)
|
|
265
|
+
|
|
266
|
+
detail = _read_openvpn_log_tail()
|
|
267
|
+
kill_openvpn_daemon()
|
|
268
|
+
log_tail = f"\nOpenVPN log tail:\n{detail}" if detail else ""
|
|
269
|
+
raise NodeError(
|
|
270
|
+
f"OpenVPN daemon started but no VPN tunnel interface appeared within {timeout}s. "
|
|
271
|
+
f"Check firewall rules and VPN endpoint connectivity.{log_tail}"
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _as_str(data: dict[str, Any], key: str) -> str:
|
|
276
|
+
"""Extract a required non-empty string from *data* or raise."""
|
|
277
|
+
value = data.get(key)
|
|
278
|
+
if not isinstance(value, str) or not value:
|
|
279
|
+
raise NodeError(f"Node response missing valid '{key}'")
|
|
280
|
+
return value
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _as_int(data: dict[str, Any], key: str, default: int) -> int:
|
|
284
|
+
"""Extract an integer from *data*, falling back to *default*."""
|
|
285
|
+
value = data.get(key, default)
|
|
286
|
+
if isinstance(value, int):
|
|
287
|
+
return value
|
|
288
|
+
raise NodeError(f"Node response has invalid '{key}'")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _resolve_machine_fingerprint(settings: Settings) -> str:
|
|
292
|
+
"""Return the machine fingerprint, generating and storing one if absent."""
|
|
293
|
+
fingerprint = settings.node_machine_fingerprint or _machine_fingerprint()
|
|
294
|
+
settings.node_machine_fingerprint = fingerprint
|
|
295
|
+
return fingerprint
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
async def _post_connect(
|
|
299
|
+
settings: Settings,
|
|
300
|
+
*,
|
|
301
|
+
auth_header: str,
|
|
302
|
+
payload: dict[str, Any],
|
|
303
|
+
max_retries: int = 3,
|
|
304
|
+
) -> dict[str, Any]:
|
|
305
|
+
"""POST to the connect endpoint with exponential-backoff retry.
|
|
306
|
+
|
|
307
|
+
Retries on 5xx and transport errors up to *max_retries* times.
|
|
308
|
+
Client errors (4xx) are raised immediately as
|
|
309
|
+
:class:`NodeError`.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
settings: Runtime settings (used for ``connect_url`` and timeout).
|
|
313
|
+
auth_header: ``Authorization`` header value (bearer token or JWT).
|
|
314
|
+
payload: JSON body to send.
|
|
315
|
+
max_retries: Maximum number of attempts before giving up.
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
The parsed JSON response body.
|
|
319
|
+
|
|
320
|
+
Raises:
|
|
321
|
+
NodeError: On non-retryable client errors or after all
|
|
322
|
+
retry attempts are exhausted.
|
|
323
|
+
"""
|
|
324
|
+
last_exc: Exception | None = None
|
|
325
|
+
for attempt in range(1, max_retries + 1):
|
|
326
|
+
try:
|
|
327
|
+
async with httpx.AsyncClient(timeout=settings.node_timeout_sec) as client:
|
|
328
|
+
headers = {
|
|
329
|
+
"Authorization": auth_header,
|
|
330
|
+
**settings.node_connect_headers,
|
|
331
|
+
}
|
|
332
|
+
response = await client.post(
|
|
333
|
+
settings.connect_url,
|
|
334
|
+
json=payload,
|
|
335
|
+
headers=headers,
|
|
336
|
+
)
|
|
337
|
+
response.raise_for_status()
|
|
338
|
+
return cast(dict[str, Any], response.json())
|
|
339
|
+
except httpx.HTTPStatusError as exc:
|
|
340
|
+
if exc.response.status_code < 500:
|
|
341
|
+
raise NodeError(
|
|
342
|
+
f"Node request failed ({exc.response.status_code}): "
|
|
343
|
+
f"{exc.response.text[:400]}"
|
|
344
|
+
) from exc
|
|
345
|
+
last_exc = exc
|
|
346
|
+
except httpx.TransportError as exc:
|
|
347
|
+
last_exc = exc
|
|
348
|
+
|
|
349
|
+
if attempt < max_retries:
|
|
350
|
+
delay = 1.0 * (2.0 ** (attempt - 1))
|
|
351
|
+
logger.warning(
|
|
352
|
+
"Connect request to %s failed (attempt %d/%d): %s — retrying in %.1fs",
|
|
353
|
+
settings.connect_url,
|
|
354
|
+
attempt,
|
|
355
|
+
max_retries,
|
|
356
|
+
last_exc,
|
|
357
|
+
delay,
|
|
358
|
+
)
|
|
359
|
+
await asyncio.sleep(delay)
|
|
360
|
+
|
|
361
|
+
raise NodeError(
|
|
362
|
+
f"Node connect failed after {max_retries} attempts: {last_exc}"
|
|
363
|
+
) from last_exc
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
async def fetch_node_bundle(settings: Settings) -> dict[str, Any]:
|
|
367
|
+
"""Fetch the provisioning bundle using a one-time node token.
|
|
368
|
+
|
|
369
|
+
POSTs the machine fingerprint to the Coda connect endpoint. The
|
|
370
|
+
returned bundle contains JWT keys, Redis URL, API paths, and
|
|
371
|
+
optional VPN configuration.
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
settings: Runtime settings (must have a non-empty
|
|
375
|
+
``node_token``).
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
The raw bundle dictionary from the Coda API.
|
|
379
|
+
|
|
380
|
+
Raises:
|
|
381
|
+
NodeError: If the token is empty or the HTTP request fails.
|
|
382
|
+
"""
|
|
383
|
+
if not settings.node_token:
|
|
384
|
+
raise NodeError("node token is empty")
|
|
385
|
+
|
|
386
|
+
payload = {
|
|
387
|
+
"machine_fingerprint": _resolve_machine_fingerprint(settings),
|
|
388
|
+
}
|
|
389
|
+
return await _post_connect(
|
|
390
|
+
settings,
|
|
391
|
+
auth_header=f"Bearer {settings.node_token}",
|
|
392
|
+
payload=payload,
|
|
393
|
+
max_retries=settings.node_connect_retries,
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
async def fetch_reconnect_bundle(settings: Settings) -> dict[str, Any]:
|
|
398
|
+
"""Fetch a reconnect bundle using stored JWT credentials.
|
|
399
|
+
|
|
400
|
+
Authenticates with a freshly signed JWT (instead of a node
|
|
401
|
+
token) to get an updated bundle. The server may refresh Redis
|
|
402
|
+
URLs, API paths, or VPN configuration.
|
|
403
|
+
|
|
404
|
+
Args:
|
|
405
|
+
settings: Runtime settings with valid ``jwt_private_key`` and
|
|
406
|
+
``jwt_key_id``.
|
|
407
|
+
|
|
408
|
+
Returns:
|
|
409
|
+
The raw bundle dictionary from the Coda API.
|
|
410
|
+
|
|
411
|
+
Raises:
|
|
412
|
+
NodeError: If the HTTP request fails.
|
|
413
|
+
"""
|
|
414
|
+
payload = {
|
|
415
|
+
"machine_fingerprint": _resolve_machine_fingerprint(settings),
|
|
416
|
+
}
|
|
417
|
+
token = sign_token(
|
|
418
|
+
settings.qpu_id,
|
|
419
|
+
settings.jwt_private_key,
|
|
420
|
+
key_id=settings.jwt_key_id,
|
|
421
|
+
)
|
|
422
|
+
return await _post_connect(
|
|
423
|
+
settings,
|
|
424
|
+
auth_header=f"Bearer {token}",
|
|
425
|
+
payload=payload,
|
|
426
|
+
max_retries=settings.node_connect_retries,
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
async def apply_node_bundle(settings: Settings, bundle: dict[str, Any]) -> None:
|
|
431
|
+
"""Apply a node bundle to the runtime settings.
|
|
432
|
+
|
|
433
|
+
Mutates *settings* in place with QPU identity, JWT credentials,
|
|
434
|
+
Redis URL, API paths, and VPN configuration from the bundle. If
|
|
435
|
+
``node_auto_vpn`` is enabled and the bundle includes a VPN
|
|
436
|
+
profile, the profile is written to disk and an OpenVPN daemon is
|
|
437
|
+
started (unless a tunnel is already active).
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
settings: The mutable runtime settings object.
|
|
441
|
+
bundle: Raw JSON bundle from :func:`fetch_node_bundle`
|
|
442
|
+
or :func:`fetch_reconnect_bundle`.
|
|
443
|
+
|
|
444
|
+
Raises:
|
|
445
|
+
NodeError: If required fields are missing or the VPN
|
|
446
|
+
setup fails.
|
|
447
|
+
"""
|
|
448
|
+
settings.qpu_id = _as_str(bundle, "qpu_id")
|
|
449
|
+
settings.qpu_display_name = (
|
|
450
|
+
bundle.get("qpu_display_name") or bundle.get("qpu_label") or ""
|
|
451
|
+
)
|
|
452
|
+
settings.native_gate_set = _as_str(bundle, "native_gate_set")
|
|
453
|
+
settings.num_qubits = _as_int(bundle, "num_qubits", settings.num_qubits)
|
|
454
|
+
jwt_private_key = bundle.get("jwt_private_key")
|
|
455
|
+
if isinstance(jwt_private_key, str) and jwt_private_key:
|
|
456
|
+
settings.jwt_private_key = jwt_private_key
|
|
457
|
+
elif not settings.jwt_private_key:
|
|
458
|
+
raise NodeError("Node response missing valid 'jwt_private_key'")
|
|
459
|
+
|
|
460
|
+
jwt_key_id = bundle.get("jwt_key_id")
|
|
461
|
+
if isinstance(jwt_key_id, str) and jwt_key_id:
|
|
462
|
+
settings.jwt_key_id = jwt_key_id
|
|
463
|
+
elif not settings.jwt_key_id:
|
|
464
|
+
raise NodeError("Node response missing valid 'jwt_key_id'")
|
|
465
|
+
|
|
466
|
+
settings.redis_url = _as_str(bundle, "redis_url")
|
|
467
|
+
settings.webapp_url = _as_str(
|
|
468
|
+
bundle,
|
|
469
|
+
"webapp_url" if "webapp_url" in bundle else "cloud_base_url",
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
connect_path = bundle.get("connect_path")
|
|
473
|
+
if isinstance(connect_path, str) and connect_path:
|
|
474
|
+
settings.connect_path = connect_path
|
|
475
|
+
|
|
476
|
+
heartbeat_path = bundle.get("heartbeat_path")
|
|
477
|
+
if isinstance(heartbeat_path, str) and heartbeat_path:
|
|
478
|
+
settings.heartbeat_path = heartbeat_path
|
|
479
|
+
|
|
480
|
+
webhook_path = bundle.get("webhook_path")
|
|
481
|
+
if isinstance(webhook_path, str) and webhook_path:
|
|
482
|
+
settings.webhook_path = webhook_path
|
|
483
|
+
|
|
484
|
+
vpn = bundle.get("vpn")
|
|
485
|
+
if isinstance(vpn, dict):
|
|
486
|
+
required = vpn.get("required")
|
|
487
|
+
interface_hint = vpn.get("interface_hint")
|
|
488
|
+
interval = vpn.get("check_interval_sec")
|
|
489
|
+
probe_targets = vpn.get("probe_targets")
|
|
490
|
+
profile = vpn.get("client_profile_ovpn")
|
|
491
|
+
|
|
492
|
+
if isinstance(required, bool):
|
|
493
|
+
settings.vpn_required = required
|
|
494
|
+
if interface_hint is None or isinstance(interface_hint, str):
|
|
495
|
+
settings.vpn_interface_hint = interface_hint
|
|
496
|
+
if isinstance(interval, int):
|
|
497
|
+
settings.vpn_check_interval_sec = interval
|
|
498
|
+
if (
|
|
499
|
+
isinstance(probe_targets, list)
|
|
500
|
+
and all(isinstance(item, str) for item in probe_targets)
|
|
501
|
+
and probe_targets
|
|
502
|
+
):
|
|
503
|
+
settings.vpn_probe_targets = cast(list[str], probe_targets)
|
|
504
|
+
|
|
505
|
+
if settings.vpn_required and (not isinstance(profile, str) or not profile):
|
|
506
|
+
raise NodeError(
|
|
507
|
+
"VPN is required but node response did not include a VPN profile. "
|
|
508
|
+
"Check VPN infrastructure status in the coda admin panel."
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
if settings.node_auto_vpn and isinstance(profile, str) and profile:
|
|
512
|
+
await asyncio.to_thread(
|
|
513
|
+
_write_vpn_profile, settings.node_vpn_profile_path, profile
|
|
514
|
+
)
|
|
515
|
+
from coda_node.vpn.guard import detect_tun_interface
|
|
516
|
+
|
|
517
|
+
iface = await asyncio.to_thread(
|
|
518
|
+
detect_tun_interface, settings.vpn_interface_hint
|
|
519
|
+
)
|
|
520
|
+
if iface is None:
|
|
521
|
+
await asyncio.to_thread(_start_openvpn, settings.node_vpn_profile_path)
|
|
522
|
+
await _wait_for_tunnel(hint=settings.vpn_interface_hint)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
async def connect_settings(settings: Settings) -> None:
|
|
526
|
+
"""Provision or reconnect the node to the Coda cloud.
|
|
527
|
+
|
|
528
|
+
On first run (token present), fetches a node bundle. On
|
|
529
|
+
subsequent runs (no token), restores the persisted VPN tunnel and
|
|
530
|
+
fetches a reconnect bundle with stored JWT credentials.
|
|
531
|
+
|
|
532
|
+
After the bundle is applied and config is persisted, any failure
|
|
533
|
+
triggers cleanup of a potentially half-started VPN daemon.
|
|
534
|
+
|
|
535
|
+
Args:
|
|
536
|
+
settings: Runtime settings to populate.
|
|
537
|
+
|
|
538
|
+
Raises:
|
|
539
|
+
NodeError: On provisioning or reconnect failure.
|
|
540
|
+
"""
|
|
541
|
+
_resolve_machine_fingerprint(settings)
|
|
542
|
+
if settings.node_token:
|
|
543
|
+
bundle = await fetch_node_bundle(settings)
|
|
544
|
+
else:
|
|
545
|
+
await ensure_persisted_vpn(settings)
|
|
546
|
+
bundle = await fetch_reconnect_bundle(settings)
|
|
547
|
+
try:
|
|
548
|
+
await apply_node_bundle(settings, bundle)
|
|
549
|
+
await asyncio.to_thread(_persist_runtime_config, settings)
|
|
550
|
+
except Exception:
|
|
551
|
+
if settings.node_token:
|
|
552
|
+
kill_openvpn_daemon()
|
|
553
|
+
raise
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
async def ensure_persisted_vpn(settings: Settings) -> None:
|
|
557
|
+
"""Restore a previously configured VPN tunnel for reconnect.
|
|
558
|
+
|
|
559
|
+
If a persisted config and VPN profile exist, and no tunnel is
|
|
560
|
+
currently active, starts an OpenVPN daemon and waits for the
|
|
561
|
+
interface to come up. No-ops if auto VPN is disabled or no
|
|
562
|
+
persisted config is found.
|
|
563
|
+
|
|
564
|
+
Args:
|
|
565
|
+
settings: Runtime settings with VPN profile path and interface
|
|
566
|
+
hint.
|
|
567
|
+
|
|
568
|
+
Raises:
|
|
569
|
+
NodeError: If VPN is required but the persisted profile
|
|
570
|
+
is missing, or if the tunnel fails to come up.
|
|
571
|
+
"""
|
|
572
|
+
if not PERSISTED_CONFIG_PATH.exists():
|
|
573
|
+
return
|
|
574
|
+
|
|
575
|
+
if not settings.node_auto_vpn:
|
|
576
|
+
return
|
|
577
|
+
|
|
578
|
+
profile_path = Path(settings.node_vpn_profile_path)
|
|
579
|
+
if not profile_path.exists():
|
|
580
|
+
if settings.vpn_required:
|
|
581
|
+
raise NodeError(
|
|
582
|
+
"Persisted VPN profile not found. Re-run node provisioning with a new token."
|
|
583
|
+
)
|
|
584
|
+
return
|
|
585
|
+
|
|
586
|
+
from coda_node.vpn.guard import detect_tun_interface
|
|
587
|
+
|
|
588
|
+
iface = await asyncio.to_thread(detect_tun_interface, settings.vpn_interface_hint)
|
|
589
|
+
if iface is not None:
|
|
590
|
+
return
|
|
591
|
+
|
|
592
|
+
await asyncio.to_thread(_start_openvpn, settings.node_vpn_profile_path)
|
|
593
|
+
await _wait_for_tunnel(hint=settings.vpn_interface_hint)
|