Skip to main content

Control your FortiClient VPN from the macOS command line.

Project description

fvpnctl — control your FortiClient VPN from the macOS command line

CI

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 / uvx deliberately 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_PORT to fvpnctl (see Usage) so it matches the --remote-debugging-port in 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 (default 9222). Overridable by the FORTI_CDP_PORT environment variable. Must match the --remote-debugging-port FortiClient was launched with.
  • --host H — debug host (default 127.0.0.1).
  • --verbose / --quiet — progress messages. Verbose is on by default and writes progress to stderr; --quiet silences it. Either way stdout carries only the machine-readable result, so --json output and shell pipelines are byte-identical. While connect waits for the tunnel (it polls once a second), verbose mode shows a Braille throbber with an elapsed-seconds counter on a TTY, falls back to a single static line when stderr is piped or redirected, and shows nothing under --quiet.

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 (default 2).

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 (exit 7); a tunnel that started negotiating and then dropped (e.g. bad credentials) is a connect failure (exit 6).
  • --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.)

While it waits, connect shows live progress on a TTY (verbose mode). The first time you connect a profile there is no timing history, so it shows an indeterminate Braille throbber with an elapsed-seconds counter. fvpnctl records how long each successful connect took (per profile, the last 10 runs, under ~/.local/state/fvpnctl/ — overridable with $FVPNCTL_STATE_DIR), so on subsequent connects it shows a progress bar that fills toward the average of those past durations:

$ fvpnctl connect office
Connecting profile office…  [█████████████░░░░░░░░░░░] 54%  7s / ~13s

The bar never claims 100% until the tunnel is actually up, and the 7s / ~13s tail stays honest if a connect runs longer than usual. Piped/redirected output falls back to a single static line, and --quiet shows nothing.

fvpnctl disconnect [profile]

Disconnect a tunnel. The profile argument is optional: with no argument disconnect tears down whatever is connected right now (it reads the active connection from the daemon), so you don't have to remember which profile is up. Pass a name to target a specific profile.

$ fvpnctl disconnect          # disconnect the active tunnel
DISCONNECTED office
$ fvpnctl disconnect office   # or name it explicitly
DISCONNECTED office

If nothing is connected, disconnect says so and exits 0 (the desired end state already holds), so it is safe to run unconditionally in scripts.

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 security tool 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 (exit 5); SSL was untested in the spike.
  • No 2FA. If the daemon enters the XAUTH state (a token/OTP is required), connect raises UnsupportedError("2FA not supported in v1") (exit 5) 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

fvpnctl-0.2.4.tar.gz (82.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

fvpnctl-0.2.4-py3-none-any.whl (52.3 kB view details)

Uploaded Python 3

File details

Details for the file fvpnctl-0.2.4.tar.gz.

File metadata

  • Download URL: fvpnctl-0.2.4.tar.gz
  • Upload date:
  • Size: 82.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.9.2

File hashes

Hashes for fvpnctl-0.2.4.tar.gz
Algorithm Hash digest
SHA256 22e5c839c9d7838b78e84d068e80e6a21d5d81cda8a5b32e931535719860528c
MD5 39389a064a8ed15f00fda9283364a001
BLAKE2b-256 994f11280fb5c7ce10e0b4e1e0d771ac062e25a0701950d14002f2e341f29da6

See more details on using hashes here.

File details

Details for the file fvpnctl-0.2.4-py3-none-any.whl.

File metadata

  • Download URL: fvpnctl-0.2.4-py3-none-any.whl
  • Upload date:
  • Size: 52.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.9.2

File hashes

Hashes for fvpnctl-0.2.4-py3-none-any.whl
Algorithm Hash digest
SHA256 6a70d3aba5cfcd5760e995577e07e14c07030ffe3bac485d8828b06ec698ac8e
MD5 380273fdfcc4a80f6fe3562dd69a38f4
BLAKE2b-256 74606922dae0f7aa5134934859c72281ef29ee9331c1eaba8983115f8f27e257

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page