Skip to content

Commit 30a0ec4

Browse files
Copilot3dgiordano
andauthored
Merge remote-tracking branch 'origin/development' into FIX-ALPN-ABSENT-HTTP1-FALLBACK
# Conflicts: # src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java Co-authored-by: 3dgiordano <797057+3dgiordano@users.noreply.github.com>
2 parents fc7b82e + 551523a commit 30a0ec4

9 files changed

Lines changed: 1525 additions & 16 deletions

src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java

Lines changed: 123 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
import java.io.InputStream;
1818
import java.io.UnsupportedEncodingException;
1919
import java.lang.reflect.Method;
20+
import java.net.InetAddress;
21+
import java.net.InetSocketAddress;
2022
import java.net.MalformedURLException;
2123
import java.net.SocketAddress;
2224
import java.net.URI;
@@ -33,11 +35,13 @@
3335
import java.nio.file.Paths;
3436
import java.nio.file.StandardOpenOption;
3537
import java.time.Duration;
38+
import java.util.ArrayList;
3639
import java.util.Arrays;
3740
import java.util.Base64;
3841
import java.util.HashSet;
3942
import java.util.Iterator;
4043
import java.util.LinkedHashMap;
44+
import java.util.List;
4145
import java.util.Locale;
4246
import java.util.Map;
4347
import java.util.Objects;
@@ -61,6 +65,7 @@
6165
import java.util.zip.Inflater;
6266
import java.util.zip.InflaterInputStream;
6367
import org.apache.commons.lang3.StringUtils;
68+
import org.apache.http.conn.DnsResolver;
6469
import org.apache.jmeter.protocol.http.control.AuthManager;
6570
import org.apache.jmeter.protocol.http.control.Authorization;
6671
import org.apache.jmeter.protocol.http.control.Cookie;
@@ -186,6 +191,15 @@ public class HTTP2JettyClient {
186191
private static final String ATTR_SKIP_H2C_UPGRADE = "bzm.skipH2cUpgrade";
187192
private static final String ATTR_ORIGIN_KEY = "bzm.http3.origin";
188193
private static final String ATTR_REQUEST_HEADERS_SERIALIZED = "bzm.request.headers.serialized";
194+
/**
195+
* JMeter's deprecated "BASIC_DIGEST" Auth Manager mechanism, still selectable in the GUI and
196+
* still present in older plans. HC4 keeps honouring it: {@code AuthManager.setupCredentials}
197+
* registers the credentials without binding them to a scheme, so they answer either challenge,
198+
* and the preemptive auth cache treats the row as Basic. Referenced by name so this file does
199+
* not have to carry a deprecation suppression, the same way the surrounding code compares
200+
* mechanisms by {@code name()}.
201+
*/
202+
private static final String BASIC_DIGEST_MECHANISM = "BASIC_DIGEST";
189203
private static final String PROP_SKIP_REDUNDANT_MANUAL_DECODE =
190204
"blazemeter.http.skipManualDecodeWhenAdvertised";
191205
private static final Path DEBUG_LOG_PATH = resolveDebugLogPath();
@@ -326,13 +340,30 @@ public class HTTP2JettyClient {
326340
* protocol variant made it.
327341
*/
328342
private final ConnectAttemptRecorder connectAttempts = new ConnectAttemptRecorder();
343+
/**
344+
* Every {@link ClientConnector} this client builds, so {@link #setSourceAddress} can reach the
345+
* QUIC one too - no transport {@code doStart} propagates the bind address to it.
346+
*/
347+
private final List<ClientConnector> connectors = new ArrayList<>();
348+
/**
349+
* The sampler's DNS Cache Manager, or {@code null} when the plan has none. Held so
350+
* {@link #configureHttpClient} can install {@link JMeterDnsSocketAddressResolver} on every
351+
* protocol-variant client before any of them is started.
352+
*/
353+
private final DnsResolver dnsResolver;
329354

330355
public HTTP2JettyClient(boolean http1UpgradeRequired, String name) {
331356
this(http1UpgradeRequired, name, null);
332357
}
333358

334359
public HTTP2JettyClient(boolean http1UpgradeRequired, String name,
335360
HTTP2ClientProfileConfig profileConfig) {
361+
this(http1UpgradeRequired, name, profileConfig, null);
362+
}
363+
364+
public HTTP2JettyClient(boolean http1UpgradeRequired, String name,
365+
HTTP2ClientProfileConfig profileConfig, DnsResolver dnsResolver) {
366+
this.dnsResolver = dnsResolver;
336367
loadProperties(profileConfig);
337368
lowLevelDebug(PLUGIN_BUILD_TAG);
338369

@@ -3470,6 +3501,7 @@ public Thread newThread(Runnable r) {
34703501

34713502
private void configureHttpClient(HttpClient client, ClientConnector connector) {
34723503
client.setUserAgentField(null);
3504+
configureDnsResolution(client);
34733505
connector.setByteBufferPool(this.bufferPool);
34743506
client.setMaxRequestsQueuedPerDestination(maxRequestsQueuedPerDestination);
34753507
client.setMaxConnectionsPerDestination(maxConnectionsPerDestination);
@@ -3488,6 +3520,23 @@ private void configureHttpClient(HttpClient client, ClientConnector connector) {
34883520
}
34893521
}
34903522

3523+
/**
3524+
* Routes host name resolution through the plan's DNS Cache Manager, when there is one.
3525+
*
3526+
* <p>With no manager configured nothing is set and {@code HttpClient.doStart} installs its own
3527+
* {@code SocketAddressResolver.Async}, which is the same default {@code HTTPHC4Impl} falls back
3528+
* to ({@code SystemDefaultDnsResolver}). Under a proxy this still resolves the proxy host rather
3529+
* than the target host, because Jetty resolves {@code HttpDestination.resolveOrigin()} - again
3530+
* matching HC4, which connects to the proxy hop of the route.
3531+
*/
3532+
private void configureDnsResolution(HttpClient client) {
3533+
if (dnsResolver == null) {
3534+
return;
3535+
}
3536+
client.setSocketAddressResolver(new JMeterDnsSocketAddressResolver(dnsResolver,
3537+
client::getExecutor, client::getScheduler, client.getAddressResolutionTimeout()));
3538+
}
3539+
34913540
private static void addConnectionLogging(HttpClient client) {
34923541
client.addBean(new Connection.Listener() {
34933542
@Override
@@ -3559,6 +3608,29 @@ private static Path resolveAlpnLogPath() {
35593608
.resolve("http2-client-alpn.log");
35603609
}
35613610

3611+
/**
3612+
* Binds every outgoing connection of this client to {@code sourceAddress} (JMeter's "Source
3613+
* address" field, a.k.a. IP spoofing), or restores the OS default when {@code null}.
3614+
*
3615+
* <p>Must be called before {@link #start()}: Jetty reads the bind address in
3616+
* {@code AbstractConnectorHttpClientTransport.doStart}, which then pushes it onto the transport's
3617+
* own connector. The QUIC connector used for HTTP/3 is not owned by any transport's
3618+
* {@code doStart}, so it is set here directly - which is also why the connectors are tracked.
3619+
*
3620+
* <p>Unlike HC4 this is per client rather than per request, because that is the granularity Jetty
3621+
* offers. {@code HTTP2Sampler} compensates by keying its per-thread client cache on the
3622+
* sampler's source-address configuration, so two samplers spoofing different IPs get their own
3623+
* client instead of silently sharing one.
3624+
*/
3625+
public void setSourceAddress(InetAddress sourceAddress) {
3626+
SocketAddress bindAddress =
3627+
sourceAddress == null ? null : new InetSocketAddress(sourceAddress, 0);
3628+
forEachHttpClient(client -> client.setBindAddress(bindAddress));
3629+
for (ClientConnector connector : connectors) {
3630+
connector.setBindAddress(bindAddress);
3631+
}
3632+
}
3633+
35623634
/**
35633635
* Attaches every connection failure recorded for {@code url} since {@code sinceMillis} to
35643636
* {@code failure} as suppressed exceptions, recovering the attempts Jetty discarded.
@@ -3569,6 +3641,7 @@ public void attachConnectAttempts(Throwable failure, URL url, long sinceMillis)
35693641

35703642
private ClientConnector createClientConnector(String name) {
35713643
ClientConnector connector = new ClientConnector();
3644+
connectors.add(connector);
35723645
if (sharedThreadPoolEnabled) {
35733646
connector.setSelectors(-1);
35743647
} else {
@@ -3778,24 +3851,44 @@ private void setAuthManager(HTTP2Sampler sampler) {
37783851
private boolean isSupportedMechanism(Authorization auth) {
37793852
String authName = auth.getMechanism().name();
37803853
return authName.equals(AuthManager.Mechanism.BASIC.name())
3781-
|| authName.equals(AuthManager.Mechanism.DIGEST.name());
3854+
|| authName.equals(AuthManager.Mechanism.DIGEST.name())
3855+
|| authName.equals(BASIC_DIGEST_MECHANISM);
3856+
}
3857+
3858+
/**
3859+
* Whether the row may answer a {@code Basic} challenge, which {@code BASIC_DIGEST} rows may.
3860+
*/
3861+
private static boolean answersBasicChallenge(Authorization auth) {
3862+
String authName = auth.getMechanism().name();
3863+
return authName.equals(AuthManager.Mechanism.BASIC.name())
3864+
|| authName.equals(BASIC_DIGEST_MECHANISM);
3865+
}
3866+
3867+
/**
3868+
* Whether the row may answer a {@code Digest} challenge, which {@code BASIC_DIGEST} rows may.
3869+
*/
3870+
private static boolean answersDigestChallenge(Authorization auth) {
3871+
String authName = auth.getMechanism().name();
3872+
return authName.equals(AuthManager.Mechanism.DIGEST.name())
3873+
|| authName.equals(BASIC_DIGEST_MECHANISM);
37823874
}
37833875

37843876
private void addAuthenticationToJettyClient(Authorization auth) {
37853877
String authName = auth.getMechanism().name();
3786-
if (authName.equals(AuthManager.Mechanism.BASIC.name())
3787-
&& BzmHttpPluginProperties.getPropDefault("httpJettyClient.auth.preemptive", false)) {
3878+
boolean preemptive =
3879+
BzmHttpPluginProperties.getPropDefault("httpJettyClient.auth.preemptive", false);
3880+
if (preemptive && answersBasicChallenge(auth)) {
37883881
BasicAuthentication.BasicResult result =
37893882
new BasicAuthentication.BasicResult(URI.create(auth.getURL()), auth.getUser(),
37903883
auth.getPass());
37913884
// Results are keyed by URI (Map.put replaces); safe to re-register every sample.
37923885
forEachAuthenticationStore(store -> store.addAuthenticationResult(result));
3793-
return;
3794-
}
3795-
3796-
String fingerprint = authFingerprint(auth);
3797-
if (!registeredAuthFingerprints.add(fingerprint)) {
3798-
return;
3886+
if (authName.equals(AuthManager.Mechanism.BASIC.name())) {
3887+
return;
3888+
}
3889+
// A BASIC_DIGEST row falls through: sending Basic up front is what HC4's auth cache does,
3890+
// but the credentials must still be able to answer whichever challenge the server sends
3891+
// back, which is the whole point of the mechanism.
37993892
}
38003893

38013894
URI uri = URI.create(auth.getURL());
@@ -3804,10 +3897,26 @@ private void addAuthenticationToJettyClient(Authorization auth) {
38043897
// Blank JMeter realm must match any challenge realm; "" would only match "".
38053898
realm = Authentication.ANY_REALM;
38063899
}
3807-
AbstractAuthentication authentication =
3808-
authName.equals(AuthManager.Mechanism.BASIC.name())
3809-
? new BasicAuthentication(uri, realm, auth.getUser(), auth.getPass())
3810-
: new DigestAuthentication(uri, realm, auth.getUser(), auth.getPass());
3900+
if (answersBasicChallenge(auth)) {
3901+
registerAuthentication(auth,
3902+
new BasicAuthentication(uri, realm, auth.getUser(), auth.getPass()));
3903+
}
3904+
if (answersDigestChallenge(auth)) {
3905+
registerAuthentication(auth,
3906+
new DigestAuthentication(uri, realm, auth.getUser(), auth.getPass()));
3907+
}
3908+
}
3909+
3910+
/**
3911+
* Adds one Jetty authentication to every protocol-variant store, once per distinct row.
3912+
*
3913+
* <p>The fingerprint carries the Jetty authentication type because a single {@code BASIC_DIGEST}
3914+
* row produces two of them, and both have to get through.
3915+
*/
3916+
private void registerAuthentication(Authorization auth, AbstractAuthentication authentication) {
3917+
if (!registeredAuthFingerprints.add(authFingerprint(auth) + '|' + authentication.getType())) {
3918+
return;
3919+
}
38113920
forEachAuthenticationStore(store -> store.addAuthentication(authentication));
38123921
}
38133922

@@ -4130,7 +4239,7 @@ private void addPreemptiveAuthorizationHeader(Request request, URL url,
41304239
StreamSupport.stream(authManager.getAuthObjects().spliterator(), false)
41314240
.map(j -> (Authorization) j.getObjectValue())
41324241
.filter(auth -> auth != null
4133-
&& AuthManager.Mechanism.BASIC.equals(auth.getMechanism())
4242+
&& answersBasicChallenge(auth)
41344243
&& !StringUtils.isEmpty(auth.getURL()))
41354244
.filter(auth -> url.toString().startsWith(auth.getURL()))
41364245
.findFirst()
@@ -5093,4 +5202,3 @@ private void copy(InputStream input, ByteArrayOutputStream output) throws IOExce
50935202
}
50945203
}
50955204
}
5096-
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package com.blazemeter.jmeter.http2.core;
2+
3+
import java.net.InetAddress;
4+
import java.net.InetSocketAddress;
5+
import java.net.UnknownHostException;
6+
import java.util.ArrayList;
7+
import java.util.List;
8+
import java.util.Map;
9+
import java.util.concurrent.Executor;
10+
import java.util.concurrent.TimeUnit;
11+
import java.util.concurrent.TimeoutException;
12+
import java.util.concurrent.atomic.AtomicBoolean;
13+
import java.util.function.Supplier;
14+
import org.apache.http.conn.DnsResolver;
15+
import org.eclipse.jetty.util.Promise;
16+
import org.eclipse.jetty.util.SocketAddressResolver;
17+
import org.eclipse.jetty.util.thread.Scheduler;
18+
19+
/**
20+
* Resolves host names through JMeter's DNS Cache Manager instead of {@code InetAddress}, so a test
21+
* plan's custom DNS servers, static host entries and per-thread DNS cache apply to this plugin
22+
* exactly as they do to {@code HTTPHC4Impl}.
23+
*
24+
* <p>{@code DNSCacheManager} is an {@code org.apache.http.conn.DnsResolver}, which is what
25+
* {@code HTTPHC4Impl} hands to its connection operator on the one-time client init. This class is
26+
* the Jetty-side equivalent: {@code HttpClient} resolves through a {@link SocketAddressResolver},
27+
* and installing one is enough to cover every protocol, because HTTP/1.1, h2, h2c and HTTP/3 all
28+
* reach the network through {@code HttpClient.newConnection}.
29+
*
30+
* <p>Deliberately modelled on {@link SocketAddressResolver.Async}, which it replaces: the lookup
31+
* runs on the client executor rather than on the caller (which may be a selector thread), and a
32+
* scheduled task fails the promise if the lookup outlives the timeout. The guard is not optional
33+
* here - JMeter never calls {@code DNSCacheManager.setTimeoutMs}, so a custom resolver inherits
34+
* dnsjava's own retry behaviour and a black-holed DNS server would otherwise pin an executor
35+
* thread with nothing failing the request.
36+
*
37+
* <p>The executor and scheduler are read lazily because the resolver is installed while the
38+
* {@code HttpClient} is still being built: {@code HttpClient.doStart} only creates its default
39+
* {@code Async} resolver when none was set, so ours has to be in place before {@code start()},
40+
* at which point {@code getExecutor()} and {@code getScheduler()} are still {@code null}.
41+
*/
42+
public class JMeterDnsSocketAddressResolver implements SocketAddressResolver {
43+
44+
private final DnsResolver dnsResolver;
45+
private final Supplier<Executor> executorSupplier;
46+
private final Supplier<Scheduler> schedulerSupplier;
47+
private final long timeoutMs;
48+
49+
public JMeterDnsSocketAddressResolver(DnsResolver dnsResolver,
50+
Supplier<Executor> executorSupplier,
51+
Supplier<Scheduler> schedulerSupplier,
52+
long timeoutMs) {
53+
this.dnsResolver = dnsResolver;
54+
this.executorSupplier = executorSupplier;
55+
this.schedulerSupplier = schedulerSupplier;
56+
this.timeoutMs = timeoutMs;
57+
}
58+
59+
@Override
60+
public void resolve(String host, int port, Map<String, Object> context,
61+
Promise<List<InetSocketAddress>> promise) {
62+
Executor executor = executorSupplier.get();
63+
if (executor == null) {
64+
// Only reachable if a caller resolves before the client is started; resolving inline is
65+
// still better than dropping the request on the floor.
66+
resolveAndComplete(host, port, promise, new AtomicBoolean());
67+
return;
68+
}
69+
executor.execute(() -> {
70+
AtomicBoolean complete = new AtomicBoolean();
71+
Scheduler.Task timeoutTask = scheduleTimeout(host, Thread.currentThread(), complete, promise);
72+
try {
73+
resolveAndComplete(host, port, promise, complete);
74+
} finally {
75+
if (timeoutTask != null) {
76+
timeoutTask.cancel();
77+
}
78+
// The timeout task interrupts this thread to unblock the lookup; clear the flag so the
79+
// pooled thread does not carry it into unrelated work.
80+
Thread.interrupted();
81+
}
82+
});
83+
}
84+
85+
private Scheduler.Task scheduleTimeout(String host, Thread resolvingThread,
86+
AtomicBoolean complete,
87+
Promise<List<InetSocketAddress>> promise) {
88+
Scheduler scheduler = schedulerSupplier.get();
89+
if (timeoutMs <= 0 || scheduler == null) {
90+
return null;
91+
}
92+
return scheduler.schedule(() -> {
93+
if (complete.compareAndSet(false, true)) {
94+
promise.failed(new TimeoutException(
95+
"DNS timeout " + timeoutMs + " ms resolving " + host));
96+
resolvingThread.interrupt();
97+
}
98+
}, timeoutMs, TimeUnit.MILLISECONDS);
99+
}
100+
101+
private void resolveAndComplete(String host, int port,
102+
Promise<List<InetSocketAddress>> promise,
103+
AtomicBoolean complete) {
104+
try {
105+
InetAddress[] addresses = resolveAddresses(host);
106+
// DNSCacheManager returns null when a custom lookup cannot parse the name, and an empty
107+
// array when a static host entry is matched case-insensitively by isStaticHost but read
108+
// case-sensitively by fromStaticHost (a JMeter bug still present on master). Neither may
109+
// reach Jetty as a success: HttpClient indexes straight into the returned list.
110+
if (addresses == null || addresses.length == 0) {
111+
throw new UnknownHostException(host);
112+
}
113+
List<InetSocketAddress> result = new ArrayList<>(addresses.length);
114+
for (InetAddress address : addresses) {
115+
result.add(new InetSocketAddress(address, port));
116+
}
117+
if (complete.compareAndSet(false, true)) {
118+
promise.succeeded(result);
119+
}
120+
} catch (Throwable failure) {
121+
if (complete.compareAndSet(false, true)) {
122+
promise.failed(failure);
123+
}
124+
}
125+
}
126+
127+
private InetAddress[] resolveAddresses(String host) throws UnknownHostException {
128+
// DNSCacheManager keeps its cache in a plain LinkedHashMap and JMeter clones one instance per
129+
// thread, but a single JMeter thread resolves concurrently here (embedded resources, and the
130+
// HTTP/3 vs HTTP/2 race, run on plugin executors). Serializing keeps that map consistent;
131+
// cache hits make the critical section negligible.
132+
synchronized (dnsResolver) {
133+
return dnsResolver.resolve(host);
134+
}
135+
}
136+
}

0 commit comments

Comments
 (0)