1
0
mirror of https://github.com/coturn/coturn.git synced 2026-10-24 00:08:57 +03:00

234 Commits

Author SHA1 Message Date
Pavel Punsky
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.
2026-10-11 17:00:40 -07:00
Pavel Punsky
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>
2026-10-11 16:59:49 -07:00
Pavel Punsky
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)
2026-09-07 15:43:30 -07:00
Pavel Punsky
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>
2026-08-31 18:21:15 -07:00
Pavel Punsky
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>
2026-08-30 17:47:26 -07:00
Pavel Punsky
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.
2026-08-24 12:41:58 -07:00
Pavel Punsky
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>
2026-08-23 18:20:46 -07:00
Pavel Punsky
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>
2026-08-23 13:52:18 -07:00
Pavel Punsky
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.
2026-08-06 12:12:06 -07:00
Pavel Punsky
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
2026-08-04 21:08:28 -07:00
Pavel Punsky
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>
2026-08-02 20:12:52 -07:00
Pavel Punsky
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>
2026-08-02 19:32:25 -07:00
Pavel Punsky
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.
2026-08-01 22:23:16 -07:00
Pavel Punsky
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>
2026-08-01 20:39:55 -07:00
Pavel Punsky
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>
2026-07-31 22:57:23 -07:00
Pavel Punsky
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>
2026-07-31 22:16:45 -07:00
Pavel Punsky
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>
2026-07-26 22:39:45 -07:00
Pavel Punsky
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`.
2026-07-26 19:56:41 -07:00
Pavel Punsky
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.
2026-07-26 12:08:00 -07:00
Pavel Punsky
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.
2026-07-25 22:51:41 -07:00
Pavel Punsky
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.
2026-07-25 22:51:24 -07:00
Pavel Punsky
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).
2026-07-25 16:16:01 -07:00
Pavel Punsky
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).
2026-07-21 22:08:49 -07:00
Cybermilitia
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>
2026-07-16 18:38:55 -10:00
Pavel Punsky
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.
2026-07-14 21:28:29 -10:00
Paul Wayper
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>
2026-07-14 10:27:46 -10:00
Pavel Punsky
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).
2026-07-12 19:43:51 -10:00
Pavel Punsky
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>
2026-07-03 16:01:31 -07:00
Pavel Punsky
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.
2026-06-21 14:25:09 -07:00
Pavel Punsky
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>
2026-06-20 23:02:01 -07:00
Pavel Punsky
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>
2026-06-20 16:04:29 -07:00
Pavel Punsky
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>
2026-05-30 21:27:05 -07:00
Pavel Punsky
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.
2026-05-30 16:50:27 -07:00
Pavel Punsky
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>
2026-05-17 19:16:55 -07:00
Pavel Punsky
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).
2026-05-16 14:55:56 -07:00
nfuhler
faff5bf106 examples/turnserver.conf: update description of cli option (#1909)
the previous description does not describe the correct default since
commit 9467af5
2026-05-10 21:12:56 -07:00
Pavel Punsky
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.
2026-05-03 22:03:08 -07:00
Pavel Punsky
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
2026-04-10 08:56:28 -07:00
Pavel Punsky
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>
2026-04-06 19:26:46 -07:00
Pavel Punsky
6e8dd731a7 Update config and Readme files about deprecated TLSv1/1.1 (#1848)
This commit only updates documentation - the change has been done in
#1693
2026-04-05 20:49:46 -07:00
Pavel Punsky
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
2026-04-04 13:09:53 -07:00
Pavel Punsky
75f8133c40 Fixes: run_tests.sh and no db (#1834) 2026-03-12 22:01:28 -07:00
Pavel Punsky
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
2026-03-06 18:00:45 -08:00
Pavel Punsky
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.
2026-03-06 17:59:21 -08:00
Pavel Punsky
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
2026-02-23 16:16:27 -08:00
Pavel Punsky
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
2026-01-05 17:35:27 -08:00
Pavel Punsky
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)
2025-12-26 21:10:24 -08:00
Pavel Punsky
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.
2025-12-25 14:06:28 -08:00
Copilot
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.
2025-07-10 15:37:02 +02:00
Pavel Punsky
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)
2025-05-30 14:13:59 -07:00