From db125634b88d152325624cb90de82c4cd5083e1c Mon Sep 17 00:00:00 2001 From: Grigory Trenin Date: Tue, 14 Apr 2026 13:26:32 -0400 Subject: [PATCH] Allow using no-udp option in vhosts Signed-off-by: Grigory Trenin --- NEWS | 2 + doc/sample.config | 10 +++ src/config.c | 2 + src/main-sec-mod-cmd.c | 2 +- src/main.c | 10 +++ src/vpn.h | 2 + src/worker-auth.c | 4 +- src/worker-vpn.c | 3 +- tests/check-config-scope.py | 1 - tests/data/test-no-udp.config | 36 ++++++++++ tests/data/test-no-udp.passwd | 3 + tests/meson.build | 2 +- tests/test-no-udp | 125 ++++++++++++++++++++++++++++++++++ tests/user-config/noudp3 | 1 + 14 files changed, 197 insertions(+), 6 deletions(-) create mode 100644 tests/data/test-no-udp.config create mode 100644 tests/data/test-no-udp.passwd create mode 100755 tests/test-no-udp create mode 100644 tests/user-config/noudp3 diff --git a/NEWS b/NEWS index 75568d09..f0637cb7 100644 --- a/NEWS +++ b/NEWS @@ -22,6 +22,8 @@ - Aligned default values for 'keepalive', 'rekey-time', 'cookie-timeout', 'auth-timeout', 'ban-reset-time', 'max-ban-score', and 'switch-to-tcp-timeout' with the values documented in sample.config +- The `no-udp` option can now be used to disable DTLS for specific vhosts. + Previously it was only available in per-user or per-group config. * Version 1.4.1 (released 2026-02-28) diff --git a/doc/sample.config b/doc/sample.config index 3f2189e0..b0d51def 100644 --- a/doc/sample.config +++ b/doc/sample.config @@ -677,6 +677,16 @@ route = 192.168.0.0/255.255.0.0 # [scope: vhost user] no-route = 192.168.5.0/255.255.255.0 +# Whether to disable DTLS (UDP) for client connections. If set to true, +# the server will only accept TCP connections and will not negotiate DTLS. +# This can be useful for clients behind restrictive firewalls or NATs that +# have issues with UDP, or to enforce TCP-only operation for specific vhosts. +# +# This setting can be overridden per vhost, user, or group. +# +# [scope: vhost user] +#no-udp = false + # If set, the script /usr/libexec/ocserv-fw will be called to restrict # the user to its allowed routes and prevent him from accessing # any other routes. In case of defaultroute, the no-routes are restricted. diff --git a/src/config.c b/src/config.c index 263970fa..1e4a9353 100644 --- a/src/config.c +++ b/src/config.c @@ -1308,6 +1308,8 @@ static int cfg_ini_handler(void *_ctx, const char *section, const char *name, READ_STRING(config->camouflage_secret); } else if (strcmp(name, "camouflage_realm") == 0) { READ_STRING(config->camouflage_realm); + } else if (strcmp(name, "no-udp") == 0) { + READ_TF(config->no_udp); } else { if (reload == 0) fprintf(stderr, diff --git a/src/main-sec-mod-cmd.c b/src/main-sec-mod-cmd.c index 005f4cef..cfeed248 100644 --- a/src/main-sec-mod-cmd.c +++ b/src/main-sec-mod-cmd.c @@ -338,7 +338,7 @@ static void apply_default_config(sec_mod_instance_st *sec_mod_instance, vhost_cfg_st *vhost = proc->vhost; if (!gc->has_no_udp) { - gc->no_udp = (vhost->perm_config.udp_port != 0) ? 0 : 1; + gc->no_udp = vhost->perm_config.config->no_udp; gc->has_no_udp = 1; } diff --git a/src/main.c b/src/main.c index adbede56..f13086b0 100644 --- a/src/main.c +++ b/src/main.c @@ -767,6 +767,16 @@ static int forward_udp_to_owner(main_server_st *s, struct listener_st *listener) } if (proc_to_send != 0) { + if (GETPCONFIG(s)->udp_port == 0 || + proc_to_send->config->no_udp) { + mslog(s, proc_to_send, LOG_WARNING, + "Received UDP packet from %s while UDP is disabled for this client. " + "Possible cause: compromised worker or incorrect worker match.", + human_addr((struct sockaddr *)&cli_addr, + cli_addr_size, tbuf, sizeof(tbuf))); + goto fail; + } + UdpFdMsg msg = UDP_FD_MSG__INIT; if (now - proc_to_send->udp_fd_receive_time <= diff --git a/src/vpn.h b/src/vpn.h index 6f3b10f0..300812ae 100644 --- a/src/vpn.h +++ b/src/vpn.h @@ -381,6 +381,8 @@ struct cfg_st { bool camouflage; /* [scope: vhost] */ char *camouflage_secret; /* [scope: vhost] */ char *camouflage_realm; /* [scope: vhost] */ + + bool no_udp; /* [scope: vhost user] */ }; /* diff --git a/src/worker-auth.c b/src/worker-auth.c index b6df236f..f7a0d0e0 100644 --- a/src/worker-auth.c +++ b/src/worker-auth.c @@ -787,8 +787,8 @@ static int recv_cookie_auth_reply(worker_st *ws) ws, msg->ipv6_local); } - if (msg->config->no_udp != 0) - WSPCONFIG(ws)->udp_port = 0; + if (msg->config->has_no_udp) + WSCONFIG(ws)->no_udp = msg->config->no_udp; /* routes */ if (check_if_default_route(msg->config->routes, diff --git a/src/worker-vpn.c b/src/worker-vpn.c index 463183e6..999cf4a7 100644 --- a/src/worker-vpn.c +++ b/src/worker-vpn.c @@ -2131,7 +2131,8 @@ static int connect_handler(worker_st *ws) DTLS_ACTIVE(ws)->udp_state = UP_DISABLED; DTLS_INACTIVE(ws)->udp_state = UP_DISABLED; - if (WSPCONFIG(ws)->udp_port != 0 && req->master_secret_set != 0) { + if (WSPCONFIG(ws)->udp_port != 0 && !WSCONFIG(ws)->no_udp && + req->master_secret_set != 0) { memcpy(ws->master_secret, req->master_secret, TLS_MASTER_SIZE); DTLS_ACTIVE(ws)->udp_state = UP_WAIT_FD; DTLS_INACTIVE(ws)->udp_state = UP_WAIT_FD; diff --git a/tests/check-config-scope.py b/tests/check-config-scope.py index 8779a73b..0515948f 100644 --- a/tests/check-config-scope.py +++ b/tests/check-config-scope.py @@ -246,7 +246,6 @@ def main(): # are per-user only and don't appear in the global sample.config — skip those. PER_USER_ONLY = { "iroute", "hostname", "explicit-ipv4", "explicit-ipv6", - "no-udp", "ipv4-dns", "ipv6-dns", "ipv4-nbns", "ipv6-nbns", } for opt in sorted(user_in_code): diff --git a/tests/data/test-no-udp.config b/tests/data/test-no-udp.config new file mode 100644 index 00000000..8289cd59 --- /dev/null +++ b/tests/data/test-no-udp.config @@ -0,0 +1,36 @@ +auth = plain[@SRCDIR@/data/test-no-udp.passwd] +isolate-workers = @ISOLATE_WORKERS@ +max-ban-score = 0 +max-clients = 16 +listen-proxy-proto = false +max-same-clients = 2 +tcp-port = @PORT@ +udp-port = @PORT@ +keepalive = 32400 +dpd = 440 +try-mtu-discovery = false +server-cert = @SRCDIR@/certs/server-cert.pem +server-key = @SRCDIR@/certs/server-key.pem +tls-priorities = "PERFORMANCE:%SERVER_PRECEDENCE:%COMPAT" +auth-timeout = 40 +socket-file = ./ocserv-socket +occtl-socket-file = @OCCTL_SOCKET@ +use-occtl = true +run-as-user = @USERNAME@ +run-as-group = @GROUP@ +device = vpns +default-domain = example.com +ipv4-dns = 192.168.1.1 +ipv4-network = @VPNNET@ +ipv6-network = @VPNNET6@ +ping-leases = false + +[vhost:udp-restricted.example.com] +no-udp = true +auth = plain[passwd=@SRCDIR@/data/test-no-udp.passwd] +config-per-user = @SRCDIR@/user-config/ +ca-cert = @SRCDIR@/certs/ca.pem +server-cert = @SRCDIR@/certs/server-cert.pem +server-key = @SRCDIR@/certs/server-key.pem +ipv4-network = @VPNNET@ +ipv6-network = @VPNNET6@ diff --git a/tests/data/test-no-udp.passwd b/tests/data/test-no-udp.passwd new file mode 100644 index 00000000..08ded726 --- /dev/null +++ b/tests/data/test-no-udp.passwd @@ -0,0 +1,3 @@ +noudp1:*:$5$FTX0hYx4Ns2EyUQJ$eSquiS6eIuqvS7lmVV37RTDGaoyK64l6UE8B370XZx. +noudp2:*:$5$O504PV8Po56eV95.$lMKsgC1qidOOSubv9J12Tq0YDtqygzAc7/fAWB/c8T3 +noudp3:*:$5$pza6pL8iS67.T5jd$aWJuSF0GeoK3f5B8KaVoU4QtYKLARU2ktX3KAX6fuz8 diff --git a/tests/meson.build b/tests/meson.build index 09f62623..1c507bbb 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -240,7 +240,7 @@ if get_option('root-tests') 'test-user-config', 'test-append-routes', 'test-ban', 'multiple-routes', 'json', 'test-udp-listen-host', 'test-max-same-1', 'test-vhost-udp-port-inheritance', - 'apple-ios', 'ipv6-iface', + 'apple-ios', 'ipv6-iface', 'test-no-udp', 'disconnect-user', 'disconnect-user2', 'terminate-commands', 'ping-leases', 'test-ban-local', 'test-client-bypass-protocol', 'ipv6-small-net', 'test-camouflage', 'test-camouflage-norealm', diff --git a/tests/test-no-udp b/tests/test-no-udp new file mode 100755 index 00000000..8e241b84 --- /dev/null +++ b/tests/test-no-udp @@ -0,0 +1,125 @@ +#!/bin/bash +# +# Copyright (C) 2026 Grigory Trenin +# +# This file is part of ocserv. +# +# ocserv is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation; either version 2 of the License, or (at +# your option) any later version. +# +# ocserv is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +# Test no-udp option + +OCCTL="${OCCTL:-../src/occtl/occtl}" +OCCTL_SOCKET=./occtl-no-udp-$$.socket +SERV="${SERV:-../src/ocserv}" +srcdir=${srcdir:-.} +PIDFILE=ocserv-pid.$$.tmp +CLIPID=oc-pid.$$.tmp +PATH=${PATH}:/usr/sbin +OUTFILE=occtl-no-udp.$$.tmp + +echo "Testing no-udp option..." + +finish() { + set +e + echo " * Cleaning up..." + test -n "${PID}" && kill ${PID} >/dev/null 2>&1 + test -n "${PIDFILE}" && rm -f ${PIDFILE} >/dev/null 2>&1 + test -n "${CLIPID}" && kill $(cat ${CLIPID}) >/dev/null 2>&1 + test -n "${CLIPID}" && rm -f ${CLIPID} >/dev/null 2>&1 + test -n "${CONFIG}" && rm -f ${CONFIG} >/dev/null 2>&1 + rm -f ${OUTFILE} +} + +connect_vhost() { + VHOST=$1 + USERNAME=$2 + + echo " * Connecting to ${VHOST} at ${ADDRESS}:${PORT}..." + ( echo ${USERNAME} | ${CMDNS1} ${OPENCONNECT} ${VHOST}:${PORT} --resolve ${VHOST}:${ADDRESS} -u ${USERNAME} --servercert=pin-sha256:xp3scfzy3rOQsv9NcOve/8YVVv+pHr4qNCXEXrNl5s8= -s ${srcdir}/scripts/vpnc-script --pid-file=${CLIPID} --passwd-on-stdin -b ) + if test $? != 0;then + fail $PID "Could not connect to server" + fi + + ${OCCTL} -s ${OCCTL_SOCKET} show user ${USERNAME} >${OUTFILE} + if test $? != 0;then + cat ${OUTFILE} + fail $PID "occtl show user ${USERNAME} failed!" + fi + + grep -i "Username: ${USERNAME}" ${OUTFILE} >/dev/null + if test $? != 0;then + cat ${OUTFILE} + fail $PID "Username missing in occtl show user output" + fi + + grep -i "vhost: ${VHOST}" ${OUTFILE} >/dev/null + if test $? != 0;then + cat ${OUTFILE} + fail $PID "Session associated with wrong vhost (expected: ${VHOST})" + fi + + if grep -i "DTLS cipher:" ${OUTFILE} >/dev/null;then + HAVE_DTLS=1 + else + HAVE_DTLS=0 + fi +} + +disconnect() { + kill "$(cat ${CLIPID})" 2>/dev/null || true + rm -f "${CLIPID}" + sleep 3 + # Restore NS1 routing in case vpnc-script failed to run during server-initiated disconnect + reset_client_routes +} + +. `dirname $0`/common.sh +. `dirname $0`/random-net.sh +. `dirname $0`/ns.sh + +eval "${GETPORT}" + +# Run server +update_config test-no-udp.config +if test "$VERBOSE" = 1;then + DEBUG="-d 3" +fi + +${CMDNS2} ${SERV} -p ${PIDFILE} -f -c ${CONFIG} ${DEBUG} & PID=$! + +sleep 4 + +echo "*** Test 1 - UDP enabled" +connect_vhost default noudp1 +if test "${HAVE_DTLS}" = 0;then + fail $PID "DTLS session was not established with default vhost" +fi +disconnect + +echo "*** Test 2 - UDP disabled per vhost" +connect_vhost udp-restricted.example.com noudp2 +if test "${HAVE_DTLS}" = 1;then + fail $PID "DTLS session established despite being disabled for this vhost" +fi +disconnect + +echo "*** Test 3 - UDP disabled per vhost, but enabled per-user" +connect_vhost udp-restricted.example.com noudp3 +if test "${HAVE_DTLS}" = 0;then + fail $PID "DTLS session failed to establish despite user-level override" +fi +disconnect + +exit 0 diff --git a/tests/user-config/noudp3 b/tests/user-config/noudp3 new file mode 100644 index 00000000..b483ddf9 --- /dev/null +++ b/tests/user-config/noudp3 @@ -0,0 +1 @@ +no-udp = false