This is an automated email from the ASF dual-hosted git repository.
dsmiley pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/solr.git
The following commit(s) were added to refs/heads/main by this push:
new 439b2e5096f SOLR-18537: Proxy requests to a node via X-Solr-targetNode
(#5112)
439b2e5096f is described below
commit 439b2e5096f7bd591d893fd9971e4ad9f9f15770
Author: David Smiley <[email protected]>
AuthorDate: Mon Nov 2 13:42:28 2026 -0500
SOLR-18537: Proxy requests to a node via X-Solr-targetNode (#5112)
Any request can now be routed to a specific SolrCloud node by setting the
`X-Solr-targetNode` header (or, for GET requests, a query parameter of the same
name). The receiving node proxies the request there. This is useful for
reaching a particular node behind a load balancer, including for node-level
admin APIs.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Nick Shanin <[email protected]>
---
.../SOLR-18537-proxyRequestsByHeader.yml | 10 +
.../java/org/apache/solr/servlet/HttpSolrCall.java | 8 +-
.../org/apache/solr/servlet/HttpSolrProxy.java | 6 +-
.../apache/solr/servlet/NodeTargetProxyFilter.java | 232 ++++++++++++++++++
.../servlet/NodeTargetProxyFilterAuthTest.java | 110 +++++++++
.../solr/servlet/NodeTargetProxyFilterTest.java | 271 +++++++++++++++++++++
.../pages/solrcloud-distributed-requests.adoc | 23 ++
.../org/apache/solr/embedded/JettySolrRunner.java | 4 +-
solr/webapp/web/WEB-INF/web.xml | 9 +
9 files changed, 665 insertions(+), 8 deletions(-)
diff --git a/changelog/unreleased/SOLR-18537-proxyRequestsByHeader.yml
b/changelog/unreleased/SOLR-18537-proxyRequestsByHeader.yml
new file mode 100644
index 00000000000..0ccbe12242c
--- /dev/null
+++ b/changelog/unreleased/SOLR-18537-proxyRequestsByHeader.yml
@@ -0,0 +1,10 @@
+title: >
+ Any request can now be routed to a specific SolrCloud node by setting the
`X-Solr-targetNode` header
+ (or, for GET requests, a query parameter of the same name). The receiving
node proxies the request there.
+ This is useful for reaching a particular node behind a load balancer,
including for node-level admin APIs.
+type: added
+authors:
+ - name: David Smiley
+links:
+ - name: SOLR-18537
+ url: https://issues.apache.org/jira/browse/SOLR-18537
diff --git a/solr/core/src/java/org/apache/solr/servlet/HttpSolrCall.java
b/solr/core/src/java/org/apache/solr/servlet/HttpSolrCall.java
index 35ab076a673..c202ee08dbe 100644
--- a/solr/core/src/java/org/apache/solr/servlet/HttpSolrCall.java
+++ b/solr/core/src/java/org/apache/solr/servlet/HttpSolrCall.java
@@ -35,6 +35,7 @@ import jakarta.servlet.http.HttpServletResponse;
import java.io.EOFException;
import java.io.IOException;
import java.lang.invoke.MethodHandles;
+import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collection;
@@ -643,13 +644,14 @@ public class HttpSolrCall {
updatedQueryParams.set(INTERNAL_REQUEST_COUNT, forwardCount);
String queryStr = updatedQueryParams.toQueryString();
- String coreUrlAndPath = coreUrl + path;
- log.info("Proxying request to: {}", coreUrlAndPath);
+ log.info("Proxying request to: {}{}", coreUrl, path);
try {
+ // path is decoded, so encode it
+ URI uri = URI.create(coreUrl + new URI(null, null, path,
null).getRawPath() + queryStr);
response.reset(); // clear all headers and status
HttpClient httpClient =
((HttpJettySolrClient)
cores.getDefaultHttpSolrClient()).getHttpClient();
- HttpSolrProxy.doHttpProxy(httpClient, req, response, coreUrlAndPath +
queryStr);
+ HttpSolrProxy.doHttpProxy(httpClient, req, response, uri);
} catch (Exception e) {
// note: don't handle interruption differently; we are stopping
sendError(
diff --git a/solr/core/src/java/org/apache/solr/servlet/HttpSolrProxy.java
b/solr/core/src/java/org/apache/solr/servlet/HttpSolrProxy.java
index 3c57062906f..d23f5d742ae 100644
--- a/solr/core/src/java/org/apache/solr/servlet/HttpSolrProxy.java
+++ b/solr/core/src/java/org/apache/solr/servlet/HttpSolrProxy.java
@@ -21,6 +21,7 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.OutputStream;
+import java.net.URI;
import java.nio.ByteBuffer;
import java.util.EnumSet;
import java.util.Set;
@@ -60,10 +61,7 @@ class HttpSolrProxy {
private static final Set<String> NO_BODY_METHODS = Set.of("GET", "HEAD",
"DELETE");
static void doHttpProxy(
- HttpClient httpClient,
- HttpServletRequest servletReq,
- HttpServletResponse servletRsp,
- String url)
+ HttpClient httpClient, HttpServletRequest servletReq,
HttpServletResponse servletRsp, URI url)
throws Throwable {
Request proxyReq =
httpClient.newRequest(url).method(servletReq.getMethod());
diff --git
a/solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java
b/solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java
new file mode 100644
index 00000000000..265af3acaea
--- /dev/null
+++ b/solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java
@@ -0,0 +1,232 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.solr.servlet;
+
+import static org.apache.solr.common.SolrException.ErrorCode.BAD_REQUEST;
+import static org.apache.solr.common.SolrException.ErrorCode.SERVER_ERROR;
+import static
org.apache.solr.common.SolrException.ErrorCode.SERVICE_UNAVAILABLE;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.UnavailableException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletRequestWrapper;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.lang.invoke.MethodHandles;
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.List;
+import org.apache.solr.client.solrj.jetty.HttpJettySolrClient;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.cloud.ZkStateReader;
+import org.apache.solr.common.params.ModifiableSolrParams;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.core.CoreContainer;
+import org.eclipse.jetty.client.HttpClient;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * If the request carries the {@link #TARGET_NODE_HEADER} header naming a
different live node,
+ * proxies the request there verbatim and returns; otherwise passes the
request through. Unlike
+ * {@link HttpSolrCall}'s collection/core-driven proxying, this works for any
endpoint, including
+ * node-level ones (e.g. {@code /solr/admin/**}, {@code /api/node/**}), so a
request can be pinned
+ * to a specific node even when reached through a load balancer that hides
which node answers.
+ */
+public class NodeTargetProxyFilter extends CoreContainerAwareHttpFilter {
+
+ private static final Logger log =
LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+ /**
+ * Header naming a live node (as it appears in ZK live_nodes) to proxy this
request to. Two
+ * shorthand forms are also accepted, both resolved relative to the current
node: a value of all
+ * digits replaces the trailing {@code -<digits>} ordinal in the current
node's hostname (e.g.
+ * {@code 8} turns {@code myhost-3.mydomain.com:3000} into {@code
myhost-8.mydomain.com:3000});
+ * otherwise, a value with no '.' or ':' is treated as a bare peer hostname,
with the current
+ * node's domain suffix and port appended.
+ */
+ public static final String TARGET_NODE_HEADER = "X-Solr-targetNode";
+
+ @Override
+ protected void doFilter(HttpServletRequest req, HttpServletResponse res,
FilterChain chain)
+ throws IOException, ServletException {
+
+ String targetNode = req.getHeader(TARGET_NODE_HEADER);
+
+ // also accept it as a query param on GET requests, for convenience when a
header can't
+ // easily be set (e.g. a browser address bar)
+ String queryString = req.getQueryString();
+ if ("GET".equals(req.getMethod())
+ && queryString != null
+ && queryString.contains(TARGET_NODE_HEADER)) { // cheap; avoid
double-parse
+ SolrParams params = SolrRequestParsers.parseQueryString(queryString);
+ String paramTargetNode = params.get(TARGET_NODE_HEADER);
+ if (paramTargetNode != null) {
+ if (targetNode != null) {
+ res.sendError(400, TARGET_NODE_HEADER + " must EITHER be a header or
url param");
+ return;
+ }
+ targetNode = paramTargetNode;
+ queryString = filterQueryString(params);
+ }
+ }
+
+ // wrap the request to filter out the header & param.
+ // NOTE: we assume downstream processing only accesses such data via
what we override !!
+ if (targetNode != null) {
+ final List<String> headerNames = filterHeaderNamesToList(req); // cheap
+ final String finalQueryString = queryString; // filtered already if
needed
+ req =
+ new HttpServletRequestWrapper(req) {
+ @Override
+ public Enumeration<String> getHeaderNames() {
+ return Collections.enumeration(headerNames);
+ }
+
+ @Override
+ public String getQueryString() {
+ return finalQueryString;
+ }
+ };
+ }
+
+ if (targetNode != null && !targetNode.isBlank() && proxyToNode(targetNode,
req, res)) {
+ return;
+ }
+ chain.doFilter(req, res);
+ }
+
+ private static List<String> filterHeaderNamesToList(HttpServletRequest req) {
+ List<String> headerNames = new ArrayList<>();
+ var headerNamesEnum = req.getHeaderNames();
+ while (headerNamesEnum.hasMoreElements()) {
+ String headerName = headerNamesEnum.nextElement();
+ if (!headerName.equalsIgnoreCase(TARGET_NODE_HEADER)) {
+ headerNames.add(headerName);
+ }
+ }
+ return headerNames;
+ }
+
+ private static String filterQueryString(SolrParams params) {
+ ModifiableSolrParams modifiableSolrParams =
ModifiableSolrParams.of(params);
+ modifiableSolrParams.remove(TARGET_NODE_HEADER);
+ String queryString = modifiableSolrParams.toQueryString();
+ if (queryString.startsWith("?")) {
+ queryString = queryString.substring(1);
+ }
+ return queryString; // could be empty string
+ }
+
+ /**
+ * Returns true if the request was handled here: proxied to {@code
targetNode}, or answered with
+ * an error. Reports errors itself, since an exception would be treated by
{@link
+ * AuthenticationFilter}, our caller, as an authentication failure.
+ */
+ private boolean proxyToNode(String targetNode, HttpServletRequest req,
HttpServletResponse res)
+ throws IOException {
+ CoreContainer cores;
+ try {
+ cores = getCores();
+ } catch (UnavailableException e) {
+ res.sendError(SERVICE_UNAVAILABLE.code, "Core Container Unavailable");
+ return true;
+ }
+ if (!cores.isZooKeeperAware()) {
+ // note: we could adapt this filter to support standalone mode with
AllowListUrlChecker
+ res.sendError(BAD_REQUEST.code, TARGET_NODE_HEADER + " header/param
requires SolrCloud");
+ return true;
+ }
+ String ownNodeName = cores.getZkController().getNodeName();
+ String resolvedNode = resolvePeerShorthand(targetNode, ownNodeName);
+ ZkStateReader zkStateReader = cores.getZkController().getZkStateReader();
+ if
(!zkStateReader.getClusterState().getLiveNodes().contains(resolvedNode)) {
+ res.sendError(BAD_REQUEST.code, "Unknown or non-live node: " +
resolvedNode);
+ return true;
+ }
+ if (resolvedNode.equals(ownNodeName)) {
+ return false; // already on the target node; let it process normally
+ }
+
+ URI uri;
+ try {
+ uri = targetUri(zkStateReader.getBaseUrlForNodeName(resolvedNode), req);
+ } catch (SolrException e) {
+ res.sendError(e.code(), e.getMessage());
+ return true;
+ } catch (IllegalArgumentException e) {
+ res.sendError(BAD_REQUEST.code, "Invalid request URI");
+ return true;
+ }
+ try {
+ HttpClient httpClient =
+ ((HttpJettySolrClient)
cores.getDefaultHttpSolrClient()).getHttpClient();
+ HttpSolrProxy.doHttpProxy(httpClient, req, res, uri);
+ } catch (Exception e) {
+ log.warn("Error proxying request to node {}", resolvedNode, e);
+ res.sendError(SERVER_ERROR.code, "Error proxying request to node: " +
resolvedNode);
+ } catch (Error e) {
+ throw e;
+ } catch (Throwable e) {
+ throw new RuntimeException(e);
+ }
+ return true;
+ }
+
+ /**
+ * The request's URI on another node. The path is reused as received (still
encoded). The query is
+ * re-encoded because Jetty accepts characters in it, like '{' or '|', that
a URI rejects.
+ */
+ private static URI targetUri(String baseUrl, HttpServletRequest req) {
+ String query = req.getQueryString();
+ query = query == null ? "" :
SolrRequestParsers.parseQueryString(query).toQueryString();
+ return URI.create(baseUrl + pathAfterContext(req) + query);
+ }
+
+ /** The still-encoded request path with the context path removed. Starts
with "/". */
+ private static String pathAfterContext(HttpServletRequest req) {
+ return req.getRequestURI().substring(req.getContextPath().length());
+ }
+
+ /**
+ * If {@code targetNode} is all digits, it's an ordinal shorthand: {@code
ownNodeName}'s first
+ * hostname label is expected to end in {@code -<digits>}, which gets
replaced. Otherwise, if it
+ * has no '.' or ':', it's a bare peer hostname; expand it using {@code
ownNodeName}'s domain
+ * suffix and {@code :port_context}. Otherwise, returned unchanged.
+ */
+ static String resolvePeerShorthand(String targetNode, String ownNodeName) {
+ if (targetNode.contains(".") || targetNode.contains(":")) {
+ return targetNode; // already a full node name
+ }
+ int colonIdx = ownNodeName.indexOf(':');
+ String ownHost = ownNodeName.substring(0, colonIdx);
+ String portAndContext = ownNodeName.substring(colonIdx);
+ int dotIdx = ownHost.indexOf('.');
+ String domainSuffix = dotIdx >= 0 ? ownHost.substring(dotIdx) : "";
+
+ if (targetNode.matches("\\d+")) {
+ String firstLabel = dotIdx >= 0 ? ownHost.substring(0, dotIdx) : ownHost;
+ targetNode = firstLabel.replaceFirst("-\\d+$", "-" + targetNode);
+ }
+
+ return targetNode + domainSuffix + portAndContext;
+ }
+}
diff --git
a/solr/core/src/test/org/apache/solr/servlet/NodeTargetProxyFilterAuthTest.java
b/solr/core/src/test/org/apache/solr/servlet/NodeTargetProxyFilterAuthTest.java
new file mode 100644
index 00000000000..3f3df71852f
--- /dev/null
+++
b/solr/core/src/test/org/apache/solr/servlet/NodeTargetProxyFilterAuthTest.java
@@ -0,0 +1,110 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.solr.servlet;
+
+import static org.apache.solr.servlet.NodeTargetProxyFilter.TARGET_NODE_HEADER;
+
+import org.apache.solr.client.solrj.SolrRequest;
+import org.apache.solr.client.solrj.request.GenericSolrRequest;
+import org.apache.solr.cloud.SolrCloudAuthTestCase;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.embedded.JettySolrRunner;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+/** Tests {@link NodeTargetProxyFilter} with authentication enabled. */
+public class NodeTargetProxyFilterAuthTest extends SolrCloudAuthTestCase {
+
+ private static final String USER = "solr";
+ private static final String PASSWORD = "SolrRocks";
+
+ private static final String SECURITY_JSON =
+ """
+ {
+ "authentication": {
+ "blockUnknown": true,
+ "class": "solr.BasicAuthPlugin",
+ "credentials": {
+ "solr": "JeRyxP8A3dVWhFgFbf/Eg2RXmuoU8BE5gbNQyxmGAJQ=
6zkmjMjkMKyJX6/f0VarEWQujju5BzxZXub6WOrEKCw=",
+ "limited": "JeRyxP8A3dVWhFgFbf/Eg2RXmuoU8BE5gbNQyxmGAJQ=
6zkmjMjkMKyJX6/f0VarEWQujju5BzxZXub6WOrEKCw="
+ }
+ },
+ "authorization": {
+ "class": "solr.RuleBasedAuthorizationPlugin",
+ "user-role": {"solr": "admin"},
+ "permissions": [{"name": "all", "role": "admin"}]
+ }
+ }
+ """;
+
+ private static JettySolrRunner receivingNode;
+ private static JettySolrRunner targetNode;
+
+ @BeforeClass
+ public static void setupCluster() throws Exception {
+ configureCluster(2)
+ .addConfig("conf", configset("cloud-minimal"))
+ .withSecurityJson(SECURITY_JSON)
+ .configure();
+ receivingNode = cluster.getJettySolrRunner(0);
+ targetNode = cluster.getJettySolrRunner(1);
+ }
+
+ @Test
+ public void testProxiesWhenAuthenticated() throws Exception {
+ var req = systemInfoRequest(targetNode.getNodeName());
+ req.setBasicAuthCredentials(USER, PASSWORD);
+ assertEquals(targetNode.getNodeName(),
receivingNode.getSolrClient().request(req).get("node"));
+ }
+
+ @Test
+ public void testUnknownNodeIs400WhenAuthenticated() {
+ var req = systemInfoRequest("no.such.host:1234_solr");
+ req.setBasicAuthCredentials(USER, PASSWORD);
+ var e = expectThrows(SolrException.class, () ->
receivingNode.getSolrClient().request(req));
+ assertEquals(400, e.code());
+ }
+
+ @Test
+ public void testUnauthenticatedIs401() {
+ var req = systemInfoRequest(targetNode.getNodeName());
+ var e = expectThrows(SolrException.class, () ->
receivingNode.getSolrClient().request(req));
+ assertEquals(401, e.code());
+ }
+
+ /** The target node authorizes the original caller, not the proxying node. */
+ @Test
+ public void testProxiedRequestAuthorizedAsCaller() {
+ var req = systemInfoRequest(targetNode.getNodeName());
+ req.setBasicAuthCredentials("limited", PASSWORD);
+ var e = expectThrows(SolrException.class, () ->
receivingNode.getSolrClient().request(req));
+ assertEquals(403, e.code());
+ }
+
+ private static GenericSolrRequest systemInfoRequest(String targetNodeName) {
+ var req =
+ new GenericSolrRequest(
+ SolrRequest.METHOD.GET,
+ "/admin/info/system",
+ SolrRequest.SolrRequestType.ADMIN,
+ SolrParams.of());
+ req.addHeader(TARGET_NODE_HEADER, targetNodeName);
+ return req;
+ }
+}
diff --git
a/solr/core/src/test/org/apache/solr/servlet/NodeTargetProxyFilterTest.java
b/solr/core/src/test/org/apache/solr/servlet/NodeTargetProxyFilterTest.java
new file mode 100644
index 00000000000..908484fc3ff
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/servlet/NodeTargetProxyFilterTest.java
@@ -0,0 +1,271 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.solr.servlet;
+
+import static org.apache.solr.servlet.NodeTargetProxyFilter.TARGET_NODE_HEADER;
+
+import java.util.Map;
+import java.util.TreeMap;
+import org.apache.solr.client.solrj.SolrRequest;
+import org.apache.solr.client.solrj.request.CollectionAdminRequest;
+import org.apache.solr.client.solrj.request.GenericSolrRequest;
+import org.apache.solr.client.solrj.request.V2Request;
+import org.apache.solr.cloud.ConfigRequest;
+import org.apache.solr.cloud.SolrCloudTestCase;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.ModifiableSolrParams;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.common.util.SimpleOrderedMap;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.embedded.JettySolrRunner;
+import org.apache.solr.handler.RequestHandlerBase;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.request.SolrRequestHandler;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.security.AuthorizationContext;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+/** Tests {@link NodeTargetProxyFilter}. */
+public class NodeTargetProxyFilterTest extends SolrCloudTestCase {
+
+ private static JettySolrRunner receivingNode;
+ private static JettySolrRunner targetNode;
+
+ /** A collection with its only replica on {@link #receivingNode}. */
+ private static final String COLLECTION = "coll";
+
+ @BeforeClass
+ public static void setupCluster() throws Exception {
+ configureCluster(2).addConfig("conf",
configset("cloud-minimal")).configure();
+ receivingNode = cluster.getJettySolrRunner(0);
+ targetNode = cluster.getJettySolrRunner(1);
+
+ CollectionAdminRequest.createCollection(COLLECTION, "conf", 1, 1)
+ .setCreateNodeSet(receivingNode.getNodeName())
+ .process(cluster.getSolrClient());
+ cluster
+ .getSolrClient()
+ .request(
+ new ConfigRequest(
+ "{'add-requesthandler': {'name': '/echo', 'class': '"
+ + EchoHandler.class.getName()
+ + "'}}"),
+ COLLECTION);
+ }
+
+ /** Echoes the headers and params of the HTTP request as seen by the
handler. */
+ public static class EchoHandler extends RequestHandlerBase {
+ @Override
+ public void handleRequestBody(SolrQueryRequest req, SolrQueryResponse rsp)
{
+ Map<String, String> headers = new
TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+ var httpReq = req.getHttpSolrCall().getReq();
+ httpReq
+ .getHeaderNames()
+ .asIterator()
+ .forEachRemaining(name -> headers.put(name,
httpReq.getHeader(name)));
+ rsp.add("headers", headers);
+ rsp.add("requestURI", httpReq.getRequestURI());
+ rsp.add("params", new SimpleOrderedMap<>(req.getParams()));
+ }
+
+ @Override
+ public SolrRequestHandler getSubHandler(String subPath) {
+ return this;
+ }
+
+ @Override
+ public String getDescription() {
+ return "echoes request headers and params";
+ }
+
+ @Override
+ public Name getPermissionName(AuthorizationContext ctx) {
+ return Name.ALL;
+ }
+ }
+
+ @Test
+ public void testHeaderProxiesToTargetNode() throws Exception {
+ var req = systemInfoRequest(SolrRequest.METHOD.GET, params());
+ req.addHeader(TARGET_NODE_HEADER, targetNode.getNodeName());
+ assertEquals(targetNode.getNodeName(), respondingNode(receivingNode, req));
+ }
+
+ @Test
+ public void testHeaderPreservedWhenTargetNameIsQueryValue() throws Exception
{
+ var req = systemInfoRequest(SolrRequest.METHOD.GET, params().set("foo",
TARGET_NODE_HEADER));
+ req.addHeader(TARGET_NODE_HEADER, targetNode.getNodeName());
+ assertEquals(targetNode.getNodeName(), respondingNode(receivingNode, req));
+ }
+
+ @Test
+ public void testHeaderPreservedWhenTargetNameIsPartOfQueryParamName() throws
Exception {
+ var req =
+ systemInfoRequest(
+ SolrRequest.METHOD.GET, params().set(TARGET_NODE_HEADER +
"Suffix", "value"));
+ req.addHeader(TARGET_NODE_HEADER, targetNode.getNodeName());
+ assertEquals(targetNode.getNodeName(), respondingNode(receivingNode, req));
+ }
+
+ @Test
+ public void testUnrelatedQueryValueWithoutHeaderIsHandledLocally() throws
Exception {
+ var req = systemInfoRequest(SolrRequest.METHOD.GET, params().set("foo",
TARGET_NODE_HEADER));
+ assertEquals(receivingNode.getNodeName(), respondingNode(receivingNode,
req));
+ }
+
+ @Test
+ public void testHeaderAndQueryParamAreRejected() {
+ var req =
+ systemInfoRequest(
+ SolrRequest.METHOD.GET, params().set(TARGET_NODE_HEADER,
targetNode.getNodeName()));
+ req.addHeader(TARGET_NODE_HEADER, targetNode.getNodeName());
+ var e = expectThrows(SolrException.class, () ->
receivingNode.getSolrClient().request(req));
+ assertEquals(400, e.code());
+ }
+
+ @Test
+ public void testHeaderProxiesV2() throws Exception {
+ var req = new V2Request.Builder("/node/system").GET().build();
+ req.addHeader(TARGET_NODE_HEADER, targetNode.getNodeName());
+ assertEquals(targetNode.getNodeName(), respondingNode(receivingNode, req));
+ }
+
+ @Test
+ public void testQueryParamProxiesGet() throws Exception {
+ var req =
+ systemInfoRequest(
+ SolrRequest.METHOD.GET, params().set(TARGET_NODE_HEADER,
targetNode.getNodeName()));
+ assertEquals(targetNode.getNodeName(), respondingNode(receivingNode, req));
+ }
+
+ @Test
+ public void testQueryParamIgnoredForPost() throws Exception {
+ // a body forces the params onto the URL's query string
+ var req =
+ systemInfoRequest(
+ SolrRequest.METHOD.POST, params().set(TARGET_NODE_HEADER,
targetNode.getNodeName()))
+ .withContent(new byte[0], "text/plain");
+ assertEquals(receivingNode.getNodeName(), respondingNode(receivingNode,
req));
+ }
+
+ /**
+ * The only replica is on the receiving node, so the target node forwards
the request back to it.
+ * The targeting directive must be consumed, or the request would bounce
back and forth.
+ */
+ @SuppressWarnings("unchecked")
+ @Test
+ public void testDirectiveConsumedWhenTargetForwardsToReplica() throws
Exception {
+ var params = params();
+ boolean useHeader = random().nextBoolean();
+ if (!useHeader) {
+ params.set(TARGET_NODE_HEADER, targetNode.getNodeName());
+ }
+ var req = new GenericSolrRequest(SolrRequest.METHOD.GET, "/" + COLLECTION
+ "/echo", params);
+ req.addHeader("X-Other", "kept");
+ if (useHeader) {
+ req.addHeader(TARGET_NODE_HEADER, targetNode.getNodeName());
+ }
+ NamedList<Object> rsp = receivingNode.getSolrClient().request(req);
+
+ Map<String, Object> headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+ headers.putAll((Map<String, ?>) rsp.get("headers"));
+ assertEquals("kept", headers.get("X-Other"));
+ assertFalse(headers.containsKey(TARGET_NODE_HEADER));
+ NamedList<?> echoedParams = (NamedList<?>) rsp.get("params");
+ assertEquals("bar", echoedParams.get("foo"));
+ assertNull(echoedParams.get(TARGET_NODE_HEADER));
+ }
+
+ /** The path is proxied as received, still encoded. */
+ @Test
+ public void testEncodedPathPreserved() throws Exception {
+ // the replica is on receivingNode, so the proxied request is served
directly (no further hop)
+ String encodedPath = "/a%20b%23c";
+ var req =
+ new GenericSolrRequest(
+ SolrRequest.METHOD.GET, "/" + COLLECTION + "/echo" + encodedPath,
params());
+ req.addHeader(TARGET_NODE_HEADER, receivingNode.getNodeName());
+ NamedList<Object> rsp = targetNode.getSolrClient().request(req);
+ assertEquals("/solr/" + COLLECTION + "/echo" + encodedPath,
rsp.get("requestURI"));
+ }
+
+ /** Jetty accepts query characters that are invalid in a URI (e.g. a
local-params '{'). */
+ @Test
+ public void testUnencodedQueryCharsProxied() throws Exception {
+ var httpClient = targetNode.getSolrClient().getHttpClient();
+ var rsp =
+ httpClient
+ .newRequest(targetNode.getBaseUrl().toString())
+ .path("/solr/" + COLLECTION + "/echo?wt=json&foo={!x}|b^2")
+ .headers(h -> h.add(TARGET_NODE_HEADER,
receivingNode.getNodeName()))
+ .send();
+ assertEquals(200, rsp.getStatus());
+ Map<?, ?> params =
+ (Map<?, ?>) ((Map<?, ?>)
Utils.fromJSONString(rsp.getContentAsString())).get("params");
+ assertEquals("{!x}|b^2", params.get("foo"));
+ }
+
+ @Test
+ public void testTargetingSelfIsHandledLocally() throws Exception {
+ var req = systemInfoRequest(SolrRequest.METHOD.GET, params());
+ req.addHeader(TARGET_NODE_HEADER, receivingNode.getNodeName());
+ assertEquals(receivingNode.getNodeName(), respondingNode(receivingNode,
req));
+ }
+
+ @Test
+ public void testUnknownNodeIs400() {
+ var req = systemInfoRequest(SolrRequest.METHOD.GET, params());
+ req.addHeader(TARGET_NODE_HEADER, "no.such.host:1234_solr");
+ var e = expectThrows(SolrException.class, () ->
receivingNode.getSolrClient().request(req));
+ assertEquals(400, e.code());
+ }
+
+ @Test
+ public void testResolvePeerShorthand() {
+ String own = "myhost-3.mydomain.com:3000_solr";
+ assertEquals(
+ "otherhost.mydomain.com:3000_solr",
+ NodeTargetProxyFilter.resolvePeerShorthand("otherhost", own));
+ assertEquals(
+ "otherhost.elsewhere.com:8983_solr",
+
NodeTargetProxyFilter.resolvePeerShorthand("otherhost.elsewhere.com:8983_solr",
own));
+ assertEquals(
+ "otherhost:3000_solr",
+ NodeTargetProxyFilter.resolvePeerShorthand("otherhost",
"myhost:3000_solr"));
+ // complete node names with dotless hosts are preserved
+ assertEquals(
+ "solr2:8983_solr",
+ NodeTargetProxyFilter.resolvePeerShorthand("solr2:8983_solr",
"solr1:8983_solr"));
+ }
+
+ private static GenericSolrRequest systemInfoRequest(
+ SolrRequest.METHOD method, SolrParams params) {
+ return new GenericSolrRequest(
+ method, "/admin/info/system", SolrRequest.SolrRequestType.ADMIN,
params);
+ }
+
+ private static ModifiableSolrParams params() {
+ return new ModifiableSolrParams().set("foo", "bar"); // including an
arbitrary pair
+ }
+
+ private static String respondingNode(JettySolrRunner node, SolrRequest<?>
req) throws Exception {
+ return (String) node.getSolrClient().request(req).get("node");
+ }
+}
diff --git
a/solr/solr-ref-guide/modules/deployment-guide/pages/solrcloud-distributed-requests.adoc
b/solr/solr-ref-guide/modules/deployment-guide/pages/solrcloud-distributed-requests.adoc
index 51733c2bb5b..28c7622b865 100644
---
a/solr/solr-ref-guide/modules/deployment-guide/pages/solrcloud-distributed-requests.adoc
+++
b/solr/solr-ref-guide/modules/deployment-guide/pages/solrcloud-distributed-requests.adoc
@@ -298,6 +298,29 @@
http://localhost:8983/solr/collection1/select?q=*:*&_route_=user1!
http://localhost:8983/solr/collection1/select?q=*:*&_route_=user1!,user2!
----
+== Routing a Request to a Specific Node
+
+Any request, including node-level APIs such as `/solr/admin/info/system` or
`/api/node/system`, can be sent to a specific node by setting the
`X-Solr-targetNode` HTTP header.
+The node that receives the request proxies it to the named node, which
processes it and returns the response.
+This is useful when Solr is reached through a load balancer, where you can't
otherwise choose which node handles a request.
+
+The value is a node name as it appears in `live_nodes` (for example,
`solr2.example.com:8983_solr`).
+A value without a `.` or `:` is treated as a hostname in the same domain and
on the same port as the receiving node; for example, `solr2` sent to
`solr1.example.com:8983_solr` targets `solr2.example.com:8983_solr`.
+If the named node isn't live, the request fails with a 400 error.
+If it names the receiving node, the request is processed there without
proxying.
+
+[source,bash]
+----
+curl -H "X-Solr-targetNode: solr2.example.com:8983_solr"
"http://lb.example.com:8983/solr/admin/info/system"
+----
+
+For `GET` requests, the target node can alternatively be given as a query
parameter of the same name, which is convenient in a browser:
+
+[source,plain]
+----
+http://lb.example.com:8983/solr/admin/info/system?X-Solr-targetNode=solr2.example.com:8983_solr
+----
+
== Near Real Time (NRT) Use Cases
Near Real Time (NRT) search means that documents are available for search soon
after being indexed.
diff --git
a/solr/test-framework/src/java/org/apache/solr/embedded/JettySolrRunner.java
b/solr/test-framework/src/java/org/apache/solr/embedded/JettySolrRunner.java
index ff8253853ff..fa06692b00d 100644
--- a/solr/test-framework/src/java/org/apache/solr/embedded/JettySolrRunner.java
+++ b/solr/test-framework/src/java/org/apache/solr/embedded/JettySolrRunner.java
@@ -57,6 +57,7 @@ import org.apache.solr.metrics.SolrMetricManager;
import org.apache.solr.servlet.AuthenticationFilter;
import org.apache.solr.servlet.CoreContainerProvider;
import org.apache.solr.servlet.LoadAdminUiServlet;
+import org.apache.solr.servlet.NodeTargetProxyFilter;
import org.apache.solr.servlet.RateLimitFilter;
import org.apache.solr.servlet.RequiredSolrRequestFilter;
import org.apache.solr.servlet.SolrServlet;
@@ -375,7 +376,8 @@ public class JettySolrRunner implements SolrBackend {
RequiredSolrRequestFilter.class,
RateLimitFilter.class,
TracingFilter.class,
- AuthenticationFilter.class)) {
+ AuthenticationFilter.class,
+ NodeTargetProxyFilter.class)) {
FilterHolder fh =
root.getServletHandler().newFilterHolder(Source.EMBEDDED);
fh.setName(filterClass.getSimpleName());
fh.setHeldClass(filterClass);
diff --git a/solr/webapp/web/WEB-INF/web.xml b/solr/webapp/web/WEB-INF/web.xml
index 136cb6d4bd4..d878be3fdd7 100644
--- a/solr/webapp/web/WEB-INF/web.xml
+++ b/solr/webapp/web/WEB-INF/web.xml
@@ -61,6 +61,15 @@
<servlet-name>SolrServlet</servlet-name>
</filter-mapping>
+ <filter>
+ <filter-name>NodeTargetProxyFilter</filter-name>
+ <filter-class>org.apache.solr.servlet.NodeTargetProxyFilter</filter-class>
+ </filter>
+ <filter-mapping>
+ <filter-name>NodeTargetProxyFilter</filter-name>
+ <servlet-name>SolrServlet</servlet-name>
+ </filter-mapping>
+
<servlet>
<servlet-name>SolrServlet</servlet-name>
<servlet-class>org.apache.solr.servlet.SolrServlet</servlet-class>