1717import java .io .InputStream ;
1818import java .io .UnsupportedEncodingException ;
1919import java .lang .reflect .Method ;
20+ import java .net .InetAddress ;
21+ import java .net .InetSocketAddress ;
2022import java .net .MalformedURLException ;
2123import java .net .SocketAddress ;
2224import java .net .URI ;
3335import java .nio .file .Paths ;
3436import java .nio .file .StandardOpenOption ;
3537import java .time .Duration ;
38+ import java .util .ArrayList ;
3639import java .util .Arrays ;
3740import java .util .Base64 ;
3841import java .util .HashSet ;
3942import java .util .Iterator ;
4043import java .util .LinkedHashMap ;
44+ import java .util .List ;
4145import java .util .Locale ;
4246import java .util .Map ;
4347import java .util .Objects ;
6165import java .util .zip .Inflater ;
6266import java .util .zip .InflaterInputStream ;
6367import org .apache .commons .lang3 .StringUtils ;
68+ import org .apache .http .conn .DnsResolver ;
6469import org .apache .jmeter .protocol .http .control .AuthManager ;
6570import org .apache .jmeter .protocol .http .control .Authorization ;
6671import 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-
0 commit comments