Skip to content

Your First Verified DTLS Exchange in C++

DTLS protects datagrams without turning them into a reliable byte stream. Build a small C++ client/server pair, verify the server's certificate, and distinguish handshake recovery from application delivery.

Before you start

Complete Your First UDP Exchange, Verified TLS and Certificates, Hostnames and Trust. Run commands on the Pi from the examples repository's network/ directory with its C++20/OpenSSL development setup.

Outcome: exchange one protected 17-byte message over loopback UDP, verify localhost, recover from a deliberately lost handshake datagram, and observe failure rather than an automatic application retransmission when data is dropped.

This example pins DTLS 1.2 on both peers, as exercised with the host and Pi's OpenSSL implementations. It is not TLS 1.3 or a DTLS 1.3 implementation. DTLS 1.3 is a separate, newer specification; the version used by a sample must be stated rather than inferred from its TLS library.

Controlled loopback fixture only

The server selects the source of its first queued UDP datagram, then handles one peer and exits. It does not implement a stateless cookie-protected public listener or multi-client service. Another local process can claim that first-peer slot. Keep the hard-coded loopback bind; do not expose this teaching server to a LAN or the internet.

DTLS protection does not supply TCP delivery

Question TLS over TCP, as used earlier This DTLS-over-UDP lesson
Transport socket SOCK_STREAM SOCK_DGRAM
Server identity Certificate trust and hostname check Same kinds of checks, through OpenSSL DTLS
Application delivery Ordered byte stream Datagram semantics, without delivery/order guarantees
Handshake loss recovery TCP supplies reliable transport DTLS protocol timers drive handshake recovery
This client's reply check Earlier TLS echo gathered exact bytes Require the exact message from one application-record read

DTLS does not repair lost or reordered application data. Decide in your application's protocol whether a missing reading should be retried, skipped or replaced by a newer one. Encryption does not make that decision for you. See the DTLS 1.2 delivery model.

See the two kinds of loss separately

This diagram illustrates the automated loss fixtures, not every normal run. A local relay drops selected ciphertext datagrams; it does not implement encryption or change system networking.

sequenceDiagram accTitle: Handshake recovery does not promise application delivery accDescr: A test relay drops the first ClientHello. The client's OpenSSL timer retransmits it and the handshake completes. After certificate verification, one protected application message is sent. A delivered echo succeeds, but a deliberately dropped echo is not automatically retransmitted and the client reaches its fixed deadline. participant C as C++ client participant S as DTLS server C--xS: First ClientHello dropped by relay Note over C: DTLS handshake timer becomes due C->>S: OpenSSL retransmits ClientHello S->>C: Handshake flight with certificate C->>S: Remaining handshake messages Note over C: Trust and localhost verified C->>S: One protected 17-byte message alt Echo delivered S->>C: Protected 17-byte echo Note over C: Exact datagram matches else Echo dropped by relay S--xC: Echo discarded Note over C: No application ACK/retry protocol Note over C: Fixed session deadline expires end

The drop-echo test also suppresses the server's alert, so the client's result specifically demonstrates its missing-data deadline rather than early receipt of a shutdown notification. A server reporting echo_bytes=17 proves that its local echo operation succeeded, not that the client received it.

Run a verified DTLS exchange

Build both targets:

cmake --preset native-debug
cmake --build --preset native-debug --target dtls_echo_client dtls_echo_server --parallel 2

Prepare terminal B's client command before starting the server, because its short deadline includes first-peer waiting. In terminal A, create fresh practice certificates and start the one-peer server:

1
2
3
4
5
dtls_practice=$(mktemp -d /tmp/network-dtls-practice.XXXXXX)
python3 fixtures/tls_echo.py certs "$dtls_practice/certs"
printf '%s\n' "$dtls_practice/certs"
./build/native/debug/dtls_echo_server 9446 \
    "$dtls_practice/certs/server.pem" "$dtls_practice/certs/server.key"

After LISTEN 9446, use terminal B. Replace the placeholder with the complete certificate-directory path printed above; variables are not shared across terminals:

dtls_certs="/paste/the/printed/certificate-directory"
./build/native/debug/dtls_echo_client 9446 "$dtls_certs/ca.pem"

Expected client output:

1
2
3
verified=localhost protocol=DTLSv1.2
echo_bytes=17 close_notify=sent
Hello over DTLS.

The server prints its own echo_bytes=17 protocol=DTLSv1.2 close_notify=sent summary and exits. Restart it before each attempt.

The server's default 5000 ms budget starts before binding/first-peer selection, so start the client promptly. If switching terminals takes longer, restart with an explicit 10000 ms budget:

./build/native/debug/dtls_echo_server 9446 \
    "$dtls_practice/certs/server.pem" "$dtls_practice/certs/server.key" 10000

The client accepts port ca.pem [hostname [session_ms]]: hostname defaults to localhost, and its session budget defaults to 3000 ms. Both budget arguments accept 1–10000 ms. UDP port zero selects a temporary server port, printed in LISTEN; clients need the actual port.

The practice CA is trusted only by that client invocation. Certificates expire after two days; generate fresh ones in a new directory as needed. Do not install the CA globally or commit private keys. The server does not require a client certificate; application login and mutual authentication are outside this exercise.

Connect the socket and configure its datagram BIO

The client uses a nonblocking UDP socket and calls the real OS connect() to fix its peer. This does not perform a TCP-style handshake, verify a certificate or prove that a server is listening.

The server peeks one byte with recvfrom(..., MSG_PEEK, ...) to discover the peer without consuming the queued datagram. It then connects that socket to the selected peer.

Both files create BIO_new_dgram(fd, BIO_NOCLOSE) and tell the BIO that the OS socket is already connected using BIO_CTRL_DGRAM_SET_CONNECTED. That control is not a substitute for OS connect. An unconnected datagram BIO can otherwise change its outgoing peer after a read; keep the distinction explicit. See OpenSSL datagram BIO behavior.

OpenSSL owns the BIO after SSL_set_bio(); each program still explicitly closes its socket. No course-specific socket header or cryptographic implementation is involved.

Wait for readiness or a DTLS timer, whichever comes first

The earlier TLS client could wait for socket readiness until its fixed deadline. DTLS also needs protocol timer work when no packet arrives.

The local wait loop asks DTLSv1_get_timeout() for the current retransmission timeout. It polls for the smaller of that duration and the remaining application budget. On a poll timeout it calls DTLSv1_handle_timeout(), then recomputes the wait. A due protocol timer may legitimately produce a zero-duration poll; an expired application deadline always fails first.

Keep the two clocks separate:

  • DTLS timer: OpenSSL decides which protocol messages need recovery.
  • Application deadline: our command's fixed stop time, never renewed by retransmissions or incoming noise.

The DTLSv1_* timer APIs keep this sample compatible with the course's OpenSSL 3.0 baseline; their name does not select DTLS 1.0. Version policy comes from the explicit DTLS 1.2 minimum/maximum. Newer OpenSSL also provides event-handling APIs. See timeout query and timeout handling.

Read the standalone C++ client

Look for DTLS_client_method(), connected datagram BIO setup, the two-clock wait, certificate validation before SSL_write_ex(), and the single-record reply check.

Full standalone client: app/dtls-echo/client.cpp
#include <algorithm>
#include <arpa/inet.h>
#include <array>
#include <cerrno>
#include <charconv>
#include <chrono>
#include <csignal>
#include <cstdint>
#include <iostream>
#include <openssl/err.h>
#include <openssl/ssl.h>
#include <poll.h>
#include <stdexcept>
#include <string>
#include <string_view>
#include <sys/socket.h>
#include <system_error>
#include <unistd.h>

int main(int argc, char** argv) {
    int fd = -1;
    SSL_CTX* context = nullptr;
    SSL* tls = nullptr;
    int status = 0;
    try {
        if (argc < 3 || argc > 5) {
            throw std::invalid_argument(
                "usage: dtls_echo_client port ca.pem [hostname [session_ms]]");
        }
        unsigned int port = 0;
        const std::string_view text = argv[1];
        const auto [end, error] =
            std::from_chars(text.data(), text.data() + text.size(), port);
        if (error != std::errc{} || end != text.data() + text.size() ||
            port == 0 || port > 65535) {
            throw std::invalid_argument("port must be 1..65535");
        }
        const std::string hostname = argc >= 4 ? argv[3] : "localhost";
        if (hostname.empty() || hostname.size() > 253 ||
            hostname.find_first_not_of("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLM"
                                       "NOPQRSTUVWXYZ0123456789.-") !=
                std::string::npos) {
            throw std::invalid_argument("hostname must be an ASCII DNS name");
        }
        int session_ms = 3000;
        if (argc == 5) {
            const std::string_view budget = argv[4];
            const auto [last, invalid] = std::from_chars(
                budget.data(), budget.data() + budget.size(), session_ms);
            if (invalid != std::errc{} ||
                last != budget.data() + budget.size() || session_ms < 1 ||
                session_ms > 10000) {
                throw std::invalid_argument("session_ms must be 1..10000");
            }
        }
        // OpenSSL writes through the socket BIO, not send(MSG_NOSIGNAL).
        if (std::signal(SIGPIPE, SIG_IGN) == SIG_ERR) {
            throw std::runtime_error("cannot ignore SIGPIPE");
        }
        context = SSL_CTX_new(DTLS_client_method());
        if (!context ||
            SSL_CTX_set_min_proto_version(context, DTLS1_2_VERSION) != 1 ||
            SSL_CTX_set_max_proto_version(context, DTLS1_2_VERSION) != 1 ||
            SSL_CTX_load_verify_locations(context, argv[2], nullptr) != 1) {
            throw std::runtime_error("cannot configure TLS or load CA file");
        }
        SSL_CTX_set_verify(context, SSL_VERIFY_PEER, nullptr);
        tls = SSL_new(context);
        if (!tls || SSL_set1_host(tls, hostname.c_str()) != 1 ||
            SSL_set_tlsext_host_name(tls, hostname.c_str()) != 1) {
            throw std::runtime_error("cannot configure server identity");
        }
        fd = ::socket(AF_INET, SOCK_DGRAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
        if (fd < 0) {
            throw std::system_error(errno, std::generic_category(), "socket");
        }
        const auto deadline = std::chrono::steady_clock::now() +
                              std::chrono::milliseconds(session_ms);
        const auto check_deadline = [&] {
            if (std::chrono::steady_clock::now() >= deadline) {
                throw std::runtime_error("session deadline expired");
            }
        };
        const auto wait_for = [&](short events) {
            for (;;) {
                check_deadline();
                const auto remaining =
                    std::chrono::ceil<std::chrono::milliseconds>(
                        deadline - std::chrono::steady_clock::now())
                        .count();
                if (remaining <= 0) {
                    throw std::runtime_error("session deadline expired");
                }
                auto wait_ms = remaining;
                timeval timer{};
                if (DTLSv1_get_timeout(tls, &timer) == 1) {
                    const auto due =
                        std::chrono::ceil<std::chrono::milliseconds>(
                            std::chrono::seconds(timer.tv_sec) +
                            std::chrono::microseconds(timer.tv_usec))
                            .count();
                    wait_ms = std::min(wait_ms, due);
                }
                pollfd ready{fd, events, 0};
                const int result = ::poll(&ready, 1, static_cast<int>(wait_ms));
                if (result < 0 && errno == EINTR) {
                    continue;
                }
                if (result < 0) {
                    throw std::system_error(errno, std::generic_category(),
                                            "poll");
                }
                check_deadline();
                if (result > 0) {
                    return;
                }
                // A protocol retransmission timer never renews our deadline.
                ERR_clear_error();
                if (DTLSv1_handle_timeout(tls) < 0) {
                    throw std::runtime_error("DTLS timer handling failed");
                }
            }
        };
        const auto retry_tls = [&](int result) {
            // No intervening OpenSSL call: result belongs to the last TLS call.
            const int error_code = SSL_get_error(tls, result);
            if (error_code == SSL_ERROR_WANT_READ) {
                wait_for(POLLIN);
            } else if (error_code == SSL_ERROR_WANT_WRITE) {
                wait_for(POLLOUT);
            } else {
                char detail[256]{};
                ERR_error_string_n(ERR_get_error(), detail, sizeof(detail));
                throw std::runtime_error("TLS operation failed (SSL error " +
                                         std::to_string(error_code) +
                                         "): " + detail);
            }
        };
        sockaddr_in server{};
        server.sin_family = AF_INET;
        server.sin_port = htons(static_cast<std::uint16_t>(port));
        server.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
        // UDP connect selects a peer; it does not contact or authenticate it.
        if (::connect(fd, reinterpret_cast<const sockaddr*>(&server),
                      sizeof(server)) < 0) {
            throw std::system_error(errno, std::generic_category(),
                                    "UDP connect");
        }
        BIO* datagram = BIO_new_dgram(fd, BIO_NOCLOSE);
        if (!datagram) {
            throw std::runtime_error("cannot create datagram BIO");
        }
        if (BIO_ctrl(datagram, BIO_CTRL_DGRAM_SET_CONNECTED, 0, &server) != 1) {
            BIO_free(datagram);
            throw std::runtime_error("cannot configure connected datagram BIO");
        }
        SSL_set_bio(tls, datagram,
                    datagram); // SSL owns BIO, not the descriptor.
        SSL_set_options(tls, SSL_OP_NO_QUERY_MTU);
        if (DTLS_set_link_mtu(tls, 1200) <= 0) {
            throw std::runtime_error("cannot set DTLS link MTU");
        }
        for (;;) {
            check_deadline();
            ERR_clear_error();
            const int result = SSL_connect(tls);
            if (result == 1) {
                break;
            }
            retry_tls(result);
        }
        if (SSL_get_verify_result(tls) != X509_V_OK) {
            throw std::runtime_error("server certificate verification failed");
        }
        constexpr std::string_view message = "Hello over DTLS.\n";
        if (DTLS_get_data_mtu(tls) < message.size()) {
            throw std::runtime_error("message exceeds negotiated data MTU");
        }
        for (;;) {
            check_deadline();
            std::size_t written = 0;
            ERR_clear_error();
            const int result =
                SSL_write_ex(tls, message.data(), message.size(), &written);
            if (result == 1) {
                if (written != message.size()) {
                    throw std::runtime_error("incomplete application datagram");
                }
                break;
            }
            retry_tls(result); // Retry this operation, not an application ACK.
        }
        // One read is one application record, never concatenate short replies.
        std::array<char, message.size() + 1> reply{};
        std::size_t received = 0;
        for (;;) {
            check_deadline();
            ERR_clear_error();
            const int result =
                SSL_read_ex(tls, reply.data(), reply.size(), &received);
            if (result == 1) {
                break;
            }
            retry_tls(result);
        }
        if (std::string_view(reply.data(), received) != message) {
            throw std::runtime_error("application datagram mismatch");
        }
        // Send our notification, without claiming a reliably delivered alert.
        for (;;) {
            check_deadline();
            ERR_clear_error();
            const int result = SSL_shutdown(tls);
            if (result >= 0) {
                break;
            }
            retry_tls(result);
        }
        std::cout << "verified=" << hostname
                  << " protocol=" << SSL_get_version(tls) << '\n'
                  << "echo_bytes=" << received << " close_notify=sent\n"
                  << message;
    } catch (const std::exception& error) {
        std::cerr << error.what() << '\n';
        status = 1;
    }
    SSL_free(tls);
    SSL_CTX_free(context);
    if (fd >= 0) {
        ::close(fd);
    }
    return status;
}

Read the bounded C++ server

This server exposes the bind, first-peer selection, SSL_accept(), one datagram read/write and explicit cleanup. The first-peer policy is intentionally restricted to a private loopback exercise; it is not public DTLS listener design.

Full standalone server: app/dtls-echo/server.cpp
#include <algorithm>
#include <arpa/inet.h>
#include <array>
#include <cerrno>
#include <charconv>
#include <chrono>
#include <cstdint>
#include <iostream>
#include <openssl/err.h>
#include <openssl/ssl.h>
#include <poll.h>
#include <stdexcept>
#include <string>
#include <string_view>
#include <sys/socket.h>
#include <system_error>
#include <unistd.h>

int main(int argc, char** argv) {
    int fd = -1;
    SSL_CTX* context = nullptr;
    SSL* tls = nullptr;
    int status = 0;
    try {
        if (argc < 4 || argc > 5) {
            throw std::invalid_argument("usage: dtls_echo_server port "
                                        "server.pem server.key [session_ms]");
        }
        unsigned int port = 0;
        const std::string_view text = argv[1];
        const auto [end, error] =
            std::from_chars(text.data(), text.data() + text.size(), port);
        if (error != std::errc{} || end != text.data() + text.size() ||
            port > 65535) {
            throw std::invalid_argument("port must be 0..65535");
        }
        int session_ms = 5000;
        if (argc == 5) {
            const std::string_view budget = argv[4];
            const auto [last, invalid] = std::from_chars(
                budget.data(), budget.data() + budget.size(), session_ms);
            if (invalid != std::errc{} ||
                last != budget.data() + budget.size() || session_ms < 1 ||
                session_ms > 10000) {
                throw std::invalid_argument("session_ms must be 1..10000");
            }
        }
        context = SSL_CTX_new(DTLS_server_method());
        if (!context ||
            SSL_CTX_set_min_proto_version(context, DTLS1_2_VERSION) != 1 ||
            SSL_CTX_set_max_proto_version(context, DTLS1_2_VERSION) != 1 ||
            SSL_CTX_use_certificate_chain_file(context, argv[2]) != 1 ||
            SSL_CTX_use_PrivateKey_file(context, argv[3], SSL_FILETYPE_PEM) !=
                1 ||
            SSL_CTX_check_private_key(context) != 1) {
            throw std::runtime_error("cannot load DTLS server certificate/key");
        }
        tls = SSL_new(context);
        if (!tls) {
            throw std::runtime_error("SSL_new failed");
        }
        fd = ::socket(AF_INET, SOCK_DGRAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
        if (fd < 0) {
            throw std::system_error(errno, std::generic_category(), "socket");
        }
        const auto deadline = std::chrono::steady_clock::now() +
                              std::chrono::milliseconds(session_ms);
        const auto check_deadline = [&] {
            if (std::chrono::steady_clock::now() >= deadline) {
                throw std::runtime_error("session deadline expired");
            }
        };
        sockaddr_in local{};
        local.sin_family = AF_INET;
        local.sin_port = htons(static_cast<std::uint16_t>(port));
        local.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
        if (::bind(fd, reinterpret_cast<const sockaddr*>(&local),
                   sizeof(local)) < 0) {
            throw std::system_error(errno, std::generic_category(), "bind");
        }
        socklen_t local_size = sizeof(local);
        if (::getsockname(fd, reinterpret_cast<sockaddr*>(&local),
                          &local_size) < 0) {
            throw std::system_error(errno, std::generic_category(),
                                    "getsockname");
        }
        std::cout << "LISTEN " << ntohs(local.sin_port) << std::endl;
        // One loopback peer only. This is NOT a public cookie-protected
        // listener.
        sockaddr_in peer{};
        for (;;) {
            check_deadline();
            char first = 0;
            socklen_t peer_size = sizeof(peer);
            const auto count =
                ::recvfrom(fd, &first, 1, MSG_PEEK,
                           reinterpret_cast<sockaddr*>(&peer), &peer_size);
            if (count >= 0) {
                break; // Leave the entire first datagram queued for OpenSSL.
            }
            if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) {
                throw std::system_error(errno, std::generic_category(),
                                        "recvfrom");
            }
            const auto remaining =
                std::chrono::ceil<std::chrono::milliseconds>(
                    deadline - std::chrono::steady_clock::now())
                    .count();
            if (remaining <= 0) {
                throw std::runtime_error("session deadline expired");
            }
            pollfd ready{fd, POLLIN, 0};
            if (::poll(&ready, 1, static_cast<int>(remaining)) < 0 &&
                errno != EINTR) {
                throw std::system_error(errno, std::generic_category(),
                                        "poll first peer");
            }
        }
        if (::connect(fd, reinterpret_cast<const sockaddr*>(&peer),
                      sizeof(peer)) < 0) {
            throw std::system_error(errno, std::generic_category(),
                                    "UDP connect");
        }
        BIO* datagram = BIO_new_dgram(fd, BIO_NOCLOSE);
        if (!datagram) {
            throw std::runtime_error("cannot create datagram BIO");
        }
        if (BIO_ctrl(datagram, BIO_CTRL_DGRAM_SET_CONNECTED, 0, &peer) != 1) {
            BIO_free(datagram);
            throw std::runtime_error("cannot configure connected datagram BIO");
        }
        SSL_set_bio(tls, datagram, datagram);
        SSL_set_options(tls, SSL_OP_NO_QUERY_MTU);
        if (DTLS_set_link_mtu(tls, 1200) <= 0) {
            throw std::runtime_error("cannot set DTLS link MTU");
        }
        const auto wait_for = [&](short events) {
            for (;;) {
                check_deadline();
                const auto remaining =
                    std::chrono::ceil<std::chrono::milliseconds>(
                        deadline - std::chrono::steady_clock::now())
                        .count();
                if (remaining <= 0) {
                    throw std::runtime_error("session deadline expired");
                }
                auto wait_ms = remaining;
                timeval timer{};
                if (DTLSv1_get_timeout(tls, &timer) == 1) {
                    const auto due =
                        std::chrono::ceil<std::chrono::milliseconds>(
                            std::chrono::seconds(timer.tv_sec) +
                            std::chrono::microseconds(timer.tv_usec))
                            .count();
                    wait_ms = std::min(wait_ms, due);
                }
                pollfd ready{fd, events, 0};
                const int result = ::poll(&ready, 1, static_cast<int>(wait_ms));
                if (result < 0 && errno == EINTR) {
                    continue;
                }
                if (result < 0) {
                    throw std::system_error(errno, std::generic_category(),
                                            "poll");
                }
                check_deadline();
                if (result > 0) {
                    return;
                }
                // A protocol retransmission timer never renews our deadline.
                ERR_clear_error();
                if (DTLSv1_handle_timeout(tls) < 0) {
                    throw std::runtime_error("DTLS timer handling failed");
                }
            }
        };
        const auto retry_tls = [&](int result) {
            // No intervening OpenSSL call: result belongs to the last TLS call.
            const int error_code = SSL_get_error(tls, result);
            if (error_code == SSL_ERROR_WANT_READ) {
                wait_for(POLLIN);
            } else if (error_code == SSL_ERROR_WANT_WRITE) {
                wait_for(POLLOUT);
            } else {
                char detail[256]{};
                ERR_error_string_n(ERR_get_error(), detail, sizeof(detail));
                throw std::runtime_error("TLS operation failed (SSL error " +
                                         std::to_string(error_code) +
                                         "): " + detail);
            }
        };
        for (;;) {
            check_deadline();
            ERR_clear_error();
            const int result = SSL_accept(tls);
            if (result == 1) {
                break;
            }
            retry_tls(result);
        }
        std::array<char, 257> message{};
        std::size_t received = 0;
        for (;;) {
            check_deadline();
            ERR_clear_error();
            const int result =
                SSL_read_ex(tls, message.data(), message.size(), &received);
            if (result == 1) {
                break;
            }
            retry_tls(result);
        }
        if (received == 0 || received > 256 ||
            received > DTLS_get_data_mtu(tls)) {
            throw std::runtime_error(
                "application datagram outside 1..256-byte policy");
        }
        for (;;) {
            check_deadline();
            std::size_t written = 0;
            ERR_clear_error();
            const int result =
                SSL_write_ex(tls, message.data(), received, &written);
            if (result == 1) {
                if (written != received) {
                    throw std::runtime_error("incomplete application datagram");
                }
                break;
            }
            retry_tls(result);
        }
        for (;;) {
            check_deadline();
            ERR_clear_error();
            const int result = SSL_shutdown(tls);
            if (result >= 0) {
                break;
            }
            retry_tls(result);
        }
        std::cout << "echo_bytes=" << received
                  << " protocol=" << SSL_get_version(tls)
                  << " close_notify=sent\n";
    } catch (const std::exception& error) {
        std::cerr << error.what() << '\n';
        status = 1;
    }
    SSL_free(tls);
    SSL_CTX_free(context);
    if (fd >= 0) {
        ::close(fd);
    }
    return status;
}

Keep payload, MTU and termination claims bounded

The server accepts one nonempty application payload of 1–256 bytes, including binary data. Its 257-byte read buffer detects an out-of-policy payload without trusting an advertised allocation size. The client requires the exact 17-byte message in one read and never joins short records into a stream-style reply.

Both programs set a fixed 1200-byte link-MTU policy for this lab, disable automatic MTU queries, and check DTLS_get_data_mtu() before their application write. That function accounts for record/security overhead; 1200 is not the permitted plaintext length. This is not general path-MTU discovery, large-message fragmentation or a guarantee for arbitrary networks. See data MTU.

After a successful echo, each program sends its local close_notify and closes without requiring the peer's notification. The sent summary is not an acknowledged delivery claim. Unlike the strict WSS exercise, this lesson does not wait for two-way TLS shutdown; UDP has no stream EOF to wait for. Neither alert loss recovery nor reliable application shutdown is implemented.

Compile independently and test deliberate loss

From network/, build each file directly:

1
2
3
4
5
6
7
mkdir -p build/direct-dtls
c++ -std=c++20 -Wall -Wextra -Wpedantic -Werror \
    app/dtls-echo/client.cpp -o build/direct-dtls/dtls_echo_client \
    $(pkg-config --cflags --libs openssl)
c++ -std=c++20 -Wall -Wextra -Wpedantic -Werror \
    app/dtls-echo/server.cpp -o build/direct-dtls/dtls_echo_server \
    $(pkg-config --cflags --libs openssl)

Run all DTLS checks without a manual server:

ctest --preset native-debug -R network.dtls --output-on-failure

Focus on the loss cases:

ctest --preset native-debug -R 'network.dtls.drop_' --output-on-failure

The Python relay drops a first ClientHello or a first server handshake datagram and requires a recovered exchange. When dropping application request/reply data, it requires a deadline failure and exactly one outgoing client application record. This tests the difference directly rather than inferring it from successful localhost traffic.

Independent OpenSSL CLI peers also exercise each C++ endpoint, including binary and 256-byte server echoes, oversized requests, short/long/wrong replies and certificate/name checks. Wrong CA or hostname cases must emit zero client application records. Idle/noisy UDP, invalid arguments, missing/mismatched key files and port conflicts remain bounded failures.

These are controlled loopback datagram drops, not measured real-network loss rates, latency benchmarks, replay-window tests or a cryptographic audit.

Verification scope

Verified on 23 October 2026 on a Raspberry Pi 4B running 64-bit Raspberry Pi OS (Debian 13), plus a Linux x86-64 host. All 589 course checks passed in Debug and Release on both machines; 26 exercise DTLS. The same 26 cases passed against directly compiled client/server pairs on both. Tests include OpenSSL CLI interoperability and controlled UDP drops. The Pi reused isolated official OpenSSL headers and runtime libraries without system package or trust changes.

Troubleshooting

  • Server already exited: its whole-session deadline includes waiting for the first peer. Restart it and run the client promptly, or use the documented 10000 ms server budget.
  • Certificate verification fails: use the generated CA and expected localhost name; check certificate dates. Do not disable verification.
  • UDP connect succeeded but DTLS fails: selecting a peer does not establish or authenticate a DTLS session. Check the server and UDP port separately.
  • Handshake stalls after one lost packet: check that your nonblocking loop services the DTLS timer, not only socket readiness.
  • Application datagram mismatch: this client does not concatenate multiple short replies. Inspect message boundaries and exact bytes.
  • Server reports an echo but the client times out: a local write is not delivery confirmation. The deliberate drop-reply test demonstrates this case.
  • Another local process claimed the server: stop the finite exercise and restart; the first-peer loopback policy is not a cookie-based listener.

Check your understanding

  1. Does successful UDP connect() verify a server?
  2. Does DTLS turn lost application messages into automatically retried requests?
  3. Why must the wait loop consider both readiness and a protocol timer?
  4. Does DTLSv1_get_timeout() force DTLS 1.0?
  5. Does close_notify=sent prove the peer received it?
  6. Why must this first-peer server stay on loopback?
Answers

UDP connect only fixes the transport peer. Application delivery/retry policy still belongs to the application. Protocol timers can become due without incoming packets. The timer API name does not select a version; this code explicitly selects DTLS 1.2. A local alert send is not an acknowledged receipt. The simplified server omits stateless cookie-based peer validation and multi-client resource management.

Continue the course

Previous: WebSocket over Verified TLS · Course overview

Next: Message IDs and Explicit Retries — correlate results and retry the same operation without confusing protection with delivery guarantees.

Donate