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.
@@ -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)