Returning an address from a client-connect script looks like it should set the client's IP. It does not: the hook's only channel to the server is a temporary file whose path arrives as the script's last argument, and the address has to be written there in ifconfig-push syntax. A script that echoes the address or exits zero without writing anything produces a connected client with a pooled address, and the server log looks perfectly healthy.

The confusion is reasonable, because several other hooks do talk back through exit codes or environment variables. This one has its own contract, and the contract starts with a filename.

The Hook Writes a File, Not a Return Value

The declaration is two lines: script-security 2 to permit the call, then client-connect /etc/openvpn/server/assign-address.sh. When a client authenticates, the server runs that script with the generated file path appended as its last argument, which is $1 only if the declaration passes no extra arguments of its own. Everything applied to that client goes into that file as ordinary directives, one per line, exactly as they would appear in a configuration. A wrapper adding its own parameters shifts the position, and the classic symptom is a script writing to a path that looks like --verbose.

Exit status is the other half of the contract. Zero means accept and read the file; anything else rejects the connection, which is how an unmapped identity gets denied instead of quietly falling back to the pool. Write the complete directive before returning success, so a half-written file never becomes a client's configuration.

Two directives deserve attention because they interact with everything else. ifconfig-push fixes the address, while a pushed route tells the client what is reachable through the tunnel; return the first and forget the second and you get a client with an address and nowhere to go. Order inside the file does not matter, but completeness does. A hook that pushes a route for a prefix no peer can reach is the same mistake seen from the other end.

Inputs arrive through environment variables: common_name, trusted_ip, ifconfig_pool_remote_ip and the rest. Treat all of them as strings from outside the server, never as shell fragments.

Address Mapping Is a Policy, Not Arithmetic

A tempting shortcut is to parse the certificate name and use its digits as the last octet. That is not an allocator, and it fails in ordinary ways: two names with the same suffix collide, a name ending in 300 is not an address, and a certificate renamed by an administrator silently moves a device. Validate subnet membership, the usable host range, reserved addresses and uniqueness on every call, and keep the mapping in a table a human can read.

Whoever controls the name controls the address, so decide what common_name actually represents in this deployment. With username-as-common-name enabled, the identity is the login rather than the certificate subject. Never pass that value into shell evaluation, and never let the client influence a file path.

A registry-backed allocator has to survive concurrency. Reserve the address atomically, handle a failed write without admitting a half-configured client, and tag each allocation with the connection that owns it, so a late disconnect from an older session cannot free an address a newer one already holds. Several server instances sharing one pool make that requirement sharper rather than optional.

Topology Decides What ifconfig-push Means

With topology subnet on 10.77.0.0/24, ifconfig-push 10.77.0.20 255.255.255.0 is an address plus its netmask. Under topology net30 the second argument is not a netmask at all but the peer address, so copying the subnet form onto a net30 server gives you a client that associates and cannot pass traffic. Change the topology deliberately, never to make an example look consistent. A server already running net30 has live clients on the old addressing, so the switch is a migration with a maintenance window, not a one-line edit.

Static addresses must sit outside the dynamic pool and outside every other static assignment, including the ones handed out by ordinary client-config-dir files. If two mechanisms can produce the same address for different clients, one of them eventually will.

Test Four Cases Before You Trust It

Run the same checks against a mapped identity, an unmapped one, a reconnect and a simulated registry failure. openvpn --config client.ovpn --verb 4 shows the assigned address in the push reply, and on the server grep -A2 PUSH_REPLY /var/log/openvpn/openvpn.log confirms what was actually sent. For the unmapped identity the expected result is a rejected connection, not a pooled address.

Then inspect the directory the daemon writes those temporary files into. The script runs in the downgraded service context, so a path writable by root but not by the daemon turns every connection into a rejection with a permission error buried several lines down in the log.

Allocation is not authorization: a pinned address still does not grant access to every destination behind the server. Keep the routing and firewall policy in its own layer, and verify it against the address you just fixed.

Mikanda mikatakata