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>

Reply via email to