Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.lang.reflect.Method;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.MalformedURLException;
import java.net.SocketAddress;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
Expand All @@ -32,11 +35,13 @@
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
Expand Down Expand Up @@ -327,6 +332,11 @@ public class HTTP2JettyClient {
* {@code findAuthentication} (realm/URI matching quirks) and prevents per-sample list growth.
*/
private final Set<String> registeredAuthFingerprints = ConcurrentHashMap.newKeySet();
/**
* Every {@link ClientConnector} this client builds, so {@link #setSourceAddress} can reach the
* QUIC one too - no transport {@code doStart} propagates the bind address to it.
*/
private final List<ClientConnector> connectors = new ArrayList<>();
/**
* The sampler's DNS Cache Manager, or {@code null} when the plan has none. Held so
* {@link #configureHttpClient} can install {@link JMeterDnsSocketAddressResolver} on every
Expand Down Expand Up @@ -3477,8 +3487,32 @@ private static Path resolveAlpnLogPath() {
.resolve("http2-client-alpn.log");
}

/**
* Binds every outgoing connection of this client to {@code sourceAddress} (JMeter's "Source
* address" field, a.k.a. IP spoofing), or restores the OS default when {@code null}.
*
* <p>Must be called before {@link #start()}: Jetty reads the bind address in
* {@code AbstractConnectorHttpClientTransport.doStart}, which then pushes it onto the transport's
* own connector. The QUIC connector used for HTTP/3 is not owned by any transport's
* {@code doStart}, so it is set here directly - which is also why the connectors are tracked.
*
* <p>Unlike HC4 this is per client rather than per request, because that is the granularity Jetty
* offers. {@code HTTP2Sampler} compensates by keying its per-thread client cache on the
* sampler's source-address configuration, so two samplers spoofing different IPs get their own
* client instead of silently sharing one.
*/
public void setSourceAddress(InetAddress sourceAddress) {
SocketAddress bindAddress =
sourceAddress == null ? null : new InetSocketAddress(sourceAddress, 0);
forEachHttpClient(client -> client.setBindAddress(bindAddress));
for (ClientConnector connector : connectors) {
connector.setBindAddress(bindAddress);
}
}

private ClientConnector createClientConnector(String name) {
ClientConnector connector = new ClientConnector();
connectors.add(connector);
if (sharedThreadPoolEnabled) {
connector.setSelectors(-1);
} else {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package com.blazemeter.jmeter.http2.core;

import java.net.Inet4Address;
import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.InterfaceAddress;
import java.net.NetworkInterface;
import java.net.SocketException;
import java.net.UnknownHostException;
import org.apache.jmeter.protocol.http.sampler.HTTPSamplerBase;
import org.apache.jmeter.protocol.http.sampler.HTTPSamplerBase.SourceType;
import org.apache.jmeter.util.JMeterUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* Resolves the local address a sample must go out from - JMeter's "Source address" field, also
* known as IP spoofing.
*
* <p>Port of {@code HTTPAbstractImpl.getIpSourceAddress()} plus the {@code httpclient.localaddress}
* fallback that {@code HTTPHCAbstractImpl} reads. It has to be a port rather than a delegation
* because that method is {@code protected} on the {@code HTTPAbstractImpl} hierarchy, which
* {@code HTTP2Sampler} does not extend - it extends {@link HTTPSamplerBase} directly.
*
* <p>Precedence matches {@code HTTPHC4Impl.setupRequest}: the sampler's own field wins, the global
* property is the fallback, and neither one means the OS picks the interface.
*/
public final class JMeterSourceAddressResolver {

private static final String LOCAL_ADDRESS_PROPERTY = "httpclient.localaddress";

private static final Logger LOG = LoggerFactory.getLogger(JMeterSourceAddressResolver.class);

private JMeterSourceAddressResolver() {
}

/**
* Returns the local address for {@code sampler}, or {@code null} to let the OS choose.
*
* @param sampler the sampler whose "Source address" field and type are read; its properties are
* already variable-expanded by the time a sample runs, so the value may differ per iteration
* @return the address to bind outgoing connections to, or {@code null} when none applies
* @throws UnknownHostException if the configured host name, IP or interface cannot be resolved,
* which is what HC4 propagates out of {@code setupRequest} to fail the sample
* @throws SocketException if the interface list cannot be read
*/
public static InetAddress resolve(HTTPSamplerBase sampler)
throws UnknownHostException, SocketException {
InetAddress fromSampler = resolveIpSource(sampler.getIpSource(), sampler.getIpSourceType());
return fromSampler != null ? fromSampler : resolveLocalAddressProperty();
}

/**
* Identifies the source-address configuration a client would be built with, for use in a client
* cache key. Empty when no source address applies.
*
* <p>Mirrors the precedence in {@link #resolve} without resolving anything, so it stays off the
* per-sample path: resolving a device name walks the interface list.
*
* <p>{@code httpclient.localaddress} is included even though it is global and normally constant.
* Every sampler that falls back to it binds to the same address, so sharing one client is the
* right outcome and keying on it changes nothing in a normal run - but a plan can change a
* JMeter property at runtime, and a cached client keeps the address it was built with. Reading
* the raw value costs a property lookup and removes the assumption that it never moves.
*/
public static String cacheKeyFor(HTTPSamplerBase sampler) {
String ipSource = sampler.getIpSource();
if (ipSource != null && !ipSource.trim().isEmpty()) {
return ipSource.trim() + "/" + sampler.getIpSourceType();
}
String localAddress = JMeterUtils.getPropDefault(LOCAL_ADDRESS_PROPERTY, "").trim();
// Prefixed so a property value can never collide with a sampler field of the same text.
return localAddress.isEmpty() ? "" : "@" + localAddress;
}

/**
* Whether {@code sampler} asks for any source address at all, without resolving it.
*
* <p>Lets callers keep an unconfigured plan on the cheap path: no interface enumeration, and no
* failure from a global property that a sampler-level field would have overridden anyway.
*/
public static boolean isConfigured(HTTPSamplerBase sampler) {
String ipSource = sampler.getIpSource();
return (ipSource != null && !ipSource.trim().isEmpty())
|| !JMeterUtils.getPropDefault(LOCAL_ADDRESS_PROPERTY, "").isEmpty();
}

private static InetAddress resolveIpSource(String ipSource, int ipSourceType)
throws UnknownHostException, SocketException {
if (ipSource == null || ipSource.trim().isEmpty()) {
return null; // did not want to spoof the IP address
}
Class<? extends InetAddress> ipClass;
switch (SourceType.values()[ipSourceType]) {
case DEVICE:
ipClass = InetAddress.class;
break;
case DEVICE_IPV4:
ipClass = Inet4Address.class;
break;
case DEVICE_IPV6:
ipClass = Inet6Address.class;
break;
case HOSTNAME:
default:
return InetAddress.getByName(ipSource);
}
return firstInterfaceAddress(ipSource, ipClass);
}

private static InetAddress firstInterfaceAddress(String device,
Class<? extends InetAddress> ipClass)
throws UnknownHostException, SocketException {
NetworkInterface net = NetworkInterface.getByName(device);
if (net == null) {
throw new UnknownHostException("Cannot find interface " + device);
}
for (InterfaceAddress interfaceAddress : net.getInterfaceAddresses()) {
InetAddress address = interfaceAddress.getAddress();
if (ipClass.isInstance(address)) {
return address;
}
}
throw new UnknownHostException("Interface " + device
+ " does not have address of type " + ipClass.getSimpleName());
}

/**
* HC4 logs a warning and carries on when this property does not resolve, so a typo degrades to
* "let the OS choose" instead of breaking every sample in the plan. Only a sampler-level field
* is allowed to fail a sample.
*/
private static InetAddress resolveLocalAddressProperty() {
String localHostOrIp = JMeterUtils.getPropDefault(LOCAL_ADDRESS_PROPERTY, "");
if (localHostOrIp.isEmpty()) {
return null;
}
try {
return InetAddress.getByName(localHostOrIp);
} catch (UnknownHostException e) {
LOG.warn("Ignoring {}={}: {}", LOCAL_ADDRESS_PROPERTY, localHostOrIp,
e.getLocalizedMessage());
return null;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.blazemeter.jmeter.http2.core.HTTP2FutureResponseListener;
import com.blazemeter.jmeter.http2.core.HTTP2JettyClient;
import com.blazemeter.jmeter.http2.core.HpackFailureDetector;
import com.blazemeter.jmeter.http2.core.JMeterSourceAddressResolver;
import com.blazemeter.jmeter.http2.core.JmeterHttpClientExceptionMapper;
import com.blazemeter.jmeter.http2.core.ProtocolErrorException;
import com.blazemeter.jmeter.http2.util.BzmHttpPluginProperties;
Expand All @@ -16,6 +17,7 @@
import java.io.IOException;
import java.lang.reflect.Field;
import java.net.ConnectException;
import java.net.InetAddress;
import java.net.MalformedURLException;
import java.net.SocketTimeoutException;
import java.net.URISyntaxException;
Expand Down Expand Up @@ -873,9 +875,15 @@ private static void relabelFileEmbeddedChildren(HTTPSampleResult parent) {

private HTTP2JettyClient buildClient() throws Exception {
HTTP2ClientKey connectionKey = buildConnectionKey();
// Resolved before the client exists: a bad source address must fail the sample without
// leaving an orphaned client behind, and it is the cheapest thing here to get wrong.
InetAddress sourceAddress = resolveSourceAddress();
HTTP2JettyClient client = new HTTP2JettyClient(isHttp1UpgradeEnabled(),
"http2[" + connectionKey.target + ":" + Thread.currentThread().getId() + "]",
buildProfileConfig(), getDNSResolver());
if (sourceAddress != null) {
client.setSourceAddress(sourceAddress);
}
client.start();
CONNECTIONS.get().put(connectionKey, client);
return client;
Expand Down Expand Up @@ -924,10 +932,39 @@ private String buildProfileKey() {
appendLongKey(key, "h1cd", getHttp1OnlyCooldownMs());
appendLongKey(key, "h2cttl", getH2cCacheTtlMs());
appendBooleanKey(key, "h2cup", isHttp1UpgradeEnabled());
appendSourceAddressKey(key);
appendDnsResolverKey(key);
return key.toString();
}

/**
* The local address outgoing connections must be bound to - the sampler's "Source address"
* field (IP spoofing), or the {@code httpclient.localaddress} property.
*
* <p>A bad host name, IP or interface propagates out and fails the sample, which is what HC4
* does by letting {@code getIpSourceAddress} throw out of {@code setupRequest}. Silently falling
* back to the default interface would make a spoofing plan look like it works while every
* request leaves from the wrong address.
*/
private InetAddress resolveSourceAddress() throws Exception {
if (!JMeterSourceAddressResolver.isConfigured(this)) {
return null;
}
return JMeterSourceAddressResolver.resolve(this);
}

/**
* Jetty binds the source address per client, not per request, so a cached client carries the one
* it was built with. Keying on the raw configuration - not on the resolved address - keeps this
* off the per-sample path: resolving a device name walks the interface list.
*/
private void appendSourceAddressKey(StringBuilder key) {
String sourceAddress = JMeterSourceAddressResolver.cacheKeyFor(this);
if (!sourceAddress.isEmpty()) {
key.append(";ipsrc=").append(sourceAddress);
}
}

/**
* A cached client carries the DNS Cache Manager it was built with, so two samplers under
* different managers (or one with a manager and one without) must not share it. Identity is
Expand Down
Loading
Loading