## 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
* 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>
## 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>
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>
## 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>
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>
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)