diff --git a/docs/design/sdk-api.md b/docs/design/sdk-api.md index dcaca33..0bf9a6b 100644 --- a/docs/design/sdk-api.md +++ b/docs/design/sdk-api.md @@ -147,7 +147,7 @@ public interface Rift extends AutoCloseable { // -- intercept (TLS-MITM) -- Intercept intercept(); // default options Intercept intercept(InterceptOptions options); // InterceptOptions.attach() discovers a running listener - Optional interceptStatus(); // GET /intercept (404 → empty); embedded: own listener, else the admin plane's + Optional interceptStatus(); // GET /intercept (404 → empty); embedded: the admin plane's once served, else own listener // -- async facade (secondary surface) -- RiftAsync async(); // createAsync(ImposterSpec), deleteAllAsync(), impostersAsync() @@ -288,7 +288,7 @@ public interface RiftTransport extends AutoCloseable { void spaceDelete(int port, String flowId); // intercept JsonValue startIntercept(JsonValue options); - default Optional interceptStatus(); // GET /intercept | embedded: the FFI-started listener, else the admin plane's GET /intercept + default Optional interceptStatus(); // GET /intercept | embedded: the admin plane's GET /intercept once one is served (it shares the FFI slot, rift#521), else the FFI-started listener void interceptAddRules(JsonValue rules); default void interceptReplaceRules(JsonValue rules); // PUT /intercept/rules | rift_intercept_replace_rules (>= 0.20.0) JsonValue interceptListRules(); void interceptClearRules(); diff --git a/docs/intercept.md b/docs/intercept.md index 6cf99ca..0bd2bdb 100644 --- a/docs/intercept.md +++ b/docs/intercept.md @@ -77,8 +77,9 @@ the way a listener it started is reached: through `ConnectOptions.interceptAddre wildcard bind (`0.0.0.0`) replaced by the admin host. No listener running → `IllegalStateException`, and intercept stays available. The explicit `attach(host, port)` form never consults `interceptAddress`: the endpoint you give is used as is. On the embedded transport -`interceptStatus()` reports the listener the handle started until it is closed, or one another client -started through the engine's admin plane (`rift.adminUri()`). +`interceptStatus()` reports the engine's one listener, whether the handle started it or another client +started it through the engine's admin plane (`rift.adminUri()`). A client that stops or restarts it +through that plane is reflected too. To start the listener at runtime instead — with your own CA, say — expose the port without launching a listener, then start it bound to the container's interface. The handle is mapped to diff --git a/rift-java-core/src/main/java/io/github/achirdlabs/rift/Rift.java b/rift-java-core/src/main/java/io/github/achirdlabs/rift/Rift.java index a6c0249..1aa5bb5 100644 --- a/rift-java-core/src/main/java/io/github/achirdlabs/rift/Rift.java +++ b/rift-java-core/src/main/java/io/github/achirdlabs/rift/Rift.java @@ -162,8 +162,9 @@ default Intercept intercept() { /** * The intercept listener the engine reports running, or empty when none is. Over the admin API * this is {@code GET /intercept} and sees a listener started by anyone — at engine launch, by - * another client. On the embedded transport it reports the listener this handle started until it is - * closed, or one another client started through this engine's {@link #adminUri() admin plane}. + * another client. On the embedded transport it reports the engine's one listener, whoever started + * it: this handle, or another client through this engine's {@link #adminUri() admin plane}, which + * can also stop or restart it. Asking does not start the admin plane. */ Optional interceptStatus(); diff --git a/rift-java-embedded/src/main/java/io/github/achirdlabs/rift/embedded/EmbeddedTransport.java b/rift-java-embedded/src/main/java/io/github/achirdlabs/rift/embedded/EmbeddedTransport.java index 47773ba..299544a 100644 --- a/rift-java-embedded/src/main/java/io/github/achirdlabs/rift/embedded/EmbeddedTransport.java +++ b/rift-java-embedded/src/main/java/io/github/achirdlabs/rift/embedded/EmbeddedTransport.java @@ -56,9 +56,11 @@ public final class EmbeddedTransport implements RiftTransport { private volatile RemoteTransport admin; /** * The listener this handle started over FFI, as {@code {interceptPort, interceptUrl}} (never the - * CA a start can return); null when none is. The engine runs one listener, but its admin plane's - * {@code GET /intercept} reports only one started through that plane (rift 0.21.0), and there is - * no FFI status symbol — so this record answers for an FFI start, the admin plane for the rest. + * CA a start can return); null when none is. There is no FFI status symbol, so this answers + * {@link #interceptStatus()} only while no admin plane is served: then nothing but this handle can + * change the engine's one intercept slot. Once a plane is served, its {@code GET /intercept} + * reads that same slot (the C-ABI and the plane have shared it since rift#521) and is the answer instead, + * since another client can stop or restart the listener through it. */ private volatile JsonValue runningIntercept; @@ -332,13 +334,11 @@ public Optional interceptStatus() { if (closed.get()) { throw new IllegalStateException("the embedded rift engine is closed"); } - JsonValue started = runningIntercept; - if (started != null) { - return Optional.of(started); - } - // Started by another client through this engine's admin plane, if it is serving one. RemoteTransport a = admin; - return a == null ? Optional.empty() : a.interceptStatus(); + if (a != null) { + return a.interceptStatus(); + } + return Optional.ofNullable(runningIntercept); } @Override diff --git a/rift-java-embedded/src/test/java/io/github/achirdlabs/rift/embedded/InterceptE2eIT.java b/rift-java-embedded/src/test/java/io/github/achirdlabs/rift/embedded/InterceptE2eIT.java index 6c4cdc8..4cabaa9 100644 --- a/rift-java-embedded/src/test/java/io/github/achirdlabs/rift/embedded/InterceptE2eIT.java +++ b/rift-java-embedded/src/test/java/io/github/achirdlabs/rift/embedded/InterceptE2eIT.java @@ -364,6 +364,49 @@ void interceptStatusReportsAListenerAnotherClientStartedThroughTheAdminPlane() { } } + @Test + void anFfiStartedListenerStoppedThroughTheAdminPlaneIsNoLongerReported() throws Exception { + // the C-ABI and the admin plane share one intercept slot (rift#521): the plane is the truth + try (Rift rift = embedded()) { + rift.intercept(); + assertTrue(rift.interceptStatus().isPresent()); + + assertEquals(204, deleteIntercept(rift.adminUri()), "the admin plane stops the FFI-started listener"); + assertEquals(Optional.empty(), rift.interceptStatus(), "no listener runs any more"); + } + } + + @Test + void aListenerRestartedThroughTheAdminPlaneIsReportedWithItsNewPort() throws Exception { + try (Rift rift = embedded(); + Rift viaAdmin = Rift.connect(ConnectOptions.builder(rift.adminUri()).versionCheck(VersionCheck.OFF).build())) { + int ffiPort = rift.intercept().address().getPort(); + assertEquals(204, deleteIntercept(rift.adminUri())); + + int theirPort = viaAdmin.intercept().address().getPort(); + org.junit.jupiter.api.Assumptions.assumeTrue(theirPort != ffiPort, + "the OS handed the freed port straight back, so a stale report would look right"); + assertEquals(theirPort, rift.interceptStatus().orElseThrow().port(), + "the report follows the shared slot, not this handle's start (" + ffiPort + ")"); + } + } + + @Test + void anFfiStartAfterTheAdminPlaneIsServedIsReported() { + // this handle's own start, answered by the plane rather than the cached record: the one case + // that fails if the plane stops seeing an FFI-started listener + try (Rift rift = embedded()) { + rift.adminUri(); + int port = rift.intercept().address().getPort(); + assertEquals(port, rift.interceptStatus().orElseThrow().port()); + } + } + + private static int deleteIntercept(URI adminUri) throws Exception { + HttpRequest delete = HttpRequest.newBuilder(adminUri.resolve("/intercept")).timeout(Duration.ofSeconds(5)).DELETE().build(); + return HttpClient.newHttpClient().send(delete, HttpResponse.BodyHandlers.discarding()).statusCode(); + } + @Test void interceptStatusOnAClosedEngineIsRefused() { Rift rift = embedded();