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 +3 -0
- fvpnctl/cdp.py +349 -0
- fvpnctl/cli.py +521 -0
- fvpnctl/controller.py +421 -0
- fvpnctl/errors.py +136 -0
- fvpnctl/keychain.py +74 -0
- fvpnctl/launcher.py +243 -0
- fvpnctl-0.1.0.dist-info/METADATA +410 -0
- fvpnctl-0.1.0.dist-info/RECORD +12 -0
- fvpnctl-0.1.0.dist-info/WHEEL +4 -0
- fvpnctl-0.1.0.dist-info/entry_points.txt +2 -0
- fvpnctl-0.1.0.dist-info/licenses/LICENSE +27 -0
fvpnctl/__init__.py
ADDED
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()
|