[ADD] vpn : cinq pilotes, secrets en coffre, diagnostic étagé
Le dépôt n'avait aucun moyen de monter un tunnel VPN ni de dire pourquoi il
refuse de monter. Cinq technologies libres, un pilote chacune, derrière un
`vpn.py` qui monte, démonte et diagnostique.
Ce qui n'est pas secret — hôte, utilisateur, routes, MTU — vit dans une
configuration JSON lisible ; clés pré-partagées et mots de passe vivent dans
un coffre KeePassXC. Un profil se montre et se partage sans donner de quoi
monter le tunnel. Les secrets s'écrivent en tmpfs sous 0700, jamais sur un
disque persistant. Le diagnostic part du noyau et remonte, pour que la
première ligne fausse soit la cause et non une conséquence.
Vérifié : 138 tests, dont le rendu de chaque fichier généré.
--- EN ---
The repository had no way to raise a VPN tunnel, nor to say why one refuses
to come up. Five free technologies, one driver each, behind a `vpn.py` that
raises, tears down and diagnoses.
What is not secret — host, user, routes, MTU — lives in readable JSON
configuration; pre-shared keys and passwords live in a KeePassXC vault. A
profile can be shown and shared without handing over the means to raise the
tunnel. Secrets are written to tmpfs at 0700, never to a persistent disk.
Diagnosis starts at the kernel and climbs, so the first false line is the
cause and not a consequence.
Checked: 138 tests, including the rendering of every generated file.
Assisted-by: Claude Opus 5
2026-09-03 23:42:49 -04:00
# VPN — five open tunnels, secrets in a KeePassXC vault
`vpn.py` brings up, tears down and diagnoses a VPN tunnel. One driver per
technology, five of them, all free software.
The split is the whole design: what is *not* secret (host, user, routes, MTU)
lives in readable JSON configuration; the pre-shared keys and the passwords
live in a KeePassXC `.kdbx` vault. A profile can therefore be shown, compared
and shared without handing over the means to bring the tunnel up.
## Which one to pick
| Driver | Pick it when | Secrets in the vault |
|--------|--------------|----------------------|
| `l2tp_ipsec` | the far side imposes it: a router, a firewall, Windows RRAS | PSK + PPP password |
| `wireguard` | you control both ends — fastest, simplest | private key (+ optional PSK) |
| `openvpn` | the site handed you a `.ovpn` file | password, if the file needs one |
| `openconnect` | Cisco AnyConnect, Pulse, GlobalProtect, Fortinet appliances | password |
| `sshuttle` | all you have is SSH access — nothing to install on the far side | none: SSH keys do the work |
## Commands
```bash
./script/vpn/vpn.py check # ce que la machine sait faire
sudo bash script/install/install_vpn.sh wireguard # ou : tous, sans argument
./script/vpn/vpn.py list
./script/vpn/vpn.py up --profile acme --dry-run
./script/vpn/vpn.py up --profile acme
./script/vpn/vpn.py status --profile acme
./script/vpn/vpn.py diagnose --profile acme
./script/vpn/vpn.py down --profile acme
```
Everything is also reachable from the CLI: **TODO › Execute › Deployment ›
VPN**, and from **TODO › Execute › Network › VPN** — a tunnel gets looked for
in both places. The menu is where profiles are created and secrets are typed
in; `vpn.py` is what the menu runs. Connecting from the menu shows the plan
first and asks before running it.
Run it as **yourself, not under sudo** : the vault lives in your home and its
master password is yours to type. Each privileged step calls `sudo` on its
own, and `--dry-run` shows every one of them without running any.
## Where things live
| Path | Content |
|------|---------|
| `private/todo/todo_override_private.json` | your profiles — gitignored, 0600 |
| `script/todo/todo.json` | the `vpn` section, empty: profiles shared by a team can go here |
| your `.kdbx` vault | one entry per profile, `ERPLibre VPN / <profile>` |
| `/dev/shm/erplibre-vpn/<profile>/` | 0700 root — **the secrets** , in tmpfs, erased on `down` |
| `/run/erplibre-vpn/<profile>.*` | non-secret state (chosen interface, pid, log), readable without sudo |
| `/etc/ipsec.conf` , `/etc/ipsec.secrets` | L2TP only: a marked block, removed on `down` |
[ADD] vpn openconnect : groupe d'URL, SSO délégué, mot de passe borné
Deux mécanismes désignent un service sur un concentrateur : un chemin
d'URL, une valeur de menu déroulant. Les confondre rend le formulaire d'un
AUTRE service — identifiants justes refusés, rien ne désignant le groupe.
Et certains ne comparent que les N premiers caractères du mot de passe,
que le profil déclare désormais sans jamais rien tronquer.
Une passerelle qui exige un navigateur intégré arrête openconnect sur
« No SSO handler », les distributions le bâtissant sans webview. Un greffon
fait l'étape web et rend un cookie ; le pilote monte lui-même, et le profil
garde son interface, son état et son diagnostic.
Vérifié : 249 tests unitaires, un tunnel monté contre une passerelle SAML.
--- EN ---
Two mechanisms designate a service on one concentrator: a URL path, and a
dropdown value. Confusing them hands over ANOTHER service's login form —
correct credentials refused, with nothing pointing at the group. And some
compare only the first N characters of the password, which the profile now
declares without ever truncating anything.
A gateway demanding an embedded browser stops openconnect on "No SSO
handler", distributions building it without a webview. A helper does the
web step and returns a cookie; the driver mounts the tunnel itself, so the
profile keeps its interface, its state and its diagnosis.
Checked: 249 unit tests, and one tunnel mounted against a SAML gateway.
Assisted-by: Claude Opus 5
2026-09-04 05:52:09 -04:00
## Site presets
A preset is a **partial profile** : everything an institution publishes and
that is the same for everybody — gateway, protocol, authentication group,
port, concentrator limits. It carries **no username and no secret** , which is
exactly what lets it be handed around. Creating a profile from one leaves the
identity to type, and nothing else.
`VPN › Create a profile from a site preset` lists them, then runs the ordinary
form pre-filled: an empty answer keeps the preset value.
Presets are read from these directories, in order:
| Path | Use |
|------|-----|
| `conf/vpn_presets/` | shipped with the repository — templates only, invented gateways, **nothing identifying** |
| `private/vpn/presets/` | git-ignored: the mount point for a private repository of real site presets |
| any directory in `vpn_preset_paths` | a private repository cloned somewhere else |
The **latest wins** on the same identifier. That is what lets a site correct a
shipped template — a gateway that moved, a group that was renamed — without
editing a git-tracked file, so without a conflict on the next `git pull` .
One `.json` file holds one preset (an object) or several (a list). Beyond the
profile fields, three keys describe the preset itself: `preset` (identifier,
lowercase, digits, `-` or `_` ), `label` and an optional `hint` . An unreadable
file is reported and skipped — a broken preset must not make the others
unreachable.
## An SSL VPN (AnyConnect), distribution by distribution
The `openconnect` driver speaks AnyConnect (Cisco), Pulse/Juniper,
GlobalProtect, Fortinet, F5 and Array. One command installs its client:
```bash
sudo bash script/install/install_vpn.sh openconnect
./.venv.erplibre/bin/python script/vpn/vpn.py check --driver openconnect
```
| Distribution | Packages | `vpnc-script` |
|--------------|----------|---------------|
| Debian, Ubuntu | `openconnect vpnc-scripts` | `/usr/share/vpnc-scripts/vpnc-script` |
| Arch, Manjaro | `openconnect` (pulls `vpnc-scripts` ) | `/usr/share/vpnc-scripts/vpnc-script` |
| Fedora, RHEL, Rocky, Alma | `openconnect` (pulls `vpnc-script` ) | `/etc/vpnc/vpnc-script` |
| openSUSE | `openconnect` (pulls `vpnc-script` ) | `/etc/vpnc/vpnc-script` |
`vpnc-script` is the one prerequisite that is **not** a binary on the `PATH` ,
so the installer looks for the file itself and names the package to install
when it is missing. Without it openconnect starts, the session opens, and the
tun interface never appears — a failure three stages above the missing
package, on a symptom that does not accuse it.
Two fields decide almost everything else. `oc_authgroup` is the
**authentication group** the site tells you to select; a typo in it comes back
as “login failed”, with nothing pointing at the group. `oc_password_len`
declares that the concentrator **compares only the first N characters** of the
password — some do, a legacy directory limit. Zero means no limit. The field
never truncates: it says so before you store the secret, and it compares
lengths when the tunnel comes up. Store just those N characters.
On the first connection openconnect refuses an unpinned server certificate and
**prints** the `--servercert sha256:…` line to copy into `oc_servercert` . That
refusal is the expected first step, not a failure.
## The two “groups” of an AnyConnect gateway
One concentrator hosts several services, and two entirely different
mechanisms select one. Confusing them does not raise a syntax error: it
hands you **another service's login form** , so correct credentials are
refused and nothing points at the group.
| Profile field | openconnect | What it is |
|---------------|-------------|------------|
| `oc_usergroup` | `--usergroup=X` | the **URL path** : `--usergroup=X` and `https://host/X` are the same thing |
| `oc_authgroup` | `--authgroup=X` | a value to pick in a **dropdown** the server presents |
A site that hands you an `.xml` profile designates its service by the path;
a site that shows you a list to choose from in a screenshot designates its
own by the dropdown.
In a Cisco `AnyConnectProfile` file, `<UserGroup>` is the path and
`<HostName>` is only a display label — despite the tag name, it is not a
hostname; `<HostAddress>` is. `VPN › Import an AnyConnect profile (.xml)`
reads those three tags and writes presets into `private/vpn/presets/` , so
the field that actually decides which service you reach is never retyped.
## SSO / SAML: what openconnect can and cannot do
When a gateway authenticates through an identity provider (Okta, Azure AD,
Duo), there is no password to send — a web page has to be completed. Cisco
signals this in **two** different ways, and only one of them works from a
plain CLI:
| Server announces | openconnect needs | Works with a distribution package |
|------------------|-------------------|-----------------------------------|
| `single-sign-on-external-browser` | `--external-browser=<cmd>` | **yes** — set `oc_external_browser` |
| `sso-v2` (embedded browser) | a built-in webview (libwebkit2gtk) | **no** — Debian, Ubuntu, Fedora and Arch all build without it |
The gateway decides which one, per tunnel group. When it asks for the
embedded browser and openconnect has no webview, it stops on:
```
Please complete the authentication process in the AnyConnect Login window.
No SSO handler
Failed to complete authentication
```
`--external-browser` does **not** help there: openconnect only takes that
path when the server announced the external-browser method. Which one a
gateway wants can be read without sending any secret:
```bash
openconnect --protocol=anyconnect --usergroup=< GROUPE > \
--authenticate --dump-http-traffic < passerelle > 2>& 1 \
| grep -E 'sso-v2|external-browser|No SSO handler'
```
[ADD] vpn install : proposer et poser le greffon SSO
openconnect refuse les passerelles qui exigent un navigateur intégré, sur
« No SSO handler » : les distributions le bâtissent sans webview. Rien
n'installait le greffon qui fait cette étape, que le pilote attendait
pourtant — une machine paraissait équipée sans l'être.
La question n'est posée que pour le pilote qui peut s'en servir, et
seulement quand le greffon manque. Son amont est arrêté depuis 2023 :
épingles intenables sur un Python récent, Qt et lxml pris de la
distribution, correctif rejoué à chaque installation. Le venv appartient
à l'utilisateur, non à root, qui n'a ni affichage ni trousseau. Vérifié :
installation depuis rien en 6,5 s, paquets éprouvés sur debian et ubuntu.
--- EN ---
openconnect refuses gateways demanding an embedded browser, on "No SSO
handler": distributions build it without a webview. Nothing installed the
helper that performs that step, which the driver expected all the same —
a machine looked equipped without being so.
The question is asked only for the driver that can use it, and only when
the helper is missing. Its upstream has been unmaintained since 2023:
pins unsatisfiable on a recent Python, Qt and lxml taken from the
distribution, a patch replayed on every install. The venv belongs to the
user, not root, which has neither display nor keyring. Checked: install
from nothing in 6.5 s, package names proven on debian and ubuntu only.
Assisted-by: Claude Opus 5
2026-09-04 07:37:49 -04:00
### Installing the helper
`VPN › Install the client packages` offers it when the driver is
OpenConnect and no helper is found — and stays quiet otherwise. From the
command line:
```bash
sudo bash script/install/install_vpn.sh openconnect --sso
```
Read what that step carries before accepting it. The helper's upstream has
been **unmaintained since 2023** , so the installer holds workarounds that
will not resolve on their own: its version pins are unsatisfiable on a
recent Python (pre-5 `lxml` does not build), Qt and `lxml` come from the
distribution rather than PyPI, and a call to `asyncio.get_event_loop()`
raises from Python 3.12 on — patched on every install, since any
reinstallation erases it. pip reports a pin conflict on `lxml` and
`keyring` ; it is expected, those are the two pins deliberately relaxed.
Package names are **verified on Debian and Ubuntu only** ; on the other
families they are best-effort, and a mistake there reads as "package not
found" without breaking anything else.
The venv belongs to the **user** , not root: the helper needs a display and
a keyring, which root does not have. The installer therefore refuses to run
without `sudo` , from which it reads who to install for.
[ADD] vpn openconnect : groupe d'URL, SSO délégué, mot de passe borné
Deux mécanismes désignent un service sur un concentrateur : un chemin
d'URL, une valeur de menu déroulant. Les confondre rend le formulaire d'un
AUTRE service — identifiants justes refusés, rien ne désignant le groupe.
Et certains ne comparent que les N premiers caractères du mot de passe,
que le profil déclare désormais sans jamais rien tronquer.
Une passerelle qui exige un navigateur intégré arrête openconnect sur
« No SSO handler », les distributions le bâtissant sans webview. Un greffon
fait l'étape web et rend un cookie ; le pilote monte lui-même, et le profil
garde son interface, son état et son diagnostic.
Vérifié : 249 tests unitaires, un tunnel monté contre une passerelle SAML.
--- EN ---
Two mechanisms designate a service on one concentrator: a URL path, and a
dropdown value. Confusing them hands over ANOTHER service's login form —
correct credentials refused, with nothing pointing at the group. And some
compare only the first N characters of the password, which the profile now
declares without ever truncating anything.
A gateway demanding an embedded browser stops openconnect on "No SSO
handler", distributions building it without a webview. A helper does the
web step and returns a cookie; the driver mounts the tunnel itself, so the
profile keeps its interface, its state and its diagnosis.
Checked: 249 unit tests, and one tunnel mounted against a SAML gateway.
Assisted-by: Claude Opus 5
2026-09-04 05:52:09 -04:00
### Delegating the web form, keeping the tunnel
For a gateway that insists on the embedded browser, set `oc_sso_helper` to
an `openconnect-sso` executable. The driver then splits the work:
| Step | Who | Runs as | Carries |
|------|-----|---------|---------|
| SAML / MFA in a real browser | the helper, `--authenticate json` | **you** (needs your display and keyring) | returns `{host, cookie, fingerprint}` |
| bringing the tunnel up | this driver, `--cookie-on-stdin` | root, via `sudo` | the cookie, on standard input only |
That split is the whole point. The helper does *only* the SAML dance; the
**profile** stays the source of truth for the interface name, the added
routes, the state files and the diagnosis. A tunnel opened by the helper
itself would be called `tun0` , would leave nothing in `/run` , and `status` ,
`diagnose` and `down` would not see it.
Two details make the cookie fail if you neglect them, and the driver handles
both: the **announced identity** must match on both steps (`oc_ac_version`
goes to the helper *and* to openconnect — a cookie issued to one client
version is refused to another), and the **fingerprint** the helper reports
wins over `oc_servercert` , because it is the one it authenticated against.
Many of these gateways present a chain the system store does not validate
(`signer not found`), and `--non-inter` would refuse it without a pin.
The cookie never touches a file: it lives in a variable, leaves by standard
input, and is masked from every display the moment it exists. On a machine
with no usable GPU — a virtual machine, typically — the embedded Chromium
falls back to Vulkan and the window dies mid-authentication; the driver
therefore forces software rendering unless those variables are already set.
Which path is taken is decided in one place, and reads in this order:
| `oc_sso` | `oc_sso_helper` resolves | Path |
|---|---|---|
| yes | yes | helper authenticates, this driver mounts |
| yes | declared but not executable | **refused, and says so** — never a silent fallback |
| yes | no | `--external-browser` , openconnect alone |
| no | — | password from the vault |
A declared helper that cannot run is an error, not an invitation to take the
other path: falling back quietly would make the mount fail on `No SSO
handler`, three stages above the real cause — a wrong path.
`vpn.py status` and `diagnose` carry a `SSO helper` line: the resolved path,
`absent` , or `not applicable` when the profile authenticates by password.
[ADD] vpn : cinq pilotes, secrets en coffre, diagnostic étagé
Le dépôt n'avait aucun moyen de monter un tunnel VPN ni de dire pourquoi il
refuse de monter. Cinq technologies libres, un pilote chacune, derrière un
`vpn.py` qui monte, démonte et diagnostique.
Ce qui n'est pas secret — hôte, utilisateur, routes, MTU — vit dans une
configuration JSON lisible ; clés pré-partagées et mots de passe vivent dans
un coffre KeePassXC. Un profil se montre et se partage sans donner de quoi
monter le tunnel. Les secrets s'écrivent en tmpfs sous 0700, jamais sur un
disque persistant. Le diagnostic part du noyau et remonte, pour que la
première ligne fausse soit la cause et non une conséquence.
Vérifié : 138 tests, dont le rendu de chaque fichier généré.
--- EN ---
The repository had no way to raise a VPN tunnel, nor to say why one refuses
to come up. Five free technologies, one driver each, behind a `vpn.py` that
raises, tears down and diagnoses.
What is not secret — host, user, routes, MTU — lives in readable JSON
configuration; pre-shared keys and passwords live in a KeePassXC vault. A
profile can be shown and shared without handing over the means to raise the
tunnel. Secrets are written to tmpfs at 0700, never to a persistent disk.
Diagnosis starts at the kernel and climbs, so the first false line is the
cause and not a consequence.
Checked: 138 tests, including the rendering of every generated file.
Assisted-by: Claude Opus 5
2026-09-03 23:42:49 -04:00
## The three security rules
1. **No secret in an argument.** `/proc/<pid>/cmdline` is readable by every
user of the machine. Secrets travel on standard input only; a single place
(`runner.py`) holds that rule, and a unit test replays the plan of **every**
driver and fails if a secret ever reaches a command line.
2. **No secret on persistent storage.** The files a technology insists on are
written 0600 into tmpfs and erased on `down` . Two drivers need none at all:
OpenConnect passes the password on standard input (`--passwd-on-stdin`), and
sshuttle has no secret to begin with. One residual, stated rather than
hidden: while an L2TP tunnel is up, root can read the pppd options file.
pppd takes a password from a file or nothing.
3. **The master password is written nowhere.** Leave `kdbx.password` empty; it
is asked once per session. Only the vault *path* is stored, in the single
gitignored file. The CLI says so when it finds a master password in the
configuration.
The L2TP PSK reaches strongSwan **hex-encoded** (`PSK 0x…`): same bytes, and
no question of escaping a `"` or a `\` inside a pre-shared key.
## What each driver settles for you
**L2TP/IPsec** — three stages, and all three are needed for an interface:
IPsec in **transport** mode protects UDP 1701, L2TP opens a session inside it,
PPP authenticates. Six pitfalls are handled here, all six found by connecting
to a real concentrator:
- `charon { install_routes = no }` , otherwise charon installs a route that
captures the L2TP traffic — the classic *"the SA is established, ppp0 never
appears"*.
- An **AppArmor** rule. AppArmor confines charon by path and `/dev/shm` is not
in its profile, so charon is denied the secrets file by the kernel and fails
three stages later on *"no shared key found"* — with the PSK sitting there,
correct. Only `journalctl -k | grep DENIED` says so. The rule goes in the
`local/` file Debian and Ubuntu provide for exactly this.
- **`rightid=%any`**. A gateway announces itself by its IP even when `right`
is a name; without this, strongSwan refuses: *"IDir '203.0.113.5' does not
match to 'vpn.example.com'"*.
- **A wait for the connection to load.** `ipsec start` returns before the
starter has pushed the connections; an immediate `ipsec up` fails on *"no
match"* — on a perfectly valid configuration, the most misleading error of
the sequence.
- **The direction of authentication.** `require chap` / `require
authentication` (xl2tpd) and `require-mschap-v2` (pppd) all mean *require
the PEER to authenticate to us*. A client must not: the server refuses, and
pppd tears the link down with *"LCP terminated by peer (peer refused to
authenticate)"*. What a client wants is `refuse-pap` and `refuse-eap` —
which speak about **us** .
- A `/32` survival route to the server (in all-traffic mode the ESP packets
would enter the tunnel they carry), and `resolvectl` , because
systemd-resolved ignores `/etc/ppp/resolv.conf` .
One packaging note that costs an hour if missed: without the **openssl**
plugin (`libstrongswan-standard-plugins`), charon advertises 3DES, the
concentrator picks it — often the only cipher it knows — and the negotiation
dies on *"ENCRYPTION_ALGORITHM 3DES_CBC not supported!"* . The installer ships
it.
**WireGuard** — it has no session, so `wg-quick up` succeeds even with a wrong
peer key or an unreachable endpoint. Nothing says no, because nobody is there
to say it. This driver therefore **waits for a handshake** before calling the
tunnel up. Routes come from `AllowedIPs` and belong to `wg-quick` ; the driver
does not double its work. No `DNS =` line either: wg-quick hands that to
`resolvconf` , missing from many systemd-resolved installs, and the whole
configuration fails when it is.
**OpenVPN** — it starts from the `.ovpn` the site gave you; this driver does
not invent one. Two things that are not obvious: `--cd` , because a `.ovpn`
references its neighbours relatively; and option order, because what follows
`--config` overrides the file — a bare `auth-user-pass` inside would otherwise
wait for a keystroke that never comes, the daemon being detached. Split tunnel
is asked for with `--route-nopull` , which also drops the pushed DNS; the driver
says so when it takes it.
**OpenConnect** — `--non-inter` is deliberate in password mode. Without it an
unknown server certificate raises a question, and openconnect would read the
answer from the standard input the password arrives on. With it, openconnect
refuses at once **and** prints the `--servercert sha256:…` line to paste into
[FIX] vpn : ne plus demander la route par défaut à qui ne la pose pas
Le formulaire demandait « tout le trafic ? » à un pilote dont le SERVEUR
décide du routage, ne faisait rien de la réponse, et `status` la jugeait
quand même : un ✗ permanent sur un tunnel sain, et un profil annonçant
« tout le trafic » sans l'obtenir.
Un drapeau, sur le modèle de celui du MTU, dit quels pilotes posent cette
route. Les autres ne sont ni interrogés ni jugés, et un drapeau laissé à
vrai n'est plus conservé. L'honorer serait pire qu'inutile : forcer une
route par défaut contre une passerelle en tunnel scindé donne un trou
noir, une passerelle ne routant pas ce qu'elle n'a pas annoncé. Les routes
déclarées, elles, restent honorées — le formulaire le dit.
--- EN ---
The form asked "all traffic?" of a driver whose SERVER decides the
routing, did nothing with the answer, and `status` judged it anyway: a
permanent ✗ on a healthy tunnel, and a profile announcing "all traffic"
without getting it.
A flag, modelled on the MTU one, says which drivers lay that route. The
others are neither asked nor judged, and a flag left true is no longer
kept. Honouring it would be worse than useless: forcing a default route
against a split-tunnel gateway gives a black hole, a gateway not routing
what it never advertised. Declared routes are still honoured — the form
says so.
Assisted-by: Claude Opus 5
2026-09-08 09:41:17 -04:00
the profile's `oc_servercert` .
Routes belong to the server, through `vpnc-script` ; the profile can add to
them, not replace them. For that reason the form does **not** ask this
driver « send ALL traffic through the tunnel? », and `status` does not judge
it: the gateway decides what enters the tunnel, and forcing a default route
against a split-tunnel gateway would not give all traffic but a black hole —
a gateway does not route what it never advertised. To force a network
through anyway, add it to `routes` , which this driver does honour, with
`0.0.0.0/0` for everything.
[ADD] vpn : cinq pilotes, secrets en coffre, diagnostic étagé
Le dépôt n'avait aucun moyen de monter un tunnel VPN ni de dire pourquoi il
refuse de monter. Cinq technologies libres, un pilote chacune, derrière un
`vpn.py` qui monte, démonte et diagnostique.
Ce qui n'est pas secret — hôte, utilisateur, routes, MTU — vit dans une
configuration JSON lisible ; clés pré-partagées et mots de passe vivent dans
un coffre KeePassXC. Un profil se montre et se partage sans donner de quoi
monter le tunnel. Les secrets s'écrivent en tmpfs sous 0700, jamais sur un
disque persistant. Le diagnostic part du noyau et remonte, pour que la
première ligne fausse soit la cause et non une conséquence.
Vérifié : 138 tests, dont le rendu de chaque fichier généré.
--- EN ---
The repository had no way to raise a VPN tunnel, nor to say why one refuses
to come up. Five free technologies, one driver each, behind a `vpn.py` that
raises, tears down and diagnoses.
What is not secret — host, user, routes, MTU — lives in readable JSON
configuration; pre-shared keys and passwords live in a KeePassXC vault. A
profile can be shown and shared without handing over the means to raise the
tunnel. Secrets are written to tmpfs at 0700, never to a persistent disk.
Diagnosis starts at the kernel and climbs, so the first false line is the
cause and not a consequence.
Checked: 138 tests, including the rendering of every generated file.
Assisted-by: Claude Opus 5
2026-09-03 23:42:49 -04:00
Set ** `oc_sso` ** when the concentrator authenticates through a **web form**
(SAML / SSO — Azure AD, Okta, Duo). There is then no password to send, and
Cisco's own client needs a screen for its embedded WebKit browser — often
with `WEBKIT_DISABLE_DMABUF_RENDERER=1` for it to render at all; its CLI
cannot do this flow. openconnect can, with no screen on the client machine:
measured in its library, it listens on **local port 29786** and waits for the
browser's redirect after launching `--external-browser` with the login URL.
On a server that "browser" is a plain `echo` , so the URL is printed for you to
open in **your own** browser — bring the redirect back with
```bash
ssh -L 29786:localhost:29786 < the client machine >
```
before opening it. The password never leaves your own workstation. Both
timeouts differ on purpose: two minutes for a password, five for a human
walking through an identity provider.
**sshuttle** — no interface at all: it redirects through the firewall. Every
interface and routing check is therefore silent for it, and the **witness
address** is the only judge — this driver is the reason the `probe` field
exists. It also insists on being run by *you* : it calls sudo itself, for the
firewall only. Running it under sudo would open the SSH session as root, with
root's keys.
## Diagnosing
`diagnose` chains the checks and names the failing stage, lowest first, so
that the first false line is the cause and not a consequence: what the
**kernel** exposes · packages present · the technology's own check (IPsec SA,
WireGuard handshake, daemon alive, OpenVPN initialisation) · interface and
addresses · each declared route · the witness address that only answers
through the tunnel · the last lines of the relevant journal. Set `probe` in
the profile to an address reachable only through the tunnel — without it,
*"it works"* stays an impression, and for sshuttle there is nothing else to
go on.
The kernel stage catches a failure no configuration can fix. Upgrading the
kernel package replaces `/lib/modules/<version>` with the new version's:
the running kernel keeps the modules already loaded and can load no other.
IPsec then becomes unavailable on a kernel that supports it, charon aborts
at initialisation on a missing `kernel-ipsec` , and the symptom surfaces three
stages higher as a connection never loaded. `diagnose` and `up` name the
version whose modules are gone and offer the only remedy — a reboot. It is
offered, never done: nothing is applied on a dry run, nor without a terminal
to answer.
## Adding a driver
`drivers/base.py` states the contract *and* carries everything true of all
technologies: directory layout, state kept between processes, routes,
systemd-resolved, the standard status checks. A new driver declares what is
its own — packages, secrets, profile fields, the form the menu unrolls, the
sequence up and down — and executes nothing: it asks a `Runner` , which either
runs or merely shows. Registering it is one line in `drivers/__init__.py` , and
`test_vpn_drivers.py` picks it up from the registry: the no-secret-on-a-command
-line rule applies to it whether or not anyone thought about it.