Copilot commented on code in PR #5112: URL: https://github.com/apache/solr/pull/5112#discussion_r4335037476
########## solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java: ########## @@ -0,0 +1,138 @@ +/* + * 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 jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.UnavailableException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +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.core.CoreContainer; +import org.eclipse.jetty.client.HttpClient; + +/** + * 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 { + + /** + * 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 '.' 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 resp, FilterChain chain) + throws IOException, ServletException { + // 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 targetNode = req.getHeader(TARGET_NODE_HEADER); + String queryString = req.getQueryString(); + if (targetNode == null + && "GET".equals(req.getMethod()) + && queryString != null + && queryString.contains(TARGET_NODE_HEADER)) { + targetNode = SolrRequestParsers.parseQueryString(queryString).get(TARGET_NODE_HEADER); + } + if (targetNode == null || targetNode.isBlank() || !proxyToNode(targetNode, req, resp)) { + chain.doFilter(req, resp); + } + } + + /** Returns true if the request was proxied to {@code targetNode}. */ + private boolean proxyToNode(String targetNode, HttpServletRequest req, HttpServletResponse resp) { + CoreContainer cores; + try { + cores = getCores(); + } catch (UnavailableException e) { + throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Core Container Unavailable"); + } + if (!cores.isZooKeeperAware()) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, TARGET_NODE_HEADER + " header requires SolrCloud"); + } + String ownNodeName = cores.getZkController().getNodeName(); + String resolvedNode = resolvePeerShorthand(targetNode, ownNodeName); + ZkStateReader zkStateReader = cores.getZkController().getZkStateReader(); + if (!zkStateReader.getClusterState().getLiveNodes().contains(resolvedNode)) { + throw new SolrException( + SolrException.ErrorCode.NOT_FOUND, "Unknown or non-live node: " + resolvedNode); + } + if (resolvedNode.equals(ownNodeName)) { + return false; // already on the target node; let it process normally Review Comment: When the selected node has no replica of the requested collection, `HttpSolrCall.sendRemoteProxy()` forwards to a replica with this routing directive intact. The replica's filter sends it back to the selected node, repeating until `_forwardedCount` causes a 510 error despite available replicas. Consume both the routing header and GET parameter before downstream processing on the selected node, including requests initially received there. Add a test targeting a non-replica node. ########## solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java: ########## @@ -0,0 +1,138 @@ +/* + * 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 jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.UnavailableException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +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.core.CoreContainer; +import org.eclipse.jetty.client.HttpClient; + +/** + * 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 { + + /** + * 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 '.' 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 resp, FilterChain chain) + throws IOException, ServletException { + // 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 targetNode = req.getHeader(TARGET_NODE_HEADER); + String queryString = req.getQueryString(); + if (targetNode == null + && "GET".equals(req.getMethod()) + && queryString != null + && queryString.contains(TARGET_NODE_HEADER)) { + targetNode = SolrRequestParsers.parseQueryString(queryString).get(TARGET_NODE_HEADER); + } + if (targetNode == null || targetNode.isBlank() || !proxyToNode(targetNode, req, resp)) { + chain.doFilter(req, resp); + } + } + + /** Returns true if the request was proxied to {@code targetNode}. */ + private boolean proxyToNode(String targetNode, HttpServletRequest req, HttpServletResponse resp) { + CoreContainer cores; + try { + cores = getCores(); + } catch (UnavailableException e) { + throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Core Container Unavailable"); + } + if (!cores.isZooKeeperAware()) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, TARGET_NODE_HEADER + " header requires SolrCloud"); + } + String ownNodeName = cores.getZkController().getNodeName(); + String resolvedNode = resolvePeerShorthand(targetNode, ownNodeName); + ZkStateReader zkStateReader = cores.getZkController().getZkStateReader(); + if (!zkStateReader.getClusterState().getLiveNodes().contains(resolvedNode)) { + throw new SolrException( + SolrException.ErrorCode.NOT_FOUND, "Unknown or non-live node: " + resolvedNode); + } + if (resolvedNode.equals(ownNodeName)) { + return false; // already on the target node; let it process normally + } + + String url = + zkStateReader.getBaseUrlForNodeName(resolvedNode) + ServletUtils.getPathAfterContext(req); + String queryString = req.getQueryString(); + if (queryString != null) { + url = url + "?" + queryString; + } + try { + HttpClient httpClient = + ((HttpJettySolrClient) cores.getDefaultHttpSolrClient()).getHttpClient(); + HttpSolrProxy.doHttpProxy(httpClient, req, resp, url); Review Comment: The raw Jetty client bypasses the authentication listeners in `HttpJettySolrClient.decorateRequest()`, and this filter establishes no caller-bound `SolrRequestInfo`. With `CertAuthPlugin`, the target authenticates the proxy node's TLS certificate instead of the original client's identity. Authorization can then reject valid requests or grant permissions belonging to the node certificate rather than the caller. Use caller-scoped internode authentication and test distinct client/node certificates with authorization enabled. ########## solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java: ########## @@ -0,0 +1,138 @@ +/* + * 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 jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.UnavailableException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +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.core.CoreContainer; +import org.eclipse.jetty.client.HttpClient; + +/** + * 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 { + + /** + * 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 '.' 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 resp, FilterChain chain) + throws IOException, ServletException { + // 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 targetNode = req.getHeader(TARGET_NODE_HEADER); + String queryString = req.getQueryString(); + if (targetNode == null + && "GET".equals(req.getMethod()) + && queryString != null + && queryString.contains(TARGET_NODE_HEADER)) { + targetNode = SolrRequestParsers.parseQueryString(queryString).get(TARGET_NODE_HEADER); + } + if (targetNode == null || targetNode.isBlank() || !proxyToNode(targetNode, req, resp)) { + chain.doFilter(req, resp); + } Review Comment: With authentication enabled, exceptions from `proxyToNode()` escape through `AuthenticationFilter`, which wraps downstream exceptions as authentication failures and runs failure cleanup before `RequiredSolrRequestFilter` can send the original status. An authenticated unknown-node request therefore does not receive the documented 404; standalone's 400 is affected too. Handle these `SolrException`s inside this filter and return normally. Add an authenticated unknown-node test. ########## solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java: ########## @@ -0,0 +1,138 @@ +/* + * 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 jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.UnavailableException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +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.core.CoreContainer; +import org.eclipse.jetty.client.HttpClient; + +/** + * 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 { + + /** + * 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 '.' 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 resp, FilterChain chain) + throws IOException, ServletException { + // 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 targetNode = req.getHeader(TARGET_NODE_HEADER); + String queryString = req.getQueryString(); + if (targetNode == null + && "GET".equals(req.getMethod()) + && queryString != null + && queryString.contains(TARGET_NODE_HEADER)) { + targetNode = SolrRequestParsers.parseQueryString(queryString).get(TARGET_NODE_HEADER); + } + if (targetNode == null || targetNode.isBlank() || !proxyToNode(targetNode, req, resp)) { + chain.doFilter(req, resp); + } + } + + /** Returns true if the request was proxied to {@code targetNode}. */ + private boolean proxyToNode(String targetNode, HttpServletRequest req, HttpServletResponse resp) { + CoreContainer cores; + try { + cores = getCores(); + } catch (UnavailableException e) { + throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Core Container Unavailable"); + } + if (!cores.isZooKeeperAware()) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, TARGET_NODE_HEADER + " header requires SolrCloud"); + } + String ownNodeName = cores.getZkController().getNodeName(); + String resolvedNode = resolvePeerShorthand(targetNode, ownNodeName); + ZkStateReader zkStateReader = cores.getZkController().getZkStateReader(); + if (!zkStateReader.getClusterState().getLiveNodes().contains(resolvedNode)) { + throw new SolrException( + SolrException.ErrorCode.NOT_FOUND, "Unknown or non-live node: " + resolvedNode); + } + if (resolvedNode.equals(ownNodeName)) { + return false; // already on the target node; let it process normally + } + + String url = + zkStateReader.getBaseUrlForNodeName(resolvedNode) + ServletUtils.getPathAfterContext(req); Review Comment: `ServletUtils.getPathAfterContext()` concatenates decoded `servletPath` and `pathInfo`, which cannot safely be appended to a URL verbatim. An encoded `%23` becomes a fragment delimiter rather than part of the upstream path; spaces and percent signs can also produce invalid or changed URLs. Preserve the encoded request URI when building the upstream URL, and test an encoded path segment. ########## solr/core/src/java/org/apache/solr/servlet/NodeTargetProxyFilter.java: ########## @@ -0,0 +1,138 @@ +/* + * 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 jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.UnavailableException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +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.core.CoreContainer; +import org.eclipse.jetty.client.HttpClient; + +/** + * 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 { + + /** + * 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 '.' 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 resp, FilterChain chain) + throws IOException, ServletException { + // 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 targetNode = req.getHeader(TARGET_NODE_HEADER); + String queryString = req.getQueryString(); + if (targetNode == null + && "GET".equals(req.getMethod()) + && queryString != null + && queryString.contains(TARGET_NODE_HEADER)) { + targetNode = SolrRequestParsers.parseQueryString(queryString).get(TARGET_NODE_HEADER); + } + if (targetNode == null || targetNode.isBlank() || !proxyToNode(targetNode, req, resp)) { + chain.doFilter(req, resp); + } + } + + /** Returns true if the request was proxied to {@code targetNode}. */ + private boolean proxyToNode(String targetNode, HttpServletRequest req, HttpServletResponse resp) { + CoreContainer cores; + try { + cores = getCores(); + } catch (UnavailableException e) { + throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Core Container Unavailable"); + } + if (!cores.isZooKeeperAware()) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, TARGET_NODE_HEADER + " header requires SolrCloud"); + } + String ownNodeName = cores.getZkController().getNodeName(); + String resolvedNode = resolvePeerShorthand(targetNode, ownNodeName); + ZkStateReader zkStateReader = cores.getZkController().getZkStateReader(); + if (!zkStateReader.getClusterState().getLiveNodes().contains(resolvedNode)) { + throw new SolrException( + SolrException.ErrorCode.NOT_FOUND, "Unknown or non-live node: " + resolvedNode); + } + if (resolvedNode.equals(ownNodeName)) { + return false; // already on the target node; let it process normally + } + + String url = + zkStateReader.getBaseUrlForNodeName(resolvedNode) + ServletUtils.getPathAfterContext(req); + String queryString = req.getQueryString(); + if (queryString != null) { + url = url + "?" + queryString; + } + try { + HttpClient httpClient = + ((HttpJettySolrClient) cores.getDefaultHttpSolrClient()).getHttpClient(); + HttpSolrProxy.doHttpProxy(httpClient, req, resp, url); + } catch (Exception e) { + throw new SolrException( + SolrException.ErrorCode.SERVER_ERROR, + "Error proxying request to node: " + resolvedNode, + e); + } catch (Error e) { + throw e; + } catch (Throwable e) { + throw new RuntimeException(e); + } + return true; + } + + /** + * 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 '.', 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(".")) { Review Comment: Complete live-node names can have single-label hosts. Resolving `solr2:8983_solr` against `solr1:8983_solr` produces `solr2:8983_solr:8983_solr`, so valid targets, including self-targets, incorrectly fail the live-node check. Preserve names containing the port separator before applying bare-hostname shorthand, and add a complete dotless-node-name test. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
