Intercept turns a rift engine into a forward proxy that terminates TLS for arbitrary
hostnames — answering some requests inline, forwarding others to a real backend or to one of
this SDK's own imposters — without changing anything about the traffic's actual destination
host name. It's obtained from a running Rift client:
Intercept intercept = rift.intercept(); // default options
Intercept intercept = rift.intercept(InterceptOptions.builder().port(8443).build());One intercept listener runs per engine at a time. A second rift.intercept() call on the same
Rift while one is open throws IllegalStateException before touching the engine; a different
Rift handle to the same engine is refused by the engine itself (HTTP 409, surfaced as an
EngineError). intercept.close() stops the listener and frees the engine for another
rift.intercept(...) — with a different CA, say. A call that fails to start (e.g. a bad committed
CA — see below) leaves intercept available to retry; it does not poison the engine.
Works on every transport: embedded starts the listener over FFM, and a connected/spawned engine
starts it over the admin API (rift ≥ 0.13.3). The bind host must be an IP literal (e.g.
127.0.0.1 or 0.0.0.0) — a hostname is rejected client-side.
The engine reports the address it bound — intercept.engineAddress() keeps it. For an engine
the SDK runs itself (spawn, embedded) that is also where the client dials. For a connected
engine it is an address on the engine's machine: address(), uri() and proxySelector() keep
it, except that a wildcard bind (0.0.0.0, [::]) is dialled at the admin host. Where the
engine's ports are remapped — a container, a port-forward — set
ConnectOptions.Builder.interceptAddress(port -> …); RiftContainer.client() sets it for you.
- A runtime start binds
127.0.0.1unless told otherwise, which answers only on the engine's own machine. For an engine in a container or on another host, bind its interface:InterceptOptions.builder().host("0.0.0.0"). The SDK logs a warning when a connected engine reports a loopback bind. - The mapping is asked about the requested port before the listener starts, so a mapping that
cannot reach it (an unexposed container port, or port
0) refuses with nothing started. Should it fail after the start, the listener is stopped and the error names it, so a retry can start another; only if that stop fails too does the listener stay, and a retry is refused. interceptAddressis separate from the imposterhostResolver: the listener is aCONNECTproxy, never an imposter's HTTP base URI, so a gateway-style imposter mapping does not apply.- A listener the engine starts at launch (
--intercept-port) binds the admin interface, which is why attaching to one (below) works without a bind host.
When the engine already started a listener at launch — via --intercept-port /
RIFT_INTERCEPT_PORT, e.g. a containerized engine with a fixed, exposed port — attach to it
instead of starting a new one:
Intercept intercept = rift.intercept(InterceptOptions.attach(host, port));attach binds to that endpoint (probing it, not starting), so host/port are the reachable
address — a mapped Docker port, say. An IPv6 host may be given bare (::1) or bracketed
([::1]); intercept.uri() brackets it either way. With rift-java-testcontainers this is one call:
RiftContainer rift = new RiftContainer().withInterceptPort(8888); // engine launches the listener
// … after start …
Intercept intercept = rift.client().intercept(rift.interceptOptions()); // attach to the mapped portIf you don't know the port — the engine was launched by something else — let the client find it:
Rift client = Rift.connect(adminUri);
Optional<InterceptStatus> running = client.interceptStatus(); // GET /intercept; empty when none runs
Intercept intercept = client.intercept(InterceptOptions.attach()); // attach to whatever is runningThe port-less attach() asks the engine (GET /intercept) where its listener is bound and reaches it
the way a listener it started is reached: through ConnectOptions.interceptAddress when one is set
(a RiftContainer client sets it to Docker's mapped port), else at the reported address with a
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 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.
A handle whose listener is stopped or replaced outside it (the admin plane, another client, on any
transport) is superseded rather than left holding the engine. The next rift.intercept(), or the old
handle's own close(), finds it stale. From then on its rule calls and trust() throw
IllegalStateException, and it makes no engine call, so it cannot stop or clear the listener
running in its place. Until something finds it stale, its rule calls still reach whatever listener
the engine runs. A listener restarted on the same port looks the same as the original, because the
engine reports a listener only by its address. For an attach(host, port) handle, only a stopped
listener is detected, because its port is the client's view and may differ from the engine's.
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 Docker's port:
RiftContainer rift = new RiftContainer().withExposedInterceptPort(8889); // exposed, not started
// … after start …
Intercept intercept = rift.client().intercept(InterceptOptions.builder()
.host("0.0.0.0").port(8889).ca(certPem, keyPem).build());Every rule is scoped to one hostname and decides what happens to requests the client sends to that host through the intercept proxy:
// Answer inline — the real host is never contacted.
intercept.serve("example.com", RiftDsl.status(418));
// Forward to a port on the engine's own machine, still terminating TLS at the intercept.
intercept.forward("payments.internal", "localhost:9443");
// Forward to another host — here a Testcontainers network alias — over https (rift ≥ 0.20.0).
intercept.forward("api.partner.com", "https://partner-mock:8443");
// Forward to one of this SDK's own imposters, by port.
intercept.redirectTo("api.partner.com", partnerImposter);serve's response is an IsSpec — the same response builder the DSL uses for stub responses
(RiftDsl.ok(), okJson(...), status(code), and so on) — but the engine's serve action is
narrower than that builder. It carries only a numeric status code, headers, and a text body.
A repeated header — withHeader("Set-Cookie", "a=1", "b=2") — goes out as one header line per
value on rift ≥ 0.18.0; an older engine would reject the rule, so the SDK refuses it there first
(unless the version check is off).
A response using anything beyond that is rejected with an InvalidDefinition, and the rule is
not registered:
- any behavior —
after/waitMs,decorate,repeat,copy,lookup,shellTransform; - any
_riftextension —templated(), a script, a fault (withLatencyFault,withErrorFault,withTcpFault), flow-state writes (setState,incrementState,deleteState,clearFlowState), or conditional GET (conditional()); - a binary body (
withBinaryBody), which the serve action can only carry as its base64 text.
// Throws InvalidDefinition: the serve action has no fault concept, so without this the rule
// would register happily and then answer a plain 200.
intercept.serve("example.com", status(200).withTextBody("b").withTcpFault(Fault.CONNECTION_RESET_BY_PEER));
// Point at an imposter instead — it is a real stub, so every construct above works there.
intercept.redirectTo("example.com", faultyImposter);forward(host, target) and rule().forward(target) take one of three forms:
| Target | Goes to | Engine |
|---|---|---|
"9443", "localhost:9443", "127.0.0.1:9443", "[::1]:9443", "http://localhost:9443" |
that port on the engine's own machine, over http | any |
"partner-mock:4600", "10.0.0.7:8080", "[fd00::7]:4600", "http://partner-mock:4600" |
that host, over http | rift ≥ 0.20.0 |
"https://partner-mock:8443", "https://localhost:9443" |
that host, over https | rift ≥ 0.20.0 |
The port is required (1–65535); a path, query or user info is refused with an
IllegalArgumentException. An older engine ignores a target host or scheme and forwards to
127.0.0.1 over http instead, so the SDK refuses such a rule there with an InvalidDefinition
(unless the version check is off). An https target is verified against the engine's outbound
trust — SpawnOptions.upstreamTrust(...) for a spawned or embedded engine,
RiftContainer.withUpstreamTrust(...) for a container — with the target host as the server name, so
https://localhost:9443 keeps localhost and needs a certificate issued for it.
Since rift 0.20.0 the forwarded request keeps the client's original Host header (before, the
imposter saw 127.0.0.1:<port>), so one imposter behind forward or redirectTo can tell several
intercepted hosts apart with a Host header predicate.
redirectTo rides the same
wire-level forward action as forward itself, pointed at imposter.port(); a rule read back
from intercept.rules() therefore only ever reports RuleKind.SERVE or RuleKind.FORWARD — the
engine has no way to echo back that a forward action originated from redirectTo.
The three methods above key on the whole host. For finer control, intercept.rule() builds a rule
with request predicates (the same DSL stubs use — path, method, headers, body) and an optional
host — the engine's full (host?, predicates, action) rule shape:
// Serve /health inline, but let everything else on this host fall through.
intercept.rule().host("example.com").when(onGet("/health")).serve(ok());
// A catch-all (no host) matching a path across every intercepted host.
intercept.rule().when(onPost("/api/**")).redirectTo(partnerImposter);
// Header/method scoping is just more predicates.
intercept.rule().host("payments.internal").when(onGet("/status")).forward("localhost:9443");A rule read back via intercept.rules() exposes its predicates through InterceptRule.predicates().
List<InterceptRule> rules = intercept.rules(); // in the order they were added
intercept.clearRules(); // removes every rule; the listener stays upRules match first-to-last, so adding a rule can never override one already installed, and
clearRules() followed by new rules leaves a moment in which a request meets no rule at all.
replaceRules swaps the whole set in one engine call (rift ≥ 0.20.0): a request in flight meets
either the old rules or the new ones.
// Declare the new set; nothing reaches the engine until the lambda returns, and nothing at all
// if it throws (an undeliverable serve, say), so the old rules stay.
intercept.replaceRules(rules -> {
rules.serve("cdn.example.com", status(503));
rules.redirectTo("api.partner.com", partnerImposter);
});
// Re-install a filtered or reordered list.
intercept.replaceRules(intercept.rules().subList(1, intercept.rules().size()));
// Remove one rule — one returned by serve/forward/redirectTo, or read back from rules().
intercept.removeRule(healthRule);removeRule reads the rules and replaces them, so it is not atomic against a rule another client
adds at the same moment. On an engine older than 0.20.0, replaceRules and removeRule throw an
InvalidDefinition naming the version they need.
intercept.close() stops a listener it started (rift ≥ 0.13.0) — rules and CA go with it — and
frees the engine for another rift.intercept(...). A restart without a supplied CA mints a new
CA, so take trust() from the new handle; a closed handle refuses trust() and rule calls rather
than hand out the old anchor. Closing an attached handle only clears its rules: the listener
belongs to whoever launched it (a container, --intercept-port). close() is idempotent, and a
failed stop leaves the handle open to retry.
With no CA supplied, the engine mints a fresh ephemeral CA per listener and signs a leaf
certificate per intercepted hostname on the fly. A client has to be told to trust that CA, or
its TLS handshake fails. Intercept.trust() returns an InterceptTrust with three ways to do
that:
No files, nothing written to disk — built entirely from the CA's PEM text in memory:
SSLContext ssl = intercept.trust().sslContext();
HttpClient client = HttpClient.newBuilder()
.sslContext(ssl)
.proxy(intercept.proxySelector())
.build();This is the right default for test code: it needs no cleanup, leaves nothing behind, and works identically whether the CA is ephemeral or committed.
sslContext() trusts only the intercept CA — right for a fully hermetic SUT. If the same SUT
also makes real HTTPS calls (say it exports telemetry over TLS while its mocked dependency is
intercepted), use sslContextWithSystemCAs() instead, which trusts the intercept CA and the
JVM's default trust anchors:
SSLContext ssl = intercept.trust().sslContextWithSystemCAs(); // intercepted hosts + real HTTPSWhen the client under test is out of your control (a third-party HTTP client, a subprocess, a whole JVM configured only via system properties), export a truststore file instead:
Path truststore = Path.of("build/tmp/intercept-trust.p12");
intercept.trust().exportTruststore(TruststoreFormat.PKCS12, "changeit", truststore);-Djavax.net.ssl.trustStore=build/tmp/intercept-trust.p12
-Djavax.net.ssl.trustStorePassword=changeit
-Djavax.net.ssl.trustStoreType=PKCS12
TruststoreFormat.JKS is also available. A null password defaults to "changeit". This is a
plain Java KeyStore write with no engine round-trip — same as sslContext(), just persisted.
By default every listener gets a fresh, ephemeral CA, which is fine for a single test process but means two independently-started engines don't trust each other's intercepted traffic and a CA can't be pinned once and reused across runs. Supply your own instead:
InterceptOptions options = InterceptOptions.builder()
.ca(Path.of("ca-cert.pem"), Path.of("ca-key.pem"))
.build();
Intercept intercept = rift.intercept(options);Both paths are required, or neither — a half-supplied pair is rejected client-side by
InterceptOptions.Builder.ca(...) (IllegalArgumentException) rather than surfacing later as a
confusing engine-side error. With a committed CA, every engine instance that loads it produces
byte-identical trust material, so one exported truststore (or one sslContext() derived from the
same PEM) works across all of them.
CA material in memory (no caller-side file). When the CA comes from a secret store rather than a
file, pass it directly — as PEM text, bytes, or a KeyStore:
.ca(certPem, keyPem) // String PEM (e.g. from an env var / Vault)
.ca(certBytes, keyBytes) // byte[] PEM
.ca(keyStore, password) // a JKS/PKCS12 holding the CA's cert + keyThe PEM is shipped inline in the start request (rift ≥ 0.13.4), so no file is staged and the
engine needs nothing on its own filesystem — this works for a containerized remote engine
without mounting anything (unlike a file-path ca(Path, Path), which the engine reads from its own
filesystem).
Let rift generate a shareable CA. Instead of supplying one, have the engine mint a fresh CA and hand back its cert and key (rift ≥ 0.13.4) to persist and redistribute:
Intercept intercept = rift.intercept(InterceptOptions.builder().generateCa().build());
Intercept.CaMaterial ca = intercept.caMaterial().orElseThrow();
Files.writeString(Path.of("ca-cert.pem"), ca.certPem());
Files.writeString(Path.of("ca-key.pem"), ca.keyPem()); // feed both to other services / a committed CAThe in-memory sslContext() above assumes the client lives in the same JVM as the test. When
the system under test runs in its own container (or any process started before the test), it
must already trust the intercept CA at startup — so the ephemeral per-listener CA won't do. Commit
one CA, have the interceptor load it, and hand the container a truststore built from the same CA.
Both sides then share a single trust anchor and the TLS handshake between them succeeds.
1. Generate the CA once and commit ca-cert.pem + ca-key.pem:
openssl req -x509 -newkey rsa:2048 -nodes -keyout ca-key.pem -out ca-cert.pem \
-days 3650 -subj "/CN=rift-intercept-ca"2. The interceptor loads that committed CA on a fixed, container-reachable port:
import static io.github.achirdlabs.rift.dsl.RiftDsl.*;
Intercept intercept = rift.intercept(InterceptOptions.builder()
.host("0.0.0.0") // reachable from the container, not just loopback
.port(8888) // fixed — matches the container's proxyPort
.ca(Path.of("intercept/ca-cert.pem"), Path.of("intercept/ca-key.pem"))
.build());
intercept.serve("cdn.optimizely.com", okJson(datafileJson));
// Write the truststore the container will mount. Because it is derived from the committed CA, it
// grants exactly the same trust as sslContext() would in-JVM.
intercept.trust().exportTruststore(
TruststoreFormat.JKS, "changeit", Path.of("intercept/rift-truststore.jks"));Because
-Djavax.net.ssl.trustStorereplaces the container's whole truststore, a SUT that also makes real HTTPS calls (telemetry, other live upstreams) would then fail those handshakes. Export withexportTruststoreWithSystemCAs(...)instead — same file, but it also carries the JVM's default trust anchors, so real endpoints keep working alongside the intercepted ones.
3. The container trusts the CA and proxies its HTTPS through the host. Under Docker the
interceptor is reachable at host.docker.internal; mount the truststore and set the JVM's trust +
proxy properties:
services:
sut:
environment:
JDK_JAVA_OPTIONS: >-
-Dhttps.proxyHost=host.docker.internal
-Dhttps.proxyPort=8888
-Djavax.net.ssl.trustStore=/intercept-certs/rift-truststore.jks
-Djavax.net.ssl.trustStorePassword=changeit
volumes:
- "./intercept:/intercept-certs"Because the CA is committed, the container can be built and started before the test runs — it
trusts the anchor up front, and the interceptor signs each intercepted leaf with the matching key.
For a non-JVM SUT, point its own trust mechanism at ca-cert.pem instead (e.g.
NODE_EXTRA_CA_CERTS, SSL_CERT_FILE, or the OS trust store).
With rift-java-testcontainers, the rift container itself can launch with the committed CA —
no runtime start, and the SUT container trusts the same file:
@Container
static final RiftContainer rift = new RiftContainer()
.withInterceptPort(8888) // launched with the engine
.withInterceptCa(Path.of("intercept/ca-cert.pem"), Path.of("intercept/ca-key.pem"));
Intercept intercept = rift.client().intercept(rift.interceptOptions()); // attach, carrying the CA
intercept.caMaterial(); // the committed pair, checked against the CA the listener servesinterceptOptions() attaches with InterceptOptions.attach(host, port, caMaterial): the attach
compares the listener's served certificate with the given one and refuses a mismatch, rather than
letting the SUT fail its handshake later. Outside Testcontainers, pass your pair to
InterceptOptions.attach(host, port, ca) yourself for the same check and caMaterial().
A file-path CA (
ca(Path, Path)) is read by the engine from its own filesystem — fine for the embedded engine, but a separate rift container reached viaRift.connect(...)resolves those paths inside the container, so mount the CA there. To avoid the mount entirely, pass the CA in memory (ca(String/byte[]/KeyStore), shipped inline, rift ≥ 0.13.4) or have rift generate one (generateCa()) — neither needs anything on the engine's filesystem.
The two pieces a java.net.http.HttpClient needs are the trusted SSLContext and a
ProxySelector that routes traffic through the intercept listener — Intercept hands back both
directly:
HttpClient client = HttpClient.newBuilder()
.sslContext(intercept.trust().sslContext())
.proxy(intercept.proxySelector())
.build();
HttpResponse<String> response = client.send(
HttpRequest.newBuilder(URI.create("https://example.com/")).GET().build(),
HttpResponse.BodyHandlers.ofString());intercept.address() (an InetSocketAddress) is also available directly, for clients configured
via http.proxyHost/http.proxyPort-style properties rather than a ProxySelector.
- One intercept per engine. If your test suite spins up more than one
Riftengine (e.g. one per test class), each gets its own intercept and its own CA (unless you commit one — see above). Don't assume trust material from one engine's intercept applies to another's. - Corporate/CI proxies. If the process under test already goes through an upstream
HTTP(S) proxy (a corporate proxy, a CI sidecar), that proxy and the intercept's
ProxySelectorcompete for the same client configuration —HttpClient.newBuilder().proxy(...)only accepts one. Either route through the intercept exclusively for the duration of the test, or configure the intercept's own upstream viaforward/redirectTorules so it, not the client, talks to the real proxy. - A
Hostpredicate written for an older engine. From rift 0.20.0 a forwarded request keeps the client'sHost, so an imposter stub matchingHost: 127.0.0.1:<port>stops matching; match the intercepted hostname instead.Proxy-AuthorizationandProxy-Connectionare not forwarded. - No engine-side echo of
REDIRECT. As noted above, rules fetched back viaintercept.rules()can't distinguish aredirectTorule from a plainforwardrule — both reportRuleKind.FORWARD. Track the association client-side if your test needs it.