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.
- fvpnctl-0.1.0/.github/workflows/ci.yml +43 -0
- fvpnctl-0.1.0/.gitignore +20 -0
- fvpnctl-0.1.0/.pre-commit-config.yaml +21 -0
- fvpnctl-0.1.0/LICENSE +27 -0
- fvpnctl-0.1.0/PKG-INFO +410 -0
- fvpnctl-0.1.0/README.md +387 -0
- fvpnctl-0.1.0/contrib/com.fvpnctl.headless.plist +42 -0
- fvpnctl-0.1.0/docs/how-it-works.md +83 -0
- fvpnctl-0.1.0/docs/superpowers/specs/2026-06-20-fortivpn-cli-via-cdp-design.md +205 -0
- fvpnctl-0.1.0/pyproject.toml +58 -0
- fvpnctl-0.1.0/src/fvpnctl/__init__.py +3 -0
- fvpnctl-0.1.0/src/fvpnctl/cdp.py +349 -0
- fvpnctl-0.1.0/src/fvpnctl/cli.py +521 -0
- fvpnctl-0.1.0/src/fvpnctl/controller.py +421 -0
- fvpnctl-0.1.0/src/fvpnctl/errors.py +136 -0
- fvpnctl-0.1.0/src/fvpnctl/keychain.py +74 -0
- fvpnctl-0.1.0/src/fvpnctl/launcher.py +243 -0
- fvpnctl-0.1.0/tests/manual/test_live.py +85 -0
- fvpnctl-0.1.0/tests/test_cdp.py +391 -0
- fvpnctl-0.1.0/tests/test_cli.py +747 -0
- fvpnctl-0.1.0/tests/test_controller.py +375 -0
- fvpnctl-0.1.0/tests/test_errors.py +76 -0
- fvpnctl-0.1.0/tests/test_keychain.py +129 -0
- fvpnctl-0.1.0/tests/test_launcher.py +302 -0
- fvpnctl-0.1.0/uv.lock +108 -0
|
@@ -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
|
fvpnctl-0.1.0/.gitignore
ADDED
|
@@ -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
|
+
[](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.
|