Metadata-Version: 2.5
Name: fvpnctl
Version: 0.3.0
Summary: Control your FortiClient VPN from the macOS command line.
Project-URL: Funding, https://buymeacoffee.com/mpasternak
Project-URL: Homepage, https://github.com/mpasternak/fortivpn-cli
Project-URL: Repository, https://github.com/mpasternak/fortivpn-cli
Project-URL: Issues, https://github.com/mpasternak/fortivpn-cli/issues
Author: Michał Pasternak
License-Expression: MIT
License-File: LICENSE
Keywords: cli,forticlient,fortinet,ipsec,macos,vpn
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: System Administrators
Classifier: Operating System :: MacOS
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: System :: Networking
Requires-Python: >=3.11
Requires-Dist: setuptools<0; sys_platform == 'win32'
Description-Content-Type: text/markdown

# fvpnctl — control your FortiClient VPN from the macOS command line

[![CI](https://github.com/mpasternak/fortivpn-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/mpasternak/fortivpn-cli/actions/workflows/ci.yml)

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. Nothing is launched
  unless you ask for it — with `fvpnctl startserver`, or by adding `--start-fvpn` to any
  command to start FortiClient automatically when it isn't already up.

> **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](LICENSE) for the full warranty/liability
> disclaimer.

---

## What it is

[FortiClient](https://www.fortinet.com/products/endpoint-security/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 launches, quits, or restarts FortiClient
behind your back. 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. So starting it is always something *you* ask for, in one of two ways:

- **`fvpnctl startserver`** — start FortiClient headless, as its own step (handy for ad-hoc
  use, or once at login via the LaunchAgent below).
- **`--start-fvpn`** — a global flag on any command: if FortiClient isn't reachable, start it
  headless with the debug port enabled, wait for it to come up, and then run the command.

If nothing is listening on the debug port and you did *not* pass `--start-fvpn`, every
command fails fast with exit code `3` and tells you exactly how to start it.

```console
$ fvpnctl --start-fvpn connect office
FortiClient is not reachable on 127.0.0.1:9222; starting it headless with debugging enabled (--start-fvpn)…
Launching FortiClient headless: /Applications/FortiClient.app/Contents/MacOS/FortiClient --hide-gui --remote-debugging-port=9222
Waiting up to 20s for FortiClient CDP on 127.0.0.1:9222…
FortiClient debug port is up on 127.0.0.1:9222.
CONNECTED office 172.16.200.2
```

The recommended launch is either `fvpnctl startserver` or, equivalently, by hand:

```bash
/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`](https://docs.astral.sh/uv/) and has **no runtime
dependencies**. The PyPI package is `fvpnctl`; the command it installs is `fvpnctl`.

**From PyPI:**

```bash
uv tool install fvpnctl   # puts the `fvpnctl` command on your PATH
fvpnctl list
```

Or run it once, without installing, with `uvx`:

```bash
uvx fvpnctl list
```

(`pipx install fvpnctl` / `pip install fvpnctl` work too.)

**From source (this checkout):**

```bash
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`](./contrib/com.fvpnctl.headless.plist):

```xml
<?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:

```bash
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:

```bash
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:

```bash
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`:

```bash
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] [--start-fvpn] <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`).
- `--start-fvpn` — if FortiClient isn't reachable on the debug port, launch it headless
  (exactly as `startserver` does), wait for it to finish starting, and then run the command,
  instead of failing with exit `3` and instructions. Inert when FortiClient is already up, so
  it is safe to leave on permanently (e.g. `alias fvpn='fvpnctl --start-fvpn'`). If
  FortiClient is not installed it still exits `8` with a download hint.
- `--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.

```console
$ fvpnctl list
NAME    TYPE   SERVER
office    ipsec  vpn.example.com
acme    ipsec  gw.acme.example
```

```console
$ 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.

```console
$ fvpnctl status
CONNECTED office 172.16.200.2 (00:01:45, ↓1.6KB ↑0)
```

```console
$ fvpnctl status
DISCONNECTED
```

```console
$ 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.

```console
$ 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:

```console
$ 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.

```console
$ 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:

```console
$ 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.

```console
$ 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.

```console
$ 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).

```console
$ 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`.

```console
$ 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, and to just
start FortiClient on demand as part of another command use `--start-fvpn`. 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`](./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` / `--start-fvpn` 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 start, quit, or restart FortiClient
  unless you ask them to: launching is opt-in via `fvpnctl startserver` or the `--start-fvpn`
  flag (or the LaunchAgent above). The tool never quits or restarts a *running* FortiClient
  at all.
- **No default profile / config file.** The profile name is always passed explicitly.

For more on how it works, see [`docs/how-it-works.md`](./docs/how-it-works.md).

---

## Development

```bash
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`:

```bash
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`](./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.
