Control your FortiClient VPN from the macOS command line.
Project description
fvpnctl — control your FortiClient VPN from the macOS command line
A small command-line tool (fvpnctl) and Python library for connecting, disconnecting,
checking, and live-monitoring the status of FortiClient VPN profiles from the terminal —
handy for scripts, automation, and headless or over-SSH use. It works with a FortiClient you
already have running, talking to it over a local debugging port.
- Zero runtime dependencies (Python standard library only).
- macOS only (Python ≥ 3.11). There is no Windows support — it drives the
macOS FortiClient.app, so
pip/uv/uvxdeliberately refuse to install on Windows, and running it from a source checkout there exits with a clear error. - Attach-only by default: commands attach to a running FortiClient; the one explicit
exception is
fvpnctl startserver, an opt-in launcher you invoke yourself.
Disclaimer. This is an independent, unofficial project. It is not affiliated with, endorsed by, or sponsored by Fortinet, Inc. "FortiClient" and "Fortinet" are registered trademarks of Fortinet, Inc. (https://www.fortinet.com); they are used here only to identify the software this tool interoperates with. This software is provided "as is", without warranty of any kind: you use it entirely at your own risk, and the author accepts no liability for any damage, data loss, dropped VPN connections, or other losses arising from its use. It relies on a local debugging interface that may change between FortiClient versions. Ensure your use complies with your organization's policies and with Fortinet's licensing terms. See LICENSE for the full warranty/liability disclaimer.
What it is
FortiClient is excellent,
mature, full-featured security and VPN software — fvpnctl doesn't replace any of it. It
simply adds a thin, scriptable command line on top, so you can bring VPN profiles up and down
and check their status from the terminal, from a script, or over SSH, without opening the GUI.
It does this by talking to a FortiClient that you launch yourself, over a local debugging port — so there is no UI scraping and the behaviour is deterministic: a command issues a request and reads back a clear connection state. Tested against FortiClient 7.4.x on macOS.
Running FortiClient with the debugging port
fvpnctl attaches to a running FortiClient over a local debugging port, which FortiClient
exposes when it is launched with --remote-debugging-port=<port> (this is off in the normal
tray-GUI mode). So you launch FortiClient yourself with that flag, and fvpnctl attaches
to it.
The tool is attach-only by default: it never automatically launches, quits, or restarts
FortiClient. That is a deliberate safety choice — FortiClient owns the tunnel and the system
network configuration, and a CLI silently bouncing it would be surprising and could drop a
live connection. The single explicit exception is fvpnctl startserver, which you run on
purpose to start FortiClient headless (handy for ad-hoc use). Otherwise lifecycle is yours (a
LaunchAgent does it once, at login). If nothing is listening on the debug port, every other
command fails fast with exit code 3 and tells you exactly how to start it — including the
fvpnctl startserver shortcut.
The recommended launch is either fvpnctl startserver or, equivalently, by hand:
/Applications/FortiClient.app/Contents/MacOS/FortiClient --hide-gui --remote-debugging-port=9222
--hide-gui runs it without the tray window (you drive everything through fvpnctl), and
--remote-debugging-port=9222 exposes the debugging port on 127.0.0.1:9222.
Install
The project is managed with uv and has no runtime
dependencies. The PyPI package is fvpnctl; the command it installs is fvpnctl.
From PyPI:
uv tool install fvpnctl # puts the `fvpnctl` command on your PATH
fvpnctl list
Or run it once, without installing, with uvx:
uvx fvpnctl list
(pipx install fvpnctl / pip install fvpnctl work too.)
From source (this checkout):
uv tool install . # install the `fvpnctl` command from the local tree
uv run fvpnctl list # or run it in place, without installing
Requirements: macOS, Python ≥ 3.11. Windows is not supported — installation on
Windows fails on purpose (the package declares an unsatisfiable win32 requirement),
and there is no Linux support for the runtime either (the CLI talks to macOS
FortiClient.app). Linux is used only by CI to run the mocked test suite.
Setup
Two one-time setup steps: make FortiClient run headless + debug at login, and put your VPN password into the Keychain.
1. Run FortiClient headless + debug at login (LaunchAgent)
This tool only attaches; something has to start FortiClient in debug mode. The clean way
is a per-user LaunchAgent that launches it once at login. A ready-to-use plist ships in
contrib/com.fvpnctl.headless.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.fvpnctl.headless</string>
<key>ProgramArguments</key>
<array>
<string>/Applications/FortiClient.app/Contents/MacOS/FortiClient</string>
<string>--hide-gui</string>
<string>--remote-debugging-port=9222</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
</dict>
</plist>
Install and load it:
cp contrib/com.fvpnctl.headless.plist ~/Library/LaunchAgents/
launchctl load -w ~/Library/LaunchAgents/com.fvpnctl.headless.plist
RunAtLoad starts it at login; KeepAlive relaunches it if it exits, so the debug port is
always available. To stop using it:
launchctl unload -w ~/Library/LaunchAgents/com.fvpnctl.headless.plist
Important — this replaces the normal tray-GUI mode. FortiClient is a single-instance
app: it cannot run twice. Running it headless + debug means you are not running the
ordinary tray-GUI FortiClient at the same time. Switching between the two modes (e.g.
quitting the headless instance and reopening the GUI app, or vice versa) drops any active
tunnel — restarting the FortiClient process tears the connection down. So pick one mode;
if you adopt this tool, let the LaunchAgent own FortiClient and drive everything through
fvpnctl.
If you customise the port, keep it consistent: pass
--port/FORTI_CDP_PORTtofvpnctl(see Usage) so it matches the--remote-debugging-portin the plist.
2. Add your VPN credentials to the Keychain
The password is never passed on the command line. It lives in the macOS login Keychain as a generic-password item and is read inside the process at connect time. Add one item per profile:
security add-generic-password -s forti-vpn-<profile> -a <username> -w
# security will prompt for the password interactively (the -w with no value).
For example, for an IPsec profile named office with username alice:
security add-generic-password -s forti-vpn-office -a alice -w
The forti-vpn-<profile> service-name convention. The Keychain service name (-s)
is always forti-vpn- followed by the exact profile name, and the account name (-a) is
the VPN username. This convention is shared verbatim with the sibling AppleScript tool, so
any Keychain item you already created for that tool works here unchanged — it is the
same item, looked up the same way. At connect time fvpnctl runs
security find-generic-password -s forti-vpn-<profile> -a <username> -w to read it back.
If the item is missing or access is denied, connect fails with exit code 4
(KeychainError) and prints the exact security add-generic-password … command to fix it.
Usage
fvpnctl [--port N] [--host H] <command> ...
Global options:
--port N— FortiClient debug port (default9222). Overridable by theFORTI_CDP_PORTenvironment variable. Must match the--remote-debugging-portFortiClient was launched with.--host H— debug host (default127.0.0.1).--verbose/--quiet— progress messages. Verbose is on by default and writes progress to stderr;--quietsilences it. Either waystdoutcarries only the machine-readable result, so--jsonoutput and shell pipelines are byte-identical.
list and status additionally accept --json for machine-readable output.
fvpnctl list
List configured VPN profiles.
$ fvpnctl list
NAME TYPE SERVER
office ipsec vpn.example.com
acme ipsec gw.acme.example
$ fvpnctl list --json
[{"connection_name": "office", "type": "ipsec", ...}, ...]
fvpnctl status
Show the current tunnel state. When connected, it also reports the tunnel IP, duration, and traffic.
$ fvpnctl status
CONNECTED office 172.16.200.2 (00:01:45, ↓1.6KB ↑0)
$ fvpnctl status
DISCONNECTED
$ fvpnctl status --json
{"ipsec_state": 2, "ssl_state": 0, "connection_name": "office", ...}
fvpnctl monitor [-n S]
Live-watch the tunnel and exit 0 the moment it disconnects — the moving-picture companion
to status. Instead of raw cumulative byte counters, it derives throughput rates from the
deltas between polls and redraws in place. It adapts to where it is writing:
- a wide terminal → a boxed, colored dashboard with live rates and a throughput sparkline;
- a narrow terminal → a single refreshing status line;
- a pipe / redirect (not a TTY) → one plain status line per poll, so the stream stays greppable and loggable.
$ fvpnctl monitor
┌─ fvpnctl monitor ────────────────────────────────┐
│ ● CONNECTED apoz │
│ │
│ IP 10.10.115.1 │
│ Uptime 00:10:54 │
│ Down ↓ 12.3 KB/s total 764.2 KB │
│ Up ↑ 4.1 KB/s total 863.1 KB │
│ │
│ ▁▂▃▃▆▅▄▃▂▁▂▃▆█▇▅ throughput │
└──────────────────────────────────────────────────┘
polling every 2s · Ctrl-C to quit
The status dot is green when CONNECTED, yellow while CONNECTING/RECONNECTING, red when down.
Color is disabled automatically when output is not a terminal or when NO_COLOR is set.
Options:
-n S/--interval S— seconds between polls (default2).
Because it exits cleanly on disconnect, it composes in shell: fvpnctl monitor && say "VPN dropped".
Piped, it doubles as a logger:
$ fvpnctl monitor | tee vpn.log
CONNECTED apoz 10.10.115.1 (00:10:54, in=782548 out=883792, ↓12.3 KB/s ↑4.1 KB/s)
...
DISCONNECTED
fvpnctl connect <profile> [-u USER] [--no-wait] [--timeout S]
Connect an IPsec profile. The username defaults to the one stored in the profile; the password is read from the Keychain. By default it waits until the tunnel reaches CONNECTED.
$ fvpnctl connect office
connecting office...
CONNECTED office 172.16.200.2
Options:
-u USER/--user USER— override the username (also selects the Keychain account).--no-wait— issue the connect and return immediately without polling for CONNECTED.--timeout S— how long to wait for CONNECTED before giving up (default 30s). A tunnel that never leaves DISCONNECTED (a silent rejection) ends in a timeout (exit7); a tunnel that started negotiating and then dropped (e.g. bad credentials) is a connect failure (exit6).--show-window— keep FortiClient's window visible after connecting (see the note below).
By default, after a successful connect fvpnctl hides FortiClient's window. FortiClient
pops its main window on connect even under --hide-gui, so connect sends it back to the
tray for you (without quitting the app) — best effort, so a failure to hide never fails an
otherwise-successful connect. Pass --show-window to keep the window up. (This only applies
in the waited path; with --no-wait the popup happens after the command returns.)
fvpnctl disconnect <profile>
Disconnect an IPsec profile.
$ fvpnctl disconnect office
DISCONNECTED office
fvpnctl ip
Print just the assigned tunnel IP — handy in scripts. Exits 1 with a message on stderr if
not connected.
$ fvpnctl ip
172.16.200.2
fvpnctl hide-window
Hide FortiClient's main window to the tray (without quitting the app). connect already does
this by default; run it manually when the window is up for another reason (e.g. a
connect --show-window, or FortiClient popped it itself).
$ fvpnctl hide-window
fvpnctl startserver
Launch FortiClient headless with the debug port enabled, so the attach-only commands have something to
attach to. This is the one command that starts FortiClient — every other command only
attaches. It is idempotent (does nothing if the port already answers), and if FortiClient is
not installed it prints a download hint and exits 8.
$ fvpnctl startserver
FortiClient debug port ready on 127.0.0.1:9222
Use it for ad-hoc sessions; for a permanent setup prefer the LaunchAgent above. FortiClient is single-instance: if an ordinary tray-GUI FortiClient is already running, quit it first — starting a second instance just forwards its arguments to the first, which won't open the debug port.
--no-wait— launch and return immediately without waiting for the port to open.
Exit codes
The exit code is selected by the type of failure, so scripts can branch on it. These
match src/fvpnctl/errors.py.
| Code | Meaning |
|---|---|
0 |
Success |
2 |
Usage error (bad arguments; from argparse) |
3 |
FortiClient not running / not reachable on the debug port (NotRunningError) |
4 |
Keychain lookup failed — item missing or access denied (KeychainError) |
5 |
Unsupported in v1 — SSL profile or 2FA/XAUTH required (UnsupportedError) |
6 |
Connect failed (negotiated then dropped) or an internal call to FortiClient failed (ConnectFailed / CDPEvaluateError) |
7 |
Timed out waiting for CONNECTED, including never leaving DISCONNECTED (ConnectTimeout) |
8 |
FortiClient is not installed — startserver could not find the app (FortiClientNotFoundError) |
1 |
Any other FortiError |
Security note
Your VPN password is treated as a long-lived secret and is handled carefully:
- It is read from the login Keychain inside the process at connect time and held only in a local variable for the duration of that one connect call.
- It is never printed, never logged, and never placed in an exception message — error messages are built only from non-secret identifiers (profile and username).
- It is never put into argv or the environment: the command line carries only the
profile name and (optionally) the username, never the password. Only Apple's
securitytool and FortiClient itself ever hold the secret.
Limitations (v1)
This release deliberately covers only the path that was empirically validated. Out of scope, with a clear error rather than a guess:
- IPsec only. Connecting an SSL VPN profile raises
UnsupportedError(exit5); SSL was untested in the spike. - No 2FA. If the daemon enters the XAUTH state (a token/OTP is required),
connectraisesUnsupportedError("2FA not supported in v1")(exit5) instead of prompting. - No profile management. No create / delete / rename / import — profiles are managed in FortiClient itself.
- No automatic lifecycle management. Commands never auto-start, quit, or restart
FortiClient. The one explicit launcher is
fvpnctl startserver(or install the LaunchAgent above); the tool still never quits or restarts a running FortiClient. - No default profile / config file. The profile name is always passed explicitly.
For more on how it works, see docs/how-it-works.md.
Development
uv sync # create the venv and install dev dependencies (pytest)
uv run pytest # run the test suite
uv run ruff check . # lint
uv run ruff format . # format
Pre-commit hooks (ruff lint + format) are configured in .pre-commit-config.yaml:
uv run pre-commit install
uv run pre-commit run --all-files
The unit suite runs entirely without FortiClient (the connection is mocked). An
attended integration test lives in tests/manual/test_live.py;
it is skipped by default and only runs when you set FORTI_LIVE=1 against a real
headless + debug FortiClient — note that it breaks the live tunnel (connect →
status → disconnect). See that file's docstring for how to run it.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file fvpnctl-0.2.1.tar.gz.
File metadata
- Download URL: fvpnctl-0.2.1.tar.gz
- Upload date:
- Size: 71.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
74707f409b9c89eb0ee29b7e75cb0ee96cd82bdc1a4393605a30c0b307a8a5ae
|
|
| MD5 |
7287c10ba74eb3ce2aa0499c23116c9b
|
|
| BLAKE2b-256 |
9586ea8aea81dc5fa740c58718d561b2714186e27ac34ddc9a82ba5f0b4c4429
|
File details
Details for the file fvpnctl-0.2.1-py3-none-any.whl.
File metadata
- Download URL: fvpnctl-0.2.1-py3-none-any.whl
- Upload date:
- Size: 44.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
564ca744f8e5a345cf1330cf9283ae93f5ba56e2dd1b680036500f317ddd8b0a
|
|
| MD5 |
ceec6cfb97c4835e516eb8965cd1ada2
|
|
| BLAKE2b-256 |
2e4bcc12ef767f1c5c60952c87c5be307a994a6affbcb39ee5f0e48362a6abac
|