Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/design/sdk-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down
20 changes: 20 additions & 0 deletions docs/testcontainers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,8 @@ Stream<DynamicTest> 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());
Expand Down
Original file line number Diff line number Diff line change
@@ -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<DynamicTest> 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<DynamicTest> 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<Integer> 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();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 &ge;
* 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 &ge; 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.
*
* <p>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");
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down
Loading