mirror of
https://github.com/coturn/coturn.git
synced 2026-10-24 00:08:57 +03:00
master
234 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
fc8eb910fb |
Fix OAuth token validity on TURN refresh (#2097)
## Summary Fix OAuth TURN token handling : - Revalidate the presented access token on every Allocate and Refresh, even when the session already has a cached MAC key. - Track the token's absolute expiry and cap each response lifetime to the time still available. - Reject tokens with zero lifetime or timestamps outside the permitted clock-skew window, and reject empty or malformed AS-RS key material. ## Validation - macOS CMake build and CTest: 21/21 passed. - macOS TURN system scripts: core, configuration, mobility, multiplex peer, stateless nonce, IPv6 relay, and DTLS default passed. RFC 5780 skipped because a second loopback address was unavailable. - New OAuth end-to-end probe passed, including Refresh after expiry and malformed-key cases. - ASan fuzzing smoke tests passed for both local targets. - Linux CMake build and CTest: 20/20 passed. The OAuth probe and all required Linux system scripts passed, including RFC 5780 and IPv6 relay. - Packaged Debian Docker image Bats tests: 19 tests, 0 failures, 2 expected skips. - clang-format 15 check passed for every changed C/H file. |
||
|
|
ac601f832d |
key OAuth MESSAGE-INTEGRITY with the whole mac_key (#2096)
RFC 7635 Section 6.2 keys HMAC-SHA1 MESSAGE-INTEGRITY with the token's full 160-bit mac_key. The OAuth path went through the long-term-credential helpers, which hash only get_hmackey_size(SHA1) = 16 bytes, so requests from RFC 7635 compliant clients failed with 401 and coturn's responses could not be verified by them. turnutils_uclient shared the truncation, so the bundled client never noticed. Add integrity helpers that take an explicit key length and use the whole mac_key for OAuth sessions, both when checking requests (userdb and check_stun_auth) and when signing responses. --oauth-legacy-mac-key on turnserver (and on turnutils_uclient) restores the 16-byte truncation for deployments that depend on it; the server logs a warning when it is on. A mac_key whose last 4 bytes are zero hashes the same in both modes. Fixes #2091 and #644 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
36b2f14516 |
Update help with missing arguments (#2079)
## Summary - Remove the deprecated `--no-dtls` and `--no-cli` turnserver flags — both were already no-ops (DTLS and CLI are off unless `--dtls`/`--cli` is passed), so the flags only added deprecation-warning noise. - Update `README.turnserver` and the `Usage` help text in `mainrelay.c` to drop the removed flags. - Strip now-unused `--no-dtls`/`--no-cli` invocations from the example test scripts and update `run_tests_dtls_default.sh` to drop the coverage for the deprecated flag's warning/compat behavior. - Update `CLAUDE.md`'s test-suite notes and load-test snippet to match. ## Test plan - [ ] `cmake --build build && ctest --test-dir build --output-on-failure` - [ ] `cd examples && ./run_tests.sh && ./run_tests_conf.sh && ./run_tests_dtls_default.sh` 🤖 Generated with [Claude Code](https://claude.com/claude-code) |
||
|
|
0feab35326 |
Make the multiplex-peer example tests request EVEN-PORT never, not ra… (#2070)
…ndomly run_client drove turnutils_uclient with -c only. In clnet_allocate, -c leaves the EVEN-PORT slot to be picked at random between 0 and -1, and multiplex-peer strictly rejects EVEN-PORT with error 508, so roughly half of all attempts were thrown away. The script compensated with an eight-attempt retry loop, which still failed outright about once in 256 per run_client call and about once in 50 per script run - observed on https://github.com/coturn/coturn/actions/runs/31279481991 (amazonlinux:2023), where all eight attempts of the first UDP case drew EVEN-PORT. Pass --no-even-port, which forces the slot to -1 unconditionally, and drop the retry loop it makes dead. Nothing is lost in coverage: EVEN-PORT under multiplex-peer is defined to be a 508, so the random draw only ever decided whether a run was wasted. A retry that can no longer fire for its stated reason would silently swallow a genuine regression. expect_peer_endpoint_limit already passed --no-even-port for the same reason. Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
285a9b76c9 |
Merge commit from fork
The UDP listener's stateless first-packet fast path short-circuits a first datagram from an unknown source when the outcome does not depend on session state, so it never allocates a child socket or a session. Its credential-mode gate sat above the packet classification, so the short-circuit only applied in long-term-credential mode; in no-auth mode the same first packets fell through to create_ioa_socket_from_fd + open_client_connection_session and held that state until the to-be-allocated watchdog expired, even though the relay never acts on them. Move the credential-mode gate below the classification: the decisions that do not depend on credential type are made at the listener in every mode, and only the 401/438 challenge synthesis stays long-term-only. A real request (e.g. ALLOCATE) still takes the session path, and an error-producing request is still reaped on the initial packet, so the bytes on the wire are unchanged. Rename udp_stateless_nonce_fast_path to udp_stateless_fast_path to reflect the mode-independent role, and document it in docs/stateless-nonce.md. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
9600cbd603 |
ci: guard against test-script hangs (timeout wrappers + job timeouts) (#2061)
## Problem The Clang workflow's `sanitize (thread)` job has been hanging for **hours** (e.g. #2058's run hung 4h28m; an earlier run hit the **6h** GitHub default), while normal runs finish in 5–15 min. **Root cause (investigated under TSan):** `turnutils_uclient`'s threaded load-gen has data races. ThreadSanitizer reports them in `__turn_getMSTime()` ([uclient.c:1027-1035](../blob/master/src/apps/uclient/uclient.c#L1027)) — which writes the shared `current_time` / `current_mstime` / `show_statistics` globals from both the **main thread** (`start_mclient`) and every **sender thread** (`sender_timer_handler`) with no synchronization — plus the shared session counters in `start_mclient`. Under the TSan build's timing these races intermittently leave the client **spinning after the transfer has already completed**. `run_tests_ipv6_relay.sh` (scenario 2, `-y` c2c) runs the client synchronously and only greps the log afterwards, so a client that never exits blocks the script forever. With no job timeout, the job then runs to GitHub's 6h default. This is **not** a regression from any recent change — the ASan job runs the identical steps and passes; the hang is purely the TSan-timing-exposed load-gen race. ## Fix — two independent guards 1. **`timeout`-wrap the blocking `turnutils_uclient` calls** in the run_tests scripts (`run_tests.sh`, `run_tests_conf.sh`, `run_tests_mobile.sh`, `run_tests_ipv6_relay.sh`). Success is already decided by grepping the log for the completion marker, which is written *before* the client starts spinning — so a post-completion kill still passes, while a genuine failure still shows no marker. Scripts that already bound the client (`ratelimit_401`, `dtls_default`, the load-gen smoke) are unchanged. 2. **`timeout-minutes` on every CI job that runs the test scripts** (`clang` sanitize, `cmake` build, `linux` build, `macos` build + build-cmake). Any future hang now fails in tens of minutes instead of six hours. ## Validation (Linux container) - **No regression** (plain autotools build): `run_tests.sh`, `run_tests_conf.sh`, `run_tests_mobile.sh`, `run_tests_ipv6_relay.sh` all pass with the wrappers. - **TSan build**: `run_tests_ipv6_relay.sh` now **passes (rc=0)** instead of hanging. - All four workflow YAMLs parse; `bash -n` clean on all four scripts. ## Follow-up (separate PR) The real fix is to eliminate the `turnutils_uclient` load-gen data races (make `__turn_getMSTime`'s time cache thread-safe and audit the shared session/counter state the sender/listener pools touch). This PR only stops the CI bleeding. |
||
|
|
fe087b6afe |
turnutils_peer: reflect DSCP/TOS on echoed datagrams (#2059)
## What `turnutils_peer` now echoes each datagram back with the **same DSCP/TOS marking it arrived with**, instead of a bare TOS of 0. ## Why Reported in #385: DSCP markers appear lost across a TURN relay. coturn actually reflects TOS correctly in **both** directions — but the reflection on the peer→client leg mirrors whatever the *peer* sends, and `turnutils_peer` sent an unmarked (CS0) echo. So the test tooling itself made the relay's return-leg DSCP handling impossible to observe, and matched the exact "CS7 → CS0" symptom in the report. With the peer reflecting, the full round-trip DSCP path is testable. ## How - On Linux, the marking is read off each datagram's ancillary data (`IP_RECVTOS` / `IPV6_RECVTCLASS`) on the `recvmmsg` path. A batch that shares one marking — a single steady flow, and what UDP-GSO already requires — sets the socket TOS once and keeps the `sendmmsg`/GSO fast path; a mixed batch falls back to a per-packet echo so each reply keeps its own marking. - Off-Linux the peer echoes without TOS reflection — the same tradeoff the relay itself makes (RFC 5766 "alternative behavior"), so no new cross-platform ancillary-data code is introduced. ## Test New `examples/run_tests_dscp.sh` system test: it marks the client socket (TOS `0x22`, as `turnutils_uclient` already does), drives traffic through a loopback relay, and asserts via `tcpdump` that the DSCP survives on **both** the relay→peer and listener→client legs. It SKIPs (exit 0) off-Linux or without `tcpdump` / capture privileges, matching the style of `run_tests_rfc5780.sh`. ## Validation Validated in a Linux container: - `ctest`: 17/17 pass - `run_tests_dscp.sh`: both legs carry `tos 0x22` (forward + return) — OK - `run_tests.sh` incl. the `-Y packet` GSO load-gen smoke: OK (batched/GSO fast path preserved) - `clang-format-15`: clean Closes #385 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
1e3570dd1e |
Advertise IPv6 allocations under their real relayed transport address (#2058)
Fixes #1322. The issue reports `clnet_allocate: !!!: relay addr cannot be received (2)` when asking `turnutils_uclient` for an IPv6 relay. It turns out to be two independent bugs that both end in that same line, one on each side of the wire. Both are reproduced by the new test, which fails on `master` and passes here. ## What the RFC requires RFC 8656 Section 3 defines the relayed transport address as *"A transport address on the TURN server that is used for relaying packets between the client and a peer"*, and Section 7.2 leaves the server two outcomes for an Allocate carrying `REQUESTED-ADDRESS-FAMILY`: > If the server does not support the address family requested by the client in REQUESTED-ADDRESS-FAMILY, or if the allocation of the requested address family is disabled by local policy, it MUST generate an Allocate error response, and it MUST include an ERROR-CODE attribute with the 440 (Address Family not Supported) response code. Section 7.3 puts one duty on the client: > If the client receives an Allocate success response, then it MUST check that the mapped address and the relayed transport address or addresses are part of an address family or families that the client understands and is prepared to handle. ## 1. `--external-ip` was applied across address families (server) `external_ip` is a single `ioa_addr` and `--external-ip` may only be given once, but it was substituted for the relayed address of *both* families. On a server with an IPv4 `--external-ip`, an IPv6 allocation was therefore advertised under an unrelated IPv4 address that no peer can reach — neither an IPv6 relay nor a 440. ``` --relay-ip=127.0.0.1 --relay-ip=::1 --external-ip=127.0.0.1, client over ::1 with -x INFO IPv4. Received relay addr: 127.0.0.1:30654 INFO clnet_allocate: !!!: relay addr cannot be received (2) ``` The relay socket really was IPv6; only the advertised address was wrong. As a second effect, the substituted family made the response draw `LIFETIME` from the IPv4 relay session that was never created for that allocation. `turn_set_xor_relayed_addr()` now applies the external IP only to a relay of its own family, which is the same rule the `ioa_addr_add_mapping()` setup path already follows in `mainrelay.c`. The option's documentation is updated to match. ## 2. `turnutils_uclient` rejected an IPv6 relay it can handle (client) The acceptance test took an IPv6 relayed address only when IPv6 had been requested *explicitly*, and consulted the global `default_address_family` rather than the `af` argument the caller passed to `clnet_allocate()`. Against a server running `--allocation-default-address-family=keep` (or the deprecated `--keep-address-family`), which answers an IPv6 client with an IPv6 relay and no `REQUESTED-ADDRESS-FAMILY` in play, a perfectly usable relay was refused — as reported in the issue thread. Section 7.3 asks only that the client refuse a family it *cannot handle*, so the check now rejects solely on a contradiction with the family this allocation requested. Note this does not make `keep` conformant — it is a deliberate deviation, and `turnserver -h` says as much (*"It breaks RFC6156 section-4.2 (violates default IPv4)"*). The client simply stops rejecting a relay it is able to use. ## Testing `examples/run_tests_ipv6_relay.sh` covers both configurations end to end, asserting a completed relayed round trip rather than just a successful allocate. It uses ports off the defaults the other scripts share, and SKIPs where no IPv6 loopback is bindable. Wired into the clang, cmake, linux and macos workflows. Verified red before green: reverting only `src/` and rebuilding turns both cases into failures. - macOS: build + `ctest` 18/18; Linux container: build + `ctest` 17/17 - Linux: `run_tests.sh`, `run_tests_conf.sh`, `run_tests_mobile.sh`, `run_tests_multiplex_peer.sh`, `run_tests_rfc5780.sh`, `run_tests_stateless_nonce.sh`, `run_tests_ipv6_relay.sh`, `run_tests_dtls_default.sh` — all OK - Fuzzing smoke tests (ASan targets 0 and 1) — OK - Packaged Docker image (debian, arm64) + bats — 19 tests, 0 failures, 2 skipped ## Deliberately not in this PR - The dual-allocation `508` guard when `external_ip_set` is left as is; with family-aware rewriting it could now be relaxed to allow a v4-rewritten + v6-as-is pair, but that is a separate behavioral change. - `--external-ip` stays single-family. A per-family form would be the complete answer for IPv6-NAT deployments. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0f6bebe56b |
Gate merges on the more tests suites (#2039)
Seven suites in examples/ ran on no platform, so nothing stopped a regression in what they cover. Three of them are the only automated proof that a shipped security fix still holds: the mobility quota bypass, and the two pre-auth session-cost paths. The Linux workflow gains all seven. macOS gains the five that do real work there; run_tests_rfc5780.sh needs a 127.0.0.2 alias macOS does not configure by default and would only report SKIP, and run_tests_multiplex_peer.sh exercises Linux-only paths. The ubuntu build-deps action gains python3. The ubuntu:22.04 and ubuntu:24.04 container images ship none, and four of these suites drive raw STUN through it: run_tests_mobility_quota.sh calls it unguarded and would have failed, while three others guard the call and would have skipped in silence - the worst outcome for a job whose purpose is to catch a revert. run_tests_conf.sh no longer sleeps a fixed 5 seconds on macOS. It discarded the server log there, so it could not use the readiness probe it already had for other platforms and raced uclient against a still- initializing server. It now captures the log on both platforms and polls, as run_tests.sh has done since the same race was fixed there. Verified per platform (autotools build, as CI builds): ubuntu:24.04 and amazonlinux:2023 containers and macOS, all suites rc=0 with no unexpected skips. Reverting the mobility quota fix makes run_tests_mobility_quota.sh exit 1 with flood_accepted=8, so the gate demonstrably catches a revert. |
||
|
|
c6cd2106b6 |
Enable stateless nonce by default (#2036)
## What Makes `--stateless-nonce` the default, and closes two paths that created a per-packet session for unauthenticated UDP traffic even when it was enabled. ## Behaviour changes for operators 1. **The default challenge nonce goes from 16 to 24 characters.** RFC 8489 requires clients to treat NONCE as an opaque string of up to 128 characters, so compliant clients are unaffected. `--stateless-nonce=false` restores the stored random nonce and the 16-character challenge. 2. **The default signing key is ephemeral**, so a restart costs each client one `438` re-auth round-trip, as does a retry that lands on a different instance behind a load balancer. `--stateless-nonce-secret` shares the key across a fleet and avoids both. (This is not a regression — today a retry landing on another instance finds no session either — but it becomes the default story.) ## Validation Run in a container (Linux, isolated network namespace): - build clean; 17/17 unit tests - 11 example suites with zero FAIL lines: `run_tests`, `_conf`, `_stateless_nonce`, `_mobile`, `_dtls_default`, `_multiplex_peer`, `_stateless_binding`, `_ratelimit_401`, `_mobility_quota`, `_expiry`, `_mobility_resume_flood` - both fuzzing smoke targets (`ASan 0`, `ASan 1`) - `clang-format-15` clean; mandoc lint unchanged at 104 pre-existing warnings |
||
|
|
3c5b2615fd |
Merge commit from fork
* Hold mobility allocation quota across first-stage close When --mobility is enabled, shutdown_client_connection() released the user and total allocation quota (and the bandwidth reservation) before the first-stage mobility close, which keeps the allocation, relay socket, session, and mobility ticket alive for a later resume. An authenticated client could therefore create a mobility-enabled allocation over TCP, disconnect, and immediately re-allocate, holding many live relay allocations past --user-quota=1 and --total-quota=1 until the relay port pool was exhausted (508). No request was rejected by either quota. Keep the quota and bandwidth charge held for as long as the allocation remains valid and resumable: release it only in the final second-stage teardown, which the allocation-timeout and forced-shutdown paths always reach. A mobility resume then has to reuse the existing allocation's single quota unit instead of acquiring a second one for the temporary resuming session, which would otherwise reject legitimate resumes under strict quotas. copy_auth_parameters() gains an acquire_quota flag: the resume path in handle_turn_refresh adopts the owner's credentials without charging the resuming session, and mobile_complete_transition keeps the charge on the surviving session. Add examples/run_tests_mobility_quota.sh and its raw-STUN driver examples/scripts/mobility_quota_flood.py: under --user-quota=1 --total-quota=1 --mobility the flood is admitted exactly once and the next allocation is rejected with 486, while a fresh-5-tuple resume still succeeds. The test fails on the vulnerable server (admits many, hits 508) and passes on the fix. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Update ns_turn_server.c * copy_auth_parameters: make acquire_quota a bool --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
62662ec53e |
Log format: ISO-8601 timestamp by default, no thread id, space delimiter (#2031)
## What Makes the default log line parseable by a log shipper with no configuration. Rebased on `master` (`441d3e9c`, which now includes #2030). Before: ``` 0: (18446744073709551615): INFO: Listener address to use: 127.0.0.1 ``` After: ``` 2026-08-02T17:32:31.297-0700 INFO Listener address to use: 127.0.0.1 ``` Two commits: | Commit | Change | |---|---| | `Cache the rendered log timestamp per thread, add millisecond precision` | perf + sub-second resolution | | `Log format: ISO-8601 by default, drop thread id, space delimiter` | the format change itself | ## Why **ISO-8601 by default.** The previous default was a counter of seconds since process start — no wall-clock time at all, so nothing downstream can order or window on it. `--new-log-timestamp` now also honours its boolean argument; it previously ignored the value and could only be turned *on*, so with the default flipped there would have been no way back. `--new-log-timestamp=false` restores the counter. **Thread id removed.** It was emitted as a bare unlabelled `(N)`, indistinguishable from any parenthesised number in a message body. Worse, it was gated on `#ifdef SYS_gettid` — a test for whether the *constant* exists, not whether the syscall does what was intended. macOS defines `SYS_gettid` as a different syscall, so the call failed with `ESRCH` and every line on every macOS build carried `(18446744073709551615)`, i.e. `(unsigned long)(pid_t)-1`: ``` SYS_gettid defined = 286 syscall(SYS_gettid) = -1 errno=3 (No such process) as (unsigned long)(pid_t)r = 18446744073709551615 ``` Dropping it also removes the deprecated `syscall(2)` use on macOS. **Space delimiter.** `:` cannot delimit anything a parser can rely on — message bodies are full of it (`realm <north.gov> user <>: ...`). These two changes are coupled: the ISO timestamp emitted *no* trailing separator (the `(tid): ` supplied it), so removing the thread id alone would have produced `...-0700INFO`. The renderer now emits only the timestamp and the caller adds one space. **Millisecond precision** (first commit). Log shippers need sub-second resolution to order records within a second, which a busy relay produces in bulk. `strftime()` has no sub-second conversion, so `%f` is expanded to milliseconds (`%%f` is left alone) and the default format is `%FT%T.%f%z`. **Timestamp caching** (first commit). Everything down to the second only changes once a second, but was re-rendered per line — and `localtime_r()` takes a lock inside libc, so relay threads serialised on it and per-line cost *grew* with thread count. Now cached in thread-local storage keyed on the second, with only the millisecond digits patched in per line. A generation counter bumped by `set_turn_log_timestamp_format()` invalidates live caches on a format change; a format rendering longer than the cache falls back to the caller's buffer. Timestamp rendering, 12 threads on a 12-core machine: | threads | before (no ms) | after (with ms) | |---|---:|---:| | 1 | 163 ns/line | 24 ns/line | | 4 | 53 ns/line | 6 ns/line | | 12 | **304 ns/line** | **3 ns/line** | ## Acceptance check Output run against the stock logstash pattern `%{TIMESTAMP_ISO8601:ts} %{LOGLEVEL:level} %{GREEDYDATA:msg}` — **43/44 startup lines parse with no further configuration**, fields extracting correctly. The one failure is pre-existing: the banner at `mainrelay.c:3211` embeds `\n\n` *inside* the message, so its text lands on a line with no prefix. The right fix is newline normalisation in the logger, not patching call sites; deferred to the `--log-format` work. ## Compatibility This is a visible break for anyone parsing coturn logs. `--new-log-timestamp=false` restores the old timestamp but not the thread id or the colons — there is no single flag that restores the previous line verbatim. Worth pairing with the remaining logging changes in one release and one changelog entry. ## Testing All re-run after the rebase onto `441d3e9c`. **Linux (Docker)** - `ctest` 17/17 - `run_tests.sh`, `run_tests_conf.sh`, `run_tests_mobile.sh`, `run_tests_multiplex_peer.sh`, `run_tests_rfc5780.sh`, `run_tests_stateless_nonce.sh`, `run_tests_dtls_default.sh` — all `rc=0`, zero `FAIL` lines - Both timestamp modes confirmed on glibc: `2026-08-03T00:46:59.742+0000 INFO ...` and, with `--new-log-timestamp=false`, `0 INFO ...` **macOS** - `ctest` 18/18; `run_tests.sh`, `run_tests_conf.sh`, `run_tests_mobile.sh`, `run_tests_stateless_nonce.sh` — all `rc=0`, zero `FAIL` lines - `clang-format` clean **Fuzzing smoke** - `fuzzing/run-local.sh ASan 0 -runs=1` and `ASan 1 -runs=1` — both clean, no new findings **Format matrix**, by hand on both platforms — default, `--new-log-timestamp=false`, explicit on, and the same three through the **config-file** path (a separate parser, also changed). **Timestamp-cache correctness** — milliseconds vary per line (51 distinct timestamps across 7 seconds under traffic); format edge cases: no `%f`, `%f` alone, `%f` at start, `%f` at end, `100%%f-%T.%f` (escaped `%%f` stays literal while the real one expands), and a >128-byte format hitting the fallback. The MSVC branch of `turn_wall_clock()` (`GetSystemTimeAsFileTime`) is inspection-only — there is no MSVC toolchain here, so CI is the first real check on it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2209a3155a |
Drop --simple-log where it is a no-op alongside --log-file=stdout (#2028)
## What
Removes `--simple-log` from the 11 places it is passed together with
`--log-file=stdout`, where it has no effect. Also drops the now-false
trailing sentence of the `run_tests_conf.sh` comment.
No behaviour change.
## Why
`--simple-log` only affects two things:
- log-file *naming*, in `set_log_file_name_func`
(`src/apps/common/ns_turn_utils.c:357`)
- rollover, in `rollover_logfile`
(`src/apps/common/ns_turn_utils.c:546`)
With `--log-file=stdout`, `set_rtpfile` takes the stdout branch at
`ns_turn_utils.c:437` and **never populates `log_fn`**. So
`set_log_file_name` is never called, and `rollover_logfile` returns
immediately at `ns_turn_utils.c:530` on `!log_fn[0]`. The flag is inert.
The comment in `run_tests_conf.sh` claimed "simple-log keeps the format
compact", which was wrong twice over: it has no effect there, and it
never affected the log *format* in the first place — only file naming
and rollover.
## One site deliberately keeps it
`examples/run_tests_multiplex_peer.sh` logs to a real file, not stdout:
```
turnserver_log="$(mktemp "${TMPDIR:-/tmp}/turnserver-multiplex-peer.XXXXXX.log")"
```
and greps that exact path (lines 80, 153, 188). Without `--simple-log`,
`set_log_file_name_func` splits at the last `.` and writes
`...XXXXXX_<date>.log` instead, so every grep would silently fail. That
is the one place the flag is load-bearing.
The commented-out `#simple-log` lines in `examples/etc/turnserver.conf`
and `docker/coturn/turnserver.conf` also stay — they document the flag
for the file-logging case, which is its actual purpose.
## Verification
Rather than relying on reading the code, the no-op claim was checked
directly. Same server invocation, with and without `--simple-log`, both
with `--log-file=stdout`:
- 45 log lines each, **byte-identical** after masking the uptime/tid
prefix and cwd
- zero files created in either case
## Testing
All `rc=0` with zero `FAIL` lines.
**macOS**: `run_tests`, `_conf`, `_stateless_nonce`, `_mobile`,
`_stateless_binding`, `_expiry`, `_ratelimit_401`,
`_mobility_resume_flood`, `_prom`, `_dtls_default`, `_rfc5780`,
`_multiplex_peer`
**Linux (Docker)**: 17/17 unit tests, plus `run_tests`, `_conf`,
`_mobile`, `_multiplex_peer`, `_rfc5780`, `_stateless_nonce`,
`_dtls_default`
The Linux pass is the one that matters for `run_tests_conf.sh` — that
script only writes `log-file=stdout` on non-Darwin, so the conf edit is
only exercised there.
No C/H files touched, so no `clang-format` run was required.
## Context
First of several small changes ahead of work on log formatting for
structured-log ingestion (ELK). Splitting the inert-flag cleanup out
first so it does not ride along with behavioural changes.
|
||
|
|
ce26268da8 |
Start the DTLS listeners only when --dtls is given (#2026)
## What The DTLS client listeners are no longer started by default. They come up only when `--dtls` is given. Until now DTLS came up automatically whenever usable certificates were configured, so every deployment that wanted TLS also exposed a DTLS handshake path it had never asked for. That path is the most expensive unauthenticated entry point the server has: it builds an `SSL` object, an `ioa_socket` and a session *before* the DTLS cookie is verified. Deployments that do not serve DTLS clients were paying for that surface silently. ## Compatibility `--no-dtls` keeps its meaning, including `--no-dtls=false` to ask for DTLS, so an existing configuration resolves to exactly the listeners it asked for. Both spellings now log a deprecation warning naming `--dtls`: ``` CONFIG: --no-dtls is deprecated and now redundant: DTLS listeners are not started unless --dtls is given CONFIG: --no-dtls=false is deprecated: use --dtls to start the DTLS listeners ``` **This is a behaviour change for anyone who relied on the old default.** A deployment that configured certificates and passed no DTLS flag loses its DTLS listeners on upgrade. The only signal such an operator gets is a startup line, emitted when the certificates are usable but DTLS is off: ``` DTLS listeners are not started; use --dtls to start them ``` If the project would rather have a transition release — default unchanged, but logging that it is about to change — that is a small edit to this branch; say so in review. ## How `turn_params` carries the state as `dtls` rather than the previous `no_dtls`, so the listener and netengine tests read as "DTLS is on" instead of double negatives, and `openssl_setup()`'s two-flag guards lose their De Morgan wrapper (`!(no_tls && no_dtls)` becomes `!no_tls || dtls`). The telnet CLI and web admin listings consequently report `dtls` instead of `no-dtls`, matching the flag an operator actually sets. Documentation follows in `README.md`, `README.turnserver`, the built-in help and `examples/etc/turnserver.conf`. The `--pkey` help entry had a sentence truncated mid-clause; the rewrite fixes it. ## Tests New `examples/run_tests_dtls_default.sh` pins four cases: - no flag: no DTLS context, the hint line is logged, and a `turnutils_uclient -S` session cannot complete; - `--dtls`: context created, DTLS client relays end to end; - `--no-dtls`: warns, stays off; - `--no-dtls=false`: warns, starts DTLS, client relays. It is wired into all four workflows, so this default is now covered on both Linux and macOS. Example scripts that only ever wanted DTLS off drop the now-redundant flag; the ones that drive DTLS clients pass `--dtls`. Also run green: `ctest` (18/18 macOS, 17/17 Linux) and, on both platforms where applicable, `run_tests.sh`, `run_tests_conf.sh`, `run_tests_multiplex_peer.sh`, `run_tests_stateless_nonce.sh`, `run_tests_stateless_binding.sh`, `run_tests_ratelimit_401.sh`, `run_tests_mobile.sh`, `run_tests_rfc5780.sh`, `run_tests_expiry.sh`; fuzzing smoke tests (ASan, both targets); and the packaged Debian image build plus its Bats suite (19 tests, 0 failures, 2 skipped). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0c5ddc5481 |
Worktree stateless nonce mi fastpath (#2025)
What — the fast path fell through on the mere presence of MESSAGE-INTEGRITY, so 20 garbage bytes put a spoofed flood back on the session-creating path. Framed honestly as ~19 KB of alloc+zeroing per ~100-byte packet rather than an OOM, since close_after_auth_challenge already bounded steady-state memory. Headline number: 200 forged-MI packets → 200 sessions before, 0 after, all still answered. How, part 1 — nonce validated in the listener; 400 / 437 / 441 / 438 answered there in check_stun_auth's order; wrong-length MI treated as missing; a verifying nonce still falls through because the credential lookup can be async. Plus the --stun-only side improvement. How, part 2 — rejections charged to the existing --unauthorized-ratelimit bucket, with the reasoning for not dropping them: RFC 8489 §9.2.5 needs the 438 for stale-nonce recovery, and after part 1 that legitimate retry lands on this very path. Includes the amplification measurements (438 under 1:1 vs 401 at 3.6–5.4:1) to explain why this caps a vector rather than closing an amplification hole. Wire compatibility — nine shapes dumped attribute-by-attribute against a pre-change build, identical. Tests — the new probe script and its assertions, the two rate-limit cases, and the full green matrix including fuzz smoke and the Docker image Bats run. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0fb7fbade1 |
Binding stateless fastpath (#2024)
## What Answer a plain STUN Binding request from an unknown UDP source in the listener, instead of building a child socket and a session to reach `handle_turn_binding()`. That function derives its entire response from the request bytes and the socket's own addresses — nothing in it needs session state. But reaching it costs an `ioa_socket` plus an ~18KB `ts_ur_super_session`, and the to-be-allocated timer holds both for 60 seconds. So one spoofed Binding request per source buys ~22KB of server memory for a minute, in the **default configuration**, with no authentication and no flag involved. | | RSS growth, 3000 Binding requests from distinct source ports | |---|---| | master | **+66,720 KB** | | this branch | **+304 KB** | All requests are still answered, byte for byte. ## How `udp_stateless_binding_fast_path()` in `dtls_listener.c`, called from `handle_udp_packet()` for a source with no child socket yet. It rebuilds the reply attribute for attribute: - success response with XOR-MAPPED-ADDRESS (plus MAPPED-ADDRESS under `--stun-backward-compatibility`, plus RESPONSE-ORIGIN/OTHER-ADDRESS under `--rfc5780`), or `420` with the same UNKNOWN-ATTRIBUTES list; - then SOFTWARE and FINGERPRINT on the same conditions as the session path; - and the same Prometheus binding counters (`stun_report_binding()` already ignores its session argument). The attribute walk mirrors `handle_turn_binding()`'s exactly, including `SKIP_ATTRIBUTES`, so ICE-style requests carrying USERNAME/PRIORITY/MESSAGE-INTEGRITY/FINGERPRINT are accepted rather than flagged as unknown. Requests the listener cannot answer identically still take the session path: - `--secure-stun` — BINDING is an authenticated method there, which is `check_stun_auth()`'s business; - CHANGE-REQUEST / RESPONSE-PORT / PADDING — the RFC 5780 probes leave from an alternate socket that only the relay owns; - `--log-binding` — its per-session log lines can only be written by the relay, so the old path stays available for debugging. Under `--no-stun` the request is dropped where the relay would have ignored it anyway — same silence on the wire, minus the session it used to leave behind. `MAX_NUMBER_OF_UNKNOWN_ATTRS` moves from `ns_turn_server.c` to `ns_turn_server.h` so both call sites share one definition. ## Wire compatibility A byte-level dump of the reply (message type plus every attribute TLV) was compared between a master build and this one for ten request shapes — bare Binding, ICE-style, unknown comprehension-required attributes, unknown comprehension-optional attribute, CHANGE-REQUEST with and without bits set, RESPONSE-PORT, PADDING, and an attribute placed after MESSAGE-INTEGRITY — in default configuration and with `--software-attribute --fingerprint --stun-backward-compatibility`. **Identical in every case.** ## Tests New `examples/run_tests_stateless_binding.sh` with `examples/scripts/stateless_binding_probe.py`: - wire shapes, including that the reflexive port in XOR-MAPPED-ADDRESS is the client's own and that the 420 echoes the offending attributes; - `--no-stun` stays silent, `--secure-stun` still challenges with 401; - a 2000-request flood asserting RSS growth stays under the threshold. The suite discriminates: against a master binary the memory case fails with `Binding flood grew RSS by 43632KB ... sessions are being created again`, while every wire assertion passes on both builds. Also run green: `ctest` (18/18 macOS, 17/17 Linux), `turnutils_rfc5769check`, and `run_tests.sh`, `run_tests_conf.sh`, `run_tests_mobile.sh`, `run_tests_multiplex_peer.sh`, `run_tests_stateless_nonce.sh`, `run_tests_ratelimit_401.sh`, `run_tests_rfc5780.sh` — the last one being the important one here, since it exercises both the plain Binding response that now comes from the listener and the CHANGE-REQUEST probes that must still reach the relay. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a97f1924bb |
Merge commit from fork
Under --mobility a resume does not hard-switch the allocation onto the new client path; it opens a dual-5-tuple transition and links the resuming session to the allocation via a single backlink (mobile_pending_resume). An authenticated user could resume one allocation repeatedly from fresh 5-tuples without completing any handoff: each resume disarmed the new session's un-allocated watchdog and overwrote that single backlink, orphaning the previous pending session. Orphans were unreachable by the transition-deadline sweep and owned no allocation timer, so they stayed allocated indefinitely, letting one user grow server memory without bound. --user-quota did not stop it, since pending sessions hold no quota unit. Enforce one pending resume per allocation: - mobile_begin_transition() fully tears down the existing pending session before opening a new one. It runs in REFRESH packet context, so the shutdown is synchronous and also clears the allocation's backlink via shutdown's transition-unlink path. - mobile_abort_transition() re-arms the disarmed watchdog so an abandoned pending left after the transition deadline is reaped from timer context (safe during the session-map sweep, which only schedules the timer). - mobile_complete_transition() promotes only when the session is still the allocation's current pending resume, so a superseded pending can neither clear live transition state nor steal a socket move. Add examples/run_tests_mobility_resume_flood.sh (+ raw-STUN driver scripts/mobility_resume_flood.py): it floods chained resumes and asserts every superseded pending is torn down. It passes on the fixed server and fails on the unpatched one. Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
eda6358d2f |
multiplex-peer: bound the shared per-thread peer demux table (#2017)
## What Moves the `--multiplex-peer` demux table out of `ioa_engine_t` into its own module, `src/apps/relay/mp_peer_table.c/.h`, and gives it two properties it did not have: - **A per-allocation endpoint limit.** New `--multiplex-peer-max-peers` (default 256). Beyond it, CREATE_PERMISSION and CHANNEL_BIND are answered with 508 and SEND indications are dropped. The first rejection per allocation is logged once. - **A reverse index** (session → its endpoints), so `mp_deregister_permission_peers` and `mp_deregister_session_peers` cost that session's own endpoints instead of a full scan of the table shared by every session on the relay thread. ## Why The table is keyed by exact peer IP:port, but permissions are per peer IP, so a single permission covers all 65535 ports of that IP: every SEND indication naming a fresh port added an entry, with no cap and no backpressure. The table is per relay thread and shared, so one busy allocation grows a structure its co-tenants pay for — and every permission expiry and session teardown walked the whole thing. Ephemeral-port churn produces the same growth without anyone trying, so this is also a robustness issue for high-volume deployments. ## Behaviour changes - `--multiplex-peer-max-peers <n>` (1–65535, default 256). Config-file key works too. No effect unless `--multiplex-peer` is set. - An allocation exceeding the limit gets **508** on CREATE_PERMISSION / CHANNEL_BIND. 508 is what this mode already returns for its other capacity limitation (EVEN-PORT), so the code is not new to multiplex-peer clients. - Nothing changes below the limit; the default is far above normal use (a typical allocation registers two endpoints — its peer's RTP and RTCP ports). ## Performance Microbenchmark of the old vs new code paths (arm64, `-O2`, keys precomputed so only table work is timed): | table entries | demux lookup (per datagram) | re-register (per SEND) | permission expiry, 2-endpoint session | |---|---|---|---| | 400 | 99 → 96 ns | 190 → 103 ns | 2.5 µs → 0.068 µs | | 4,000 | 116 → 119 ns | 236 → 125 ns | 9.3 µs → 0.055 µs | | 50,000 | 129 → 133 ns | 270 → 147 ns | 98.7 µs → 0.24 µs | | 500,000 | 501 → 510 ns | 1002 → 510 ns | 986 µs → 1.2 µs | - Data plane is untouched — same map, same lookup; deltas are noise. - The SEND path is ~2× cheaper: the old code did `ur_addr_map_get` **and** `ur_addr_map_put`, and `put` re-scans the bucket. Registering an endpoint the session already owns now returns after the `get`. - Memory: +128 B per endpoint for the index copy, ~310 B per session with endpoints (index array starts at 2 slots because `ioa_addr` is 128 B and the common case is RTP + RTCP). Worst case per allocation is now ~66 KB at the default limit rather than unbounded. The per-thread bound is sessions × limit, so a hard ceiling still wants `--total-quota`. ## Tests - `tests/test_mp_peer_table.c` (new, 8 cases): idempotent re-registration, cross-session conflict, a 4096-port registration run capped at 64, the limit being per-session rather than global, slot reuse after release, per-IP and per-family deregistration, and a deterministic assertion — via a `deregister_ops` counter — that deregistration never touches 200 entries belonging to another session. - `examples/run_tests_multiplex_peer.sh`: server start/stop refactored into functions; adds a run under `--multiplex-peer-max-peers=4` (normal relaying unaffected) and one under `=1` asserting the client sees 508 and the server logs the limit. The second SKIPs where multiplex-peer is inactive (non-Linux). ## Validation - macOS: 18/18 ctest, all `examples/run_tests*.sh`, both fuzzing smoke targets, `make lint` clean. - Linux (Docker): 17/17 ctest; `run_tests.sh`, `run_tests_conf.sh`, `run_tests_mobile.sh`, `run_tests_multiplex_peer.sh`, `run_tests_rfc5780.sh`, `run_tests_stateless_nonce.sh` all green. - Packaged image: `make docker.image dockerfile=debian` + `make test.docker` — 19 Bats tests, 0 failures. ## Reviewer notes - `deregister_ops` is a counter on the table struct read only by the unit test; it is one increment inside a loop that already runs. - `mp_deregister_peer()` had no callers before this change and still has none; it is kept as the exact-key counterpart of the two bulk helpers. - Docs updated: `docs/multiplex-peer.md` (table layout, files-changed table, CLI examples) and `examples/etc/turnserver.conf`. |
||
|
|
283662c348 |
Split --stun-backward-compatibility: move RFC 3489 handling to its own deprecated flag (#2008)
## Problem
`--stun-backward-compatibility` controls two unrelated behaviors:
- **(a)** accepting obsolete **RFC 3489** ("classic" STUN) Binding
requests, which carry no magic cookie;
- **(b)** adding the deprecated `MAPPED-ADDRESS` attribute to modern
**RFC 5389** Binding responses, alongside `XOR-MAPPED-ADDRESS`.
Only (a) is the obsolete part. (b) serves clients that speak RFC 5389
correctly but cannot parse `XOR-MAPPED-ADDRESS`, and (b) is what the
amplification warning in `turnserver.conf` is actually about. So
retiring RFC 3489 support can't be done by deleting the flag — that
would silently drop (b) too.
## Change
Move (a) behind a new `--rfc3489-compatibility`, which logs a
deprecation warning at startup naming the removal release.
`--stun-backward-compatibility` keeps (b) only.
Setting **both** reproduces the previous single-flag behavior exactly,
so operators have a mechanical migration path.
| Flags | Modern request | Old (cookie-less) request |
|---|---|---|
| none (default) | `XOR-MAPPED` | no response |
| `--stun-backward-compatibility` | `XOR-MAPPED` + `MAPPED` | no
response |
| `--rfc3489-compatibility` | `XOR-MAPPED` | `MAPPED` + `SOFTWARE`,
cookie echoed |
| both | `XOR-MAPPED` + `MAPPED` | `MAPPED` + `SOFTWARE`, cookie echoed
|
Nothing changes for anyone who does not set either flag; both default to
off.
### A bug the split exposed
Because the two halves are now independent, the old-STUN path can no
longer borrow (b) to supply an address. `MAPPED-ADDRESS` is the **only**
address attribute RFC 3489 defines, but it was gated purely on
`stun_backward_compatibility` — so `--rfc3489-compatibility` alone would
have answered classic-STUN clients with an **address-less** response
(`[SOFTWARE]` and nothing else).
`stun_set_binding_response_str()` now always emits `MAPPED-ADDRESS` when
building an old-STUN response. On the modern path it stays opt-in, since
it widens the response and raises the amplification gain.
### Other bits
- The UDP classifier follows the new flag, so an old-STUN datagram is
only spared `--drop-invalid-packets` when the option that can actually
process it is enabled.
- Corrects the flag's description in both shipped `turnserver.conf`
files, which disagreed with each other and with the code: the
`examples/` copy claimed the option *disables* `MAPPED-ADDRESS`, and the
`docker/` copy said "instead of" rather than "in addition to"
`XOR-MAPPED-ADDRESS`.
- `README.md` / `STATUS.md` / man page now mark RFC 3489 support as
deprecated.
## Context
RFC 5389 deprecated the RFC 3489 mechanisms in 2008; RFC 8489 dropped
the compatibility language entirely. This PR is step 1 of a two-step
retirement — it decouples the useful half from the obsolete half and
starts the deprecation clock. Step 2 (deleting the RFC 3489 request path
and the `old_stun_*` API, ~300 lines) is left for a major-version
window, since those functions are installed as public `libturnclient`
API.
## Testing
- New unit test pins the address-attribute matrix across both knobs;
verified it fails without the `MAPPED-ADDRESS` fix above.
- `ctest`: 16/16 macOS, 15/15 Linux.
- All six `examples/run_tests*.sh` clean on macOS and in a Linux
container (including `run_tests_rfc5780.sh`, which shares the
CHANGE-REQUEST code path — 0 FAIL / 0 SKIP in the container; it skips on
macOS without a `127.0.0.2` alias).
- Both fuzz targets smoke-clean; packaged Docker image tests 19 passed /
0 failed.
- All four flag combinations verified end-to-end via CLI **and** config
file with a raw prober, since `turnutils_stunclient` deliberately cannot
send a cookie-less request.
- `clang-format-15` clean.
|
||
|
|
f074fecd16 |
Narrow ChannelBind to the RFC 8656 channel range 0x4000-0x4FFF (#2006)
RFC 8656 par. 12 reduces the allowed channel numbers from RFC 5766's 0x4000-0x7FFF so that ChannelData first bytes stay inside the [64..79] slot RFC 7983 assigns to TURN in the DTLS-SRTP demultiplexing scheme; 0x5000-0xFFFF is reserved for collision avoidance. Channels bound in the legacy range produce frames that conformant demultiplexing endpoints must drop. Split the validation macro instead of tightening it in place: - STUN_VALID_CHANNEL_BIND is the strict RFC 8656 range, used where a channel is established: the server's ChannelBind check now returns 400 for 0x5000+, and stun_set_channel_bind_request_str() picks its random channel from the strict range, so turnutils_uclient is conformant automatically (and still interops with old servers, the strict range being a subset of the legacy one). - STUN_VALID_CHANNEL keeps the lenient RFC 5766 range for all receive/framing-side checks, so ChannelData from legacy peers is still recognized and stream framing stays in sync. - New server option --rfc5766-channel-numbers (default off) restores acceptance of legacy-range ChannelBind for deployments with old clients that pick channels above 0x4FFF. |
||
|
|
dfec35702c |
Return 508, not 400/440, when a well-formed Allocate cannot be satisfied (#2007)
## Summary RFC 8656 §7.2 assigns **508 (Insufficient Capacity)** to a well-formed request the server cannot satisfy; 400 means the request was malformed and 440 means the address family is unsupported. Two config-dependent Allocate paths used the wrong code: - **Dual allocation under `--external-ip`** returned **440**, which tells the client the server lacks an address family entirely — a client may permanently disable IPv6 against a server that supports it fine. Now 508. - **EVEN-PORT under `--multiplex-peer`** returned **400**, which tells a conformant client its request was malformed, so it will not retry without EVEN-PORT. Now 508, matching the sibling missing-shared-socket path in `create_relay_ioa_sockets()`. The `even_port` arm of the dual-allocation check is an unreachable backstop (the attribute loop already rejects ADDITIONAL-ADDRESS-FAMILY + EVEN-PORT in both attribute orderings); it is now commented as such and aligned with the upstream check's 400 and reason text instead of the old 440. Also updated: `run_tests_multiplex_peer.sh`'s randomized-EVEN-PORT retry now matches on error 508, and the stale EVEN-PORT notes in CLAUDE.md and docs/multiplex-peer.md are corrected (the latter claimed EVEN-PORT was silently ignored; it is rejected). ## Validation - macOS and Linux (Docker): clean builds, all unit tests, all example suites (`run_tests`, `_conf`, `_mobile`, `_multiplex_peer`, `_rfc5780`, `_stateless_nonce`) with zero FAIL lines; fuzzing smoke tests. - Both paths verified end-to-end: `turnutils_uclient -Z` against an `--external-ip` server now receives `error 508`, and an EVEN-PORT Allocate against a `--multiplex-peer` server (Linux) receives `error 508` with the matching server-log reason. |
||
|
|
142dd93b30 |
Add --stateless-nonce: bound memory under valid-structure STUN floods (#1999) (#2002)
Closes #1999. ## Problem A structurally valid STUN request from a spoofed UDP source commits real resources before any authentication: a child socket in the listener's address map plus an ~18KB `ts_ur_super_session` — allocated solely to store the random 401-challenge nonce — kept alive for 60 seconds by the to-be-allocated timer. Early packet validation (#1768) doesn't help against well-formed packets, and `--unauthorized-ratelimit` suppresses only the 401 *responses*, after the state already exists. At 10k spoofed pps that is hundreds of thousands of live sessions (multiple GB) from traffic that never authenticates. ## Approach With `--stateless-nonce` (off by default) the challenge nonce becomes an **authenticated timestamp cookie** instead of stored random state: ``` nonce = <8 hex chars: 32-bit issue time> || <16 hex chars: HMAC-SHA256(K, "<client-addr>|<ts-hex>") truncated> ``` Because the server can *validate* a nonce (parse timestamp → recompute MAC with a constant-time compare → check age against `--stale-nonce`, default 600s, with a 5s future-skew allowance), three things follow: 1. **Listener fast path** (`udp_stateless_nonce_fast_path`, `dtls_listener.c`): MESSAGE-INTEGRITY-less requests from unknown UDP sources are answered with the 401 challenge directly from the listener — no socket, no session. Packets the relay would silently ignore for a fresh source (indications, unbound channel data, STUN failing the full check) are dropped with no state either. 2. **Fresh-session nonce acceptance** (`check_stun_auth`): the client's authenticated retry lands on a brand-new session, which accepts a presented nonce whose MAC verifies for that client address and whose issue time is within the lifetime. Stale-nonce expiry is anchored to the real issue time. 3. **Challenge-session teardown**: a UDP session whose only output was a 401/438 (or whose challenge the ratelimit suppressed) is torn down immediately instead of idling for 60s. This bounds memory against floods that attach a garbage MESSAGE-INTEGRITY and therefore must travel the session path. Result: zero attacker memory cost for MI-less floods; one transient, immediately-freed session per packet for garbage-MI floods. `--stateless-nonce-secret=<secret>` (implies the flag) additionally derives the key via `K = SHA-256("coturn-stateless-nonce-v1" || secret)` so a fleet sharing the secret cross-validates nonces across restarts and load balancers. Without it the key is ephemeral per process. ## Wire transparency - The fast-path 401 replicates the session path byte-for-byte: same attributes in the same order (NONCE, REALM, THIRD-PARTY-AUTHORIZATION for oauth, SOFTWARE, FINGERPRINT), including realm selection by ORIGIN on ALLOCATE, plus the same `--unauthorized-ratelimit` suppression and Prometheus 401 counters. - Anything the fast path cannot answer identically falls through to the untouched session path: BINDING (RFC 5780), ALLOCATE under alternate/aux-server redirection, REFRESH under `--mobility`, CONNECTION-BIND, and all MI-carrying requests. - Nonce reuse within its lifetime is how RFC 8489 long-term auth already works (per-request freshness comes from MESSAGE-INTEGRITY over the random transaction ID); binding the nonce to the client address is strictly stronger than a stored random value. The nonce grows from 16 to 24 chars when the flag is on — RFC 8489 requires clients to echo nonces opaquely up to 128 chars. - With the flag off, every changed code path short-circuits to stock behavior. Design notes, threat model, and limitations: `docs/stateless-nonce.md`. ## Reviewer notes - The branch is four commits telling the story in order: the feature with windowed derivation, an int→bool type cleanup around the touched code, the switch to the timestamp-cookie format (exact expiry, no window scanning), and the shared fleet key. Happy to squash if preferred. - `NONCE_MAX_SIZE` grows to hold both nonce formats, and nonce emitters now use `strlen()` instead of the fixed `NONCE_MAX_SIZE - 1` (which would otherwise pad the shorter legacy random nonce). - `stun_is_command_message_full_check_str()` gained `bool` parameters (was `int`), rippling through its nine in-repo callers. - One pre-existing test-harness fix rides along: `run_tests.sh`/`run_tests_mobile.sh` raced clients against a still-initializing server on macOS (fixed `sleep 2` instead of log polling); both now poll on both platforms. The CLAUDE.md container command also gained `libmicrohttpd-dev` and a `git safe.directory` line it needs on a clean image. - Known limitation: key rotation for `--stateless-nonce-secret` means restarting with a new secret (one standard 438 re-auth per client); sign-with-new/verify-with-both is documented as future work. ## Validation - New Unity suite `tests/test_stateless_nonce.c`: format, determinism, per-input divergence, single-character tampering across the whole cookie, malformed inputs, age/skew boundaries, KDF determinism and secret binding, and a cross-server test (two keys from one secret validate each other's nonces; a third secret does not). - New end-to-end suite `examples/run_tests_stateless_nonce.sh`: standard relay workload with the feature on over UDP/TCP (+TLS/DTLS and threaded variants on Linux), asserting the listener fast path actually engaged and the secret-derived-key startup markers. - Full battery green on macOS and in a clean Linux container: cmake build + ctest, `run_tests.sh`, `run_tests_conf.sh`, `run_tests_mobile.sh`, `run_tests_multiplex_peer.sh`, `run_tests_rfc5780.sh`, `run_tests_stateless_nonce.sh`, fuzzing smoke (ASan targets 0/1), and the packaged Docker image bats tests (19 tests, 0 failures). |
||
|
|
ca87ff0511 |
Add --drain-min-allocations: shutdown threshold for drain mode (#1997)
## Summary In drain mode (entered on SIGUSR1) the server waits for the active allocation count to reach exactly 0 before shutting down. This PR adds `--drain-min-allocations=<number>` (also usable as a `turnserver.conf` key) so drain completes once the active allocation count drops **to the configured value or below**. The default of 0 preserves the existing behavior exactly. This is useful when a small number of long-lived allocations shouldn't hold up a rolling restart or decommission indefinitely. ## Changes - `turn_params_t` gains a `drain_min_allocations` field (default 0), wired through the option enum, long-options table, usage text, and config parsing. Negative values are logged and clamped to 0. - The drain check in `run_listener_server` becomes `global_allocation_count <= turn_params.drain_min_allocations`, and the "Drain complete" log line now reports the remaining count and threshold. - Documentation added to the example `turnserver.conf`, `README.turnserver`, and the `turnserver.1` man page (man page edited in place to match the committed txt2man output rather than regenerating, to avoid unrelated formatting churn). ## Validation - Functional smoke test: with `--drain-min-allocations=10` and 6 live allocations, SIGUSR1 shut the server down, logging `Drain complete (6 allocations remaining, threshold 10)`. Control run with the default kept draining while allocations were active. Config-file spelling verified. - macOS: build, all 15 unit tests, `run_tests.sh`, `run_tests_conf.sh`, `run_tests_mobile.sh`, `run_tests_multiplex_peer.sh`, `run_tests_rfc5780.sh` — all pass, no FAIL lines; clang-format clean. - Fuzzing smoke tests (ASan, both targets): pass. - Linux (Docker): clean build, ctest, all five system-test suites pass. - Packaged Debian Docker image (arm64): builds; Bats tests 19/19 pass (2 environment skips). |
||
|
|
89ee928358 |
Alternate server tcp udp distinction (#1605)
- Added separate TCP and UDP alternate server lists to resolve port ambiguity. - Enabled the use of an existing TLS counter for Alternate Server round-robin selection, fixing a bug where only the generic counter was used. - Ensures correct redirection for clients using different protocols (TCP/UDP). - Improves reliability and prevents misdirected connections in multi-protocol setups. Fixes #1604 --------- Co-authored-by: CUMHUR KARAHAN <cumhur.karahan@turkcell.com.tr> Co-authored-by: Gustavo Garcia <gustavogb@gmail.com> Co-authored-by: Pavel Punsky <eakraly@users.noreply.github.com> |
||
|
|
ce8b25737d |
feat: RFC 8016 graceful dual-5-tuple mobility handoff (#1975)
Historically a MOBILITY-TICKET resume hard-switched the allocation onto the new client socket at REFRESH time and closed the old socket immediately. RFC 8016 instead requires the server to keep receiving on, and transmitting peer->client data to, the old 5-tuple until the client is active on the new one, and only then discard the old 5-tuple. Implement that make-before-break handoff: - handle_turn_refresh no longer transfers the socket. It rotates the mobility ticket, refreshes the allocation on the original session, and opens a bounded transition: the original session keeps the allocation and its old socket (peer->client egress stays on the old 5-tuple), while the resuming session is kept alive and linked as "pending" and the REFRESH success response is sent on the new socket. - read_client_connection promotes the allocation onto the new socket on the client's first packet from the new 5-tuple (mobile_complete_transition): the socket is moved to the allocation session, which closes the old socket and discards the old 5-tuple, and the packet is then served by the allocation. RFC names the trigger as the first Send/ChannelData; we promote on any first packet so control requests (CreatePermission/ChannelBind/Refresh) a roaming client issues from the new address are served by the allocation. - The per-session sweep aborts a transition left open past a 30s deadline, conservatively keeping the allocation on the old path and reaping the pending session. shutdown_client_connection unlinks a transition partner on teardown. Transition state (session ids + deadline) is added to ts_ur_super_session; the pending session's un-allocated watchdog is disarmed and its stray mobile-map entry removed while it legitimately holds no allocation. Also align resume error codes with RFC 8016: an unusable/unknown ticket now returns 437 (Allocation Mismatch) instead of the non-standard 404. examples/run_tests_mobile.sh now asserts, from the server log, that the handoff actually executed (mobile_complete_transition emits a marker), so a regression that skipped or broke the transition fails the test. docs/mobility-rfc8016.md documents the design, the trigger/deadline semantics, the quota note, and the deferred ticket-entropy hardening item. |
||
|
|
54fe73b9f9 |
Implement a minimum log level option (#1222)
The `-v` or `--verbose` options change a few things, but they have
almost no effect
on the `turnserver`. This implements a `log_min_level` setting and a
`--log-min-level`
option to allow the user to set a minimum level below which log messages
are not
emitted.
The option takes a level *name* — `debug`, `info`, `warning` or `error`
(case-insensitive) — and is available both as a command-line flag and as
a
config-file key:
```
turnserver --log-min-level=warning
```
```ini
# turnserver.conf
log-min-level=warning
```
Scale, most to least verbose: `debug` (the default, i.e. current
behaviour) →
`info` → `warning` → `error`. An unrecognised value is reported and
ignored
rather than silently reinterpreted, so no argument can leave the server
mute.
Documented in `README.turnserver`, the `turnserver(1)` man page, and
both example
configs. Covered by a new unit test, `tests/test_log_min_level.c`.
---
### Note: this also drops the `CONTROL` log level
The `TURN_LOG_LEVEL_CONTROL` level is removed from the `TURN_LOG_LEVEL`
enum.
Nothing in the tree ever logged at that level — it existed only as an
enum value
and two `switch` arms in `ns_turn_utils.c` — so no logging behaviour
changes.
Two consequences worth flagging for reviewers:
- **It is a public-header/ABI break.** `ns_turn_utils.h` is installed as
a public
header (`${includedir}/turn`), so `TURN_LOG_LEVEL` is shipped API.
Removing the
middle enumerator renumbers `TURN_LOG_LEVEL_WARNING` (3 → 2) and
`TURN_LOG_LEVEL_ERROR` (4 → 3). Anything already compiled against
`libturnclient` that passes `TURN_LOG_LEVEL_ERROR` still passes `4`,
which is
now above every level, so its messages would be dropped. This deserves a
changelog note on release.
- **This is why `--log-min-level` takes a name and not a number.** With
the levels
renumbered, a numeric argument is a moving target across releases: an
earlier
revision of this PR documented `4 = errors only`, which after the enum
change
sat above every level and silenced the server entirely, fatal errors
included.
Level names cannot drift out of sync with the enum that way.
Relatedly, `log_min_level` is typed as `TURN_LOG_LEVEL` rather than
`int`.
The enum has no negative enumerators, so it takes an unsigned underlying
type;
as an `int`, a negative minimum was promoted in `level < log_min_level`
and
filtered out *every* message instead of none.
---------
Signed-off-by: Paul Wayper <paulway@redhat.com>
Co-authored-by: Pavel Punsky <eakraly@users.noreply.github.com>
|
||
|
|
554322e86c |
test: add RFC 5780 (NAT behavior discovery) coverage (#1982)
## Summary RFC 5780 (STUN NAT Behavior Discovery) support spans the STUN attribute codecs, the server-side `handle_turn_binding` path, and the `--rfc5780` relay wiring, but had no automated test. This adds two layers of coverage. ### Unit tests — `tests/test_stun_msg.c` Round-trip each RFC 5780 attribute through the same encode/decode helpers the server uses to build binding responses: - `CHANGE-REQUEST` — all four change-ip/change-port bit combinations - `RESPONSE-PORT` — value round-trip - `PADDING` — length round-trip - `OTHER-ADDRESS` / `RESPONSE-ORIGIN` — address encode/decode equality ### System test — `examples/run_tests_rfc5780.sh` Starts a `turnserver --rfc5780` bound to two loopback IPs (the ≥2-address condition `get_alt_addr()` requires) and drives `turnutils_stunclient -f`, asserting the client observes an `OTHER-ADDRESS` and the `CHANGE-REQUEST` / `RESPONSE-PORT` probe sequence end-to-end. Follows the existing `run_tests_*.sh` conventions (per-PID logs, readiness poll, `trap cleanup`). **SKIPs gracefully** (exit 0) on hosts without a second bindable loopback IP (e.g. macOS without a `127.0.0.2` alias) rather than hanging or failing. Registered the new script in `CLAUDE.md`'s local and Docker validation lists. ## Test plan - `ctest` unit suite: 16/16 pass in `test_stun_msg` (4 new). - System script in Linux (Docker): PASS — client logged `Other addr: 127.0.0.2:3479`, plus the `CHANGE-REQUEST` (origin shifted to the alternate IP+port) and `RESPONSE-PORT` follow-up probes. - macOS: correctly SKIPs (no second loopback IP). |
||
|
|
37df051316 |
fix: bind mobility session-resume to the original allocation owner (#1969)
In handle_turn_refresh, a MOBILITY-TICKET (MICE) resume selects the
original allocation from the mobile-connections map by the client-
supplied mobile id and is supposed to re-validate the REFRESH's
MESSAGE-INTEGRITY against that original allocation's credentials.
The credential adoption (copy_auth_parameters from the original session
into the resuming session) was gated on the resuming session not being
already authenticated:
if (!(ss->hmackey_set)) {
copy_auth_parameters(orig_ss, ss);
}
When the resuming session had already authenticated (hmackey_set == 1,
e.g. a persistent TCP/TLS control connection that authenticated under
its own account), the copy was skipped and check_stun_auth then verified
the REFRESH against the resuming session's own USERNAME/REALM/key
instead of the original allocation's. The authenticated identity was
never compared to the allocation being resumed, so a session could be
granted an allocation it did not own.
Make the adoption unconditional so check_stun_auth always validates the
resume against the original allocation owner's key, username, and realm.
Legitimate resumes are unaffected: they arrive on a fresh 5-tuple with
hmackey_set == 0 and already took the copy path; a same-user resume over
a reused connection copies identical credentials.
Add examples/run_tests_mobile.sh, which starts the server with
--mobility and drives turnutils_uclient -M (UDP/TCP, legacy and
threaded) so the session-resume branch gets automated coverage, and wire
it into the Linux CMake/Clang/autotools CI workflows. Document it in
CLAUDE.md.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
47ce433cde |
Add per-source rate-limiting of UDP 401 Unauthorized responses (#1957)
TURN/STUN long-term auth answers the first unauthenticated request with
a 401 Unauthorized carrying REALM/NONCE. On UDP, where source addresses
are spoofable and the 401 is larger than the request that triggers it,
the server becomes a reflection/amplification vector: an attacker forges
a victim's source IP and bounces amplified 401s at it.
This adds an opt-in mitigation that caps how many UDP 401 responses the
server emits toward any single source IP per second. Past the cap the
server stays silent, denying both the reflection and the amplification.
Authenticated relay traffic is untouched — only requests that reach the
401 branch spend budget.
Flags (off by default):
--unauthorized-ratelimit Enable the mitigation (UDP only).
--unauthorized-ratelimit-rps=<n> Max 401 responses per source IP per
second. Default 10.
Implementation:
- Lock-free, fixed 4096-bucket direct-mapped table (~80 KiB, no
malloc/free, no lock, no list scan on the hot path); the source IP
appears only as a 32-bit tag, never as a pointer. The port is stripped
from the key so an attacker cannot evade the limit by rotating the
source port. A fixed 1-second window backs the per-second limit.
- Colliding addresses share the live bucket budget instead of receiving
a fresh allowance, so hash collisions can never expand reflected output.
- Drop and collision diagnostics are each latched to one log line per
(bucket, window) to avoid log amplification.
- Portable 32-bit atomics over C11 <stdatomic.h> with an MSVC
Interlocked* fallback; the on/off flag and the per-second threshold are
pointers into turn_params so every relay thread sees live CLI values
without per-thread copies.
Observability (Prometheus):
- turn_unauthenticated_401_{requests,responses,dropped_responses}
- turn_ratelimit_{hash_collisions,occupied_buckets,total_buckets}
Tests & docs:
- Unity unit test (tests/test_ratelimit.c) covering live-bucket
collisions, occupancy tracking, and the collision counter.
- End-to-end positive/negative system test (run_tests_ratelimit_401.sh)
and a Prometheus counter test (run_tests_prom.sh).
- A 401-response-flood load harness under examples/loadtest/.
- docs/401-ratelimit.md design note, plus man page, README, Prometheus
docs, and the example/Docker turnserver.conf entries.
|
||
|
|
53f3ed9104 |
Prom https (#1956)
## Summary Adds an optional **HTTPS mode for the Prometheus `/metrics` endpoint**, and bundles three supporting improvements that surfaced while building and testing it: more reliable `libmicrohttpd` detection, a fix for slow `turnserver` startup on macOS, and expanded/robust Prometheus tests. ## Changes ### 1. Optional HTTPS for the Prometheus exporter The exporter previously served `/metrics` over plain HTTP only. It can now optionally serve over HTTPS via libmicrohttpd's TLS support. - `--prometheus-tls` — serve the metrics endpoint over HTTPS instead of HTTP. - `--prometheus-cert <file>` / `--prometheus-key <file>` — PEM cert/key for the endpoint; default to the server's own `--cert` / `--pkey` when unset. `start_prometheus_server()` loads the cert/key into memory, enables `MHD_USE_TLS`, and passes `MHD_OPTION_HTTPS_MEM_CERT/KEY`. If TLS is requested but no cert/key can be loaded (or libmicrohttpd lacks TLS support), it logs an error and does not start. The startup log now notes the `(http)`/`(https)` scheme. `docs/Prometheus.md` documents the new flags. *(`src/apps/relay/prom_server.c`, `mainrelay.c`, `mainrelay.h`, `docs/Prometheus.md`)* ### 2. Reliable `libmicrohttpd` detection in `configure` The autotools `configure` probed libmicrohttpd with a bare `cc -lmicrohttpd` link test (no include/lib paths), so it failed to find Homebrew-installed libmicrohttpd on macOS and silently disabled Prometheus — even though OpenSSL/libevent (also Homebrew) were found via pkg-config. `configure` now detects libmicrohttpd via `pkg-config` first (matching how libssl/libevent are already detected), falling back to the bare link probe on platforms without a `.pc` file. `./configure && make` now builds the exporter on macOS without manual `CFLAGS`/`LDFLAGS`. *(`configure`)* ### 3. Faster `turnserver` startup on macOS macOS lacks `pthread_barrier_*`, so the thread-startup rendezvous in `netengine.c` degraded to a flat `sleep(5)` — adding ~5s to every startup (and it was racy: threads proceeded even if setup took longer than the fixed sleep). Replaced the shim with a real portable barrier built on a mutex + condition variable, behind a unified macro API: **native `pthread_barrier` is still used on Linux**, the portable barrier is used only where it is unavailable. The previous racy `sched_yield()` spin-wait fallback is removed as well. *(`src/apps/relay/netengine.c`)* ### 4. Prometheus test coverage + robustness `examples/run_tests_prom.sh` now also covers the HTTPS endpoint (explicit cert/key, cert/key inherited from `--cert`/`--pkey`, and a negative test that an unreadable cert keeps the endpoint down, including a check that the TLS endpoint rejects plaintext HTTP). Readiness is now detected by polling the endpoint / server log instead of fixed `sleep` intervals, making the suite both faster (~70s → ~2s) and less flaky. *(`examples/run_tests_prom.sh`)* ## New flags | Flag | Description | |---|---| | `--prometheus-tls` | Serve `/metrics` over HTTPS. Requires libmicrohttpd built with TLS support. | | `--prometheus-cert <file>` | PEM certificate for the HTTPS endpoint (default: the server's `--cert`). | | `--prometheus-key <file>` | PEM private key for the HTTPS endpoint (default: the server's `--pkey`). | ## Validation - **macOS** (portable-barrier path): `clang-format` clean, 10/10 unit tests, and `run_tests.sh` / `run_tests_conf.sh` / `run_tests_multiplex_peer.sh` / `run_tests_prom.sh` all pass. Exporter now reachable at startup `t=0` (was `t=5`). - **Linux** (Docker, native-`pthread_barrier` path): clean build, 9/9 unit tests, and all four system-test suites pass (TCP/TLS/UDP/DTLS). --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
273f2e8a0c |
Add optional TLS transport for Redis connections (#1951)
The Redis driver previously connected only over plaintext TCP, so a Redis server configured for TLS-only (tls-port) was unreachable. Add an opt-in TLS transport built on the hiredis_ssl library, covering both the synchronous userdb path and the asynchronous statsdb path. Fixes #651 New connection-string keys (for --redis-userdb and --redis-statsdb): tls/ssl, ca/cacert, capath, cert, clientkey, sni/servername, verify (peer|none). Server-certificate verification is on by default. - Extract the connection-string parser into a dependency-free unit (dbd_redis_conninfo.c/.h) and unit-test it (tests/test_redis_conninfo.c). - hiredis_ssl is detected via pkg-config; without it tls=true is rejected with an explicit error and the build still compiles (hiredis-only). - The blocking sync handshake honors connect_timeout so a plaintext endpoint mistakenly addressed with tls=true cannot hang. - Document the options in docs/Redis.md, the man page, and example config. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
8c7d8fcb86 |
Enable --udp-recvmmsg by default on Linux (#1930)
## Summary Flips the Linux default for `--udp-recvmmsg` from **off** to **on**. Operators opt out with `--udp-recvmmsg=false` (or `=0`). > **Stacked on #1929.** This depends on the recvmmsg-scoping change in #1929 and is based on that branch, so the diff shows only the default-on change. GitHub will auto-retarget the base to `master` once #1929 merges. Merge #1929 first. ## Why this is now safe The original objection to default-on (recorded in `docs/PerformanceIterationLog.md`) was the **per-session-relay-socket prealloc tax**: `--udp-recvmmsg` applied the 16-buffer batch path to every connected relay socket, which only ever carries one flow, so the churn ate the listener-side win. #1929 scoped recvmmsg to **shared fan-in sockets only** (`udp_recvmmsg_eligible`: the client listener, plus the per-thread shared relay socket under `--multiplex-peer`). Per-session relay sockets now stay on the single-recv path regardless of the flag, so that tax is gone. The one socket touched by default — the client listener — is a genuine fan-in point: - batches whenever client concurrency is non-trivial (measured `avg_batch ≈ 16` under load), and - costs little when idle (few packets ⇒ few prealloc cycles). ## What changed - `mainrelay.c`: `turn_params.udp_recvmmsg` default `false → true` (Linux only). - Removed the now-dead `--multiplex-peer` auto-enable block and the `udp_recvmmsg_set_explicitly` tracking it relied on; multiplex-peer gets its recvmmsg window from the default. The opt-out flows through the normal `get_bool_value` path. - Help text, `man/man1/turnserver.1`, `examples/etc/turnserver.conf`, `CLAUDE.md`, and `docs/PerformanceIterationLog.md` updated for the new default + opt-out. Per-session relay sockets and DTLS session sockets are unchanged. ## Validation - **Format:** clang-format 15.0.7 clean. - **macOS:** build + ctest 6/6 + `run_tests.sh` pass. - **Linux (Docker, clean build):** ctest 5/5; `run_tests.sh`, `run_tests_conf.sh`, `run_tests_multiplex_peer.sh` all pass (no FAIL). - **Runtime proof (loopback, `--udp-recvmmsg-log`):** - Default, no flag: recvmmsg active, `calls=13714 packets=219306 avg_batch=15.99`. - `--udp-recvmmsg=false`: zero recvmmsg activity — opt-out confirmed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
9f488fe323 |
Reap TURN permissions/channels via a per-thread sweep instead of per-object timers (#1926)
## Summary Each TURN permission and channel carried its own libevent timer, torn down and recreated (`IOA_EVENT_DEL` + `set_ioa_timer`) on **every** refresh. At high allocation counts this is the dominant scheduling and memory cost: ~200k+ live libevent events at 100k allocations (≥2 per allocation), each ~228 B of resident heap (a `timer_event`, a libevent `struct event`, and a `strdup` of the handler name) plus a min-heap node. This replaces per-object timers with a single **per-thread sweep**. `timer_timeout_handler` already runs once a second per relay thread on that thread's own engine; after refreshing `ctime` it now walks the thread-local `sessions_map` and reaps permissions/channels whose `expiration_time` has passed, **reusing the existing `client_ss_perm_timeout_handler` / `client_ss_channel_timeout_handler`** so teardown (including `mp_deregister_permission_peers` in multiplex-peer mode) is unchanged. `update_turn_permission_lifetime` / `update_channel_lifetime` now just push `expiration_time` forward. The now-dead `lifetime_ev` fields are removed from `turn_permission_info` (648→640 B) and `ch_info` (64→56 B), along with the spurious "strange permission" error log that would otherwise fire on every sweep-driven expiry. ## Why For workloads with many concurrent allocations (e.g. 100k VoIP sessions), the per-object timer model puts ~200k+ nodes in libevent's min-heap and burns a free/malloc/strdup cycle on every refresh. The expiry deadline is already stored in `expiration_time`; a periodic sweep makes the timer redundant. ## Trade-offs - Expiry latency rises to **≤1s** (negligible vs 300–600s permission/channel TTLs). - The sweep is a bounded per-thread array walk — ~39k trivial comparisons/thread/s at 100k allocations over 128 threads. ## Performance (measured) Faithful libevent 2.1.12 microbenchmark of the create/refresh/destroy path: | Metric | Before | After | |---|---|---| | Resident heap per timer | ~228 B | 0 | | Refresh op | 110 ns (del+create at 100k heap depth) | 0.28 ns (field store) | | `sizeof(turn_permission_info)` | 648 B | 640 B | | `sizeof(ch_info)` | 64 B | 56 B | At 100k allocations (≥2 timers each): **~46 MB** of libevent objects freed and ~200k fewer min-heap nodes. ## Testing - Unit (`ctest`), `run_tests.sh`, `run_tests_conf.sh`, `run_tests_multiplex_peer.sh` — pass on macOS and in a clean Ubuntu 24.04 Docker build. - New `examples/run_tests_expiry.sh`: forces 4s server-side permission/channel lifetimes so the sweep must reap mid-session, and asserts the verbose log shows reaping while the server stays healthy. **Verified to FAIL when the sweep call is disabled** (negative control), confirming it catches the regression rather than passing trivially. |
||
|
|
e0c37a3aa0 |
Multiplexpeer (#1916)
## Summary Adds **`--multiplex-peer`**, a non-standard relay mode that replaces the per-allocation peer-side port bind with **one shared IPv4+IPv6 UDP socket pair per relay thread**. Sessions are demultiplexed by exact peer IP:port in a per-thread `mp_table`. This lifts the ~16 k allocation cap that the default 49152-65535 relay port range imposes, and dramatically reduces kernel-level UDP receive-buffer drops under high pps. Design and trade-offs: [docs/multiplex-peer.md](docs/multiplex-peer.md). ## What changes ### Server (turnserver) - **`--multiplex-peer`** (cross-platform) — enable the shared per-thread relay sockets. Replaces the per-session port bind. Implies sendmmsg batching on Linux and default-enables `--udp-recvmmsg` (override with `--udp-recvmmsg=0`). Incompatible with EVEN-PORT — those Allocates are rejected with 400. - **`--multiplex-peer-port <port>`** (cross-platform, default 3480) — base port; thread `i` binds `<base>+2i` (IPv4) and `<base>+2i+1` (IPv6). A 4-thread server consumes 8 ports. - **`--udp-gso`** (Linux-only CLI) — UDP-GSO (`UDP_SEGMENT` cmsg) on the relay send path. Requires `--multiplex-peer` (which is what enables the sendmmsg batching GSO piggybacks on); passing `--udp-gso` alone is a silent no-op. - **CLI surface tightened**: `--udp-recvmmsg`, `--udp-recvmmsg-log`, `--udp-gso` and their fields are now `#if defined(__linux__)` — absent from `--help`, rejected with `unrecognized option`, and the code paths compile out on macOS/Windows. - **Windows portability**: `SO_REUSEPORT` in `mp_open_socket` wrapped in `#ifdef` (MSVC's Winsock doesn't define it; REUSEPORT was defensive anyway because the per-thread port layout is unique by construction). - **`--sock-buf-size` honoured at startup**: the shared multiplex-peer relay socket now calls `set_ioa_socket_buf_size` in `mp_open_socket` so the configured rcvbuf is in effect from the moment the socket exists, not deferred to the first Allocate. ### turnutils_uclient (loadgen) - **`--no-even-port`** — force `ep = -1` on Allocate. The default path randomly attaches EVEN-PORT (with no-R bit) even under `-c`, which `--multiplex-peer` strictly rejects with 400; this flag makes alloc-flood runs against multiplex-peer deterministic. - **Legacy `timer_handler` now wraps the per-tick send batch with `uclient_send_batch_begin/_end`** — without this, runs with `--sender-threads 0` (the default for `-m < 4`) silently fell through every send to plain `send(2)`. strace A/B: 205 k `sendto` → 61 k `sendmsg` (GSO) + 4 k `sendmmsg` + small `sendto` residual for control. ## Measured impact (3-droplet DigitalOcean, c-4 / 4 vCPU, 8 concurrent UDP streams, 45 s) | | baseline | `--udp-recvmmsg` | `--multiplex-peer` | `--multiplex-peer --udp-gso` | |---|---:|---:|---:|---:| | Server NIC rx pps (UDP relay both legs) | 350 k | 334 k | 326 k | 294 k | | Server `UdpInDatagrams` pps | 279 k | 292 k | 300 k | 294 k | | **Server `UdpRcvbufErrors` pps** | **71 k** | 42 k | 26 k | **0.3 k (−99.6 %)** | | **`turnserver` process CPU** | **387 %** | 205 % | 283 % | **133 % (−65 %)** | | Server host idle | 22 % | 49 % | 41 % | **68 %** | Same loadgen-side packet rate (~2 M pps reported by uclient `send_pps` after the legacy-path batching fix). Iteration log: [docs/PerformanceIterationLog.md](docs/PerformanceIterationLog.md). ## Test plan - [x] `ctest --test-dir build` — 3/3 pass (test_ioaddr, test_stun_msg, test_http_server) on macOS + Linux. - [x] `examples/run_tests.sh` — 4 protocols + 4 threaded + load-gen smoke on Linux; 4 protocols on macOS. - [x] `examples/run_tests_conf.sh` — same coverage, conf-driven. - [x] `examples/run_tests_multiplex_peer.sh` — UDP/TCP/TLS/DTLS via `--multiplex-peer --multiplex-peer-port=35000` on macOS + Linux. - [x] Flag matrix smoke on macOS: `--multiplex-peer`, `--multiplex-peer-port=42000`, `--multiplex-peer --udp-gso` (no-op), `uclient --no-even-port`, `uclient --listener-threads N --sender-threads M` — all pass; `--udp-recvmmsg` / `--udp-gso` correctly rejected with `unrecognized option`. - [x] Flag matrix smoke on Linux (Docker): same + `--udp-recvmmsg` accepted, `--multiplex-peer` auto-enables `--udp-recvmmsg`, `--udp-recvmmsg=0` overrides the auto-enable. - [x] Windows compile fix verified — `SO_REUSEPORT` no longer referenced unconditionally. - [x] 3-droplet perf matrix completed; per-hop UDP counters captured. ## Docs updated - New: [docs/multiplex-peer.md](docs/multiplex-peer.md) - [README.turnserver](README.turnserver): full entries for `--multiplex-peer`, `--multiplex-peer-port`, `--udp-gso`; clarified `--udp-recvmmsg` auto-enable semantics. - [README.turnutils](README.turnutils): added `--no-even-port`, plus previously-undocumented `--listener-threads` / `--sender-threads` loadgen pool flags. - [examples/etc/turnserver.conf](examples/etc/turnserver.conf): commented `udp-recvmmsg`, `udp-recvmmsg-log`, `udp-gso`, `multiplex-peer`, `multiplex-peer-port` keys with one-paragraph descriptions and pointer to `docs/multiplex-peer.md`. - Man pages regenerated via `./make-man.sh`. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
7cde430a98 |
examples: exercise uclient thread pools, UDP-GSO, recv_pps in CI tests (#1914)
## Summary `examples/run_tests.sh` and `examples/run_tests_conf.sh` previously covered only the legacy single-threaded uclient path. After recent PRs they were no longer exercising: - `--listener-threads` from #1911 (recv thread pool) - `--sender-threads` from #1913 (send thread pool) - `--udp-gso` from #1907 (server-side GSO send) - the `recv_pps` metric introduced in #1913 A regression in any of those would have shipped silently. This PR expands both scripts so every CI cycle hits all four features end-to-end. ## What changes **Per-protocol coverage doubled.** The four protocol tests (TCP / TLS / UDP / DTLS) are factored into a shell function and each runs twice: 1. Default uclient flags — legacy single-thread paths. 2. `--listener-threads 1 --sender-threads 1` — engages both pools at minimum non-zero size, so the slab counters, per-thread libevent base, and pick_listener_base / pick_sender_id paths all fire. The `grep "tot_send_bytes ~ 1000, tot_recv_bytes ~ 1000"` target is stable across both variants because the workload (`-m 1 -n default=5 -l 200`) totals 1000 bytes either way — threading doesn't change the byte count, only the machinery that produces it. **Server-side GSO enabled on Linux.** `run_tests.sh` adds `--udp-gso` next to the existing `--udp-recvmmsg`. `run_tests_conf.sh` writes both keys into the generated `turnserver.conf` on Linux. The conf-file parser uses the same long-options table as the CLI (see `mainrelay.c` `long_options`), so the keys map 1:1. **Load-gen smoke on Linux.** A short `-Y packet -m 4 -l 100 -c` run with `--sender-threads 2` ends each script: ```bash timeout -s INT 6s turnutils_uclient \ -Y packet -m 4 -l 100 -c -e 127.0.0.1 -g \ --listener-threads 1 --sender-threads 2 \ -u user -W secret 127.0.0.1 ``` The grep asserts the progress print contains a **non-zero** `send_pps` AND a **non-zero** `recv_pps`. Non-zero `recv_pps` is the canonical signal that the listener-pool slab reduction (`recv_count_snapshot`) is feeding back into the load-rate reporter — the codepath most likely to silently drop output if the thread pool's stop ordering or slab plumbing regresses. `timeout -s INT 6s` triggers a clean SIGINT-driven exit; exit codes 0, 124, and 130 all count as success because we just want a fixed-duration run. ## Test plan Linux Docker (authoritative): ``` === run_tests.sh === Using TURNSERVER_EXTRA_ARGS="--udp-recvmmsg --udp-gso" Running turn client TCP OK Running turn client TLS OK Running turn client UDP OK Running turn client DTLS OK Running turn client TCP (threaded) OK Running turn client TLS (threaded) OK Running turn client UDP (threaded) OK Running turn client DTLS (threaded) OK Running turn client UDP load-gen smoke OK === run_tests_conf.sh === Running turn client TCP OK Running turn client TLS OK Running turn client UDP OK Running turn client DTLS OK Running turn client TCP (threaded) OK Running turn client TLS (threaded) OK Running turn client UDP (threaded) OK Running turn client DTLS (threaded) OK Running turn client UDP load-gen smoke OK ``` 9/9 in each. macOS local TCP test fails the same way it did before this change (pre-existing flake on Darwin loopback TCP, unrelated to threading or GSO). - [x] Both scripts run end-to-end in Linux Docker against a fresh build. - [x] `--help` flag list verified: `--listener-threads`, `--sender-threads`, `-Y` all present. - [x] No new dependencies; only uses `timeout` (already in coreutils on Linux base images). |
||
|
|
faff5bf106 |
examples/turnserver.conf: update description of cli option (#1909)
the previous description does not describe the correct default since
commit
|
||
|
|
69bc0e7351 |
Load generator mode in turnutils_uclient (#1894)
## Summary Adds load-generator modes to `turnutils_uclient` for repeatable TURN server performance testing: - Adds `-Y packet|alloc|invalid` load modes. - Supports packet flood, allocation flood, and invalid-packet flood workflows. - Adds unique local client ports for allocation flood mode. - Removes default packet pacing in load-generator modes unless explicitly set. - Adds helper scripts under `examples/loadtest/`. - Documents load-test usage in `README.turnutils`, `man/man1/turnutils.1`, `CLAUDE.md`, and `docs/PerformanceIterationLog.md`. The performance log captures DigitalOcean benchmark methodology, A/B lessons, hot-path findings, and future optimization candidates. |
||
|
|
3294c714aa |
Add Linux-only recvmmsg receive path for DTLS/UDP listener (#1852)
## Summary This change lets the listener batch incoming UDP datagrams when `--udp-recvmmsg` is enabled, reducing per-packet overhead on busy listeners while preserving the existing behavior as the default and fallback path. ## What changed - add a new `--udp-recvmmsg` runtime flag - implement a batched UDP receive path in the DTLS listener using `recvmmsg()` - reuse packet classification and datagram processing logic across batched and non-batched receive paths - reduce buffer/metadata churn by reusing listener-side scratch state and network buffers - keep compatibility safeguards by falling back when `recvmmsg()` is unavailable or unsupported - expose the setting in admin/CLI configuration output - update the example test runner to enable the flag on Linux ## Why The current listener processes UDP datagrams one at a time. On Linux, `recvmmsg()` allows the server to receive multiple packets per syscall, which should improve throughput and lower CPU overhead under load for UDP-heavy traffic. ## Notes - the feature is opt-in and defaults to disabled - the implementation is Linux-specific and leaves the existing path unchanged on other platforms - the listener still falls back to the legacy receive path if batched receive is unavailable at runtime ## Testing - updated `examples/run_tests.sh` to pass `--udp-recvmmsg` on Linux - validated behavior through the existing listener flow and fallback handling |
||
|
|
69aa0e4401 |
Keep only NEV_UDP_SOCKET_PER_THREAD network engine (#1849)
Remove the two engine implementations (NEV_UDP_SOCKET_PER_SESSION and NEV_UDP_SOCKET_PER_ENDPOINT) and all the dispatch/selection logic around them. NEV_UDP_SOCKET_PER_THREAD is now the sole, unconditional implementation. - mainrelay.h: removed _NET_ENG_VERSION enum, typedef, and net_engine_version / net_engine_version_txt struct fields - mainrelay.c: removed NE_TYPE_OPT CLI option, set_network_engine(), per-endpoint branch in print_features(), and all remaining net_engine_version references - netengine.c: removed run_udp_listener_thread(), setup_socket_per_endpoint_udp_listener_servers() (~190 lines), setup_socket_per_session_udp_listener_servers() (~90 lines); simplified setup_barriers(), setup_relay_server(), run_general_relay_thread(), setup_general_relay_servers(), and setup_server() by eliminating all engine-type conditionals - turn_admin_server.c: replaced dynamic engine version lookups with hardcoded values (3 / "UDP thread per CPU core") in CLI and HTTPS status handlers --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
6e8dd731a7 |
Update config and Readme files about deprecated TLSv1/1.1 (#1848)
This commit only updates documentation - the change has been done in #1693 |
||
|
|
17b15e39cd |
Fix tests execution in CI (#1836)
Small fixes across CI workflows and test scripts: - In examples/run_tests.sh & examples/run_tests_conf.sh: ensure both turnserver and the turnutils_peer background process are killed at the end - cmake.yml so binaries end up in the expected folder - linux.yml - add install so that binaries are in the expected folder |
||
|
|
75f8133c40 | Fixes: run_tests.sh and no db (#1834) | ||
|
|
9467af5041 |
CLI interface is disabled by default (#1830)
cli interface is ON by default which creates a security risk (even though requires a password) and recommended to be disabled. Instead of just recommendation, this PR disables CLI by default and now requires an explicit flag to enable it If using old configuration or cli arguments to turnserver - it will log an error message about `--no-cli` being deprecated while doing nothing (already disabled). This log line will be removed in the future |
||
|
|
20d8e38297 |
Disable reason string in response messages to reduce amplification factor (#1829)
Disable the messages by default - they can be re-enabled using `--include-reason-string` option As a result of not sending reason string (which is optional by standard and provide debugging information for the actual numeric error code) response message size can be decreased by up to NNN bytes. |
||
|
|
e59f0ffeaa |
Fix thread sanitizer warning (#1812)
Resolves all sanitizer warnings caused by 1. simultaneous access to logger time 2. barrier during threads initialization at startup |
||
|
|
0177bcd373 |
Update version to 4.8.0 (#1791)
Update version to 4.8.0 Set new release version to 4.8.0 Contains security fixes and highly recommended to upgrade |
||
|
|
21ddce65be |
Fix crashes while setting socket buffer size (#1789)
Some places in code do not have access to the buffer size which result in crash which can be seen in tests This PR removes the call to `set_ioa_socket_buf_size` from those places (which is redundant anyway) |
||
|
|
59921981bb |
Implement configurable buffer sizes (#1780)
# Description Replace the hardcoded buffer sizes inside coturn to make them configurable for different use cases (low bitrate use cases can save memory and high bitrate use case can avoid congestion) - based on #1089 Add this feature in both sides (listener and relay connections). # Tests For now it is only the automated CI tests. Confirmed with debugger that buffer sizes are set according to the arguments. |
||
|
|
99984fbccd |
Add configurable CPU count option for containerized environments (#1717)
This PR adds a new `--cpus` configuration option to address CPU detection issues in virtualized and containerized environments where `_SC_NPROCESSORS_CONF` and `_SC_NPROCESSORS_ONLN` return host CPU counts instead of allocated container CPUs. ## Problem In containerized deployments, coturn detects the host's CPU count (e.g., 128 CPUs) instead of the container's allocated CPUs (e.g., 2 CPUs). This causes the server to create excessive relay threads and database connections, leading to resource exhaustion and performance issues. ## Solution Added a new `cpus` configuration option that allows manual override of CPU detection: ### Command Line Usage ```bash turnserver --cpus 2 ``` ### Configuration File Usage ```ini # Override system CPU count detection for containers cpus=2 ``` ## Key Features - **Backward Compatible**: No changes needed for existing deployments - **Input Validation**: Values must be between 1 and 128 with proper error handling - **Comprehensive Documentation**: Updated man pages and example config files - **Both Interfaces**: Works via command line and configuration file ## Testing The implementation has been thoroughly tested: ```bash # Container with 2 allocated CPUs on 128-CPU host $ turnserver --cpus 2 INFO: System cpu num is 128 # Host detection INFO: System enable num is 128 # Host detection INFO: Configured cpu num is 2 # Override applied INFO: Total General servers: 2 # Correct thread count ``` - ✅ Command line option: `--cpus 8` creates 8 relay servers - ✅ Config file option: `cpus=6` creates 6 relay servers - ✅ Error handling: Invalid values show appropriate errors - ✅ Default behavior: Without option, uses system detection - ✅ RFC5769 tests: All protocol tests still pass ## Files Modified - `src/apps/relay/mainrelay.c` - Core implementation - `src/apps/relay/mainrelay.h` - Added configuration flag - `examples/etc/turnserver.conf` - Added documentation and example - `man/man1/turnserver.1` - Updated man page This change directly addresses the resource consumption issues in containerized environments while maintaining full backward compatibility. Fixes #1628. |
||
|
|
678996a529 |
Update version to 4.7.0 (#1691)
Set new release version to 4.7.0 Updating minor version due to some breaking changes in options to enable more secure/robust configuration without additional flags (or relying on recommended conf file which people seem to skip during updates) |