summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMichael Peter Christen <mc@yacy.net>2026-07-11 23:40:15 +0200
committerMichael Peter Christen <mc@yacy.net>2026-07-11 23:40:15 +0200
commitbfddfe5e7ed90a80b4acf9a71a567b61b77cfcc4 (patch)
treed1918d46d198606e867b9d83e0a64bff19c0891b
parentedeb6162c435e4ad9098233eed4f8c9c9b0312e2 (diff)
isolate Solr 9.0 Jetty client dependencies in a relocated bridge
-rw-r--r--.classpath10
-rw-r--r--.externalToolBuilders/solr9-bridge.launch10
-rw-r--r--.project10
-rw-r--r--JETTY12_MIGRATION.md203
-rw-r--r--build.xml36
-rw-r--r--ivy.xml47
-rw-r--r--source/net/yacy/http/AdminAccessPolicy.java60
-rw-r--r--source/net/yacy/http/AdminAuthenticationContext.java25
-rw-r--r--source/net/yacy/http/InetPathAccessHandler.java23
-rw-r--r--source/net/yacy/http/InetPathAccessRule.java42
-rw-r--r--source/net/yacy/http/YaCyDigestCredential.java45
-rw-r--r--source/net/yacy/http/YaCySecurityHandler.java38
-rw-r--r--source/net/yacy/http/servlets/Jetty9ServletResource.java99
-rw-r--r--source/net/yacy/http/servlets/ServletResource.java33
-rw-r--r--source/net/yacy/http/servlets/YaCyDefaultServlet.java285
-rw-r--r--test/java/net/yacy/http/AdminSecurityTest.java37
-rw-r--r--test/java/net/yacy/http/InetPathAccessHandlerTest.java12
-rwxr-xr-xtest/jetty-solr-dependency-guard.sh81
-rw-r--r--test/jetty/RelocateJettyPackages.java135
-rwxr-xr-xtest/solr9-jetty-bridge-spike.sh39
20 files changed, 1118 insertions, 152 deletions
diff --git a/.classpath b/.classpath
index 62dbe260e..495d71af5 100644
--- a/.classpath
+++ b/.classpath
@@ -12,5 +12,15 @@
<attribute name="module" value="true"/>
</attributes>
</classpathentry>
+ <classpathentry kind="lib" path="lib/solr9-bridge-solr-core-9.0.0.jar"/>
+ <classpathentry kind="lib" path="lib/solr9-bridge-solr-solrj-9.0.0.jar"/>
+ <classpathentry kind="lib" path="lib/solr9-bridge-solr-scripting-9.0.0.jar"/>
+ <classpathentry kind="lib" path="lib/solr9-bridge-jetty-client-9.4.58.v20250814.jar"/>
+ <classpathentry kind="lib" path="lib/solr9-bridge-jetty-http-9.4.58.v20250814.jar"/>
+ <classpathentry kind="lib" path="lib/solr9-bridge-jetty-io-9.4.58.v20250814.jar"/>
+ <classpathentry kind="lib" path="lib/solr9-bridge-jetty-util-9.4.58.v20250814.jar"/>
+ <classpathentry kind="lib" path="lib/solr9-bridge-http2-client-9.4.58.v20250814.jar"/>
+ <classpathentry kind="lib" path="lib/solr9-bridge-http2-common-9.4.58.v20250814.jar"/>
+ <classpathentry kind="lib" path="lib/solr9-bridge-http2-http-client-transport-9.4.58.v20250814.jar"/>
<classpathentry kind="output" path="gen"/>
</classpath>
diff --git a/.externalToolBuilders/solr9-bridge.launch b/.externalToolBuilders/solr9-bridge.launch
new file mode 100644
index 000000000..012878bbd
--- /dev/null
+++ b/.externalToolBuilders/solr9-bridge.launch
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<launchConfiguration type="org.eclipse.ant.AntBuilderLaunchConfigurationType">
+ <booleanAttribute key="org.eclipse.ant.ui.ATTR_TARGETS_UPDATED" value="true"/>
+ <stringAttribute key="org.eclipse.ant.ui.ATTR_ANT_FULL_TARGETS" value="build-solr9-bridge,"/>
+ <stringAttribute key="org.eclipse.ant.ui.ATTR_ANT_MANUAL_TARGETS" value="build-solr9-bridge,"/>
+ <booleanAttribute key="org.eclipse.ui.externaltools.ATTR_TRIGGERS_CONFIGURED" value="true"/>
+ <stringAttribute key="org.eclipse.ui.externaltools.ATTR_RUN_BUILD_KINDS" value="full,"/>
+ <stringAttribute key="org.eclipse.ui.externaltools.ATTR_LOCATION" value="${workspace_loc:/yacy/build.xml}"/>
+ <stringAttribute key="org.eclipse.ui.externaltools.ATTR_WORKING_DIRECTORY" value="${workspace_loc:/yacy}"/>
+</launchConfiguration>
diff --git a/.project b/.project
index 5f27c43c7..9b96098cf 100644
--- a/.project
+++ b/.project
@@ -10,6 +10,16 @@
<arguments>
</arguments>
</buildCommand>
+ <buildCommand>
+ <name>org.eclipse.ui.externaltools.ExternalToolBuilder</name>
+ <triggers>full,</triggers>
+ <arguments>
+ <dictionary>
+ <key>LaunchConfigHandle</key>
+ <value>&lt;project&gt;/.externalToolBuilders/solr9-bridge.launch</value>
+ </dictionary>
+ </arguments>
+ </buildCommand>
<buildCommand>
<name>org.eclipse.jdt.core.javabuilder</name>
<arguments>
diff --git a/JETTY12_MIGRATION.md b/JETTY12_MIGRATION.md
new file mode 100644
index 000000000..72d867f98
--- /dev/null
+++ b/JETTY12_MIGRATION.md
@@ -0,0 +1,203 @@
+# Jetty 12 Migration Contract
+
+## Scope
+
+YaCy will migrate its embedded HTTP server from Jetty 9 to Jetty 12 while
+remaining on Java 17 and keeping the existing Servlet 4.0 `javax.servlet`
+source API. Jetty 12's EE8 environment provides this compatibility layer; the
+migration does not require converting YaCy to `jakarta.servlet`.
+
+This contract covers the dependency boundary only. It does not yet migrate the
+Jetty-specific adapters under `source/net/yacy/http`.
+
+## Solr Boundary
+
+YaCy embeds Solr 9 but does not directly use Solr's embedded `JettySolrRunner`
+or its Jetty-based HTTP/2 clients. Remote Solr access is implemented with
+`HttpSolrClient` and `ConcurrentUpdateSolrClient`, both configured with Apache
+HttpClient in `RemoteInstance`.
+
+The three Solr artifacts in `ivy.xml` therefore belong to the private
+`solr9-bridge` configuration and explicitly exclude the `org.eclipse.jetty`
+and `org.eclipse.jetty.http2` families. They are retrieved below
+`build/solr9-bridge/input`, not directly into the runtime `lib` directory.
+
+Solr 9.0 nevertheless has an eager internal dependency that cannot be removed:
+`CoreContainer` constructs both `HttpShardHandlerFactory` and
+`UpdateShardHandler`, which create `Http2SolrClient` instances during startup
+even in YaCy's standalone embedded configuration. The build therefore rewrites
+Solr's Jetty references and the minimal Jetty 9 client/HTTP2 implementation to
+the private `net.yacy.solr9.jetty` package. The generated artifacts have the
+`solr9-bridge-` prefix under `lib/`; unrelocated Solr and HTTP2 jars are removed
+from that directory before compilation.
+
+Run the boundary guard after resolving dependencies:
+
+```sh
+ant clean compile
+test/jetty-solr-dependency-guard.sh
+```
+
+## Current Jetty 9 Roots
+
+The direct Jetty dependencies reflect APIs imported by YaCy source code.
+Dependencies needed only by another Jetty module remain transitive.
+
+| Responsibility | Direct Jetty 9 artifact |
+| --- | --- |
+| HTTP primitives | `jetty-http` |
+| Connection and output APIs | `jetty-io` |
+| Server and handlers | `jetty-server` |
+| Utility, resource, and TLS APIs | `jetty-util` |
+| CONNECT and proxy handlers | `jetty-proxy` |
+| Authentication and constraints | `jetty-security` |
+| Servlet container | `jetty-servlet` |
+| QoS and servlet helpers | `jetty-servlets` |
+| Web application context | `jetty-webapp` |
+
+The public `jetty-client` is owned transitively by `jetty-proxy`. YaCy has no
+direct client API imports. `jetty-deploy` and `jetty-jmx` are not part of the
+current root graph. HTTP/2 exists only inside the relocated Solr island, not as
+an embedded-server feature.
+
+## Jetty 12 EE8 Target Graph
+
+The minimal target keeps Jetty Core separate from the EE8 Servlet layer.
+
+| Current responsibility | Jetty 12 target family |
+| --- | --- |
+| HTTP, IO, server, utilities | Jetty Core `jetty-http`, `jetty-io`, `jetty-server`, `jetty-util` |
+| Proxy handlers | Jetty Core `jetty-proxy` and its client transitive |
+| Servlet container | `org.eclipse.jetty.ee8:jetty-ee8-servlet` |
+| Servlet helpers and QoS | `org.eclipse.jetty.ee8:jetty-ee8-servlets` |
+| Web application context | `org.eclipse.jetty.ee8:jetty-ee8-webapp` |
+| Servlet authentication | `org.eclipse.jetty.ee8:jetty-ee8-security` plus Jetty Core security transitives |
+| Servlet API | Servlet 4.0 in the `javax.servlet` namespace, aligned with the EE8 environment |
+
+Do not add Jetty deploy, JMX, server-side HTTP/2, or Jakarta EE modules unless
+a concrete YaCy feature requires them and its contract is verified separately.
+The private Solr 9 HTTP/2 island above is the only current exception.
+
+### Solr 9 Isolation Gate (P1)
+
+Jetty 9 client classes and Jetty 12 server classes cannot safely share the
+same application classloader because they use overlapping
+`org.eclipse.jetty.*` packages with incompatible APIs.
+
+The P1 investigation rules out the two initially attractive shortcuts:
+
+1. **A Solr upgrade is not part of this migration.** YaCy remains on Solr
+ 9.0.0. A later Solr upgrade may provide another migration option, but it is
+ deliberately not a P1 implementation path. A comparison with Solr 9.10.1
+ also showed that upgrading within the Solr 9 line would not remove the
+ boundary: it selects Jetty 10.0.26 and still eagerly constructs
+ `Http2SolrClient` instances.
+2. **Whole-Solr classloader isolation is not a minimal boundary for YaCy.**
+ Solr API objects are part of the application boundary: 88 files under
+ `source/` and `test/` currently import `org.apache.solr` types. A child
+ classloader would either create incompatible class identities or require a
+ broad new facade and data conversion layer.
+
+Solr 10 is also outside this migration contract: although it moves to Jetty
+12, the Solr 10 server requires Java 21 and uses the Jakarta Servlet namespace.
+
+P1 implements a **relocated Solr 9.0.0 Jetty client bridge**. The bridge keeps
+the public `org.apache.solr.*` classes visible to YaCy while
+rewriting Solr 9.0.0's internal `org.eclipse.jetty.*` references and the
+required Jetty 9 client/HTTP2 implementation into a private package. It is
+built reproducibly by Ant; no edited jar is stored in the repository. P1 does
+not change the Solr or Lucene versions.
+
+P1 is complete only when a bridge proof passes all of these checks:
+
+1. only the selected embedded-server Jetty line uses the public
+ `org.eclipse.jetty` package;
+2. no unrelocated Jetty 9/10 class is packaged by the bridge;
+3. `EmbeddedSolrConnectorTest` starts and closes a `CoreContainer`;
+4. an embedded update followed by a query succeeds;
+5. the bridge dependency set and relocation rules are generated by the build;
+6. the bridge can be removed without changing YaCy's Solr-facing source API.
+
+The integrated bridge passes `EmbeddedSolrConnectorTest` (`OK (4 tests)`) with
+the original Solr and HTTP2 jars removed. It also passes when
+Jetty 12.1.11 Core client, HTTP, IO, proxy, security, server, and utility jars
+are present in the same application classpath. The reduced private island is:
+
+- Solr Core, SolrJ, and Solr Scripting 9.0.0 with only their Jetty references
+ rewritten;
+- Jetty 9.4 client, HTTP, IO, and utility;
+- Jetty 9.4 HTTP/2 client, common, and HTTP-client transport.
+
+Jetty server, servlet, security, proxy, webapp, and XML are not included in
+the Solr island. The proof can be repeated with:
+
+```sh
+ant clean compile
+test/solr9-jetty-bridge-spike.sh
+```
+
+Set `JETTY12_CLASSPATH` to a colon-separated set of resolved Jetty 12 jars to
+repeat the coexistence variant. The script compiles only its focused test into
+a temporary directory; the bridge itself is already produced by `ant compile`.
+
+All six P1 checks pass. The Solr isolation gate is therefore closed for the
+Jetty 12 server migration. Reconsidering Solr remains a separate future
+decision, not an automatic part of this migration.
+
+The optional version comparison that established the limitation of the Solr 9
+line can be repeated without changing YaCy's production dependencies with:
+
+```sh
+javap -classpath ~/.ivy2/cache/org.apache.solr/solr-core/jars/solr-core-9.10.1.jar \
+ -private -c org.apache.solr.update.UpdateShardHandler
+javap -classpath ~/.ivy2/cache/org.apache.solr/solr-core/jars/solr-core-9.10.1.jar \
+ -private -c org.apache.solr.handler.component.HttpShardHandlerFactory
+```
+
+## Completion Gates
+
+The dependency phase is complete when all of these checks pass with one public
+Jetty version on the resolved classpath and the private Solr island:
+
+1. `ant clean compile`
+2. `test/jetty-solr-dependency-guard.sh`
+3. Startup with embedded Solr enabled
+4. A proven resolution for the Solr 9 isolation gate
+5. A remote Solr request through the Apache-based client
+6. Proxy traffic including CONNECT
+7. `test/jetty-smoke-test.sh`
+
+The following implementation phase may then replace `Jetty9HttpServerImpl` and
+the remaining Jetty adapter APIs without changing the Solr dependency graph.
+
+## P2.1 Helper Removal
+
+`YaCyDefaultServlet` no longer imports Jetty HTTP header/method constants,
+MIME lookup, URI joining, writer adaptation, inclusive byte ranges, multipart
+output, or `Resource`. The small operations use Servlet/JDK APIs or local
+implementations. Static resources are exposed through the container-neutral
+`ServletResource` interface; `Jetty9ServletResource` is the only Jetty 9
+adapter for the existing resource behavior.
+
+`YaCyQoSFilter` and `YaCyDigestCredential` remain explicit container adapters
+rather than being replaced by simplified local implementations that could
+change request priority or authentication behavior.
+
+## P2.2 Authentication And Access Rules
+
+The request-level administrator decision is represented by
+`AdminAccessPolicy`: public access, the configured localhost bypass, or the
+administrator role. It combines the existing pure `AdminSecurity` checks
+without depending on Servlet or Jetty APIs. In particular, the localhost
+without account option and the localhost-only stored-hash authentication used
+by `bin/apicall.sh` remain supported.
+
+`AdminAuthenticationContext` carries the true socket peer IP only for the
+duration of the current authentication call. `YaCySecurityHandler` publishes
+and clears that context, and `YaCyDigestCredential` only adapts Jetty's BASIC
+and DIGEST credential objects to the container-neutral password check.
+
+The portable address/path syntax of `serverClient` is represented by
+`InetPathAccessRule`. `InetPathAccessHandler` remains the Jetty 9 matcher
+adapter; Jetty 12 can consume the normalized `address|path` rules with its
+native path-aware access handler.
diff --git a/build.xml b/build.xml
index 585b1d216..821478b7e 100644
--- a/build.xml
+++ b/build.xml
@@ -38,6 +38,10 @@
<property name="lib" location="lib"/>
<property name="libt" location="libt"/>
<property name="build" location="build/classes/java/main"/> <!-- reuse Gradle build path -->
+ <property name="solr9.bridge.build" location="build/solr9-bridge"/>
+ <property name="solr9.bridge.input" location="${solr9.bridge.build}/input"/>
+ <property name="solr9.bridge.tool" location="${solr9.bridge.build}/tool"/>
+ <property name="solr9.bridge.tool.libs" location="${solr9.bridge.build}/tool-libs"/>
<property name="javadoc" location="javadoc"/>
<property name="htroot" location="htroot"/>
<property name="test" location="test/java"/>
@@ -91,6 +95,8 @@
-->
<ivy:retrieve conf="compile" pathid="compile.path" pattern="${ivy.lib.dir}/[artifact]-[revision].[ext]" />
<ivy:retrieve conf="test" pathid="test.path" pattern="${libt}/[artifact]-[revision].[ext]" />
+ <ivy:retrieve conf="solr9-bridge" pathid="solr9.bridge.input.path" pattern="${solr9.bridge.input}/[artifact]-[revision].[ext]" />
+ <ivy:retrieve conf="solr9-bridge-tool" pathid="solr9.bridge.tool.path" pattern="${solr9.bridge.tool.libs}/[artifact]-[revision].[ext]" />
<property name="target-resolve-already-run" value="true" />
</target>
@@ -171,9 +177,36 @@
<pathelement location="${build}" />
<pathelement location="${lib}/J7Zip-modified-1.0.2.jar" />
<path refid="compile.path" />
+ <fileset dir="${lib}" includes="solr9-bridge-*.jar" />
</path>
- <target name="compile-core" depends="init,resolve" description="compile YaCy core">
+ <target name="build-solr9-bridge" depends="init,resolve" description="build the private Solr 9.0 Jetty client island">
+ <delete dir="${solr9.bridge.tool}" failonerror="false"/>
+ <delete failonerror="false">
+ <fileset dir="${lib}" includes="solr9-bridge-*.jar,solr-core-9.0.0.jar,solr-solrj-9.0.0.jar,solr-scripting-9.0.0.jar,http2-*.jar" />
+ </delete>
+ <mkdir dir="${solr9.bridge.tool}"/>
+ <javac srcdir="test/jetty" destdir="${solr9.bridge.tool}"
+ includes="RelocateJettyPackages.java" includeantruntime="false"
+ release="${javacRelease}" encoding="UTF-8">
+ <classpath refid="solr9.bridge.tool.path"/>
+ </javac>
+ <path id="solr9.bridge.runtime.path">
+ <pathelement location="${solr9.bridge.tool}"/>
+ <path refid="solr9.bridge.tool.path"/>
+ </path>
+ <apply executable="${java.home}/bin/java" parallel="false" failonerror="true" dest="${lib}">
+ <arg value="-cp"/>
+ <arg pathref="solr9.bridge.runtime.path"/>
+ <arg value="net.yacy.test.jetty.RelocateJettyPackages"/>
+ <srcfile/>
+ <targetfile/>
+ <fileset dir="${solr9.bridge.input}" includes="*.jar"/>
+ <mapper type="glob" from="*" to="solr9-bridge-*"/>
+ </apply>
+ </target>
+
+ <target name="compile-core" depends="init,resolve,build-solr9-bridge" description="compile YaCy core">
<!-- compile the core sources -->
<echo message="project.class.path: ${toString:project.class.path}" />
<javac srcdir="${src}/" destdir="${build}"
@@ -417,6 +450,7 @@
<fileset dir="." includes="TEST-*" />
</delete>
<delete dir="test/DATA" failonerror="false"/>
+ <delete dir="${solr9.bridge.build}" failonerror="false"/>
</target>
<!-- run YaCy (needed for NetBeans4) -->
diff --git a/ivy.xml b/ivy.xml
index 71c9a4a70..236c1911b 100644
--- a/ivy.xml
+++ b/ivy.xml
@@ -4,6 +4,8 @@
<!-- https://wrongnotes.blogspot.com/2014/02/simplest-explanation-of-ivy.html -->
<conf name="compile" visibility="public"/>
<conf name="test" visibility="public"/>
+ <conf name="solr9-bridge" visibility="private"/>
+ <conf name="solr9-bridge-tool" visibility="private"/>
</configurations>
<dependencies defaultconf="compile->default">
@@ -64,17 +66,31 @@
<dependency org="org.apache.poi" name="poi" rev="5.5.1" />
<dependency org="org.apache.poi" name="poi-scratchpad" rev="5.5.1" />
- <dependency org="org.apache.solr" name="solr-core" rev="9.0.0" conf="compile->master"/>
- <dependency org="org.apache.solr" name="solr-scripting" rev="9.0.0" conf="compile->master"/>
- <dependency org="org.apache.solr" name="solr-solrj" rev="9.0.0" conf="compile->master" />
+ <!--
+ Solr is embedded and its Jetty-backed runner and HTTP/2 clients are not used directly by YaCy.
+ Keep Solr's Jetty 9 runtime family out of the embedded-server classpath so Jetty can
+ be migrated independently. The master configuration already omits transitives; the
+ explicit excludes preserve that boundary if the configuration mapping changes later.
+ -->
+ <dependency org="org.apache.solr" name="solr-core" rev="9.0.0" conf="solr9-bridge->master">
+ <exclude org="org.eclipse.jetty" module="*"/>
+ <exclude org="org.eclipse.jetty.http2" module="*"/>
+ </dependency>
+ <dependency org="org.apache.solr" name="solr-scripting" rev="9.0.0" conf="solr9-bridge->master">
+ <exclude org="org.eclipse.jetty" module="*"/>
+ <exclude org="org.eclipse.jetty.http2" module="*"/>
+ </dependency>
+ <dependency org="org.apache.solr" name="solr-solrj" rev="9.0.0" conf="solr9-bridge->master">
+ <exclude org="org.eclipse.jetty" module="*"/>
+ <exclude org="org.eclipse.jetty.http2" module="*"/>
+ </dependency>
<dependency org="org.apache.zookeeper" name="zookeeper" rev="3.9.5" conf="compile->master" />
<dependency org="org.bitlet" name="weupnp" rev="0.1.4" />
<dependency org="org.bouncycastle" name="bcmail-jdk18on" rev="1.84" />
<dependency org="com.fasterxml.woodstox" name="woodstox-core" rev="7.2.1" />
- <dependency org="org.eclipse.jetty" name="jetty-client" rev="9.4.58.v20250814" />
- <dependency org="org.eclipse.jetty" name="jetty-deploy" rev="9.4.58.v20250814" conf="compile->master" />
- <dependency org="org.eclipse.jetty" name="jetty-jmx" rev="9.4.58.v20250814" conf="compile->master"/>
- <dependency org="org.eclipse.jetty" name="jetty-http" rev="9.4.58.v20250814"/>
+ <dependency org="org.eclipse.jetty" name="jetty-http" rev="9.4.58.v20250814" conf="compile->default;solr9-bridge->master"/>
+ <dependency org="org.eclipse.jetty" name="jetty-io" rev="9.4.58.v20250814" conf="compile->default;solr9-bridge->master"/>
+ <dependency org="org.eclipse.jetty" name="jetty-client" rev="9.4.58.v20250814" conf="solr9-bridge->master"/>
<dependency org="org.eclipse.jetty" name="jetty-proxy" rev="9.4.58.v20250814"/>
<dependency org="org.eclipse.jetty" name="jetty-security" rev="9.4.58.v20250814"/>
<dependency org="org.eclipse.jetty" name="jetty-server" rev="9.4.58.v20250814"/>
@@ -82,11 +98,17 @@
<dependency org="org.eclipse.jetty" name="jetty-servlet" rev="9.4.58.v20250814">
<exclude module="jetty-util-ajax" />
</dependency>
- <dependency org="org.eclipse.jetty" name="jetty-util" rev="9.4.58.v20250814" />
+ <dependency org="org.eclipse.jetty" name="jetty-util" rev="9.4.58.v20250814" conf="compile->default;solr9-bridge->master" />
<dependency org="org.eclipse.jetty" name="jetty-webapp" rev="9.4.58.v20250814" />
- <dependency org="org.eclipse.jetty.http2" name="http2-client" rev="9.4.58.v20250814" conf="compile->master"/>
- <dependency org="org.eclipse.jetty.http2" name="http2-common" rev="9.4.58.v20250814" conf="compile->master"/>
- <dependency org="org.eclipse.jetty.http2" name="http2-http-client-transport" rev="9.4.58.v20250814" conf="compile->master"/>
+ <!--
+ Temporary Solr 9.0 runtime bridge: CoreContainer eagerly constructs an
+ Http2SolrClient for its shard/update handlers even in standalone mode.
+ YaCy source must not use these clients. Remove this Jetty 9 island only
+ after the relocated Solr 9.0 bridge replaces these unrelocated jars.
+ -->
+ <dependency org="org.eclipse.jetty.http2" name="http2-client" rev="9.4.58.v20250814" conf="solr9-bridge->master"/>
+ <dependency org="org.eclipse.jetty.http2" name="http2-common" rev="9.4.58.v20250814" conf="solr9-bridge->master"/>
+ <dependency org="org.eclipse.jetty.http2" name="http2-http-client-transport" rev="9.4.58.v20250814" conf="solr9-bridge->master"/>
<dependency org="org.jsoup" name="jsoup" rev="1.22.2" />
<dependency org="org.jwat" name="jwat-warc" rev="1.2.1">
<exclude module="bcprov-jdk15to18" />
@@ -104,6 +126,9 @@
<dependency org="junit" name="junit" rev="4.13.2" conf="test->default"/>
<dependency org="org.hamcrest" name="hamcrest-core" rev="3.0" conf="test->default"/>
+ <!-- Build-time bytecode relocation for the private Solr 9.0 Jetty client island. -->
+ <dependency org="org.ow2.asm" name="asm" rev="9.9" conf="solr9-bridge-tool->master"/>
+ <dependency org="org.ow2.asm" name="asm-commons" rev="9.9" conf="solr9-bridge-tool->master"/>
<dependency org="com.github.luben" name="zstd-jni" rev="1.5.7-11"></dependency>
<dependency org="ai.djl" name ="api" rev="0.36.0"></dependency>
</dependencies>
diff --git a/source/net/yacy/http/AdminAccessPolicy.java b/source/net/yacy/http/AdminAccessPolicy.java
new file mode 100644
index 000000000..e4bad1350
--- /dev/null
+++ b/source/net/yacy/http/AdminAccessPolicy.java
@@ -0,0 +1,60 @@
+package net.yacy.http;
+
+import java.net.MalformedURLException;
+
+import net.yacy.cora.document.id.MultiProtocolURL;
+
+/** Container-neutral policy for administrator access to a request path. */
+public final class AdminAccessPolicy {
+
+ public enum Decision {
+ PUBLIC,
+ LOCAL_BYPASS,
+ ADMIN_REQUIRED
+ }
+
+ private final boolean protectAllPages;
+ private final boolean privateRobinsonMode;
+ private final boolean publicSearchPage;
+ private final boolean allowLocalhostWithoutLogin;
+ private final String adminUser;
+ private final String adminHash;
+
+ public AdminAccessPolicy(final boolean protectAllPages, final boolean privateRobinsonMode,
+ final boolean publicSearchPage, final boolean allowLocalhostWithoutLogin,
+ final String adminUser, final String adminHash) {
+ this.protectAllPages = protectAllPages;
+ this.privateRobinsonMode = privateRobinsonMode;
+ this.publicSearchPage = publicSearchPage;
+ this.allowLocalhostWithoutLogin = allowLocalhostWithoutLogin;
+ this.adminUser = adminUser;
+ this.adminHash = adminHash;
+ }
+
+ public Decision decide(final String path, final String socketPeerIp, final String referer,
+ final String authorizationHeader) {
+ if (!AdminSecurity.isProtectedPath(path, this.protectAllPages,
+ this.privateRobinsonMode, this.publicSearchPage)) {
+ return Decision.PUBLIC;
+ }
+
+ if (AdminSecurity.isLocalhostAccess(socketPeerIp, refererHost(referer))) {
+ if (this.allowLocalhostWithoutLogin || AdminSecurity.checkLocalhostLazyAuth(
+ authorizationHeader, this.adminUser, this.adminHash)) {
+ return Decision.LOCAL_BYPASS;
+ }
+ }
+ return Decision.ADMIN_REQUIRED;
+ }
+
+ private static String refererHost(final String referer) {
+ if (referer == null || referer.isEmpty()) {
+ return null;
+ }
+ try {
+ return new MultiProtocolURL(referer).getHost();
+ } catch (final MalformedURLException e) {
+ return null;
+ }
+ }
+}
diff --git a/source/net/yacy/http/AdminAuthenticationContext.java b/source/net/yacy/http/AdminAuthenticationContext.java
new file mode 100644
index 000000000..e276d4cbd
--- /dev/null
+++ b/source/net/yacy/http/AdminAuthenticationContext.java
@@ -0,0 +1,25 @@
+package net.yacy.http;
+
+import net.yacy.cora.protocol.Domains;
+
+/** Request-bound facts needed while the container verifies admin credentials. */
+public final class AdminAuthenticationContext {
+
+ private static final ThreadLocal<String> SOCKET_PEER_IP = new ThreadLocal<>();
+
+ private AdminAuthenticationContext() {
+ }
+
+ public static void setSocketPeerIp(final String ip) {
+ SOCKET_PEER_IP.set(ip);
+ }
+
+ public static void clear() {
+ SOCKET_PEER_IP.remove();
+ }
+
+ public static boolean isLocalhostRequest() {
+ final String ip = SOCKET_PEER_IP.get();
+ return ip != null && Domains.isLocalhost(ip);
+ }
+}
diff --git a/source/net/yacy/http/InetPathAccessHandler.java b/source/net/yacy/http/InetPathAccessHandler.java
index 84a3ddc4a..6d3651b83 100644
--- a/source/net/yacy/http/InetPathAccessHandler.java
+++ b/source/net/yacy/http/InetPathAccessHandler.java
@@ -98,23 +98,14 @@ public class InetPathAccessHandler extends InetAccessHandler {
*/
protected void addPattern(final String pattern, final PathMappings<InetAddressSet> pathMappings)
throws IllegalArgumentException {
- if (pattern != null && !pattern.isEmpty()) {
- final int idx = pattern.indexOf('|');
-
- final String addr = idx > 0 ? pattern.substring(0, idx) : pattern;
- final String path = (idx > 0 && (pattern.length() > idx + 1)) ? pattern.substring(idx + 1) : "/*";
-
- if (!addr.isEmpty()) {
- final PathSpec pathSpec = PathSpec.from(path);
- InetAddressSet addresses = pathMappings.get(pathSpec);
- if (addresses == null) {
- addresses = new InetAddressSet();
- pathMappings.put(pathSpec, addresses);
- }
- addresses.add(addr);
-
- }
+ final InetPathAccessRule rule = InetPathAccessRule.parse(pattern);
+ final PathSpec pathSpec = PathSpec.from(rule.pathPattern());
+ InetAddressSet addresses = pathMappings.get(pathSpec);
+ if (addresses == null) {
+ addresses = new InetAddressSet();
+ pathMappings.put(pathSpec, addresses);
}
+ addresses.add(rule.addressPattern());
}
/**
diff --git a/source/net/yacy/http/InetPathAccessRule.java b/source/net/yacy/http/InetPathAccessRule.java
new file mode 100644
index 000000000..f93f6336b
--- /dev/null
+++ b/source/net/yacy/http/InetPathAccessRule.java
@@ -0,0 +1,42 @@
+package net.yacy.http;
+
+/** Container-neutral representation of a server-client address/path rule. */
+public final class InetPathAccessRule {
+
+ private static final String DEFAULT_PATH = "/*";
+
+ private final String addressPattern;
+ private final String pathPattern;
+
+ private InetPathAccessRule(final String addressPattern, final String pathPattern) {
+ this.addressPattern = addressPattern;
+ this.pathPattern = pathPattern;
+ }
+
+ public static InetPathAccessRule parse(final String pattern) {
+ if (pattern == null || pattern.isEmpty()) {
+ throw new IllegalArgumentException("Access rule must not be empty");
+ }
+ final int separator = pattern.indexOf('|');
+ final String address = separator > 0 ? pattern.substring(0, separator) : pattern;
+ final String path = separator > 0 && pattern.length() > separator + 1
+ ? pattern.substring(separator + 1)
+ : DEFAULT_PATH;
+ if (address.isEmpty()) {
+ throw new IllegalArgumentException("Access rule has no address: " + pattern);
+ }
+ return new InetPathAccessRule(address, path);
+ }
+
+ public String addressPattern() {
+ return this.addressPattern;
+ }
+
+ public String pathPattern() {
+ return this.pathPattern;
+ }
+
+ public String asJettyPattern() {
+ return this.addressPattern + '|' + this.pathPattern;
+ }
+}
diff --git a/source/net/yacy/http/YaCyDigestCredential.java b/source/net/yacy/http/YaCyDigestCredential.java
index 450c12f1f..f11c676d6 100644
--- a/source/net/yacy/http/YaCyDigestCredential.java
+++ b/source/net/yacy/http/YaCyDigestCredential.java
@@ -24,7 +24,6 @@
package net.yacy.http;
-import net.yacy.cora.protocol.Domains;
import net.yacy.search.Switchboard;
import net.yacy.search.SwitchboardConstants;
@@ -46,43 +45,6 @@ public class YaCyDigestCredential extends Credential {
private static final long serialVersionUID = -3527894085562480001L;
- /**
- * True socket peer IP of the request currently being authenticated on this thread.
- * <p>
- * Jetty's {@link Credential#check(Object)} API does not hand over the request, so the
- * credential can not tell on its own whether a request comes from localhost. The
- * {@link YaCySecurityHandler} therefore publishes the request's socket peer IP here for
- * the duration of the request (set at the start of handling, cleared in a finally block).
- * This replaces the former global "recent localhost access" timestamp, which was a
- * process-wide value not bound to the request being checked.
- */
- private static final ThreadLocal<String> REQUEST_CLIENT_IP = new ThreadLocal<String>();
-
- /**
- * Publish the socket peer IP of the request being authenticated on the current thread.
- * Must be paired with {@link #clearRequestClientIP()} in a finally block.
- * @param ip the true socket peer IP (never an X-Real-IP derived address)
- */
- public static void setRequestClientIP(final String ip) {
- REQUEST_CLIENT_IP.set(ip);
- }
-
- /**
- * Remove the request client IP published for the current thread.
- */
- public static void clearRequestClientIP() {
- REQUEST_CLIENT_IP.remove();
- }
-
- /**
- * @return true when the request currently authenticated on this thread comes from
- * localhost. Fails closed (returns false) when no request IP was published.
- */
- private static boolean isRequestFromLocalhost() {
- final String ip = REQUEST_CLIENT_IP.get();
- return ip != null && Domains.isLocalhost(ip);
- }
-
private String hash; // remember password hash, either MD5(Base64(user:pwd)) or with encryption prefix "MD5:" + MD5(user:realm:pwd)
private String foruser; // remember the user as YaCy credential is username:pwd (not just pwd)
private Credential c;
@@ -108,14 +70,11 @@ public class YaCyDigestCredential extends Credential {
//
// We must therefore know whether THIS request comes from localhost. Jetty's
// Credential.check() is not given the request, so YaCySecurityHandler publishes
- // the request's true socket peer IP into REQUEST_CLIENT_IP for the duration of the
- // request (see the detailed rationale on YaCySecurityHandler.handle()); we read it
- // back here. Using the socket peer - not the spoofable X-Real-IP header - keeps
- // this exception restricted to genuine local callers.
+ // the true socket peer IP through AdminAuthenticationContext for this request.
return AdminSecurity.checkAdminPassword(this.foruser, this.hash,
sb.getConfig(SwitchboardConstants.ADMIN_REALM, ""),
sb.getConfig(SwitchboardConstants.ADMIN_ACCOUNT_USER_NAME, "admin"),
- isRequestFromLocalhost(),
+ AdminAuthenticationContext.isLocalhostRequest(),
(String) credentials);
}
throw new UnsupportedOperationException();
diff --git a/source/net/yacy/http/YaCySecurityHandler.java b/source/net/yacy/http/YaCySecurityHandler.java
index 436b167f1..9e6923d07 100644
--- a/source/net/yacy/http/YaCySecurityHandler.java
+++ b/source/net/yacy/http/YaCySecurityHandler.java
@@ -25,13 +25,11 @@
package net.yacy.http;
import java.io.IOException;
-import java.net.MalformedURLException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
-import net.yacy.cora.document.id.MultiProtocolURL;
import net.yacy.cora.protocol.RequestHeader;
import net.yacy.search.Switchboard;
import net.yacy.search.SwitchboardConstants;
@@ -47,7 +45,7 @@ import org.eclipse.jetty.server.Request;
* and updates AccessTracker
*
* This is a thin adapter to the servlet container: the decision logic is in
- * the container neutral {@link AdminSecurity}.
+ * the container-neutral {@link AdminAccessPolicy} and {@link AdminSecurity}.
*/
public class YaCySecurityHandler extends ConstraintSecurityHandler {
@@ -82,11 +80,11 @@ public class YaCySecurityHandler extends ConstraintSecurityHandler {
public void handle(final String pathInContext, final Request baseRequest,
final HttpServletRequest request, final HttpServletResponse response)
throws IOException, ServletException {
- YaCyDigestCredential.setRequestClientIP(baseRequest.getRemoteAddr());
+ AdminAuthenticationContext.setSocketPeerIp(baseRequest.getRemoteAddr());
try {
super.handle(pathInContext, baseRequest, request, response);
} finally {
- YaCyDigestCredential.clearRequestClientIP();
+ AdminAuthenticationContext.clear();
}
}
@@ -109,30 +107,20 @@ public class YaCySecurityHandler extends ConstraintSecurityHandler {
final String remoteip = request.getRemoteAddr();
serverAccessTracker.track(remoteip, pathInContext);
- final boolean protectedPage = AdminSecurity.isProtectedPath(pathInContext,
+ final AdminAccessPolicy policy = new AdminAccessPolicy(
sb.getConfigBool(SwitchboardConstants.ADMIN_ACCOUNT_All_PAGES, false),
sb.isRobinsonMode() && !sb.isPublicRobinson(),
- sb.getConfigBool(SwitchboardConstants.PUBLIC_SEARCHPAGE, true));
- if (!protectedPage) {
+ sb.getConfigBool(SwitchboardConstants.PUBLIC_SEARCHPAGE, true),
+ sb.getConfigBool(SwitchboardConstants.ADMIN_ACCOUNT_FOR_LOCALHOST, false),
+ sb.getConfig(SwitchboardConstants.ADMIN_ACCOUNT_USER_NAME, "admin"),
+ sb.getConfig(SwitchboardConstants.ADMIN_ACCOUNT_B64MD5, ""));
+ final AdminAccessPolicy.Decision decision = policy.decide(pathInContext, remoteip,
+ request.getHeader(RequestHeader.REFERER), request.getHeader(RequestHeader.AUTHORIZATION));
+ if (decision == AdminAccessPolicy.Decision.PUBLIC) {
return super.prepareConstraintInfo(pathInContext, request);
}
-
- String refererHost;
- try {
- refererHost = new MultiProtocolURL(request.getHeader(RequestHeader.REFERER)).getHost();
- } catch (MalformedURLException e) {
- refererHost = null;
- }
- if (AdminSecurity.isLocalhostAccess(remoteip, refererHost)) {
- if (sb.getConfigBool(SwitchboardConstants.ADMIN_ACCOUNT_FOR_LOCALHOST, false)) {
- return null;
- }
- // last chance to authorize using the admin from localhost
- if (AdminSecurity.checkLocalhostLazyAuth(request.getHeader(RequestHeader.AUTHORIZATION),
- sb.getConfig(SwitchboardConstants.ADMIN_ACCOUNT_USER_NAME, "admin"),
- sb.getConfig(SwitchboardConstants.ADMIN_ACCOUNT_B64MD5, ""))) {
- return null;
- }
+ if (decision == AdminAccessPolicy.Decision.LOCAL_BYPASS) {
+ return null;
}
RoleInfo roleinfo = new RoleInfo();
roleinfo.setChecked(true); // RoleInfo.setChecked() : in Jetty this means - marked to have any security constraint
diff --git a/source/net/yacy/http/servlets/Jetty9ServletResource.java b/source/net/yacy/http/servlets/Jetty9ServletResource.java
new file mode 100644
index 000000000..ba1d3d576
--- /dev/null
+++ b/source/net/yacy/http/servlets/Jetty9ServletResource.java
@@ -0,0 +1,99 @@
+package net.yacy.http.servlets;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.URL;
+
+import org.eclipse.jetty.util.resource.Resource;
+
+/** Jetty 9 adapter for the static-resource operations used by YaCy. */
+final class Jetty9ServletResource implements ServletResource {
+
+ private final Resource delegate;
+
+ private Jetty9ServletResource(final Resource delegate) {
+ this.delegate = delegate;
+ }
+
+ static void disableDefaultCaches() {
+ Resource.setDefaultUseCaches(false);
+ }
+
+ static ServletResource from(final String location) throws IOException {
+ return wrap(Resource.newResource(location));
+ }
+
+ static ServletResource from(final File file) throws IOException {
+ return wrap(Resource.newResource(file));
+ }
+
+ static ServletResource from(final URL url) throws IOException {
+ return wrap(Resource.newResource(url));
+ }
+
+ private static ServletResource wrap(final Resource resource) {
+ return resource == null ? null : new Jetty9ServletResource(resource);
+ }
+
+ @Override
+ public ServletResource addPath(final String path) throws IOException {
+ return wrap(this.delegate.addPath(path));
+ }
+
+ @Override
+ public boolean exists() {
+ return this.delegate.exists();
+ }
+
+ @Override
+ public boolean isDirectory() {
+ return this.delegate.isDirectory();
+ }
+
+ @Override
+ public long lastModified() {
+ return this.delegate.lastModified();
+ }
+
+ @Override
+ public long length() {
+ return this.delegate.length();
+ }
+
+ @Override
+ public String getName() {
+ return this.delegate.getName();
+ }
+
+ @Override
+ public File getFile() throws IOException {
+ return this.delegate.getFile();
+ }
+
+ @Override
+ public InputStream getInputStream() throws IOException {
+ return this.delegate.getInputStream();
+ }
+
+ @Override
+ public String getListHTML(final String base, final boolean parent, final String query) throws IOException {
+ return this.delegate.getListHTML(base, parent, query);
+ }
+
+ @Override
+ public void writeTo(final OutputStream output, final long start, final long count) throws IOException {
+ this.delegate.writeTo(output, start, count);
+ }
+
+ @Override
+ public void close() {
+ this.delegate.close();
+ }
+
+ @Override
+ public String toString() {
+ return this.delegate.toString();
+ }
+}
diff --git a/source/net/yacy/http/servlets/ServletResource.java b/source/net/yacy/http/servlets/ServletResource.java
new file mode 100644
index 000000000..74e35113a
--- /dev/null
+++ b/source/net/yacy/http/servlets/ServletResource.java
@@ -0,0 +1,33 @@
+package net.yacy.http.servlets;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.File;
+
+/** Container-neutral view of a static resource served by YaCy. */
+public interface ServletResource extends AutoCloseable {
+
+ ServletResource addPath(String path) throws IOException;
+
+ boolean exists();
+
+ boolean isDirectory();
+
+ long lastModified();
+
+ long length();
+
+ String getName();
+
+ File getFile() throws IOException;
+
+ InputStream getInputStream() throws IOException;
+
+ String getListHTML(String base, boolean parent, String query) throws IOException;
+
+ void writeTo(OutputStream output, long start, long count) throws IOException;
+
+ @Override
+ void close();
+}
diff --git a/source/net/yacy/http/servlets/YaCyDefaultServlet.java b/source/net/yacy/http/servlets/YaCyDefaultServlet.java
index 54cb227a0..e90a7619f 100644
--- a/source/net/yacy/http/servlets/YaCyDefaultServlet.java
+++ b/source/net/yacy/http/servlets/YaCyDefaultServlet.java
@@ -28,7 +28,9 @@ import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
-import java.io.OutputStream;
+import java.io.OutputStream;
+import java.io.Writer;
+import java.io.FilterOutputStream;
import java.lang.reflect.Array;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
@@ -57,16 +59,7 @@ import org.apache.commons.fileupload.FileItem;
import org.apache.commons.fileupload.FileItemFactory;
import org.apache.commons.fileupload.disk.DiskFileItemFactory;
import org.apache.commons.fileupload.servlet.ServletFileUpload;
-import org.eclipse.jetty.http.HttpHeader;
-import org.eclipse.jetty.http.HttpMethod;
-import org.eclipse.jetty.http.MimeTypes;
-import org.eclipse.jetty.io.WriterOutputStream;
-import org.eclipse.jetty.server.InclusiveByteRange;
-import org.eclipse.jetty.util.MultiPartOutputStream;
-import org.eclipse.jetty.util.URIUtil;
-import org.eclipse.jetty.util.resource.Resource;
-
-import com.google.common.net.HttpHeaders;
+import com.google.common.net.HttpHeaders;
import net.yacy.cora.date.GenericFormatter;
import net.yacy.cora.document.analysis.Classification;
@@ -127,10 +120,18 @@ public class YaCyDefaultServlet extends HttpServlet {
private static final long serialVersionUID = 4900000000000001110L;
protected ServletContext _servletContext;
protected boolean _acceptRanges = true;
- protected boolean _dirAllowed = true;
- protected Resource _resourceBase;
- protected MimeTypes _mimeTypes;
- protected String[] _welcomes;
+ protected boolean _dirAllowed = true;
+ protected ServletResource _resourceBase;
+ protected String[] _welcomes;
+
+ private static final String METHOD_HEAD = "HEAD";
+ private static final String METHOD_POST = "POST";
+ private static final String HEADER_CONTENT_RANGE = "Content-Range";
+ private static final String HEADER_IF_MODIFIED_SINCE = "If-Modified-Since";
+ private static final String HEADER_IF_UNMODIFIED_SINCE = "If-Unmodified-Since";
+ private static final String HEADER_REQUEST_RANGE = "Request-Range";
+ private static final String MIME_TEXT_HTML = "text/html";
+ private static final String MIME_TEXT_HTML_UTF8 = "text/html;charset=utf-8";
protected File _htLocalePath;
protected File _htDocsPath;
@@ -149,8 +150,7 @@ public class YaCyDefaultServlet extends HttpServlet {
this._servletContext = this.getServletContext();
- this._mimeTypes = new MimeTypes();
- final String tmpstr = this.getServletContext().getInitParameter("welcomeFile");
+ final String tmpstr = this.getServletContext().getInitParameter("welcomeFile");
if (tmpstr == null) {
this._welcomes = HTTPDFileHandler.defaultFiles;
} else {
@@ -159,14 +159,14 @@ public class YaCyDefaultServlet extends HttpServlet {
this._acceptRanges = this.getInitBoolean("acceptRanges", this._acceptRanges);
this._dirAllowed = this.getInitBoolean("dirAllowed", this._dirAllowed);
- Resource.setDefaultUseCaches(false); // caching is handled internally (prevent double caching)
+ Jetty9ServletResource.disableDefaultCaches(); // caching is handled internally (prevent double caching)
final String rb = this.getInitParameter("resourceBase");
try {
if (rb != null) {
- this._resourceBase = Resource.newResource(rb);
+ this._resourceBase = Jetty9ServletResource.from(rb);
} else {
- this._resourceBase = Resource.newResource(sb.getConfig(SwitchboardConstants.HTROOT_PATH, SwitchboardConstants.HTROOT_PATH_DEFAULT)); //default
+ this._resourceBase = Jetty9ServletResource.from(sb.getConfig(SwitchboardConstants.HTROOT_PATH, SwitchboardConstants.HTROOT_PATH_DEFAULT)); //default
}
} catch (final IOException e) {
ConcurrentLog.severe("FILEHANDLER", "event=http.resource subsystem=http result=missing-resource-base reason=" + e.getMessage());
@@ -201,14 +201,14 @@ public class YaCyDefaultServlet extends HttpServlet {
* @param pathInContext The path to find a resource for.
* @return The resource to serve.
*/
- public Resource getResource(final String pathInContext) {
- Resource r = null;
+ public ServletResource getResource(final String pathInContext) {
+ ServletResource r = null;
try {
if (this._resourceBase != null) {
r = this._resourceBase.addPath(pathInContext);
} else {
final URL u = this._servletContext.getResource(pathInContext);
- r = Resource.newResource(u);
+ r = Jetty9ServletResource.from(u);
}
if (ConcurrentLog.isFine("FILEHANDLER")) {
@@ -249,10 +249,10 @@ public class YaCyDefaultServlet extends HttpServlet {
}
String pathInContext = pathInfo == null ? "/" : pathInfo; // this is the path of the resource in _resourceBase (= path within htroot respective htDocs)
- final boolean endsWithSlash = pathInContext.endsWith(URIUtil.SLASH);
+ final boolean endsWithSlash = pathInContext.endsWith("/");
// Find the resource
- Resource resource = null;
+ ServletResource resource = null;
try {
@@ -266,7 +266,7 @@ public class YaCyDefaultServlet extends HttpServlet {
hasClass = true;
} else {
final String pathofClass = pathInContext.substring(0, p) + ".class";
- final Resource classresource = this._resourceBase.addPath(pathofClass);
+ final ServletResource classresource = this._resourceBase.addPath(pathofClass);
// Does a class resource exist?
if (classresource != null && classresource.exists() && !classresource.isDirectory()) {
hasClass = true;
@@ -281,7 +281,7 @@ public class YaCyDefaultServlet extends HttpServlet {
if (!hasClass && (resource == null || !resource.exists()) && !pathInContext.contains("..")) {
// try to get this in the alternative htDocsPath
if (resource != null) resource.close();
- resource = Resource.newResource(new File(this._htDocsPath, pathInContext));
+ resource = Jetty9ServletResource.from(new File(this._htDocsPath, pathInContext));
}
if (ConcurrentLog.isFine("FILEHANDLER")) {
@@ -301,7 +301,7 @@ public class YaCyDefaultServlet extends HttpServlet {
if (q != null && q.length() != 0) {
pathInContext += "?" + q;
}
- response.sendRedirect(response.encodeRedirectURL(URIUtil.addPaths(this._servletContext.getContextPath(), pathInContext)));
+ response.sendRedirect(response.encodeRedirectURL(addPaths(this._servletContext.getContextPath(), pathInContext)));
} else {
if (hasClass) { // this is a YaCy servlet, handle the template
this.handleTemplate(pathInfo, request, response);
@@ -379,7 +379,7 @@ public class YaCyDefaultServlet extends HttpServlet {
private boolean shouldWrapBody(final HttpServletRequest request) {
final String method = request.getMethod();
- if (method == null || !HttpMethod.POST.asString().equalsIgnoreCase(method)) {
+ if (method == null || !METHOD_POST.equalsIgnoreCase(method)) {
return false;
}
final String contentType = request.getContentType();
@@ -498,8 +498,8 @@ public class YaCyDefaultServlet extends HttpServlet {
return null;
}
for (final String _welcome : this._welcomes) {
- final String welcome_in_context = URIUtil.addPaths(pathInContext, _welcome);
- final Resource welcome = this.getResource(welcome_in_context);
+ final String welcome_in_context = addPaths(pathInContext, _welcome);
+ final ServletResource welcome = this.getResource(welcome_in_context);
if (welcome != null && welcome.exists()) {
return _welcome;
}
@@ -510,15 +510,15 @@ public class YaCyDefaultServlet extends HttpServlet {
/* Check modification date headers.
* send a 304 response instead of content if not modified since
*/
- protected boolean passConditionalHeaders(final HttpServletRequest request, final HttpServletResponse response, final Resource resource)
+ protected boolean passConditionalHeaders(final HttpServletRequest request, final HttpServletResponse response, final ServletResource resource)
throws IOException {
try {
- if (!request.getMethod().equals(HttpMethod.HEAD.asString())) {
-
- final String ifms = request.getHeader(HttpHeader.IF_MODIFIED_SINCE.asString());
+ if (!request.getMethod().equals(METHOD_HEAD)) {
+
+ final String ifms = request.getHeader(HEADER_IF_MODIFIED_SINCE);
if (ifms != null) {
- final long ifmsl = request.getDateHeader(HttpHeader.IF_MODIFIED_SINCE.asString());
+ final long ifmsl = request.getDateHeader(HEADER_IF_MODIFIED_SINCE);
if (ifmsl != -1) {
if (resource.lastModified() / 1000 <= ifmsl / 1000) {
response.reset();
@@ -530,7 +530,7 @@ public class YaCyDefaultServlet extends HttpServlet {
}
// Parse the if[un]modified dates and compare to resource
- final long date = request.getDateHeader(HttpHeader.IF_UNMODIFIED_SINCE.asString());
+ final long date = request.getDateHeader(HEADER_IF_UNMODIFIED_SINCE);
if (date != -1) {
if (resource.lastModified() / 1000 > date / 1000) {
@@ -552,7 +552,7 @@ public class YaCyDefaultServlet extends HttpServlet {
/* ------------------------------------------------------------------- */
protected void sendDirectory(final HttpServletRequest request,
final HttpServletResponse response,
- final Resource resource,
+ final ServletResource resource,
final String pathInContext)
throws IOException {
if (!this._dirAllowed) {
@@ -560,7 +560,7 @@ public class YaCyDefaultServlet extends HttpServlet {
return;
}
- final String base = URIUtil.addEncodedPaths(request.getRequestURI(), URIUtil.SLASH);
+ final String base = addPaths(request.getRequestURI(), "/");
final String dir = resource.getListHTML(base, pathInContext.length() > 1, request.getQueryString());
if (dir == null) {
@@ -569,7 +569,7 @@ public class YaCyDefaultServlet extends HttpServlet {
}
final byte[] data = dir.getBytes(StandardCharsets.UTF_8);
- response.setContentType(MimeTypes.Type.TEXT_HTML_UTF_8.asString());
+ response.setContentType(MIME_TEXT_HTML_UTF8);
response.setContentLength(data.length);
response.setHeader(HeaderFramework.CACHE_CONTROL, "no-cache, no-store");
response.setDateHeader(HeaderFramework.EXPIRES, System.currentTimeMillis() + 10000); // consider that directories are not modified that often
@@ -591,7 +591,7 @@ public class YaCyDefaultServlet extends HttpServlet {
protected void sendData(final HttpServletRequest request,
final HttpServletResponse response,
final boolean include,
- final Resource resource,
+ final ServletResource resource,
final Enumeration<String> reqRanges)
throws IOException {
@@ -602,7 +602,7 @@ public class YaCyDefaultServlet extends HttpServlet {
try {
out = response.getOutputStream();
} catch (final IllegalStateException e) {
- out = new WriterOutputStream(response.getWriter());
+ out = new CharacterOutputStream(response.getWriter());
}
// remove the last-modified field since caching otherwise does not work
@@ -629,14 +629,14 @@ public class YaCyDefaultServlet extends HttpServlet {
}
} else {
// Parse the satisfiable ranges
- final List<InclusiveByteRange> ranges = InclusiveByteRange.satisfiableRanges(reqRanges, content_length);
+ final List<HttpByteRange> ranges = HttpByteRange.satisfiableRanges(reqRanges, content_length);
// if there are no satisfiable ranges, send 416 response
if (ranges == null || ranges.isEmpty()) {
this.writeHeaders(response, resource, content_length);
response.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
- response.setHeader(HttpHeader.CONTENT_RANGE.asString(),
- InclusiveByteRange.to416HeaderRangeString(content_length));
+ response.setHeader(HEADER_CONTENT_RANGE,
+ HttpByteRange.to416HeaderRangeString(content_length));
resource.writeTo(out, 0, content_length);
out.close();
return;
@@ -645,11 +645,11 @@ public class YaCyDefaultServlet extends HttpServlet {
// if there is only a single valid range (must be satisfiable
// since were here now), send that range with a 216 response
if (ranges.size() == 1) {
- final InclusiveByteRange singleSatisfiableRange = ranges.iterator().next();
+ final HttpByteRange singleSatisfiableRange = ranges.iterator().next();
final long singleLength = singleSatisfiableRange.getSize();
this.writeHeaders(response, resource, singleLength);
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
- response.setHeader(HttpHeader.CONTENT_RANGE.asString(),
+ response.setHeader(HEADER_CONTENT_RANGE,
singleSatisfiableRange.toHeaderRangeString(content_length));
resource.writeTo(out, singleSatisfiableRange.getFirst(), singleLength);
out.close();
@@ -665,14 +665,14 @@ public class YaCyDefaultServlet extends HttpServlet {
if (mimetype == null) {
ConcurrentLog.warn("FILEHANDLER","YaCyDefaultServlet: Unknown mimetype for " + request.getRequestURI());
}
- final MultiPartOutputStream multi = new MultiPartOutputStream(out);
+ final MultipartByteRangeOutputStream multi = new MultipartByteRangeOutputStream(out);
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
// If the request has a "Request-Range" header then we need to
// send an old style multipart/x-byteranges Content-Type. This
// keeps Netscape and acrobat happy. This is what Apache does.
String ctp;
- if (request.getHeader(HttpHeader.REQUEST_RANGE.asString()) != null) {
+ if (request.getHeader(HEADER_REQUEST_RANGE) != null) {
ctp = "multipart/x-byteranges; boundary=";
} else {
ctp = "multipart/byteranges; boundary=";
@@ -686,7 +686,7 @@ public class YaCyDefaultServlet extends HttpServlet {
int length = 0;
final String[] header = new String[ranges.size()];
for (int i = 0; i < ranges.size(); i++) {
- final InclusiveByteRange ibr = ranges.get(i);
+ final HttpByteRange ibr = ranges.get(i);
header[i] = ibr.toHeaderRangeString(content_length);
length +=
((i > 0) ? 2 : 0)
@@ -700,7 +700,7 @@ public class YaCyDefaultServlet extends HttpServlet {
response.setContentLength(length);
for (int i = 0; i < ranges.size(); i++) {
- final InclusiveByteRange ibr = ranges.get(i);
+ final HttpByteRange ibr = ranges.get(i);
multi.startPart(mimetype, new String[]{HeaderFramework.CONTENT_RANGE + ": " + header[i]});
final long start = ibr.getFirst();
@@ -731,10 +731,10 @@ public class YaCyDefaultServlet extends HttpServlet {
}
/* ------------------------------------------------------------ */
- protected void writeHeaders(final HttpServletResponse response, final Resource resource, final long count) {
+ protected void writeHeaders(final HttpServletResponse response, final ServletResource resource, final long count) {
if (response.getContentType() == null) {
final String extensionmime;
- if ((extensionmime = this._mimeTypes.getMimeByExtension(resource.getName())) != null) {
+ if ((extensionmime = this._servletContext.getMimeType(resource.getName())) != null) {
response.setContentType(extensionmime);
}
}
@@ -1035,7 +1035,7 @@ public class YaCyDefaultServlet extends HttpServlet {
}
this.updateRespHeadersForImages(target, response);
- final String mimeType = Classification.ext2mime(targetExt, MimeTypes.Type.TEXT_HTML.asString());
+ final String mimeType = Classification.ext2mime(targetExt, MIME_TEXT_HTML);
response.setContentType(mimeType);
response.setContentLength(result.length());
response.setStatus(HttpServletResponse.SC_OK);
@@ -1150,7 +1150,7 @@ public class YaCyDefaultServlet extends HttpServlet {
templatePatterns.put(SwitchboardConstants.GREETING_IMAGE_ALT, sb.getConfig(SwitchboardConstants.GREETING_IMAGE_ALT, ""));
templatePatterns.put("clientlanguage", localeSelection);
- final String mimeType = Classification.ext2mime(targetExt, MimeTypes.Type.TEXT_HTML.asString());
+ final String mimeType = Classification.ext2mime(targetExt, MIME_TEXT_HTML);
InputStream fis;
final long fileSize = targetLocalizedFile.length();
@@ -1239,7 +1239,7 @@ public class YaCyDefaultServlet extends HttpServlet {
*/
private void writeInputStream(final HttpServletResponse response, final String targetExt, final InputStream inStream)
throws IOException {
- final String mimeType = Classification.ext2mime(targetExt, MimeTypes.Type.TEXT_HTML.asString());
+ final String mimeType = Classification.ext2mime(targetExt, MIME_TEXT_HTML);
response.setContentType(mimeType);
response.setStatus(HttpServletResponse.SC_OK);
final byte[] buffer = new byte[4096];
@@ -1372,4 +1372,177 @@ public class YaCyDefaultServlet extends HttpServlet {
}
}
+ /** Join two servlet paths without depending on a container URI utility. */
+ private static String addPaths(final String first, final String second) {
+ if (first == null || first.isEmpty()) {
+ return second == null ? "" : second;
+ }
+ if (second == null || second.isEmpty()) {
+ return first;
+ }
+ final boolean firstEndsWithSlash = first.charAt(first.length() - 1) == '/';
+ final boolean secondStartsWithSlash = second.charAt(0) == '/';
+ if (firstEndsWithSlash && secondStartsWithSlash) {
+ return first + second.substring(1);
+ }
+ if (!firstEndsWithSlash && !secondStartsWithSlash) {
+ return first + '/' + second;
+ }
+ return first + second;
+ }
+
+ /** Equivalent of Jetty's WriterOutputStream default-charset behavior. */
+ private static final class CharacterOutputStream extends OutputStream {
+
+ private final Writer writer;
+ private final byte[] singleByte = new byte[1];
+
+ private CharacterOutputStream(final Writer writer) {
+ this.writer = writer;
+ }
+
+ @Override
+ public void write(final int value) throws IOException {
+ this.singleByte[0] = (byte) value;
+ this.write(this.singleByte);
+ }
+
+ @Override
+ public void write(final byte[] data, final int offset, final int length) throws IOException {
+ this.writer.write(new String(data, offset, length));
+ }
+
+ @Override
+ public void flush() throws IOException {
+ this.writer.flush();
+ }
+
+ @Override
+ public void close() throws IOException {
+ this.writer.close();
+ }
+ }
+
+ /** Inclusive byte range parsed from an HTTP Range header. */
+ private static final class HttpByteRange {
+
+ private final long first;
+ private final long last;
+
+ private HttpByteRange(final long first, final long last) {
+ this.first = first;
+ this.last = last;
+ }
+
+ private long getFirst() {
+ return this.first;
+ }
+
+ private long getLast() {
+ return this.last;
+ }
+
+ private long getSize() {
+ return this.last - this.first + 1;
+ }
+
+ private String toHeaderRangeString(final long size) {
+ return "bytes " + this.first + '-' + this.last + '/' + size;
+ }
+
+ private static String to416HeaderRangeString(final long size) {
+ return "bytes */" + size;
+ }
+
+ private static List<HttpByteRange> satisfiableRanges(
+ final Enumeration<String> headers, final long size) {
+ final List<HttpByteRange> ranges = new java.util.ArrayList<>();
+ while (headers != null && headers.hasMoreElements()) {
+ final String header = headers.nextElement();
+ if (header == null || !header.regionMatches(true, 0, "bytes=", 0, 6)) {
+ continue;
+ }
+ final String[] specifications = header.substring(6).split(",");
+ for (final String specification : specifications) {
+ final HttpByteRange range = parse(specification.trim(), size);
+ if (range != null) {
+ ranges.add(range);
+ }
+ }
+ }
+ return ranges;
+ }
+
+ private static HttpByteRange parse(final String specification, final long size) {
+ final int separator = specification.indexOf('-');
+ if (separator < 0 || size <= 0) {
+ return null;
+ }
+ try {
+ if (separator == 0) {
+ final long suffixLength = Long.parseLong(specification.substring(1).trim());
+ if (suffixLength <= 0) {
+ return null;
+ }
+ return new HttpByteRange(Math.max(0, size - suffixLength), size - 1);
+ }
+ final long first = Long.parseLong(specification.substring(0, separator).trim());
+ if (first < 0 || first >= size) {
+ return null;
+ }
+ final String lastText = specification.substring(separator + 1).trim();
+ final long last = lastText.isEmpty()
+ ? size - 1
+ : Math.min(Long.parseLong(lastText), size - 1);
+ return last < first ? null : new HttpByteRange(first, last);
+ } catch (final NumberFormatException e) {
+ return null;
+ }
+ }
+ }
+
+ /** Minimal multipart writer for HTTP byte-range responses. */
+ private static final class MultipartByteRangeOutputStream extends FilterOutputStream {
+
+ private static final byte[] CRLF = {'\r', '\n'};
+ private final String boundary = "yacy-" + java.util.UUID.randomUUID().toString();
+ private boolean firstPart = true;
+
+ private MultipartByteRangeOutputStream(final OutputStream output) {
+ super(output);
+ }
+
+ private String getBoundary() {
+ return this.boundary;
+ }
+
+ private void startPart(final String contentType, final String[] headers) throws IOException {
+ if (!this.firstPart) {
+ this.out.write(CRLF);
+ }
+ this.firstPart = false;
+ this.writeAscii("--" + this.boundary + "\r\n");
+ if (contentType != null) {
+ this.writeAscii(HeaderFramework.CONTENT_TYPE + ": " + contentType + "\r\n");
+ }
+ if (headers != null) {
+ for (final String header : headers) {
+ this.writeAscii(header + "\r\n");
+ }
+ }
+ this.out.write(CRLF);
+ }
+
+ @Override
+ public void close() throws IOException {
+ this.out.write(CRLF);
+ this.writeAscii("--" + this.boundary + "--\r\n");
+ super.close();
+ }
+
+ private void writeAscii(final String value) throws IOException {
+ this.out.write(value.getBytes(StandardCharsets.ISO_8859_1));
+ }
+ }
+
}
diff --git a/test/java/net/yacy/http/AdminSecurityTest.java b/test/java/net/yacy/http/AdminSecurityTest.java
index 4fd8abb58..196eae3ea 100644
--- a/test/java/net/yacy/http/AdminSecurityTest.java
+++ b/test/java/net/yacy/http/AdminSecurityTest.java
@@ -106,4 +106,41 @@ public class AdminSecurityTest {
// a request from localhost referred by a remote page is not a localhost access
Assert.assertFalse(AdminSecurity.isLocalhostAccess("127.0.0.1", "example.org"));
}
+
+ /** Test the complete request-level policy used by the container adapter. */
+ @Test
+ public void testAdminAccessPolicy() {
+ final String user = "admin";
+ final String hash = AdminSecurity.calcHash(user + ":secret");
+ final AdminAccessPolicy localAllowed = new AdminAccessPolicy(
+ false, false, true, true, user, hash);
+
+ Assert.assertEquals(AdminAccessPolicy.Decision.PUBLIC,
+ localAllowed.decide("/index.html", "192.0.2.1", null, null));
+ Assert.assertEquals(AdminAccessPolicy.Decision.ADMIN_REQUIRED,
+ localAllowed.decide("/Settings_p.html", "192.0.2.1", null, null));
+ Assert.assertEquals(AdminAccessPolicy.Decision.LOCAL_BYPASS,
+ localAllowed.decide("/Settings_p.html", "127.0.0.1", null, null));
+ Assert.assertEquals(AdminAccessPolicy.Decision.ADMIN_REQUIRED,
+ localAllowed.decide("/Settings_p.html", "127.0.0.1", "https://example.org/", null));
+
+ final AdminAccessPolicy loginRequired = new AdminAccessPolicy(
+ false, false, true, false, user, hash);
+ Assert.assertEquals(AdminAccessPolicy.Decision.ADMIN_REQUIRED,
+ loginRequired.decide("/Settings_p.html", "127.0.0.1", null, null));
+ final String lazyAuth = "Basic " + Base64Order.standardCoder.encodeString(user + ":" + hash);
+ Assert.assertEquals(AdminAccessPolicy.Decision.LOCAL_BYPASS,
+ loginRequired.decide("/Settings_p.html", "127.0.0.1", null, lazyAuth));
+ }
+
+ /** The credential context is request-bound and fails closed after cleanup. */
+ @Test
+ public void testAdminAuthenticationContext() {
+ AdminAuthenticationContext.clear();
+ Assert.assertFalse(AdminAuthenticationContext.isLocalhostRequest());
+ AdminAuthenticationContext.setSocketPeerIp("127.0.0.1");
+ Assert.assertTrue(AdminAuthenticationContext.isLocalhostRequest());
+ AdminAuthenticationContext.clear();
+ Assert.assertFalse(AdminAuthenticationContext.isLocalhostRequest());
+ }
}
diff --git a/test/java/net/yacy/http/InetPathAccessHandlerTest.java b/test/java/net/yacy/http/InetPathAccessHandlerTest.java
index 5dfdf8a4f..641ad8bf1 100644
--- a/test/java/net/yacy/http/InetPathAccessHandlerTest.java
+++ b/test/java/net/yacy/http/InetPathAccessHandlerTest.java
@@ -32,6 +32,18 @@ import org.junit.Test;
*/
public class InetPathAccessHandlerTest {
+ @Test
+ public void testPortableRuleParsing() {
+ final InetPathAccessRule addressOnly = InetPathAccessRule.parse("192.168.1.0/24");
+ Assert.assertEquals("192.168.1.0/24", addressOnly.addressPattern());
+ Assert.assertEquals("/*", addressOnly.pathPattern());
+
+ final InetPathAccessRule addressAndPath = InetPathAccessRule.parse("127.0.0.1|/api/*");
+ Assert.assertEquals("127.0.0.1", addressAndPath.addressPattern());
+ Assert.assertEquals("/api/*", addressAndPath.pathPattern());
+ Assert.assertEquals("127.0.0.1|/api/*", addressAndPath.asJettyPattern());
+ }
+
/**
* Check the handler allow the given ip/path pairs.
*
diff --git a/test/jetty-solr-dependency-guard.sh b/test/jetty-solr-dependency-guard.sh
new file mode 100755
index 000000000..0f2fad8a2
--- /dev/null
+++ b/test/jetty-solr-dependency-guard.sh
@@ -0,0 +1,81 @@
+#!/usr/bin/env sh
+
+# Guard the classpath boundary needed to migrate YaCy's embedded server from
+# Jetty 9 to Jetty 12 while Solr 9's Jetty client is relocated into a private
+# package and kept out of YaCy source code.
+
+set -eu
+
+repo_root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
+cd "$repo_root"
+
+fail() {
+ echo "FAIL: $*" >&2
+ exit 1
+}
+
+if grep -R -n -E \
+ --include='*.java' \
+ '(Http2SolrClient|CloudHttp2SolrClient|ConcurrentUpdateHttp2SolrClient|LBHttp2SolrClient|JettySolrRunner)' \
+ source >/dev/null 2>&1; then
+ grep -R -n -E \
+ --include='*.java' \
+ '(Http2SolrClient|CloudHttp2SolrClient|ConcurrentUpdateHttp2SolrClient|LBHttp2SolrClient|JettySolrRunner)' \
+ source >&2
+ fail "YaCy source must not use Solr's Jetty-backed clients or runner"
+fi
+
+if grep -E \
+ 'name="(jetty-deploy|jetty-jmx)"' \
+ ivy.xml >/dev/null 2>&1; then
+ fail "ivy.xml contains a forbidden direct Jetty dependency"
+fi
+
+grep -E 'name="jetty-client".*conf="solr9-bridge->master"' ivy.xml >/dev/null 2>&1 || \
+ fail "jetty-client must only be a direct input of the Solr 9 bridge"
+
+grep -E 'org="org.eclipse.jetty" name="jetty-io"' ivy.xml >/dev/null 2>&1 || \
+ fail "jetty-io must be an explicit dependency because YaCy imports its API"
+
+for artifact in http2-client http2-common http2-http-client-transport; do
+ grep -E "org=\"org.eclipse.jetty.http2\" name=\"$artifact\".*conf=\"solr9-bridge->master\"" ivy.xml >/dev/null 2>&1 || \
+ fail "$artifact must only be an input of the Solr 9 bridge"
+done
+
+if [ -d lib ]; then
+ for pattern in \
+ 'jetty-deploy-*.jar' \
+ 'jetty-jmx-*.jar' \
+ 'solr-core-*.jar' \
+ 'solr-solrj-*.jar' \
+ 'solr-scripting-*.jar' \
+ 'http2-*.jar'; do
+ for artifact in lib/$pattern; do
+ [ -e "$artifact" ] || continue
+ fail "forbidden resolved artifact: $artifact"
+ done
+ done
+
+ for artifact in \
+ solr-core-9.0.0 \
+ solr-solrj-9.0.0 \
+ solr-scripting-9.0.0 \
+ jetty-client-9.4.58.v20250814 \
+ jetty-http-9.4.58.v20250814 \
+ jetty-io-9.4.58.v20250814 \
+ jetty-util-9.4.58.v20250814 \
+ http2-client-9.4.58.v20250814 \
+ http2-common-9.4.58.v20250814 \
+ http2-http-client-transport-9.4.58.v20250814; do
+ jar="lib/solr9-bridge-$artifact.jar"
+ [ -f "$jar" ] || fail "missing generated bridge artifact: $jar"
+ if jar tf "$jar" | grep '^org/eclipse/jetty/' >/dev/null 2>&1; then
+ fail "unrelocated Jetty class in $jar"
+ fi
+ if zipgrep -a -E 'org(/|\.)eclipse(/|\.)jetty' "$jar" >/dev/null 2>&1; then
+ fail "unrelocated Jetty reference in $jar"
+ fi
+ done
+fi
+
+echo "PASS: Solr 9 uses only the relocated Jetty client island."
diff --git a/test/jetty/RelocateJettyPackages.java b/test/jetty/RelocateJettyPackages.java
new file mode 100644
index 000000000..0766388e5
--- /dev/null
+++ b/test/jetty/RelocateJettyPackages.java
@@ -0,0 +1,135 @@
+/*
+ * Proof-of-concept bytecode relocator for the Jetty 12 migration.
+ *
+ * This is deliberately a small build tool, not YaCy runtime code. It keeps
+ * Solr's public packages unchanged while moving its private Jetty 9 linkage
+ * below net.yacy.solr9.jetty.
+ */
+package net.yacy.test.jetty;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Enumeration;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+import java.util.jar.JarOutputStream;
+
+import org.objectweb.asm.ClassReader;
+import org.objectweb.asm.ClassWriter;
+import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.commons.ClassRemapper;
+import org.objectweb.asm.commons.Remapper;
+
+public final class RelocateJettyPackages {
+
+ private static final String SOURCE_INTERNAL = "org/eclipse/jetty";
+ private static final String TARGET_INTERNAL = "net/yacy/solr9/jetty";
+ private static final String SOURCE_BINARY = "org.eclipse.jetty";
+ private static final String TARGET_BINARY = "net.yacy.solr9.jetty";
+
+ private RelocateJettyPackages() {
+ }
+
+ public static void main(final String[] args) throws IOException {
+ if (args.length != 2) {
+ throw new IllegalArgumentException("usage: RelocateJettyPackages INPUT.jar OUTPUT.jar");
+ }
+ relocate(Path.of(args[0]), Path.of(args[1]));
+ }
+
+ private static void relocate(final Path input, final Path output) throws IOException {
+ Files.createDirectories(output.toAbsolutePath().getParent());
+ final Remapper remapper = new JettyRemapper();
+
+ try (JarFile source = new JarFile(input.toFile());
+ JarOutputStream target = new JarOutputStream(Files.newOutputStream(output))) {
+ final Enumeration<JarEntry> entries = source.entries();
+ while (entries.hasMoreElements()) {
+ final JarEntry entry = entries.nextElement();
+ if (entry.isDirectory() || isSignature(entry.getName())) {
+ continue;
+ }
+ final String outputName = relocateEntryName(entry.getName());
+ final JarEntry outputEntry = new JarEntry(outputName);
+ outputEntry.setTime(entry.getTime());
+ target.putNextEntry(outputEntry);
+ try (InputStream stream = source.getInputStream(entry)) {
+ final byte[] content = readAll(stream);
+ if (entry.getName().endsWith(".class")) {
+ target.write(relocateClass(content, remapper));
+ } else if (entry.getName().startsWith("META-INF/services/")) {
+ target.write(new String(content, StandardCharsets.UTF_8)
+ .replace(SOURCE_BINARY, TARGET_BINARY)
+ .getBytes(StandardCharsets.UTF_8));
+ } else {
+ target.write(content);
+ }
+ }
+ target.closeEntry();
+ }
+ }
+ }
+
+ private static final class JettyRemapper extends Remapper {
+
+ private JettyRemapper() {
+ super(Opcodes.ASM9);
+ }
+
+ @Override
+ public String map(final String internalName) {
+ return replacePrefix(internalName, SOURCE_INTERNAL, TARGET_INTERNAL);
+ }
+
+ @Override
+ public Object mapValue(final Object value) {
+ if (value instanceof String) {
+ final String text = (String) value;
+ return text.replace(SOURCE_BINARY, TARGET_BINARY)
+ .replace(SOURCE_INTERNAL, TARGET_INTERNAL);
+ }
+ return super.mapValue(value);
+ }
+ }
+
+ private static byte[] relocateClass(final byte[] content, final Remapper remapper) {
+ final ClassReader reader = new ClassReader(content);
+ final ClassWriter writer = new ClassWriter(0);
+ reader.accept(new ClassRemapper(writer, remapper), 0);
+ return writer.toByteArray();
+ }
+
+ private static boolean isSignature(final String name) {
+ final String upper = name.toUpperCase(java.util.Locale.ROOT);
+ return upper.startsWith("META-INF/")
+ && (upper.endsWith(".SF") || upper.endsWith(".RSA") || upper.endsWith(".DSA"));
+ }
+
+ private static String replacePrefix(final String value, final String source, final String target) {
+ if (value.equals(source)) {
+ return target;
+ }
+ if (value.startsWith(source + "/")) {
+ return target + value.substring(source.length());
+ }
+ return value;
+ }
+
+ private static String relocateEntryName(final String name) {
+ final String internalName = replacePrefix(name, SOURCE_INTERNAL, TARGET_INTERNAL);
+ if (internalName.startsWith("META-INF/services/")) {
+ return internalName.replace(SOURCE_BINARY, TARGET_BINARY);
+ }
+ return internalName;
+ }
+
+ private static byte[] readAll(final InputStream stream) throws IOException {
+ final ByteArrayOutputStream output = new ByteArrayOutputStream();
+ stream.transferTo(output);
+ return output.toByteArray();
+ }
+}
diff --git a/test/solr9-jetty-bridge-spike.sh b/test/solr9-jetty-bridge-spike.sh
new file mode 100755
index 000000000..00eb0ff5c
--- /dev/null
+++ b/test/solr9-jetty-bridge-spike.sh
@@ -0,0 +1,39 @@
+#!/usr/bin/env sh
+
+# Exercise the generated Solr 9.0 bridge. Run `ant compile` first. An optional
+# JETTY12_CLASSPATH verifies coexistence with already resolved Jetty 12 jars.
+
+set -eu
+
+repo_root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
+cd "$repo_root"
+
+fail() {
+ echo "FAIL: $*" >&2
+ exit 1
+}
+
+[ -d build/classes/java/main ] || fail "missing compiled YaCy classes; run ant compile first"
+test/jetty-solr-dependency-guard.sh
+
+work=$(mktemp -d "${TMPDIR:-/tmp}/yacy-solr9-bridge-test.XXXXXX")
+trap 'rm -rf "$work"' EXIT HUP INT TERM
+
+classpath=build/classes/java/main
+for archive in lib/*.jar libt/*.jar; do
+ case $(basename "$archive") in
+ yacycore.jar) continue ;;
+ esac
+ classpath="$classpath:$archive"
+done
+if [ -n "${JETTY12_CLASSPATH:-}" ]; then
+ classpath="$classpath:$JETTY12_CLASSPATH"
+fi
+
+javac --release 17 -cp "$classpath" -d "$work" \
+ test/java/net/yacy/cora/federate/solr/connector/EmbeddedSolrConnectorTest.java
+
+java -cp "$work:$classpath" org.junit.runner.JUnitCore \
+ net.yacy.cora.federate.solr.connector.EmbeddedSolrConnectorTest
+
+echo "PASS: integrated Solr 9.0 bridge starts, updates, queries and closes."