When a client-connect script exits nonzero, OpenVPN rejects the client, and operators usually assume the matching client-disconnect script tidies up. Often it never runs, because disconnect processing is conditional on what the connect stage managed to complete. Treating rejection and rollback as one mechanism is what turns a failed login into a leaked firewall rule or an orphaned record. They are two separate obligations, and only one of them is automatic. Read the hook's own exit path before blaming the server for leaving state behind.

A nonzero exit rejects the client; it does not undo your work

The exit status of client-connect is an admission decision. Zero means admit, anything else means tear the instance down before it is fully established. Nothing in that contract inspects what the script already did to the outside world. If the script added an iptables rule, wrote a row, or bumped a counter, those changes survive the rejection.

The same asymmetry applies to plugins, where several connect functions can run for one instance. A later failure does not retroactively cancel an earlier success, and the earlier function has no way to learn that a sibling failed.

Rejection is automatic, cleanup is not

The OpenVPN 2.6 manual ties client-disconnect processing to prior connect processing having succeeded. A documented exception covers cascaded chains: if one connect step has already returned success, the matching disconnect steps still run when the instance is torn down, even though a later connect step failed. That exception is narrow and depends on how many callbacks are configured, which is why testing one shell script tells you nothing about a chain.

Map the actual configuration before designing around it: which script, which plugin, and in what order they run. The table below lists what a half-finished setup leaves behind.

What the hook did before failingWhat the client seesWhat stays behind
Created a firewall entryA rejection, with no tunnelA rule matching an address nobody owns
Wrote a state rowA rejection, with no tunnelA record of a session that never started
Allocated a tunnel addressA rejection, with no tunnelAn address marked busy until something releases it

Each row ends the same way for the client and differently for the host. The client cannot tell which of them happened, so the hook has to unwind what it did before returning the failure status.

Compensate inside the script that failed

Validate everything you can before touching shared state: certificate fields, address availability, the group the client belongs to. The cheapest rejection is the one that happens before any allocation. Once setup spans several external operations, keep a record of which ones completed and unwind them in reverse order before returning the failure status.

A concrete case: the script creates a per-client firewall entry, then fails to write the state row that describes it. The failing branch should remove the entry it just created and then exit nonzero. Waiting for a disconnect event that may never be scheduled is how a rule outlives the session it was meant for.

Make compensation idempotent and owned

Cleanup must be safe to run twice and precise about what it may delete. A late event from an earlier attempt should not remove a newer session's allocation just because both share a common name or a recycled tunnel address. Record the specific handle or operation id with the resource, and delete by that key rather than by name. Database transactions help only with the database's own changes; a firewall rule outside that boundary needs an explicit compensation step.

Assume the process can die between the change and the cleanup, and add a reconciler that compares external state against the admitted client list on a schedule.

Test the three paths that actually differ

Exercise three cases: a rejection before anything is allocated, one after a partial allocation, and a normal admitted session that later ends. Those three produce different log lines and different residue. Assert on the external state after each one, not only on the client's failure message, because the client failing is the expected outcome in two of the three.

Log the hook stage, the exit status, and what was allocated or released, with credentials left out. A hook log that names the resource it created is what makes a leaked rule findable six months later.

Ũhoro ũhĩtĩte