fvpnctl 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.
fvpnctl/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """fvpnctl — control FortiClient IPsec VPN on macOS via the Chrome DevTools Protocol."""
2
+
3
+ __version__ = "0.1.0"
fvpnctl/cdp.py ADDED
@@ -0,0 +1,349 @@
1
+ """Dependency-free Chrome DevTools Protocol (CDP) transport for FortiClient.
2
+
3
+ What this is
4
+ ------------
5
+ ``CDPSession`` is a tiny, VPN-agnostic CDP client. It discovers FortiClient's
6
+ Electron renderer target over the ``/json`` HTTP endpoint, opens a raw WebSocket
7
+ to it, and runs ``Runtime.evaluate`` so callers can drive the in-page
8
+ ``window.guimessenger`` API. It knows nothing about VPN profiles or state — that
9
+ lives one layer up in ``controller.py``. It is a promotion of the empirically
10
+ validated spike client (``/tmp/cdp_eval.py``); see ``docs/how-it-works.md`` for the
11
+ findings this is built on.
12
+
13
+ Why it is a hand-rolled raw WebSocket (and not a library)
14
+ ---------------------------------------------------------
15
+ Two constraints force a stdlib-only, hand-written WebSocket:
16
+
17
+ * **Zero runtime dependencies** is a hard design requirement (design spec
18
+ sections 2 and 8): no ``websocket-client``, no ``aiohttp`` — just ``socket``,
19
+ ``json``, ``urllib.request``, ``base64``, ``struct`` and ``os``.
20
+ * The obvious "use the platform's WebSocket" escape hatch is closed here: the
21
+ Node/Electron runtime FortiClient ships (node v20) has **no global**
22
+ ``WebSocket``, and we are *attach-only* — we never spawn our own JS context,
23
+ we connect to an already-running FortiClient. So there is no JS-side WebSocket
24
+ to borrow either. Hence a ~40-line raw RFC 6455 framing layer in Python.
25
+
26
+ How the framing is factored (for testability)
27
+ ----------------------------------------------
28
+ RFC 6455 framing is the failure-prone part, so it lives in two pure
29
+ module-level functions — :func:`encode_frame` and :func:`decode_frame` — that
30
+ take/return bytes and a ``recv_exact`` callable. They are unit-tested directly,
31
+ across all three payload-length encodings, with no socket involved. Socket I/O
32
+ is routed through the :meth:`CDPSession._send` / :meth:`CDPSession._recv` seam so
33
+ ``evaluate()`` can be driven by a fake transport in tests.
34
+
35
+ See ``docs/how-it-works.md`` sections 1-2 for the launch command and the
36
+ ``window.guimessenger`` API surface this transport exists to reach.
37
+
38
+ Why the ``NotRunningError`` message stays factual
39
+ -------------------------------------------------
40
+ ``NotRunningError`` here reports only *what failed* (the URL it could not reach
41
+ and the underlying reason) — it deliberately carries **no** launch instructions
42
+ or doc pointers. Telling the user *how to fix it* (run ``fvpnctl startserver``,
43
+ the exact launch command, where to download FortiClient) is the CLI's job
44
+ (``cli.py``), which has access to ``launcher`` to detect the installed
45
+ executable and tailor the advice. Keeping the transport decoupled means the
46
+ same exception is reusable by non-CLI callers that want to phrase their own
47
+ guidance.
48
+ """
49
+
50
+ import base64
51
+ import json
52
+ import os
53
+ import socket
54
+ import struct
55
+ import urllib.request
56
+ from collections.abc import Callable
57
+
58
+ from fvpnctl.errors import CDPEvaluateError, NotRunningError
59
+
60
+ # WebSocket opcodes (RFC 6455 section 5.2) we care about.
61
+ _OPCODE_TEXT = 0x1
62
+ _OPCODE_BINARY = 0x2
63
+ _OPCODE_CLOSE = 0x8
64
+
65
+
66
+ def encode_frame(text: str) -> bytes:
67
+ """Encode ``text`` as a single masked client text frame (RFC 6455).
68
+
69
+ Why masked: the RFC requires every client-to-server frame to set the mask
70
+ bit and XOR its payload with a 4-byte key; servers (including Chrome/CDP)
71
+ reject unmasked client frames. The length is encoded in one of three ways
72
+ depending on size — 7-bit, 16-bit (``126`` marker), or 64-bit (``127``
73
+ marker) — and this function picks the right one.
74
+
75
+ Returns the complete frame bytes ready to hand to ``sendall``.
76
+ """
77
+ payload = text.encode("utf-8")
78
+ n = len(payload)
79
+ header = bytearray([0x80 | _OPCODE_TEXT]) # FIN bit + text opcode.
80
+ if n < 126:
81
+ header.append(0x80 | n) # 0x80 = mask bit; low 7 bits hold the length.
82
+ elif n < 65536:
83
+ header.append(0x80 | 126) # 126 marker -> 16-bit extended length follows.
84
+ header += struct.pack(">H", n)
85
+ else:
86
+ header.append(0x80 | 127) # 127 marker -> 64-bit extended length follows.
87
+ header += struct.pack(">Q", n)
88
+ mask = os.urandom(4)
89
+ header += mask
90
+ masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
91
+ return bytes(header) + masked
92
+
93
+
94
+ def decode_frame(recv_exact: Callable[[int], bytes]) -> tuple[int, bytes]:
95
+ """Read and decode one WebSocket frame using ``recv_exact(n) -> bytes``.
96
+
97
+ ``recv_exact`` must return exactly ``n`` bytes (blocking until it can) or
98
+ raise; routing all reads through it keeps this function pure and lets tests
99
+ feed frames from an in-memory buffer. Server-to-client frames are normally
100
+ unmasked, but a mask is honoured if present (the RFC permits either way of
101
+ framing on read).
102
+
103
+ Returns ``(opcode, payload_bytes)``. The caller inspects the opcode (e.g.
104
+ ``0x8`` close, ``0x1`` text) and decodes the payload as needed.
105
+ """
106
+ b0, b1 = recv_exact(2)
107
+ opcode = b0 & 0x0F
108
+ masked = bool(b1 & 0x80)
109
+ length = b1 & 0x7F
110
+ if length == 126:
111
+ length = struct.unpack(">H", recv_exact(2))[0]
112
+ elif length == 127:
113
+ length = struct.unpack(">Q", recv_exact(8))[0]
114
+ mask = recv_exact(4) if masked else None
115
+ data = recv_exact(length) if length else b""
116
+ if mask:
117
+ data = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
118
+ return opcode, data
119
+
120
+
121
+ class CDPSession:
122
+ """A single attach-only CDP connection to a running FortiClient renderer.
123
+
124
+ Lifecycle: construct with the debugging ``port``/``host``, call
125
+ :meth:`connect` to discover the renderer target and open the WebSocket, then
126
+ :meth:`evaluate` JS expressions, then :meth:`close`. Usable as a context
127
+ manager, which closes on exit. The class is pure transport — it has no
128
+ knowledge of VPN profiles or ``window.guimessenger`` semantics.
129
+ """
130
+
131
+ def __init__(self, port: int = 9222, host: str = "127.0.0.1"):
132
+ """Configure where to attach.
133
+
134
+ :param port: the ``--remote-debugging-port`` FortiClient was launched on.
135
+ :param host: the loopback host the debugging endpoint binds to.
136
+ """
137
+ self.port = port
138
+ self.host = host
139
+ self._sock: socket.socket | None = None
140
+ self._msg_id = 0
141
+
142
+ # -- target discovery ---------------------------------------------------
143
+
144
+ def discover_target(self) -> dict:
145
+ """Find the renderer ``page`` target via ``GET http://host:port/json``.
146
+
147
+ Prefers a target of type ``page`` that exposes a ``webSocketDebuggerUrl``
148
+ (FortiClient's renderer is the single ``base.html`` page; see
149
+ docs/how-it-works.md section 1), falling back to any other target that
150
+ carries a debugger URL.
151
+
152
+ :returns: the chosen target dict (has a ``webSocketDebuggerUrl`` key).
153
+ :raises NotRunningError: if the HTTP endpoint is unreachable, or no
154
+ target exposes a ``webSocketDebuggerUrl`` (FortiClient is not running
155
+ with CDP enabled). The message is factual only (the URL + the
156
+ reason); the CLI layers on the actionable "how to launch" guidance.
157
+ """
158
+ url = f"http://{self.host}:{self.port}/json"
159
+ try:
160
+ with urllib.request.urlopen(url, timeout=5) as resp:
161
+ targets = json.loads(resp.read())
162
+ except OSError as exc:
163
+ # OSError covers ConnectionRefusedError, URLError's socket failures,
164
+ # timeouts, DNS errors — i.e. "nothing is listening on the port".
165
+ # FACTUAL message only: name the URL and the reason. The CLI adds the
166
+ # "run fvpnctl startserver / launch command / download" guidance.
167
+ raise NotRunningError(
168
+ f"Cannot reach FortiClient's CDP endpoint at {url}: {exc}."
169
+ ) from exc
170
+
171
+ debuggable = [t for t in targets if t.get("webSocketDebuggerUrl")]
172
+ if not debuggable:
173
+ raise NotRunningError(
174
+ f"Cannot reach a debuggable page target at {url} "
175
+ f"(got {len(targets)} target(s), none with a webSocketDebuggerUrl)."
176
+ )
177
+ for target in debuggable:
178
+ if target.get("type") == "page":
179
+ return target
180
+ # No 'page' type, but some target is debuggable — attach to it anyway.
181
+ return debuggable[0]
182
+
183
+ # -- connection ---------------------------------------------------------
184
+
185
+ def connect(self) -> None:
186
+ """Discover the renderer target and open the raw WebSocket to it.
187
+
188
+ Idempotent: returns immediately if already connected, so it is safe to
189
+ call both implicitly (via ``__enter__``) and explicitly.
190
+
191
+ :raises NotRunningError: when FortiClient's CDP endpoint is unreachable
192
+ or exposes no debuggable page target (see :meth:`discover_target`).
193
+ """
194
+ if self._sock is not None:
195
+ return # already connected; connect() is idempotent
196
+ target = self.discover_target()
197
+ self._sock = self._open_websocket(target["webSocketDebuggerUrl"])
198
+
199
+ def _open_websocket(self, ws_url: str) -> socket.socket:
200
+ """Open a TCP socket to ``ws_url`` and perform the RFC 6455 handshake.
201
+
202
+ ``ws_url`` looks like ``ws://host:port/devtools/page/<id>``. We send the
203
+ ``Upgrade: websocket`` request by hand (stdlib has no WebSocket client)
204
+ and require a ``101 Switching Protocols`` status before returning the
205
+ live socket.
206
+ """
207
+ if not ws_url.startswith("ws://"):
208
+ raise NotRunningError(f"Unexpected non-ws debugger URL: {ws_url!r}")
209
+ rest = ws_url[len("ws://") :]
210
+ hostport, _, path = rest.partition("/")
211
+ host, _, port = hostport.partition(":")
212
+ port = int(port or 80)
213
+
214
+ sock = socket.create_connection((host, port), timeout=5)
215
+ key = base64.b64encode(os.urandom(16)).decode()
216
+ request = (
217
+ f"GET /{path} HTTP/1.1\r\n"
218
+ f"Host: {host}:{port}\r\n"
219
+ "Upgrade: websocket\r\n"
220
+ "Connection: Upgrade\r\n"
221
+ f"Sec-WebSocket-Key: {key}\r\n"
222
+ "Sec-WebSocket-Version: 13\r\n\r\n"
223
+ )
224
+ sock.sendall(request.encode())
225
+ buf = b""
226
+ while b"\r\n\r\n" not in buf:
227
+ chunk = sock.recv(4096)
228
+ if not chunk:
229
+ raise NotRunningError("Socket closed during WebSocket handshake.")
230
+ buf += chunk
231
+ status_line = buf.split(b"\r\n", 1)[0]
232
+ if b" 101 " not in status_line:
233
+ raise NotRunningError(f"WebSocket upgrade failed: {status_line!r}")
234
+ return sock
235
+
236
+ # -- I/O seam (monkeypatched in tests) ----------------------------------
237
+
238
+ def _recv_exact(self, n: int) -> bytes:
239
+ """Read exactly ``n`` bytes from the socket or raise on early close."""
240
+ assert self._sock is not None
241
+ buf = b""
242
+ while len(buf) < n:
243
+ chunk = self._sock.recv(n - len(buf))
244
+ if not chunk:
245
+ raise CDPEvaluateError("CDP socket closed mid-frame.")
246
+ buf += chunk
247
+ return buf
248
+
249
+ def _send(self, text: str) -> None:
250
+ """Send one text frame over the WebSocket (the socket-I/O seam)."""
251
+ assert self._sock is not None
252
+ self._sock.sendall(encode_frame(text))
253
+
254
+ def _recv(self) -> tuple[int, bytes]:
255
+ """Read one WebSocket frame from the socket (the socket-I/O seam)."""
256
+ return decode_frame(self._recv_exact)
257
+
258
+ # -- evaluation ---------------------------------------------------------
259
+
260
+ def evaluate(self, expression: str, await_promise: bool = True):
261
+ """Run ``expression`` via ``Runtime.evaluate`` and return its value.
262
+
263
+ The evaluate flags are the validated ones from the spike:
264
+ ``returnByValue:true`` (so the JS value comes back serialised, not as a
265
+ remote object ref), ``userGesture:true`` (FortiClient gates some
266
+ ``guimessenger`` calls behind a user gesture), and ``awaitPromise`` —
267
+ because every ``window.guimessenger`` method returns a Promise resolving
268
+ to a JSON string (see docs/how-it-works.md section 2).
269
+
270
+ :param expression: the JavaScript to evaluate in the renderer.
271
+ :param await_promise: wait for a returned Promise to settle (default
272
+ ``True``); pass ``False`` for synchronous expressions.
273
+ :returns: the parsed JS value (``result.result.value``), or the result
274
+ object itself when the response carries no ``value`` (e.g. an
275
+ un-serialised object reference).
276
+ :raises CDPEvaluateError: if the renderer reports ``exceptionDetails``
277
+ (the in-page call threw), the response carries a top-level CDP
278
+ ``error``, or the peer closes the connection instead of replying.
279
+ """
280
+ self._msg_id += 1
281
+ msg_id = self._msg_id
282
+ command = {
283
+ "id": msg_id,
284
+ "method": "Runtime.evaluate",
285
+ "params": {
286
+ "expression": expression,
287
+ "awaitPromise": await_promise,
288
+ "returnByValue": True,
289
+ "userGesture": True,
290
+ },
291
+ }
292
+ self._send(json.dumps(command))
293
+
294
+ # CDP multiplexes async events and other replies on the same socket;
295
+ # read until we see the reply whose id matches our request.
296
+ while True:
297
+ opcode, data = self._recv()
298
+ if opcode == _OPCODE_CLOSE:
299
+ raise CDPEvaluateError("CDP connection closed by FortiClient before reply.")
300
+ if opcode not in (_OPCODE_TEXT, _OPCODE_BINARY):
301
+ continue # Ignore ping/pong/continuation frames.
302
+ message = json.loads(data.decode("utf-8"))
303
+ if message.get("id") == msg_id:
304
+ return self._parse_evaluate_reply(message, expression)
305
+
306
+ @staticmethod
307
+ def _parse_evaluate_reply(message: dict, expression: str):
308
+ """Extract the value from a matched ``Runtime.evaluate`` reply or raise."""
309
+ if "error" in message:
310
+ err = message["error"]
311
+ raise CDPEvaluateError(
312
+ f"CDP error evaluating {expression!r}: "
313
+ f"{err.get('message', err)} (code {err.get('code')})"
314
+ )
315
+ result = message.get("result", {})
316
+ if result.get("exceptionDetails"):
317
+ details = result["exceptionDetails"]
318
+ exc = details.get("exception", {})
319
+ description = exc.get("description") or details.get("text") or json.dumps(details)
320
+ raise CDPEvaluateError(f"JS exception evaluating {expression!r}: {description}")
321
+ value = result.get("result", {})
322
+ if "value" in value:
323
+ return value["value"]
324
+ return value
325
+
326
+ # -- teardown -----------------------------------------------------------
327
+
328
+ def close(self) -> None:
329
+ """Close the WebSocket/socket if open; safe to call more than once."""
330
+ sock = self._sock
331
+ self._sock = None
332
+ if sock is None:
333
+ return
334
+ try:
335
+ sock.close()
336
+ except OSError:
337
+ pass # socket already torn down — closing a dead socket is not an error
338
+
339
+ def __enter__(self) -> "CDPSession":
340
+ # Entering the context opens the connection — the idiomatic session
341
+ # contract, so `with CDPSession() as s: s.evaluate(...)` works without a
342
+ # separate connect() call. A real consumer (tests/manual/test_live.py)
343
+ # relied on this; the CLI also calls connect() explicitly, which is now a
344
+ # safe no-op thanks to connect()'s idempotence.
345
+ self.connect()
346
+ return self
347
+
348
+ def __exit__(self, exc_type, exc, tb) -> None:
349
+ self.close()