Skip to content

Commit 9953529

Browse files
committed
test: dtls test improvements
Signed-off-by: James M Snell <jasnell@gmail.com> Assisted-by: Claude/Opus
1 parent 344f9f2 commit 9953529

14 files changed

Lines changed: 697 additions & 7 deletions

src/dtls/dtls.h

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,16 +58,18 @@ void RecordTimestampStat(Stats* stats) {
5858
V(MESSAGES_SENT, messages_sent) \
5959
V(RETRANSMIT_COUNT, retransmit_count)
6060

61-
// State indices shared between C++ and JS via AliasedStruct/DataView.
61+
// State "indices" shared between C++ and JS via AliasedStruct/DataView. These
62+
// are BYTE OFFSETS into the state struct, not sequential indices: session_count
63+
// is a uint32, so `busy`, which follows it, sits at byte offset 8. The
64+
// static_asserts in dtls_endpoint.cc pin these to the actual struct layout.
6265
// Keep in sync with lib/internal/dtls/state.js.
6366
enum DTLSEndpointStateIndex {
6467
IDX_ENDPOINT_STATE_BOUND = 0,
65-
IDX_ENDPOINT_STATE_LISTENING,
66-
IDX_ENDPOINT_STATE_CLOSING,
67-
IDX_ENDPOINT_STATE_DESTROYED,
68-
IDX_ENDPOINT_STATE_SESSION_COUNT,
69-
IDX_ENDPOINT_STATE_BUSY,
70-
IDX_ENDPOINT_STATE_COUNT
68+
IDX_ENDPOINT_STATE_LISTENING = 1,
69+
IDX_ENDPOINT_STATE_CLOSING = 2,
70+
IDX_ENDPOINT_STATE_DESTROYED = 3,
71+
IDX_ENDPOINT_STATE_SESSION_COUNT = 4,
72+
IDX_ENDPOINT_STATE_BUSY = 8,
7173
};
7274

7375
enum DTLSSessionStateIndex {

src/dtls/dtls_endpoint.cc

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
#include <openssl/err.h>
2020
#include <openssl/ssl.h>
2121

22+
#include <cstddef>
2223
#include <cstring>
2324

2425
namespace node {
@@ -45,6 +46,22 @@ struct SendReq {
4546
};
4647
} // namespace
4748

49+
// The endpoint state "indices" are byte offsets into DTLSEndpointStateData,
50+
// accessed from JS via a DataView. Pin them to the actual struct layout so a
51+
// mismatch (as once existed for `busy`, which follows a uint32) can't recur.
52+
static_assert(IDX_ENDPOINT_STATE_BOUND ==
53+
offsetof(DTLSEndpointStateData, bound));
54+
static_assert(IDX_ENDPOINT_STATE_LISTENING ==
55+
offsetof(DTLSEndpointStateData, listening));
56+
static_assert(IDX_ENDPOINT_STATE_CLOSING ==
57+
offsetof(DTLSEndpointStateData, closing));
58+
static_assert(IDX_ENDPOINT_STATE_DESTROYED ==
59+
offsetof(DTLSEndpointStateData, destroyed));
60+
static_assert(IDX_ENDPOINT_STATE_SESSION_COUNT ==
61+
offsetof(DTLSEndpointStateData, session_count));
62+
static_assert(IDX_ENDPOINT_STATE_BUSY ==
63+
offsetof(DTLSEndpointStateData, busy));
64+
4865
DTLSEndpoint::DTLSEndpoint(Environment* env, Local<Object> wrap)
4966
: HandleWrap(env,
5067
wrap,
@@ -268,6 +285,11 @@ void DTLSEndpoint::CloseGracefully() {
268285

269286
server_context_.reset();
270287

288+
// Keep ourselves alive until OnClose() runs, so a garbage collection while
289+
// uv_close() is in flight cannot collect the wrapper before the close is
290+
// reported. Released in OnClose().
291+
self_ref_ = BaseObjectPtr<DTLSEndpoint>(this);
292+
271293
// HandleWrap::Close() calls uv_close and manages the lifecycle.
272294
HandleWrap::Close();
273295
}
@@ -293,6 +315,9 @@ void DTLSEndpoint::Destroy() {
293315
state_->listening = 0;
294316
}
295317

318+
// Keep ourselves alive until OnClose() (see CloseGracefully()).
319+
self_ref_ = BaseObjectPtr<DTLSEndpoint>(this);
320+
296321
HandleWrap::Close();
297322
}
298323

@@ -402,6 +427,17 @@ void DTLSEndpoint::OnClose() {
402427
state_->destroyed = 1;
403428
DTLS_STAT_RECORD_TIMESTAMP(DTLSEndpointStats, destroyed_at);
404429

430+
// Release the strong self-reference taken when the close was initiated.
431+
// HandleWrap::OnClose still holds its own reference for the duration of this
432+
// call, so this does not free us here.
433+
self_ref_.reset();
434+
435+
// A close initiated outside CloseGracefully()/Destroy() (e.g. an endpoint
436+
// abandoned mid-construction and closed at environment teardown) takes no
437+
// self-reference, so its wrapper may already be collected. There is no JS
438+
// side to notify in that case; skip it rather than touch a freed wrapper.
439+
if (persistent().IsEmpty()) return;
440+
405441
Local<Function> cb = GetCallback(DTLS_CB_ENDPOINT_CLOSE);
406442
if (!cb.IsEmpty()) {
407443
Local<Value> argv[] = {};

src/dtls/dtls_endpoint.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,11 @@ class DTLSEndpoint final : public HandleWrap {
156156
AliasedStruct<DTLSEndpointStateData> state_;
157157
AliasedStruct<DTLSEndpointStats> stats_;
158158

159+
// Strong self-reference held while a graceful close/destroy is in flight, so
160+
// the wrapper is not garbage-collected before OnClose() runs and reports the
161+
// close. Cleared in OnClose().
162+
BaseObjectPtr<DTLSEndpoint> self_ref_;
163+
159164
bool listening_ = false;
160165
uint32_t mtu_ = 1200; // Conservative default MTU for data payload
161166
};
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// Flags: --experimental-dtls --no-warnings
2+
3+
// Test: DTLSEndpoint/DTLSSession state fields and callback accessors reflect
4+
// what is set and the connection lifecycle.
5+
6+
import {
7+
hasCrypto, skip, mustCall, mustNotCall, mustCallAtLeast,
8+
} from '../common/index.mjs';
9+
import assert from 'node:assert';
10+
import * as fixtures from '../common/fixtures.mjs';
11+
12+
const { strictEqual } = assert;
13+
const { readKey } = fixtures;
14+
15+
if (!hasCrypto) {
16+
skip('missing crypto');
17+
}
18+
19+
if (!process.features.dtls) {
20+
skip('DTLS is not enabled');
21+
}
22+
23+
const { listen, connect } = await import('node:dtls');
24+
25+
const cert = readKey('agent1-cert.pem').toString();
26+
const key = readKey('agent1-key.pem').toString();
27+
const ca = readKey('ca1-cert.pem').toString();
28+
29+
const gotServerSession = Promise.withResolvers();
30+
31+
const server = listen(mustCall((session) => {
32+
gotServerSession.resolve(session);
33+
}), { cert, key, port: 0, host: '127.0.0.1' });
34+
35+
// --- Endpoint state after listen(): bound and listening. ---
36+
const es = server.state;
37+
strictEqual(es.bound, true);
38+
strictEqual(es.listening, true);
39+
strictEqual(es.closing, false);
40+
strictEqual(es.destroyed, false);
41+
strictEqual(es.sessionCount, 0);
42+
43+
// The busy property is settable via the endpoint and reflected in the state view.
44+
strictEqual(server.busy, false);
45+
strictEqual(es.busy, false);
46+
server.busy = true;
47+
strictEqual(server.busy, true);
48+
strictEqual(es.busy, true);
49+
server.busy = false;
50+
strictEqual(es.busy, false);
51+
52+
// --- Endpoint onerror accessor. ---
53+
strictEqual(server.onerror, undefined);
54+
const onEndpointError = mustNotCall();
55+
server.onerror = onEndpointError;
56+
strictEqual(server.onerror, onEndpointError);
57+
58+
const client = connect('127.0.0.1', server.address.port, {
59+
ca: [ca],
60+
rejectUnauthorized: false,
61+
});
62+
63+
// --- Session state during the handshake. ---
64+
const cs = client.state;
65+
strictEqual(cs.handshaking, true);
66+
strictEqual(cs.open, false);
67+
strictEqual(cs.closing, false);
68+
strictEqual(cs.destroyed, false);
69+
strictEqual(cs.hasMessageListener, false);
70+
71+
// --- Session callback accessors: unset, then set. ---
72+
strictEqual(client.onmessage, undefined);
73+
strictEqual(client.onerror, undefined);
74+
strictEqual(client.onhandshake, undefined);
75+
strictEqual(client.onkeylog, undefined);
76+
// A connect() session owns its internal endpoint.
77+
strictEqual(client.ownsEndpoint, true);
78+
79+
client.onmessage = mustNotCall();
80+
strictEqual(typeof client.onmessage, 'function');
81+
// Attaching a message listener flips the shared flag.
82+
strictEqual(cs.hasMessageListener, true);
83+
84+
client.onerror = mustNotCall();
85+
strictEqual(typeof client.onerror, 'function');
86+
87+
client.onhandshake = mustCall();
88+
strictEqual(typeof client.onhandshake, 'function');
89+
90+
client.onkeylog = mustCallAtLeast();
91+
strictEqual(typeof client.onkeylog, 'function');
92+
93+
await client.opened;
94+
95+
// --- Session state after the handshake completes. ---
96+
strictEqual(cs.handshaking, false);
97+
strictEqual(cs.open, true);
98+
99+
const serverSession = await gotServerSession.promise;
100+
await serverSession.opened;
101+
strictEqual(es.sessionCount, 1);
102+
103+
await client.close();
104+
await server.close();

test/parallel/test-dtls-alpn.mjs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,33 @@ await serverAlpnChecked.promise;
5353

5454
await session.close();
5555
await endpoint.close();
56+
57+
// ALPN with no protocol in common: the handshake still completes and neither
58+
// peer reports a negotiated protocol.
59+
{
60+
const gotServerSession = Promise.withResolvers();
61+
62+
const server = listen(mustCall((s) => gotServerSession.resolve(s)), {
63+
cert: serverCert.toString(),
64+
key: serverKey.toString(),
65+
port: 0,
66+
host: '127.0.0.1',
67+
alpn: ['bar'],
68+
});
69+
70+
const client = connect('127.0.0.1', server.address.port, {
71+
ca: [ca.toString()],
72+
rejectUnauthorized: false,
73+
alpn: ['foo'],
74+
});
75+
76+
await client.opened;
77+
const serverSession = await gotServerSession.promise;
78+
await serverSession.opened;
79+
80+
strictEqual(client.alpnProtocol, undefined);
81+
strictEqual(serverSession.alpnProtocol, undefined);
82+
83+
await client.close();
84+
await server.close();
85+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Flags: --experimental-dtls --no-warnings
2+
3+
// Test: cipher and ECDH-curve selection and validation.
4+
5+
import { hasCrypto, skip, mustCall, mustNotCall } from '../common/index.mjs';
6+
import assert from 'node:assert';
7+
import * as fixtures from '../common/fixtures.mjs';
8+
9+
const { strictEqual, throws } = assert;
10+
const { readKey } = fixtures;
11+
12+
if (!hasCrypto) {
13+
skip('missing crypto');
14+
}
15+
16+
if (!process.features.dtls) {
17+
skip('DTLS is not enabled');
18+
}
19+
20+
const { listen, connect } = await import('node:dtls');
21+
22+
const cert = readKey('agent1-cert.pem').toString();
23+
const key = readKey('agent1-key.pem').toString();
24+
const ca = readKey('ca1-cert.pem').toString();
25+
26+
const CIPHER = 'ECDHE-RSA-AES128-GCM-SHA256';
27+
28+
// Case 1: a specific cipher is negotiated and reported on both peers.
29+
{
30+
const gotServerSession = Promise.withResolvers();
31+
32+
const server = listen(mustCall((session) => {
33+
gotServerSession.resolve(session);
34+
}), { cert, key, port: 0, host: '127.0.0.1', ciphers: CIPHER });
35+
36+
const client = connect('127.0.0.1', server.address.port, {
37+
ca: [ca],
38+
rejectUnauthorized: false,
39+
ciphers: CIPHER,
40+
});
41+
42+
await client.opened;
43+
const serverSession = await gotServerSession.promise;
44+
await serverSession.opened;
45+
46+
strictEqual(client.cipher.name, CIPHER);
47+
strictEqual(serverSession.cipher.name, CIPHER);
48+
49+
await client.close();
50+
await server.close();
51+
}
52+
53+
// Case 2: an invalid cipher list is rejected.
54+
throws(() => listen(mustNotCall(), {
55+
cert, key, port: 0, host: '127.0.0.1', ciphers: 'THIS-IS-NOT-A-CIPHER',
56+
}), { code: 'ERR_CRYPTO_OPERATION_FAILED' });
57+
58+
// Case 3: a valid ECDH curve completes a handshake.
59+
{
60+
const server = listen(mustCall(), {
61+
cert, key, port: 0, host: '127.0.0.1', ecdhCurve: 'P-256',
62+
});
63+
64+
const client = connect('127.0.0.1', server.address.port, {
65+
ca: [ca],
66+
rejectUnauthorized: false,
67+
ecdhCurve: 'P-256',
68+
});
69+
70+
await client.opened;
71+
72+
await client.close();
73+
await server.close();
74+
}
75+
76+
// Case 4: an invalid ECDH curve is rejected.
77+
throws(() => listen(mustNotCall(), {
78+
cert, key, port: 0, host: '127.0.0.1', ecdhCurve: 'not-a-curve',
79+
}), { code: 'ERR_CRYPTO_OPERATION_FAILED' });

0 commit comments

Comments
 (0)