From d1a68359c968944674b3ad617031bcabe8286057 Mon Sep 17 00:00:00 2001 From: 4f4d <148759936+4f4d@users.noreply.github.com> Date: Wed, 20 May 2026 13:36:07 +0530 Subject: [PATCH 1/7] [openvpn] Avoided unnecessary CRL restarts #589 Moved OpenVPN-specific helpers into the image directory, made CRL refresh restart OpenVPN only when the CRL changes, and moved the base OpenVPN cron entries into an image-local crontab. Closes #589 --- images/common/init_command.sh | 7 +- images/common/utils.sh | 64 ----------- images/openwisp_openvpn/openvpn.crontab | 2 + images/openwisp_openvpn/openvpn.sh | 13 ++- images/openwisp_openvpn/openvpn_utils.sh | 137 +++++++++++++++++++++++ images/openwisp_openvpn/revokelist.sh | 13 ++- tests/runtests.py | 128 +++++++++++++++++++-- 7 files changed, 281 insertions(+), 83 deletions(-) create mode 100644 images/openwisp_openvpn/openvpn.crontab create mode 100644 images/openwisp_openvpn/openvpn_utils.sh diff --git a/images/common/init_command.sh b/images/common/init_command.sh index 5ee4a25a..aaff7ac3 100644 --- a/images/common/init_command.sh +++ b/images/common/init_command.sh @@ -30,16 +30,13 @@ elif [ "$MODULE_NAME" = 'freeradius' ]; then fi elif [ "$MODULE_NAME" = 'openvpn' ]; then if [[ -z "$VPN_DOMAIN" ]]; then exit; fi + source openvpn_utils.sh wait_nginx_services openvpn_preconfig openvpn_config openvpn_config_download crl_download - echo "*/1 * * * * sh /openvpn.sh" | crontab - - ( - crontab -l - echo "0 0 * * * sh /revokelist.sh" - ) | crontab - + crontab /openvpn.crontab crond # Schedule send topology script only when # network topology module is enabled. diff --git a/images/common/utils.sh b/images/common/utils.sh index ff5ea6da..56fe98bc 100644 --- a/images/common/utils.sh +++ b/images/common/utils.sh @@ -197,67 +197,3 @@ postfix_config() { postmap /etc/aliases newaliases } - -get_redis_value() { - local key="$1" - echo -en "GET $key\r\n" | nc redis 6379 | awk 'NR==2 {gsub(/\r/, ""); print}' -} - -openvpn_preconfig() { - mkdir -p /dev/net - if [ ! -c /dev/net/tun ]; then - mknod /dev/net/tun c 10 200 - fi - ip -6 route show default 2>/dev/null - if [ $? = 0 ]; then - echo "Enabling IPv6 Forwarding" - sysctl -w net.ipv6.conf.all.disable_ipv6=0 || echo "Failed to enable IPv6 support" - sysctl -w net.ipv6.conf.default.forwarding=1 || echo "Failed to enable IPv6 Forwarding default" - sysctl -w net.ipv6.conf.all.forwarding=1 || echo "Failed to enable IPv6 Forwarding" - fi -} - -openvpn_config() { - # Fectch UUID and Key of the default VPN only if they - # are not already set. The user may override the UUID and Key - # by setting them in the environment variables to use deploy - # a different VPN server. - if [ -z "$UUID" ]; then - export UUID=$(get_redis_value "openwisp_default_vpn_uuid") - export KEY=$(get_redis_value "openwisp_default_vpn_key") - export CA_UUID=$(get_redis_value "openwisp_default_vpn_ca_uuid") - fi -} - -openvpn_config_checksum() { - OFILE=$(curl --silent --insecure \ - "${API_INTERNAL}/controller/vpn/checksum/${UUID}/?key=${KEY}") - export OFILE - NFILE=$(cat checksum) - export NFILE -} - -openvpn_config_download() { - curl --silent --retry 10 --retry-delay 5 --retry-max-time 300 --insecure --output vpn.tar.gz \ - "${API_INTERNAL}/controller/vpn/download-config/${UUID}/?key=${KEY}" - curl --silent --insecure --output checksum \ - "${API_INTERNAL}/controller/vpn/checksum/${UUID}/?key=${KEY}" - tar xzf vpn.tar.gz - chmod 600 ./*.pem -} - -crl_download() { - curl --silent --insecure --output revoked.crl \ - "${DASHBOARD_INTERNAL}/admin/pki/ca/x509/ca/${CA_UUID}.crl" -} - -init_send_network_topology() { - if [ -z "$TOPOLOGY_UUID" ]; then - export TOPOLOGY_UUID=$(get_redis_value "default_openvpn_topology_uuid") - export TOPOLOGY_KEY=$(get_redis_value "default_openvpn_topology_key") - fi - ( - crontab -l - echo "*/$TOPOLOGY_UPDATE_INTERVAL * * * * TOPOLOGY_UUID=$TOPOLOGY_UUID TOPOLOGY_KEY=$TOPOLOGY_KEY sh /send-topology.sh" - ) | crontab - -} diff --git a/images/openwisp_openvpn/openvpn.crontab b/images/openwisp_openvpn/openvpn.crontab new file mode 100644 index 00000000..b157072d --- /dev/null +++ b/images/openwisp_openvpn/openvpn.crontab @@ -0,0 +1,2 @@ +*/1 * * * * sh /openvpn.sh +*/5 * * * * sh /revokelist.sh diff --git a/images/openwisp_openvpn/openvpn.sh b/images/openwisp_openvpn/openvpn.sh index a3af89b8..49379780 100644 --- a/images/openwisp_openvpn/openvpn.sh +++ b/images/openwisp_openvpn/openvpn.sh @@ -3,12 +3,19 @@ # This script will be called by cronjob to # update OpenVPN configurations periodically. cd / -source /utils.sh +. /utils.sh +. /openvpn_utils.sh openvpn_config -openvpn_config_checksum +if ! openvpn_config_checksum; then + echo "Failed to fetch or validate OpenVPN checksum" >&2 + exit 1 +fi if [ "${OFILE}" != "${NFILE}" ]; then - openvpn_config_download + if ! openvpn_config_download; then + echo "Failed to download updated OpenVPN configuration" >&2 + exit 1 + fi supervisorctl restart openvpn fi diff --git a/images/openwisp_openvpn/openvpn_utils.sh b/images/openwisp_openvpn/openvpn_utils.sh new file mode 100644 index 00000000..f0f1f81b --- /dev/null +++ b/images/openwisp_openvpn/openvpn_utils.sh @@ -0,0 +1,137 @@ +#!/bin/sh + +get_redis_value() { + local key="$1" + printf 'GET %s\r\n' "$key" | nc -w 5 redis 6379 | awk 'NR==2 {gsub(/\r/, ""); print}' +} + +function openvpn_preconfig { + mkdir -p /dev/net + if [ ! -c /dev/net/tun ]; then + mknod /dev/net/tun c 10 200 + fi + ip -6 route show default 2>/dev/null + if [ $? = 0 ]; then + echo "Enabling IPv6 Forwarding" + sysctl -w net.ipv6.conf.all.disable_ipv6=0 || echo "Failed to enable IPv6 support" + sysctl -w net.ipv6.conf.default.forwarding=1 || echo "Failed to enable IPv6 Forwarding default" + sysctl -w net.ipv6.conf.all.forwarding=1 || echo "Failed to enable IPv6 Forwarding" + fi +} + +function openvpn_config { + # Fectch UUID and Key of the default VPN only if they + # are not already set. The user may override the UUID and Key + # by setting them in the environment variables to use deploy + # a different VPN server. + if [ -z "$UUID" ]; then + export UUID=$(get_redis_value "openwisp_default_vpn_uuid") + export KEY=$(get_redis_value "openwisp_default_vpn_key") + export CA_UUID=$(get_redis_value "openwisp_default_vpn_ca_uuid") + fi +} + +function openvpn_config_checksum { + local remote_checksum + local local_checksum + + remote_checksum=$(curl --silent --show-error --fail --insecure \ + ${API_INTERNAL}/controller/vpn/checksum/$UUID/?key=$KEY) || return 1 + if [ -z "$remote_checksum" ] || [ ! -s checksum ]; then + return 1 + fi + + local_checksum=$(cat checksum) + if [ -z "$local_checksum" ]; then + return 1 + fi + + export OFILE="$remote_checksum" + export NFILE="$local_checksum" +} + +function openvpn_config_download { + local tmp_tar + local tmp_checksum + + tmp_tar=$(mktemp /tmp/vpn-config.XXXXXX.tar.gz) || return 1 + tmp_checksum=$(mktemp /tmp/vpn-checksum.XXXXXX) || { + rm -f "$tmp_tar" + return 1 + } + trap 'rm -f "$tmp_tar" "$tmp_checksum"' EXIT HUP INT TERM + + curl --silent --show-error --fail --retry 10 --retry-delay 5 --retry-max-time 300 \ + --insecure --output "$tmp_tar" \ + ${API_INTERNAL}/controller/vpn/download-config/$UUID/?key=$KEY || return 1 + test -s "$tmp_tar" || return 1 + + curl --silent --show-error --fail --insecure --output "$tmp_checksum" \ + ${API_INTERNAL}/controller/vpn/checksum/$UUID/?key=$KEY || return 1 + test -s "$tmp_checksum" || return 1 + + tar xzf "$tmp_tar" || return 1 + set -- ./*.pem + test -e "$1" || return 1 + chmod 600 "$@" || return 1 + + mv "$tmp_checksum" checksum || return 1 + rm -f "$tmp_tar" + trap - EXIT HUP INT TERM +} + +function crl_download_to { + local output_path="${1:-revoked.crl}" + curl --silent --show-error --fail --insecure --output "$output_path" \ + ${DASHBOARD_INTERNAL}/admin/pki/ca/x509/ca/${CA_UUID}.crl +} + +function crl_download { + curl --silent --show-error --fail --retry 10 --retry-delay 5 --retry-max-time 300 \ + --insecure --output revoked.crl \ + ${DASHBOARD_INTERNAL}/admin/pki/ca/x509/ca/${CA_UUID}.crl +} + +function crl_download_if_changed { + local tmp_crl + tmp_crl=$(mktemp /tmp/revoked.crl.XXXXXX) || return 2 + trap 'rm -f "$tmp_crl"' EXIT HUP INT TERM + + if ! crl_download_to "$tmp_crl"; then + trap - EXIT HUP INT TERM + rm -f "$tmp_crl" + return 2 + fi + + if [ ! -f revoked.crl ] || ! cmp -s "$tmp_crl" revoked.crl; then + mv "$tmp_crl" revoked.crl || return 2 + trap - EXIT HUP INT TERM + return 0 + fi + + trap - EXIT HUP INT TERM + rm -f "$tmp_crl" + return 1 +} + +function init_send_network_topology { + case "$TOPOLOGY_UPDATE_INTERVAL" in + ''|*[!0-9]*|0) + echo "Skipping topology cron: invalid TOPOLOGY_UPDATE_INTERVAL: $TOPOLOGY_UPDATE_INTERVAL" >&2 + return 0 + ;; + esac + + if [ -z "$TOPOLOGY_UUID" ]; then + export TOPOLOGY_UUID=$(get_redis_value "default_openvpn_topology_uuid") + export TOPOLOGY_KEY=$(get_redis_value "default_openvpn_topology_key") + fi + if [ -z "$TOPOLOGY_UUID" ] || [ -z "$TOPOLOGY_KEY" ]; then + echo "Skipping topology cron: missing TOPOLOGY_UUID or TOPOLOGY_KEY" >&2 + return 0 + fi + ( + crontab -l + echo "*/$TOPOLOGY_UPDATE_INTERVAL * * * * TOPOLOGY_UUID=$TOPOLOGY_UUID TOPOLOGY_KEY=$TOPOLOGY_KEY sh /send-topology.sh" + ) | crontab - +} diff --git a/images/openwisp_openvpn/revokelist.sh b/images/openwisp_openvpn/revokelist.sh index 35e93dd2..5df0b6ee 100644 --- a/images/openwisp_openvpn/revokelist.sh +++ b/images/openwisp_openvpn/revokelist.sh @@ -3,8 +3,15 @@ # This script will be called by cronjob to # update CRL periodically. cd / -source /utils.sh +. /utils.sh +. /openvpn_utils.sh openvpn_config -crl_download -supervisorctl restart openvpn +crl_download_if_changed +crl_status=$? + +if [ "$crl_status" -eq 0 ]; then + supervisorctl restart openvpn +elif [ "$crl_status" -eq 2 ]; then + echo "Failed to download CRL, keeping existing revoked.crl" >&2 +fi diff --git a/tests/runtests.py b/tests/runtests.py index ffa47ede..6974585c 100644 --- a/tests/runtests.py +++ b/tests/runtests.py @@ -6,6 +6,7 @@ from pathlib import Path from urllib import error as urlerror from urllib import request +from uuid import uuid4 import requests from selenium.common.exceptions import TimeoutException @@ -352,10 +353,12 @@ def test_console_errors(self): def test_add_superuser(self): """Create new user to ensure a new user can be added.""" + username = f"test_superuser_{uuid4().hex[:8]}" + email = f"{username}@example.com" self.login() - self.create_superuser() + self.create_superuser(email=email, username=username) self.assertEqual( - "The user “test_superuser” was changed successfully.", + f"The user “{username}” was changed successfully.", self.find_element(By.CLASS_NAME, "success").text, ) @@ -460,14 +463,125 @@ def _test_celery_task_registered(container_name): with self.subTest("Test celery_monitoring container"): _test_celery_task_registered("celery_monitoring") + def test_openvpn_revokelist_restart_logic(self): + """Ensure revokelist restart behavior only triggers on CRL changes.""" + + container_id = self.docker_compose_get_container_id("openvpn") + openvpn_container = self.docker_client.containers.get(container_id) + test_dir = "/tmp/openwisp-crl-test" + + def _run_case(mode, seed_crl=None): + setup_existing = "" + if seed_crl is None: + setup_existing = f"rm -f {test_dir}/work/revoked.crl" + else: + setup_existing = ( + f"printf '%s\\n' '{seed_crl}' > {test_dir}/work/revoked.crl" + ) + + command = f""" +set -eu +rm -rf {test_dir} +mkdir -p {test_dir}/bin {test_dir}/work +cat > {test_dir}/bin/supervisorctl <<'EOF' +#!/bin/sh +printf '%s\\n' "$*" >> {test_dir}/supervisor.log +EOF +chmod +x {test_dir}/bin/supervisorctl +cp /openvpn_utils.sh {test_dir}/openvpn_utils.sh +cat >> {test_dir}/openvpn_utils.sh <<'EOF' + +openvpn_config() {{ :; }} + +crl_download_to() {{ + output_path="${{1:-revoked.crl}}" + case "${{CRL_TEST_MODE}}" in + initial|unchanged) + printf '%s\\n' 'v1-initial-crl' > "$output_path" + ;; + changed) + printf '%s\\n' 'v2-changed-crl' > "$output_path" + ;; + fail) + return 1 + ;; + esac +}} +EOF +sed \ + -e 's|^cd /$|cd {test_dir}/work|' \ + -e 's|^source /utils.sh$|:|' \ + -e 's|^[.] /utils.sh$|:|' \ + -e 's|^source /openvpn_utils.sh$|. {test_dir}/openvpn_utils.sh|' \ + -e 's|^[.] /openvpn_utils.sh$|. {test_dir}/openvpn_utils.sh|' \ + /revokelist.sh > {test_dir}/revokelist.sh +grep -Fx "cd {test_dir}/work" {test_dir}/revokelist.sh >/dev/null +grep -Fx ":" {test_dir}/revokelist.sh >/dev/null +grep -Fx ". {test_dir}/openvpn_utils.sh" {test_dir}/revokelist.sh >/dev/null +! grep -Fx "cd /" {test_dir}/revokelist.sh >/dev/null +! grep -Fx "source /utils.sh" {test_dir}/revokelist.sh >/dev/null +! grep -Fx "source /openvpn_utils.sh" {test_dir}/revokelist.sh >/dev/null +! grep -Fx ". /utils.sh" {test_dir}/revokelist.sh >/dev/null +! grep -Fx ". /openvpn_utils.sh" {test_dir}/revokelist.sh >/dev/null +chmod +x {test_dir}/revokelist.sh +{setup_existing} +: > {test_dir}/supervisor.log +CRL_TEST_MODE={mode} PATH={test_dir}/bin:$PATH \ + sh {test_dir}/revokelist.sh > {test_dir}/stdout 2> {test_dir}/stderr +""" + result = openvpn_container.exec_run(["sh", "-lc", command]) + self.assertEqual(result.exit_code, 0, result.output.decode("utf-8")) + + def _read(path): + read_result = openvpn_container.exec_run( + ["sh", "-lc", f"cat {path} 2>/dev/null || true"] + ) + return read_result.output.decode("utf-8") + + return { + "stdout": _read(f"{test_dir}/stdout"), + "stderr": _read(f"{test_dir}/stderr"), + "supervisor": _read(f"{test_dir}/supervisor.log"), + "revoked_crl": _read(f"{test_dir}/work/revoked.crl"), + } + + initial = _run_case("initial") + self.assertEqual(initial["stdout"], "") + self.assertEqual(initial["stderr"], "") + self.assertEqual(initial["supervisor"], "restart openvpn\n") + self.assertEqual(initial["revoked_crl"], "v1-initial-crl\n") + + unchanged = _run_case("unchanged", seed_crl="v1-initial-crl") + self.assertEqual(unchanged["stdout"], "") + self.assertEqual(unchanged["stderr"], "") + self.assertEqual(unchanged["supervisor"], "") + self.assertEqual(unchanged["revoked_crl"], "v1-initial-crl\n") + + changed = _run_case("changed", seed_crl="v1-initial-crl") + self.assertEqual(changed["stdout"], "") + self.assertEqual(changed["stderr"], "") + self.assertEqual(changed["supervisor"], "restart openvpn\n") + self.assertEqual(changed["revoked_crl"], "v2-changed-crl\n") + + failed = _run_case("fail", seed_crl="v1-initial-crl") + self.assertEqual(failed["stdout"], "") + self.assertIn( + "Failed to download CRL, keeping existing revoked.crl", + failed["stderr"], + ) + self.assertEqual(failed["supervisor"], "") + self.assertEqual(failed["revoked_crl"], "v1-initial-crl\n") + def test_radius_user_registration(self): """Ensure users can register using the RADIUS API.""" - url = f"{self.config['api_url']}/api/v1/radius/organization/default/account/" + username = f"signup-user-{uuid4().hex[:8]}" + email = f"{username}@signup.com" + url = f'{self.config["api_url"]}/api/v1/radius/organization/default/account/' response = requests.post( url, json={ - "username": "signup-user", - "email": "user@signup.com", + "username": username, + "email": email, "password1": "rLx6OH%[", "password2": "rLx6OH%[", }, @@ -476,9 +590,7 @@ def test_radius_user_registration(self): self.assertEqual(response.status_code, 201) # Delete the created user self.login() - self.get_resource( - "signup-user", "/admin/openwisp_users/user/", "field-username" - ) + self.get_resource(username, "/admin/openwisp_users/user/", "field-username") self.objects_to_delete.append(self.base_driver.current_url) def test_freeradius(self): From 65df8691a6eb8cbed1e5cf162cf88d69ff7c0eeb Mon Sep 17 00:00:00 2001 From: 4f4d <148759936+4f4d@users.noreply.github.com> Date: Wed, 20 May 2026 14:45:48 +0530 Subject: [PATCH 2/7] [openvpn] Fixed mktemp portability issue #589 Related to #589 --- images/openwisp_openvpn/openvpn_utils.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/images/openwisp_openvpn/openvpn_utils.sh b/images/openwisp_openvpn/openvpn_utils.sh index f0f1f81b..ec8733ea 100644 --- a/images/openwisp_openvpn/openvpn_utils.sh +++ b/images/openwisp_openvpn/openvpn_utils.sh @@ -54,7 +54,7 @@ function openvpn_config_download { local tmp_tar local tmp_checksum - tmp_tar=$(mktemp /tmp/vpn-config.XXXXXX.tar.gz) || return 1 + tmp_tar=$(mktemp /tmp/vpn-config.XXXXXX) || return 1 tmp_checksum=$(mktemp /tmp/vpn-checksum.XXXXXX) || { rm -f "$tmp_tar" return 1 From 0d3f543ee5119210dee6745e7093d2c8fc42d2a1 Mon Sep 17 00:00:00 2001 From: 4f4d <148759936+4f4d@users.noreply.github.com> Date: Wed, 20 May 2026 23:16:34 +0530 Subject: [PATCH 3/7] [openvpn] Hardened config update validation #589 Preserve self-recovery when the local checksum is missing, verify downloaded OpenVPN config archives against the API checksum before applying them, and add regression coverage for mismatch and recovery cases. Related to #589 --- images/openwisp_openvpn/openvpn_utils.sh | 57 +++++++-- tests/runtests.py | 147 +++++++++++++++++++++++ 2 files changed, 197 insertions(+), 7 deletions(-) diff --git a/images/openwisp_openvpn/openvpn_utils.sh b/images/openwisp_openvpn/openvpn_utils.sh index ec8733ea..6c156f26 100644 --- a/images/openwisp_openvpn/openvpn_utils.sh +++ b/images/openwisp_openvpn/openvpn_utils.sh @@ -37,13 +37,15 @@ function openvpn_config_checksum { remote_checksum=$(curl --silent --show-error --fail --insecure \ ${API_INTERNAL}/controller/vpn/checksum/$UUID/?key=$KEY) || return 1 - if [ -z "$remote_checksum" ] || [ ! -s checksum ]; then + if [ -z "$remote_checksum" ]; then return 1 fi - local_checksum=$(cat checksum) + if [ -s checksum ]; then + local_checksum=$(cat checksum) + fi if [ -z "$local_checksum" ]; then - return 1 + local_checksum="" fi export OFILE="$remote_checksum" @@ -53,13 +55,20 @@ function openvpn_config_checksum { function openvpn_config_download { local tmp_tar local tmp_checksum + local tmp_extract_dir + local expected_checksum + local actual_checksum tmp_tar=$(mktemp /tmp/vpn-config.XXXXXX) || return 1 tmp_checksum=$(mktemp /tmp/vpn-checksum.XXXXXX) || { rm -f "$tmp_tar" return 1 } - trap 'rm -f "$tmp_tar" "$tmp_checksum"' EXIT HUP INT TERM + tmp_extract_dir=$(mktemp -d /tmp/vpn-config-dir.XXXXXX) || { + rm -f "$tmp_tar" "$tmp_checksum" + return 1 + } + trap 'rm -f "$tmp_tar" "$tmp_checksum"; rm -rf "$tmp_extract_dir"' EXIT HUP INT TERM curl --silent --show-error --fail --retry 10 --retry-delay 5 --retry-max-time 300 \ --insecure --output "$tmp_tar" \ @@ -68,15 +77,49 @@ function openvpn_config_download { curl --silent --show-error --fail --insecure --output "$tmp_checksum" \ ${API_INTERNAL}/controller/vpn/checksum/$UUID/?key=$KEY || return 1 - test -s "$tmp_checksum" || return 1 + test -s "$tmp_checksum" || { + echo "Downloaded OpenVPN checksum is empty" >&2 + return 1 + } + + expected_checksum=$(awk 'NF {print $1; exit}' "$tmp_checksum" | tr -d '\r') + if [ -z "$expected_checksum" ]; then + echo "Downloaded OpenVPN checksum is empty" >&2 + return 1 + fi + + case "${#expected_checksum}" in + 32) + actual_checksum=$(md5sum "$tmp_tar" | awk '{print $1}') || return 1 + ;; + 40) + actual_checksum=$(sha1sum "$tmp_tar" | awk '{print $1}') || return 1 + ;; + 64) + actual_checksum=$(sha256sum "$tmp_tar" | awk '{print $1}') || return 1 + ;; + 128) + actual_checksum=$(sha512sum "$tmp_tar" | awk '{print $1}') || return 1 + ;; + *) + echo "Unsupported OpenVPN checksum format: $expected_checksum" >&2 + return 1 + ;; + esac + if [ "$actual_checksum" != "$expected_checksum" ]; then + echo "Downloaded OpenVPN config checksum mismatch" >&2 + return 1 + fi - tar xzf "$tmp_tar" || return 1 - set -- ./*.pem + tar xzf "$tmp_tar" -C "$tmp_extract_dir" || return 1 + set -- "$tmp_extract_dir"/*.pem test -e "$1" || return 1 chmod 600 "$@" || return 1 + cp -R "$tmp_extract_dir"/. / || return 1 mv "$tmp_checksum" checksum || return 1 rm -f "$tmp_tar" + rm -rf "$tmp_extract_dir" trap - EXIT HUP INT TERM } diff --git a/tests/runtests.py b/tests/runtests.py index 6974585c..e0279561 100644 --- a/tests/runtests.py +++ b/tests/runtests.py @@ -572,6 +572,153 @@ def _read(path): self.assertEqual(failed["supervisor"], "") self.assertEqual(failed["revoked_crl"], "v1-initial-crl\n") + def test_openvpn_config_update_integrity_logic(self): + """Ensure config update logic recovers local state and rejects mismatches.""" + + container_id = self.docker_compose_get_container_id("openvpn") + openvpn_container = self.docker_client.containers.get(container_id) + test_dir = "/tmp/openwisp-config-test" + + def _run_case(mode): + seed_checksum = "" + if mode == "mismatch": + seed_checksum = ( + f"printf '%s\\n' 'old-checksum' > {test_dir}/work/checksum" + ) + + command = f""" +set -eu +rm -rf {test_dir} +mkdir -p {test_dir}/bin {test_dir}/work {test_dir}/payload +cat > {test_dir}/bin/supervisorctl <<'EOF' +#!/bin/sh +printf '%s\\n' "$*" >> {test_dir}/supervisor.log +EOF +chmod +x {test_dir}/bin/supervisorctl +cat > {test_dir}/bin/curl <<'EOF' +#!/bin/sh +set -eu +output="" +url="" +while [ "$#" -gt 0 ]; do + case "$1" in + --output) + output="$2" + shift 2 + ;; + --retry|--retry-delay|--retry-max-time) + shift 2 + ;; + --silent|--show-error|--fail|--insecure) + shift + ;; + *) + url="$1" + shift + ;; + esac +done +case "$url" in + */download-config/*) + cp "$OPENVPN_TEST_TAR" "$output" + ;; + */checksum/*) + if [ -n "$output" ]; then + printf '%s\\n' "$OPENVPN_REMOTE_CHECKSUM" > "$output" + else + printf '%s\\n' "$OPENVPN_REMOTE_CHECKSUM" + fi + ;; + *) + echo "Unexpected curl URL: $url" >&2 + exit 1 + ;; +esac +EOF +chmod +x {test_dir}/bin/curl +printf '%s\\n' 'old-ca' > {test_dir}/work/ca.pem +printf '%s\\n' 'old-cert' > {test_dir}/work/cert.pem +printf '%s\\n' 'old-key' > {test_dir}/work/key.pem +printf '%s\\n' 'old-dh' > {test_dir}/work/dh.pem +{seed_checksum} +printf '%s\\n' 'new-ca' > {test_dir}/payload/ca.pem +printf '%s\\n' 'new-cert' > {test_dir}/payload/cert.pem +printf '%s\\n' 'new-key' > {test_dir}/payload/key.pem +printf '%s\\n' 'new-dh' > {test_dir}/payload/dh.pem +tar czf {test_dir}/payload.tar.gz -C {test_dir}/payload . +ACTUAL_CHECKSUM=$(sha256sum {test_dir}/payload.tar.gz | awk '{{print $1}}') +case "{mode}" in + mismatch) + REMOTE_CHECKSUM='0000000000000000000000000000000000000000000000000000000000000000' + ;; + missing_local_checksum) + REMOTE_CHECKSUM="$ACTUAL_CHECKSUM" + ;; +esac +sed \ + -e 's|cp -R "\\$tmp_extract_dir"/. /|cp -R "$tmp_extract_dir"/. {test_dir}/work/|' \ + /openvpn_utils.sh > {test_dir}/openvpn_utils.sh +sed \ + -e 's|^cd /$|cd {test_dir}/work|' \ + -e 's|^[.] /utils.sh$|:|' \ + -e 's|^[.] /openvpn_utils.sh$|. {test_dir}/openvpn_utils.sh|' \ + /openvpn.sh > {test_dir}/openvpn.sh +grep -Fx "cd {test_dir}/work" {test_dir}/openvpn.sh >/dev/null +grep -Fx ":" {test_dir}/openvpn.sh >/dev/null +grep -Fx ". {test_dir}/openvpn_utils.sh" {test_dir}/openvpn.sh >/dev/null +grep -F 'cp -R "$tmp_extract_dir"/. {test_dir}/work/' \ + {test_dir}/openvpn_utils.sh >/dev/null +! grep -Fx "cd /" {test_dir}/openvpn.sh >/dev/null +! grep -Fx ". /utils.sh" {test_dir}/openvpn.sh >/dev/null +! grep -Fx ". /openvpn_utils.sh" {test_dir}/openvpn.sh >/dev/null +chmod +x {test_dir}/openvpn.sh +: > {test_dir}/supervisor.log +OPENVPN_TEST_TAR={test_dir}/payload.tar.gz \ +OPENVPN_REMOTE_CHECKSUM="$REMOTE_CHECKSUM" \ +PATH={test_dir}/bin:$PATH \ +UUID=test-vpn KEY=test-key \ + sh {test_dir}/openvpn.sh > {test_dir}/stdout 2> {test_dir}/stderr +""" + result = openvpn_container.exec_run(["sh", "-lc", command]) + + def _read(path): + read_result = openvpn_container.exec_run( + ["sh", "-lc", f"cat {path} 2>/dev/null || true"] + ) + return read_result.output.decode("utf-8") + + return { + "exit_code": result.exit_code, + "stdout": _read(f"{test_dir}/stdout"), + "stderr": _read(f"{test_dir}/stderr"), + "supervisor": _read(f"{test_dir}/supervisor.log"), + "ca_pem": _read(f"{test_dir}/work/ca.pem"), + "checksum": _read(f"{test_dir}/work/checksum"), + } + + mismatch = _run_case("mismatch") + self.assertEqual(mismatch["exit_code"], 1) + self.assertEqual(mismatch["stdout"], "") + self.assertIn( + "Downloaded OpenVPN config checksum mismatch", + mismatch["stderr"], + ) + self.assertIn( + "Failed to download updated OpenVPN configuration", + mismatch["stderr"], + ) + self.assertEqual(mismatch["supervisor"], "") + self.assertEqual(mismatch["ca_pem"], "old-ca\n") + self.assertEqual(mismatch["checksum"], "old-checksum\n") + + missing_local_checksum = _run_case("missing_local_checksum") + self.assertEqual(missing_local_checksum["exit_code"], 0) + self.assertEqual(missing_local_checksum["stdout"], "") + self.assertEqual(missing_local_checksum["stderr"], "") + self.assertEqual(missing_local_checksum["supervisor"], "restart openvpn\n") + self.assertEqual(missing_local_checksum["ca_pem"], "new-ca\n") + self.assertRegex(missing_local_checksum["checksum"].strip(), r"^[0-9a-f]{64}$") + def test_radius_user_registration(self): """Ensure users can register using the RADIUS API.""" username = f"signup-user-{uuid4().hex[:8]}" From fee43dec3ea4dcc9a7170905ee4b05ca25d7130c Mon Sep 17 00:00:00 2001 From: 4f4d <148759936+4f4d@users.noreply.github.com> Date: Thu, 21 May 2026 01:40:52 +0530 Subject: [PATCH 4/7] [openvpn] Retry CRL temp download path #589 Use the same retry and backoff options for the temporary CRL download path that are already used for the direct CRL refresh path. Related to #589 --- images/openwisp_openvpn/openvpn_utils.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/images/openwisp_openvpn/openvpn_utils.sh b/images/openwisp_openvpn/openvpn_utils.sh index 6c156f26..eba59fa9 100644 --- a/images/openwisp_openvpn/openvpn_utils.sh +++ b/images/openwisp_openvpn/openvpn_utils.sh @@ -125,8 +125,9 @@ function openvpn_config_download { function crl_download_to { local output_path="${1:-revoked.crl}" - curl --silent --show-error --fail --insecure --output "$output_path" \ - ${DASHBOARD_INTERNAL}/admin/pki/ca/x509/ca/${CA_UUID}.crl + curl --silent --show-error --fail --retry 10 --retry-delay 5 --retry-max-time 300 \ + --insecure --output "$output_path" \ + ${DASHBOARD_INTERNAL}/admin/pki/ca/x509/ca/${CA_UUID}.crl } function crl_download { From f0ab110eea35c5de649313f7f66433312ebf5490 Mon Sep 17 00:00:00 2001 From: 4f4d <148759936+4f4d@users.noreply.github.com> Date: Thu, 21 May 2026 02:41:05 +0530 Subject: [PATCH 5/7] [openvpn] Guard IPv6 preconfig probe #589 Related to #589 --- images/openwisp_openvpn/openvpn_utils.sh | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/images/openwisp_openvpn/openvpn_utils.sh b/images/openwisp_openvpn/openvpn_utils.sh index eba59fa9..77377335 100644 --- a/images/openwisp_openvpn/openvpn_utils.sh +++ b/images/openwisp_openvpn/openvpn_utils.sh @@ -10,8 +10,7 @@ function openvpn_preconfig { if [ ! -c /dev/net/tun ]; then mknod /dev/net/tun c 10 200 fi - ip -6 route show default 2>/dev/null - if [ $? = 0 ]; then + if ip -6 route show default >/dev/null 2>&1; then echo "Enabling IPv6 Forwarding" sysctl -w net.ipv6.conf.all.disable_ipv6=0 || echo "Failed to enable IPv6 support" sysctl -w net.ipv6.conf.default.forwarding=1 || echo "Failed to enable IPv6 Forwarding default" From 4a76bf80c0e9acca3bd7f81f9ed3c91f90f03ac0 Mon Sep 17 00:00:00 2001 From: 4f4d <148759936+4f4d@users.noreply.github.com> Date: Sat, 6 Jun 2026 11:50:30 +0530 Subject: [PATCH 6/7] [openvpn] Validate CRL download results #589 Validate CRL downloads before replacing the live revoked.crl file. This keeps existing CRL contents when downloads fail or return empty responses, and keeps startup and refresh behavior aligned. Fixes #589 --- images/openwisp_openvpn/openvpn_utils.sh | 102 ++++++++++++++--------- tests/runtests.py | 12 +++ 2 files changed, 73 insertions(+), 41 deletions(-) diff --git a/images/openwisp_openvpn/openvpn_utils.sh b/images/openwisp_openvpn/openvpn_utils.sh index 77377335..2a73e5f6 100644 --- a/images/openwisp_openvpn/openvpn_utils.sh +++ b/images/openwisp_openvpn/openvpn_utils.sh @@ -5,7 +5,7 @@ get_redis_value() { printf 'GET %s\r\n' "$key" | nc -w 5 redis 6379 | awk 'NR==2 {gsub(/\r/, ""); print}' } -function openvpn_preconfig { +openvpn_preconfig() { mkdir -p /dev/net if [ ! -c /dev/net/tun ]; then mknod /dev/net/tun c 10 200 @@ -18,24 +18,25 @@ function openvpn_preconfig { fi } -function openvpn_config { - # Fectch UUID and Key of the default VPN only if they +openvpn_config() { + # Fetch UUID and Key of the default VPN only if they # are not already set. The user may override the UUID and Key # by setting them in the environment variables to use deploy # a different VPN server. if [ -z "$UUID" ]; then - export UUID=$(get_redis_value "openwisp_default_vpn_uuid") - export KEY=$(get_redis_value "openwisp_default_vpn_key") - export CA_UUID=$(get_redis_value "openwisp_default_vpn_ca_uuid") + UUID="$(get_redis_value "openwisp_default_vpn_uuid")" + KEY="$(get_redis_value "openwisp_default_vpn_key")" + CA_UUID="$(get_redis_value "openwisp_default_vpn_ca_uuid")" + export UUID KEY CA_UUID fi } -function openvpn_config_checksum { +openvpn_config_checksum() { local remote_checksum local local_checksum remote_checksum=$(curl --silent --show-error --fail --insecure \ - ${API_INTERNAL}/controller/vpn/checksum/$UUID/?key=$KEY) || return 1 + "${API_INTERNAL}/controller/vpn/checksum/${UUID}/?key=${KEY}") || return 1 if [ -z "$remote_checksum" ]; then return 1 fi @@ -51,7 +52,7 @@ function openvpn_config_checksum { export NFILE="$local_checksum" } -function openvpn_config_download { +openvpn_config_download() { local tmp_tar local tmp_checksum local tmp_extract_dir @@ -71,11 +72,11 @@ function openvpn_config_download { curl --silent --show-error --fail --retry 10 --retry-delay 5 --retry-max-time 300 \ --insecure --output "$tmp_tar" \ - ${API_INTERNAL}/controller/vpn/download-config/$UUID/?key=$KEY || return 1 + "${API_INTERNAL}/controller/vpn/download-config/${UUID}/?key=${KEY}" || return 1 test -s "$tmp_tar" || return 1 curl --silent --show-error --fail --insecure --output "$tmp_checksum" \ - ${API_INTERNAL}/controller/vpn/checksum/$UUID/?key=$KEY || return 1 + "${API_INTERNAL}/controller/vpn/checksum/${UUID}/?key=${KEY}" || return 1 test -s "$tmp_checksum" || { echo "Downloaded OpenVPN checksum is empty" >&2 return 1 @@ -88,22 +89,22 @@ function openvpn_config_download { fi case "${#expected_checksum}" in - 32) - actual_checksum=$(md5sum "$tmp_tar" | awk '{print $1}') || return 1 - ;; - 40) - actual_checksum=$(sha1sum "$tmp_tar" | awk '{print $1}') || return 1 - ;; - 64) - actual_checksum=$(sha256sum "$tmp_tar" | awk '{print $1}') || return 1 - ;; - 128) - actual_checksum=$(sha512sum "$tmp_tar" | awk '{print $1}') || return 1 - ;; - *) - echo "Unsupported OpenVPN checksum format: $expected_checksum" >&2 - return 1 - ;; + 32) + actual_checksum=$(md5sum "$tmp_tar" | awk '{print $1}') || return 1 + ;; + 40) + actual_checksum=$(sha1sum "$tmp_tar" | awk '{print $1}') || return 1 + ;; + 64) + actual_checksum=$(sha256sum "$tmp_tar" | awk '{print $1}') || return 1 + ;; + 128) + actual_checksum=$(sha512sum "$tmp_tar" | awk '{print $1}') || return 1 + ;; + *) + echo "Unsupported OpenVPN checksum format: $expected_checksum" >&2 + return 1 + ;; esac if [ "$actual_checksum" != "$expected_checksum" ]; then echo "Downloaded OpenVPN config checksum mismatch" >&2 @@ -122,20 +123,33 @@ function openvpn_config_download { trap - EXIT HUP INT TERM } -function crl_download_to { +crl_download_to() { local output_path="${1:-revoked.crl}" curl --silent --show-error --fail --retry 10 --retry-delay 5 --retry-max-time 300 \ --insecure --output "$output_path" \ - ${DASHBOARD_INTERNAL}/admin/pki/ca/x509/ca/${CA_UUID}.crl + "${DASHBOARD_INTERNAL}/admin/pki/ca/x509/ca/${CA_UUID}.crl" || return 1 + test -s "$output_path" } -function crl_download { - curl --silent --show-error --fail --retry 10 --retry-delay 5 --retry-max-time 300 \ - --insecure --output revoked.crl \ - ${DASHBOARD_INTERNAL}/admin/pki/ca/x509/ca/${CA_UUID}.crl +crl_download() { + local tmp_crl + + tmp_crl=$(mktemp /tmp/revoked.crl.XXXXXX) || return 1 + if ! crl_download_to "$tmp_crl"; then + rm -f "$tmp_crl" + return 1 + fi + if [ ! -s "$tmp_crl" ]; then + rm -f "$tmp_crl" + return 1 + fi + mv "$tmp_crl" revoked.crl || { + rm -f "$tmp_crl" + return 1 + } } -function crl_download_if_changed { +crl_download_if_changed() { local tmp_crl tmp_crl=$(mktemp /tmp/revoked.crl.XXXXXX) || return 2 trap 'rm -f "$tmp_crl"' EXIT HUP INT TERM @@ -145,6 +159,11 @@ function crl_download_if_changed { rm -f "$tmp_crl" return 2 fi + if [ ! -s "$tmp_crl" ]; then + trap - EXIT HUP INT TERM + rm -f "$tmp_crl" + return 2 + fi if [ ! -f revoked.crl ] || ! cmp -s "$tmp_crl" revoked.crl; then mv "$tmp_crl" revoked.crl || return 2 @@ -157,17 +176,18 @@ function crl_download_if_changed { return 1 } -function init_send_network_topology { +init_send_network_topology() { case "$TOPOLOGY_UPDATE_INTERVAL" in - ''|*[!0-9]*|0) - echo "Skipping topology cron: invalid TOPOLOGY_UPDATE_INTERVAL: $TOPOLOGY_UPDATE_INTERVAL" >&2 - return 0 - ;; + '' | *[!0-9]* | 0) + echo "Skipping topology cron: invalid TOPOLOGY_UPDATE_INTERVAL: $TOPOLOGY_UPDATE_INTERVAL" >&2 + return 0 + ;; esac if [ -z "$TOPOLOGY_UUID" ]; then - export TOPOLOGY_UUID=$(get_redis_value "default_openvpn_topology_uuid") - export TOPOLOGY_KEY=$(get_redis_value "default_openvpn_topology_key") + TOPOLOGY_UUID="$(get_redis_value "default_openvpn_topology_uuid")" + TOPOLOGY_KEY="$(get_redis_value "default_openvpn_topology_key")" + export TOPOLOGY_UUID TOPOLOGY_KEY fi if [ -z "$TOPOLOGY_UUID" ] || [ -z "$TOPOLOGY_KEY" ]; then echo "Skipping topology cron: missing TOPOLOGY_UUID or TOPOLOGY_KEY" >&2 diff --git a/tests/runtests.py b/tests/runtests.py index e0279561..e6a55720 100644 --- a/tests/runtests.py +++ b/tests/runtests.py @@ -502,6 +502,9 @@ def _run_case(mode, seed_crl=None): changed) printf '%s\\n' 'v2-changed-crl' > "$output_path" ;; + empty) + : > "$output_path" + ;; fail) return 1 ;; @@ -572,6 +575,15 @@ def _read(path): self.assertEqual(failed["supervisor"], "") self.assertEqual(failed["revoked_crl"], "v1-initial-crl\n") + empty = _run_case("empty", seed_crl="v1-initial-crl") + self.assertEqual(empty["stdout"], "") + self.assertIn( + "Failed to download CRL, keeping existing revoked.crl", + empty["stderr"], + ) + self.assertEqual(empty["supervisor"], "") + self.assertEqual(empty["revoked_crl"], "v1-initial-crl\n") + def test_openvpn_config_update_integrity_logic(self): """Ensure config update logic recovers local state and rejects mismatches.""" From 46ceef1d9fa5d085e37e07b539d5e063bfcd6d5a Mon Sep 17 00:00:00 2001 From: 4f4d <148759936+4f4d@users.noreply.github.com> Date: Sat, 6 Jun 2026 21:20:24 +0530 Subject: [PATCH 7/7] [openvpn] Fix POSIX startup syntax #589 Replace bash-specific source usage and conditionals in init_command.sh with POSIX-compatible syntax. This keeps the OpenVPN startup path compatible with the script's /bin/sh shebang. Related to #589 --- images/common/init_command.sh | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/images/common/init_command.sh b/images/common/init_command.sh index aaff7ac3..cc313fbb 100644 --- a/images/common/init_command.sh +++ b/images/common/init_command.sh @@ -1,7 +1,7 @@ #!/bin/sh # OpenWISP common module init script set -e -source utils.sh +. ./utils.sh init_conf @@ -24,13 +24,13 @@ elif [ "$MODULE_NAME" = 'postfix' ]; then elif [ "$MODULE_NAME" = 'freeradius' ]; then wait_nginx_services if [ "$DEBUG_MODE" = 'False' ]; then - source docker-entrypoint.sh + . ./docker-entrypoint.sh else - source docker-entrypoint.sh -X + . ./docker-entrypoint.sh -X fi elif [ "$MODULE_NAME" = 'openvpn' ]; then - if [[ -z "$VPN_DOMAIN" ]]; then exit; fi - source openvpn_utils.sh + if [ -z "$VPN_DOMAIN" ]; then exit; fi + . ./openvpn_utils.sh wait_nginx_services openvpn_preconfig openvpn_config @@ -40,7 +40,7 @@ elif [ "$MODULE_NAME" = 'openvpn' ]; then crond # Schedule send topology script only when # network topology module is enabled. - if [ "$USE_OPENWISP_TOPOLOGY" == "True" ]; then + if [ "$USE_OPENWISP_TOPOLOGY" = "True" ]; then init_send_network_topology fi # Supervisor is used to start the service because OpenVPN @@ -90,7 +90,7 @@ elif [ "$MODULE_NAME" = 'celery' ]; then ${OPENWISP_CELERY_NETWORK_COMMAND_FLAGS} fi - if [[ "$USE_OPENWISP_FIRMWARE" == "True" && "$USE_OPENWISP_CELERY_FIRMWARE" == "True" ]]; then + if [ "$USE_OPENWISP_FIRMWARE" = "True" ] && [ "$USE_OPENWISP_CELERY_FIRMWARE" = "True" ]; then echo "Starting the 'firmware_upgrader' celery worker" celery -A openwisp worker -l ${DJANGO_LOG_LEVEL} --queues firmware_upgrader \ -n firmware_upgrader@%h --logfile /opt/openwisp/logs/celery_firmware_upgrader.log \ @@ -101,7 +101,7 @@ elif [ "$MODULE_NAME" = 'celery' ]; then tail -f /opt/openwisp/logs/* elif [ "$MODULE_NAME" = 'celery_monitoring' ]; then python services.py database redis dashboard - if [[ "$USE_OPENWISP_MONITORING" == "True" && "$USE_OPENWISP_CELERY_MONITORING" == 'True' ]]; then + if [ "$USE_OPENWISP_MONITORING" = "True" ] && [ "$USE_OPENWISP_CELERY_MONITORING" = 'True' ]; then echo "Starting the 'monitoring' celery worker" celery -A openwisp worker -l ${DJANGO_LOG_LEVEL} --queues monitoring \ -n monitoring@%h --logfile /opt/openwisp/logs/celery_monitoring.log \