fvpnctl 0.1.0__tar.gz

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,43 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ # Minimal token scope: this workflow only reads the repo to run tests.
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ test:
14
+ # The unit suite is platform-agnostic: it mocks `subprocess`/sockets and the
15
+ # one live test skips unless FORTI_LIVE=1, so Linux runners are sufficient and
16
+ # fast even though the tool itself is macOS-only at runtime.
17
+ runs-on: ubuntu-latest
18
+ strategy:
19
+ fail-fast: false
20
+ matrix:
21
+ python-version: ["3.11", "3.12", "3.13"]
22
+
23
+ steps:
24
+ - uses: actions/checkout@v4
25
+
26
+ - name: Install uv
27
+ # Third-party action pinned to a full commit SHA (supply-chain safety for
28
+ # fork PRs on a public repo); comment tracks the human-readable version.
29
+ uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
30
+ with:
31
+ python-version: ${{ matrix.python-version }}
32
+
33
+ - name: Install dependencies
34
+ run: uv sync
35
+
36
+ - name: Ruff lint
37
+ run: uv run ruff check .
38
+
39
+ - name: Ruff format check
40
+ run: uv run ruff format --check .
41
+
42
+ - name: Run tests
43
+ run: uv run pytest
@@ -0,0 +1,20 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ .venv/
7
+ venv/
8
+ dist/
9
+ build/
10
+ .pytest_cache/
11
+ .ruff_cache/
12
+ .mypy_cache/
13
+
14
+ # macOS
15
+ .DS_Store
16
+
17
+ # Editors
18
+ .idea/
19
+ .vscode/
20
+ *.swp
@@ -0,0 +1,21 @@
1
+ # Pre-commit hooks. Run `pre-commit install` once to enable the git hook.
2
+ # Ruff handles lint + format; the pre-commit-hooks set covers the usual hygiene.
3
+ repos:
4
+ - repo: https://github.com/pre-commit/pre-commit-hooks
5
+ rev: v5.0.0
6
+ hooks:
7
+ - id: trailing-whitespace
8
+ - id: end-of-file-fixer
9
+ - id: check-yaml
10
+ - id: check-toml
11
+ - id: check-added-large-files
12
+ - id: check-merge-conflict
13
+ - id: mixed-line-ending
14
+ - id: detect-private-key # critical for OSS: blocks committing private keys
15
+
16
+ - repo: https://github.com/astral-sh/ruff-pre-commit
17
+ rev: v0.13.0
18
+ hooks:
19
+ - id: ruff
20
+ args: [--fix]
21
+ - id: ruff-format
fvpnctl-0.1.0/LICENSE ADDED
@@ -0,0 +1,27 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Michał Pasternak
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ This is an independent, unofficial project and is not affiliated with,
26
+ endorsed by, or sponsored by Fortinet, Inc. "FortiClient" and "Fortinet" are
27
+ registered trademarks of Fortinet, Inc. (https://www.fortinet.com).
fvpnctl-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,410 @@
1
+ Metadata-Version: 2.4
2
+ Name: fvpnctl
3
+ Version: 0.1.0
4
+ Summary: Control your FortiClient VPN from the macOS command line (no GUI automation).
5
+ Project-URL: Homepage, https://github.com/mpasternak/fortivpn-cli
6
+ Project-URL: Repository, https://github.com/mpasternak/fortivpn-cli
7
+ Project-URL: Issues, https://github.com/mpasternak/fortivpn-cli/issues
8
+ Author: Michał Pasternak
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: cli,forticlient,fortinet,ipsec,macos,vpn
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: System Administrators
15
+ Classifier: Operating System :: MacOS
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: System :: Networking
21
+ Requires-Python: >=3.11
22
+ Description-Content-Type: text/markdown
23
+
24
+ # fvpnctl — control your FortiClient VPN from the macOS command line
25
+
26
+ [![CI](https://github.com/mpasternak/fortivpn-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/mpasternak/fortivpn-cli/actions/workflows/ci.yml)
27
+
28
+ A small command-line tool (`fvpnctl`) and Python library for **connecting, disconnecting,
29
+ and checking the status of FortiClient VPN profiles from the terminal** — handy for scripts,
30
+ automation, and headless or over-SSH use, with no GUI needed. It works with a FortiClient you
31
+ already have running, talking to it over a local debugging port.
32
+
33
+ - Zero runtime dependencies (Python standard library only).
34
+ - Python ≥ 3.11, macOS only.
35
+ - Attach-only by default: commands attach to a running FortiClient; the one explicit
36
+ exception is `fvpnctl startserver`, an opt-in launcher you invoke yourself.
37
+
38
+ > **Disclaimer.** This is an independent, unofficial project. It is **not affiliated with,
39
+ > endorsed by, or sponsored by Fortinet, Inc.** "FortiClient" and "Fortinet" are registered
40
+ > trademarks of Fortinet, Inc. (<https://www.fortinet.com>); they are used here only to
41
+ > identify the software this tool interoperates with. This software is provided **"as is",
42
+ > without warranty of any kind**: you use it **entirely at your own risk**, and the author
43
+ > accepts **no liability** for any damage, data loss, dropped VPN connections, or other
44
+ > losses arising from its use. It relies on a local debugging interface that may change
45
+ > between FortiClient versions. Ensure your use complies with your organization's policies
46
+ > and with Fortinet's licensing terms. See [LICENSE](LICENSE) for the full warranty/liability
47
+ > disclaimer.
48
+
49
+ ---
50
+
51
+ ## What it is
52
+
53
+ [FortiClient](https://www.fortinet.com/products/endpoint-security/forticlient) is excellent,
54
+ mature, full-featured security and VPN software — `fvpnctl` doesn't replace any of it. It
55
+ simply adds a thin, scriptable command line on top, so you can bring VPN profiles up and down
56
+ and check their status from the terminal, from a script, or over SSH, without opening the GUI.
57
+
58
+ It does this by talking to a FortiClient that you launch yourself, over a local debugging
59
+ port — so there is no UI scraping and the behaviour is deterministic: a command issues a
60
+ request and reads back a clear connection state. Tested against FortiClient 7.4.x on macOS.
61
+
62
+ ### Running FortiClient with the debugging port
63
+
64
+ `fvpnctl` attaches to a running FortiClient over a local debugging port, which FortiClient
65
+ exposes when it is launched with `--remote-debugging-port=<port>` (this is off in the normal
66
+ tray-GUI mode). So **you launch FortiClient yourself with that flag**, and `fvpnctl` attaches
67
+ to it.
68
+
69
+ The tool is **attach-only by default**: it never *automatically* launches, quits, or restarts
70
+ FortiClient. That is a deliberate safety choice — FortiClient owns the tunnel and the system
71
+ network configuration, and a CLI silently bouncing it would be surprising and could drop a
72
+ live connection. The single explicit exception is `fvpnctl startserver`, which you run on
73
+ purpose to start FortiClient headless (handy for ad-hoc use). Otherwise lifecycle is yours (a
74
+ LaunchAgent does it once, at login). If nothing is listening on the debug port, every *other*
75
+ command fails fast with exit code `3` and tells you exactly how to start it — including the
76
+ `fvpnctl startserver` shortcut.
77
+
78
+ The recommended launch is either `fvpnctl startserver` or, equivalently, by hand:
79
+
80
+ ```bash
81
+ /Applications/FortiClient.app/Contents/MacOS/FortiClient --hide-gui --remote-debugging-port=9222
82
+ ```
83
+
84
+ `--hide-gui` runs it without the tray window (you drive everything through `fvpnctl`), and
85
+ `--remote-debugging-port=9222` exposes the debugging port on `127.0.0.1:9222`.
86
+
87
+ ---
88
+
89
+ ## Install
90
+
91
+ The project is managed with [`uv`](https://docs.astral.sh/uv/) and has **no runtime
92
+ dependencies**. The PyPI package is `fvpnctl`; the command it installs is `fvpnctl`.
93
+
94
+ **From PyPI:**
95
+
96
+ ```bash
97
+ uv tool install fvpnctl # puts the `fvpnctl` command on your PATH
98
+ fvpnctl list
99
+ ```
100
+
101
+ Or run it once, without installing, with `uvx`:
102
+
103
+ ```bash
104
+ uvx fvpnctl list
105
+ ```
106
+
107
+ (`pipx install fvpnctl` / `pip install fvpnctl` work too.)
108
+
109
+ **From source (this checkout):**
110
+
111
+ ```bash
112
+ uv tool install . # install the `fvpnctl` command from the local tree
113
+ uv run fvpnctl list # or run it in place, without installing
114
+ ```
115
+
116
+ Requirements: macOS, Python ≥ 3.11.
117
+
118
+ ---
119
+
120
+ ## Setup
121
+
122
+ Two one-time setup steps: make FortiClient run headless + debug at login, and put your VPN
123
+ password into the Keychain.
124
+
125
+ ### 1. Run FortiClient headless + debug at login (LaunchAgent)
126
+
127
+ This tool only attaches; something has to start FortiClient in debug mode. The clean way
128
+ is a per-user LaunchAgent that launches it once at login. A ready-to-use plist ships in
129
+ [`contrib/com.fvpnctl.headless.plist`](./contrib/com.fvpnctl.headless.plist):
130
+
131
+ ```xml
132
+ <?xml version="1.0" encoding="UTF-8"?>
133
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
134
+ <plist version="1.0">
135
+ <dict>
136
+ <key>Label</key>
137
+ <string>com.fvpnctl.headless</string>
138
+ <key>ProgramArguments</key>
139
+ <array>
140
+ <string>/Applications/FortiClient.app/Contents/MacOS/FortiClient</string>
141
+ <string>--hide-gui</string>
142
+ <string>--remote-debugging-port=9222</string>
143
+ </array>
144
+ <key>RunAtLoad</key>
145
+ <true/>
146
+ <key>KeepAlive</key>
147
+ <true/>
148
+ </dict>
149
+ </plist>
150
+ ```
151
+
152
+ Install and load it:
153
+
154
+ ```bash
155
+ cp contrib/com.fvpnctl.headless.plist ~/Library/LaunchAgents/
156
+ launchctl load -w ~/Library/LaunchAgents/com.fvpnctl.headless.plist
157
+ ```
158
+
159
+ `RunAtLoad` starts it at login; `KeepAlive` relaunches it if it exits, so the debug port is
160
+ always available. To stop using it:
161
+
162
+ ```bash
163
+ launchctl unload -w ~/Library/LaunchAgents/com.fvpnctl.headless.plist
164
+ ```
165
+
166
+ **Important — this replaces the normal tray-GUI mode.** FortiClient is a single-instance
167
+ app: it cannot run twice. Running it headless + debug means you are *not* running the
168
+ ordinary tray-GUI FortiClient at the same time. Switching between the two modes (e.g.
169
+ quitting the headless instance and reopening the GUI app, or vice versa) **drops any active
170
+ tunnel** — restarting the FortiClient process tears the connection down. So pick one mode;
171
+ if you adopt this tool, let the LaunchAgent own FortiClient and drive everything through
172
+ `fvpnctl`.
173
+
174
+ > If you customise the port, keep it consistent: pass `--port`/`FORTI_CDP_PORT` to `fvpnctl`
175
+ > (see Usage) so it matches the `--remote-debugging-port` in the plist.
176
+
177
+ ### 2. Add your VPN credentials to the Keychain
178
+
179
+ The password is never passed on the command line. It lives in the macOS **login Keychain**
180
+ as a generic-password item and is read inside the process at connect time. Add one item per
181
+ profile:
182
+
183
+ ```bash
184
+ security add-generic-password -s forti-vpn-<profile> -a <username> -w
185
+ # security will prompt for the password interactively (the -w with no value).
186
+ ```
187
+
188
+ For example, for an IPsec profile named `office` with username `alice`:
189
+
190
+ ```bash
191
+ security add-generic-password -s forti-vpn-office -a alice -w
192
+ ```
193
+
194
+ **The `forti-vpn-<profile>` service-name convention.** The Keychain *service* name (`-s`)
195
+ is always `forti-vpn-` followed by the exact profile name, and the *account* name (`-a`) is
196
+ the VPN username. This convention is shared verbatim with the sibling AppleScript tool, so
197
+ **any Keychain item you already created for that tool works here unchanged** — it is the
198
+ same item, looked up the same way. At connect time `fvpnctl` runs
199
+ `security find-generic-password -s forti-vpn-<profile> -a <username> -w` to read it back.
200
+
201
+ If the item is missing or access is denied, `connect` fails with exit code `4`
202
+ (`KeychainError`) and prints the exact `security add-generic-password …` command to fix it.
203
+
204
+ ---
205
+
206
+ ## Usage
207
+
208
+ ```
209
+ fvpnctl [--port N] [--host H] <command> ...
210
+ ```
211
+
212
+ Global options:
213
+
214
+ - `--port N` — FortiClient debug port (default `9222`). Overridable by the `FORTI_CDP_PORT`
215
+ environment variable. Must match the `--remote-debugging-port` FortiClient was launched
216
+ with.
217
+ - `--host H` — debug host (default `127.0.0.1`).
218
+ - `--verbose` / `--quiet` — progress messages. Verbose is **on by default** and writes
219
+ progress to **stderr**; `--quiet` silences it. Either way `stdout` carries only the
220
+ machine-readable result, so `--json` output and shell pipelines are byte-identical.
221
+
222
+ `list` and `status` additionally accept `--json` for machine-readable output.
223
+
224
+ ### `fvpnctl list`
225
+
226
+ List configured VPN profiles.
227
+
228
+ ```console
229
+ $ fvpnctl list
230
+ NAME TYPE SERVER
231
+ office ipsec vpn.example.com
232
+ acme ipsec gw.acme.example
233
+ ```
234
+
235
+ ```console
236
+ $ fvpnctl list --json
237
+ [{"connection_name": "office", "type": "ipsec", ...}, ...]
238
+ ```
239
+
240
+ ### `fvpnctl status`
241
+
242
+ Show the current tunnel state. When connected, it also reports the tunnel IP, duration, and
243
+ traffic.
244
+
245
+ ```console
246
+ $ fvpnctl status
247
+ CONNECTED office 172.16.200.2 (00:01:45, ↓1.6KB ↑0)
248
+ ```
249
+
250
+ ```console
251
+ $ fvpnctl status
252
+ DISCONNECTED
253
+ ```
254
+
255
+ ```console
256
+ $ fvpnctl status --json
257
+ {"ipsec_state": 2, "ssl_state": 0, "connection_name": "office", ...}
258
+ ```
259
+
260
+ ### `fvpnctl connect <profile> [-u USER] [--no-wait] [--timeout S]`
261
+
262
+ Connect an IPsec profile. The username defaults to the one stored in the profile; the
263
+ password is read from the Keychain. By default it waits until the tunnel reaches CONNECTED.
264
+
265
+ ```console
266
+ $ fvpnctl connect office
267
+ connecting office...
268
+ CONNECTED office 172.16.200.2
269
+ ```
270
+
271
+ Options:
272
+
273
+ - `-u USER` / `--user USER` — override the username (also selects the Keychain account).
274
+ - `--no-wait` — issue the connect and return immediately without polling for CONNECTED.
275
+ - `--timeout S` — how long to wait for CONNECTED before giving up (default 30s). A tunnel
276
+ that never leaves DISCONNECTED (a silent rejection) ends in a timeout (exit `7`); a
277
+ tunnel that started negotiating and then dropped (e.g. bad credentials) is a connect
278
+ failure (exit `6`).
279
+ - `--show-window` — keep FortiClient's window visible after connecting (see the note below).
280
+
281
+ By default, after a successful connect `fvpnctl` **hides FortiClient's window**. FortiClient
282
+ pops its main window on connect even under `--hide-gui`, so `connect` sends it back to the
283
+ tray for you (without quitting the app) — best effort, so a failure to hide never fails an
284
+ otherwise-successful connect. Pass `--show-window` to keep the window up. (This only applies
285
+ in the waited path; with `--no-wait` the popup happens after the command returns.)
286
+
287
+ ### `fvpnctl disconnect <profile>`
288
+
289
+ Disconnect an IPsec profile.
290
+
291
+ ```console
292
+ $ fvpnctl disconnect office
293
+ DISCONNECTED office
294
+ ```
295
+
296
+ ### `fvpnctl ip`
297
+
298
+ Print just the assigned tunnel IP — handy in scripts. Exits `1` with a message on stderr if
299
+ not connected.
300
+
301
+ ```console
302
+ $ fvpnctl ip
303
+ 172.16.200.2
304
+ ```
305
+
306
+ ### `fvpnctl hide-window`
307
+
308
+ Hide FortiClient's main window to the tray (without quitting the app). `connect` already does
309
+ this by default; run it manually when the window is up for another reason (e.g. a
310
+ `connect --show-window`, or FortiClient popped it itself).
311
+
312
+ ```console
313
+ $ fvpnctl hide-window
314
+ ```
315
+
316
+ ### `fvpnctl startserver`
317
+
318
+ Launch FortiClient headless with the debug port enabled, so the attach-only commands have something to
319
+ attach to. **This is the one command that starts FortiClient** — every other command only
320
+ attaches. It is idempotent (does nothing if the port already answers), and if FortiClient is
321
+ not installed it prints a download hint and exits `8`.
322
+
323
+ ```console
324
+ $ fvpnctl startserver
325
+ FortiClient debug port ready on 127.0.0.1:9222
326
+ ```
327
+
328
+ Use it for ad-hoc sessions; for a permanent setup prefer the LaunchAgent above. FortiClient
329
+ is single-instance: if an ordinary tray-GUI FortiClient is already running, quit it first —
330
+ starting a second instance just forwards its arguments to the first, which won't open the
331
+ debug port.
332
+
333
+ - `--no-wait` — launch and return immediately without waiting for the port to open.
334
+
335
+ ---
336
+
337
+ ## Exit codes
338
+
339
+ The exit code is selected by the *type* of failure, so scripts can branch on it. These
340
+ match [`src/fvpnctl/errors.py`](./src/fvpnctl/errors.py).
341
+
342
+ | Code | Meaning |
343
+ |------|---------|
344
+ | `0` | Success |
345
+ | `2` | Usage error (bad arguments; from argparse) |
346
+ | `3` | FortiClient not running / not reachable on the debug port (`NotRunningError`) |
347
+ | `4` | Keychain lookup failed — item missing or access denied (`KeychainError`) |
348
+ | `5` | Unsupported in v1 — SSL profile or 2FA/XAUTH required (`UnsupportedError`) |
349
+ | `6` | Connect failed (negotiated then dropped) or an internal call to FortiClient failed (`ConnectFailed` / `CDPEvaluateError`) |
350
+ | `7` | Timed out waiting for CONNECTED, including never leaving DISCONNECTED (`ConnectTimeout`) |
351
+ | `8` | FortiClient is not installed — `startserver` could not find the app (`FortiClientNotFoundError`) |
352
+ | `1` | Any other `FortiError` |
353
+
354
+ ---
355
+
356
+ ## Security note
357
+
358
+ Your VPN password is treated as a long-lived secret and is handled carefully:
359
+
360
+ - It is read from the **login Keychain inside the process** at connect time and held only
361
+ in a local variable for the duration of that one connect call.
362
+ - It is **never printed, never logged, and never placed in an exception message** — error
363
+ messages are built only from non-secret identifiers (profile and username).
364
+ - It is **never put into argv or the environment**: the command line carries only the
365
+ profile name and (optionally) the username, never the password. Only Apple's `security`
366
+ tool and FortiClient itself ever hold the secret.
367
+
368
+ ---
369
+
370
+ ## Limitations (v1)
371
+
372
+ This release deliberately covers only the path that was empirically validated. Out of
373
+ scope, with a clear error rather than a guess:
374
+
375
+ - **IPsec only.** Connecting an SSL VPN profile raises `UnsupportedError` (exit `5`); SSL
376
+ was untested in the spike.
377
+ - **No 2FA.** If the daemon enters the XAUTH state (a token/OTP is required), `connect`
378
+ raises `UnsupportedError("2FA not supported in v1")` (exit `5`) instead of prompting.
379
+ - **No profile management.** No create / delete / rename / import — profiles are managed in
380
+ FortiClient itself.
381
+ - **No automatic lifecycle management.** Commands never auto-start, quit, or restart
382
+ FortiClient. The one explicit launcher is `fvpnctl startserver` (or install the LaunchAgent
383
+ above); the tool still never quits or restarts a *running* FortiClient.
384
+ - **No default profile / config file.** The profile name is always passed explicitly.
385
+
386
+ For more on how it works, see [`docs/how-it-works.md`](./docs/how-it-works.md).
387
+
388
+ ---
389
+
390
+ ## Development
391
+
392
+ ```bash
393
+ uv sync # create the venv and install dev dependencies (pytest)
394
+ uv run pytest # run the test suite
395
+ uv run ruff check . # lint
396
+ uv run ruff format . # format
397
+ ```
398
+
399
+ Pre-commit hooks (ruff lint + format) are configured in `.pre-commit-config.yaml`:
400
+
401
+ ```bash
402
+ uv run pre-commit install
403
+ uv run pre-commit run --all-files
404
+ ```
405
+
406
+ The unit suite runs entirely without FortiClient (the connection is mocked). An
407
+ **attended** integration test lives in [`tests/manual/test_live.py`](./tests/manual/test_live.py);
408
+ it is skipped by default and only runs when you set `FORTI_LIVE=1` against a real
409
+ headless + debug FortiClient — note that it **breaks the live tunnel** (connect →
410
+ status → disconnect). See that file's docstring for how to run it.