From 203e02efa4b4b94166c688b9a12605bd6976469a Mon Sep 17 00:00:00 2001 From: Mohsen Zainalpour Date: Fri, 16 Oct 2026 23:37:31 -0400 Subject: [PATCH] docs(spawn): host-less imposters bind --host and --local-only guards them from rift 0.23.0 rift 0.23.0 binds a host-less imposter to the spawned engine's --host, as Mountebank does (rift#1517), and --local-only now refuses non-loopback clients on imposters too (rift#1523). With the SDK's defaults (127.0.0.1, localOnly on) spawned imposters are loopback-only, so a containerised system under test needs host("0.0.0.0") on the imposter and localOnly(false) on Linux. The SpawnOptions docs said the reverse; they now give the recipe, and a live IT pins both behaviours. Closes #321 --- docs/design/sdk-api.md | 6 +- docs/testcontainers.md | 20 ++++ .../rift/conformance/Ipv6HostIT.java | 4 +- .../conformance/SpawnHostInheritanceIT.java | 95 +++++++++++++++++++ .../github/achirdlabs/rift/SpawnOptions.java | 24 ++++- .../achirdlabs/rift/ImposterUriHostTest.java | 5 +- 6 files changed, 143 insertions(+), 11 deletions(-) create mode 100644 rift-java-conformance/src/test/java/io/github/achirdlabs/rift/conformance/SpawnHostInheritanceIT.java diff --git a/docs/design/sdk-api.md b/docs/design/sdk-api.md index eb24f29..e0d1467 100644 --- a/docs/design/sdk-api.md +++ b/docs/design/sdk-api.md @@ -184,9 +184,11 @@ public final class ConnectOptions { public final class SpawnOptions { // binaryPath (Path), version (String, default = SDK's pinned engine version), // host (default 127.0.0.1; an IP literal, IPv6 bare ::1 or bracketed [::1] — a bare one needs - // rift >= 0.18.0; localOnly(true) makes the engine bind loopback whatever this says), + // rift >= 0.18.0; localOnly(true) makes the engine bind loopback whatever this says; from + // rift 0.23.0 a host-less imposter binds it too, rift#1517), // adminPort (0 = ephemeral), allowInjection (default true), - // localOnly (default true), logLevel, env (Map), workingDir, mirrorUrl, + // localOnly (default true; from rift 0.23.0 it also refuses non-loopback clients on imposters, + // rift#1523, so a containerised SUT needs it off plus imposter host("0.0.0.0")), logLevel, env (Map), workingDir, mirrorUrl, // startupTimeout (default 15s), shutdownTimeout (default 5s), inheritLog (bool), // upstreamTrust (UpstreamTrust, rift >= 0.18.0 — CaFile or SkipVerify; CaPem and a declared // version below 0.18.0 are refused at build()), diff --git a/docs/testcontainers.md b/docs/testcontainers.md index 2d0718c..627a455 100644 --- a/docs/testcontainers.md +++ b/docs/testcontainers.md @@ -176,6 +176,26 @@ See [docs/recording.md](recording.md#recording-an-origin-behind-a-private-ca) fo (`RecordedRequest.bodyOmitted()`). An image tagged with a version older than 0.22.0 is refused, and a non-zero budget below the body ceiling is refused when the container starts, as the engine would. +## A spawned engine reached from a container + +This is the reverse of the setup above. Here the engine runs on the host via `Rift.spawn()`, and the +system under test runs in a container, reaching the host through `host.docker.internal` or the bridge +gateway. From rift 0.23.0 an imposter with no `host` of its own binds the spawned engine's `--host` +(`127.0.0.1` by default, rift#1517). `--local-only`, which is on by default, also refuses non-loopback +clients on imposters (rift#1523). So the container needs both of these: + +```java +try (Rift rift = Rift.spawn(SpawnOptions.builder().localOnly(false).build())) { + rift.create(imposter("orders").port(4545).host("0.0.0.0") // every interface, not just loopback + .stub(onGet("/orders").willReturn(okJson("[]")))); + // the container calls http://host.docker.internal:4545/orders +} +``` + +Docker Desktop forwards container traffic to loopback, so it works without `localOnly(false)`. A +Linux bridge (`172.17.0.1`) does not. With `RiftContainer` the engine and its imposters are inside +the container, so none of this applies. + ## Using it with the Spring module Publish the container's admin URI as a property and point `@EnableRift(transport = CONNECT)` at it: diff --git a/rift-java-conformance/src/test/java/io/github/achirdlabs/rift/conformance/Ipv6HostIT.java b/rift-java-conformance/src/test/java/io/github/achirdlabs/rift/conformance/Ipv6HostIT.java index 822af36..5e022c7 100644 --- a/rift-java-conformance/src/test/java/io/github/achirdlabs/rift/conformance/Ipv6HostIT.java +++ b/rift-java-conformance/src/test/java/io/github/achirdlabs/rift/conformance/Ipv6HostIT.java @@ -67,8 +67,8 @@ Stream anImposterOnItsOwnHostIsReachedThere() { } private static void assertServes(Rift rift) throws Exception { - // The engine binds an imposter on 0.0.0.0 (IPv4) unless the imposter names its own host, - // whatever the admin host is, so bind this one to ::1 too; uri() then reports it (#243). + // A host-less imposter binds the spawn --host from rift 0.23.0 (rift#1517) but the embedded + // engine's own default elsewhere, so name ::1 here explicitly; uri() then reports it (#243). Imposter imposter = rift.create(imposter("v6").protocol("http").host("::1") .stub(onGet("/").willReturn(status(200).withTextBody("v6")))); assertEquals("[::1]", imposter.uri().getHost()); diff --git a/rift-java-conformance/src/test/java/io/github/achirdlabs/rift/conformance/SpawnHostInheritanceIT.java b/rift-java-conformance/src/test/java/io/github/achirdlabs/rift/conformance/SpawnHostInheritanceIT.java new file mode 100644 index 0000000..7e51be7 --- /dev/null +++ b/rift-java-conformance/src/test/java/io/github/achirdlabs/rift/conformance/SpawnHostInheritanceIT.java @@ -0,0 +1,95 @@ +package io.github.achirdlabs.rift.conformance; + +import io.github.achirdlabs.rift.Imposter; +import io.github.achirdlabs.rift.Rift; +import io.github.achirdlabs.rift.SpawnOptions; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; + +import java.io.IOException; +import java.net.Inet4Address; +import java.net.InetAddress; +import java.net.NetworkInterface; +import java.net.SocketException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Collections; +import java.util.Optional; +import java.util.stream.Stream; + +import static io.github.achirdlabs.rift.conformance.LiveEngine.gatedTo; +import static io.github.achirdlabs.rift.dsl.RiftDsl.imposter; +import static io.github.achirdlabs.rift.dsl.RiftDsl.ok; +import static io.github.achirdlabs.rift.dsl.RiftDsl.onGet; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * From rift 0.23.0 a spawned engine's host-less imposters bind its {@code --host} (rift#1517), and + * {@code --local-only} refuses non-loopback clients on imposters too (rift#1523). So reaching a + * spawned imposter from off the loopback interface, as a containerised system under test does, needs + * both {@code host("0.0.0.0")} on the imposter and {@code localOnly(false)} (#321). Probed over this + * machine's own non-loopback IPv4 address. Needs {@code RIFT_IT=1}. + */ +class SpawnHostInheritanceIT { + + private static final String SPAWN_ONLY = "--host and --local-only are spawn-CLI flags"; + + @TestFactory + Stream aHostlessImposterIsLoopbackOnly() { + return gatedTo(ConformanceTransport.SPAWN, SPAWN_ONLY, "a host-less imposter answers on loopback only", () -> { + InetAddress lan = lanAddress(); + try (Rift rift = Rift.spawn(SpawnOptions.builder().build())) { + Imposter imposter = rift.create(imposter("hostless").stub(onGet("/").willReturn(ok()))); + assertEquals(200, get("127.0.0.1", imposter.port()).orElseThrow()); + assertFalse(get(lan.getHostAddress(), imposter.port()).isPresent(), "not reachable on " + lan); + } + }); + } + + @TestFactory + Stream anOffLoopbackClientNeedsAWildcardImposterAndLocalOnlyOff() { + return gatedTo(ConformanceTransport.SPAWN, SPAWN_ONLY, "host(0.0.0.0) is reachable off loopback only with localOnly(false)", () -> { + InetAddress lan = lanAddress(); + try (Rift localOnly = Rift.spawn(SpawnOptions.builder().build())) { + Imposter wildcard = localOnly.create(imposter("wild").host("0.0.0.0").stub(onGet("/").willReturn(ok()))); + assertFalse(get(lan.getHostAddress(), wildcard.port()).isPresent(), "--local-only drops a non-loopback client"); + } + try (Rift open = Rift.spawn(SpawnOptions.builder().localOnly(false).build())) { + Imposter wildcard = open.create(imposter("wild").host("0.0.0.0").stub(onGet("/").willReturn(ok()))); + assertEquals(200, get(lan.getHostAddress(), wildcard.port()).orElseThrow()); + } + }); + } + + /** This machine's first non-loopback, up IPv4 address; the test is skipped without one. */ + private static InetAddress lanAddress() throws SocketException { + for (NetworkInterface nic : Collections.list(NetworkInterface.getNetworkInterfaces())) { + if (!nic.isUp() || nic.isLoopback() || nic.isVirtual()) { + continue; + } + for (InetAddress address : Collections.list(nic.getInetAddresses())) { + if (address instanceof Inet4Address && !address.isLoopbackAddress() && !address.isLinkLocalAddress()) { + return address; + } + } + } + assumeTrue(false, "no non-loopback IPv4 address to probe from"); + throw new IllegalStateException("unreachable"); + } + + /** The status, or empty when the connection is refused or dropped. */ + private static Optional get(String host, int port) throws InterruptedException { + try { + HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build(); + return Optional.of(client.send(HttpRequest.newBuilder(URI.create("http://" + host + ":" + port + "/")) + .timeout(Duration.ofSeconds(3)).GET().build(), HttpResponse.BodyHandlers.discarding()).statusCode()); + } catch (IOException refusedOrDropped) { + return Optional.empty(); + } + } +} diff --git a/rift-java-core/src/main/java/io/github/achirdlabs/rift/SpawnOptions.java b/rift-java-core/src/main/java/io/github/achirdlabs/rift/SpawnOptions.java index c127ae3..066f12a 100644 --- a/rift-java-core/src/main/java/io/github/achirdlabs/rift/SpawnOptions.java +++ b/rift-java-core/src/main/java/io/github/achirdlabs/rift/SpawnOptions.java @@ -198,11 +198,20 @@ public Builder version(String version) { } /** - * The interface the engine's admin API binds ({@code --host}), default {@code 127.0.0.1}: an - * IP literal, including IPv6 {@code ::1} bare or bracketed (a bare one needs rift ≥ - * 0.18.0). The admin URI is built from it, bracketed when it is IPv6. {@link - * #localOnly(boolean)} is on by default and makes the engine bind loopback whatever this - * says, so turn it off for this to take effect. + * The engine's {@code --host}, default {@code 127.0.0.1}: an IP literal, including IPv6 + * {@code ::1} bare or bracketed (a bare one needs rift ≥ 0.18.0). The admin API binds it, + * and the admin URI is built from it, bracketed when it is IPv6. {@link #localOnly(boolean)} + * is on by default and makes the engine bind loopback whatever this says, so turn it off for + * this to take effect. + * + *

From rift 0.23.0 (rift#1517) an imposter created without its own {@code host} binds this + * address too, as in Mountebank, so spawned imposters are loopback-only by default; before + * it, a host-less imposter bound every interface. A system under test outside the loopback + * interface (in a container, through {@code host.docker.internal} or the bridge gateway) + * reaches an imposter that names its own interface, {@code imposter("x").host("0.0.0.0")}, on + * an engine started with {@link #localOnly(boolean) localOnly(false)}: from 0.23.0 {@code + * --local-only} also refuses non-loopback clients on imposters (rift#1523). Docker Desktop + * forwards to loopback and is admitted either way; a Linux bridge is not. */ public Builder host(String host) { this.host = Objects.requireNonNull(host, "host"); @@ -219,6 +228,11 @@ public Builder allowInjection(boolean allowInjection) { return this; } + /** + * The engine's {@code --local-only}, default on: it binds the admin API to loopback and, from + * rift 0.23.0 (rift#1523), refuses non-loopback clients on every imposter port too. Turn it off + * for a system under test that reaches the engine from off the loopback interface. + */ public Builder localOnly(boolean localOnly) { this.localOnly = localOnly; return this; diff --git a/rift-java-core/src/test/java/io/github/achirdlabs/rift/ImposterUriHostTest.java b/rift-java-core/src/test/java/io/github/achirdlabs/rift/ImposterUriHostTest.java index b8e9a63..a4133f1 100644 --- a/rift-java-core/src/test/java/io/github/achirdlabs/rift/ImposterUriHostTest.java +++ b/rift-java-core/src/test/java/io/github/achirdlabs/rift/ImposterUriHostTest.java @@ -17,8 +17,9 @@ /** * An imposter bound to its own host is reached there, not at the admin host, on an engine the SDK - * runs locally (#243). The engine binds an imposter on its {@code host} field (default {@code - * 0.0.0.0}) whatever interface the admin API uses. + * runs locally (#243). The engine binds an imposter on its {@code host} field whatever interface + * the admin API uses; a host-less one binds the engine's default, which a spawned engine takes from + * its {@code --host} from rift 0.23.0 (rift#1517). */ class ImposterUriHostTest {