diff --git a/.gitmodules b/.gitmodules index 50ca346a5..9c39b5eb2 100644 --- a/.gitmodules +++ b/.gitmodules @@ -26,3 +26,6 @@ path = lib/multipart-parser-c url = https://github.com/iafonov/multipart-parser-c.git branch = main +[submodule "lib/usrsctp"] + path = lib/usrsctp + url = https://github.com/sctplab/usrsctp diff --git a/Makefile b/Makefile index 84e3684af..8015292c3 100644 --- a/Makefile +++ b/Makefile @@ -101,6 +101,10 @@ LSQUIC_DIR = lib/lsquic LSQUIC_BUILD_DIR = $(LSQUIC_DIR)/build LSQUIC_LIB = $(LSQUIC_BUILD_DIR)/src/liblsquic/liblsquic.a +USRSCTP_DIR = lib/usrsctp +USRSCTP_BUILD_DIR = $(USRSCTP_DIR)/build +USRSCTP_LIB = $(USRSCTP_BUILD_DIR)/usrsctplib/libusrsctp.a + CURL_LIBS := $(shell pkg-config --libs libcurl 2>/dev/null) NGHTTP2_LIBS := $(shell pkg-config --libs libnghttp2 2>/dev/null) BROTLI_LIBS := $(shell pkg-config --libs libbrotlienc libbrotlicommon libbrotlidec 2>/dev/null) @@ -140,6 +144,15 @@ ifeq ($(CWIST_WEBTRANSPORT),1) CFLAGS += -DCWIST_WEBTRANSPORT endif +# WebRTC DataChannel (src/net/webrtc/). DataChannel-only: SDP offer/answer, +# ICE-lite, DTLS via the vendored BoringSSL, and SCTP DataChannels (RFC 8831) +# through the lib/usrsctp submodule running in AF_CONN raw mode tunneled over +# DTLS. Set CWIST_WEBRTC=0 to leave it out. +CWIST_WEBRTC ?= 1 +ifeq ($(CWIST_WEBRTC),1) + CFLAGS += -DCWIST_WEBRTC -I$(USRSCTP_DIR)/usrsctplib +endif + # Detect OS IO_SRC = src/sys/io/io_select.c # Default fallback @@ -699,6 +712,16 @@ EXTERNAL_LIBS = $(URIPARSER_LIB) \ $(BORINGSSL_SSL_LIB) \ $(BORINGSSL_CRYPTO_LIB) +ifeq ($(CWIST_WEBRTC),1) + SRCS += src/net/webrtc/webrtc.c \ + src/net/webrtc/ice.c \ + src/net/webrtc/sdp.c \ + src/net/webrtc/dtls.c \ + src/net/webrtc/sctp.c + EXTERNAL_LIBS += $(USRSCTP_LIB) + LIBS += $(USRSCTP_LIB) +endif + # --- Build Targets --- all: $(LIBTTAK_LIB) $(CJSON_LIB) $(URIPARSER_LIB) $(SQLITE_DIR)/sqlite3.c $(LSQUIC_LIB) $(LIB_NAME) @@ -716,6 +739,34 @@ $(SQLITE_DIR)/sqlite3.c: # Ensure lsquic submodule is checked out before compiling objects that need its headers $(OBJS): | lib/lsquic/include/lsquic.h +ifeq ($(CWIST_WEBRTC),1) +$(OBJS): | $(USRSCTP_DIR)/usrsctplib/usrsctp.h + +$(USRSCTP_DIR)/usrsctplib/usrsctp.h: + @if [ ! -f "$@" ]; then \ + echo "Initializing usrsctp submodule..."; \ + git submodule update --init --recursive $(USRSCTP_DIR); \ + fi + +USRSCTP_REV := $(if $(wildcard $(USRSCTP_DIR)/.git),$(shell git -C $(USRSCTP_DIR) rev-parse HEAD 2>/dev/null)) +USRSCTP_STAMP = $(USRSCTP_BUILD_DIR)/.usrsctp_built_$(or $(USRSCTP_REV),unversioned) + +$(USRSCTP_LIB): $(USRSCTP_STAMP) + +$(USRSCTP_STAMP): + @echo "Building usrsctp..." + @mkdir -p $(USRSCTP_BUILD_DIR) + cmake -S $(USRSCTP_DIR) -B $(USRSCTP_BUILD_DIR) \ + -DCMAKE_C_COMPILER=$(CC) \ + -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_SHARED_LIBS=OFF \ + -Dsctp_build_programs=OFF \ + -Dsctp_build_fuzzer=OFF + cmake --build $(USRSCTP_BUILD_DIR) --target usrsctp + @rm -f $(USRSCTP_BUILD_DIR)/.usrsctp_built_* + @touch $@ +endif + lib/lsquic/include/lsquic.h: @if [ ! -f "$@" ]; then \ echo "Initializing lsquic submodule..."; \ @@ -802,6 +853,7 @@ TEST_TARGETS = test_worker_affinity \ test_app_resource_limits \ test_classic_pool_scaling \ test_reactor_wake \ + test_reactor_timer \ test_reactor_drain_chunk \ test_latency_probe \ test_sstring \ @@ -844,6 +896,7 @@ TEST_TARGETS = test_worker_affinity \ test_cors \ test_websocket \ test_websocket_async \ + test_webrtc \ test_jwt \ test_migrate \ test_json_heal \ @@ -927,6 +980,7 @@ bench_security_pool: $(LIB_NAME) tests/bench_security_pool.c $(CC) $(CFLAGS) -o bench_security_pool tests/bench_security_pool.c $(LIB_NAME) $(LIBS) ./bench_security_pool +# WebRTC DataChannel end-to-end test (see the test_webrtc rule below). test: $(TEST_TARGETS) src/sys/app/app.o: src/sys/app/worker_affinity.h @@ -952,6 +1006,14 @@ test_reactor_wake: tests/test_reactor_wake.c src/sys/io/reactor.c $(CC) $(CFLAGS) -o $@ tests/test_reactor_wake.c -pthread ./$@ +# Reactor one-shot timers, on the default backend and on forced epoll. +test_reactor_timer: tests/test_reactor_timer.c src/sys/io/reactor.c + $(CC) $(CFLAGS) -o $@ tests/test_reactor_timer.c -pthread + ./$@ +ifeq ($(UNAME_S),Linux) + CWIST_REACTOR_BACKEND=epoll ./$@ +endif + # Cooperative-queuing correctness test (issue #25): CWIST_REACTOR_DRAIN_CHUNK # interleaves foreign-thread post draining into a big CQE batch instead of # only at the batch's end. See tests/bench_cooperative_queuing.c for the @@ -1463,6 +1525,36 @@ test_multipart: $(LIB_NAME) tests/test_multipart.c $(CC) $(CFLAGS) -o test_multipart tests/test_multipart.c $(LIB_NAME) $(LIBS) ./test_multipart +ifeq ($(CWIST_WEBRTC),1) +test_webrtc: $(LIB_NAME) $(USRSCTP_LIB) tests/test_webrtc.c + $(CC) $(CFLAGS) -o test_webrtc tests/test_webrtc.c $(LIB_NAME) $(LIBS) + ./test_webrtc + +# Loopback DataChannel benchmark: idle cost, throughput, RTT. Not part of +# `make test`; run ./bench_webrtc [small_count] [large_count] after building. +bench_webrtc: $(LIB_NAME) $(USRSCTP_LIB) tests/bench_webrtc.c + $(CC) $(CFLAGS) -o bench_webrtc tests/bench_webrtc.c $(LIB_NAME) $(LIBS) + +# Real-browser interop: starts example/webrtc and drives headless Chromium +# against it (tests/browser/webrtc_chromium.mjs). Needs chromium and Node >= 22; +# not part of `make test`. Uses port 8080. +test_webrtc_browser: $(LIB_NAME) $(USRSCTP_LIB) + $(MAKE) -C example/webrtc + @(cd example/webrtc && exec env CWIST_WORKERS=1 ./webrtc-server > /dev/null 2>&1) & pid=$$!; \ + sleep 1; \ + node tests/browser/webrtc_chromium.mjs http://localhost:8080/; rc=$$?; \ + kill $$pid 2>/dev/null; wait $$pid 2>/dev/null; \ + exit $$rc + +.PHONY: test_webrtc bench_webrtc test_webrtc_browser +else +# CWIST_WEBRTC=0: the module and its test are compiled out. +test_webrtc bench_webrtc test_webrtc_browser: + @echo "CWIST_WEBRTC=0: skipping $@" + +.PHONY: test_webrtc bench_webrtc test_webrtc_browser +endif + test_waf: $(LIB_NAME) tests/test_waf.c $(CC) $(CFLAGS) -o test_waf tests/test_waf.c $(LIB_NAME) $(LIBS) ./test_waf diff --git a/example/webrtc/Makefile b/example/webrtc/Makefile new file mode 100644 index 000000000..2bf0b61ea --- /dev/null +++ b/example/webrtc/Makefile @@ -0,0 +1,25 @@ +CC ?= gcc +CFLAGS = -Wall -Wextra -g -I../../include + +ROOT = ../.. +LIBS = $(ROOT)/libcwist.a \ + $(ROOT)/lib/cnats/build/lib/libnats_static.a \ + $(ROOT)/lib/libttak/lib/libttak.a \ + $(ROOT)/lib/cjson/libcjson.a \ + $(ROOT)/lib/uriparser/build/liburiparser.a \ + $(ROOT)/lib/lsquic/build/src/liblsquic/liblsquic.a \ + $(ROOT)/lib/usrsctp/build/usrsctplib/libusrsctp.a \ + $(ROOT)/lib/boringssl/build/libssl.a \ + $(ROOT)/lib/boringssl/build/libcrypto.a \ + -pthread -ldl -lm -lstdc++ -lz -lcurl -lnghttp2 + +SRCS = main.c +TARGET = webrtc-server + +all: $(TARGET) + +$(TARGET): $(SRCS) $(ROOT)/libcwist.a + $(CC) $(CFLAGS) -o $(TARGET) $(SRCS) $(LIBS) + +clean: + rm -f $(TARGET) diff --git a/example/webrtc/README.md b/example/webrtc/README.md new file mode 100644 index 000000000..40b566310 --- /dev/null +++ b/example/webrtc/README.md @@ -0,0 +1,45 @@ +# CWIST WebRTC DataChannel example + +DataChannel-only WebRTC echo server: the browser sends an SDP offer over +HTTP POST, the CWIST server (acting as an ICE-lite endpoint) answers, and +both ends run ICE + DTLS + SCTP (RFC 8831) so messages on a DataChannel are +echoed back. + +## Build + +```sh +make -C ../.. libcwist.a # builds lib/usrsctp too (CWIST_WEBRTC=1 default) +make +``` + +## Run + +```sh +./webrtc-server +# signaling: http://localhost:8080/ +# [webrtc] ctx ready in pid on UDP port (first offer) +``` + +Open http://localhost:8080/ in a browser, click **Connect**, type a message +and press **Send**. Every DataChannel message is echoed back and printed on +the server console. + +## Layout + +- `main.c` — cwist app serving `index.html` (GET /) and SDP answers + (POST /offer, `application/sdp`), plus the echo logic on the cwist webrtc + ctx. `cwist_app_listen` forks one HTTP worker per core and a ctx belongs to + the process that created it, so each worker creates its own ctx (own UDP + port, own reactor thread) on its first offer; the answer carries that + port. Message, channel and close handlers run on the ctx's reactor thread. +- `index.html` — minimal RTCPeerConnection client using a DataChannel. + +## Notes / limitations (MVP) + +- The server is ICE-lite: it answers STUN binding requests and treats a + valid `USE-CANDIDATE` request as the nominated pair. The browser must be + the controlling agent (the default). +- The browser connects to the host candidate advertised in the answer + (`a=candidate:` with the server's best-guess local IPv4). Set the env + var or edit the code behind a NAT; there is no STUN/TURN server support. +- Ordered delivery only; the answer pins `max-message-size:262144`. diff --git a/example/webrtc/index.html b/example/webrtc/index.html new file mode 100644 index 000000000..3018d84cd --- /dev/null +++ b/example/webrtc/index.html @@ -0,0 +1,48 @@ + + + + + CWIST WebRTC DataChannel echo + + +

CWIST WebRTC DataChannel echo

+ + + +

+  
+
+
diff --git a/example/webrtc/main.c b/example/webrtc/main.c
new file mode 100644
index 000000000..9446aafb8
--- /dev/null
+++ b/example/webrtc/main.c
@@ -0,0 +1,109 @@
+/**
+ * @file main.c
+ * @brief WebRTC DataChannel echo server: SDP signaling over HTTP POST,
+ * ICE-lite + DTLS + SCTP DataChannel echo over UDP.
+ *
+ * Open http://localhost:8080/ in a browser, click Connect, and type
+ * messages: the server echoes every DataChannel message back.
+ */
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+
+/* One webrtc ctx per process.  cwist_app_listen() forks HTTP workers, and a
+ * ctx (with its reactor thread) belongs to the process that created it, so
+ * each worker makes its own on its first offer.  Every answer names the UDP
+ * port of the ctx that wrote it, so browsers reach the right process. */
+static cwist_webrtc_ctx *g_webrtc;
+static pthread_once_t g_webrtc_once = PTHREAD_ONCE_INIT;
+
+static void on_message(cwist_webrtc_conn *conn, uint16_t channel, const uint8_t *data, size_t len,
+                       cwist_webrtc_data_type type, void *user) {
+    (void)user;
+    (void)channel;
+    if (type == CWIST_WEBRTC_DATA_STRING)
+        printf("[webrtc] text (%zu bytes): %.*s\n", len, (int)len, (const char *)data);
+    else
+        printf("[webrtc] binary (%zu bytes)\n", len);
+    cwist_webrtc_conn_send(conn, channel, data, len, type); /* echo with the same type */
+}
+
+static void on_channel(cwist_webrtc_conn *conn, uint16_t channel, const char *label,
+                       void *user) {
+    (void)conn;
+    (void)user;
+    printf("[webrtc] channel %u opened by peer (label=%s)\n", channel, label);
+}
+
+static void on_close(cwist_webrtc_conn *conn, void *user) {
+    (void)conn;
+    (void)user;
+    printf("[webrtc] connection closed\n");
+}
+
+static void index_handler(cwist_http_request *req, cwist_http_response *res) {
+    (void)req;
+    FILE *f = fopen("index.html", "rb");
+    if (!f) {
+        /* Fall back to the source directory layout. */
+        f = fopen("example/webrtc/index.html", "rb");
+    }
+    if (!f) {
+        cwist_http_header_add(&res->headers, "Content-Type", "text/plain");
+        cwist_sstring_assign(res->body, "index.html not found");
+        return;
+    }
+    char html[16384];
+    size_t n = fread(html, 1, sizeof(html) - 1, f);
+    fclose(f);
+    html[n] = '\0';
+    cwist_http_header_add(&res->headers, "Content-Type", "text/html");
+    cwist_sstring_assign(res->body, html);
+}
+
+static void webrtc_init(void) {
+    g_webrtc = cwist_webrtc_ctx_new(0);
+    if (!g_webrtc) {
+        fprintf(stderr, "failed to create webrtc ctx\n");
+        return;
+    }
+    cwist_webrtc_ctx_set_message_handler(g_webrtc, &on_message, NULL);
+    cwist_webrtc_ctx_set_channel_handler(g_webrtc, &on_channel, NULL);
+    cwist_webrtc_ctx_set_close_handler(g_webrtc, &on_close, NULL);
+    printf("[webrtc] ctx ready in pid %d on UDP port %u\n", (int)getpid(),
+           cwist_webrtc_ctx_port(g_webrtc));
+}
+
+static void offer_handler(cwist_http_request *req, cwist_http_response *res) {
+    pthread_once(&g_webrtc_once, webrtc_init);
+    /* Handlers run on several HTTP threads at once: no static buffer. */
+    char answer[4096];
+    if (!g_webrtc ||
+        cwist_webrtc_handle_offer(g_webrtc, req->body->data, answer, sizeof(answer)) < 0) {
+        cwist_http_header_add(&res->headers, "Content-Type", "text/plain");
+        cwist_sstring_assign(res->body, "invalid SDP offer");
+        res->status_code = CWIST_HTTP_BAD_REQUEST;
+        return;
+    }
+    printf("[webrtc] answered offer; UDP port %u, fingerprint %s\n",
+           cwist_webrtc_ctx_port(g_webrtc), cwist_webrtc_ctx_fingerprint(g_webrtc));
+    cwist_http_header_add(&res->headers, "Content-Type", "application/sdp");
+    cwist_sstring_assign(res->body, answer);
+}
+
+int main(void) {
+    cwist_app *app = cwist_app_create();
+    cwist_app_get(app, "/", index_handler);
+    cwist_app_post(app, "/offer", offer_handler);
+    printf("signaling: http://localhost:8080/\n");
+    cwist_app_listen(app, 8080);
+    /* Every worker returns here; each frees the ctx it created, if any. */
+    cwist_app_destroy(app);
+    cwist_webrtc_ctx_free(g_webrtc);
+    return 0;
+}
diff --git a/include/cwist/net/webrtc.h b/include/cwist/net/webrtc.h
new file mode 100644
index 000000000..f2eeae4e8
--- /dev/null
+++ b/include/cwist/net/webrtc.h
@@ -0,0 +1,176 @@
+/** @file webrtc.h
+ * @brief WebRTC DataChannel server (SDP + ICE-lite + DTLS + SCTP) interface.
+ *
+ * DataChannel-only WebRTC per RFC 8831/8832: the server acts as an ICE-lite
+ * endpoint, completes a DTLS handshake (BoringSSL, plain DTLS, no SRTP) over
+ * the nominated UDP path, and runs SCTP with DataChannel establishment over
+ * the DTLS connection via usrsctp.
+ *
+ * Threading: a ctx runs on one cwist reactor.  cwist_webrtc_ctx_new() creates
+ * a private reactor and thread; cwist_webrtc_ctx_new_on() attaches to a
+ * reactor the caller already runs.  Every callback is invoked on that
+ * reactor's run thread.  cwist_webrtc_conn_send(), cwist_webrtc_conn_close(),
+ * cwist_webrtc_handle_offer() and cwist_webrtc_ctx_free() may be called from
+ * any thread.
+ *
+ * Processes: a ctx belongs to the process that created it.  A forked child
+ * (e.g. a cwist_app_listen() worker) must create its own ctx; on an inherited
+ * one, cwist_webrtc_handle_offer() returns -1 and cwist_webrtc_ctx_free() does
+ * nothing.
+ *
+ * Connection lifetime: a cwist_webrtc_conn pointer passed to a callback is
+ * valid for the duration of that callback.  To use it later or from another
+ * thread, take a reference with cwist_webrtc_conn_retain() and drop it with
+ * cwist_webrtc_conn_release().  After the close handler runs, sends on a
+ * retained conn fail with -1.
+ */
+#ifndef __CWIST_NET_WEBRTC_H__
+#define __CWIST_NET_WEBRTC_H__
+
+#include 
+#include 
+
+#include 
+
+typedef struct cwist_webrtc_ctx cwist_webrtc_ctx;
+typedef struct cwist_webrtc_conn cwist_webrtc_conn;
+
+/** DataChannel message type (RFC 8831 section 8 payload protocol ids). */
+typedef enum {
+    CWIST_WEBRTC_DATA_BINARY = 0,  /**< PPID 53 (57 when empty); ArrayBuffer/Blob in a browser. */
+    CWIST_WEBRTC_DATA_STRING = 1   /**< PPID 51 (56 when empty); UTF-8 string in a browser. */
+} cwist_webrtc_data_type;
+
+/**
+ * @brief DataChannel message callback, invoked on the ctx's reactor thread.
+ * @param channel_id SCTP stream id (DataChannel id).
+ * @param type Whether the peer sent a string or binary message; echo it back
+ *             with the same type to preserve it.
+ */
+typedef void (*cwist_webrtc_message_cb)(cwist_webrtc_conn *conn, uint16_t channel_id,
+                                        const uint8_t *data, size_t len,
+                                        cwist_webrtc_data_type type, void *user);
+
+/**
+ * @brief Optional DataChannel open notification (remote peer opened a channel).
+ */
+typedef void (*cwist_webrtc_channel_cb)(cwist_webrtc_conn *conn, uint16_t channel_id,
+                                        const char *label, void *user);
+
+/**
+ * @brief Optional connection-closed notification.  Runs once per connection
+ *        whose SCTP association came up; after it returns, the conn pointer is
+ *        only valid through references the application still holds.
+ */
+typedef void (*cwist_webrtc_close_cb)(cwist_webrtc_conn *conn, void *user);
+
+/**
+ * @brief Create a WebRTC context bound to a UDP port (0 picks an ephemeral
+ *        port), running on its own reactor thread.
+ * @return NULL on failure.
+ */
+cwist_webrtc_ctx *cwist_webrtc_ctx_new(uint16_t port);
+
+/**
+ * @brief Create a WebRTC context on a reactor the caller runs.
+ *
+ * The ctx registers its UDP socket and timers with @p reactor and does all of
+ * its work inside that reactor's callbacks; no thread is created.  The
+ * reactor must keep running until cwist_webrtc_ctx_free() returns.
+ * @return NULL on failure.
+ */
+cwist_webrtc_ctx *cwist_webrtc_ctx_new_on(cwist_reactor_t *reactor, uint16_t port);
+
+/**
+ * @brief UDP port the context is bound to.
+ */
+uint16_t cwist_webrtc_ctx_port(const cwist_webrtc_ctx *ctx);
+
+/**
+ * @brief Set the callback for incoming DataChannel messages.  Set handlers
+ *        before answering the first offer.
+ */
+void cwist_webrtc_ctx_set_message_handler(cwist_webrtc_ctx *ctx, cwist_webrtc_message_cb cb,
+                                          void *user);
+
+/**
+ * @brief Set the callback for incoming DataChannel open (DCEP) events.
+ */
+void cwist_webrtc_ctx_set_channel_handler(cwist_webrtc_ctx *ctx, cwist_webrtc_channel_cb cb,
+                                          void *user);
+
+/**
+ * @brief Set the callback for closed connections.
+ */
+void cwist_webrtc_ctx_set_close_handler(cwist_webrtc_ctx *ctx, cwist_webrtc_close_cb cb,
+                                        void *user);
+
+/**
+ * @brief Answer a browser SDP offer (ICE-lite, setup:passive).
+ * @param offer The remote SDP offer.
+ * @param answer_out Buffer receiving the generated SDP answer.
+ * @param out_len Size of answer_out.
+ * @return 0 on success, -1 on error.
+ *
+ * Safe from any thread.  The connection completes asynchronously (ICE
+ * nomination, DTLS, SCTP) on the ctx's reactor thread.  An answered offer
+ * whose peer never shows up is dropped after 30 seconds.
+ */
+int cwist_webrtc_handle_offer(cwist_webrtc_ctx *ctx, const char *offer, char *answer_out,
+                              size_t out_len);
+
+/**
+ * @brief Number of connections whose SCTP association is up.
+ */
+int cwist_webrtc_ctx_connection_count(const cwist_webrtc_ctx *ctx);
+
+/**
+ * @brief Send a message on a DataChannel.
+ *
+ * Safe from any thread.  The message is queued if SCTP is not ready or its
+ * send buffer is full; queued bytes are reported by
+ * cwist_webrtc_conn_buffered_amount().
+ * @return 0 when sent or queued; -1 if the conn is closed, @p len exceeds
+ *         262144 bytes (the advertised max-message-size), or the queue already
+ *         holds 4 MiB.
+ */
+int cwist_webrtc_conn_send(cwist_webrtc_conn *conn, uint16_t channel_id, const uint8_t *data,
+                           size_t len, cwist_webrtc_data_type type);
+
+/**
+ * @brief Bytes accepted by cwist_webrtc_conn_send() and not yet handed to
+ *        SCTP (like RTCDataChannel.bufferedAmount, summed over channels).
+ */
+size_t cwist_webrtc_conn_buffered_amount(const cwist_webrtc_conn *conn);
+
+/**
+ * @brief Local certificate fingerprint (SHA-256, colon-separated hex).
+ */
+const char *cwist_webrtc_ctx_fingerprint(const cwist_webrtc_ctx *ctx);
+
+/**
+ * @brief Take a reference on @p conn so it stays valid outside a callback.
+ */
+void cwist_webrtc_conn_retain(cwist_webrtc_conn *conn);
+
+/**
+ * @brief Drop a reference taken with cwist_webrtc_conn_retain().
+ */
+void cwist_webrtc_conn_release(cwist_webrtc_conn *conn);
+
+/**
+ * @brief Close a connection (SCTP abort, DTLS teardown).  Safe from any
+ *        thread; the close handler runs on the reactor thread.
+ */
+void cwist_webrtc_conn_close(cwist_webrtc_conn *conn);
+
+/**
+ * @brief Close every connection and free the context.
+ *
+ * Safe from any thread.  For a ctx made with cwist_webrtc_ctx_new_on() and
+ * called off the reactor thread, this waits for the reactor to finish the
+ * teardown, so the reactor must still be running.
+ */
+void cwist_webrtc_ctx_free(cwist_webrtc_ctx *ctx);
+
+#endif
diff --git a/include/cwist/sys/io/reactor.h b/include/cwist/sys/io/reactor.h
index c30e71841..411e6fed2 100644
--- a/include/cwist/sys/io/reactor.h
+++ b/include/cwist/sys/io/reactor.h
@@ -51,6 +51,28 @@ typedef struct cwist_reactor_post {
 
 bool cwist_reactor_post(cwist_reactor_t *reactor, cwist_reactor_post_t *node);
 
+/* One-shot timers run on the reactor's run thread.  The timer is caller-owned
+ * (typically embedded in the object it belongs to); the reactor keeps a
+ * min-heap of armed timers and shortens its poll wait to the earliest
+ * deadline, so an idle reactor with no armed timers never wakes for them.
+ *
+ * Arm, cancel and the callback all run on the run thread (or before the
+ * reactor starts running).  Re-arming from inside the callback is allowed.
+ * Foreign threads reach a timer through cwist_reactor_post(). */
+typedef struct cwist_reactor_timer {
+    uint64_t deadline_ns; /* CLOCK_MONOTONIC */
+    uint32_t heap_slot;   /* 0 when not armed, else heap index + 1 */
+    void (*cb)(void *ctx);
+    void *ctx;
+} cwist_reactor_timer_t;
+
+void cwist_reactor_timer_init(cwist_reactor_timer_t *timer, void (*cb)(void *ctx), void *ctx);
+/* (Re-)arm @p timer to fire once after @p delay_us microseconds. */
+bool cwist_reactor_timer_arm(cwist_reactor_t *reactor, cwist_reactor_timer_t *timer,
+                             uint64_t delay_us);
+void cwist_reactor_timer_cancel(cwist_reactor_t *reactor, cwist_reactor_timer_t *timer);
+bool cwist_reactor_timer_armed(const cwist_reactor_timer_t *timer);
+
 #ifdef __cplusplus
 }
 #endif
diff --git a/lib/usrsctp b/lib/usrsctp
new file mode 160000
index 000000000..fd070e05a
--- /dev/null
+++ b/lib/usrsctp
@@ -0,0 +1 @@
+Subproject commit fd070e05a7474f38c7fecdf4d4b6005d2547ee00
diff --git a/src/net/webrtc/dtls.c b/src/net/webrtc/dtls.c
new file mode 100644
index 000000000..5f2746fcb
--- /dev/null
+++ b/src/net/webrtc/dtls.c
@@ -0,0 +1,217 @@
+/** @file dtls.c
+ * @brief DTLS helpers: ephemeral certificate generation, fingerprint, SSL_CTX.
+ *
+ * Provides the minimal DTLS layer for WebRTC: an ephemeral self-signed
+ * ECDSA certificate, the SHA-256 certificate fingerprint used in SDP
+ * a=fingerprint attributes, construction of client/server SSL_CTX
+ * objects pinned to DTLS 1.2, and the datagram BIO the sessions run on.
+ */
+#include "webrtc_internal.h"
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+/** @brief DTLS record/link MTU assumed for the media path (bytes).
+ * Used to size outgoing DTLS records so they fit typical UDP/ICE paths
+ * without IP fragmentation.
+ */
+#define CWIST_DTLS_MTU 1200
+
+/** @brief Generate an ephemeral self-signed ECDSA certificate for DTLS.
+ * @param cert_out Receives a new X509 certificate; set to NULL on entry
+ *                 and on failure.
+ * @param pkey_out Receives the matching EVP_PKEY; set to NULL on entry
+ *                 and on failure.
+ * @return 0 on success, -1 on any OpenSSL error.
+ * @note The key is P-256 (NID_X9_62_prime256v1). The certificate has
+ *       version 3 (v2 encoding), serial 1, a 30-day validity starting now,
+ *       and CN "cwist-webrtc". On failure all intermediate objects are
+ *       freed and both outputs remain NULL; on success ownership moves to
+ *       the caller, who must free them with X509_free()/EVP_PKEY_free().
+ */
+int cwist_dtls_generate_cert(X509 **cert_out, EVP_PKEY **pkey_out) {
+    *cert_out = NULL;
+    *pkey_out = NULL;
+
+    EVP_PKEY_CTX *kctx = EVP_PKEY_CTX_new_id(EVP_PKEY_EC, NULL);
+    if (!kctx)
+        return -1;
+    if (EVP_PKEY_keygen_init(kctx) <= 0 || EVP_PKEY_CTX_set_ec_paramgen_curve_nid(kctx, NID_X9_62_prime256v1) <= 0) {
+        EVP_PKEY_CTX_free(kctx);
+        return -1;
+    }
+    EVP_PKEY *pkey = NULL;
+    if (EVP_PKEY_keygen(kctx, &pkey) <= 0) {
+        EVP_PKEY_CTX_free(kctx);
+        return -1;
+    }
+    EVP_PKEY_CTX_free(kctx);
+
+    X509 *cert = X509_new();
+    if (!cert) {
+        EVP_PKEY_free(pkey);
+        return -1;
+    }
+    X509_set_version(cert, 2);
+    ASN1_INTEGER_set(X509_get_serialNumber(cert), 1);
+    X509_gmtime_adj(X509_getm_notBefore(cert), 0);
+    X509_gmtime_adj(X509_getm_notAfter(cert), 60L * 60L * 24L * 30);
+    X509_set_pubkey(cert, pkey);
+    X509_NAME *name = X509_get_subject_name(cert);
+    X509_NAME_add_entry_by_txt(name, "CN", MBSTRING_ASC, (const uint8_t *)"cwist-webrtc", -1, -1, 0);
+    X509_set_issuer_name(cert, name);
+    if (X509_sign(cert, pkey, EVP_sha256()) == 0) {
+        X509_free(cert);
+        EVP_PKEY_free(pkey);
+        return -1;
+    }
+    *cert_out = cert;
+    *pkey_out = pkey;
+    return 0;
+}
+
+/** @brief Compute the SHA-256 fingerprint of a certificate in SDP format.
+ * @param cert Certificate to hash; must be non-NULL.
+ * @param out  Output buffer receiving the fingerprint text.
+ * @param cap  Capacity of @p out in bytes (including the NUL terminator).
+ * @return 0 on success, -1 if the DER encoding fails, the certificate
+ *         exceeds the internal 2048-byte DER buffer, or the digest does
+ *         not fit in @p out.
+ * @retval 0 Fingerprint written, e.g. "AB:12:...:EF" in uppercase hex.
+ * @retval -1 Encoding, size, or digest error.
+ * @note The result is the colon-separated uppercase hex SHA-256 digest as
+ *       used in the SDP a=fingerprint attribute. No terminator is written
+ *       beyond what snprintf() already emits.
+ */
+int cwist_dtls_fingerprint(X509 *cert, char *out, size_t cap) {
+    uint8_t der[2048];
+    int der_len = i2d_X509(cert, NULL);
+    if (der_len <= 0 || (size_t)der_len > sizeof(der))
+        return -1;
+    uint8_t *p = der;
+    i2d_X509(cert, &p);
+    uint8_t digest[EVP_MAX_MD_SIZE];
+    unsigned int dlen = 0;
+    if (!EVP_Digest(der, (size_t)der_len, digest, &dlen, EVP_sha256(), NULL))
+        return -1;
+    size_t off = 0;
+    for (unsigned int i = 0; i < dlen && off + 3 < cap; i++)
+        off += (size_t)snprintf(out + off, cap - off, "%s%02X", i ? ":" : "", digest[i]);
+    return off > 0 && off < cap ? 0 : -1;
+}
+
+/** @brief Create a DTLS SSL_CTX for the client or server role.
+ * @param is_server Non-zero to build a DTLS server context, zero for a
+ *                  DTLS client context.
+ * @param cert      Server certificate; used (with @p pkey) only when
+ *                  @p is_server is non-zero. May be NULL for a client.
+ * @param pkey      Private key matching @p cert; only used for a server.
+ * @return New SSL_CTX on success, NULL on allocation failure or when
+ *         certificate/key loading or validation fails on the server side.
+ * @note The context is pinned to DTLS 1.2 as the minimum protocol version.
+ *       Client contexts use SSL_VERIFY_NONE: peer fingerprint verification
+ *       is deferred to the SDP exchange (the test pins the expected
+ *       fingerprint out of band).
+ */
+SSL_CTX *cwist_dtls_ctx_new(int is_server, X509 *cert, EVP_PKEY *pkey) {
+    SSL_CTX *ctx = SSL_CTX_new(is_server ? DTLS_server_method() : DTLS_client_method());
+    if (!ctx)
+        return NULL;
+    SSL_CTX_set_min_proto_version(ctx, DTLS1_2_VERSION);
+    if (is_server) {
+        if (SSL_CTX_use_certificate(ctx, cert) != 1 || SSL_CTX_use_PrivateKey(ctx, pkey) != 1 ||
+            SSL_CTX_check_private_key(ctx) != 1) {
+            SSL_CTX_free(ctx);
+            return NULL;
+        }
+    } else {
+        /* Peer fingerprint verification is optional at MVP; the test pins the
+         * expected fingerprint via the SDP exchange. */
+        SSL_CTX_set_verify(ctx, SSL_VERIFY_NONE, NULL);
+    }
+    return ctx;
+}
+
+/** @brief Return the DTLS link MTU in bytes.
+ * @return The value of CWIST_DTLS_MTU (1200).
+ */
+unsigned int cwist_dtls_link_mtu(void) {
+    return CWIST_DTLS_MTU;
+}
+
+/* ---- datagram BIO ----
+ *
+ * DTLS needs datagram semantics: one BIO_read returns exactly one datagram and
+ * one BIO_write is exactly one datagram.  A memory BIO is a byte stream, so
+ * records written back to back get concatenated and a fixed-size read can cut
+ * one in half across two UDP packets.  This BIO keeps the boundaries and also
+ * skips the extra copy through the memory buffer: reads hand over the datagram
+ * the event loop is processing, writes go straight to the ctx send batch. */
+
+static BIO_METHOD *g_dgram_method;
+static pthread_once_t g_dgram_once = PTHREAD_ONCE_INIT;
+
+/** @brief Return the current inbound datagram once, then report "retry". */
+static int dgram_bio_read(BIO *bio, char *out, int outl) {
+    struct cwist_webrtc_conn *conn = BIO_get_data(bio);
+    BIO_clear_retry_flags(bio);
+    if (!conn || !conn->dtls_in) {
+        BIO_set_retry_read(bio);
+        return -1;
+    }
+    size_t n = conn->dtls_in_len;
+    if (outl < 0 || n > (size_t)outl)
+        n = outl < 0 ? 0 : (size_t)outl; /* DTLS drops a truncated datagram. */
+    memcpy(out, conn->dtls_in, n);
+    conn->dtls_in = NULL;
+    conn->dtls_in_len = 0;
+    return (int)n;
+}
+
+/** @brief Emit one datagram to the conn's peer. */
+static int dgram_bio_write(BIO *bio, const char *in, int inl) {
+    struct cwist_webrtc_conn *conn = BIO_get_data(bio);
+    BIO_clear_retry_flags(bio);
+    if (!conn || inl < 0) return -1;
+    cwist_webrtc_conn_dtls_out(conn, (const uint8_t *)in, (size_t)inl);
+    return inl;
+}
+
+/** @brief Flush always succeeds (the event loop flushes the batch); nothing
+ *         else is supported. */
+static long dgram_bio_ctrl(BIO *bio, int cmd, long num, void *ptr) {
+    (void)bio;
+    (void)num;
+    (void)ptr;
+    return cmd == BIO_CTRL_FLUSH ? 1 : 0;
+}
+
+/** @brief Mark a new BIO initialised. */
+static int dgram_bio_create(BIO *bio) {
+    BIO_set_init(bio, 1);
+    return 1;
+}
+
+/** @brief Build the shared BIO_METHOD once. */
+static void dgram_method_init(void) {
+    BIO_METHOD *m = BIO_meth_new(BIO_get_new_index() | BIO_TYPE_SOURCE_SINK, "cwist-webrtc-dgram");
+    if (!m) return;
+    if (!BIO_meth_set_read(m, dgram_bio_read) || !BIO_meth_set_write(m, dgram_bio_write) ||
+        !BIO_meth_set_ctrl(m, dgram_bio_ctrl) || !BIO_meth_set_create(m, dgram_bio_create)) {
+        BIO_meth_free(m);
+        return;
+    }
+    g_dgram_method = m;
+}
+
+BIO *cwist_dtls_bio_new(struct cwist_webrtc_conn *conn) {
+    pthread_once(&g_dgram_once, dgram_method_init);
+    if (!g_dgram_method) return NULL;
+    BIO *bio = BIO_new(g_dgram_method);
+    if (bio) BIO_set_data(bio, conn);
+    return bio;
+}
diff --git a/src/net/webrtc/ice.c b/src/net/webrtc/ice.c
new file mode 100644
index 000000000..26b99a755
--- /dev/null
+++ b/src/net/webrtc/ice.c
@@ -0,0 +1,603 @@
+/** @file ice.c
+ * @brief Minimal STUN/ICE (RFC 5389 / RFC 8445) for the ICE-lite agent.
+ *
+ * Implements just enough of STUN message parsing, validation, and construction
+ * to run an ICE-lite endpoint: binding request/response handling,
+ * MESSAGE-INTEGRITY (HMAC-SHA1) and FINGERPRINT (CRC-32) generation and
+ * checking, XOR-MAPPED-ADDRESS extraction, and random ICE credential
+ * generation. No retransmission or state machine logic lives here; callers in
+ * webrtc.c drive timing and nomination.
+ */
+#include "webrtc_internal.h"
+
+#include 
+#include 
+#include 
+#include 
+
+/** @name STUN wire-format constants (RFC 5389)
+ * @{ */
+#define STUN_MAGIC 0x2112A442u /**< Magic cookie; also the XOR mask for addresses/ports. */
+#define STUN_HDR_LEN 20        /**< Fixed STUN message header size in bytes. */
+#define ATTR_MAPPED_ADDRESS 0x0001     /**< MAPPED-ADDRESS attribute type. Unused (we send XOR-MAPPED-ADDRESS). */
+#define ATTR_USERNAME 0x0006           /**< USERNAME attribute type ("remoteufrag:localufrag"). */
+#define ATTR_MESSAGE_INTEGRITY 0x0008  /**< MESSAGE-INTEGRITY attribute type (HMAC-SHA1, 20 bytes). */
+#define ATTR_PRIORITY 0x0024           /**< PRIORITY attribute type (ICE, RFC 8445). */
+#define ATTR_USE_CANDIDATE 0x0025      /**< USE-CANDIDATE attribute type (nomination hint). */
+#define ATTR_XOR_MAPPED_ADDRESS 0x0020 /**< XOR-MAPPED-ADDRESS attribute type. */
+#define ATTR_FINGERPRINT 0x8028        /**< FINGERPRINT attribute type (CRC-32 xor 0x5354554E). */
+#define ATTR_ICE_CONTROLLED 0x8029     /**< ICE-CONTROLLED attribute type. Unused by this agent. */
+#define ATTR_ICE_CONTROLLING 0x802A    /**< ICE-CONTROLLING attribute type (we always send it). */
+/** @} */
+
+/** Byte-at-a-time CRC-32 (IEEE, poly 0xEDB88320) lookup table, built lazily by crc32_init(). */
+static uint32_t crc32_table[256];
+/** Set once crc32_table has been initialized. */
+static bool crc32_ready = false;
+
+/** @brief Build the CRC-32 lookup table on first use.
+ *
+ * Idempotent; subsequent calls return immediately.
+ */
+static void crc32_init(void) {
+    if (crc32_ready)
+        return;
+    for (uint32_t i = 0; i < 256; i++) {
+        uint32_t c = i;
+        for (int k = 0; k < 8; k++)
+            c = (c & 1) ? 0xEDB88320u ^ (c >> 1) : c >> 1;
+        crc32_table[i] = c;
+    }
+    crc32_ready = true;
+}
+
+/** @brief Incremental CRC-32 (IEEE) over possibly several consecutive calls.
+ * @param c    Running CRC value; start a new checksum with 0xFFFFFFFF and xor
+ *             the final result with 0xFFFFFFFF (the STUN FINGERPRINT framing).
+ * @param data Bytes to fold into the checksum.
+ * @param len  Number of bytes at @p data.
+ * @return Updated running CRC.
+ */
+static uint32_t crc32_update(uint32_t c, const uint8_t *data, size_t len) {
+    crc32_init();
+    for (size_t i = 0; i < len; i++)
+        c = crc32_table[(c ^ data[i]) & 0xFF] ^ (c >> 8);
+    return c;
+}
+
+/** @brief Read a 32-bit big-endian value.
+ * @param p Pointer to at least 4 readable bytes.
+ * @return The value in host byte order semantics (as an integer composed from big-endian bytes).
+ */
+static uint32_t rd32(const uint8_t *p) {
+    return (uint32_t)p[0] << 24 | (uint32_t)p[1] << 16 | (uint32_t)p[2] << 8 | p[3];
+}
+
+/** @brief Read a 16-bit big-endian value.
+ * @param p Pointer to at least 2 readable bytes.
+ */
+static uint16_t rd16(const uint8_t *p) {
+    return (uint16_t)((uint16_t)p[0] << 8 | p[1]);
+}
+
+/** @brief Write a 16-bit value in big-endian order.
+ * @param p Destination buffer (2 bytes).
+ * @param v Value to encode.
+ */
+static void wr16(uint8_t *p, uint16_t v) {
+    p[0] = (uint8_t)(v >> 8);
+    p[1] = (uint8_t)v;
+}
+
+/** @brief Write a 32-bit value in big-endian order.
+ * @param p Destination buffer (4 bytes).
+ * @param v Value to encode.
+ */
+static void wr32(uint8_t *p, uint32_t v) {
+    p[0] = (uint8_t)(v >> 24);
+    p[1] = (uint8_t)(v >> 16);
+    p[2] = (uint8_t)(v >> 8);
+    p[3] = (uint8_t)v;
+}
+
+/** @brief Check whether a datagram looks like a STUN message.
+ * @param buf Datagram bytes.
+ * @param len Number of bytes at @p buf.
+ * @return 1 if @p buf holds at least a STUN header and the magic cookie matches,
+ *         0 otherwise.
+ * @note This is a shape check only; no attribute parsing or auth is performed.
+ */
+int cwist_ice_stun_is_message(const uint8_t *buf, size_t len) {
+    return len >= STUN_HDR_LEN && rd32(buf + 4) == STUN_MAGIC;
+}
+
+/** @brief Check whether a datagram is a STUN binding request.
+ * @param buf Datagram bytes.
+ * @param len Number of bytes at @p buf.
+ * @return true if @p buf is a STUN message with type CWIST_STUN_BINDING_REQUEST.
+ */
+bool cwist_ice_stun_is_binding_request(const uint8_t *buf, size_t len) {
+    return cwist_ice_stun_is_message(buf, len) && rd16(buf) == CWIST_STUN_BINDING_REQUEST;
+}
+
+/** Callback invoked once per STUN attribute during a walk_attrs() scan.
+ * @param type    Attribute type field.
+ * @param val     Pointer to the attribute value inside the message buffer.
+ * @param val_len Value length in bytes (unpadded).
+ * @param arg     Opaque pointer supplied to walk_attrs().
+ * @retval true   Continue walking.
+ * @retval false  Stop walking (walk_attrs() then reports failure to its caller).
+ */
+typedef bool (*attr_cb)(uint16_t type, const uint8_t *val, uint16_t val_len, void *arg);
+
+/** @brief Walk the attribute list of a STUN message.
+ * @param msg STUN message buffer (header + attributes).
+ * @param len Total bytes available at @p msg.
+ * @param cb  Callback invoked for each attribute, in message order.
+ * @param arg Passed through to @p cb.
+ * @return true if the attribute area is well-formed and fully consumed,
+ *         false if the message is truncated, malformed, or @p cb stopped the walk.
+ */
+static bool walk_attrs(const uint8_t *msg, size_t len, attr_cb cb, void *arg) {
+    uint16_t msg_len = rd16(msg + 2);
+    if ((size_t)msg_len + STUN_HDR_LEN > len || msg_len % 4 != 0)
+        return false;
+    size_t off = STUN_HDR_LEN;
+    size_t end = STUN_HDR_LEN + msg_len;
+    while (off + 4 <= end) {
+        uint16_t type = rd16(msg + off);
+        uint16_t alen = rd16(msg + off + 2);
+        off += 4;
+        if (off + alen > end)
+            return false;
+        if (!cb(type, msg + off, alen, arg))
+            return false;
+        off += (alen + 3) & ~3u;
+    }
+    return off == end;
+}
+
+/** State for attr_find_cb(): which attribute type to look for and the result. */
+typedef struct {
+    bool found;           /**< Set true once the wanted attribute has been seen. */
+    uint16_t val_len;     /**< Value length of the found attribute (valid only if found). */
+    uint16_t type_wanted; /**< Attribute type to search for. */
+} attr_find_arg;
+
+/** @brief attr_cb that records the first occurrence of a wanted attribute.
+ * Stops the walk (returns false) after the first match so later duplicates are ignored.
+ */
+static bool attr_find_cb(uint16_t type, const uint8_t *val, uint16_t val_len, void *arg) {
+    (void)val;
+    attr_find_arg *a = arg;
+    if (type == a->type_wanted) {
+        a->found = true;
+        a->val_len = val_len;
+        return false;
+    }
+    return true;
+}
+
+/** @brief Check whether a STUN message contains an attribute of the given type.
+ * @param msg  STUN message buffer.
+ * @param len  Bytes available at @p msg.
+ * @param type Attribute type to look for.
+ * @return true if present. Malformed messages simply report "not found".
+ */
+static bool attr_present(const uint8_t *msg, size_t len, uint16_t type) {
+    attr_find_arg a = { .type_wanted = type };
+    walk_attrs(msg, len, attr_find_cb, &a);
+    return a.found;
+}
+
+/** @brief Check whether a STUN message carries the USE-CANDIDATE attribute.
+ * @param buf Datagram bytes.
+ * @param len Number of bytes at @p buf.
+ */
+bool cwist_ice_stun_has_use_candidate(const uint8_t *buf, size_t len) {
+    return attr_present(buf, len, ATTR_USE_CANDIDATE);
+}
+
+/** @brief Locate the MESSAGE-INTEGRITY attribute in a STUN message.
+ * @param msg    STUN message buffer.
+ * @param len    Bytes available at @p msg.
+ * @param mi_off Receives the offset of the MI attribute header within @p msg.
+ * @return true if a MESSAGE-INTEGRITY attribute was found, false otherwise.
+ * @retval true  Only if the attribute length is exactly 20 (HMAC-SHA1 size).
+ * @warning A short/long MI attribute is reported as "not found" here; callers
+ *          treat that as an unsigned message rather than as a hard error.
+ */
+static bool find_mi(const uint8_t *msg, size_t len, size_t *mi_off) {
+    if (!cwist_ice_stun_is_message(msg, len))
+        return false;
+    uint16_t msg_len = rd16(msg + 2);
+    size_t end = STUN_HDR_LEN + msg_len;
+    size_t off = STUN_HDR_LEN;
+    while (off + 4 <= end) {
+        uint16_t type = rd16(msg + off);
+        uint16_t alen = rd16(msg + off + 2);
+        if (type == ATTR_MESSAGE_INTEGRITY) {
+            *mi_off = off;
+            return alen == 20;
+        }
+        off += 4 + ((alen + 3) & ~3u);
+    }
+    return false;
+}
+
+/** @brief Validate the MESSAGE-INTEGRITY of a STUN message against a password.
+ * @param buf Datagram bytes (any STUN message type; also used for responses).
+ * @param len Number of bytes at @p buf.
+ * @param pwd Short-term credential password used as the HMAC-SHA1 key.
+ * @return 1 if the HMAC matches, 0 if the message is malformed, the HMAC does
+ *         not match, or OpenSSL allocation fails.
+ * @retval 1 Also when no MESSAGE-INTEGRITY attribute is present: accepted for
+ *            MVP interoperability (RFC 8445 requires MI, but the agent stays
+ *            permissive here).
+ * @note The HMAC covers the message up to and including the MI attribute
+ *       header, with the header's length field adjusted to end at the MI
+ *       attribute, per RFC 5389.
+ */
+int cwist_ice_stun_validate_request(const uint8_t *buf, size_t len, const char *pwd) {
+    size_t pwd_len = strlen(pwd);
+    uint8_t hmac[EVP_MAX_MD_SIZE];
+    unsigned int hmac_len = 0;
+    size_t mi_off;
+    if (!cwist_ice_stun_is_message(buf, len))
+        return 0;
+    if (!find_mi(buf, len, &mi_off))
+        return 1; /* no MI: accept (RFC 8445 requires it, but stay permissive at MVP) */
+    /* MI covers the message up to and including the MI attribute header, with
+     * the message length field set to end at the MI attribute. */
+    uint8_t hdr[STUN_HDR_LEN];
+    memcpy(hdr, buf, STUN_HDR_LEN);
+    wr16(hdr + 2, (uint16_t)(mi_off - STUN_HDR_LEN + 4 + 20));
+    HMAC_CTX *ctx = HMAC_CTX_new();
+    if (!ctx)
+        return 0;
+    HMAC_Init_ex(ctx, pwd, (int)pwd_len, EVP_sha1(), NULL);
+    HMAC_Update(ctx, hdr, STUN_HDR_LEN);
+    HMAC_Update(ctx, buf + STUN_HDR_LEN, mi_off - STUN_HDR_LEN);
+    HMAC_Final(ctx, hmac, &hmac_len);
+    HMAC_CTX_free(ctx);
+    return hmac_len == 20 && memcmp(hmac, buf + mi_off + 4, 20) == 0;
+}
+
+/** @brief Build a STUN binding response for a received binding request.
+ *
+ * Emits a message containing XOR-MAPPED-ADDRESS (IPv4 only), MESSAGE-INTEGRITY
+ * (HMAC-SHA1 keyed with @p pwd), and FINGERPRINT attributes, reusing the
+ * request's transaction ID.
+ *
+ * @param out    Destination buffer.
+ * @param cap    Capacity of @p out in bytes.
+ * @param req    The received binding request (used for its transaction ID).
+ * @param req_len Bytes at @p req; currently unused.
+ * @param mapped Address/port to report back (XOR-encoded with the magic cookie).
+ * @param pwd    Short-term credential password for the MI HMAC.
+ * @return Total response length in bytes, or -1 if @p cap is too small or
+ *         OpenSSL allocation fails.
+ */
+int cwist_ice_stun_build_response(uint8_t *out, size_t cap, const uint8_t *req, size_t req_len,
+                                  const struct sockaddr_in *mapped, const char *pwd) {
+    (void)req_len;
+    uint8_t body[64];
+    size_t blen = 0;
+
+    /* XOR-MAPPED-ADDRESS, IPv4 */
+    wr16(body + blen, ATTR_XOR_MAPPED_ADDRESS);
+    wr16(body + blen + 2, 8);
+    blen += 4;
+    body[blen++] = 0x00;
+    body[blen++] = 0x01; /* family IPv4 */
+    wr16(body + blen, (uint16_t)(ntohs(mapped->sin_port) ^ 0xFFFF));
+    blen += 2;
+    uint32_t addr = ntohl(mapped->sin_addr.s_addr) ^ STUN_MAGIC;
+    wr32(body + blen, addr);
+    blen += 4;
+    while (blen % 4)
+        body[blen++] = 0;
+
+    /* MESSAGE-INTEGRITY over header+body+MI attr header with adjusted length */
+    uint16_t len_for_mi = (uint16_t)(blen + 4 + 20);
+    uint8_t hdr[STUN_HDR_LEN];
+    memset(hdr, 0, STUN_HDR_LEN);
+    wr16(hdr, CWIST_STUN_BINDING_RESPONSE);
+    wr16(hdr + 2, len_for_mi);
+    wr32(hdr + 4, STUN_MAGIC);
+    memcpy(hdr + 8, req + 8, 12);
+    uint8_t mi[20];
+    unsigned int mi_len = 0;
+    HMAC_CTX *hctx = HMAC_CTX_new();
+    if (!hctx)
+        return -1;
+    HMAC_Init_ex(hctx, pwd, (int)strlen(pwd), EVP_sha1(), NULL);
+    HMAC_Update(hctx, hdr, STUN_HDR_LEN);
+    HMAC_Update(hctx, body, blen);
+    HMAC_Final(hctx, mi, &mi_len);
+    HMAC_CTX_free(hctx);
+
+    wr16(body + blen, ATTR_MESSAGE_INTEGRITY);
+    wr16(body + blen + 2, 20);
+    blen += 4;
+    memcpy(body + blen, mi, 20);
+    blen += 20;
+
+    /* FINGERPRINT */
+    uint16_t len_for_fp = (uint16_t)(blen + 8);
+    uint8_t hdr2[STUN_HDR_LEN];
+    memcpy(hdr2, hdr, STUN_HDR_LEN);
+    wr16(hdr2 + 2, len_for_fp);
+    uint32_t c = crc32_update(0xFFFFFFFFu, hdr2, STUN_HDR_LEN);
+    c = crc32_update(c, body, blen);
+    c ^= 0xFFFFFFFFu;
+    wr16(body + blen, ATTR_FINGERPRINT);
+    wr16(body + blen + 2, 4);
+    wr32(body + blen + 4, c ^ 0x5354554Eu);
+    blen += 8;
+
+    if (cap < STUN_HDR_LEN + blen)
+        return -1;
+    memcpy(out, hdr2, STUN_HDR_LEN);
+    memcpy(out + STUN_HDR_LEN, body, blen);
+    return (int)(STUN_HDR_LEN + blen);
+}
+
+/** @brief Build an unsigned ICE-controlling STUN binding request.
+ *
+ * The request carries USERNAME (only if @p username is non-empty),
+ * ICE-CONTROLLING, USE-CANDIDATE, and PRIORITY attributes. MESSAGE-INTEGRITY
+ * and FINGERPRINT are intentionally not added here; the MI key (the peer's
+ * password) is only known at send time, so the caller signs the built request
+ * with cwist_ice_stun_sign_request().
+ *
+ * @param out      Destination buffer.
+ * @param cap      Capacity of @p out in bytes.
+ * @param txid     12-byte transaction ID to copy into the header.
+ * @param username "remoteufrag:localufrag" string for the USERNAME attribute;
+ *                 skipped when empty.
+ * @return Total request length in bytes, or -1 if @p cap is too small.
+ */
+int cwist_ice_stun_build_request(uint8_t *out, size_t cap, const uint8_t txid[12],
+                                 const char *username) {
+    size_t ulen = strlen(username);
+    if (cap < STUN_HDR_LEN)
+        return -1;
+    memset(out, 0, STUN_HDR_LEN);
+    wr16(out, CWIST_STUN_BINDING_REQUEST);
+    wr32(out + 4, STUN_MAGIC);
+    memcpy(out + 8, txid, 12);
+
+    size_t blen = 0;
+    uint8_t body[256];
+    /* SOFTWARE-ish placeholder skipped; USERNAME */
+    if (ulen > 0) {
+        wr16(body + blen, ATTR_USERNAME);
+        wr16(body + blen + 2, (uint16_t)ulen);
+        blen += 4;
+        memcpy(body + blen, username, ulen);
+        blen += ulen;
+        while (blen % 4)
+            body[blen++] = 0;
+    }
+    /* ICE-CONTROLLING (8 bytes) */
+    wr16(body + blen, ATTR_ICE_CONTROLLING);
+    wr16(body + blen + 2, 8);
+    memset(body + blen + 4, 0, 8);
+    blen += 12;
+    /* USE-CANDIDATE */
+    wr16(body + blen, ATTR_USE_CANDIDATE);
+    wr16(body + blen + 2, 0);
+    blen += 4;
+    /* PRIORITY */
+    wr16(body + blen, ATTR_PRIORITY);
+    wr16(body + blen + 2, 4);
+    wr32(body + blen + 4, 0x7EFFFFFFu);
+    blen += 8;
+
+    /* MESSAGE-INTEGRITY + FINGERPRINT added by the caller via _finalize; here we
+     * keep the request unsigned because the MI key (peer pwd) is supplied at
+     * send time. cwist_ice_stun_sign_request() handles it. */
+    wr16(out + 2, (uint16_t)blen);
+    if (cap < STUN_HDR_LEN + blen)
+        return -1;
+    memcpy(out + STUN_HDR_LEN, body, blen);
+    return (int)(STUN_HDR_LEN + blen);
+}
+
+/** @brief Append MESSAGE-INTEGRITY and FINGERPRINT attributes to a built request.
+ *
+ * Rewrites the message header with the final length after appending. The MI
+ * HMAC covers header+body up to and including the MI attribute header, with the
+ * length field adjusted accordingly, per RFC 5389; the FINGERPRINT CRC covers
+ * the whole message including MI.
+ *
+ * @param msg     Buffer holding a message previously built by
+ *                cwist_ice_stun_build_request(); extended in place.
+ * @param cap     Total capacity of @p msg.
+ * @param msg_len Current length of the message in @p msg.
+ * @param pwd     Short-term credential password for the MI HMAC.
+ * @return New total message length, or -1 if @p cap is too small or OpenSSL
+ *         allocation fails.
+ */
+static int stun_sign(uint8_t *msg, size_t cap, size_t msg_len, const char *pwd) {
+    uint8_t body[64];
+    size_t blen = 0;
+    uint16_t len_for_mi = (uint16_t)(msg_len - STUN_HDR_LEN + 4 + 20);
+    uint8_t hdr[STUN_HDR_LEN];
+    memcpy(hdr, msg, STUN_HDR_LEN);
+    wr16(hdr + 2, len_for_mi);
+    uint8_t mi[20];
+    unsigned int mi_len = 0;
+    HMAC_CTX *hctx = HMAC_CTX_new();
+    if (!hctx)
+        return -1;
+    HMAC_Init_ex(hctx, pwd, (int)strlen(pwd), EVP_sha1(), NULL);
+    HMAC_Update(hctx, hdr, STUN_HDR_LEN);
+    HMAC_Update(hctx, msg + STUN_HDR_LEN, msg_len - STUN_HDR_LEN);
+    HMAC_Final(hctx, mi, &mi_len);
+    HMAC_CTX_free(hctx);
+
+    wr16(body + blen, ATTR_MESSAGE_INTEGRITY);
+    wr16(body + blen + 2, 20);
+    blen += 4;
+    memcpy(body + blen, mi, 20);
+    blen += 20;
+
+    uint16_t len_for_fp = (uint16_t)(msg_len - STUN_HDR_LEN + blen + 8);
+    uint8_t hdr2[STUN_HDR_LEN];
+    memcpy(hdr2, hdr, STUN_HDR_LEN);
+    wr16(hdr2 + 2, len_for_fp);
+    uint32_t c = crc32_update(0xFFFFFFFFu, hdr2, STUN_HDR_LEN);
+    c = crc32_update(c, msg + STUN_HDR_LEN, msg_len - STUN_HDR_LEN);
+    c = crc32_update(c, body, blen);
+    c ^= 0xFFFFFFFFu;
+    wr16(body + blen, ATTR_FINGERPRINT);
+    wr16(body + blen + 2, 4);
+    wr32(body + blen + 4, c ^ 0x5354554Eu);
+    blen += 8;
+
+    if (cap < msg_len + blen)
+        return -1;
+    memcpy(msg + msg_len, body, blen);
+    memcpy(msg, hdr2, STUN_HDR_LEN);
+    return (int)(msg_len + blen);
+}
+
+/** @brief Public wrapper around stun_sign() for signing a built binding request.
+ * @param msg     Buffer holding the request; extended in place.
+ * @param cap     Total capacity of @p msg.
+ * @param msg_len Current request length.
+ * @param pwd     Peer password used as the MI HMAC key.
+ * @return New total length, or -1 on failure (see stun_sign()).
+ */
+int cwist_ice_stun_sign_request(uint8_t *msg, size_t cap, size_t msg_len, const char *pwd) {
+    return stun_sign(msg, cap, msg_len, pwd);
+}
+
+/** State for parse_resp_cb(): collected MESSAGE-INTEGRITY value and mapped address. */
+typedef struct {
+    const uint8_t *mi_val;   /**< Pointer to the MI attribute value (into the message buffer), or NULL. */
+    struct sockaddr_in mapped; /**< Decoded XOR-MAPPED-ADDRESS, valid only if has_mapped. */
+    bool has_mapped;         /**< True once an IPv4 XOR-MAPPED-ADDRESS has been decoded. */
+} parse_resp_arg;
+
+/** @brief attr_cb collecting the MI value and XOR-MAPPED-ADDRESS from a response.
+ * Never stops the walk; duplicates overwrite earlier values (last one wins).
+ */
+static bool parse_resp_cb(uint16_t type, const uint8_t *val, uint16_t val_len, void *arg) {
+    parse_resp_arg *a = arg;
+    if (type == ATTR_MESSAGE_INTEGRITY) {
+        a->mi_val = val;
+        (void)val_len;
+    } else if (type == ATTR_XOR_MAPPED_ADDRESS && val_len >= 8) {
+        a->mapped.sin_family = AF_INET;
+        a->mapped.sin_port = htons((uint16_t)(rd16(val + 2) ^ 0xFFFF));
+        a->mapped.sin_addr.s_addr = htonl(rd32(val + 4) ^ STUN_MAGIC);
+        a->has_mapped = true;
+    }
+    return true;
+}
+
+/** @brief Validate a received STUN binding response.
+ *
+ * Checks, in order: well-formed STUN message of type BINDING-RESPONSE,
+ * transaction ID match against @p txid, MESSAGE-INTEGRITY HMAC with @p pwd
+ * (via cwist_ice_stun_validate_request()), and presence of a MESSAGE-INTEGRITY
+ * attribute. If @p mapped is non-NULL, an IPv4 XOR-MAPPED-ADDRESS must also be
+ * present and is decoded into it.
+ *
+ * @param buf    Datagram bytes.
+ * @param len    Number of bytes at @p buf.
+ * @param txid   Transaction ID the response must carry (the one we sent).
+ * @param pwd    Local password used as the MI HMAC key.
+ * @param mapped Optional output for the decoded mapped address.
+ * @retval 1 Valid response.
+ * @retval 0 Any check failed or the message is malformed.
+ */
+int cwist_ice_stun_parse_response(const uint8_t *buf, size_t len, const uint8_t txid[12],
+                                  const char *pwd, struct sockaddr_in *mapped) {
+    if (!cwist_ice_stun_is_message(buf, len) || rd16(buf) != CWIST_STUN_BINDING_RESPONSE)
+        return 0;
+    if (memcmp(buf + 8, txid, 12) != 0)
+        return 0;
+    if (!cwist_ice_stun_validate_request(buf, len, pwd))
+        return 0;
+    parse_resp_arg a = { 0 };
+    if (!walk_attrs(buf, len, parse_resp_cb, &a))
+        return 0;
+    if (!a.mi_val)
+        return 0;
+    if (mapped) {
+        if (!a.has_mapped)
+            return 0;
+        *mapped = a.mapped;
+    }
+    return 1;
+}
+
+/** Base64url alphabet (no padding) used to encode random ICE credentials. */
+static const char b64url_chars[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
+
+/** @brief Extract the sender's ufrag from the USERNAME attribute of a STUN message.
+ *
+ * A Binding request's USERNAME is ":"
+ * (RFC 8445 section 7.2.2), so for a request we received this returns the
+ * part after the first ':' -- the remote peer's ufrag, as it appears in the
+ * peer's SDP.
+ *
+ * @param buf Datagram bytes.
+ * @param len Number of bytes at @p buf.
+ * @param out Output buffer for the NUL-terminated ufrag.
+ * @param cap Capacity of @p out; output is truncated to cap - 1 characters.
+ * @retval 0 Ufrag extracted (possibly truncated).
+ * @retval -1 Not a STUN message, or no USERNAME attribute present.
+ */
+int cwist_ice_stun_get_remote_ufrag(const uint8_t *buf, size_t len, char *out, size_t cap) {
+    if (!cwist_ice_stun_is_message(buf, len))
+        return -1;
+    uint16_t msg_len = rd16(buf + 2);
+    size_t end = STUN_HDR_LEN + msg_len;
+    size_t off = STUN_HDR_LEN;
+    while (off + 4 <= end) {
+        uint16_t type = rd16(buf + off);
+        uint16_t alen = rd16(buf + off + 2);
+        if (type == ATTR_USERNAME) {
+            if (off + 4 + alen > end) return -1;
+            const uint8_t *name = buf + off + 4;
+            size_t colon = 0;
+            while (colon < alen && name[colon] != ':') colon++;
+            if (colon == alen) return -1; /* no ':' separator */
+            size_t i = 0;
+            for (size_t j = colon + 1; j < alen && i + 1 < cap; j++) out[i++] = (char)name[j];
+            out[i] = '\0';
+            return 0;
+        }
+        off += 4 + ((alen + 3) & ~3u);
+    }
+    return -1;
+}
+
+/** @brief Generate random ICE ufrag/pwd credentials.
+ *
+ * Both strings are drawn from 24 bytes of CSPRNG output encoded with the
+ * base64url alphabet (6 bits per character).
+ *
+ * @param ufrag     Output buffer for the ufrag.
+ * @param ufrag_cap Capacity of @p ufrag; up to 8 characters plus NUL are written.
+ * @param pwd       Output buffer for the password.
+ * @param pwd_cap   Capacity of @p pwd; up to 32 characters plus NUL are written.
+ * @warning Behavior is undefined if either capacity is 0 (cap - 1 underflows).
+ */
+void cwist_ice_random_creds(char *ufrag, size_t ufrag_cap, char *pwd, size_t pwd_cap) {
+    uint8_t raw[24];
+    RAND_bytes(raw, sizeof(raw));
+    size_t n = ufrag_cap - 1 < 8 ? ufrag_cap - 1 : 8;
+    for (size_t i = 0; i < n; i++)
+        ufrag[i] = b64url_chars[raw[i] & 63];
+    ufrag[n] = '\0';
+    size_t m = pwd_cap - 1 < 32 ? pwd_cap - 1 : 32;
+    for (size_t i = 0; i < m; i++)
+        pwd[i] = b64url_chars[raw[8 + (i % 16)] & 63];
+    pwd[m] = '\0';
+}
diff --git a/src/net/webrtc/sctp.c b/src/net/webrtc/sctp.c
new file mode 100644
index 000000000..6b6ff70bf
--- /dev/null
+++ b/src/net/webrtc/sctp.c
@@ -0,0 +1,439 @@
+/** @file sctp.c
+ * @brief SCTP DataChannels over DTLS (RFC 8831) and DCEP (RFC 8832).
+ *
+ * usrsctp runs in AF_CONN raw mode tunneled over the DTLS connection
+ * (RFC 8831), initialised without its own threads.  Its global output
+ * callback hands packets to cwist_webrtc_conn_sctp_out(), which encrypts them
+ * on the conn's owner thread.
+ *
+ * The address handle convention follows usrsctp's ekr_loop example: every
+ * endpoint registers its connection pointer as its handle, binds with it,
+ * and the offering side "connects" to its own handle.  Packets are injected
+ * with usrsctp_conninput(conn) and replies come back through the same
+ * handle, which keeps routing unambiguous when several associations share
+ * one usrsctp instance.  The answering side listens and accepts.  Inbound
+ * data is drained with usrsctp_recvv() by the event loop; no receive
+ * callbacks are registered, so usrsctp never calls into us with data on an
+ * arbitrary thread.
+ *
+ * Timers: usrsctp keeps one process-wide timer wheel.  cwist_sctp_tick()
+ * advances it by the real time elapsed since the previous tick, whichever
+ * ctx calls it, so several ctxs ticking concurrently cannot make SCTP time
+ * run fast.  A timer that fires may emit a packet for a conn owned by another
+ * reactor; the output path marshals it there.  Teardown takes the same lock
+ * as the tick, so a timer pass never runs against a half-closed conn.
+ */
+#include "webrtc_internal.h"
+
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+
+/** @name DCEP message types (RFC 8832) */
+/**@{*/
+#define DCEP_ACK 0x02  /**< DCEP ACK: acknowledges a received OPEN. */
+#define DCEP_OPEN 0x03 /**< DCEP OPEN: announces a new DataChannel. */
+/**@}*/
+
+/** @name SCTP payload protocol identifiers (RFC 8831 section 8) */
+/**@{*/
+#define PPID_DCEP 50         /**< DCEP control message (OPEN/ACK). */
+#define PPID_STRING 51       /**< UTF-8 string message. */
+#define PPID_BINARY 53       /**< Binary message. */
+#define PPID_STRING_EMPTY 56 /**< Empty string (sent as one 0x00 byte). */
+#define PPID_BINARY_EMPTY 57 /**< Empty binary message (sent as one 0x00 byte). */
+/**@}*/
+
+/** WebRTC DataChannel well-known SCTP port (RFC 8831, both endpoints use it). */
+#define CWIST_SCTP_PORT 5000
+
+/** Streams negotiated in each direction (libwebrtc uses the same count). */
+#define CWIST_SCTP_STREAMS 1024
+
+/** SCTP packet size.  DTLS 1.2 with AES-GCM adds 37 bytes (13 header, 8
+ *  explicit nonce, 16 tag), so the datagram stays under the 1200-byte DTLS
+ *  link MTU with room to spare. */
+#define CWIST_SCTP_MTU 1160
+
+/** SCTP heartbeat interval (ms). */
+#define CWIST_SCTP_HEARTBEAT_MS 10000
+
+/** Socket buffer for each association: holds several max-size messages. */
+#define CWIST_SCTP_SOCKBUF (1 << 20)
+
+/** Serialises usrsctp_handle_timers() and the tick clock, and keeps conn
+ *  teardown out of a timer pass (see the file comment). */
+static pthread_mutex_t g_sctp_lock = PTHREAD_MUTEX_INITIALIZER;
+/** Monotonic time (ms) up to which usrsctp's clock has been advanced. */
+static uint64_t g_sctp_clock_ms;
+/** Guards the one-time usrsctp initialisation. */
+static pthread_once_t g_sctp_once = PTHREAD_ONCE_INIT;
+
+/** @brief CLOCK_MONOTONIC in milliseconds. */
+static uint64_t sctp_now_ms(void) {
+    struct timespec ts;
+    clock_gettime(CLOCK_MONOTONIC, &ts);
+    return (uint64_t)ts.tv_sec * 1000ull + (uint64_t)ts.tv_nsec / 1000000ull;
+}
+
+/**
+ * @brief usrsctp's global output callback.
+ * @param addr The conn registered as the AF_CONN address.
+ * @return 0 when handed off, -1 if there is no conn.
+ */
+static int sctp_global_output(void *addr, void *buffer, size_t length, uint8_t tos,
+                              uint8_t set_df) {
+    (void)tos;
+    (void)set_df;
+    struct cwist_webrtc_conn *conn = addr;
+    if (!conn) return -1;
+    cwist_webrtc_conn_sctp_out(conn, buffer, length);
+    return 0;
+}
+
+/** @brief Initialise usrsctp once: no threads, no UDP encapsulation. */
+static void sctp_init_once(void) {
+    usrsctp_init_nothreads(0, &sctp_global_output, NULL);
+    /* RFC 8831: SCTP runs directly over DTLS, never over UDP tunneling. */
+    usrsctp_sysctl_set_sctp_udp_tunneling_port(0);
+    g_sctp_clock_ms = sctp_now_ms();
+}
+
+int cwist_sctp_global_init(void) {
+    pthread_once(&g_sctp_once, sctp_init_once);
+    return 0;
+}
+
+void cwist_sctp_tick(void) {
+    pthread_mutex_lock(&g_sctp_lock);
+    uint64_t now = sctp_now_ms();
+    if (now > g_sctp_clock_ms) {
+        uint64_t elapsed = now - g_sctp_clock_ms;
+        g_sctp_clock_ms = now;
+        usrsctp_handle_timers((uint32_t)(elapsed > UINT32_MAX ? UINT32_MAX : elapsed));
+    }
+    pthread_mutex_unlock(&g_sctp_lock);
+}
+
+/* ---- per-stream state: 2 bits per stream id ---- */
+
+/** @brief Read flag @p bit (0: opened by peer, 1: OPEN sent) of stream @p ch. */
+static bool ch_get(const struct cwist_webrtc_conn *conn, uint16_t ch, int bit) {
+    size_t off = (size_t)ch * 2 + (size_t)bit;
+    if (off / 8 >= conn->ch_bits_len) return false;
+    return (conn->ch_bits[off / 8] >> (off % 8)) & 1u;
+}
+
+/**
+ * @brief Set flag @p bit of stream @p ch, growing the bitmap on demand (most
+ *        conns use a handful of low stream ids).
+ * @return 0, or -1 on allocation failure.
+ */
+static int ch_set(struct cwist_webrtc_conn *conn, uint16_t ch, int bit) {
+    size_t off = (size_t)ch * 2 + (size_t)bit;
+    if (off / 8 >= conn->ch_bits_len) {
+        uint32_t len = conn->ch_bits_len ? conn->ch_bits_len : 16;
+        while (off / 8 >= len) len *= 2;
+        uint8_t *bits = cwist_alloc(len);
+        if (!bits) return -1;
+        if (conn->ch_bits_len) memcpy(bits, conn->ch_bits, conn->ch_bits_len);
+        cwist_free(conn->ch_bits);
+        conn->ch_bits = bits;
+        conn->ch_bits_len = len;
+    }
+    conn->ch_bits[off / 8] |= (uint8_t)(1u << (off % 8));
+    return 0;
+}
+
+/** @brief The socket carrying the association (accepted socket on the server). */
+static struct socket *sctp_data_sock(const struct cwist_webrtc_conn *conn) {
+    return conn->sctp_acc ? conn->sctp_acc : conn->is_server_role ? NULL : conn->sctp_sock;
+}
+
+/**
+ * @brief Send a DCEP control message (OPEN with @p label, or ACK).
+ * @return 0 when sent, 1 when the send buffer is full, -1 on error.
+ */
+static int dcep_send(struct cwist_webrtc_conn *conn, uint16_t channel, uint8_t type,
+                     const char *label) {
+    uint8_t msg[256];
+    size_t len = 0;
+    msg[len++] = type;
+    if (type == DCEP_OPEN) {
+        size_t label_len = label ? strlen(label) : 0;
+        if (label_len > 200) label_len = 200;
+        msg[len++] = 0x00; /* DATA_CHANNEL_RELIABLE */
+        msg[len++] = 0x00;
+        msg[len++] = 0x00; /* priority */
+        msg[len++] = 0x00;
+        msg[len++] = 0x00;
+        msg[len++] = 0x00; /* reliability parameter */
+        msg[len++] = 0x00;
+        msg[len++] = (uint8_t)(label_len >> 8);
+        msg[len++] = (uint8_t)label_len;
+        msg[len++] = 0x00;
+        msg[len++] = 0x00; /* protocol length 0 */
+        memcpy(msg + len, label, label_len);
+        len += label_len;
+    }
+
+    struct sctp_sndinfo info;
+    memset(&info, 0, sizeof(info));
+    info.snd_sid = channel;
+    info.snd_ppid = htonl(PPID_DCEP);
+    ssize_t rc = usrsctp_sendv(sctp_data_sock(conn), msg, len, NULL, 0, &info, sizeof(info),
+                               SCTP_SENDV_SNDINFO, 0);
+    if (rc >= 0) return 0;
+    return (errno == EWOULDBLOCK || errno == EAGAIN) ? 1 : -1;
+}
+
+/** @brief Route one complete inbound message: DCEP control or user data. */
+static void dispatch_message(struct cwist_webrtc_conn *conn, uint16_t sid, uint32_t ppid,
+                             const uint8_t *msg, size_t datalen) {
+    if (ppid == PPID_DCEP && datalen >= 1) {
+        if (msg[0] == DCEP_OPEN && datalen >= 12) {
+            uint16_t label_len = (uint16_t)((uint16_t)msg[8] << 8 | msg[9]);
+            char label[201];
+            size_t n = label_len;
+            if (n > sizeof(label) - 1) n = sizeof(label) - 1;
+            if (12 + n > datalen) n = datalen - 12;
+            memcpy(label, msg + 12, n);
+            label[n] = '\0';
+            ch_set(conn, sid, 0);
+            dcep_send(conn, sid, DCEP_ACK, NULL);
+            cwist_webrtc_conn_on_channel_open(conn, sid, label);
+        }
+        /* ACK (0x02): the channel we opened is confirmed; nothing to do. */
+    } else if (ppid == PPID_BINARY) {
+        cwist_webrtc_conn_on_message(conn, sid, msg, datalen, 0);
+    } else if (ppid == PPID_STRING) {
+        cwist_webrtc_conn_on_message(conn, sid, msg, datalen, 1);
+    } else if (ppid == PPID_BINARY_EMPTY || ppid == PPID_STRING_EMPTY) {
+        /* SCTP cannot carry a zero-length message: the 0x00 is padding. */
+        cwist_webrtc_conn_on_message(conn, sid, msg, 0, ppid == PPID_STRING_EMPTY);
+    }
+}
+
+/** @brief Socket options shared by the listening, connecting and accepted sockets. */
+static void sctp_apply_sockopts(struct socket *sock) {
+    int on = 1;
+    usrsctp_set_non_blocking(sock, 1);
+    usrsctp_setsockopt(sock, IPPROTO_SCTP, SCTP_RECVRCVINFO, &on, sizeof(on));
+    usrsctp_setsockopt(sock, IPPROTO_SCTP, SCTP_NODELAY, &on, sizeof(on));
+
+    /* close() aborts instead of lingering in SHUTDOWN: no timer can fire for
+     * the association after its conn is gone. */
+    struct linger lg = {.l_onoff = 1, .l_linger = 0};
+    usrsctp_setsockopt(sock, SOL_SOCKET, SO_LINGER, &lg, sizeof(lg));
+
+    int buf = CWIST_SCTP_SOCKBUF;
+    usrsctp_setsockopt(sock, SOL_SOCKET, SO_SNDBUF, &buf, sizeof(buf));
+    usrsctp_setsockopt(sock, SOL_SOCKET, SO_RCVBUF, &buf, sizeof(buf));
+
+    struct sctp_initmsg init;
+    memset(&init, 0, sizeof(init));
+    init.sinit_num_ostreams = CWIST_SCTP_STREAMS;
+    init.sinit_max_instreams = CWIST_SCTP_STREAMS;
+    usrsctp_setsockopt(sock, IPPROTO_SCTP, SCTP_INITMSG, &init, sizeof(init));
+
+    /* Fixed path MTU: the DTLS link MTU is known and PMTU probes would only
+     * get lost inside the tunnel. */
+    struct sctp_paddrparams pp;
+    memset(&pp, 0, sizeof(pp));
+    pp.spp_assoc_id = SCTP_FUTURE_ASSOC;
+    pp.spp_flags = SPP_PMTUD_DISABLE | SPP_HB_ENABLE;
+    pp.spp_pathmtu = CWIST_SCTP_MTU;
+    /* Heartbeats keep both directions talking well inside the 30 s liveness
+     * window even when the peer sends no ICE consent checks. */
+    pp.spp_hbinterval = CWIST_SCTP_HEARTBEAT_MS;
+    usrsctp_setsockopt(sock, IPPROTO_SCTP, SCTP_PEER_ADDR_PARAMS, &pp, sizeof(pp));
+
+    /* Association loss (abort, shutdown) arrives as a notification so the
+     * conn is closed right away instead of waiting for the liveness timer. */
+    struct sctp_event ev;
+    memset(&ev, 0, sizeof(ev));
+    ev.se_assoc_id = SCTP_ALL_ASSOC;
+    ev.se_on = 1;
+    ev.se_type = SCTP_ASSOC_CHANGE;
+    usrsctp_setsockopt(sock, IPPROTO_SCTP, SCTP_EVENT, &ev, sizeof(ev));
+}
+
+int cwist_sctp_conn_open(struct cwist_webrtc_conn *conn) {
+    cwist_sctp_global_init();
+    conn->sctp_sock = usrsctp_socket(AF_CONN, SOCK_STREAM, IPPROTO_SCTP, NULL, NULL, 0, NULL);
+    if (!conn->sctp_sock) return -1;
+    sctp_apply_sockopts(conn->sctp_sock);
+    usrsctp_register_address(conn);
+
+    struct sockaddr_conn local;
+    memset(&local, 0, sizeof(local));
+    local.sconn_family = AF_CONN;
+    local.sconn_port = htons(CWIST_SCTP_PORT);
+    local.sconn_addr = conn;
+    if (usrsctp_bind(conn->sctp_sock, (struct sockaddr *)&local, sizeof(local)) < 0) goto fail;
+
+    if (conn->is_server_role) {
+        /* Passive side must accept the incoming SCTP association. */
+        if (usrsctp_listen(conn->sctp_sock, 1) < 0) goto fail;
+    } else {
+        /* Active side initiates the SCTP association (sends the INIT). */
+        struct sockaddr_conn remote = local;
+        if (usrsctp_connect(conn->sctp_sock, (struct sockaddr *)&remote, sizeof(remote)) < 0 &&
+            errno != EINPROGRESS)
+            goto fail;
+    }
+    return 0;
+
+fail:
+    cwist_sctp_conn_close(conn);
+    return -1;
+}
+
+void cwist_sctp_conn_close(struct cwist_webrtc_conn *conn) {
+    if (!conn->sctp_sock && !conn->sctp_acc) return;
+    pthread_mutex_lock(&g_sctp_lock);
+    if (conn->sctp_acc) {
+        usrsctp_close(conn->sctp_acc);
+        conn->sctp_acc = NULL;
+    }
+    if (conn->sctp_sock) {
+        usrsctp_close(conn->sctp_sock);
+        conn->sctp_sock = NULL;
+    }
+    usrsctp_deregister_address(conn);
+    pthread_mutex_unlock(&g_sctp_lock);
+    cwist_free(conn->ch_bits);
+    conn->ch_bits = NULL;
+    conn->ch_bits_len = 0;
+    cwist_free(conn->rx_partial);
+    conn->rx_partial = NULL;
+    conn->rx_partial_len = conn->rx_partial_cap = 0;
+}
+
+void cwist_sctp_conn_input(struct cwist_webrtc_conn *conn, const uint8_t *data, size_t len) {
+    if (conn->sctp_sock || conn->sctp_acc) usrsctp_conninput(conn, data, len, 0);
+}
+
+/**
+ * @brief Append a piece of a partially delivered message.
+ * @return 0, or -1 if the message would exceed the advertised maximum.
+ */
+static int rx_partial_append(struct cwist_webrtc_conn *conn, const uint8_t *data, size_t len) {
+    size_t need = conn->rx_partial_len + len;
+    if (need > CWIST_WEBRTC_MAX_MESSAGE) return -1;
+    if (need > conn->rx_partial_cap) {
+        size_t cap = conn->rx_partial_cap ? conn->rx_partial_cap : 65536;
+        while (cap < need) cap *= 2;
+        uint8_t *buf = cwist_alloc(cap);
+        if (!buf) return -1;
+        if (conn->rx_partial_len) memcpy(buf, conn->rx_partial, conn->rx_partial_len);
+        cwist_free(conn->rx_partial);
+        conn->rx_partial = buf;
+        conn->rx_partial_cap = cap;
+    }
+    memcpy(conn->rx_partial + conn->rx_partial_len, data, len);
+    conn->rx_partial_len = need;
+    return 0;
+}
+
+int cwist_sctp_conn_drain(struct cwist_webrtc_conn *conn) {
+    if (!conn->sctp_sock && !conn->sctp_acc) return 0;
+    if (conn->is_server_role && !conn->sctp_acc) {
+        conn->sctp_acc = usrsctp_accept(conn->sctp_sock, NULL, NULL);
+        if (!conn->sctp_acc) return 0;
+        sctp_apply_sockopts(conn->sctp_acc);
+        /* One association per conn: the listener has done its job. */
+        pthread_mutex_lock(&g_sctp_lock);
+        usrsctp_close(conn->sctp_sock);
+        conn->sctp_sock = NULL;
+        pthread_mutex_unlock(&g_sctp_lock);
+    }
+    struct socket *sock = sctp_data_sock(conn);
+    if (!sock) return 0;
+    uint8_t *buf = conn->ctx->sctp_buf;
+    for (;;) {
+        struct sctp_rcvinfo rcv;
+        socklen_t infolen = sizeof(rcv);
+        unsigned int infotype = 0;
+        int msg_flags = 0;
+        struct sockaddr_conn from;
+        socklen_t fromlen = sizeof(from);
+        ssize_t n = usrsctp_recvv(sock, buf, CWIST_WEBRTC_MAX_MESSAGE, (struct sockaddr *)&from,
+                                  &fromlen, &rcv, &infolen, &infotype, &msg_flags);
+        if (n < 0) return (errno == EWOULDBLOCK || errno == EAGAIN) ? 0 : -1;
+        if (n == 0) return -1; /* peer shut the association down */
+        if (msg_flags & MSG_NOTIFICATION) {
+            const union sctp_notification *note = (const union sctp_notification *)buf;
+            if ((size_t)n >= sizeof(note->sn_header) &&
+                note->sn_header.sn_type == SCTP_ASSOC_CHANGE &&
+                (size_t)n >= sizeof(note->sn_assoc_change)) {
+                uint16_t state = note->sn_assoc_change.sac_state;
+                if (state == SCTP_COMM_LOST || state == SCTP_SHUTDOWN_COMP ||
+                    state == SCTP_CANT_STR_ASSOC)
+                    return -1;
+            }
+            continue;
+        }
+        if (infotype != SCTP_RECVV_RCVINFO || infolen < (socklen_t)sizeof(rcv)) continue;
+        if (!(msg_flags & MSG_EOR)) {
+            /* Partial delivery: keep the piece until the end of the message. */
+            if (rx_partial_append(conn, buf, (size_t)n) < 0) return -1;
+            continue;
+        }
+        if (conn->rx_partial_len) {
+            if (rx_partial_append(conn, buf, (size_t)n) < 0) return -1;
+            size_t total = conn->rx_partial_len;
+            conn->rx_partial_len = 0;
+            dispatch_message(conn, rcv.rcv_sid, ntohl(rcv.rcv_ppid), conn->rx_partial, total);
+        } else {
+            dispatch_message(conn, rcv.rcv_sid, ntohl(rcv.rcv_ppid), buf, (size_t)n);
+        }
+        if (conn->close_requested || atomic_load(&conn->detached)) return 0;
+    }
+}
+
+bool cwist_sctp_assoc_established(struct cwist_webrtc_conn *conn) {
+    if (conn->is_server_role) return conn->sctp_acc != NULL;
+    if (!conn->sctp_sock) return false;
+    struct sctp_status status;
+    socklen_t slen = sizeof(status);
+    memset(&status, 0, sizeof(status));
+    if (usrsctp_getsockopt(conn->sctp_sock, IPPROTO_SCTP, SCTP_STATUS, &status, &slen) < 0)
+        return false;
+    return status.sstat_state == SCTP_ESTABLISHED;
+}
+
+int cwist_sctp_send(struct cwist_webrtc_conn *conn, uint16_t channel, const uint8_t *data,
+                    size_t len, int is_string) {
+    struct socket *sock = sctp_data_sock(conn);
+    if (!sock || !conn->sctp_ready || channel >= CWIST_SCTP_STREAMS) return -1;
+    if (!ch_get(conn, channel, 0) && !ch_get(conn, channel, 1)) {
+        /* Brand-new channel opened by us: announce it with DCEP OPEN. */
+        char label[32];
+        snprintf(label, sizeof(label), "dc%u", channel);
+        int rc = dcep_send(conn, channel, DCEP_OPEN, label);
+        if (rc != 0) return rc;
+        if (ch_set(conn, channel, 1) < 0) return -1;
+    }
+    struct sctp_sndinfo info;
+    memset(&info, 0, sizeof(info));
+    info.snd_sid = channel;
+    static const uint8_t empty_pad = 0;
+    if (len == 0) {
+        /* RFC 8831 section 6.6: an empty message is one byte with an
+         * "empty" PPID. */
+        info.snd_ppid = htonl(is_string ? PPID_STRING_EMPTY : PPID_BINARY_EMPTY);
+        data = &empty_pad;
+        len = 1;
+    } else {
+        info.snd_ppid = htonl(is_string ? PPID_STRING : PPID_BINARY);
+    }
+    ssize_t rc =
+        usrsctp_sendv(sock, data, len, NULL, 0, &info, sizeof(info), SCTP_SENDV_SNDINFO, 0);
+    if (rc >= 0) return 0;
+    return (errno == EWOULDBLOCK || errno == EAGAIN) ? 1 : -1;
+}
diff --git a/src/net/webrtc/sdp.c b/src/net/webrtc/sdp.c
new file mode 100644
index 000000000..017b04d8d
--- /dev/null
+++ b/src/net/webrtc/sdp.c
@@ -0,0 +1,172 @@
+/** @file sdp.c
+ * @brief Minimal SDP (RFC 4566) parse/generate for DataChannel offers/answers.
+ *
+ * Supports only the subset of SDP needed for a single SCTP DataChannel
+ * m-section: ICE credentials, DTLS fingerprint/setup, mid, and ice-lite.
+ * Parsing extracts fields into a cwist_sdp_info struct; generation produces
+ * offer or answer strings with CRLF line endings.
+ */
+#include "webrtc_internal.h"
+
+#include 
+#include 
+
+/** @brief Copy the value part of an attribute line after a fixed prefix.
+ * @param line   NUL-terminated SDP line beginning with @p prefix.
+ * @param prefix Attribute prefix (e.g. "a=ice-ufrag:") to strip.
+ * @param dst    Destination buffer, always NUL-terminated on success.
+ * @param cap    Capacity of @p dst in bytes.
+ * @note Does nothing if @p line does not start with @p prefix.
+ *       Silently truncates values longer than cap - 1.
+ */
+static void copy_attr_value(const char *line, const char *prefix, char *dst, size_t cap) {
+    size_t plen = strlen(prefix);
+    if (strncmp(line, prefix, plen) != 0)
+        return;
+    const char *v = line + plen;
+    size_t n = strcspn(v, "\r\n");
+    if (n >= cap)
+        n = cap - 1;
+    memcpy(dst, v, n);
+    dst[n] = '\0';
+}
+
+/** @brief Parse an SDP blob into a cwist_sdp_info structure.
+ * @param sdp   SDP text (not required to be NUL-terminated).
+ * @param len   Length of @p sdp in bytes.
+ * @param info  Output structure; zeroed first, then filled from recognized lines.
+ * @return 0 on success, -1 if ice-ufrag or ice-pwd is missing or empty.
+ * @note Lines longer than 511 bytes are truncated. If an attribute appears
+ *       more than once, the last occurrence wins. Sets info->has_lite when
+ *       "a=ice-lite" appears.
+ */
+int cwist_sdp_parse(const char *sdp, size_t len, cwist_sdp_info *info) {
+    memset(info, 0, sizeof(*info));
+    char line[512];
+    size_t pos = 0;
+    while (pos < len) {
+        size_t n = strcspn(sdp + pos, "\r\n");
+        if (n >= sizeof(line))
+            n = sizeof(line) - 1;
+        memcpy(line, sdp + pos, n);
+        line[n] = '\0';
+        pos += n;
+        while (pos < len && (sdp[pos] == '\r' || sdp[pos] == '\n'))
+            pos++;
+
+        if (strncmp(line, "a=ice-ufrag:", 12) == 0)
+            copy_attr_value(line, "a=ice-ufrag:", info->ice_ufrag, sizeof(info->ice_ufrag));
+        else if (strncmp(line, "a=ice-pwd:", 10) == 0)
+            copy_attr_value(line, "a=ice-pwd:", info->ice_pwd, sizeof(info->ice_pwd));
+        else if (strncmp(line, "a=fingerprint:", 14) == 0)
+            copy_attr_value(line, "a=fingerprint:sha-256 ", info->fingerprint,
+                            sizeof(info->fingerprint));
+        else if (strncmp(line, "a=setup:", 8) == 0)
+            copy_attr_value(line, "a=setup:", info->setup, sizeof(info->setup));
+        else if (strncmp(line, "a=mid:", 6) == 0)
+            copy_attr_value(line, "a=mid:", info->mid, sizeof(info->mid));
+        else if (strcmp(line, "a=ice-lite") == 0)
+            info->has_lite = 1;
+    }
+    if (info->ice_ufrag[0] == '\0' || info->ice_pwd[0] == '\0')
+        return -1;
+    return 0;
+}
+
+/** @brief Emit the shared session/m-section portion of an SDP offer.
+ * @param out         Output buffer, receives CRLF-terminated SDP text.
+ * @param cap         Capacity of @p out in bytes.
+ * @param fingerprint DTLS certificate fingerprint (hex, colon-separated).
+ * @param ufrag       ICE username fragment.
+ * @param pwd         ICE password.
+ * @param mid         Media section ID for the BUNDLE group and m-line.
+ * @param setup       DTLS setup attribute value ("active"/"passive").
+ * @param with_lite   Nonzero to include an "a=ice-lite" attribute line.
+ * @param sess_id     o= line session ID.
+ * @return 0 on success, -1 if the output was truncated or encoding failed.
+ * @warning Not NUL-guaranteed on truncation; failure must be checked before use.
+ */
+static int write_session(char *out, size_t cap, const char *fingerprint, const char *ufrag,
+                         const char *pwd, const char *mid, const char *setup, int with_lite,
+                         long long sess_id) {
+    int n = snprintf(out, cap,
+                     "v=0\r\n"
+                     "o=- %lld 2 IN IP4 127.0.0.1\r\n"
+                     "s=-\r\n"
+                     "t=0 0\r\n"
+                     "a=group:BUNDLE %s\r\n"
+                     "m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n"
+                     "c=IN IP4 0.0.0.0\r\n"
+                     "a=mid:%s\r\n"
+                     "a=ice-ufrag:%s\r\n"
+                     "a=ice-pwd:%s\r\n"
+                     "%s"
+                     "a=fingerprint:sha-256 %s\r\n"
+                     "a=setup:%s\r\n"
+                     "a=sctp-port:5000\r\n"
+                     "a=max-message-size:262144\r\n",
+                     sess_id, mid, mid, ufrag, pwd, with_lite ? "a=ice-lite\r\n" : "", fingerprint,
+                     setup);
+    if (n < 0 || (size_t)n >= cap)
+        return -1;
+    return 0;
+}
+
+/** @brief Generate an SDP answer for an ICE-lite DataChannel endpoint.
+ * @param out         Output buffer, receives CRLF-terminated SDP answer.
+ * @param cap         Capacity of @p out in bytes.
+ * @param fingerprint DTLS certificate fingerprint (hex, colon-separated).
+ * @param ufrag       ICE username fragment.
+ * @param pwd         ICE password.
+ * @param mid         Media section ID (also used in the BUNDLE group).
+ * @param host        Local host address for the a=candidate line.
+ * @param port        Local UDP port for the a=candidate line.
+ * @return 0 on success, -1 if the output was truncated or encoding failed.
+ * @note Declares ice-lite and hardcodes a=setup:passive, since the
+ *       ICE-lite answering endpoint takes the passive DTLS role, and emits
+ *       a single host candidate followed by end-of-candidates.
+ */
+int cwist_sdp_write_answer(char *out, size_t cap, const char *fingerprint, const char *ufrag,
+                           const char *pwd, const char *mid, const char *host, uint16_t port) {
+    /* ICE-lite answering endpoint takes the passive DTLS role. */
+    int n = snprintf(out, cap,
+                     "v=0\r\n"
+                     "o=- 872978578 2 IN IP4 127.0.0.1\r\n"
+                     "s=-\r\n"
+                     "t=0 0\r\n"
+                     "a=group:BUNDLE %s\r\n"
+                     "m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n"
+                     "c=IN IP4 0.0.0.0\r\n"
+                     "a=mid:%s\r\n"
+                     "a=ice-ufrag:%s\r\n"
+                     "a=ice-pwd:%s\r\n"
+                     "a=ice-lite\r\n"
+                     "a=fingerprint:sha-256 %s\r\n"
+                     "a=setup:passive\r\n"
+                     "a=candidate:1 1 UDP 2122260223 %s %u typ host\r\n"
+                     "a=end-of-candidates\r\n"
+                     "a=sctp-port:5000\r\n"
+                     "a=max-message-size:262144\r\n",
+                     mid, mid, ufrag, pwd, fingerprint, host, (unsigned)port);
+    if (n < 0 || (size_t)n >= cap)
+        return -1;
+    return 0;
+}
+
+/** @brief Generate an SDP offer for a full (non-lite) DataChannel endpoint.
+ * @param out         Output buffer, receives CRLF-terminated SDP offer.
+ * @param cap         Capacity of @p out in bytes.
+ * @param fingerprint DTLS certificate fingerprint (hex, colon-separated).
+ * @param ufrag       ICE username fragment.
+ * @param pwd         ICE password.
+ * @param mid         Media section ID (also used in the BUNDLE group).
+ * @return 0 on success, -1 if the output was truncated or encoding failed.
+ * @note Thin wrapper over write_session() with setup "active", no ice-lite,
+ *       and a fixed session ID; the full agent offering endpoint takes the
+ *       active DTLS role.
+ */
+int cwist_sdp_write_offer(char *out, size_t cap, const char *fingerprint, const char *ufrag,
+                          const char *pwd, const char *mid) {
+    /* Full agent offering endpoint takes the active DTLS role. */
+    return write_session(out, cap, fingerprint, ufrag, pwd, mid, "active", 0, 337018573);
+}
diff --git a/src/net/webrtc/webrtc.c b/src/net/webrtc/webrtc.c
new file mode 100644
index 000000000..6fac3fdcd
--- /dev/null
+++ b/src/net/webrtc/webrtc.c
@@ -0,0 +1,1241 @@
+/** @file webrtc.c
+ * @brief WebRTC DataChannel context: UDP I/O, ICE-lite, DTLS, SCTP driving.
+ *
+ * A ctx is one UDP socket served by one cwist reactor (see webrtc_internal.h
+ * for the threading and lifetime rules).  Work happens only in reactor
+ * callbacks, and every callback follows the same shape:
+ *
+ *   ctx_enter()  - mark this thread as servicing the ctx, cache the time
+ *   ...          - handle datagrams / a timer / a posted request
+ *   ctx_leave()  - service conns touched this round (drain SCTP, flush
+ *                  queued sends, apply closes), then send every queued
+ *                  datagram in one sendmmsg()
+ *
+ * Datagrams are read in batches with recvmmsg().  A DTLS record is decrypted
+ * and fed to usrsctp right away, but draining SCTP and flushing sends waits
+ * until the batch is done, so a burst of packets for one conn costs one drain.
+ * Nothing runs on a fixed tick: each conn has one timer (STUN retransmit,
+ * DTLS retransmit, liveness or park expiry, whichever applies) and the ctx
+ * drives usrsctp's clock only while it holds associations, every 10 ms
+ * after recent traffic and every 250 ms otherwise.
+ */
+#include "webrtc_internal.h"
+
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+/** @name Timing */
+/**@{*/
+#define STUN_RTO_INITIAL_MS 300     /**< First Binding retransmit (client role). */
+#define STUN_RTO_MAX_MS 3000        /**< Retransmit backoff cap. */
+#define STUN_MAX_ATTEMPTS 7         /**< Binding requests before giving up. */
+#define PARK_TIMEOUT_MS 30000       /**< Answered offer with no nomination. */
+#define HANDSHAKE_TIMEOUT_MS 30000  /**< ICE + DTLS must finish within this. */
+#define LIVENESS_TIMEOUT_MS 30000   /**< Silence before an established conn is dropped. */
+#define SCTP_TICK_ACTIVE_MS 10      /**< usrsctp clock step after recent traffic. */
+#define SCTP_TICK_IDLE_MS 250       /**< usrsctp clock step when quiet. */
+#define SCTP_ACTIVE_WINDOW_MS 1000  /**< "Recent" for the tick choice. */
+/**@}*/
+
+/** Most datagrams read in one readiness callback before yielding. */
+#define RX_BUDGET 256
+/** Socket buffer requested for the UDP socket (the kernel may clamp it). */
+#define UDP_SOCKBUF (4 << 20)
+
+/** The ctx this thread is servicing right now, if any. */
+static _Thread_local cwist_webrtc_ctx *tl_ctx;
+
+static void ctx_release(cwist_webrtc_ctx *ctx);
+static void ctx_release_resources(cwist_webrtc_ctx *ctx);
+static void conn_teardown(struct cwist_webrtc_conn *conn);
+static void conn_rearm_timer(struct cwist_webrtc_conn *conn);
+static void ctx_arm_sctp_timer(cwist_webrtc_ctx *ctx);
+static void ctx_note_activity(cwist_webrtc_ctx *ctx);
+static void ctx_teardown_owner(cwist_webrtc_ctx *ctx);
+static void conn_timer_cb(void *arg);
+
+/* ---- time ---- */
+
+/** @brief CLOCK_MONOTONIC in nanoseconds. */
+static uint64_t mono_ns(void) {
+    struct timespec ts;
+    clock_gettime(CLOCK_MONOTONIC, &ts);
+    return (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec;
+}
+
+/* ---- references ---- */
+
+/** @brief Take a ctx reference. */
+static void ctx_retain(cwist_webrtc_ctx *ctx) {
+    atomic_fetch_add_explicit(&ctx->refs, 1, memory_order_relaxed);
+}
+
+void cwist_webrtc_conn_retain(cwist_webrtc_conn *conn) {
+    if (conn) atomic_fetch_add_explicit(&conn->refs, 1, memory_order_relaxed);
+}
+
+void cwist_webrtc_conn_release(cwist_webrtc_conn *conn) {
+    if (!conn || atomic_fetch_sub_explicit(&conn->refs, 1, memory_order_acq_rel) != 1) return;
+    /* Last reference: teardown already released everything but the memory,
+     * unless the conn never got registered (post dropped at ctx teardown). */
+    cwist_webrtc_ctx *ctx = conn->ctx;
+    if (conn->ssl) SSL_free(conn->ssl);
+    cwist_free(conn->ch_bits);
+    cwist_free(conn->rx_partial);
+    cwist_free(conn);
+    ctx_release(ctx);
+}
+
+/* ---- owner-thread bracketing ---- */
+
+bool cwist_webrtc_on_owner(const cwist_webrtc_ctx *ctx) {
+    return tl_ctx == ctx;
+}
+
+/**
+ * @brief Enter a reactor callback for @p ctx.  The outermost entry holds a
+ *        ctx reference, so a handler that frees the ctx cannot pull memory
+ *        out from under the callback still running.
+ * @return The previously serviced ctx (restore it with ctx_leave()).
+ */
+static cwist_webrtc_ctx *ctx_enter(cwist_webrtc_ctx *ctx) {
+    cwist_webrtc_ctx *prev = tl_ctx;
+    if (prev != ctx) {
+        ctx_retain(ctx);
+        tl_ctx = ctx;
+        ctx->now_ns = mono_ns();
+    }
+    return prev;
+}
+
+/** @brief Send every queued datagram (sendmmsg on Linux). */
+static void ctx_tx_flush(cwist_webrtc_ctx *ctx) {
+    uint32_t n = ctx->tx_n;
+    ctx->tx_n = 0;
+    if (n == 0 || ctx->udp_fd < 0) return;
+#ifdef __linux__
+    struct mmsghdr msgs[CWIST_WEBRTC_TX_BATCH];
+    struct iovec iov[CWIST_WEBRTC_TX_BATCH];
+    for (uint32_t i = 0; i < n; i++) {
+        iov[i].iov_base = ctx->tx_bufs + (size_t)i * CWIST_WEBRTC_DGRAM_MAX;
+        iov[i].iov_len = ctx->tx_len[i];
+        memset(&msgs[i].msg_hdr, 0, sizeof(msgs[i].msg_hdr));
+        msgs[i].msg_hdr.msg_name = &ctx->tx_addr[i];
+        msgs[i].msg_hdr.msg_namelen = sizeof(ctx->tx_addr[i]);
+        msgs[i].msg_hdr.msg_iov = &iov[i];
+        msgs[i].msg_hdr.msg_iovlen = 1;
+    }
+    uint32_t off = 0;
+    while (off < n) {
+        int sent = sendmmsg(ctx->udp_fd, msgs + off, n - off, MSG_DONTWAIT);
+        if (sent > 0) {
+            off += (uint32_t)sent;
+            continue;
+        }
+        if (sent < 0 && errno == EINTR) continue;
+        /* EAGAIN/ENOBUFS or a per-destination error: UDP is lossy anyway and
+         * DTLS/SCTP retransmit, so skip this datagram and keep going. */
+        off++;
+    }
+#else
+    for (uint32_t i = 0; i < n; i++) {
+        sendto(ctx->udp_fd, ctx->tx_bufs + (size_t)i * CWIST_WEBRTC_DGRAM_MAX, ctx->tx_len[i], 0,
+               (const struct sockaddr *)&ctx->tx_addr[i], sizeof(ctx->tx_addr[i]));
+    }
+#endif
+}
+
+/** @brief Queue one datagram for the end-of-round flush (owner thread). */
+static void ctx_tx_queue(cwist_webrtc_ctx *ctx, const uint8_t *data, size_t len,
+                         const struct sockaddr_in *to) {
+    if (len > CWIST_WEBRTC_DGRAM_MAX || ctx->closed) return;
+    if (ctx->tx_n == CWIST_WEBRTC_TX_BATCH) ctx_tx_flush(ctx);
+    uint32_t i = ctx->tx_n++;
+    memcpy(ctx->tx_bufs + (size_t)i * CWIST_WEBRTC_DGRAM_MAX, data, len);
+    ctx->tx_len[i] = len;
+    ctx->tx_addr[i] = *to;
+}
+
+/** @brief Put @p conn on the end-of-round service list (holds a reference). */
+static void conn_mark_dirty(struct cwist_webrtc_conn *conn) {
+    if (conn->dirty) return;
+    conn->dirty = true;
+    cwist_webrtc_conn_retain(conn);
+    conn->dirty_next = conn->ctx->dirty;
+    conn->ctx->dirty = conn;
+}
+
+/** @brief Hand queued sends to SCTP until it pushes back. */
+static void conn_flush_pending(struct cwist_webrtc_conn *conn) {
+    while (conn->pending_head && conn->sctp_ready) {
+        cwist_webrtc_msg *m = conn->pending_head;
+        int rc = cwist_sctp_send(conn, m->channel, m->data, m->len, m->is_string);
+        if (rc == 1) break; /* send buffer full: retried after the next inbound packet */
+        conn->pending_head = m->next;
+        if (!conn->pending_head) conn->pending_tail = NULL;
+        atomic_fetch_sub_explicit(&conn->buffered, m->len, memory_order_relaxed);
+        cwist_free(m);
+    }
+}
+
+/** @brief End-of-round work for one conn: SCTP drain, sends, close. */
+static void conn_service(struct cwist_webrtc_conn *conn) {
+    if (atomic_load(&conn->detached)) return;
+    if (conn->state == CWIST_CONN_ESTABLISHED && !conn->close_requested) {
+        if (cwist_sctp_conn_drain(conn) < 0) conn->close_requested = true;
+        if (!conn->close_requested && !conn->sctp_ready && cwist_sctp_assoc_established(conn)) {
+            conn->sctp_ready = true;
+            conn->notified_open = true;
+            atomic_fetch_add(&conn->ctx->ready_count, 1);
+        }
+        if (!conn->close_requested) conn_flush_pending(conn);
+    }
+    if (conn->close_requested) conn_teardown(conn);
+}
+
+/** @brief Leave a reactor callback: service dirty conns, flush datagrams. */
+static void ctx_leave(cwist_webrtc_ctx *ctx, cwist_webrtc_ctx *prev) {
+    if (prev != ctx) {
+        /* Servicing can dirty more conns (a message handler sending on
+         * another conn), so loop until the list stays empty. */
+        while (ctx->dirty) {
+            struct cwist_webrtc_conn *list = ctx->dirty;
+            ctx->dirty = NULL;
+            while (list) {
+                struct cwist_webrtc_conn *next = list->dirty_next;
+                list->dirty = false;
+                conn_service(list);
+                cwist_webrtc_conn_release(list);
+                list = next;
+            }
+        }
+        ctx_tx_flush(ctx);
+        tl_ctx = prev;
+        ctx_release(ctx);
+    }
+}
+
+/* ---- connection table ---- */
+
+/** @brief Bucket index for a remote address. */
+static uint32_t conn_hash(const cwist_webrtc_ctx *ctx, const struct sockaddr_in *a) {
+    uint32_t h = a->sin_addr.s_addr * 0x9E3779B1u ^ (uint32_t)a->sin_port * 0x85EBCA77u;
+    h ^= h >> 15;
+    return h & (ctx->nbuckets - 1);
+}
+
+/** @brief Look up the conn nominated on @p remote. */
+static struct cwist_webrtc_conn *table_find(const cwist_webrtc_ctx *ctx,
+                                            const struct sockaddr_in *remote) {
+    for (struct cwist_webrtc_conn *c = ctx->buckets[conn_hash(ctx, remote)]; c; c = c->hnext) {
+        if (c->remote.sin_addr.s_addr == remote->sin_addr.s_addr &&
+            c->remote.sin_port == remote->sin_port)
+            return c;
+    }
+    return NULL;
+}
+
+/** @brief Double the bucket array once the load factor passes 1. */
+static void table_grow(cwist_webrtc_ctx *ctx) {
+    uint32_t nb = ctx->nbuckets * 2;
+    struct cwist_webrtc_conn **b = cwist_alloc(nb * sizeof(*b));
+    if (!b) return; /* keep the old, longer chains */
+    uint32_t old_n = ctx->nbuckets;
+    struct cwist_webrtc_conn **old = ctx->buckets;
+    ctx->buckets = b;
+    ctx->nbuckets = nb;
+    for (uint32_t i = 0; i < old_n; i++) {
+        struct cwist_webrtc_conn *c = old[i];
+        while (c) {
+            struct cwist_webrtc_conn *next = c->hnext;
+            uint32_t h = conn_hash(ctx, &c->remote);
+            c->hnext = b[h];
+            b[h] = c;
+            c = next;
+        }
+    }
+    cwist_free(old);
+}
+
+/** @brief Insert @p conn keyed by its remote address (takes the table's reference). */
+static void table_insert(cwist_webrtc_ctx *ctx, struct cwist_webrtc_conn *conn) {
+    if (ctx->nconns + 1 > ctx->nbuckets) table_grow(ctx);
+    uint32_t h = conn_hash(ctx, &conn->remote);
+    conn->hnext = ctx->buckets[h];
+    ctx->buckets[h] = conn;
+    ctx->nconns++;
+    conn->registered = true;
+    conn->parked = false;
+}
+
+/** @brief Unlink @p conn from the table or the parked list. */
+static void table_remove(cwist_webrtc_ctx *ctx, struct cwist_webrtc_conn *conn) {
+    struct cwist_webrtc_conn **pp =
+        conn->parked ? &ctx->parked : &ctx->buckets[conn_hash(ctx, &conn->remote)];
+    while (*pp && *pp != conn) pp = &(*pp)->hnext;
+    if (*pp) {
+        *pp = conn->hnext;
+        if (!conn->parked) ctx->nconns--;
+    }
+    conn->hnext = NULL;
+    conn->registered = false;
+    conn->parked = false;
+}
+
+/* ---- conn lifecycle ---- */
+
+/** @brief Allocate a conn (one reference, owned by the caller). */
+static struct cwist_webrtc_conn *conn_new(cwist_webrtc_ctx *ctx, const struct sockaddr_in *remote,
+                                          bool is_server_role) {
+    struct cwist_webrtc_conn *conn = cwist_malloc(sizeof(*conn));
+    if (!conn) return NULL;
+    conn->ctx = ctx;
+    ctx_retain(ctx);
+    atomic_init(&conn->refs, 1);
+    atomic_init(&conn->detached, false);
+    atomic_init(&conn->close_posted, false);
+    atomic_init(&conn->buffered, 0);
+    conn->is_server_role = is_server_role;
+    conn->state = CWIST_CONN_STUN;
+    if (remote) conn->remote = *remote;
+    conn->created_ns = mono_ns();
+    conn->last_rx_ns = conn->created_ns;
+    conn->stun_rto_ms = STUN_RTO_INITIAL_MS;
+    cwist_reactor_timer_init(&conn->timer, conn_timer_cb, conn);
+    return conn;
+}
+
+/**
+ * @brief Close @p conn now (owner thread): SCTP abort, DTLS free, unlink,
+ *        close handler.  Idempotent; memory goes with the last reference.
+ */
+static void conn_teardown(struct cwist_webrtc_conn *conn) {
+    if (atomic_exchange(&conn->detached, true)) return;
+    cwist_webrtc_ctx *ctx = conn->ctx;
+    conn->state = CWIST_CONN_CLOSED;
+    cwist_reactor_timer_cancel(ctx->reactor, &conn->timer);
+    if (conn->sctp_ready) atomic_fetch_sub(&ctx->ready_count, 1);
+    conn->sctp_ready = false;
+    if (conn->sctp_sock || conn->sctp_acc) {
+        cwist_sctp_conn_close(conn);
+        if (--ctx->sctp_assocs == 0) cwist_reactor_timer_cancel(ctx->reactor, &ctx->sctp_timer);
+    }
+    if (conn->ssl) {
+        SSL_free(conn->ssl);
+        conn->ssl = NULL;
+    }
+    while (conn->pending_head) {
+        cwist_webrtc_msg *m = conn->pending_head;
+        conn->pending_head = m->next;
+        atomic_fetch_sub_explicit(&conn->buffered, m->len, memory_order_relaxed);
+        cwist_free(m);
+    }
+    conn->pending_tail = NULL;
+    bool notify = conn->notified_open;
+    conn->notified_open = false;
+    if (notify && ctx->close_cb) ctx->close_cb(conn, ctx->close_user);
+    if (conn->registered) {
+        table_remove(ctx, conn);
+        cwist_webrtc_conn_release(conn); /* the table's reference */
+    }
+}
+
+/** @brief Create the DTLS session on the custom datagram BIO and start it. */
+static void conn_start_dtls(struct cwist_webrtc_conn *conn) {
+    cwist_webrtc_ctx *ctx = conn->ctx;
+    SSL_CTX *ssl_ctx = conn->is_server_role ? ctx->server_ssl_ctx : ctx->client_ssl_ctx;
+    conn->ssl = SSL_new(ssl_ctx);
+    BIO *bio = conn->ssl ? cwist_dtls_bio_new(conn) : NULL;
+    if (!bio) {
+        conn->close_requested = true;
+        conn_mark_dirty(conn);
+        return;
+    }
+    SSL_set_bio(conn->ssl, bio, bio); /* one BIO for both directions */
+    SSL_set_options(conn->ssl, SSL_OP_NO_QUERY_MTU);
+    SSL_set_mtu(conn->ssl, cwist_dtls_link_mtu());
+    if (conn->is_server_role)
+        SSL_set_accept_state(conn->ssl);
+    else
+        SSL_set_connect_state(conn->ssl);
+    conn->state = CWIST_CONN_DTLS;
+    SSL_do_handshake(conn->ssl); /* client: emits ClientHello */
+    conn_rearm_timer(conn);
+}
+
+/** @brief DTLS finished: open SCTP and start the usrsctp clock. */
+static void conn_on_dtls_up(struct cwist_webrtc_conn *conn) {
+    cwist_webrtc_ctx *ctx = conn->ctx;
+    conn->state = CWIST_CONN_ESTABLISHED;
+    if (cwist_sctp_conn_open(conn) < 0) {
+        conn->close_requested = true;
+        return;
+    }
+    if (ctx->sctp_assocs++ == 0) ctx_arm_sctp_timer(ctx);
+    conn_rearm_timer(conn);
+}
+
+/** @brief Feed one DTLS datagram through the session (and SCTP above it). */
+static void conn_dtls_input(struct cwist_webrtc_conn *conn, const uint8_t *buf, size_t len) {
+    conn->dtls_in = buf;
+    conn->dtls_in_len = len;
+    if (conn->state == CWIST_CONN_DTLS) {
+        int rc = SSL_do_handshake(conn->ssl);
+        if (rc == 1) {
+            conn_on_dtls_up(conn);
+        } else if (SSL_get_error(conn->ssl, rc) == SSL_ERROR_SSL) {
+            conn->close_requested = true;
+        } else {
+            conn_rearm_timer(conn); /* a flight arrived: new retransmit deadline */
+        }
+    }
+    if (conn->state == CWIST_CONN_ESTABLISHED && !conn->close_requested) {
+        for (;;) {
+            int n = SSL_read(conn->ssl, conn->ctx->plain_buf, CWIST_WEBRTC_MAX_MESSAGE);
+            if (n > 0) {
+                cwist_sctp_conn_input(conn, conn->ctx->plain_buf, (size_t)n);
+                continue;
+            }
+            int err = SSL_get_error(conn->ssl, n);
+            if (err == SSL_ERROR_ZERO_RETURN || err == SSL_ERROR_SSL ||
+                (err == SSL_ERROR_SYSCALL && n == 0))
+                conn->close_requested = true;
+            break;
+        }
+    }
+    conn->dtls_in = NULL;
+    conn->dtls_in_len = 0;
+    conn_mark_dirty(conn);
+}
+
+/** @brief Send (or resend) the client-role Binding request. */
+static void conn_send_stun_request(struct cwist_webrtc_conn *conn) {
+    uint8_t msg[512];
+    char username[128];
+    snprintf(username, sizeof(username), "%s:%s", conn->peer_ufrag, conn->ctx->ice_ufrag);
+    int len = cwist_ice_stun_build_request(msg, sizeof(msg), conn->stun_txid, username);
+    if (len < 0) return;
+    len = cwist_ice_stun_sign_request(msg, sizeof(msg), (size_t)len, conn->peer_pwd);
+    if (len < 0) return;
+    ctx_tx_queue(conn->ctx, msg, (size_t)len, &conn->remote);
+    conn->stun_attempts++;
+}
+
+/* ---- per-conn timer ---- */
+
+/** @brief Point the conn timer at the next deadline its state needs. */
+static void conn_rearm_timer(struct cwist_webrtc_conn *conn) {
+    cwist_webrtc_ctx *ctx = conn->ctx;
+    uint64_t now = ctx->now_ns ? ctx->now_ns : mono_ns();
+    uint64_t delay_us;
+    switch (conn->state) {
+        case CWIST_CONN_STUN:
+            if (conn->parked) {
+                uint64_t end = conn->created_ns + PARK_TIMEOUT_MS * 1000000ull;
+                delay_us = end > now ? (end - now) / 1000 : 0;
+            } else if (!conn->is_server_role) {
+                delay_us = (uint64_t)conn->stun_rto_ms * 1000;
+            } else {
+                uint64_t end = conn->created_ns + HANDSHAKE_TIMEOUT_MS * 1000000ull;
+                delay_us = end > now ? (end - now) / 1000 : 0;
+            }
+            break;
+        case CWIST_CONN_DTLS: {
+            struct timeval tv;
+            uint64_t end = conn->created_ns + HANDSHAKE_TIMEOUT_MS * 1000000ull;
+            uint64_t hs_us = end > now ? (end - now) / 1000 : 0;
+            delay_us = hs_us;
+            if (DTLSv1_get_timeout(conn->ssl, &tv) == 1) {
+                uint64_t t = (uint64_t)tv.tv_sec * 1000000ull + (uint64_t)tv.tv_usec;
+                if (t < delay_us) delay_us = t;
+            }
+            break;
+        }
+        case CWIST_CONN_ESTABLISHED: {
+            uint64_t end = conn->last_rx_ns + LIVENESS_TIMEOUT_MS * 1000000ull;
+            delay_us = end > now ? (end - now) / 1000 : 0;
+            break;
+        }
+        default: return;
+    }
+    cwist_reactor_timer_arm(ctx->reactor, &conn->timer, delay_us);
+}
+
+/** @brief Conn timer: retransmit, give up, or check liveness. */
+static void conn_timer_cb(void *arg) {
+    struct cwist_webrtc_conn *conn = arg;
+    cwist_webrtc_ctx *ctx = conn->ctx;
+    cwist_webrtc_ctx *prev = ctx_enter(ctx);
+    uint64_t now = ctx->now_ns;
+    uint64_t age_ms = (now - conn->created_ns) / 1000000ull;
+    switch (conn->state) {
+        case CWIST_CONN_STUN:
+            if (conn->parked || conn->is_server_role) {
+                if (age_ms >= (conn->parked ? PARK_TIMEOUT_MS : HANDSHAKE_TIMEOUT_MS))
+                    conn->close_requested = true;
+            } else if (conn->stun_attempts >= STUN_MAX_ATTEMPTS) {
+                conn->close_requested = true;
+            } else {
+                conn_send_stun_request(conn);
+                conn->stun_rto_ms = conn->stun_rto_ms * 2 > STUN_RTO_MAX_MS ? STUN_RTO_MAX_MS
+                                                                            : conn->stun_rto_ms * 2;
+            }
+            break;
+        case CWIST_CONN_DTLS:
+            if (age_ms >= HANDSHAKE_TIMEOUT_MS) {
+                conn->close_requested = true;
+            } else if (DTLSv1_handle_timeout(conn->ssl) < 0) {
+                conn->close_requested = true;
+            }
+            break;
+        case CWIST_CONN_ESTABLISHED:
+            if ((now - conn->last_rx_ns) / 1000000ull >= LIVENESS_TIMEOUT_MS)
+                conn->close_requested = true;
+            break;
+        default: break;
+    }
+    if (conn->close_requested)
+        conn_mark_dirty(conn);
+    else
+        conn_rearm_timer(conn);
+    ctx_leave(ctx, prev);
+}
+
+/* ---- SCTP clock ---- */
+
+/** @brief Arm the usrsctp tick: fast after recent traffic, slow when quiet. */
+static void ctx_arm_sctp_timer(cwist_webrtc_ctx *ctx) {
+    uint64_t now = ctx->now_ns ? ctx->now_ns : mono_ns();
+    ctx->sctp_fast = now - ctx->last_activity_ns < SCTP_ACTIVE_WINDOW_MS * 1000000ull;
+    uint64_t step_ms = ctx->sctp_fast ? SCTP_TICK_ACTIVE_MS : SCTP_TICK_IDLE_MS;
+    cwist_reactor_timer_arm(ctx->reactor, &ctx->sctp_timer, step_ms * 1000);
+}
+
+/** @brief Record traffic; switch a slow SCTP tick back to the fast rate. */
+static void ctx_note_activity(cwist_webrtc_ctx *ctx) {
+    ctx->last_activity_ns = ctx->now_ns;
+    if (ctx->sctp_assocs > 0 && !ctx->sctp_fast) ctx_arm_sctp_timer(ctx);
+}
+
+/** @brief Advance usrsctp's clock (fires SCTP retransmit/SACK timers). */
+static void ctx_sctp_timer_cb(void *arg) {
+    cwist_webrtc_ctx *ctx = arg;
+    cwist_webrtc_ctx *prev = ctx_enter(ctx);
+    cwist_sctp_tick();
+    if (ctx->sctp_assocs > 0) ctx_arm_sctp_timer(ctx);
+    ctx_leave(ctx, prev);
+}
+
+/* ---- callbacks used by sctp.c / dtls.c ---- */
+
+void cwist_webrtc_conn_on_message(struct cwist_webrtc_conn *conn, uint16_t channel,
+                                  const uint8_t *data, size_t len, int is_string) {
+    cwist_webrtc_ctx *ctx = conn->ctx;
+    if (ctx->msg_cb)
+        ctx->msg_cb(conn, channel, data, len,
+                    is_string ? CWIST_WEBRTC_DATA_STRING : CWIST_WEBRTC_DATA_BINARY, ctx->msg_user);
+}
+
+void cwist_webrtc_conn_on_channel_open(struct cwist_webrtc_conn *conn, uint16_t channel,
+                                       const char *label) {
+    cwist_webrtc_ctx *ctx = conn->ctx;
+    if (ctx->ch_cb) ctx->ch_cb(conn, channel, label, ctx->ch_user);
+}
+
+void cwist_webrtc_conn_dtls_out(struct cwist_webrtc_conn *conn, const uint8_t *data, size_t len) {
+    ctx_tx_queue(conn->ctx, data, len, &conn->remote);
+}
+
+/** @brief An SCTP packet produced off the owner thread, on its way there. */
+typedef struct {
+    cwist_reactor_post_t post; /**< Post node. */
+    struct cwist_webrtc_conn *conn; /**< Referenced while posted. */
+    size_t len;                /**< Packet length. */
+    uint8_t data[];            /**< Packet. */
+} sctp_out_post;
+
+/** @brief Owner side of a marshalled SCTP packet: encrypt and queue it. */
+static void sctp_out_post_cb(void *arg) {
+    sctp_out_post *p = arg;
+    struct cwist_webrtc_conn *conn = p->conn;
+    cwist_webrtc_ctx *prev = ctx_enter(conn->ctx);
+    if (conn->ssl) SSL_write(conn->ssl, p->data, (int)p->len);
+    ctx_leave(conn->ctx, prev);
+    cwist_webrtc_conn_release(conn);
+    cwist_free(p);
+}
+
+void cwist_webrtc_conn_sctp_out(struct cwist_webrtc_conn *conn, const void *buffer, size_t len) {
+    if (cwist_webrtc_on_owner(conn->ctx)) {
+        /* No detached check: teardown closes SCTP (emitting its ABORT)
+         * before it frees the DTLS session. */
+        if (conn->ssl) SSL_write(conn->ssl, buffer, (int)len);
+        return;
+    }
+    /* A usrsctp timer pass on another thread produced this packet. */
+    sctp_out_post *p = cwist_alloc(sizeof(*p) + len);
+    if (!p) return; /* SCTP retransmits */
+    p->conn = conn;
+    p->len = len;
+    memcpy(p->data, buffer, len);
+    p->post.cb = sctp_out_post_cb;
+    p->post.ctx = p;
+    cwist_webrtc_conn_retain(conn);
+    cwist_reactor_post(conn->ctx->reactor, &p->post);
+}
+
+/* ---- inbound datagrams ---- */
+
+/** @brief Handle a STUN message: Binding request (ICE-lite) or response (client). */
+static void handle_stun(cwist_webrtc_ctx *ctx, const uint8_t *buf, size_t len,
+                        const struct sockaddr_in *remote) {
+    if (cwist_ice_stun_is_binding_request(buf, len)) {
+        if (!atomic_load(&ctx->ice_lite_server))
+            return; /* no offer answered yet: not an ICE-lite endpoint */
+        if (!cwist_ice_stun_validate_request(buf, len, ctx->ice_pwd))
+            return; /* bad MESSAGE-INTEGRITY: drop (RFC 8445 would send a 400) */
+        uint8_t resp[256];
+        int rlen =
+            cwist_ice_stun_build_response(resp, sizeof(resp), buf, len, remote, ctx->ice_pwd);
+        if (rlen < 0) return;
+        ctx_tx_queue(ctx, resp, (size_t)rlen, remote);
+
+        bool nominate = cwist_ice_stun_has_use_candidate(buf, len);
+        struct cwist_webrtc_conn *conn = table_find(ctx, remote);
+        if (conn) {
+            conn->last_rx_ns = ctx->now_ns; /* consent check keeps it alive */
+        } else if (nominate) {
+            /* Adopt the conn parked by cwist_webrtc_handle_offer() when the
+             * USERNAME's remote ufrag matches; otherwise the request is still
+             * authenticated with our password, so start a fresh conn. */
+            char rfrag[64] = "";
+            cwist_ice_stun_get_remote_ufrag(buf, len, rfrag, sizeof(rfrag));
+            for (struct cwist_webrtc_conn *c = ctx->parked; c; c = c->hnext) {
+                if (rfrag[0] && strcmp(c->peer_ufrag, rfrag) == 0) {
+                    conn = c;
+                    break;
+                }
+            }
+            if (conn) {
+                table_remove(ctx, conn); /* the parked list's reference moves */
+            } else {
+                conn = conn_new(ctx, NULL, true);
+                if (!conn) return;
+            }
+            conn->remote = *remote;
+            conn->last_rx_ns = ctx->now_ns;
+            table_insert(ctx, conn);
+        }
+        if (conn && nominate && conn->state == CWIST_CONN_STUN) conn_start_dtls(conn);
+        return;
+    }
+    /* STUN response: only client-role conns expect these. */
+    struct cwist_webrtc_conn *conn = table_find(ctx, remote);
+    if (!conn || conn->is_server_role || conn->state != CWIST_CONN_STUN) return;
+    struct sockaddr_in mapped;
+    if (cwist_ice_stun_parse_response(buf, len, conn->stun_txid, conn->peer_pwd, &mapped)) {
+        conn->last_rx_ns = ctx->now_ns;
+        conn_start_dtls(conn);
+    }
+}
+
+/** @brief Demultiplex one datagram (RFC 7983): STUN or DTLS. */
+static void handle_packet(cwist_webrtc_ctx *ctx, const uint8_t *buf, size_t len,
+                          const struct sockaddr_in *remote) {
+    if (len == 0) return;
+    if (buf[0] <= 3) {
+        if (cwist_ice_stun_is_message(buf, len)) handle_stun(ctx, buf, len, remote);
+        return;
+    }
+    if (buf[0] < 20 || buf[0] > 63) return; /* not DTLS (RTP/RTCP or garbage) */
+    struct cwist_webrtc_conn *conn = table_find(ctx, remote);
+    if (!conn || !conn->ssl || atomic_load(&conn->detached)) return;
+    conn->last_rx_ns = ctx->now_ns;
+    conn_dtls_input(conn, buf, len);
+}
+
+static void udp_readable(int fd, void *payload);
+
+/** @brief Arm the UDP read slot. */
+static bool ctx_arm_udp_slot(cwist_webrtc_ctx *ctx) {
+    if (!ctx->owns_reactor) ctx_retain(ctx);
+    ctx->udp_armed = cwist_reactor_add(ctx->reactor, ctx->udp_fd, udp_readable, &ctx, sizeof(ctx));
+    if (!ctx->udp_armed && !ctx->owns_reactor) ctx_release(ctx);
+    return ctx->udp_armed;
+}
+
+/** @brief Close the UDP socket and wake a waiting cwist_webrtc_ctx_free(). */
+static void ctx_close_fd(cwist_webrtc_ctx *ctx) {
+    if (ctx->udp_fd >= 0) {
+        close(ctx->udp_fd);
+        ctx->udp_fd = -1;
+    }
+    pthread_mutex_lock(&ctx->free_mu);
+    ctx->fd_closed = true;
+    pthread_cond_broadcast(&ctx->free_cv);
+    pthread_mutex_unlock(&ctx->free_mu);
+}
+
+/** @brief Read slot fired: drain the socket in recvmmsg batches. */
+static void udp_readable(int fd, void *payload) {
+    cwist_webrtc_ctx *ctx = *(cwist_webrtc_ctx **)payload;
+    ctx->udp_armed = false;
+    if (ctx->closed) {
+        /* Woken by teardown: the slot is consumed, so the fd can go. */
+        ctx_close_fd(ctx);
+        if (!ctx->owns_reactor) ctx_release(ctx);
+        return;
+    }
+    cwist_webrtc_ctx *prev = ctx_enter(ctx);
+    int handled = 0;
+    while (handled < RX_BUDGET) {
+#ifdef __linux__
+        struct mmsghdr msgs[CWIST_WEBRTC_RX_BATCH];
+        struct iovec iov[CWIST_WEBRTC_RX_BATCH];
+        struct sockaddr_in from[CWIST_WEBRTC_RX_BATCH];
+        for (int i = 0; i < CWIST_WEBRTC_RX_BATCH; i++) {
+            iov[i].iov_base = ctx->rx_bufs + (size_t)i * CWIST_WEBRTC_DGRAM_MAX;
+            iov[i].iov_len = CWIST_WEBRTC_DGRAM_MAX;
+            memset(&msgs[i].msg_hdr, 0, sizeof(msgs[i].msg_hdr));
+            msgs[i].msg_hdr.msg_name = &from[i];
+            msgs[i].msg_hdr.msg_namelen = sizeof(from[i]);
+            msgs[i].msg_hdr.msg_iov = &iov[i];
+            msgs[i].msg_hdr.msg_iovlen = 1;
+        }
+        int n = recvmmsg(fd, msgs, CWIST_WEBRTC_RX_BATCH, MSG_DONTWAIT, NULL);
+        if (n < 0 && errno == EINTR) continue;
+        if (n <= 0) break;
+        for (int i = 0; i < n; i++) {
+            if (msgs[i].msg_hdr.msg_flags & MSG_TRUNC) continue;
+            if (msgs[i].msg_hdr.msg_namelen < sizeof(struct sockaddr_in) ||
+                from[i].sin_family != AF_INET)
+                continue;
+            handle_packet(ctx, iov[i].iov_base, msgs[i].msg_len, &from[i]);
+        }
+        handled += n;
+        if (n < CWIST_WEBRTC_RX_BATCH) break;
+#else
+        struct sockaddr_in from;
+        socklen_t flen = sizeof(from);
+        ssize_t n = recvfrom(fd, ctx->rx_bufs, CWIST_WEBRTC_DGRAM_MAX, MSG_DONTWAIT,
+                             (struct sockaddr *)&from, &flen);
+        if (n < 0 && errno == EINTR) continue;
+        if (n <= 0) break;
+        if (flen >= sizeof(struct sockaddr_in) && from.sin_family == AF_INET)
+            handle_packet(ctx, ctx->rx_bufs, (size_t)n, &from);
+        handled++;
+#endif
+    }
+    if (handled > 0) ctx_note_activity(ctx);
+    ctx_leave(ctx, prev);
+    if (!ctx->closed) {
+        /* The slot that fired held a ref; the new slot takes its own. */
+        ctx_arm_udp_slot(ctx);
+    }
+    if (!ctx->owns_reactor) ctx_release(ctx);
+}
+
+/* ---- host address ---- */
+
+/** @brief Best-guess non-loopback IPv4 address for the SDP host candidate. */
+static void detect_host_ip(char *out, size_t cap) {
+    snprintf(out, cap, "127.0.0.1");
+    struct ifaddrs *ifas = NULL;
+    if (getifaddrs(&ifas) < 0) return;
+    for (struct ifaddrs *ifa = ifas; ifa; ifa = ifa->ifa_next) {
+        if (!ifa->ifa_addr || ifa->ifa_addr->sa_family != AF_INET) continue;
+        struct sockaddr_in *sin = (struct sockaddr_in *)ifa->ifa_addr;
+        uint32_t a = ntohl(sin->sin_addr.s_addr);
+        if (((a >> 24) & 0xFF) == 127) continue;
+        snprintf(out, cap, "%u.%u.%u.%u", (a >> 24) & 0xFF, (a >> 16) & 0xFF, (a >> 8) & 0xFF,
+                 a & 0xFF);
+        break;
+    }
+    freeifaddrs(ifas);
+}
+
+/* ---- ctx lifecycle ---- */
+
+/** @brief Free the ctx memory once the last reference is gone. */
+static void ctx_release(cwist_webrtc_ctx *ctx) {
+    if (atomic_fetch_sub_explicit(&ctx->refs, 1, memory_order_acq_rel) != 1) return;
+    ctx_release_resources(ctx);
+    pthread_mutex_destroy(&ctx->free_mu);
+    pthread_cond_destroy(&ctx->free_cv);
+    cwist_free(ctx);
+}
+
+/** @brief Release everything but the memory: sockets, TLS state, buffers. */
+static void ctx_release_resources(cwist_webrtc_ctx *ctx) {
+    if (ctx->server_ssl_ctx) SSL_CTX_free(ctx->server_ssl_ctx);
+    if (ctx->client_ssl_ctx) SSL_CTX_free(ctx->client_ssl_ctx);
+    if (ctx->cert) X509_free(ctx->cert);
+    if (ctx->pkey) EVP_PKEY_free(ctx->pkey);
+    ctx->server_ssl_ctx = ctx->client_ssl_ctx = NULL;
+    ctx->cert = NULL;
+    ctx->pkey = NULL;
+    cwist_free(ctx->buckets);
+    cwist_free(ctx->rx_bufs);
+    cwist_free(ctx->plain_buf);
+    cwist_free(ctx->sctp_buf);
+    cwist_free(ctx->tx_bufs);
+    ctx->buckets = NULL;
+    ctx->rx_bufs = ctx->plain_buf = ctx->sctp_buf = ctx->tx_bufs = NULL;
+}
+
+/**
+ * @brief Close every conn and stop the ctx's timers (owner thread, or any
+ *        thread once the reactor no longer runs).  Idempotent.
+ */
+static void ctx_teardown_owner(cwist_webrtc_ctx *ctx) {
+    if (ctx->closed) return;
+    cwist_webrtc_ctx *prev = ctx_enter(ctx);
+    while (ctx->parked) conn_teardown(ctx->parked);
+    for (uint32_t i = 0; i < ctx->nbuckets; i++) {
+        while (ctx->buckets[i]) conn_teardown(ctx->buckets[i]);
+    }
+    cwist_reactor_timer_cancel(ctx->reactor, &ctx->sctp_timer);
+    ctx_tx_flush(ctx); /* SCTP ABORTs from the closes */
+    ctx->closed = true;
+    ctx_leave(ctx, prev); /* dirty conns are detached: only their refs drop */
+}
+
+/** @brief Allocate the ctx, bind the socket, create the DTLS identity. */
+static cwist_webrtc_ctx *ctx_create(cwist_reactor_t *reactor, bool owns_reactor, uint16_t port) {
+    if (!reactor || cwist_sctp_global_init() < 0) return NULL;
+    cwist_webrtc_ctx *ctx = cwist_malloc(sizeof(*ctx));
+    if (!ctx) return NULL;
+    ctx->reactor = reactor;
+    ctx->owns_reactor = owns_reactor;
+    ctx->owner_pid = getpid();
+    atomic_init(&ctx->refs, 1);
+    atomic_init(&ctx->ice_lite_server, 0);
+    atomic_init(&ctx->ready_count, 0);
+    ctx->udp_fd = -1;
+    pthread_mutex_init(&ctx->free_mu, NULL);
+    pthread_cond_init(&ctx->free_cv, NULL);
+    cwist_reactor_timer_init(&ctx->sctp_timer, ctx_sctp_timer_cb, ctx);
+
+    ctx->nbuckets = 64;
+    ctx->buckets = cwist_alloc(ctx->nbuckets * sizeof(*ctx->buckets));
+    ctx->rx_bufs = cwist_alloc((size_t)CWIST_WEBRTC_RX_BATCH * CWIST_WEBRTC_DGRAM_MAX);
+    ctx->tx_bufs = cwist_alloc((size_t)CWIST_WEBRTC_TX_BATCH * CWIST_WEBRTC_DGRAM_MAX);
+    ctx->plain_buf = cwist_alloc(CWIST_WEBRTC_MAX_MESSAGE);
+    ctx->sctp_buf = cwist_alloc(CWIST_WEBRTC_MAX_MESSAGE);
+    if (!ctx->buckets || !ctx->rx_bufs || !ctx->tx_bufs || !ctx->plain_buf || !ctx->sctp_buf)
+        goto fail;
+
+    ctx->udp_fd = socket(AF_INET, SOCK_DGRAM, 0);
+    if (ctx->udp_fd < 0) goto fail;
+    int flags = fcntl(ctx->udp_fd, F_GETFL, 0);
+    fcntl(ctx->udp_fd, F_SETFL, flags | O_NONBLOCK);
+    int buf = UDP_SOCKBUF;
+    setsockopt(ctx->udp_fd, SOL_SOCKET, SO_RCVBUF, &buf, sizeof(buf));
+    setsockopt(ctx->udp_fd, SOL_SOCKET, SO_SNDBUF, &buf, sizeof(buf));
+    struct sockaddr_in addr;
+    memset(&addr, 0, sizeof(addr));
+    addr.sin_family = AF_INET;
+    addr.sin_addr.s_addr = htonl(INADDR_ANY);
+    addr.sin_port = htons(port);
+    if (bind(ctx->udp_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) goto fail;
+    socklen_t alen = sizeof(addr);
+    if (getsockname(ctx->udp_fd, (struct sockaddr *)&addr, &alen) < 0) goto fail;
+    ctx->port = ntohs(addr.sin_port);
+
+    if (cwist_dtls_generate_cert(&ctx->cert, &ctx->pkey) < 0) goto fail;
+    if (cwist_dtls_fingerprint(ctx->cert, ctx->fingerprint, sizeof(ctx->fingerprint)) < 0)
+        goto fail;
+    ctx->server_ssl_ctx = cwist_dtls_ctx_new(1, ctx->cert, ctx->pkey);
+    ctx->client_ssl_ctx = cwist_dtls_ctx_new(0, NULL, NULL);
+    if (!ctx->server_ssl_ctx || !ctx->client_ssl_ctx) goto fail;
+
+    cwist_ice_random_creds(ctx->ice_ufrag, sizeof(ctx->ice_ufrag), ctx->ice_pwd,
+                           sizeof(ctx->ice_pwd));
+    detect_host_ip(ctx->host_ip, sizeof(ctx->host_ip));
+    return ctx;
+
+fail:
+    if (ctx->udp_fd >= 0) close(ctx->udp_fd);
+    ctx_release(ctx);
+    return NULL;
+}
+
+/** @brief Run thread of a ctx created by cwist_webrtc_ctx_new(). */
+static void *ctx_thread_main(void *arg) {
+    cwist_webrtc_ctx *ctx = arg;
+    cwist_reactor_run(ctx->reactor);
+    return NULL;
+}
+
+cwist_webrtc_ctx *cwist_webrtc_ctx_new(uint16_t port) {
+    cwist_reactor_t *reactor = cwist_reactor_create();
+    if (!reactor) return NULL;
+    cwist_webrtc_ctx *ctx = ctx_create(reactor, true, port);
+    if (!ctx) {
+        cwist_reactor_destroy(reactor);
+        return NULL;
+    }
+    if (!ctx_arm_udp_slot(ctx) || pthread_create(&ctx->thread, NULL, &ctx_thread_main, ctx) != 0) {
+        cwist_webrtc_ctx_free(ctx);
+        return NULL;
+    }
+    ctx->thread_running = true;
+    return ctx;
+}
+
+cwist_webrtc_ctx *cwist_webrtc_ctx_new_on(cwist_reactor_t *reactor, uint16_t port) {
+    cwist_webrtc_ctx *ctx = ctx_create(reactor, false, port);
+    if (!ctx) return NULL;
+    if (!ctx_arm_udp_slot(ctx)) {
+        close(ctx->udp_fd);
+        ctx->udp_fd = -1;
+        ctx_release(ctx);
+        return NULL;
+    }
+    return ctx;
+}
+
+/**
+ * @brief Teardown on a caller-run reactor (owner thread).  If the read slot
+ *        is pending, a datagram to ourselves wakes it and its callback closes
+ *        the fd; otherwise the fd is closed here.
+ */
+static void ctx_teardown_on_reactor(cwist_webrtc_ctx *ctx) {
+    ctx_teardown_owner(ctx);
+    if (ctx->udp_armed && ctx->udp_fd >= 0) {
+        struct sockaddr_in self;
+        memset(&self, 0, sizeof(self));
+        self.sin_family = AF_INET;
+        self.sin_port = htons(ctx->port);
+        self.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
+        uint8_t b = 0;
+        if (sendto(ctx->udp_fd, &b, 1, 0, (struct sockaddr *)&self, sizeof(self)) == 1) return;
+        /* Could not wake it: cancel the slot and close here. */
+        cwist_reactor_del(ctx->reactor, ctx->udp_fd);
+        ctx->udp_armed = false;
+        ctx_release(ctx); /* the slot's reference */
+    }
+    ctx_close_fd(ctx);
+}
+
+/** @brief Posted teardown for a caller-run reactor. */
+static void ctx_free_post_cb(void *arg) {
+    cwist_webrtc_ctx *ctx = arg;
+    ctx_teardown_on_reactor(ctx);
+    ctx_release(ctx); /* the post's reference */
+}
+
+/** @brief Posted teardown for an owned reactor: close conns, stop the loop. */
+static void ctx_stop_post_cb(void *arg) {
+    cwist_webrtc_ctx *ctx = arg;
+    ctx_teardown_owner(ctx);
+    cwist_reactor_stop(ctx->reactor);
+}
+
+void cwist_webrtc_ctx_free(cwist_webrtc_ctx *ctx) {
+    if (!ctx) return;
+    if (getpid() != ctx->owner_pid) {
+        /* A forked child's copy: its reactor thread exists only in the
+         * parent, and the sockets and memory are the parent's to release.
+         * Leave the copy alone; it goes away with this process. */
+        return;
+    }
+    if (ctx->owns_reactor) {
+        if (ctx->thread_running) {
+            ctx->free_post.cb = ctx_stop_post_cb;
+            ctx->free_post.ctx = ctx;
+            cwist_reactor_post(ctx->reactor, &ctx->free_post);
+            pthread_join(ctx->thread, NULL);
+        }
+        /* The loop is gone (or never ran): finish here.  Destroying the
+         * reactor drains leftover posts, which only drop references. */
+        ctx_teardown_owner(ctx);
+        if (ctx->udp_fd >= 0) {
+            close(ctx->udp_fd);
+            ctx->udp_fd = -1;
+        }
+        cwist_reactor_destroy(ctx->reactor);
+        ctx->reactor = NULL;
+        ctx_release(ctx);
+        return;
+    }
+    if (tl_ctx == ctx) {
+        /* Called from one of our own callbacks: tear down inline; the read
+         * slot's callback finishes the fd close after we return. */
+        ctx_teardown_on_reactor(ctx);
+    } else {
+        ctx_retain(ctx);
+        ctx->free_post.cb = ctx_free_post_cb;
+        ctx->free_post.ctx = ctx;
+        cwist_reactor_post(ctx->reactor, &ctx->free_post);
+        pthread_mutex_lock(&ctx->free_mu);
+        while (!ctx->fd_closed) pthread_cond_wait(&ctx->free_cv, &ctx->free_mu);
+        pthread_mutex_unlock(&ctx->free_mu);
+    }
+    ctx_release(ctx);
+}
+
+/* ---- public accessors ---- */
+
+uint16_t cwist_webrtc_ctx_port(const cwist_webrtc_ctx *ctx) {
+    return ctx->port;
+}
+
+const char *cwist_webrtc_ctx_fingerprint(const cwist_webrtc_ctx *ctx) {
+    return ctx->fingerprint;
+}
+
+void cwist_webrtc_ctx_set_message_handler(cwist_webrtc_ctx *ctx, cwist_webrtc_message_cb cb,
+                                          void *user) {
+    ctx->msg_cb = cb;
+    ctx->msg_user = user;
+}
+
+void cwist_webrtc_ctx_set_channel_handler(cwist_webrtc_ctx *ctx, cwist_webrtc_channel_cb cb,
+                                          void *user) {
+    ctx->ch_cb = cb;
+    ctx->ch_user = user;
+}
+
+void cwist_webrtc_ctx_set_close_handler(cwist_webrtc_ctx *ctx, cwist_webrtc_close_cb cb,
+                                        void *user) {
+    ctx->close_cb = cb;
+    ctx->close_user = user;
+}
+
+int cwist_webrtc_ctx_connection_count(const cwist_webrtc_ctx *ctx) {
+    return atomic_load(&ctx->ready_count);
+}
+
+size_t cwist_webrtc_conn_buffered_amount(const cwist_webrtc_conn *conn) {
+    return atomic_load_explicit(&conn->buffered, memory_order_relaxed);
+}
+
+/* ---- offer / connect (any thread) ---- */
+
+/** @brief Owner side of handle_offer: park the conn until its first nomination. */
+static void park_post_cb(void *arg) {
+    struct cwist_webrtc_conn *conn = arg;
+    cwist_webrtc_ctx *ctx = conn->ctx;
+    if (ctx->closed) {
+        cwist_webrtc_conn_release(conn);
+        return;
+    }
+    cwist_webrtc_ctx *prev = ctx_enter(ctx);
+    conn->hnext = ctx->parked;
+    ctx->parked = conn;
+    conn->registered = true;
+    conn->parked = true; /* the post's reference becomes the list's */
+    conn_rearm_timer(conn);
+    ctx_leave(ctx, prev);
+}
+
+int cwist_webrtc_handle_offer(cwist_webrtc_ctx *ctx, const char *offer, char *answer_out,
+                              size_t out_len) {
+    cwist_sdp_info info;
+    if (!ctx || !offer || cwist_sdp_parse(offer, strlen(offer), &info) < 0) return -1;
+    if (getpid() != ctx->owner_pid)
+        return -1; /* inherited across fork(): this process has no loop for it */
+    const char *mid = info.mid[0] ? info.mid : "0";
+    if (cwist_sdp_write_answer(answer_out, out_len, ctx->fingerprint, ctx->ice_ufrag, ctx->ice_pwd,
+                               mid, ctx->host_ip, ctx->port) < 0)
+        return -1;
+    struct cwist_webrtc_conn *conn = conn_new(ctx, NULL, true);
+    if (!conn) return -1;
+    snprintf(conn->peer_ufrag, sizeof(conn->peer_ufrag), "%s", info.ice_ufrag);
+    snprintf(conn->peer_pwd, sizeof(conn->peer_pwd), "%s", info.ice_pwd);
+    atomic_store(&ctx->ice_lite_server, 1);
+    conn->reg_post.cb = park_post_cb;
+    conn->reg_post.ctx = conn;
+    cwist_reactor_post(ctx->reactor, &conn->reg_post);
+    return 0;
+}
+
+/** @brief Owner side of connect: register and send the first Binding request. */
+static void connect_post_cb(void *arg) {
+    struct cwist_webrtc_conn *conn = arg;
+    cwist_webrtc_ctx *ctx = conn->ctx;
+    if (ctx->closed) {
+        cwist_webrtc_conn_release(conn);
+        return;
+    }
+    cwist_webrtc_ctx *prev = ctx_enter(ctx);
+    table_insert(ctx, conn); /* the post's reference becomes the table's */
+    conn_send_stun_request(conn);
+    conn_rearm_timer(conn);
+    ctx_leave(ctx, prev);
+}
+
+cwist_webrtc_conn *cwist_webrtc_connect(cwist_webrtc_ctx *ctx, const struct sockaddr_in *remote,
+                                        const char *peer_ufrag, const char *peer_pwd) {
+    struct cwist_webrtc_conn *conn = conn_new(ctx, remote, false);
+    if (!conn) return NULL;
+    snprintf(conn->peer_ufrag, sizeof(conn->peer_ufrag), "%s", peer_ufrag);
+    snprintf(conn->peer_pwd, sizeof(conn->peer_pwd), "%s", peer_pwd);
+    RAND_bytes(conn->stun_txid, sizeof(conn->stun_txid));
+    cwist_webrtc_conn_retain(conn); /* returned to the caller */
+    conn->reg_post.cb = connect_post_cb;
+    conn->reg_post.ctx = conn;
+    cwist_reactor_post(ctx->reactor, &conn->reg_post);
+    return conn;
+}
+
+/* ---- send / close (any thread) ---- */
+
+/** @brief Append a message to the pending queue (owner thread). */
+static void conn_enqueue(struct cwist_webrtc_conn *conn, cwist_webrtc_msg *m) {
+    m->next = NULL;
+    if (conn->pending_tail)
+        conn->pending_tail->next = m;
+    else
+        conn->pending_head = m;
+    conn->pending_tail = m;
+}
+
+/**
+ * @brief Owner side of foreign-thread sends: move the whole inbox onto the
+ *        pending queue in arrival order, then flush once at ctx_leave().
+ *
+ * Producers push onto conn->inbox and post the kick only when the inbox was
+ * empty, so a burst of sends from another thread costs one wakeup, one
+ * service pass and one sendmmsg instead of one each.
+ */
+static void kick_post_cb(void *arg) {
+    struct cwist_webrtc_conn *conn = arg;
+    /* acq_rel: producers that see the emptied inbox re-post kick_post, which
+     * must happen after the reactor's reads of it for this run. */
+    cwist_webrtc_msg *list = atomic_exchange_explicit(&conn->inbox, NULL, memory_order_acq_rel);
+    cwist_webrtc_msg *rev = NULL;
+    while (list) {
+        cwist_webrtc_msg *next = list->next;
+        list->next = rev;
+        rev = list;
+        list = next;
+    }
+    if (atomic_load(&conn->detached)) {
+        while (rev) {
+            cwist_webrtc_msg *next = rev->next;
+            atomic_fetch_sub_explicit(&conn->buffered, rev->len, memory_order_relaxed);
+            cwist_free(rev);
+            rev = next;
+        }
+    } else if (rev) {
+        cwist_webrtc_ctx *prev = ctx_enter(conn->ctx);
+        ctx_note_activity(conn->ctx);
+        while (rev) {
+            cwist_webrtc_msg *next = rev->next;
+            conn_enqueue(conn, rev);
+            rev = next;
+        }
+        conn_mark_dirty(conn);
+        ctx_leave(conn->ctx, prev);
+    }
+    cwist_webrtc_conn_release(conn); /* the kick's reference */
+}
+
+/**
+ * @brief Reserve @p len bytes of the conn's send queue.
+ * @return true if it fits under CWIST_WEBRTC_MAX_BUFFERED.
+ */
+static bool conn_reserve(struct cwist_webrtc_conn *conn, size_t len) {
+    size_t before = atomic_fetch_add_explicit(&conn->buffered, len, memory_order_relaxed);
+    if (before + len > CWIST_WEBRTC_MAX_BUFFERED && before > 0) {
+        atomic_fetch_sub_explicit(&conn->buffered, len, memory_order_relaxed);
+        return false;
+    }
+    return true;
+}
+
+int cwist_webrtc_conn_send(cwist_webrtc_conn *conn, uint16_t channel_id, const uint8_t *data,
+                           size_t len, cwist_webrtc_data_type type) {
+    if (!conn || atomic_load(&conn->detached) || len > CWIST_WEBRTC_MAX_MESSAGE ||
+        (len > 0 && !data))
+        return -1;
+    int is_string = type == CWIST_WEBRTC_DATA_STRING;
+    if (cwist_webrtc_on_owner(conn->ctx)) {
+        ctx_note_activity(conn->ctx);
+        if (conn->sctp_ready && !conn->pending_head) {
+            int rc = cwist_sctp_send(conn, channel_id, data, len, is_string);
+            if (rc == 0) return 0;
+            if (rc < 0) return -1;
+        }
+        if (!conn_reserve(conn, len)) return -1;
+        cwist_webrtc_msg *m = cwist_alloc(sizeof(*m) + len);
+        if (!m) {
+            atomic_fetch_sub_explicit(&conn->buffered, len, memory_order_relaxed);
+            return -1;
+        }
+        m->conn = conn;
+        m->len = (uint32_t)len;
+        m->channel = channel_id;
+        m->is_string = (uint8_t)is_string;
+        if (len) memcpy(m->data, data, len);
+        conn_enqueue(conn, m);
+        conn_mark_dirty(conn);
+        return 0;
+    }
+    if (!conn_reserve(conn, len)) return -1;
+    cwist_webrtc_msg *m = cwist_alloc(sizeof(*m) + len);
+    if (!m) {
+        atomic_fetch_sub_explicit(&conn->buffered, len, memory_order_relaxed);
+        return -1;
+    }
+    m->conn = conn;
+    m->len = (uint32_t)len;
+    m->channel = channel_id;
+    m->is_string = (uint8_t)is_string;
+    if (len) memcpy(m->data, data, len);
+    cwist_webrtc_msg *head = atomic_load_explicit(&conn->inbox, memory_order_acquire);
+    do {
+        m->next = head;
+    } while (!atomic_compare_exchange_weak_explicit(&conn->inbox, &head, m, memory_order_acq_rel,
+                                                    memory_order_acquire));
+    if (head == NULL) {
+        /* First message of a batch: wake the owner.  The kick node is reused;
+         * the reactor reads its link before running it, so re-posting from
+         * here while the previous kick runs is safe. */
+        cwist_webrtc_conn_retain(conn);
+        conn->kick_post.cb = kick_post_cb;
+        conn->kick_post.ctx = conn;
+        cwist_reactor_post(conn->ctx->reactor, &conn->kick_post);
+    }
+    return 0;
+}
+
+/** @brief Owner side of a foreign-thread close. */
+static void close_post_cb(void *arg) {
+    struct cwist_webrtc_conn *conn = arg;
+    if (!atomic_load(&conn->detached)) {
+        cwist_webrtc_ctx *prev = ctx_enter(conn->ctx);
+        conn->close_requested = true;
+        conn_mark_dirty(conn);
+        ctx_leave(conn->ctx, prev);
+    }
+    atomic_store(&conn->close_posted, false);
+    cwist_webrtc_conn_release(conn);
+}
+
+void cwist_webrtc_conn_close(cwist_webrtc_conn *conn) {
+    if (!conn || atomic_load(&conn->detached)) return;
+    if (cwist_webrtc_on_owner(conn->ctx)) {
+        /* Deferred to the end of the round: the caller may be inside a
+         * message callback that is still reading this conn's SCTP socket. */
+        conn->close_requested = true;
+        conn_mark_dirty(conn);
+        return;
+    }
+    if (atomic_exchange(&conn->close_posted, true)) return;
+    cwist_webrtc_conn_retain(conn);
+    conn->close_post.cb = close_post_cb;
+    conn->close_post.ctx = conn;
+    cwist_reactor_post(conn->ctx->reactor, &conn->close_post);
+}
diff --git a/src/net/webrtc/webrtc_internal.h b/src/net/webrtc/webrtc_internal.h
new file mode 100644
index 000000000..8b90f9782
--- /dev/null
+++ b/src/net/webrtc/webrtc_internal.h
@@ -0,0 +1,348 @@
+/**
+ * @file webrtc_internal.h
+ * @brief Internal types and cross-file helpers for the WebRTC DataChannel
+ *        module (ICE-lite + DTLS + SCTP over UDP).
+ *
+ * Threading model: every ctx is bound to one cwist reactor, and all mutable
+ * ctx/conn state is touched only on that reactor's run thread (the "owner").
+ * Other threads reach a ctx or conn through cwist_reactor_post() nodes:
+ * cwist_webrtc_conn_send(), cwist_webrtc_conn_close(),
+ * cwist_webrtc_handle_offer() and cwist_webrtc_ctx_free() all post when
+ * called off the owner thread and act inline when called on it.
+ *
+ * Lifetimes are reference counted.  A conn holds a reference on its ctx; the
+ * ctx's connection table holds a reference on each registered conn; every
+ * in-flight post holds a reference on the object it targets.  Teardown
+ * (closing sockets, freeing SSL/SCTP state) happens on the owner thread and is
+ * separate from freeing the memory, which happens on the last release.
+ *
+ * Not part of the public API; only the module's .c files and its tests
+ * include this header.
+ */
+#ifndef __CWIST_WEBRTC_INTERNAL_H__
+#define __CWIST_WEBRTC_INTERNAL_H__
+
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+struct socket; /* usrsctp */
+
+/** Largest DataChannel message accepted either way; advertised in the SDP as
+ *  a=max-message-size. */
+#define CWIST_WEBRTC_MAX_MESSAGE 262144
+/** Per-conn cap on bytes queued by cwist_webrtc_conn_send() and not yet taken
+ *  by SCTP.  Sends beyond it fail instead of growing the queue. */
+#define CWIST_WEBRTC_MAX_BUFFERED (4u << 20)
+/** Largest UDP datagram handled; bigger ones are dropped. */
+#define CWIST_WEBRTC_DGRAM_MAX 2048
+/** Datagrams received per recvmmsg() call. */
+#define CWIST_WEBRTC_RX_BATCH 32
+/** Datagrams queued before a sendmmsg() flush. */
+#define CWIST_WEBRTC_TX_BATCH 64
+
+/** Connection lifecycle. */
+typedef enum {
+    CWIST_CONN_STUN = 0,        /**< Waiting for ICE nomination (or a STUN response, client). */
+    CWIST_CONN_DTLS = 1,        /**< DTLS handshake in flight. */
+    CWIST_CONN_ESTABLISHED = 2, /**< DTLS up; SCTP pending or up. */
+    CWIST_CONN_CLOSED = 3       /**< Torn down. */
+} cwist_conn_state;
+
+/**
+ * @brief A DataChannel message on its way into SCTP.
+ *
+ * Foreign-thread sends travel through the conn's inbox in the same node they
+ * wait in on the pending queue, so a queued send costs one allocation.
+ */
+typedef struct cwist_webrtc_msg {
+    struct cwist_webrtc_msg *next; /**< Inbox / pending-queue link. */
+    struct cwist_webrtc_conn *conn; /**< Target conn (referenced while posted). */
+    uint32_t len;                 /**< Payload length in bytes. */
+    uint16_t channel;             /**< SCTP stream id. */
+    uint8_t is_string;            /**< Non-zero for PPID 50 (string). */
+    uint8_t data[];               /**< Payload. */
+} cwist_webrtc_msg;
+
+/** @brief One peer: ICE state, DTLS session, SCTP association. */
+struct cwist_webrtc_conn {
+    cwist_webrtc_ctx *ctx;   /**< Owning ctx (referenced). */
+    atomic_int refs;         /**< Reference count; memory is freed at zero. */
+    atomic_bool detached;    /**< Set at teardown; API calls fail fast. */
+    atomic_bool close_posted; /**< A foreign close post is in flight. */
+    atomic_size_t buffered;  /**< Bytes queued by sends, not yet taken by SCTP. */
+
+    bool is_server_role;     /**< true: answering (DTLS passive); false: offering. */
+    cwist_conn_state state;  /**< Lifecycle state. */
+    bool registered;         /**< In the ctx table or parked list. */
+    bool parked;             /**< Waiting for its first STUN check (remote unknown). */
+    bool sctp_ready;         /**< SCTP association up; sends go straight to SCTP. */
+    bool close_requested;    /**< Close at the end of the current service pass. */
+    bool dirty;              /**< On the ctx dirty list. */
+    bool notified_open;      /**< The close handler is owed a call. */
+
+    struct sockaddr_in remote;              /**< Nominated peer address. */
+    struct cwist_webrtc_conn *hnext;        /**< Hash-bucket / parked-list link. */
+    struct cwist_webrtc_conn *dirty_next;   /**< Dirty-list link. */
+
+    /* ICE */
+    char peer_ufrag[64];     /**< Remote ICE ufrag. */
+    char peer_pwd[128];      /**< Remote ICE password (client role signs with it). */
+    uint8_t stun_txid[12];   /**< Outstanding Binding request (client role). */
+    int stun_attempts;       /**< Binding requests sent so far (client role). */
+    uint32_t stun_rto_ms;    /**< Current retransmit timeout (client role). */
+
+    /* DTLS */
+    SSL *ssl;                /**< DTLS session (custom datagram BIO). */
+    const uint8_t *dtls_in;  /**< Datagram being fed to the BIO, or NULL. */
+    size_t dtls_in_len;      /**< Length of dtls_in. */
+
+    /* SCTP */
+    struct socket *sctp_sock; /**< Listening (server) or connected (client) socket. */
+    struct socket *sctp_acc;  /**< Accepted association socket (server role). */
+    uint8_t *ch_bits;        /**< 2 bits per stream: bit0 opened by peer, bit1 OPEN sent. */
+    uint32_t ch_bits_len;    /**< Size of ch_bits in bytes. */
+    uint8_t *rx_partial;     /**< Reassembly buffer for a message delivered in pieces. */
+    size_t rx_partial_len;   /**< Bytes held in rx_partial. */
+    size_t rx_partial_cap;   /**< Capacity of rx_partial. */
+    cwist_webrtc_msg *pending_head; /**< Sends waiting for SCTP. */
+    cwist_webrtc_msg *pending_tail; /**< Tail of the pending queue. */
+    _Atomic(cwist_webrtc_msg *) inbox; /**< Foreign-thread sends (LIFO stack). */
+
+    /* Timers and posts */
+    cwist_reactor_timer_t timer;   /**< STUN retransmit / DTLS / liveness / park expiry. */
+    uint64_t created_ns;           /**< Creation time (handshake deadline). */
+    uint64_t last_rx_ns;           /**< Last datagram from the peer (liveness). */
+    cwist_reactor_post_t reg_post;   /**< Registration post (offer/connect). */
+    cwist_reactor_post_t close_post; /**< Foreign-thread close post. */
+    cwist_reactor_post_t kick_post;  /**< Drains the inbox on the owner thread. */
+};
+
+/** @brief A UDP endpoint bound to one reactor, serving many conns. */
+struct cwist_webrtc_ctx {
+    cwist_reactor_t *reactor; /**< Reactor whose run thread owns this ctx. */
+    bool owns_reactor;        /**< Created by cwist_webrtc_ctx_new(); run on our thread. */
+    pid_t owner_pid;          /**< Process that created the ctx (fork detection). */
+    pthread_t thread;         /**< Run thread when owns_reactor. */
+    bool thread_running;      /**< thread was started. */
+    atomic_int refs;          /**< Reference count (user + conns + posts + armed fd slot). */
+
+    int udp_fd;               /**< Non-blocking UDP socket. */
+    uint16_t port;            /**< Bound UDP port. */
+    bool udp_armed;           /**< A read slot is pending on the reactor. */
+    bool closed;              /**< Teardown ran (owner thread). */
+    atomic_int ice_lite_server; /**< An offer was answered: accept Binding requests. */
+
+    X509 *cert;               /**< Ephemeral DTLS certificate. */
+    EVP_PKEY *pkey;           /**< Its private key. */
+    SSL_CTX *server_ssl_ctx;  /**< DTLS server (passive) context. */
+    SSL_CTX *client_ssl_ctx;  /**< DTLS client (active) context. */
+    char fingerprint[128];    /**< SHA-256 fingerprint of cert, SDP form. */
+    char ice_ufrag[16];       /**< Local ICE ufrag. */
+    char ice_pwd[64];         /**< Local ICE password. */
+    char host_ip[64];         /**< Best-guess local IPv4 for the SDP host candidate. */
+
+    cwist_webrtc_message_cb msg_cb; /**< Message handler. */
+    void *msg_user;                 /**< Its user pointer. */
+    cwist_webrtc_channel_cb ch_cb;  /**< Channel-open handler. */
+    void *ch_user;                  /**< Its user pointer. */
+    cwist_webrtc_close_cb close_cb; /**< Connection-closed handler. */
+    void *close_user;               /**< Its user pointer. */
+
+    /* Connection table, keyed by remote address. */
+    cwist_webrtc_conn **buckets; /**< Hash buckets (power of two). */
+    uint32_t nbuckets;           /**< Bucket count. */
+    uint32_t nconns;             /**< Conns in the table. */
+    cwist_webrtc_conn *parked;   /**< Answered offers not yet nominated. */
+    cwist_webrtc_conn *dirty;    /**< Conns to service at the end of this round. */
+    atomic_int ready_count;      /**< Conns with SCTP up. */
+
+    /* SCTP clock */
+    cwist_reactor_timer_t sctp_timer; /**< Drives usrsctp timers while associations exist. */
+    int sctp_assocs;                  /**< Conns holding an SCTP socket. */
+    uint64_t last_activity_ns;        /**< Last datagram or send; picks the tick rate. */
+    bool sctp_fast;                   /**< The tick is armed at the fast rate. */
+
+    /* I/O */
+    uint8_t *rx_bufs;   /**< CWIST_WEBRTC_RX_BATCH datagram buffers. */
+    uint8_t *plain_buf; /**< Decrypted DTLS record buffer. */
+    uint8_t *sctp_buf;  /**< usrsctp_recvv() buffer (one full message). */
+    uint8_t *tx_bufs;   /**< CWIST_WEBRTC_TX_BATCH datagram buffers. */
+    size_t tx_len[CWIST_WEBRTC_TX_BATCH];             /**< Queued datagram lengths. */
+    struct sockaddr_in tx_addr[CWIST_WEBRTC_TX_BATCH]; /**< Queued destinations. */
+    uint32_t tx_n;      /**< Datagrams queued. */
+    uint64_t now_ns;    /**< Monotonic time cached at the start of a round. */
+
+    /* Teardown handshake for ctxs on a caller-run reactor. */
+    cwist_reactor_post_t free_post; /**< Teardown post. */
+    pthread_mutex_t free_mu;        /**< Guards fd_closed. */
+    pthread_cond_t free_cv;         /**< Signals fd_closed. */
+    bool fd_closed;                 /**< UDP socket closed by the owner. */
+};
+
+/** @name STUN message types (ice.c) */
+/**@{*/
+#define CWIST_STUN_BINDING_REQUEST 0x0001  /**< STUN Binding request. */
+#define CWIST_STUN_BINDING_RESPONSE 0x0101 /**< STUN Binding success response. */
+/**@}*/
+
+/** @name ICE-lite STUN (ice.c) */
+/**@{*/
+/** @brief Non-zero if @p buf looks like a STUN message (magic cookie, length). */
+int cwist_ice_stun_is_message(const uint8_t *buf, size_t len);
+/** @brief true if @p buf is a STUN Binding request. */
+bool cwist_ice_stun_is_binding_request(const uint8_t *buf, size_t len);
+/** @brief true if the request carries USE-CANDIDATE (nomination). */
+bool cwist_ice_stun_has_use_candidate(const uint8_t *buf, size_t len);
+/**
+ * @brief Extract the sender's ufrag from USERNAME ("recipient:sender",
+ *        RFC 8445 section 7.2.2) -- the peer's SDP ice-ufrag.
+ * @return 0 on success, -1 if USERNAME is missing or has no ':'.
+ */
+int cwist_ice_stun_get_remote_ufrag(const uint8_t *buf, size_t len, char *out, size_t cap);
+/**
+ * @brief Check MESSAGE-INTEGRITY (HMAC-SHA1 keyed with @p pwd).
+ * @return 1 if valid, 0 otherwise.
+ */
+int cwist_ice_stun_validate_request(const uint8_t *buf, size_t len, const char *pwd);
+/**
+ * @brief Build a signed Binding success response with XOR-MAPPED-ADDRESS.
+ * @return Response length, or -1 if it does not fit in @p cap.
+ */
+int cwist_ice_stun_build_response(uint8_t *out, size_t cap, const uint8_t *req, size_t req_len,
+                                  const struct sockaddr_in *mapped, const char *pwd);
+/**
+ * @brief Build an unsigned Binding request (USERNAME, USE-CANDIDATE, ...).
+ * @return Message length, or -1 if it does not fit in @p cap.
+ */
+int cwist_ice_stun_build_request(uint8_t *out, size_t cap, const uint8_t txid[12],
+                                 const char *username);
+/**
+ * @brief Append MESSAGE-INTEGRITY and FINGERPRINT to a request.
+ * @return New message length, or -1 if it does not fit in @p cap.
+ */
+int cwist_ice_stun_sign_request(uint8_t *msg, size_t cap, size_t msg_len, const char *pwd);
+/**
+ * @brief Validate a response: cookie, txid match, MI with pwd, return mapped address.
+ * @param mapped Filled with the XOR-MAPPED-ADDRESS on success.
+ * @return 1 on success, 0 on validation failure.
+ */
+int cwist_ice_stun_parse_response(const uint8_t *buf, size_t len, const uint8_t txid[12],
+                                  const char *pwd, struct sockaddr_in *mapped);
+/** @brief Generate random ICE credentials (ice-chars only). */
+void cwist_ice_random_creds(char *ufrag, size_t ufrag_cap, char *pwd, size_t pwd_cap);
+/**@}*/
+
+/** @brief Fields the ICE-lite agent needs from an SDP description. */
+typedef struct {
+    char ice_ufrag[64];     /**< Remote ICE ufrag. */
+    char ice_pwd[128];      /**< Remote ICE password. */
+    char fingerprint[128];  /**< Remote DTLS certificate fingerprint. */
+    char setup[16];         /**< a=setup value (e.g. "active", "passive"). */
+    char mid[16];           /**< a=mid value. */
+    int has_lite;           /**< Non-zero if a=ice-lite was present. */
+} cwist_sdp_info;
+
+/** @name SDP (sdp.c) */
+/**@{*/
+/**
+ * @brief Parse the fields the ICE-lite agent needs from an SDP description.
+ * @param info Filled on return; missing fields stay zeroed.
+ * @return 0 on success, -1 if ice-ufrag or ice-pwd is missing or empty.
+ */
+int cwist_sdp_parse(const char *sdp, size_t len, cwist_sdp_info *info);
+/**
+ * @brief Write an ICE-lite, DTLS-passive DataChannel answer.
+ * @return 0 on success, -1 if it does not fit in @p cap.
+ */
+int cwist_sdp_write_answer(char *out, size_t cap, const char *fingerprint, const char *ufrag,
+                           const char *pwd, const char *mid, const char *host, uint16_t port);
+/**
+ * @brief Write a DTLS-active DataChannel offer (loopback tests).
+ * @return 0 on success, -1 if it does not fit in @p cap.
+ */
+int cwist_sdp_write_offer(char *out, size_t cap, const char *fingerprint, const char *ufrag,
+                          const char *pwd, const char *mid);
+/**@}*/
+
+/** @name DTLS (dtls.c) */
+/**@{*/
+/** @brief Generate an ephemeral self-signed P-256 certificate. @return 0 or -1. */
+int cwist_dtls_generate_cert(X509 **cert, EVP_PKEY **pkey);
+/** @brief SHA-256 fingerprint of @p cert in SDP form ("AB:CD:..."). @return 0 or -1. */
+int cwist_dtls_fingerprint(X509 *cert, char *out, size_t cap);
+/** @brief DTLS 1.2 SSL_CTX for the server (with cert) or client role. */
+SSL_CTX *cwist_dtls_ctx_new(int is_server, X509 *cert, EVP_PKEY *pkey);
+/**
+ * @brief Datagram BIO for @p conn: reads return conn->dtls_in once, writes go
+ *        to cwist_webrtc_conn_dtls_out() one datagram per call.
+ */
+BIO *cwist_dtls_bio_new(struct cwist_webrtc_conn *conn);
+/** @brief DTLS link MTU (bytes per datagram) used for handshake fragmentation. */
+unsigned int cwist_dtls_link_mtu(void);
+/**@}*/
+
+/** @name SCTP (sctp.c) */
+/**@{*/
+/** @brief One-time usrsctp initialisation (no threads, AF_CONN only). @return 0. */
+int cwist_sctp_global_init(void);
+/** @brief Advance usrsctp's process-wide timer clock to now. Any thread. */
+void cwist_sctp_tick(void);
+/** @brief Create, bind and listen/connect the conn's SCTP socket. @return 0 or -1. */
+int cwist_sctp_conn_open(struct cwist_webrtc_conn *conn);
+/** @brief Abort the association and release SCTP state. Idempotent. */
+void cwist_sctp_conn_close(struct cwist_webrtc_conn *conn);
+/** @brief Feed one decrypted SCTP packet to usrsctp. */
+void cwist_sctp_conn_input(struct cwist_webrtc_conn *conn, const uint8_t *data, size_t len);
+/**
+ * @brief Accept (server role), then deliver every complete inbound message.
+ * @return 0 normally, -1 if the association is gone and the conn should close.
+ */
+int cwist_sctp_conn_drain(struct cwist_webrtc_conn *conn);
+/** @brief true once the SCTP association is up. */
+bool cwist_sctp_assoc_established(struct cwist_webrtc_conn *conn);
+/**
+ * @brief Send one message, announcing the channel with DCEP OPEN first if we
+ *        are opening it.
+ * @return 0 when SCTP took it, 1 when the send buffer is full (retry after the
+ *         next inbound packet), -1 on a hard error (message dropped).
+ */
+int cwist_sctp_send(struct cwist_webrtc_conn *conn, uint16_t channel, const uint8_t *data,
+                    size_t len, int is_string);
+/**@}*/
+
+/** @name Cross-file helpers (webrtc.c) */
+/**@{*/
+/** @brief true when the calling thread is servicing @p ctx right now. */
+bool cwist_webrtc_on_owner(const cwist_webrtc_ctx *ctx);
+/** @brief Queue one datagram to the conn's peer (owner thread). */
+void cwist_webrtc_conn_dtls_out(struct cwist_webrtc_conn *conn, const uint8_t *data, size_t len);
+/** @brief usrsctp output for @p conn; marshalled to the owner thread when needed. */
+void cwist_webrtc_conn_sctp_out(struct cwist_webrtc_conn *conn, const void *buffer, size_t len);
+/** @brief Deliver a complete inbound message to the message handler. */
+void cwist_webrtc_conn_on_message(struct cwist_webrtc_conn *conn, uint16_t channel,
+                                  const uint8_t *data, size_t len, int is_string);
+/** @brief Report a channel the peer opened to the channel handler. */
+void cwist_webrtc_conn_on_channel_open(struct cwist_webrtc_conn *conn, uint16_t channel,
+                                       const char *label);
+/**
+ * @brief Offering-side dialer used by the loopback tests: registers a client
+ *        conn to @p remote and starts ICE.
+ * @return A conn reference owned by the caller (cwist_webrtc_conn_release()),
+ *         or NULL.
+ */
+cwist_webrtc_conn *cwist_webrtc_connect(cwist_webrtc_ctx *ctx, const struct sockaddr_in *remote,
+                                        const char *peer_ufrag, const char *peer_pwd);
+/**@}*/
+
+#endif
diff --git a/src/sys/io/reactor.c b/src/sys/io/reactor.c
index 013f2b995..b6cc19055 100644
--- a/src/sys/io/reactor.c
+++ b/src/sys/io/reactor.c
@@ -37,6 +37,7 @@
 #include 
 #include 
 #include 
+#include 
 
 #ifdef __linux__
 #include 
@@ -187,6 +188,10 @@ struct cwist_reactor {
     reactor_slot_chunk_t *chunks;
     reactor_event_ctx_t *free_head;  /* Free list threaded through slots. */
     pthread_mutex_t pool_lock;
+    /* Armed one-shot timers, min-heap on deadline_ns.  Run thread only. */
+    cwist_reactor_timer_t **timer_heap;
+    uint32_t timer_n;
+    uint32_t timer_cap;
 #ifdef __linux__
     /* Deferred SQE batching: submissions made by the reactor's own run thread
      * while it dispatches a CQE batch (overwhelmingly connection re-arms, one
@@ -464,6 +469,127 @@ static void reactor_drain_posts(cwist_reactor_t *r) {
     }
 }
 
+/* run flag: cleared by cwist_reactor_stop() from any thread. */
+static bool reactor_running(cwist_reactor_t *r) {
+    return __atomic_load_n(&r->running, __ATOMIC_ACQUIRE) && atomic_load(&g_cwist_running);
+}
+
+/* ---- one-shot timers (min-heap, run thread only) ---- */
+
+/* Upper bound on any poll wait, timers or not: the shutdown flags are only
+ * re-checked between waits (see cwist_reactor_run). */
+#define REACTOR_IDLE_WAIT_NS 100000000ull
+
+static uint64_t reactor_now_ns(void) {
+    struct timespec ts;
+    clock_gettime(CLOCK_MONOTONIC, &ts);
+    return (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec;
+}
+
+static void timer_heap_place(cwist_reactor_t *r, uint32_t i, cwist_reactor_timer_t *t) {
+    r->timer_heap[i] = t;
+    t->heap_slot = i + 1;
+}
+
+static void timer_heap_up(cwist_reactor_t *r, uint32_t i) {
+    cwist_reactor_timer_t *t = r->timer_heap[i];
+    while (i > 0) {
+        uint32_t parent = (i - 1) / 2;
+        if (r->timer_heap[parent]->deadline_ns <= t->deadline_ns) break;
+        timer_heap_place(r, i, r->timer_heap[parent]);
+        i = parent;
+    }
+    timer_heap_place(r, i, t);
+}
+
+static void timer_heap_down(cwist_reactor_t *r, uint32_t i) {
+    cwist_reactor_timer_t *t = r->timer_heap[i];
+    for (;;) {
+        uint32_t child = 2 * i + 1;
+        if (child >= r->timer_n) break;
+        if (child + 1 < r->timer_n &&
+            r->timer_heap[child + 1]->deadline_ns < r->timer_heap[child]->deadline_ns)
+            child++;
+        if (t->deadline_ns <= r->timer_heap[child]->deadline_ns) break;
+        timer_heap_place(r, i, r->timer_heap[child]);
+        i = child;
+    }
+    timer_heap_place(r, i, t);
+}
+
+static void timer_heap_remove(cwist_reactor_t *r, cwist_reactor_timer_t *t) {
+    uint32_t i = t->heap_slot - 1;
+    t->heap_slot = 0;
+    cwist_reactor_timer_t *last = r->timer_heap[--r->timer_n];
+    if (i == r->timer_n) return;
+    timer_heap_place(r, i, last);
+    if (i > 0 && r->timer_heap[(i - 1) / 2]->deadline_ns > last->deadline_ns)
+        timer_heap_up(r, i);
+    else
+        timer_heap_down(r, i);
+}
+
+void cwist_reactor_timer_init(cwist_reactor_timer_t *timer, void (*cb)(void *ctx), void *ctx) {
+    if (!timer) return;
+    timer->deadline_ns = 0;
+    timer->heap_slot = 0;
+    timer->cb = cb;
+    timer->ctx = ctx;
+}
+
+bool cwist_reactor_timer_arm(cwist_reactor_t *r, cwist_reactor_timer_t *timer, uint64_t delay_us) {
+    if (!r || !timer || !timer->cb) return false;
+    if (timer->heap_slot) timer_heap_remove(r, timer);
+    if (r->timer_n == r->timer_cap) {
+        uint32_t cap = r->timer_cap ? r->timer_cap * 2 : 64;
+        cwist_reactor_timer_t **heap = cwist_alloc(cap * sizeof(*heap));
+        if (!heap) return false;
+        if (r->timer_n) memcpy(heap, r->timer_heap, r->timer_n * sizeof(*heap));
+        cwist_free(r->timer_heap);
+        r->timer_heap = heap;
+        r->timer_cap = cap;
+    }
+    timer->deadline_ns = reactor_now_ns() + delay_us * 1000ull;
+    r->timer_n++;
+    timer_heap_place(r, r->timer_n - 1, timer);
+    timer_heap_up(r, r->timer_n - 1);
+    return true;
+}
+
+void cwist_reactor_timer_cancel(cwist_reactor_t *r, cwist_reactor_timer_t *timer) {
+    if (!r || !timer || !timer->heap_slot) return;
+    timer_heap_remove(r, timer);
+}
+
+bool cwist_reactor_timer_armed(const cwist_reactor_timer_t *timer) {
+    return timer && timer->heap_slot != 0;
+}
+
+/* Fire every timer whose deadline has passed.  At most the timers armed on
+ * entry run, so a callback that re-arms itself with a zero delay waits for
+ * the next round instead of spinning here. */
+static void reactor_run_timers(cwist_reactor_t *r) {
+    if (r->timer_n == 0) return;
+    uint64_t now = reactor_now_ns();
+    uint32_t budget = r->timer_n;
+    while (budget-- > 0 && r->timer_n > 0 && r->timer_heap[0]->deadline_ns <= now) {
+        cwist_reactor_timer_t *t = r->timer_heap[0];
+        timer_heap_remove(r, t);
+        t->cb(t->ctx);
+    }
+}
+
+/* Poll wait for this round: the time to the earliest timer, capped at the
+ * idle wait. */
+static uint64_t reactor_wait_ns(const cwist_reactor_t *r) {
+    if (r->timer_n == 0) return REACTOR_IDLE_WAIT_NS;
+    uint64_t now = reactor_now_ns();
+    uint64_t deadline = r->timer_heap[0]->deadline_ns;
+    if (deadline <= now) return 0;
+    uint64_t wait = deadline - now;
+    return wait < REACTOR_IDLE_WAIT_NS ? wait : REACTOR_IDLE_WAIT_NS;
+}
+
 static void reactor_wake_cb(int fd, void *ctx) {
     cwist_reactor_t *r = *(cwist_reactor_t *const *)ctx;
     uint64_t buf[8];
@@ -484,7 +610,7 @@ cwist_reactor_t *cwist_reactor_create(void) {
     cwist_reactor_t *r = cwist_alloc(sizeof(cwist_reactor_t));
     if (!r) return NULL;
     memset(r, 0, sizeof(cwist_reactor_t));
-    r->running = false;
+    __atomic_store_n(&r->running, false, __ATOMIC_RELEASE);
     pthread_mutex_init(&r->pool_lock, NULL);
 #ifdef __linux__
     pthread_mutex_init(&r->impl.sq_lock, NULL);
@@ -625,6 +751,7 @@ void cwist_reactor_destroy(cwist_reactor_t *reactor) {
 #ifdef __linux__
     pthread_mutex_destroy(&reactor->impl.sq_lock);
 #endif
+    cwist_free(reactor->timer_heap);
     reactor_slot_chunk_t *chunk = reactor->chunks;
     while (chunk) {
         reactor_slot_chunk_t *next = chunk->next;
@@ -987,14 +1114,15 @@ static uint64_t reactor_round_budget_us(void) {
 
 void cwist_reactor_run(cwist_reactor_t *reactor) {
     if (!reactor) return;
-    reactor->running = true;
+    __atomic_store_n(&reactor->running, true, __ATOMIC_RELEASE);
 
 #ifdef __linux__
     if (!reactor->impl.use_epoll) {
         reactor->owner = pthread_self();
         const uint64_t cq_grace_ns = reactor_cq_grace_ns();
-        while (reactor->running && atomic_load(&g_cwist_running)) {
+        while (reactor_running(reactor)) {
             reactor_drain_posts(reactor);
+            reactor_run_timers(reactor);
             /* Submit the re-arms queued by the previous dispatch batch under
              * the SQ lock, then wait in a separate call.  The submit enter
              * MUST hold the lock: uring_submit/uring_submit_batch roll the
@@ -1045,7 +1173,11 @@ void cwist_reactor_run(cwist_reactor_t *reactor) {
                 }
             }
 
-            static const struct __kernel_timespec idle_ts = {.tv_sec = 0, .tv_nsec = 100000000};
+            /* Bounded by the idle wait, shortened to the next timer. */
+            const uint64_t wait_ns = reactor_wait_ns(reactor);
+            const struct __kernel_timespec idle_ts = {
+                .tv_sec = (long long)(wait_ns / 1000000000ull),
+                .tv_nsec = (long long)(wait_ns % 1000000000ull)};
             uint32_t to_submit = reactor->sq_unsubmitted;
             reactor->sq_unsubmitted = 0;
             if (to_submit > 0) {
@@ -1177,9 +1309,12 @@ void cwist_reactor_run(cwist_reactor_t *reactor) {
         }
     } else {
         struct epoll_event events[1024];
-        while (reactor->running && atomic_load(&g_cwist_running)) {
+        while (reactor_running(reactor)) {
             reactor_drain_posts(reactor);
-            int n = epoll_wait(reactor->impl.epoll_fd, events, 1024, 100);
+            reactor_run_timers(reactor);
+            /* Round the wait up so a timer is never polled for early. */
+            int wait_ms = (int)((reactor_wait_ns(reactor) + 999999ull) / 1000000ull);
+            int n = epoll_wait(reactor->impl.epoll_fd, events, 1024, wait_ms);
             if (n < 0) {
                 if (errno == EINTR) continue;
                 break;
@@ -1199,9 +1334,12 @@ void cwist_reactor_run(cwist_reactor_t *reactor) {
      * without a signal (cwist_shutdown_request() from another thread) only
      * clears g_cwist_running and closes the listen socket, which wakes
      * no kevent, so the flag must be re-checked periodically. */
-    const struct timespec idle_ts = {.tv_sec = 0, .tv_nsec = 100 * 1000 * 1000};
-    while (reactor->running && atomic_load(&g_cwist_running)) {
+    while (reactor_running(reactor)) {
         reactor_drain_posts(reactor);
+        reactor_run_timers(reactor);
+        const uint64_t wait_ns = reactor_wait_ns(reactor);
+        const struct timespec idle_ts = {.tv_sec = (time_t)(wait_ns / 1000000000ull),
+                                         .tv_nsec = (long)(wait_ns % 1000000000ull)};
         int n = kevent(reactor->impl.kq_fd, NULL, 0, events, 1024, &idle_ts);
         if (n < 0) {
             if (errno == EINTR) continue;
@@ -1220,7 +1358,8 @@ void cwist_reactor_run(cwist_reactor_t *reactor) {
 
 void cwist_reactor_stop(cwist_reactor_t *reactor) {
     if (!reactor) return;
-    reactor->running = false;
+    /* Called from foreign threads (shutdown paths, embedders). */
+    __atomic_store_n(&reactor->running, false, __ATOMIC_RELEASE);
     /* A run thread parked in io_uring_enter(GETEVENTS) on an idle SQPOLL
      * ring sleeps until a CQE arrives: the kernel ignores the enter
      * timeout for SQPOLL rings, so with no pending SQEs the wait never
diff --git a/src/sys/wasi/compat.c b/src/sys/wasi/compat.c
index 9e512944a..ff870d4b3 100644
--- a/src/sys/wasi/compat.c
+++ b/src/sys/wasi/compat.c
@@ -88,6 +88,32 @@ void cwist_reactor_destroy(cwist_reactor_t *reactor) {
     (void)reactor;
 }
 
+void cwist_reactor_timer_init(cwist_reactor_timer_t *timer, void (*cb)(void *ctx), void *ctx) {
+    if (!timer) return;
+    timer->deadline_ns = 0;
+    timer->heap_slot = 0;
+    timer->cb = cb;
+    timer->ctx = ctx;
+}
+
+bool cwist_reactor_timer_arm(cwist_reactor_t *reactor, cwist_reactor_timer_t *timer,
+                             uint64_t delay_us) {
+    (void)reactor;
+    (void)timer;
+    (void)delay_us;
+    return false;
+}
+
+void cwist_reactor_timer_cancel(cwist_reactor_t *reactor, cwist_reactor_timer_t *timer) {
+    (void)reactor;
+    (void)timer;
+}
+
+bool cwist_reactor_timer_armed(const cwist_reactor_timer_t *timer) {
+    (void)timer;
+    return false;
+}
+
 /* --- Metrics / parked-writer fast paths: under WASI 0.2 the real
  * metrics.c and writer_fast.c join the build, so these stubs exist only
  * for preview1 where those units cannot compile. ------------------------ */
diff --git a/tests/bench_webrtc.c b/tests/bench_webrtc.c
new file mode 100644
index 000000000..2846037ef
--- /dev/null
+++ b/tests/bench_webrtc.c
@@ -0,0 +1,182 @@
+/** @file bench_webrtc.c
+ * @brief WebRTC DataChannel micro-benchmark over UDP loopback.
+ *
+ * Measures three things between an answering ICE-lite ctx and an in-process
+ * offering peer:
+ *   1. idle cost: context switches and CPU time of a ctx with no traffic,
+ *   2. one-way throughput for small and large messages,
+ *   3. round-trip latency (p50/p99) of sequential ping/pong.
+ *
+ * Usage: ./bench_webrtc [small_count] [large_count]
+ */
+#include 
+
+#include "../src/net/webrtc/webrtc_internal.h"
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+typedef struct {
+    atomic_long msgs;
+    atomic_long bytes;
+    atomic_int echo;
+} bench_server;
+
+typedef struct {
+    atomic_long pongs;
+} bench_client;
+
+static uint64_t now_ns(void) {
+    struct timespec ts;
+    clock_gettime(CLOCK_MONOTONIC, &ts);
+    return (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec;
+}
+
+static void server_on_message(cwist_webrtc_conn *conn, uint16_t channel, const uint8_t *data,
+                              size_t len, cwist_webrtc_data_type type, void *user) {
+    (void)type;
+    bench_server *s = user;
+    atomic_fetch_add(&s->msgs, 1);
+    atomic_fetch_add(&s->bytes, (long)len);
+    if (atomic_load(&s->echo))
+        cwist_webrtc_conn_send(conn, channel, data, len, CWIST_WEBRTC_DATA_BINARY);
+}
+
+static void client_on_message(cwist_webrtc_conn *conn, uint16_t channel, const uint8_t *data,
+                              size_t len, cwist_webrtc_data_type type, void *user) {
+    (void)type;
+    (void)conn;
+    (void)channel;
+    (void)data;
+    (void)len;
+    bench_client *c = user;
+    atomic_fetch_add(&c->pongs, 1);
+}
+
+/* Retry while the send queue pushes back. */
+static void send_blocking(cwist_webrtc_conn *conn, const uint8_t *data, size_t len) {
+    while (cwist_webrtc_conn_send(conn, 1, data, len, CWIST_WEBRTC_DATA_BINARY) < 0) usleep(50);
+}
+
+/* Spin (yielding) rather than sleep: usleep() granularity would otherwise
+ * dominate the RTT numbers. */
+static int wait_until(atomic_long *v, long target, int timeout_ms) {
+    uint64_t deadline = now_ns() + (uint64_t)timeout_ms * 1000000ull;
+    while (atomic_load(v) < target) {
+        if (now_ns() > deadline) return -1;
+        sched_yield();
+    }
+    return 0;
+}
+
+static void bench_throughput(cwist_webrtc_conn *conn, bench_server *s, long count, size_t size) {
+    uint8_t *buf = malloc(size);
+    memset(buf, 0x5A, size);
+    long base = atomic_load(&s->msgs);
+    uint64_t t0 = now_ns();
+    for (long i = 0; i < count; i++) send_blocking(conn, buf, size);
+    int rc = wait_until(&s->msgs, base + count, 60000);
+    double sec = (double)(now_ns() - t0) / 1e9;
+    long got = atomic_load(&s->msgs) - base;
+    printf("throughput %7zu B x %-7ld: %s%.0f msg/s, %.1f MB/s (%.2fs)\n", size, count,
+           rc ? "TIMEOUT " : "", (double)got / sec, (double)got * (double)size / sec / 1e6, sec);
+    free(buf);
+}
+
+static int cmp_u64(const void *a, const void *b) {
+    uint64_t x = *(const uint64_t *)a, y = *(const uint64_t *)b;
+    return x < y ? -1 : x > y;
+}
+
+static void bench_latency(cwist_webrtc_conn *conn, bench_server *s, bench_client *c, int rounds) {
+    atomic_store(&s->echo, 1);
+    uint64_t *rtt = malloc(sizeof(*rtt) * (size_t)rounds);
+    int done = 0;
+    for (int i = 0; i < rounds; i++) {
+        long base = atomic_load(&c->pongs);
+        uint64_t t0 = now_ns();
+        send_blocking(conn, (const uint8_t *)"ping", 4);
+        if (wait_until(&c->pongs, base + 1, 5000) < 0) break;
+        rtt[done++] = now_ns() - t0;
+    }
+    atomic_store(&s->echo, 0);
+    if (done) {
+        qsort(rtt, (size_t)done, sizeof(*rtt), cmp_u64);
+        printf("latency    rtt x %d: p50 %.1f us, p99 %.1f us, max %.1f us\n", done,
+               (double)rtt[done / 2] / 1e3, (double)rtt[(size_t)done * 99 / 100] / 1e3,
+               (double)rtt[done - 1] / 1e3);
+    }
+    free(rtt);
+}
+
+static void bench_idle(void) {
+    cwist_webrtc_ctx *idle = cwist_webrtc_ctx_new(0);
+    assert(idle);
+    usleep(100000);
+    struct rusage r0, r1;
+    getrusage(RUSAGE_SELF, &r0);
+    usleep(1000000);
+    getrusage(RUSAGE_SELF, &r1);
+    long csw = (r1.ru_nvcsw - r0.ru_nvcsw) + (r1.ru_nivcsw - r0.ru_nivcsw);
+    double cpu_ms = (double)(r1.ru_utime.tv_sec - r0.ru_utime.tv_sec) * 1e3 +
+                    (double)(r1.ru_utime.tv_usec - r0.ru_utime.tv_usec) / 1e3 +
+                    (double)(r1.ru_stime.tv_sec - r0.ru_stime.tv_sec) * 1e3 +
+                    (double)(r1.ru_stime.tv_usec - r0.ru_stime.tv_usec) / 1e3;
+    printf("idle ctx   1s           : %ld context switches, %.2f ms CPU\n", csw, cpu_ms);
+    cwist_webrtc_ctx_free(idle);
+}
+
+int main(int argc, char **argv) {
+    long small_count = argc > 1 ? atol(argv[1]) : 200000;
+    long large_count = argc > 2 ? atol(argv[2]) : 2000;
+
+    bench_idle();
+
+    bench_server s = {0};
+    bench_client c = {0};
+    cwist_webrtc_ctx *server = cwist_webrtc_ctx_new(0);
+    cwist_webrtc_ctx *client = cwist_webrtc_ctx_new(0);
+    assert(server && client);
+    cwist_webrtc_ctx_set_message_handler(server, &server_on_message, &s);
+    cwist_webrtc_ctx_set_message_handler(client, &client_on_message, &c);
+
+    char offer[1024], answer[2048];
+    assert(cwist_sdp_write_offer(offer, sizeof(offer),
+                                 "00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:"
+                                 "44:55:66:77:88:99:AA:BB:CC:DD:EE:FF",
+                                 "benchfrag", "benchpassword0123456789012", "0") == 0);
+    assert(cwist_webrtc_handle_offer(server, offer, answer, sizeof(answer)) == 0);
+    cwist_sdp_info ans;
+    assert(cwist_sdp_parse(answer, strlen(answer), &ans) == 0);
+
+    struct sockaddr_in remote;
+    memset(&remote, 0, sizeof(remote));
+    remote.sin_family = AF_INET;
+    remote.sin_port = htons(cwist_webrtc_ctx_port(server));
+    remote.sin_addr.s_addr = htonl(0x7F000001);
+    cwist_webrtc_conn *conn = cwist_webrtc_connect(client, &remote, ans.ice_ufrag, ans.ice_pwd);
+    assert(conn);
+
+    /* Warm-up: open the channel and wait for the first message to land. */
+    send_blocking(conn, (const uint8_t *)"warm", 4);
+    if (wait_until(&s.msgs, 1, 10000) < 0) {
+        fprintf(stderr, "bench_webrtc: connection did not come up\n");
+        return 1;
+    }
+
+    bench_throughput(conn, &s, small_count, 64);
+    bench_throughput(conn, &s, small_count / 4, 1024);
+    bench_throughput(conn, &s, large_count, 65536);
+    bench_latency(conn, &s, &c, 2000);
+
+    cwist_webrtc_ctx_free(client);
+    cwist_webrtc_ctx_free(server);
+    return 0;
+}
diff --git a/tests/browser/webrtc_chromium.mjs b/tests/browser/webrtc_chromium.mjs
new file mode 100644
index 000000000..3bd42ddae
--- /dev/null
+++ b/tests/browser/webrtc_chromium.mjs
@@ -0,0 +1,150 @@
+// Browser interop check for the WebRTC DataChannel stack.
+//
+// Drives headless Chromium over the DevTools protocol (no Playwright needed;
+// Node >= 22 for the global WebSocket) against the running example server
+// (example/webrtc, POST /offer signaling, DataChannel echo) and checks:
+//   - the DataChannel opens (ICE-lite + DTLS + SCTP + DCEP with a real browser),
+//   - a text message comes back as a string with the same content,
+//   - a 200 000-byte binary message comes back byte for byte,
+//   - an empty string comes back empty,
+// then reports the average round trip over 200 small messages.
+//
+// Usage: node tests/browser/webrtc_chromium.mjs [url]
+//   CHROMIUM=/path/to/chromium overrides the browser binary.
+// `make test_webrtc_browser` builds and starts the example, then runs this.
+import { spawn } from 'node:child_process';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { setTimeout as sleep } from 'node:timers/promises';
+
+const url = process.argv[2] || 'http://localhost:8080/';
+const port = 9300 + Math.floor(Math.random() * 500);
+const profile = mkdtempSync(join(tmpdir(), 'cwist-webrtc-cdp-'));
+const chrome = spawn(process.env.CHROMIUM || 'chromium', [
+  '--headless=new', `--remote-debugging-port=${port}`, '--no-first-run',
+  '--no-default-browser-check', `--user-data-dir=${profile}`, 'about:blank',
+], { stdio: 'ignore' });
+
+async function pageTarget() {
+  for (let i = 0; i < 50; i++) {
+    try {
+      const list = await (await fetch(`http://127.0.0.1:${port}/json`)).json();
+      const page = list.find((t) => t.type === 'page');
+      if (page) return page;
+    } catch {
+      /* not up yet */
+    }
+    await sleep(200);
+  }
+  throw new Error('chromium did not start');
+}
+
+function cdpClient(ws) {
+  let nextId = 0;
+  const pending = new Map();
+  ws.onmessage = (ev) => {
+    const msg = JSON.parse(ev.data);
+    if (msg.id && pending.has(msg.id)) {
+      pending.get(msg.id)(msg);
+      pending.delete(msg.id);
+    }
+  };
+  return (method, params = {}) =>
+    new Promise((resolve) => {
+      const id = ++nextId;
+      pending.set(id, resolve);
+      ws.send(JSON.stringify({ id, method, params }));
+    });
+}
+
+// Runs inside the page.
+const pageScript = `(async () => {
+  const t0 = performance.now();
+  const pc = new RTCPeerConnection();
+  const dc = pc.createDataChannel('echo');
+  dc.binaryType = 'arraybuffer';
+  const opened = new Promise((res, rej) => {
+    dc.onopen = res;
+    setTimeout(() => rej(new Error('open timeout: ice=' + pc.iceConnectionState +
+                                   ' conn=' + pc.connectionState)), 15000);
+  });
+  await pc.setLocalDescription(await pc.createOffer());
+  const r = await fetch('/offer', { method: 'POST', body: pc.localDescription.sdp });
+  if (!r.ok) throw new Error('offer rejected: HTTP ' + r.status);
+  await pc.setRemoteDescription({ type: 'answer', sdp: await r.text() });
+  await opened;
+  const openMs = performance.now() - t0;
+
+  const inbox = [];
+  let wake = null;
+  dc.onmessage = (ev) => { inbox.push(ev.data); if (wake) wake(); };
+  const next = () => new Promise((res, rej) => {
+    if (inbox.length) return res(inbox.shift());
+    const timer = setTimeout(() => rej(new Error('echo timeout')), 10000);
+    wake = () => { wake = null; clearTimeout(timer); res(inbox.shift()); };
+  });
+
+  dc.send('hello cwist');
+  const text = await next();
+
+  const big = new Uint8Array(200000);
+  for (let i = 0; i < big.length; i++) big[i] = (i * 31) & 255;
+  dc.send(big);
+  const bigEcho = await next();
+  const bigView = bigEcho instanceof ArrayBuffer ? new Uint8Array(bigEcho) : null;
+  let bigOk = !!bigView && bigView.length === big.length;
+  for (let i = 0; bigOk && i < big.length; i++) if (bigView[i] !== big[i]) bigOk = false;
+
+  dc.send('');
+  const empty = await next();
+
+  const rt0 = performance.now();
+  for (let i = 0; i < 200; i++) { dc.send('p' + i); await next(); }
+  const rttMs = (performance.now() - rt0) / 200;
+  pc.close();
+  return JSON.stringify({
+    open_ms: Math.round(openMs),
+    text_type: typeof text, text,
+    big_ok: bigOk, big_len: bigView ? bigView.length : -1,
+    empty_type: typeof empty, empty_len: typeof empty === 'string' ? empty.length : -1,
+    avg_rtt_ms: Number(rttMs.toFixed(3)),
+  });
+})()`;
+
+let failed = false;
+try {
+  const page = await pageTarget();
+  const ws = new WebSocket(page.webSocketDebuggerUrl);
+  await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
+  const send = cdpClient(ws);
+  await send('Page.enable');
+  await send('Page.navigate', { url });
+  await sleep(1000);
+  const res = await send('Runtime.evaluate', {
+    expression: pageScript, awaitPromise: true, returnByValue: true,
+  });
+  ws.close();
+  if (res.result.exceptionDetails) {
+    const d = res.result.exceptionDetails;
+    throw new Error(d.exception?.description || d.text);
+  }
+  const out = JSON.parse(res.result.result.value);
+  console.log('webrtc_chromium:', JSON.stringify(out));
+  const checks = [
+    ['text echoed as a string', out.text_type === 'string' && out.text === 'hello cwist'],
+    ['200000-byte binary echoed intact', out.big_ok],
+    ['empty string echoed', out.empty_type === 'string' && out.empty_len === 0],
+  ];
+  for (const [name, ok] of checks) {
+    console.log(`  ${ok ? 'ok  ' : 'FAIL'} ${name}`);
+    if (!ok) failed = true;
+  }
+} catch (err) {
+  console.log('webrtc_chromium: FAIL', err.message);
+  failed = true;
+} finally {
+  chrome.kill('SIGKILL');
+  rmSync(profile, { recursive: true, force: true });
+}
+process.exitCode = failed ? 1 : 0;
diff --git a/tests/test_reactor_timer.c b/tests/test_reactor_timer.c
new file mode 100644
index 000000000..674e96c42
--- /dev/null
+++ b/tests/test_reactor_timer.c
@@ -0,0 +1,132 @@
+#ifndef _GNU_SOURCE
+#define _GNU_SOURCE
+#endif
+/* Reactor one-shot timer test.  Includes the implementation like
+ * test_reactor_wake.c so it runs without the rest of libcwist; run it once per
+ * backend (CWIST_REACTOR_BACKEND=epoll forces epoll on Linux).
+ *
+ * Checks: timers fire in deadline order regardless of arm order, never early;
+ * a cancelled timer never fires; re-arming replaces the deadline; a callback
+ * can re-arm itself.
+ */
+#include 
+#include 
+#include 
+#include 
+#include "../src/sys/io/reactor.c"
+
+void *cwist_alloc(size_t size) {
+    return calloc(1, size);
+}
+void cwist_free(void *ptr) {
+    free(ptr);
+}
+atomic_int g_cwist_running = 1;
+
+static cwist_reactor_t *loop;
+static uint64_t start_ns;
+
+typedef struct {
+    cwist_reactor_timer_t timer;
+    uint64_t delay_us;
+    uint64_t fired_ns;
+    int order;
+} probe_t;
+
+static int fired_count;
+static probe_t probes[5];
+static cwist_reactor_timer_t cancelled, rearmed, repeat, stopper;
+static int cancelled_fired, rearmed_fired, repeat_left = 5;
+static uint64_t rearmed_fired_ns;
+
+static void probe_cb(void *ctx) {
+    probe_t *p = ctx;
+    p->fired_ns = reactor_now_ns();
+    p->order = fired_count++;
+}
+
+static void cancelled_cb(void *ctx) {
+    (void)ctx;
+    cancelled_fired = 1;
+}
+
+static void rearmed_cb(void *ctx) {
+    (void)ctx;
+    rearmed_fired++;
+    rearmed_fired_ns = reactor_now_ns();
+}
+
+static void repeat_cb(void *ctx) {
+    (void)ctx;
+    if (--repeat_left > 0) assert(cwist_reactor_timer_arm(loop, &repeat, 2000));
+}
+
+static void stop_cb(void *ctx) {
+    (void)ctx;
+    cwist_reactor_stop(loop);
+}
+
+static void *run_loop(void *arg) {
+    (void)arg;
+    cwist_reactor_run(loop);
+    return NULL;
+}
+
+int main(void) {
+    loop = cwist_reactor_create();
+    assert(loop);
+    start_ns = reactor_now_ns();
+
+    /* Arm out of deadline order. */
+    const uint64_t delays_us[5] = {40000, 10000, 30000, 5000, 20000};
+    for (int i = 0; i < 5; i++) {
+        probes[i].delay_us = delays_us[i];
+        cwist_reactor_timer_init(&probes[i].timer, probe_cb, &probes[i]);
+        assert(cwist_reactor_timer_arm(loop, &probes[i].timer, delays_us[i]));
+    }
+    cwist_reactor_timer_init(&cancelled, cancelled_cb, NULL);
+    assert(cwist_reactor_timer_arm(loop, &cancelled, 15000));
+    cwist_reactor_timer_cancel(loop, &cancelled);
+    assert(!cwist_reactor_timer_armed(&cancelled));
+
+    /* Re-arm pushes the deadline out: must fire once, near 50 ms. */
+    cwist_reactor_timer_init(&rearmed, rearmed_cb, NULL);
+    assert(cwist_reactor_timer_arm(loop, &rearmed, 1000));
+    assert(cwist_reactor_timer_arm(loop, &rearmed, 50000));
+
+    cwist_reactor_timer_init(&repeat, repeat_cb, NULL);
+    assert(cwist_reactor_timer_arm(loop, &repeat, 2000));
+
+    cwist_reactor_timer_init(&stopper, stop_cb, NULL);
+    assert(cwist_reactor_timer_arm(loop, &stopper, 80000));
+
+    pthread_t th;
+    assert(pthread_create(&th, NULL, run_loop, NULL) == 0);
+    assert(pthread_join(th, NULL) == 0);
+
+    assert(fired_count == 5);
+    for (int i = 0; i < 5; i++) {
+        uint64_t elapsed_us = (probes[i].fired_ns - start_ns) / 1000;
+        assert(elapsed_us >= probes[i].delay_us);
+        /* Generous lateness bound for loaded CI runners. */
+        assert(elapsed_us < probes[i].delay_us + 50000);
+        for (int j = 0; j < 5; j++) {
+            if (probes[j].delay_us < probes[i].delay_us) assert(probes[j].order < probes[i].order);
+        }
+    }
+    assert(!cancelled_fired);
+    assert(rearmed_fired == 1);
+    assert((rearmed_fired_ns - start_ns) / 1000 >= 50000);
+    assert(repeat_left == 0);
+    assert(loop->timer_n == 0);
+
+    cwist_reactor_destroy(loop);
+    printf("test_reactor_timer: all assertions passed (%s)\n",
+#ifdef __linux__
+           getenv("CWIST_REACTOR_BACKEND") ? getenv("CWIST_REACTOR_BACKEND") : "default"
+#else
+           "kqueue"
+#endif
+    );
+    return 0;
+}
diff --git a/tests/test_webrtc.c b/tests/test_webrtc.c
new file mode 100644
index 000000000..d880c3b40
--- /dev/null
+++ b/tests/test_webrtc.c
@@ -0,0 +1,400 @@
+/** @file test_webrtc.c
+ * @brief End-to-end WebRTC DataChannel tests over UDP loopback.
+ *
+ * Drives full ICE + DTLS + SCTP exchanges between in-process cwist webrtc
+ * contexts (an answering ICE-lite endpoint and an offering peer built on the
+ * internal client role) and checks:
+ *   - STUN and SDP building blocks,
+ *   - ping/pong in both directions, channel-open notification,
+ *   - a message near the 256 KiB limit (SCTP partial delivery reassembly)
+ *     and rejection of one over it,
+ *   - the 4 MiB send-queue cap while SCTP is not up,
+ *   - close from a foreign thread reaching the peer's close handler,
+ *   - a ctx attached to a caller-run reactor (cwist_webrtc_ctx_new_on) and
+ *     freed from another thread.
+ */
+#include 
+#include 
+
+#include "../src/net/webrtc/webrtc_internal.h"
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#define BIG_LEN 200000
+
+typedef struct test_peer {
+    atomic_int got_ping;
+    atomic_int got_pong;
+    atomic_int got_channel;
+    atomic_int got_big;
+    atomic_int got_text;       /* "hi" received as a string */
+    atomic_int got_empty_text; /* empty string */
+    atomic_int got_empty_bin;  /* empty binary */
+    atomic_int closed;
+    char label[64];
+} test_peer;
+
+static uint64_t now_ms(void) {
+    struct timespec ts;
+    clock_gettime(CLOCK_MONOTONIC, &ts);
+    return (uint64_t)ts.tv_sec * 1000ull + (uint64_t)ts.tv_nsec / 1000000ull;
+}
+
+/* Wait until *flag reaches want, up to timeout_ms. */
+static int wait_flag(atomic_int *flag, int want, int timeout_ms) {
+    uint64_t end = now_ms() + (uint64_t)timeout_ms;
+    while (atomic_load(flag) < want) {
+        if (now_ms() > end) return -1;
+        usleep(1000);
+    }
+    return 0;
+}
+
+static int big_payload_ok(const uint8_t *data, size_t len) {
+    if (len != BIG_LEN) return 0;
+    for (size_t i = 0; i < len; i++) {
+        if (data[i] != (uint8_t)(i * 31u)) return 0;
+    }
+    return 1;
+}
+
+/* Track string/empty messages; echo them back with the same type. */
+static int note_typed(test_peer *p, cwist_webrtc_conn *conn, uint16_t channel, const uint8_t *data,
+                      size_t len, cwist_webrtc_data_type type, int echo) {
+    if (len == 0) {
+        atomic_fetch_add(type == CWIST_WEBRTC_DATA_STRING ? &p->got_empty_text : &p->got_empty_bin,
+                         1);
+    } else if (len == 2 && memcmp(data, "hi", 2) == 0 && type == CWIST_WEBRTC_DATA_STRING) {
+        atomic_fetch_add(&p->got_text, 1);
+    } else {
+        return 0;
+    }
+    if (echo) assert(cwist_webrtc_conn_send(conn, channel, data, len, type) == 0);
+    return 1;
+}
+
+static void server_on_message(cwist_webrtc_conn *conn, uint16_t channel, const uint8_t *data,
+                              size_t len, cwist_webrtc_data_type type, void *user) {
+    test_peer *p = user;
+    if (note_typed(p, conn, channel, data, len, type, 1)) return;
+    if (len == 4 && memcmp(data, "ping", 4) == 0) {
+        atomic_fetch_add(&p->got_ping, 1);
+        /* Reply on the same channel from inside the callback (owner path). */
+        assert(cwist_webrtc_conn_send(conn, channel, (const uint8_t *)"pong", 4,
+                                      CWIST_WEBRTC_DATA_BINARY) == 0);
+    } else if (big_payload_ok(data, len)) {
+        atomic_fetch_add(&p->got_big, 1);
+        /* Echo the big message back: exercises the owner-side queue. */
+        assert(cwist_webrtc_conn_send(conn, channel, data, len, CWIST_WEBRTC_DATA_BINARY) == 0);
+    }
+}
+
+static void client_on_message(cwist_webrtc_conn *conn, uint16_t channel, const uint8_t *data,
+                              size_t len, cwist_webrtc_data_type type, void *user) {
+    test_peer *p = user;
+    if (note_typed(p, conn, channel, data, len, type, 0)) return;
+    if (len == 4 && memcmp(data, "pong", 4) == 0)
+        atomic_fetch_add(&p->got_pong, 1);
+    else if (big_payload_ok(data, len))
+        atomic_fetch_add(&p->got_big, 1);
+}
+
+static void peer_on_channel(cwist_webrtc_conn *conn, uint16_t channel, const char *label,
+                            void *user) {
+    test_peer *p = user;
+    (void)conn;
+    (void)channel;
+    snprintf(p->label, sizeof(p->label), "%s", label);
+    atomic_store(&p->got_channel, 1);
+}
+
+static void peer_on_close(cwist_webrtc_conn *conn, void *user) {
+    test_peer *p = user;
+    (void)conn;
+    atomic_fetch_add(&p->closed, 1);
+}
+
+/* Answer an offer on @p server and dial it from @p client.  The offer carries
+ * the client ctx's real ICE credentials, as a browser's would, so the server
+ * adopts the conn it parked for the offer. */
+static cwist_webrtc_conn *dial(cwist_webrtc_ctx *server, cwist_webrtc_ctx *client) {
+    char offer[1024];
+    assert(cwist_sdp_write_offer(offer, sizeof(offer),
+                                 "00:11:22:33:44:55:66:77:88:99:"
+                                 "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55",
+                                 client->ice_ufrag, client->ice_pwd, "0") == 0);
+    char answer[2048];
+    assert(cwist_webrtc_handle_offer(server, offer, answer, sizeof(answer)) == 0);
+    assert(strstr(answer, "a=ice-lite") != NULL);
+    assert(strstr(answer, "a=setup:passive") != NULL);
+    assert(strstr(answer, "webrtc-datachannel") != NULL);
+    assert(strstr(answer, "a=candidate:") != NULL);
+    cwist_sdp_info ans;
+    assert(cwist_sdp_parse(answer, strlen(answer), &ans) == 0);
+
+    struct sockaddr_in remote;
+    memset(&remote, 0, sizeof(remote));
+    remote.sin_family = AF_INET;
+    remote.sin_port = htons(cwist_webrtc_ctx_port(server));
+    remote.sin_addr.s_addr = htonl(0x7F000001);
+    cwist_webrtc_conn *conn = cwist_webrtc_connect(client, &remote, ans.ice_ufrag, ans.ice_pwd);
+    assert(conn != NULL);
+    return conn;
+}
+
+typedef struct {
+    cwist_reactor_post_t post;
+    cwist_webrtc_ctx *ctx;
+    atomic_int done;
+    int parked;
+    uint32_t nconns;
+} table_probe;
+
+static void table_probe_cb(void *arg) {
+    table_probe *p = arg;
+    p->parked = 0;
+    for (cwist_webrtc_conn *c = p->ctx->parked; c; c = c->hnext) p->parked++;
+    p->nconns = p->ctx->nconns;
+    atomic_store(&p->done, 1);
+}
+
+/* Read the server's connection table on its reactor thread. */
+static void probe_table(cwist_webrtc_ctx *ctx, int *parked, uint32_t *nconns) {
+    table_probe p = {.ctx = ctx};
+    p.post.cb = table_probe_cb;
+    p.post.ctx = &p;
+    assert(cwist_reactor_post(ctx->reactor, &p.post));
+    assert(wait_flag(&p.done, 1, 5000) == 0);
+    *parked = p.parked;
+    *nconns = p.nconns;
+}
+
+static void unit_checks(void) {
+    /* ICE/STUN: build, sign, validate, respond. */
+    {
+        uint8_t req[512];
+        uint8_t txid[12];
+        memset(txid, 0xAB, sizeof(txid));
+        int len = cwist_ice_stun_build_request(req, sizeof(req), txid, "rfrag:lfrag");
+        assert(len > 0);
+        len = cwist_ice_stun_sign_request(req, sizeof(req), (size_t)len, "testpassword0123456789");
+        assert(len > 0);
+        assert(cwist_ice_stun_is_message(req, (size_t)len));
+        assert(cwist_ice_stun_is_binding_request(req, (size_t)len));
+        assert(cwist_ice_stun_has_use_candidate(req, (size_t)len));
+        assert(cwist_ice_stun_validate_request(req, (size_t)len, "testpassword0123456789") == 1);
+        assert(cwist_ice_stun_validate_request(req, (size_t)len, "wrongpassword000000000") == 0);
+        char rfrag[64];
+        /* USERNAME is ":"; the sender's ufrag comes back. */
+        assert(cwist_ice_stun_get_remote_ufrag(req, (size_t)len, rfrag, sizeof(rfrag)) == 0);
+        assert(strcmp(rfrag, "lfrag") == 0);
+
+        struct sockaddr_in mapped;
+        memset(&mapped, 0, sizeof(mapped));
+        mapped.sin_family = AF_INET;
+        mapped.sin_port = htons(54321);
+        mapped.sin_addr.s_addr = htonl(0x7F000001);
+        uint8_t resp[512];
+        int rlen = cwist_ice_stun_build_response(resp, sizeof(resp), req, (size_t)len, &mapped,
+                                                 "testpassword0123456789");
+        assert(rlen > 0);
+        struct sockaddr_in parsed;
+        assert(cwist_ice_stun_parse_response(resp, (size_t)rlen, txid, "testpassword0123456789",
+                                             &parsed) == 1);
+        assert(parsed.sin_port == htons(54321));
+        assert(parsed.sin_addr.s_addr == htonl(0x7F000001));
+    }
+
+    /* SDP. */
+    {
+        char offer[1024];
+        assert(cwist_sdp_write_offer(offer, sizeof(offer),
+                                     "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:"
+                                     "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99",
+                                     "offrfrag", "offpassword0123456789012", "0") == 0);
+        cwist_sdp_info info;
+        assert(cwist_sdp_parse(offer, strlen(offer), &info) == 0);
+        assert(strcmp(info.ice_ufrag, "offrfrag") == 0);
+        assert(strcmp(info.ice_pwd, "offpassword0123456789012") == 0);
+        assert(strcmp(info.setup, "active") == 0);
+        assert(info.has_lite == 0);
+    }
+}
+
+/* Ping/pong, channel open, big message, close handler: both ctxs on their
+ * own reactor threads. */
+static void test_end_to_end(void) {
+    cwist_webrtc_ctx *server = cwist_webrtc_ctx_new(0);
+    assert(server != NULL);
+    assert(cwist_webrtc_ctx_port(server) > 0);
+    assert(strchr(cwist_webrtc_ctx_fingerprint(server), ':') != NULL);
+    test_peer *sp = calloc(1, sizeof(*sp));
+    test_peer *cp = calloc(1, sizeof(*cp));
+    cwist_webrtc_ctx_set_message_handler(server, &server_on_message, sp);
+    cwist_webrtc_ctx_set_channel_handler(server, &peer_on_channel, sp);
+    cwist_webrtc_ctx_set_close_handler(server, &peer_on_close, sp);
+
+    cwist_webrtc_ctx *client = cwist_webrtc_ctx_new(0);
+    assert(client != NULL);
+    cwist_webrtc_ctx_set_message_handler(client, &client_on_message, cp);
+    cwist_webrtc_ctx_set_close_handler(client, &peer_on_close, cp);
+
+    cwist_webrtc_conn *conn = dial(server, client);
+
+    /* Queued before SCTP is up; flushed once the association comes up. */
+    assert(cwist_webrtc_conn_send(conn, 1, (const uint8_t *)"ping", 4, CWIST_WEBRTC_DATA_BINARY) ==
+           0);
+    assert(wait_flag(&sp->got_ping, 1, 10000) == 0);
+    assert(wait_flag(&sp->got_channel, 1, 1000) == 0);
+    assert(strcmp(sp->label, "dc1") == 0);
+    assert(wait_flag(&cp->got_pong, 1, 5000) == 0);
+    assert(cwist_webrtc_ctx_connection_count(server) == 1);
+    assert(cwist_webrtc_ctx_connection_count(client) == 1);
+    /* The conn parked by handle_offer was adopted, not duplicated. */
+    int parked;
+    uint32_t nconns;
+    probe_table(server, &parked, &nconns);
+    assert(parked == 0);
+    assert(nconns == 1);
+
+    /* Second exchange on the established channel. */
+    assert(cwist_webrtc_conn_send(conn, 1, (const uint8_t *)"ping", 4, CWIST_WEBRTC_DATA_BINARY) ==
+           0);
+    assert(wait_flag(&sp->got_ping, 2, 5000) == 0);
+    assert(wait_flag(&cp->got_pong, 2, 5000) == 0);
+
+    /* Large message both ways: more than one SCTP read, reassembled. */
+    uint8_t *big = malloc(BIG_LEN);
+    for (size_t i = 0; i < BIG_LEN; i++) big[i] = (uint8_t)(i * 31u);
+    assert(cwist_webrtc_conn_send(conn, 1, big, BIG_LEN, CWIST_WEBRTC_DATA_BINARY) == 0);
+    assert(wait_flag(&sp->got_big, 1, 10000) == 0);
+    assert(wait_flag(&cp->got_big, 1, 10000) == 0);
+    free(big);
+
+    /* Message types survive the round trip, including empty messages
+     * (RFC 8831 empty PPIDs). */
+    assert(cwist_webrtc_conn_send(conn, 1, (const uint8_t *)"hi", 2, CWIST_WEBRTC_DATA_STRING) ==
+           0);
+    assert(cwist_webrtc_conn_send(conn, 1, NULL, 0, CWIST_WEBRTC_DATA_STRING) == 0);
+    assert(cwist_webrtc_conn_send(conn, 1, NULL, 0, CWIST_WEBRTC_DATA_BINARY) == 0);
+    assert(wait_flag(&sp->got_text, 1, 5000) == 0);
+    assert(wait_flag(&sp->got_empty_text, 1, 5000) == 0);
+    assert(wait_flag(&sp->got_empty_bin, 1, 5000) == 0);
+    assert(wait_flag(&cp->got_text, 1, 5000) == 0);
+    assert(wait_flag(&cp->got_empty_text, 1, 5000) == 0);
+    assert(wait_flag(&cp->got_empty_bin, 1, 5000) == 0);
+
+    /* Over the advertised max-message-size: rejected up front. */
+    uint8_t *huge = calloc(1, CWIST_WEBRTC_MAX_MESSAGE + 1);
+    assert(cwist_webrtc_conn_send(conn, 1, huge, CWIST_WEBRTC_MAX_MESSAGE + 1,
+                                  CWIST_WEBRTC_DATA_BINARY) == -1);
+    free(huge);
+
+    /* Close from this (foreign) thread: our close handler runs, the SCTP
+     * ABORT reaches the server and its close handler runs too. */
+    cwist_webrtc_conn_close(conn);
+    assert(wait_flag(&cp->closed, 1, 5000) == 0);
+    assert(wait_flag(&sp->closed, 1, 5000) == 0);
+    assert(cwist_webrtc_ctx_connection_count(client) == 0);
+    assert(cwist_webrtc_ctx_connection_count(server) == 0);
+    /* The retained conn outlives its teardown; sends now fail fast. */
+    assert(cwist_webrtc_conn_send(conn, 1, (const uint8_t *)"late", 4, CWIST_WEBRTC_DATA_BINARY) ==
+           -1);
+    cwist_webrtc_conn_release(conn);
+
+    cwist_webrtc_ctx_free(client);
+    cwist_webrtc_ctx_free(server);
+    free(sp);
+    free(cp);
+}
+
+/* Sends queue while SCTP is down, up to the 4 MiB cap. */
+static void test_send_queue_cap(void) {
+    cwist_webrtc_ctx *server = cwist_webrtc_ctx_new(0);
+    cwist_webrtc_ctx *client = cwist_webrtc_ctx_new(0);
+    assert(server && client);
+    /* No offer answered on server: it ignores our Binding requests, so the
+     * conn stays in ICE and every send is queued. */
+    struct sockaddr_in remote;
+    memset(&remote, 0, sizeof(remote));
+    remote.sin_family = AF_INET;
+    remote.sin_port = htons(cwist_webrtc_ctx_port(server));
+    remote.sin_addr.s_addr = htonl(0x7F000001);
+    cwist_webrtc_conn *conn = cwist_webrtc_connect(client, &remote, "nouser", "nopassword");
+    assert(conn);
+
+    static uint8_t chunk[65536];
+    size_t accepted = 0;
+    int rc;
+    while ((rc = cwist_webrtc_conn_send(conn, 1, chunk, sizeof(chunk), CWIST_WEBRTC_DATA_BINARY)) ==
+           0)
+        accepted += sizeof(chunk);
+    assert(accepted == CWIST_WEBRTC_MAX_BUFFERED);
+    assert(cwist_webrtc_conn_buffered_amount(conn) == CWIST_WEBRTC_MAX_BUFFERED);
+
+    cwist_webrtc_ctx_free(client);
+    /* Teardown dropped the queue. */
+    assert(cwist_webrtc_conn_buffered_amount(conn) == 0);
+    cwist_webrtc_conn_release(conn);
+    cwist_webrtc_ctx_free(server);
+}
+
+static void *reactor_main(void *arg) {
+    cwist_reactor_run(arg);
+    return NULL;
+}
+
+/* A server ctx on a reactor this test runs itself, freed from this thread
+ * while the reactor keeps running. */
+static void test_external_reactor(void) {
+    cwist_reactor_t *reactor = cwist_reactor_create();
+    assert(reactor);
+    cwist_webrtc_ctx *server = cwist_webrtc_ctx_new_on(reactor, 0);
+    assert(server);
+    test_peer *sp = calloc(1, sizeof(*sp));
+    test_peer *cp = calloc(1, sizeof(*cp));
+    cwist_webrtc_ctx_set_message_handler(server, &server_on_message, sp);
+    cwist_webrtc_ctx_set_close_handler(server, &peer_on_close, sp);
+    pthread_t th;
+    assert(pthread_create(&th, NULL, reactor_main, reactor) == 0);
+
+    cwist_webrtc_ctx *client = cwist_webrtc_ctx_new(0);
+    assert(client);
+    cwist_webrtc_ctx_set_message_handler(client, &client_on_message, cp);
+    cwist_webrtc_conn *conn = dial(server, client);
+    assert(cwist_webrtc_conn_send(conn, 2, (const uint8_t *)"ping", 4, CWIST_WEBRTC_DATA_BINARY) ==
+           0);
+    assert(wait_flag(&sp->got_ping, 1, 10000) == 0);
+    assert(wait_flag(&cp->got_pong, 1, 5000) == 0);
+
+    /* Freed off the reactor thread: waits for the teardown, which runs the
+     * server's close handler on the reactor thread. */
+    cwist_webrtc_ctx_free(server);
+    assert(atomic_load(&sp->closed) == 1);
+
+    cwist_reactor_stop(reactor);
+    assert(pthread_join(th, NULL) == 0);
+    cwist_reactor_destroy(reactor);
+
+    cwist_webrtc_conn_release(conn);
+    cwist_webrtc_ctx_free(client);
+    free(sp);
+    free(cp);
+}
+
+int main(void) {
+    unit_checks();
+    test_end_to_end();
+    test_send_queue_cap();
+    test_external_reactor();
+    printf("test_webrtc: all assertions passed\n");
+    return 0;
+}