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