@@ -15,6 +15,7 @@ out
.vs
.deps
.libs
+.cache
Makefile
Makefile.in
aclocal.m4
@@ -137,6 +137,8 @@ man_help(void)
msg(M_CLIENT, "push-update-broad options : Broadcast a message to
update the specified options.");
msg(M_CLIENT, " Ex. push-update-broad
\"route something, -dns\"");
msg(M_CLIENT, "push-update-cid CID options : Send an update
message to the client identified by CID.");
+ msg(M_CLIENT, "reload-push-options [sync] : Reload push options
from config file for new clients.");
+ msg(M_CLIENT, " With 'sync': also sync
options to connected clients (add new, remove old).");
msg(M_CLIENT, "END");
}
@@ -1723,6 +1725,33 @@ man_dispatch_command(struct management *man,
struct status_output *so, const cha
man_push_update(man, p, UPT_BY_CID);
}
}
+ else if (streq(p[0], "reload-push-options"))
+ {
+ if (man->persist.callback.reload_push_options)
+ {
+ bool sync = (p[1] && streq(p[1], "sync"));
+ bool status =
(*man->persist.callback.reload_push_options)(man->persist.callback.arg,
sync);
+ if (status)
+ {
+ if (sync)
+ {
+ msg(M_CLIENT, "SUCCESS: push options reloaded and
synced to all clients");
+ }
+ else
+ {
+ msg(M_CLIENT, "SUCCESS: push options reloaded
from config file");
+ }
+ }
+ else
+ {
+ msg(M_CLIENT, "ERROR: failed to reload push options");
+ }
+ }
+ else
+ {
+ man_command_unsupported("reload-push-options");
+ }
+ }
#if 1
else if (streq(p[0], "test"))
{
@@ -198,6 +198,7 @@ struct management_callback
bool (*remote_entry_get)(void *arg, unsigned int index, char **remote);
bool (*push_update_broadcast)(void *arg, const char *options);
bool (*push_update_by_cid)(void *arg, unsigned long cid, const
char *options);
+ bool (*reload_push_options)(void *arg, bool sync);
};
/*
@@ -34,6 +34,7 @@
#include "forward.h"
#include "multi.h"
#include "push.h"
+#include "options_util.h"
#include "run_command.h"
#include "otime.h"
#include "gremlin.h"
@@ -4100,6 +4101,284 @@ management_get_peer_info(void *arg, const
unsigned long cid)
return ret;
}
+/**
+ * Check if an option string exists in a push_list.
+ */
+static bool
+push_option_exists(const struct push_list *list, const char *option)
+{
+ const struct push_entry *e = list->head;
+ while (e)
+ {
+ if (e->enable && e->option && strcmp(e->option, option) == 0)
+ {
+ return true;
+ }
+ e = e->next;
+ }
+ return false;
+}
+
+/*
+ * Helper to append to push list using specific GC.
+ */
+static void
+push_list_add(struct push_list *list, const char *opt, struct gc_arena *gc)
+{
+ struct push_entry *e;
+ ALLOC_OBJ_CLEAR_GC(e, struct push_entry, gc);
+ e->enable = true;
+ e->option = opt;
+
+ if (list->tail)
+ {
+ list->tail->next = e;
+ list->tail = e;
+ }
+ else
+ {
+ list->head = e;
+ list->tail = e;
+ }
+}
+
+/**
+ * Find the index of an updatable option type for a given option string.
+ * @param option The option string to check (e.g., "route 10.0.0.0 255.0.0.0")
+ * @return Index into updatable_options[] or -1 if not found
+ */
+static ssize_t
+find_updatable_option_index(const char *option)
+{
+ size_t len = strlen(option);
+ for (size_t i = 0; i < updatable_options_count; ++i)
+ {
+ size_t opt_len = strlen(updatable_options[i]);
+ if (len >= opt_len
+ && strncmp(option, updatable_options[i], opt_len) == 0
+ && (option[opt_len] == '\0' || option[opt_len] == ' '))
+ {
+ return (ssize_t)i;
+ }
+ }
+ return -1;
+}
+
+/**
+ * Reload push options from the configuration file.
+ * This function re-reads the config file and updates the push_list
+ * that will be sent to new connecting clients.
+ *
+ * Thread safety: OpenVPN uses a single-threaded event loop, so this
+ * function runs sequentially with all other operations.
+ *
+ * @param arg Pointer to multi_context
+ * @param sync If true, sync options to connected clients (add new,
remove old)
+ * @return true on success, false on failure
+ */
+static bool
+management_callback_reload_push_options(void *arg, bool sync)
+{
+ struct multi_context *m = (struct multi_context *)arg;
+ struct gc_arena gc = gc_new();
+ bool ret = false;
+
+ msg(M_INFO, "MANAGEMENT: Reloading push options from config file");
+
+ /* Check if we have a config file to reload from */
+ if (!m->top.options.config)
+ {
+ msg(M_WARN, "MANAGEMENT: Cannot reload push options - no
config file specified");
+ goto cleanup;
+ }
+
+ /* Save reference to old push_list for sync comparison */
+ struct push_list old_push_list = m->top.options.push_list;
+
+ /* Create a temporary options structure to parse the config */
+ struct options new_options;
+ CLEAR(new_options);
+
+ /* Initialize the gc_arena for the new options */
+ new_options.gc = gc_new();
+
+ /* Set up environment for config parsing */
+ struct env_set *es = env_set_create(&gc);
+ unsigned int option_types_found = 0;
+
+ /* Re-read the configuration file */
+ read_config_file(&new_options, m->top.options.config, 0,
+ m->top.options.config, 0, M_WARN,
+ OPT_P_DEFAULT, &option_types_found, es);
+
+ /* Validate we got a sensible result - if old list had entries
but new is empty,
+ * this likely indicates a parsing error */
+ if (old_push_list.head && !new_options.push_list.head)
+ {
+ msg(M_WARN, "MANAGEMENT: Config reload returned empty push
list - aborting");
+ gc_free(&new_options.gc);
+ goto cleanup;
+ }
+
+ /* Create a new GC arena for the new push list */
+ struct gc_arena new_push_list_gc = gc_new();
+ struct push_list new_push_list = { NULL, NULL };
+
+ /* Copy each push entry from the parsed config to the new push_list
+ * using the new dedicated push_list_gc */
+ const struct push_entry *e = new_options.push_list.head;
+ while (e)
+ {
+ if (e->enable && e->option)
+ {
+ /* Copy the option string to the new dedicated gc_arena */
+ const char *opt = string_alloc(e->option, &new_push_list_gc);
+ push_list_add(&new_push_list, opt, &new_push_list_gc);
+ }
+ e = e->next;
+ }
+
+ /* Free the temporary options gc_arena (parsed config) */
+ gc_free(&new_options.gc);
+
+ /* Sync options to connected clients if requested */
+ /* We do this BEFORE swapping the lists so we can compare old vs new */
+ if (sync)
+ {
+ /* Calculate required buffer size: sum of all option lengths
+ separators */
+ size_t opts_size = 0;
+ const struct push_entry *size_e = new_push_list.head;
+ while (size_e)
+ {
+ if (size_e->enable && size_e->option)
+ {
+ opts_size += strlen(size_e->option) + 2; /* option + ", " */
+ }
+ size_e = size_e->next;
+ }
+ /* Add space for removal commands: "-type, " for each
updatable option type */
+ opts_size += updatable_options_count * 32;
+ /* Minimum size to avoid edge cases */
+ if (opts_size < PUSH_BUNDLE_SIZE)
+ {
+ opts_size = PUSH_BUNDLE_SIZE;
+ }
+
+ struct buffer opts = alloc_buf_gc(opts_size, &gc);
+ bool first = true;
+ int added = 0, removed = 0;
+
+ /* Set of option types that have been removed/modified */
+ bool *type_removed = gc_malloc(updatable_options_count *
sizeof(bool), true, &gc);
+
+ /* 1. Detect removed options and mark their types */
+ const struct push_entry *old_e = old_push_list.head;
+ while (old_e)
+ {
+ if (old_e->enable && old_e->option)
+ {
+ if (!push_option_exists(&new_push_list, old_e->option))
+ {
+ ssize_t type_idx =
find_updatable_option_index(old_e->option);
+ if (type_idx >= 0)
+ {
+ type_removed[type_idx] = true;
+ removed++;
+ msg(D_PUSH, "MANAGEMENT: Sync removing: %s",
old_e->option);
+ }
+ else
+ {
+ msg(M_WARN, "MANAGEMENT: Cannot sync removal
of option '%s' (not updatable)", old_e->option);
+ }
+ }
+ }
+ old_e = old_e->next;
+ }
+
+ /* 2. Add removal commands for all marked types */
+ for (size_t i = 0; i < updatable_options_count; ++i)
+ {
+ if (type_removed[i])
+ {
+ if (!first)
+ {
+ buf_printf(&opts, ", ");
+ }
+ /* Send -type to remove all options of that type */
+ buf_printf(&opts, "-%s", updatable_options[i]);
+ first = false;
+ }
+ }
+
+ /* 3. Add new options AND re-add options belonging to removed types */
+ const struct push_entry *new_e = new_push_list.head;
+ while (new_e)
+ {
+ if (new_e->enable && new_e->option)
+ {
+ bool should_send = false;
+ bool is_existing = push_option_exists(&old_push_list,
new_e->option);
+
+ /* Check if this option belongs to a type that was reset */
+ bool type_was_reset = false;
+ ssize_t type_idx = find_updatable_option_index(new_e->option);
+ if (type_idx >= 0 && type_removed[type_idx])
+ {
+ type_was_reset = true;
+ }
+
+ /* Always send new options */
+ if (!is_existing)
+ {
+ should_send = true;
+ added++;
+ msg(D_PUSH, "MANAGEMENT: Sync adding: %s", new_e->option);
+ }
+ /* Also resend options if their type was reset
(because we sent -type) */
+ else if (type_was_reset)
+ {
+ should_send = true;
+ msg(D_PUSH, "MANAGEMENT: Sync re-adding (type
reset): %s", new_e->option);
+ }
+
+ if (should_send)
+ {
+ if (!first)
+ {
+ buf_printf(&opts, ", ");
+ }
+ buf_printf(&opts, "%s", new_e->option);
+ first = false;
+ }
+ }
+ new_e = new_e->next;
+ }
+
+ if (BLEN(&opts) > 0)
+ {
+ msg(M_INFO, "MANAGEMENT: Syncing push options to clients
(added=%d, removed=%d)",
+ added, removed);
+ management_callback_send_push_update_broadcast(m, BSTR(&opts));
+ }
+ else
+ {
+ msg(M_INFO, "MANAGEMENT: No changes to sync to clients");
+ }
+ }
+
+ /* Now replace the old push_list with the new one and free old memory */
+ gc_free(&m->top.options.push_list_gc);
+ m->top.options.push_list_gc = new_push_list_gc;
+ m->top.options.push_list = new_push_list;
+
+ msg(M_INFO, "MANAGEMENT: Push options reloaded successfully");
+ ret = true;
+
+cleanup:
+ gc_free(&gc);
+ return ret;
+}
+
#endif /* ifdef ENABLE_MANAGEMENT */
@@ -4125,6 +4404,7 @@ init_management_callback_multi(struct multi_context *m)
cb.get_peer_info = management_get_peer_info;
cb.push_update_broadcast =
management_callback_send_push_update_broadcast;
cb.push_update_by_cid = management_callback_send_push_update_by_cid;
+ cb.reload_push_options = management_callback_reload_push_options;
management_set_callback(management, &cb);
}
#endif /* ifdef ENABLE_MANAGEMENT */
@@ -807,6 +807,7 @@ init_options(struct options *o, const bool init_gc)
if (init_gc)
{
gc_init(&o->gc);
+ gc_init(&o->push_list_gc);
gc_init(&o->dns_options.gc);
o->gc_owned = true;
}
@@ -942,6 +943,7 @@ uninit_options(struct options *o)
if (o->gc_owned)
{
gc_free(&o->gc);
+ gc_free(&o->push_list_gc);
gc_free(&o->dns_options.gc);
}
}
@@ -487,6 +487,7 @@ struct options
in_addr_t server_bridge_pool_end;
struct push_list push_list;
+ struct gc_arena push_list_gc;
bool ifconfig_pool_defined;
in_addr_t ifconfig_pool_start;
in_addr_t ifconfig_pool_end;
@@ -193,7 +193,7 @@ atoi_constrained(const char *str, int *value,
const char *name, int min, int max
return true;
}
-static const char *updatable_options[] = { "block-ipv6", "block-outside-dns",
+const char *updatable_options[] = { "block-ipv6", "block-outside-dns",
"dhcp-option", "dns",
"ifconfig", "ifconfig-ipv6",
"push-continuation",
"redirect-gateway",
@@ -202,6 +202,8 @@ static const char *updatable_options[] = {
"block-ipv6", "block-outside-dns",
"route-metric", "topology",
"tun-mtu", "keepalive" };
+const size_t updatable_options_count = sizeof(updatable_options) /
sizeof(char *);
+
bool
check_push_update_option_flags(char *line, int *i, unsigned int *flags)
{
@@ -232,8 +234,7 @@ check_push_update_option_flags(char *line, int *i,
unsigned int *flags)
}
size_t len = strlen(&line[*i]);
- int count = sizeof(updatable_options) / sizeof(char *);
- for (int j = 0; j < count; ++j)
+ for (size_t j = 0; j < updatable_options_count; ++j)
{
size_t opt_len = strlen(updatable_options[j]);
if (len < opt_len)
@@ -108,4 +108,7 @@ bool apply_pull_filter(const struct options *o, char *line);
*/
bool check_push_update_option_flags(char *line, int *i, unsigned int *flags);
+extern const char *updatable_options[];
+extern const size_t updatable_options_count;
+
#endif /* ifndef OPTIONS_UTIL_H_ */
b/tests/reload_push_options/.gitignore
new file mode 100644
@@ -0,0 +1,6 @@
+# Generated during tests
+keys/
+results/
+
+
+
b/tests/reload_push_options/Dockerfile
new file mode 100644
@@ -0,0 +1,46 @@
+# Build OpenVPN from source
+FROM debian:bookworm-slim AS builder
+
+RUN apt-get update && apt-get install -y \
+ build-essential \
+ autoconf \
+ automake \
+ libtool \
+ pkg-config \
+ libssl-dev \
+ liblz4-dev \
+ liblzo2-dev \
+ libpam0g-dev \
+ libcap-ng-dev \
+ libnl-genl-3-dev \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /src
+COPY . .
+
+RUN autoreconf -fvi && \
+ ./configure --disable-systemd --disable-plugins && \
+ make -j$(nproc)
+
+# Runtime image
+FROM debian:bookworm-slim
+
+RUN apt-get update && apt-get install -y \
+ libssl3 \
+ liblz4-1 \
+ liblzo2-2 \
+ libcap-ng0 \
+ libnl-genl-3-200 \
+ iproute2 \
+ iptables \
+ netcat-openbsd \
+ gettext-base \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY --from=builder /src/src/openvpn/openvpn /usr/local/sbin/openvpn
+
+# Copy default server config into the image
+COPY tests/reload_push_options/configs/server.conf.default
/etc/openvpn/server.conf.default
+
+RUN mkdir -p /dev/net && \
+ mknod /dev/net/tun c 10 200 || true
b/tests/reload_push_options/README.md
new file mode 100644
@@ -0,0 +1,84 @@
+# reload-push-options Test Suite
+
+Integration tests for the `reload-push-options` management command.
+
+## Prerequisites
+
+- Docker & Docker Compose
+- OpenSSL (for key generation)
+
+## Quick Start
+
+```bash
+cd tests/reload_push_options
+./run.sh
+```
+
+## What it tests
+
+| Test | Description |
+|------|-------------|
+| 1 | Basic reload without sync - existing clients unchanged |
+| 2 | Sync with new route added |
+| 3 | Sync with route removed |
+| 4 | Sync with all routes removed |
+| 5 | Sync with only new routes (complete replacement) |
+| 6 | Sync with mixed changes (add + remove) |
+| 7 | New client receives updated config |
+| 8 | Stress test with 500 routes |
+
+## Architecture
+
+```
+┌─────────────────────────────────────────────────┐
+│ Docker Network │
+│ 10.100.0.0/24 │
+│ │
+│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
+│ │ Server │ │ Client1 │ │ Client2 │ │
+│ │ .0.2 │◄───│ .0.10 │ │ .0.11 │ │
+│ │ │◄───│ │ │ │ │
+│ │ :7505 │ │ │ │ │ │
+│ │ (mgmt) │ │ │ │ │ │
+│ └──────────┘ └──────────┘ └──────────┘ │
+│ │ │
+└───────┼─────────────────────────────────────────┘
+ │
+ localhost:7505 (management interface)
+```
+
+## Manual Testing
+
+```bash
+# Start the environment
+docker compose up -d
+
+# Connect to management interface
+nc localhost 7505
+
+# Commands to try:
+help
+status
+reload-push-options
+reload-push-options sync
+```
+
+## Files
+
+- `docker-compose.yml` - Container orchestration
+- `Dockerfile` - Builds OpenVPN from source
+- `configs/server.conf.default` - Default server config (baked into image)
+- `configs/client.conf` - Client config
+- `keys/` - PKI (auto-generated)
+- `scripts/` - Entrypoints and helpers
+- `results/` - Test output and logs
+
+## How Config Updates Work
+
+1. Default server config (`server.conf.default`) is copied into the
Docker image
+2. On container start, `server-entrypoint.sh` restores it to
`/etc/openvpn/server.conf`
+3. During tests, config is updated inside the container via `docker
compose exec`
+4. This ensures each test run starts from a clean, known state
+
+
+
b/tests/reload_push_options/client-entrypoint.sh
new file mode 100755
@@ -0,0 +1,35 @@
+#!/bin/bash
+set -e
+
+CLIENT_NAME=${CLIENT_NAME:-client}
+LOG_FILE="/var/log/openvpn/${CLIENT_NAME}.log"
+
+# Function to log routes
+log_routes() {
+ echo "=== Routes at $(date -Iseconds) ===" >> "$LOG_FILE"
+ ip route show | grep -E "^(10\.|172\.|192\.168\.)" >> "$LOG_FILE"
2>/dev/null || echo "(no VPN routes)" >> "$LOG_FILE"
+ echo "" >> "$LOG_FILE"
+}
+
+# Monitor route changes in background
+monitor_routes() {
+ while true; do
+ ip monitor route 2>/dev/null | while read -r line; do
+ echo "[$(date -Iseconds)] ROUTE CHANGE: $line" >> "$LOG_FILE"
+ done
+ sleep 1
+ done
+}
+
+# Start route monitor
+monitor_routes &
+
+# Log initial routes
+echo "=== Client $CLIENT_NAME starting ===" > "$LOG_FILE"
+log_routes
+
+# Start OpenVPN
+exec /usr/local/sbin/openvpn --config /etc/openvpn/client.conf \
+ --log-append "$LOG_FILE" \
+ --verb 4
+
b/tests/reload_push_options/configs/client.conf
new file mode 100644
@@ -0,0 +1,22 @@
+# OpenVPN Client Config for reload-push-options testing
+client
+dev tun
+proto udp
+remote 10.100.0.2 1194
+
+ca /etc/openvpn/keys/ca.crt
+# cert and key are set via command line based on client name
+
+nobind
+persist-key
+persist-tun
+
+# Verbose logging
+verb 4
+
+# Allow scripts to run
+script-security 2
+
+# Script to log route changes
+route-up /scripts/log-routes.sh
+route-pre-down /scripts/log-routes.sh
b/tests/reload_push_options/configs/server.conf.default
new file mode 100644
@@ -0,0 +1,16 @@
+dev tun
+proto udp
+port 1194
+server 10.8.0.0 255.255.255.0
+ca /etc/openvpn/keys/ca.crt
+cert /etc/openvpn/keys/server.crt
+key /etc/openvpn/keys/server.key
+dh /etc/openvpn/keys/dh.pem
+keepalive 10 120
+persist-key
+persist-tun
+management 0.0.0.0 7505
+verb 4
+log /var/log/openvpn.log
+${PUSH_OPTIONS}
+
b/tests/reload_push_options/docker-compose.yml
new file mode 100644
@@ -0,0 +1,73 @@
+services:
+ server:
+ build:
+ context: ../..
+ dockerfile: tests/reload_push_options/Dockerfile
+ cap_add:
+ - NET_ADMIN
+ devices:
+ - /dev/net/tun:/dev/net/tun
+ networks:
+ vpn_net:
+ ipv4_address: 10.100.0.2
+ volumes:
+ - ./keys:/etc/openvpn/keys:ro
+ - ./scripts:/scripts:ro
+ ports:
+ - "7505:7505" # Management interface
+ command: ["/scripts/server-entrypoint.sh"]
+ healthcheck:
+ test: ["CMD", "sh", "-c", "ip link show tun0 >/dev/null 2>&1"]
+ interval: 2s
+ timeout: 2s
+ retries: 15
+ start_period: 5s
+
+ client1:
+ build:
+ context: ../..
+ dockerfile: tests/reload_push_options/Dockerfile
+ cap_add:
+ - NET_ADMIN
+ devices:
+ - /dev/net/tun:/dev/net/tun
+ networks:
+ vpn_net:
+ ipv4_address: 10.100.0.10
+ volumes:
+ - ./configs:/etc/openvpn:ro
+ - ./keys:/etc/openvpn/keys:ro
+ - ./scripts:/scripts:ro
+ - ./results:/results
+ depends_on:
+ server:
+ condition: service_healthy
+ command: ["/scripts/client-entrypoint.sh", "client1"]
+
+ client2:
+ build:
+ context: ../..
+ dockerfile: tests/reload_push_options/Dockerfile
+ cap_add:
+ - NET_ADMIN
+ devices:
+ - /dev/net/tun:/dev/net/tun
+ networks:
+ vpn_net:
+ ipv4_address: 10.100.0.11
+ volumes:
+ - ./configs:/etc/openvpn:ro
+ - ./keys:/etc/openvpn/keys:ro
+ - ./scripts:/scripts:ro
+ - ./results:/results
+ depends_on:
+ server:
+ condition: service_healthy
+ command: ["/scripts/client-entrypoint.sh", "client2"]
+
+networks:
+ vpn_net:
+ driver: bridge
+ ipam:
+ config:
+ - subnet: 10.100.0.0/24
new file mode 100755
@@ -0,0 +1,277 @@
+#!/bin/bash
+# Test suite for reload-push-options management command
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+cd "$SCRIPT_DIR"
+
+# Colors
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m'
+
+pass() { echo -e "${GREEN}✓ PASS${NC}: $1"; }
+fail() {
+ echo -e "${RED}✗ FAIL${NC}: $1"
+ info "Debug: Server logs (last 30 lines):"
+ docker compose exec -T server tail -30 /var/log/openvpn.log
2>/dev/null || true
+ info "Debug: Client1 logs (last 30 lines):"
+ docker compose exec -T client1 tail -30 /results/client1.log
2>/dev/null || true
+ docker compose down -v 2>/dev/null || true
+ exit 1
+}
+info() { echo -e "${YELLOW}→${NC} $1"; }
+
+mgmt() {
+ echo "$1" | nc -q1 localhost 7505 2>/dev/null || echo "$1" | nc
-w1 localhost 7505 2>/dev/null
+}
+
+get_client_log_lines() {
+ local client="$1"
+ docker compose exec -T "$client" wc -l /results/${client}.log
2>/dev/null | awk '{print $1}' || echo "0"
+}
+
+wait_for_client_ready() {
+ local client="$1"
+ local lines_before="$2"
+ local max_wait=20
+
+ for i in $(seq 1 $max_wait); do
+ sleep 1
+ if docker compose exec -T "$client" tail -n +$((lines_before
+ 1)) /results/${client}.log 2>/dev/null | grep -q "Initialization
Sequence Completed"; then
+ return 0
+ fi
+ done
+ return 1
+}
+
+get_client_routes() {
+ local client="$1"
+ if ! docker compose exec -T "$client" ip link show tun0 &>/dev/null; then
+ echo "(tun0 not found)"
+ return
+ fi
+ docker compose exec -T "$client" ip route show 2>/dev/null | grep
-E "^(192\.168\.|10\.|172\.)" || echo "(no matching routes)"
+}
+
+update_server_config() {
+ local config_content="$1"
+ # Generate config from template inside the container using envsubst
+ docker compose exec -T -e PUSH_OPTIONS="$config_content" server \
+ sh -c 'envsubst '"'"'${PUSH_OPTIONS}'"'"' <
/etc/openvpn/server.conf.default > /etc/openvpn/server.conf'
+}
+
+wait_for_clients() {
+ info "Waiting for clients to connect..."
+ for i in {1..30}; do
+ local count=$(mgmt "status" | grep -c "^client" || true)
+ if [ "$count" -ge 2 ]; then
+ return 0
+ fi
+ sleep 1
+ done
+ fail "Clients did not connect in time"
+}
+
+cleanup() {
+ info "Cleaning up..."
+ docker compose down -v 2>/dev/null || true
+ rm -rf results/*
+}
+
+# Run test: update config, reload (with or without sync), verify routes
+# Args: test_name, client, sync (0|1|skip), config_content,
must_have_pattern, must_not_have_pattern
+# sync=skip: don't send mgmt command, just verify routes (for reconnect tests)
+run_test() {
+ local test_name="$1"
+ local client="$2"
+ local sync="$3"
+ local config_content="$4"
+ local must_have="$5"
+ local must_not_have="$6"
+
+ echo ""
+ echo "--- $test_name ---"
+
+ local routes_before=$(get_client_routes "$client")
+ local log_lines=$(get_client_log_lines "$client")
+
+ update_server_config "$config_content"
+
+ if [ "$sync" != "skip" ]; then
+ local cmd="reload-push-options"
+ [ "$sync" = "1" ] && cmd="reload-push-options sync"
+
+ local result=$(mgmt "$cmd")
+ echo "Management response: $result"
+
+ if ! echo "$result" | grep -q "SUCCESS"; then
+ fail "$test_name: command failed"
+ fi
+ pass "$test_name: command succeeded"
+
+ if [ "$sync" = "1" ]; then
+ wait_for_client_ready "$client" "$log_lines"
+ else
+ sleep 2
+ fi
+ fi
+
+ local routes=$(get_client_routes "$client")
+ info "Routes: $routes"
+
+ if [ -n "$must_have" ]; then
+ if echo "$routes" | grep -qE "$must_have"; then
+ pass "$test_name: expected routes present"
+ else
+ fail "$test_name: expected routes ($must_have) not found"
+ fi
+ fi
+
+ if [ -n "$must_not_have" ]; then
+ if echo "$routes" | grep -qE "$must_not_have"; then
+ fail "$test_name: removed routes ($must_not_have) still present"
+ else
+ pass "$test_name: routes correctly removed"
+ fi
+ fi
+}
+
+trap cleanup EXIT
+
+# Generate keys if needed
+if [ ! -f keys/ca.crt ]; then
+ info "Generating test PKI..."
+ chmod +x scripts/gen-keys.sh
+ ./scripts/gen-keys.sh
+fi
+
+chmod +x scripts/*.sh
+rm -rf results/*
+mkdir -p results
+
+echo ""
+echo "=========================================="
+echo " reload-push-options Test Suite"
+echo "=========================================="
+echo ""
+
+docker compose down -v 2>/dev/null || true
+
+# Initial config is now baked into the image (server.conf.default)
+# and restored on container start by server-entrypoint.sh
+
+info "Building and starting containers..."
+docker compose build
+docker compose up -d --wait
+wait_for_clients
+
+# Test 1: No sync - routes must NOT change (still have initial
routes, not the new 192.168.30.0)
+run_test "Test 1: No sync" client1 0 \
+ 'push "route 192.168.10.0 255.255.255.0"
+push "route 192.168.20.0 255.255.255.0"
+push "route 192.168.30.0 255.255.255.0"
+push "dhcp-option DNS 8.8.8.8"' \
+ "192\.168\.10\.0|192\.168\.20\.0" "192\.168\.30\.0"
+
+# Test 2: Add route
+run_test "Test 2: Add route" client1 1 \
+ 'push "route 192.168.10.0 255.255.255.0"
+push "route 192.168.20.0 255.255.255.0"
+push "route 192.168.30.0 255.255.255.0"
+push "route 192.168.40.0 255.255.255.0"
+push "dhcp-option DNS 8.8.8.8"' \
+ "192\.168\.40\.0" ""
+
+# Test 3: Remove route
+run_test "Test 3: Remove route" client1 1 \
+ 'push "route 192.168.10.0 255.255.255.0"
+push "route 192.168.30.0 255.255.255.0"
+push "route 192.168.40.0 255.255.255.0"
+push "dhcp-option DNS 8.8.8.8"' \
+ "" "192\.168\.20\.0"
+
+# Test 4: Remove all routes
+run_test "Test 4: Remove all routes" client1 1 \
+ 'push "dhcp-option DNS 8.8.8.8"' \
+ "" "192\.168\."
+
+# Test 5: Add new routes
+run_test "Test 5: New routes" client1 1 \
+ 'push "route 172.16.0.0 255.255.0.0"
+push "route 172.17.0.0 255.255.0.0"
+push "dhcp-option DNS 1.1.1.1"' \
+ "172\.(16|17)\.0\.0" ""
+
+# Test 6: Mixed - remove 172.16, keep 172.17, add 10.10
+run_test "Test 6: Mixed changes" client1 1 \
+ 'push "route 172.17.0.0 255.255.0.0"
+push "route 10.10.0.0 255.255.0.0"
+push "dhcp-option DNS 1.1.1.1"' \
+ "172\.17\.0\.0|10\.10\.0\.0" "172\.16\.0\.0"
+
+# Test 7: Reconnected client gets current config
+echo ""
+echo "--- Test 7: Reconnect ---"
+info "Updating config and restarting client2"
+
+update_server_config 'push "route 172.17.0.0 255.255.0.0"
+push "route 10.10.0.0 255.255.0.0"
+push "route 192.168.100.0 255.255.255.0"
+push "route 192.168.200.0 255.255.255.0"
+push "dhcp-option DNS 1.1.1.1"'
+
+docker compose restart client2
+sleep 5
+
+run_test "Test 7: Reconnect" client2 skip \
+ 'push "route 172.17.0.0 255.255.0.0"
+push "route 10.10.0.0 255.255.0.0"
+push "route 192.168.100.0 255.255.255.0"
+push "route 192.168.200.0 255.255.255.0"
+push "dhcp-option DNS 1.1.1.1"' \
+ "172\.17\.0\.0|10\.10\.0\.0|192\.168\.100\.0|192\.168\.200\.0" ""
+
+# Test 8: Stress test with 500 routes
+echo ""
+echo "--- Test 8: 500 routes stress test ---"
+info "Generating config with 500 routes..."
+
+# Generate 500 routes: 10.{1-250}.{0,128}.0/25
+routes_config=""
+for i in $(seq 1 250); do
+ routes_config+="push \"route 10.$i.0.0 255.255.128.0\"
+"
+ routes_config+="push \"route 10.$i.128.0 255.255.128.0\"
+"
+done
+routes_config+='push "dhcp-option DNS 8.8.8.8"'
+
+update_server_config "$routes_config"
+
+log_lines=$(get_client_log_lines client1)
+result=$(mgmt "reload-push-options sync")
+echo "Management response: $result"
+
+if ! echo "$result" | grep -q "SUCCESS"; then
+ fail "Test 8: 500 routes - command failed"
+fi
+pass "Test 8: 500 routes - command succeeded"
+
+wait_for_client_ready client1 "$log_lines"
+
+routes=$(get_client_routes client1)
+route_count=$(echo "$routes" | grep -c "^10\." || true)
+info "Route count: $route_count"
+
+if [ "$route_count" -ge 450 ]; then
+ pass "Test 8: 500 routes - received $route_count routes"
+else
+ fail "Test 8: 500 routes - expected ~500 routes, got $route_count"
+fi
+
+echo ""
+echo "=========================================="
+echo -e "${GREEN}All tests completed!${NC}"
+echo "=========================================="
b/tests/reload_push_options/scripts/client-entrypoint.sh
new file mode 100755
@@ -0,0 +1,18 @@
+#!/bin/bash
+set -e
+
+CLIENT_NAME="${1:-client1}"
+echo "Starting OpenVPN client: $CLIENT_NAME"
+
+# Wait for server to be ready
+sleep 3
+
+# Start OpenVPN with client-specific cert/key
+exec /usr/local/sbin/openvpn \
+ --config /etc/openvpn/client.conf \
+ --cert /etc/openvpn/keys/${CLIENT_NAME}.crt \
+ --key /etc/openvpn/keys/${CLIENT_NAME}.key \
+ --log /results/${CLIENT_NAME}.log
+
+
+
b/tests/reload_push_options/scripts/gen-keys.sh
new file mode 100755
@@ -0,0 +1,48 @@
+#!/bin/bash
+# Generate test PKI for OpenVPN testing
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+KEYS_DIR="$SCRIPT_DIR/../keys"
+mkdir -p "$KEYS_DIR"
+cd "$KEYS_DIR"
+
+# Generate CA
+openssl genrsa -out ca.key 2048
+openssl req -new -x509 -days 365 -key ca.key -out ca.crt \
+ -subj "/CN=Test CA"
+
+# Generate server cert
+openssl genrsa -out server.key 2048
+openssl req -new -key server.key -out server.csr \
+ -subj "/CN=server"
+openssl x509 -req -days 365 -in server.csr -CA ca.crt -CAkey ca.key \
+ -CAcreateserial -out server.crt
+
+# Generate client1 cert
+openssl genrsa -out client1.key 2048
+openssl req -new -key client1.key -out client1.csr \
+ -subj "/CN=client1"
+openssl x509 -req -days 365 -in client1.csr -CA ca.crt -CAkey ca.key \
+ -CAcreateserial -out client1.crt
+
+# Generate client2 cert
+openssl genrsa -out client2.key 2048
+openssl req -new -key client2.key -out client2.csr \
+ -subj "/CN=client2"
+openssl x509 -req -days 365 -in client2.csr -CA ca.crt -CAkey ca.key \
+ -CAcreateserial -out client2.crt
+
+# Generate DH params (2048 required by modern OpenSSL)
+openssl dhparam -out dh.pem 2048
+
+# Generate TLS auth key
+openvpn --genkey secret ta.key 2>/dev/null || \
+ dd if=/dev/urandom of=ta.key bs=256 count=1 2>/dev/null
+
+# Cleanup CSRs
+rm -f *.csr
+
+echo "Keys generated in $KEYS_DIR"
+ls -la "$KEYS_DIR"
+
b/tests/reload_push_options/scripts/log-routes.sh
new file mode 100755
@@ -0,0 +1,13 @@
+#!/bin/bash
+# Log current routes to results file
+TIMESTAMP=$(date +%Y%m%d_%H%M%S)
+ROUTES_FILE="/results/routes_${common_name:-unknown}_${TIMESTAMP}.txt"
+
+echo "=== Route event at $TIMESTAMP ===" >> "$ROUTES_FILE"
+echo "Script: $script_type" >> "$ROUTES_FILE"
+echo "Routes:" >> "$ROUTES_FILE"
+ip route show >> "$ROUTES_FILE"
+echo "" >> "$ROUTES_FILE"
+
+
+
b/tests/reload_push_options/scripts/server-entrypoint.sh
new file mode 100755
@@ -0,0 +1,20 @@
+#!/bin/bash
+set -e
+
+echo "Starting OpenVPN server..."
+
+# Default push options (used on initial start)
+export PUSH_OPTIONS='push "route 192.168.10.0 255.255.255.0"
+push "route 192.168.20.0 255.255.255.0"
+push "dhcp-option DNS 8.8.8.8"'
+
+# Generate config from template
+envsubst '${PUSH_OPTIONS}' < /etc/openvpn/server.conf.default >
/etc/openvpn/server.conf
+echo "Generated server config with default push options"
+
+# Enable IP forwarding (ignore error in container)
+echo 1 > /proc/sys/net/ipv4/ip_forward 2>/dev/null || true
+
+# Start OpenVPN
+exec /usr/local/sbin/openvpn --config /etc/openvpn/server.conf