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,161 @@
1
+ """Authenticated webhook delivery for job results and errors.
2
+
3
+ After a job completes (or fails), the result is POST-ed to a Coda
4
+ callback URL as a signed JSON payload. Each request carries a fresh
5
+ short-lived JWT so the cloud can verify the sender.
6
+
7
+ Delivery includes exponential-backoff retry for transient server errors
8
+ (5xx) and transport failures. Client errors (4xx) are raised
9
+ immediately since retrying them would be pointless.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import logging
16
+ from dataclasses import dataclass
17
+
18
+ import httpx
19
+
20
+ from coda_node.server.auth import sign_token
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ __all__ = ["WebhookClient", "WebhookPayload"]
25
+
26
+ WebhookPayloadValue = dict[str, int] | float | int | str
27
+
28
+ _MAX_RETRIES = 3
29
+ _BACKOFF_BASE = 1.0
30
+ _BACKOFF_FACTOR = 2.0
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class WebhookPayload:
35
+ """Immutable container for a job result or error sent via webhook.
36
+
37
+ Only non-``None`` optional fields are included when serialized with
38
+ :meth:`to_dict`, keeping the JSON payload minimal.
39
+ """
40
+
41
+ job_id: str
42
+ status: str
43
+ counts: dict[str, int] | None = None
44
+ execution_time_ms: float | None = None
45
+ shots_completed: int | None = None
46
+ error: str | None = None
47
+
48
+ def to_dict(self) -> dict[str, WebhookPayloadValue]:
49
+ """Serialize to a dict, omitting ``None``-valued optional fields."""
50
+ result: dict[str, WebhookPayloadValue] = {
51
+ "job_id": self.job_id,
52
+ "status": self.status,
53
+ }
54
+ if self.counts is not None:
55
+ result["counts"] = self.counts
56
+ if self.execution_time_ms is not None:
57
+ result["execution_time_ms"] = self.execution_time_ms
58
+ if self.shots_completed is not None:
59
+ result["shots_completed"] = self.shots_completed
60
+ if self.error is not None:
61
+ result["error"] = self.error
62
+ return result
63
+
64
+
65
+ class WebhookClient:
66
+ """Send signed job results to Coda callback URLs.
67
+
68
+ Maintains a long-lived :class:`httpx.AsyncClient` for connection
69
+ pooling and handles JWT signing, serialization, and retry logic.
70
+
71
+ Args:
72
+ qpu_id: QPU identifier used as the JWT ``sub`` claim.
73
+ jwt_private_key: PEM-encoded RSA private key for signing.
74
+ jwt_key_id: ``kid`` header value for the JWT.
75
+ timeout: HTTP request timeout in seconds.
76
+ max_retries: Maximum number of delivery attempts per webhook.
77
+ extra_headers: Additional HTTP headers merged into every
78
+ webhook request (e.g. deployment-protection bypass).
79
+ """
80
+
81
+ def __init__(
82
+ self,
83
+ qpu_id: str,
84
+ jwt_private_key: str,
85
+ jwt_key_id: str,
86
+ timeout: float = 30.0,
87
+ max_retries: int = _MAX_RETRIES,
88
+ extra_headers: dict[str, str] | None = None,
89
+ ) -> None:
90
+ """Initialise the webhook client with QPU identity and signing credentials."""
91
+ self._qpu_id = qpu_id
92
+ self._jwt_private_key = jwt_private_key
93
+ self._jwt_key_id = jwt_key_id
94
+ self._max_retries = max_retries
95
+ self._extra_headers = extra_headers or {}
96
+ self._client = httpx.AsyncClient(timeout=timeout)
97
+
98
+ async def _post_with_retry(
99
+ self, url: str, body: dict[str, WebhookPayloadValue]
100
+ ) -> None:
101
+ """POST *body* to *url* with JWT auth, retrying on 5xx and transport errors."""
102
+ last_exc: Exception | None = None
103
+ for attempt in range(1, self._max_retries + 1):
104
+ token = sign_token(
105
+ self._qpu_id, self._jwt_private_key, key_id=self._jwt_key_id
106
+ )
107
+ try:
108
+ response = await self._client.post(
109
+ url,
110
+ json=body,
111
+ headers={
112
+ "Authorization": f"Bearer {token}",
113
+ **self._extra_headers,
114
+ },
115
+ )
116
+ response.raise_for_status()
117
+ return
118
+ except (httpx.TransportError, httpx.HTTPStatusError) as exc:
119
+ last_exc = exc
120
+ if (
121
+ isinstance(exc, httpx.HTTPStatusError)
122
+ and exc.response.status_code < 500
123
+ ):
124
+ raise
125
+ delay = _BACKOFF_BASE * (_BACKOFF_FACTOR ** (attempt - 1))
126
+ logger.warning(
127
+ "Webhook POST to %s failed (attempt %d/%d): %s — retrying in %.1fs",
128
+ url,
129
+ attempt,
130
+ self._max_retries,
131
+ exc,
132
+ delay,
133
+ )
134
+ await asyncio.sleep(delay)
135
+
136
+ if last_exc is not None:
137
+ raise last_exc
138
+
139
+ async def send_result(self, callback_url: str, payload: WebhookPayload) -> None:
140
+ """Deliver a job result to the Coda cloud with retry.
141
+
142
+ Args:
143
+ callback_url: The URL provided in the original job message.
144
+ payload: Result data to POST as JSON.
145
+
146
+ Raises:
147
+ httpx.HTTPStatusError: On non-retryable (4xx) responses.
148
+ httpx.TransportError: After all retry attempts are exhausted.
149
+ """
150
+ await self._post_with_retry(callback_url, payload.to_dict())
151
+
152
+ async def send_error(self, callback_url: str, job_id: str, error: str) -> None:
153
+ """Convenience wrapper to report a job failure."""
154
+ await self.send_result(
155
+ callback_url,
156
+ WebhookPayload(job_id=job_id, status="failed", error=error),
157
+ )
158
+
159
+ async def close(self) -> None:
160
+ """Shut down the underlying HTTP connection pool."""
161
+ await self._client.aclose()
@@ -0,0 +1,49 @@
1
+ """VPN tunnel management, health monitoring, and node provisioning.
2
+
3
+ This package handles the full VPN lifecycle for a Coda-connected node:
4
+
5
+ * **Preflight** -- detect an active tunnel interface and probe
6
+ connectivity to the cloud endpoints before the server accepts jobs.
7
+ * **Background monitoring** -- periodically re-check VPN health and
8
+ transition the service state between READY, DEGRADED, and
9
+ VPN_UNAVAILABLE.
10
+ * **Self-service provisioning** -- given a one-time token, fetch
11
+ credentials and a VPN profile from the Coda cloud, start an OpenVPN
12
+ daemon, and persist everything for future reconnects.
13
+ """
14
+
15
+ from coda_node.vpn.guard import (
16
+ ProbeResult,
17
+ ServiceState,
18
+ VPNGuard,
19
+ VPNStatus,
20
+ detect_tun_interface,
21
+ validate_key_permissions,
22
+ )
23
+ from coda_node.vpn.service import (
24
+ OPENVPN_LOG_PATH,
25
+ OPENVPN_PID_PATH,
26
+ apply_node_bundle,
27
+ connect_settings,
28
+ ensure_persisted_vpn,
29
+ fetch_node_bundle,
30
+ fetch_reconnect_bundle,
31
+ kill_openvpn_daemon,
32
+ )
33
+
34
+ __all__ = [
35
+ "OPENVPN_LOG_PATH",
36
+ "OPENVPN_PID_PATH",
37
+ "ProbeResult",
38
+ "ServiceState",
39
+ "VPNGuard",
40
+ "VPNStatus",
41
+ "apply_node_bundle",
42
+ "connect_settings",
43
+ "detect_tun_interface",
44
+ "ensure_persisted_vpn",
45
+ "fetch_node_bundle",
46
+ "fetch_reconnect_bundle",
47
+ "kill_openvpn_daemon",
48
+ "validate_key_permissions",
49
+ ]
coda_node/vpn/guard.py ADDED
@@ -0,0 +1,424 @@
1
+ """VPN preflight checks and ongoing health monitoring.
2
+
3
+ The :class:`VPNGuard` runs a three-stage preflight (interface detection,
4
+ DNS resolution, HTTP probes) and then enters a background watch loop
5
+ that continuously re-evaluates VPN health, firing a callback whenever
6
+ the service state changes.
7
+
8
+ Platform-specific helpers detect active tunnel interfaces on macOS
9
+ (``ifconfig``), Linux (``ip link``), and Windows (``Get-NetAdapter``).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import json
16
+ import logging
17
+ import platform
18
+ import socket
19
+ import subprocess
20
+ import time
21
+ from dataclasses import dataclass, field
22
+ from enum import Enum
23
+ from pathlib import Path
24
+ from typing import TYPE_CHECKING
25
+ from urllib.parse import urlparse
26
+
27
+ import httpx
28
+
29
+ if TYPE_CHECKING:
30
+ from collections.abc import Awaitable, Callable
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ __all__ = [
35
+ "ProbeResult",
36
+ "ServiceState",
37
+ "VPNGuard",
38
+ "VPNStatus",
39
+ "detect_tun_interface",
40
+ "validate_key_permissions",
41
+ ]
42
+
43
+
44
+ class ServiceState(Enum):
45
+ """Overall readiness state of the node runtime."""
46
+
47
+ BOOTING = "booting"
48
+ VPN_UNAVAILABLE = "vpn_unavailable"
49
+ READY = "ready"
50
+ DEGRADED = "degraded"
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class ProbeResult:
55
+ """Outcome of a single HTTP health probe against a VPN endpoint."""
56
+
57
+ target: str
58
+ ok: bool
59
+ latency_ms: float | None = None
60
+ error: str | None = None
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class VPNStatus:
65
+ """Aggregate result of a VPN preflight or health check."""
66
+
67
+ ok: bool
68
+ interface_found: bool
69
+ probes: list[ProbeResult] = field(default_factory=list)
70
+ reason: str = ""
71
+
72
+
73
+ def _parse_darwin_tun_interfaces(ifconfig_output: str) -> str | None:
74
+ """Extract the first active utun/tun interface from ``ifconfig`` output."""
75
+ current_iface: str | None = None
76
+ for line in ifconfig_output.splitlines():
77
+ if line and not line[0].isspace():
78
+ current_iface = line.split(":")[0]
79
+ elif (
80
+ current_iface
81
+ and current_iface.startswith(("utun", "tun"))
82
+ and line.strip().startswith("inet ")
83
+ ):
84
+ return current_iface
85
+ return None
86
+
87
+
88
+ def _parse_windows_tun_interfaces(
89
+ adapter_json: str, hint: str | None = None
90
+ ) -> str | None:
91
+ """Find a TAP/WinTUN adapter from ``Get-NetAdapter`` JSON output."""
92
+ try:
93
+ raw_adapters = json.loads(adapter_json)
94
+ except json.JSONDecodeError:
95
+ return None
96
+
97
+ if isinstance(raw_adapters, dict):
98
+ adapters = [raw_adapters]
99
+ elif isinstance(raw_adapters, list):
100
+ adapters = raw_adapters
101
+ else:
102
+ return None
103
+
104
+ hint_lower = hint.lower() if hint else None
105
+ for adapter in adapters:
106
+ if not isinstance(adapter, dict):
107
+ continue
108
+
109
+ name = adapter.get("Name")
110
+ description = adapter.get("InterfaceDescription")
111
+ status = adapter.get("Status")
112
+ if not isinstance(name, str) or not isinstance(description, str):
113
+ continue
114
+ if not isinstance(status, str) or status.lower() != "up":
115
+ continue
116
+ if (
117
+ hint_lower
118
+ and hint_lower not in name.lower()
119
+ and hint_lower not in description.lower()
120
+ ):
121
+ continue
122
+
123
+ description_lower = description.lower()
124
+ if any(
125
+ marker in description_lower
126
+ for marker in ("tap-windows", "wintun", "openvpn", "tap adapter")
127
+ ):
128
+ return name
129
+ return None
130
+
131
+
132
+ def detect_tun_interface(hint: str | None = None) -> str | None:
133
+ """Detect an active VPN tunnel interface on the local machine.
134
+
135
+ Uses platform-specific commands (``ifconfig`` on macOS, ``ip link``
136
+ on Linux, ``Get-NetAdapter`` on Windows) to find a TUN/TAP adapter
137
+ that is currently UP.
138
+
139
+ Args:
140
+ hint: If provided, check only this specific interface name
141
+ instead of scanning all interfaces.
142
+
143
+ Returns:
144
+ The interface name (e.g. ``"utun3"``, ``"tun0"``) or ``None``
145
+ if no active VPN interface is found.
146
+ """
147
+ system = platform.system()
148
+
149
+ if hint:
150
+ try:
151
+ if system == "Darwin":
152
+ result = subprocess.run(
153
+ ["ifconfig", hint],
154
+ capture_output=True,
155
+ text=True,
156
+ timeout=5,
157
+ )
158
+ elif system == "Windows":
159
+ result = subprocess.run(
160
+ [
161
+ "powershell",
162
+ "-NoProfile",
163
+ "-Command",
164
+ (
165
+ "Get-NetAdapter -IncludeHidden | "
166
+ "Select-Object Name,InterfaceDescription,Status | "
167
+ "ConvertTo-Json -Compress"
168
+ ),
169
+ ],
170
+ capture_output=True,
171
+ text=True,
172
+ timeout=5,
173
+ )
174
+ else:
175
+ result = subprocess.run(
176
+ ["ip", "link", "show", hint],
177
+ capture_output=True,
178
+ text=True,
179
+ timeout=5,
180
+ )
181
+
182
+ if system == "Windows":
183
+ return _parse_windows_tun_interfaces(result.stdout, hint)
184
+ if result.returncode == 0 and (
185
+ "UP" in result.stdout or "up" in result.stdout
186
+ ):
187
+ return hint
188
+ except (FileNotFoundError, subprocess.TimeoutExpired):
189
+ return None
190
+ return None
191
+
192
+ try:
193
+ if system == "Darwin":
194
+ result = subprocess.run(
195
+ ["ifconfig"],
196
+ capture_output=True,
197
+ text=True,
198
+ timeout=5,
199
+ )
200
+ return _parse_darwin_tun_interfaces(result.stdout)
201
+
202
+ if system == "Windows":
203
+ result = subprocess.run(
204
+ [
205
+ "powershell",
206
+ "-NoProfile",
207
+ "-Command",
208
+ (
209
+ "Get-NetAdapter -IncludeHidden | "
210
+ "Select-Object Name,InterfaceDescription,Status | "
211
+ "ConvertTo-Json -Compress"
212
+ ),
213
+ ],
214
+ capture_output=True,
215
+ text=True,
216
+ timeout=5,
217
+ )
218
+ return _parse_windows_tun_interfaces(result.stdout)
219
+
220
+ result = subprocess.run(
221
+ ["ip", "-o", "link", "show", "type", "tun"],
222
+ capture_output=True,
223
+ text=True,
224
+ timeout=5,
225
+ )
226
+ for line in result.stdout.splitlines():
227
+ parts = line.split(":")
228
+ if len(parts) >= 2:
229
+ return parts[1].strip()
230
+ except (FileNotFoundError, subprocess.TimeoutExpired):
231
+ return None
232
+
233
+ return None
234
+
235
+
236
+ async def _probe_target(
237
+ url: str,
238
+ timeout: float = 5.0,
239
+ extra_headers: dict[str, str] | None = None,
240
+ ) -> ProbeResult:
241
+ """Send an HTTP HEAD to *url* and return a :class:`ProbeResult`."""
242
+ started = time.monotonic()
243
+ try:
244
+ async with httpx.AsyncClient(timeout=timeout, verify=True) as client:
245
+ response = await client.head(url, headers=extra_headers or {})
246
+ latency_ms = round((time.monotonic() - started) * 1000, 1)
247
+ return ProbeResult(
248
+ target=url,
249
+ ok=response.status_code < 500,
250
+ latency_ms=latency_ms,
251
+ )
252
+ except Exception as exc:
253
+ latency_ms = round((time.monotonic() - started) * 1000, 1)
254
+ return ProbeResult(
255
+ target=url,
256
+ ok=False,
257
+ latency_ms=latency_ms,
258
+ error=str(exc)[:200],
259
+ )
260
+
261
+
262
+ def _resolve_host(hostname: str) -> bool:
263
+ """Return ``True`` if *hostname* resolves to at least one address."""
264
+ try:
265
+ socket.getaddrinfo(hostname, 443, socket.AF_UNSPEC, socket.SOCK_STREAM)
266
+ return True
267
+ except (OSError, socket.gaierror):
268
+ return False
269
+
270
+
271
+ class VPNGuard:
272
+ """Monitor VPN health and expose readiness state.
273
+
274
+ The guard starts in :attr:`ServiceState.BOOTING` and transitions to
275
+ READY, VPN_UNAVAILABLE, or DEGRADED based on the preflight and
276
+ ongoing watch results.
277
+
278
+ Args:
279
+ probe_targets: HTTP URLs to probe for connectivity (typically
280
+ the Coda API endpoints reachable only over VPN).
281
+ interface_hint: Specific interface name to look for instead of
282
+ auto-detecting.
283
+ check_interval_sec: Seconds between background health checks.
284
+ vpn_required: When ``False``, preflight passes even without a
285
+ detected tunnel (useful for local development).
286
+ extra_headers: Additional HTTP headers merged into every probe
287
+ request (e.g. deployment-protection bypass).
288
+ """
289
+
290
+ def __init__(
291
+ self,
292
+ probe_targets: list[str] | None = None,
293
+ interface_hint: str | None = None,
294
+ check_interval_sec: int = 10,
295
+ vpn_required: bool = True,
296
+ extra_headers: dict[str, str] | None = None,
297
+ ) -> None:
298
+ """Initialise the guard with probe targets and monitoring settings."""
299
+ self._probe_targets = probe_targets or []
300
+ self._interface_hint = interface_hint
301
+ self._check_interval = check_interval_sec
302
+ self._vpn_required = vpn_required
303
+ self._extra_headers = extra_headers or {}
304
+ self._state = ServiceState.BOOTING
305
+ self._running = False
306
+
307
+ @property
308
+ def state(self) -> ServiceState:
309
+ """Current service state of the VPN guard."""
310
+ return self._state
311
+
312
+ @property
313
+ def is_ready(self) -> bool:
314
+ """``True`` when the VPN guard considers the node fully healthy."""
315
+ return self._state == ServiceState.READY
316
+
317
+ async def preflight(self) -> VPNStatus:
318
+ """Run a full VPN health check: interface, DNS, and HTTP probes.
319
+
320
+ Updates :attr:`state` and returns a :class:`VPNStatus` summary.
321
+ When *vpn_required* is ``False``, all checks pass regardless of
322
+ the actual tunnel state.
323
+ """
324
+ iface = await asyncio.to_thread(detect_tun_interface, self._interface_hint)
325
+ interface_found = iface is not None
326
+
327
+ if not interface_found and self._vpn_required:
328
+ self._state = ServiceState.VPN_UNAVAILABLE
329
+ return VPNStatus(
330
+ ok=False,
331
+ interface_found=False,
332
+ reason="No VPN tunnel interface detected",
333
+ )
334
+
335
+ dns_failures: list[str] = []
336
+ for target in self._probe_targets:
337
+ hostname = urlparse(target).hostname
338
+ if hostname and not await asyncio.to_thread(_resolve_host, hostname):
339
+ dns_failures.append(hostname)
340
+
341
+ if dns_failures and self._vpn_required:
342
+ self._state = ServiceState.VPN_UNAVAILABLE
343
+ return VPNStatus(
344
+ ok=False,
345
+ interface_found=interface_found,
346
+ reason=f"DNS resolution failed for: {', '.join(dns_failures)}",
347
+ )
348
+
349
+ probes: list[ProbeResult] = []
350
+ for target in self._probe_targets:
351
+ probes.append(
352
+ await _probe_target(target, extra_headers=self._extra_headers)
353
+ )
354
+
355
+ failed = [probe for probe in probes if not probe.ok]
356
+ if failed and self._vpn_required:
357
+ self._state = ServiceState.VPN_UNAVAILABLE
358
+ reasons = "; ".join(
359
+ f"{probe.target}: {probe.error or 'unreachable'}" for probe in failed
360
+ )
361
+ return VPNStatus(
362
+ ok=False,
363
+ interface_found=interface_found,
364
+ probes=probes,
365
+ reason=f"Probe failures: {reasons}",
366
+ )
367
+
368
+ self._state = ServiceState.READY
369
+ return VPNStatus(ok=True, interface_found=interface_found, probes=probes)
370
+
371
+ async def watch(
372
+ self,
373
+ on_change: Callable[[ServiceState], Awaitable[None]] | None = None,
374
+ ) -> None:
375
+ """Continuously monitor VPN health until :meth:`stop` is called.
376
+
377
+ Runs :meth:`preflight` every *check_interval_sec* seconds and
378
+ invokes *on_change* whenever the service transitions between
379
+ READY and DEGRADED.
380
+
381
+ Args:
382
+ on_change: Optional async callback invoked with the new
383
+ :class:`ServiceState` on each transition.
384
+ """
385
+ self._running = True
386
+ while self._running:
387
+ await asyncio.sleep(self._check_interval)
388
+ previous = self._state
389
+ status = await self.preflight()
390
+
391
+ if status.ok and previous not in (ServiceState.READY, ServiceState.BOOTING):
392
+ if on_change:
393
+ await on_change(ServiceState.READY)
394
+ elif not status.ok and previous == ServiceState.READY:
395
+ self._state = ServiceState.DEGRADED
396
+ if on_change:
397
+ await on_change(ServiceState.DEGRADED)
398
+
399
+ def stop(self) -> None:
400
+ """Signal the watch loop to exit after the current iteration."""
401
+ self._running = False
402
+
403
+
404
+ def validate_key_permissions(path: str) -> bool:
405
+ """Check that a key file has restrictive permissions.
406
+
407
+ On POSIX systems the file must be mode ``0600`` or ``0400``. On
408
+ Windows the check only verifies the file exists (NTFS ACLs are not
409
+ inspected).
410
+
411
+ Args:
412
+ path: Filesystem path to the key file.
413
+
414
+ Returns:
415
+ ``True`` if the file exists and has acceptable permissions.
416
+ """
417
+ try:
418
+ p = Path(path)
419
+ if platform.system() == "Windows":
420
+ return p.is_file()
421
+ mode = p.stat().st_mode & 0o777
422
+ return mode in (0o600, 0o400)
423
+ except OSError:
424
+ return False