Embeddable Server¶
The rift binary is a thin wrapper around library entry points in rift-http-proxy. A Rust host can
run the same server in-process — optionally around its own ImposterManager — and bind the admin and
metrics planes to addresses of its choosing.
The server composition lives in the rift_http_proxy::server module (Cli, Commands,
ServerBuilder, RunningServer, admin_bind_addr, run_metrics_server, bind_metrics_server,
RunningMetrics); none of these are re-exported at the crate root. The crate root re-exports the
rift-mock-core modules plus the TCP-fault seam (TcpFaultKind, tcp_fault_carrier, FaultCell,
FaultIo, InjectedFault, is_injected_fault, LingerOnClose, arm_linger_if_requested) and
install_default_crypto_provider. gateway::apply_tcp_fault applies a fault carrier on a listener
of your own (SPI → Detecting a TCP fault in-process).
Cli's Debug output redacts its credentials: --api-key, --intercept-auth and
--intercept-ca-key-pem render as "<redacted>" when set (issue #1166), so a host that flattens
Cli into its own derived-Debug parser does not leak them into a log line.
ServerBuilder¶
ServerBuilder composes the standard admin API, imposter listeners, and (optionally) the metrics
server, then serves them.
use rift_http_proxy::server::{Cli, ServerBuilder};
use clap::Parser;
// Build from parsed CLI options (same flags as the `rift` binary):
let builder = ServerBuilder::from_cli(Cli::parse());
| Method | Signature | Purpose |
|---|---|---|
from_cli |
fn from_cli(cli: Cli) -> Self |
Seed the builder from CLI options (port, host, configfile, datadir, TLS defaults, metrics port, …). |
manager |
fn manager(self, manager: Arc<ImposterManager>) -> Self |
The embedding seam — inject a pre-built ImposterManager (e.g. one wired with custom SPI backends) instead of letting the builder construct the default one. Skips internal construction, including --datadir write-through, TLS defaults and --host inheritance: an injected manager's host-less imposters bind every interface unless you call ImposterManager::with_default_host(cli.host.clone()) on it yourself (issue #1517). --local-only is not inherited either: an injected manager serves every client unless you call ImposterManager::with_peer_policy(PeerPolicy::LoopbackOnly) on it (issue #1523). A front door bound over a manager follows that manager's peer_policy(). |
imposter_source |
fn imposter_source(self, source: Arc<dyn ImposterSource>) -> Self |
Register an --imposters URI scheme (repeatable). file:/https: are built in; claiming a scheme already taken is a startup error. |
accept_runtimes |
fn accept_runtimes(self, runtimes: Vec<tokio::runtime::Handle>) -> Self |
Fan imposter accept loops out across per-core runtimes (issue #745). Applies only to the builder-constructed manager; an injected one uses ImposterManager::with_accept_runtimes. Empty keeps the single-listener topology. |
admin_authorizer |
fn admin_authorizer(self, authorizer: Arc<dyn AdminAuthorizer>) -> Self |
Per-request admin authorization hook (issue #854) — see AdminAuthorizer. |
reported_admin_port |
fn reported_admin_port(self, port: u16) -> Self |
The port GET /config reports, when the admin plane binds somewhere other than where clients reach it (issue #1135). See AdminApiServer::with_reported_admin_port below. |
reported_host |
fn reported_host(self, host: Option<String>) -> Self |
The options.host GET /config reports, when the embedder re-points --host for its own listener (issue #1560). Report-only: the bind is untouched. Default is the CLI's --host; None reports no host. |
run |
async fn run(self) -> anyhow::Result<()> |
Load configs, bind, and serve forever (returns only on error/shutdown). |
start |
async fn start(self) -> anyhow::Result<RunningServer> |
Same, but returns a RunningServer handle once bound — supports ephemeral (:0) ports and programmatic shutdown. |
RunningServer¶
Returned by start(); lets the host discover bound addresses and control lifecycle.
| Method | Signature | Purpose |
|---|---|---|
admin_addr |
fn admin_addr(&self) -> SocketAddr |
The bound admin API address (resolve an ephemeral :0 to the real port). |
metrics_addr |
fn metrics_addr(&self) -> Option<SocketAddr> |
The bound metrics address, or None if metrics weren't started. |
intercept_addr |
fn intercept_addr(&self) -> Option<SocketAddr> |
The bound intercept proxy address, whether started by --intercept-port or later by POST /intercept; None when none is running. |
front_door_addr |
fn front_door_addr(&self) -> Option<SocketAddr> |
The bound front door address; None unless --front-door was given. |
join |
async fn join(self) -> anyhow::Result<()> |
Await the server until it exits, consuming it. |
wait |
async fn wait(&self) -> anyhow::Result<()> |
Await the server until it exits without consuming it — so you can race it against your own shutdown signal. |
shutdown |
async fn shutdown(&self) |
Trigger a graceful shutdown. |
use rift_http_proxy::server::{Cli, ServerBuilder};
use clap::Parser;
let server = ServerBuilder::from_cli(Cli::parse()).start().await?;
println!("admin listening on {}", server.admin_addr());
// ... run your test suite against server.admin_addr() ...
server.shutdown().await;
Racing the server against a shutdown signal¶
join moves the server, so a select! arm that wins against it can no longer reach shutdown.
wait borrows instead, which is what lets a host own the shutdown policy — run its own teardown
between the signal and the server stopping, and still surface an admin-plane failure if the server
dies on its own first:
tokio::select! {
result = server.wait() => return result, // the admin plane exited — surface why
() = termination_signal() => {} // asked to stop — fall through
}
// your own teardown here (drain a cluster, deregister from a load balancer, ...)
server.shutdown().await;
The accept loop's error is delivered to the first caller of wait/join; later calls return
Ok(()) (anyhow::Error is not Clone). shutdown takes &self, so the server can also be held
in an Arc and stopped from another task.
Injecting a custom ImposterManager¶
ImposterManager (from rift-mock-core) is the injection point for every SPI backend (see
Extension Points). Build it, wire your backends, then hand it to
the server:
use std::sync::Arc;
use rift_mock_core::imposter::ImposterManager;
use rift_http_proxy::server::{Cli, ServerBuilder};
use clap::Parser;
let manager = Arc::new(
ImposterManager::new()
.with_flow_store_provider(my_flow_store_provider)
.with_request_journal(my_journal),
);
ServerBuilder::from_cli(Cli::parse())
.manager(manager)
.run()
.await?;
Reserving the server's own ports¶
No imposter may take a port the server's own listeners hold (#1557). ServerBuilder registers
its admin API, /metrics, front door and intercept listener on the manager it serves, an injected
one included, with the port each one really bound, and releases them at shutdown. So do
AdminApiServer::bind and bind_front_door on the manager they are given. A listener the embedder
binds itself is registered like this:
use rift_mock_core::imposter::{PortReservation, ServerListener};
// Held for as long as the listener runs; dropping it releases the port.
let reservation = PortReservation::new(&manager, &[listener.local_addr()?], ServerListener::Admin);
reserve_port(port, by) and release_port(port) are the same thing without the guard. An explicit
imposter port that is reserved is refused with ImposterError::PortInUse, the 400 already in
use; auto-assign skips it. Under with_serve_unbound(true) the imposter is instead registered
without a listener, as a failed bind is. The check runs when an imposter binds, never when a config
is decoded, so stored configs stay decodable.
What scripts may require() (ScriptFileAccess)¶
A running imposter carries a ScriptFileAccess that decides which files its Mountebank-style
scripts may load with require() (issue #1611), and which CSV files its lookup behaviors may read
(issue #1617). It is never part of ImposterConfig, so it is not serialized, not a stub_key input
and not in replicated bytes: the door that writes the imposter chooses it.
| Variant | require() and lookup read |
|---|---|
ScriptFileAccess::Confined(root) |
Only files under root; anything else is refused with one message that never says whether the file exists. ScriptFileAccess::network(Some(root)). |
ScriptFileAccess::Refused (the default) |
Nothing. ScriptFileAccess::network(None). |
ScriptFileAccess::Trusted { base } |
Any path; a relative one from base. ScriptFileAccess::trusted_cwd() uses the working directory. For configs a local operator wrote. |
| Item | Signature | Purpose |
|---|---|---|
with_script_file_access |
fn with_script_file_access(self, access: ScriptFileAccess) -> Self |
The access of every create or apply that names none: create_imposter, create_imposter_as, apply_config, apply_one. Defaults to Refused, so an embedder that has not chosen fails closed. |
create_imposter_from |
async fn create_imposter_from(&self, config: ImposterConfig, persistence: Persistence, script_files: ScriptFileAccess) -> Result<u16, ImposterError> |
create_imposter_as with the access this door grants. |
apply_config_from |
async fn apply_config_from(&self, desired: Vec<ImposterConfig>, script_files: ScriptFileAccess) -> Result<ApplyReport, ImposterError> |
apply_config with the access this door grants. A running imposter the apply keeps takes it too, in the same step as its stub patch, when that grants no more than it had; otherwise the apply replaces the imposter (issue #1645). |
DesiredImposter::script_files |
pub script_files: ScriptFileAccess |
The access of one apply_desired / apply_one_desired entry. |
add_stub_from, replace_stubs_from, replace_stub_from, replace_stub_by_id_from |
add_stub, replace_stubs, replace_stub, replace_stub_by_id with one more script_files: ScriptFileAccess argument |
A stub write by a door: in the same step as the write, the imposter's access narrows to what both it and the door allow (ScriptFileAccess::meet), so the stub never runs with more than its door grants, whatever apply runs alongside (issue #1645). The admin API's stub routes use these. The plain methods keep the imposter's access as it is. |
Imposter::verify_from |
fn verify_from(&self, opts: &VerifyOptions, door: &ScriptFileAccess) -> anyhow::Result<VerifyOutcome> |
verify for predicates a door supplies: an inject predicate's require gets what both the imposter's access and door allow. verify keeps the imposter's own access, for a caller trusted at least as far. |
set_script_file_access |
fn set_script_file_access(&self, port: u16, script_files: ScriptFileAccess) -> Result<(), ImposterError> |
Set a running imposter's access, wider or narrower. Not for a door about to write stubs: a separate set and write race a concurrent apply; use the _from writes. |
Rift's own server gives the admin API, --datadir files and https: sources network(--scripts-dir),
and local file: sources trusted_cwd(); the C-ABI's host calls are trusted_cwd() and its admin
plane is network(None). A multi-tenant embedder should pass network(Some(root)) per tenant.
A lookup path is resolved under the access every time it runs, so an imposter the default
Refused access applies serves its lookups with their tokens unreplaced. check_lookup_paths(stubs,
&access) is the refusal Rift's admin API runs before a write; it is not part of admission_check,
because whether a path is readable depends on the door and not on the config, so a stored config
never fails to replay for it.
Reconciling one imposter (apply_one)¶
ImposterManager::apply_config reconciles the whole imposter set: it deletes every running port the
set leaves out and checks every imposter it keeps. An embedder that already knows exactly one port
changed can reconcile that port alone:
| Item | Signature | Purpose |
|---|---|---|
apply_one |
async fn apply_one(&self, config: ImposterConfig) -> Result<ApplyReport, ImposterError> |
Make the decisions apply_config makes for config's port and no other: create it if absent, replace it on an imposter-level change, rebind it if it is registered without a listener, toggle enabled in place, and patch its stubs in place so unchanged stubs keep their response cycles. The ApplyReport is the one apply_config would report for that port. A running imposter stays in its persistence store and a new one is written to the datadir. |
apply_one_desired |
async fn apply_one_desired(&self, desired: DesiredImposter) -> Result<ApplyReport, ImposterError> |
apply_one with the store the imposter belongs to, as in apply_desired: a running imposter whose store changed is retagged in place. |
configmust name an explicit port. A config with no port, or port0, is refused withImposterError::ExplicitPortRequired; auto-assigned imposters go throughapply_config.Errmeans nothing changed: an invalid protocol, a duplicate stubid, or a missing port. A failure while applying, such as a bind failure, is reported inApplyReport::failed.apply_onenever deletes. Remove a port withdelete_imposter.- Nothing serializes
apply_oneagainst a concurrentapply_config, whose delete sweep can remove the port, just as two concurrentapply_configcalls are not serialized.
Response cycle positions in the datadir (start_cursor_flusher)¶
A manager built with_datadir(Some(dir)) saves each datadir imposter's response cycle positions to
<dir>/<port>.cursors (issue #1603), so a restart resumes them as Mountebank does. Nothing is written
until something flushes: ServerBuilder::start starts the flusher on its manager, and an embedder
that drives the manager itself starts its own.
| Item | Signature | Purpose |
|---|---|---|
start_cursor_flusher |
fn start_cursor_flusher(self: &Arc<Self>, period: Duration) -> Option<CursorFlusher> |
Flush every period on the current runtime. None without a datadir. It also writes recorded requests behind (issue #1608); a ResponseSequencer, which holds the cursors itself, leaves only that half to do. |
CursorFlusher::stop |
async fn stop(self) -> Vec<(u16, ImposterError)> |
Stop it and flush once more, for a graceful stop; returns the ports that could not be written. Dropping it stops the flush without the last one. |
flush_recorded_requests |
async fn flush_recorded_requests(&self) -> Vec<(u16, ImposterError)> |
Write every datadir imposter's recorded requests to <port>.requests.jsonl (issue #1608), including any still waiting for a response, as a graceful stop does. The flusher writes them behind every period and CursorFlusher::stop calls this. |
flush_cursors |
async fn flush_cursors(&self) -> Vec<(u16, ImposterError)> |
Write every position that moved since the last flush. A failed port is retried by the next one. |
create_imposter_resuming |
async fn create_imposter_resuming(&self, config: ImposterConfig, script_files: ScriptFileAccess) -> Result<u16, ImposterError> |
Create a datadir imposter loaded from its <port>.json, resuming from <port>.cursors. Every other create starts each cycle at the first response and removes a <port>.cursors an earlier imposter on the port left. apply_desired resumes the imposters it creates with Persistence::Datadir. |
A missing <port>.cursors resumes nothing, and one that cannot be read is ignored with a warning.
Deleting the imposter deletes the file.
Replaying admitted configs (deserialize_replayed, admission_check)¶
Decoding an ImposterConfig, Stub or StubResponse runs the engine's admission checks. A
predicate jsonpath/xpath selector or matches pattern that does not compile, a behaviors block
that does not parse, or a copy/lookup selector that does not compile is refused inside serde.
That is right for configs arriving at a door. It is wrong for
configs an engine already admitted, which an embedder keeps in its own log or tables or receives from
a peer: whether those bytes decode would then depend on the admission rules of the engine version
reading them, and a later release that tightens a check would make stored configs unreadable.
| Item | Signature | Purpose |
|---|---|---|
deserialize_replayed |
fn deserialize_replayed<'de, T: Deserialize<'de>, D: Deserializer<'de>>(d: D) -> Result<T, D::Error> |
Decode with exactly the admission checks above switched off. T can be any type that contains configs, and it works as #[serde(deserialize_with = "deserialize_replayed")] on a field. |
admission_check |
fn admission_check(config: &ImposterConfig) -> Result<(), String> |
Run the checks the decode skipped. A config a door admits passes. Otherwise Err carries the message of the first failure in the order a door decodes, so it names what the door would have named. |
admission_check_stub |
fn admission_check_stub(stub: &Stub) -> Result<(), String> |
The same for one stub. |
admission_check judges the program that will run, which differs from what a door checked in one
place, a top-level repeat next to a behaviors block. A door checks the block first and merges the
repeat in afterwards. So a malformed repeat inside the block that the top-level one replaces is
refused by a door but passes admission_check, and when the top-level repeat is itself malformed,
both refuse but admission_check names the repeat first.
Some refusals are door-only, so a door refuses them and admission_check passes on what a replayed
decode makes of them: a response with two types or none, and every behaviors rule #1582 added. A
behaviors element with two behavior types or none is stored split into one step per key, and a
null repeat as absent, so the decoded value no longer holds them. A 0 repeat and a
copy.from or lookup key.from without exactly one key it could see, and does not check: releases
up to 0.23.0 stored them, and a store replays what it holds. Rift's own --datadir loads this way
too, with deserialize_replayed and then admission_check.
The replay floor. The floor is the first release that ships deserialize_replayed. A config an
engine at or after the floor admitted through any door decodes with deserialize_replayed on every
later engine, to the same value, and passes admission_check unless a check was added after it was
admitted. What a replayed decode still refuses is part of the stored format and does not change:
- type and shape errors;
- a
waitordelayRangewhoseminexceeds itsmax; _behaviorsgiven as an array, orbehaviorsas a scalar;- a single-valued header (
proxy.injectHeaders,_rift.fault.error.headers) named twice; - a
_rift.fault.tcpprobability outside 0.0–1.0, or its object form without atype.
Configs stored by an engine older than the floor are not covered.
Run admission_check on a replayed value before handing it to ImposterManager, and decide there
what a refused config means for you, for example skip that imposter and report it, as the engine
does for a --datadir file it now refuses. The switch is per thread and lasts for one call; decoding
is synchronous, so it never spans an .await.
use rift_mock_core::imposter::{ImposterConfig, admission_check, deserialize_replayed};
let mut decoder = serde_json::Deserializer::from_slice(&stored_bytes);
let config: ImposterConfig = deserialize_replayed(&mut decoder)?;
decoder.end()?; // a hand-built decoder does not reject trailing bytes by itself
match admission_check(&config) {
Ok(()) => { manager.apply_one(config).await?; }
Err(reason) => tracing::warn!(%reason, "stored imposter no longer admitted; skipped"),
}
Bindable admin & metrics servers¶
For finer control — running only the admin plane, or binding each plane independently — use the
lower-level bind APIs. Both follow the same pattern: pass :0 to get an OS-assigned port and read
the actual address back from the returned handle.
AdminApiServer¶
use std::sync::Arc;
use rift_http_proxy::admin_api::AdminApiServer;
let running = AdminApiServer::new(addr, manager, api_key) // api_key: Option<String>
.with_config_source(config_source) // ConfigSource, optional
.with_allow_injection(true) // enable JS inject, optional
.bind()
.await?;
println!("admin bound to {}", running.local_addr());
| Item | Signature | Purpose |
|---|---|---|
AdminApiServer::new |
fn new(addr: SocketAddr, manager: Arc<ImposterManager>, api_key: Option<String>) -> Self |
Construct the admin server; api_key (when Some) gates the admin API via the Authorization header, or Mountebank's x-api-key when Authorization is absent (issue #1565). |
with_config_source |
fn with_config_source(self, source: ConfigSource) -> Self |
Retain the load source so POST /admin/reload can re-read it. |
with_imposter_sources |
fn with_imposter_sources(self, sources: Arc<SourceSet>, datadir: Option<PathBuf>) -> Self |
Retain an --imposters source set, and the --datadir loaded beside it, so POST /admin/reload re-reads both and applies them as one set. Source imposters are applied as Persistence::Ephemeral and never written to the datadir (issue #1122). ImposterManager::apply_config keeps a running imposter in the store it is in: it persists what it creates, and changes to imposters already in the datadir. |
with_allow_injection |
fn with_allow_injection(self, allow: bool) -> Self |
Admit scripted configs (inject, decorate, shellTransform, _rift.script, …) submitted through this admin API, and report the setting from GET /config. The embedder spelling of --allowInjection; default false. |
with_local_only |
fn with_local_only(self, local_only: bool) -> Self |
Record --local-only for GET /config to report (issue #879). Stated explicitly, not inferred from the bind address. It reports only; it refuses nothing — pair it with with_peer_policy. |
with_peer_policy |
fn with_peer_policy(self, policy: PeerPolicy) -> Self |
Which clients the admin accept loop serves (issue #1568). Default PeerPolicy::Any. LoopbackOnly closes a non-loopback client at accept, before any HTTP work, and counts rift_blocked_connections_total{listener="admin"}. ServerBuilder::start sets LoopbackOnly under --local-only: a loopback bind alone still accepts a non-loopback source routed over loopback. |
with_admin_authorizer |
fn with_admin_authorizer(self, authorizer: Arc<dyn AdminAuthorizer>) -> Self |
Install the per-request authorization hook (issue #854); ServerBuilder::admin_authorizer is the builder spelling. |
with_intercept |
fn with_intercept(self, control: InterceptControl) -> Self |
Serve the /intercept* routes against this shared control slot. Without it every /intercept* route answers 404. See Intercept proxy. |
with_scripts_dir |
fn with_scripts_dir(self, dir: PathBuf) -> Self |
Root that _rift.script file: references resolve under for imposters created through this API (issue #356), and the only place their scripts may require() from (issue #1611). Without it both are refused. |
with_reported_admin_port |
fn with_reported_admin_port(self, port: u16) -> Self |
Report port from GET /config instead of the port this server bound (issue #1135). For a host that fronts the admin API with its own public listener and binds the core to an ephemeral loopback port — without it, options.port advertises the private port to Mountebank-compat clients that read it to build URLs. Unset reports the bound port; 0 is a configured 0, not "unset". ServerBuilder::reported_admin_port is the builder spelling. |
listen |
fn listen(self, addr: ListenAddr) -> Self |
Listen on addr instead of the exact address given to new (issue #1528): ListenAddr::Any(port) for [::] dual-stack with a 0.0.0.0 fallback, ListenAddr::Loopback(port) for 127.0.0.1 and [::1], ListenAddr::Exact(addr) for one address. new(addr, …) alone is Exact(addr); an IPv6 address is dual-stack. |
with_reported_host |
fn with_reported_host(self, host: Option<String>) -> Self |
The --host flag GET /config reports as options.host (issue #1528): the flag, null when unset, never the bound address. |
with_require_admin_auth |
fn with_require_admin_auth(self, require: bool) -> Self |
Make bind fail when this server would be reachable off-host with no api_key, instead of warning (issue #863). The embedder spelling of --require-admin-auth. |
bind |
async fn bind(self) -> anyhow::Result<RunningAdminApi> |
Bind and start serving; returns once bound. |
run |
async fn run(self) -> anyhow::Result<()> |
Bind and serve until the accept loop exits. |
bind reports the authentication posture either way. When addr is not loopback and api_key
is None, it logs a warning naming the address and the remedies — the admin API can create
imposters and drive the TLS intercept proxy, so an unauthenticated off-host bind is worth stating
out loud. with_require_admin_auth(true) turns that warning into a startup error; the check runs
before the listener binds, so a refusal never leaves a socket behind. It gates on authentication,
not on the address: a real api_key satisfies it on any bind, and loopback satisfies it with none.
RunningAdminApi: local_addr(&self) -> SocketAddr (the first bound address), local_addrs(&self) -> &[SocketAddr]
(every bound address: 127.0.0.1 and [::1] under ListenAddr::Loopback, issue #1528), shutdown(&self),
join(self) -> anyhow::Result<()>, wait(&self) -> anyhow::Result<()> (the non-consuming form of join, as above).
Computing addr the way the CLI does¶
If your binary parses rift's Cli, derive the admin address with server::admin_listen_addr rather
than reading --host/--port yourself — --local-only pins loopback and outranks --host, no
--host listens on every interface, and a private copy of that rule can disagree with the exposure
check about which address is being judged (issues #1131, #1528):
use rift_http_proxy::imposter::PeerPolicy;
use rift_http_proxy::server::admin_listen_addr;
// Loopback, Any (no --host) or Exact(--host), on --port. A DNS-name --host is resolved here, so
// call it once and judge the same address you bind (issue #1575).
let listen = admin_listen_addr(&cli)?;
let running = AdminApiServer::new(listen.judged(), manager, cli.api_key.clone())
.listen(listen)
.with_require_admin_auth(cli.require_admin_auth)
.with_local_only(cli.local_only)
.with_peer_policy(if cli.local_only { PeerPolicy::LoopbackOnly } else { PeerPolicy::Any })
.bind()
.await?;
Do not call check_admin_exposure yourself here: bind already runs it on addr, so a hand-rolled
call in front of it judges the same address twice and logs the warning twice. Thread
--require-admin-auth through with_require_admin_auth instead — that is what turns the warning
into a refusal. (ServerBuilder::start does check before binding, but only because it has a
metrics listener it must not have to unwind; an embedder binding the admin plane alone does not.)
| Item | Signature | Purpose |
|---|---|---|
Cli::connect_host |
fn connect_host(&self) -> &str |
The host a local client (rift healthcheck, rift save) connects to: --host, or 127.0.0.1 when it is unset (issue #1528). Cli::host is an Option<String> since #1528, None when no --host/MB_HOST was given. |
admin_listen_addr |
fn admin_listen_addr(cli: &Cli) -> anyhow::Result<ListenAddr> |
Where the admin plane listens under this CLI (issue #1528): Loopback(port) under --local-only, Any(port) with no --host, otherwise Exact(--host:port), a DNS name resolved to its first address (issue #1575). Same errors as admin_bind_addr. |
ListenAddr |
enum ListenAddr { Any(u16), Loopback(u16), Exact(SocketAddr) } |
Where a server-level listener binds (issue #1528). judged() is the address the exposure check is handed ([::]:port, 127.0.0.1:port, or the exact address); bind() returns every std listener it needs (one, or two under Loopback). |
admin_bind_addr |
fn admin_bind_addr(cli: &Cli) -> anyhow::Result<SocketAddr> |
The address the admin plane is judged by under this CLI: --local-only pins loopback, otherwise --host, on --port; with no --host, [::]:port (issue #1528). It is admin_listen_addr(cli)?.judged(), the same value the exposure check is handed. ServerBuilder::start calls it too, so the rule has one definition. Accepts an IPv4 literal, an IPv6 literal, bare (::1) or bracketed ([::1]), or a DNS name, resolved to its first address (issue #1575); each call resolves afresh, so call it once and use that address for both the exposure check and the bind. Errors when --host does not resolve. |
ConfigSource (from rift-http-proxy) is either File { path, no_parse } (a single --configfile,
with optional EJS preprocessing) or Dir(PathBuf) (a --datadir of one-imposter-per-file configs).
Front door¶
bind_front_door(addr: SocketAddr, manager, routes) binds exactly one address. When the bind comes
from a user-facing --front-door-style value, use bind_front_door_at with server::parse_front_door_addr
instead, so a bare port gets the CLI's rule: every interface, [::] dual-stack with an IPv4
fallback (issue #1528).
use rift_http_proxy::front_door::bind_front_door_at;
use rift_http_proxy::server::parse_front_door_addr;
let listen = parse_front_door_addr("8080")?; // Any(8080); "0.0.0.0:8080" is Exact
let running = bind_front_door_at(listen, manager, routes, None).await?;
| Function | Signature | Purpose |
|---|---|---|
bind_front_door_at |
async fn bind_front_door_at(addr: ListenAddr, manager: Arc<ImposterManager>, routes: Arc<ArcSwap<CompiledRoutes>>, observer: Option<Arc<dyn RouteObserver>>) -> anyhow::Result<RunningFrontDoor> |
Bind every listener a ListenAddr needs (issue #1561). bind_front_door and bind_front_door_with_observer are this with ListenAddr::Exact. |
parse_front_door_addr |
fn parse_front_door_addr(value: &str) -> anyhow::Result<ListenAddr> |
The --front-door rule: HOST:PORT is Exact, a bare port is Any, anything else is an error naming the value. |
Metrics server¶
use rift_http_proxy::server::bind_metrics_server;
let metrics = bind_metrics_server(addr).await?; // addr may be `:0`
println!("metrics bound to {}", metrics.local_addr());
metrics.shutdown().await;
| Function / method | Signature | Purpose |
|---|---|---|
run_metrics_server |
async fn run_metrics_server(addr: SocketAddr) -> anyhow::Result<()> |
Serve metrics forever on a fixed address. |
bind_metrics_server |
async fn bind_metrics_server(addr: SocketAddr) -> anyhow::Result<RunningMetrics> |
Bind (supports :0) and return a handle. |
bind_metrics_listeners |
async fn bind_metrics_listeners(addr: ListenAddr, policy: PeerPolicy) -> anyhow::Result<RunningMetrics> |
bind_metrics_server on every listener a ListenAddr needs (issue #1528), serving the clients policy admits (issue #1568); a refused one is closed unanswered and counted in rift_blocked_connections_total{listener="metrics"}. ServerBuilder::start binds Any with PeerPolicy::Any or, under --local-only, Loopback with PeerPolicy::LoopbackOnly. bind_metrics_server passes PeerPolicy::Any. |
RunningMetrics::local_addr |
fn local_addr(&self) -> SocketAddr |
The first bound metrics address. |
RunningMetrics::local_addrs |
fn local_addrs(&self) -> &[SocketAddr] |
Every bound metrics address (two under ListenAddr::Loopback). |
RunningMetrics::shutdown |
async fn shutdown(&self) |
Stop the metrics server. |
RunningMetrics::join |
async fn join(self) -> anyhow::Result<()> |
Await until it exits. |
Testing against a dead admin plane (test-util)¶
RunningServer::wait() reports the admin accept loop dying, which an embedder typically propagates
to process exit. Provoking that failure for real means breaking the listener, so the test-util
feature exposes constructors whose "accept loop" is a future you supply (issue #825):
use rift_http_proxy::server::RunningServer;
let server = RunningServer::with_admin_accept_task(async {
Err(anyhow::anyhow!("admin plane died"))
});
let err = server.wait().await.expect_err("the embedder must see the failure");
RunningAdminApi::with_accept_task is the same seam one layer down. Neither binds a listener
(admin_addr() reports 127.0.0.1:0), and both are test scaffolding — do not enable test-util in
a production build.
Bootstrap helpers¶
ServerBuilder composes the running server, but a binary also has bootstrap concerns around it:
applying an rcfile's defaults, stopping a server by PID file, and saving a running server's
imposters. These live in rift_http_proxy::bootstrap so an alternative binary keeps CLI parity with
rift instead of reimplementing them (issue #807).
| Function | Signature | Purpose |
|---|---|---|
apply_rcfile_defaults |
fn apply_rcfile_defaults(cli: &mut Cli, rcfile: &Path) -> anyhow::Result<()> |
Fill CLI fields still at their clap defaults from a Mountebank-compatible JSON rcfile. An explicitly-supplied flag always wins; unrecognised keys are logged with warn! and ignored, and a path that does not exist is a warn! and Ok (issue #1574). Any other Err means nothing was applied; the rift binary treats it as fatal. |
apply_rcfile_defaults_reporting |
fn apply_rcfile_defaults_reporting(cli: &mut Cli, rcfile: &Path) -> anyhow::Result<Vec<String>> |
The same, returning the unrecognised keys instead of logging them — for a caller that applies the rcfile before it installs a log subscriber. |
rcfile_not_found |
fn rcfile_not_found(err: &anyhow::Error) -> bool |
True when an apply_rcfile_defaults_reporting error is the rcfile path not existing — the one failure Mountebank warns about and starts through (issue #1574). missing_rcfile_warning(&Path) -> String is the text the rift binary prints for it. |
log_filter |
fn log_filter(cli: &Cli) -> anyhow::Result<EnvFilter> |
The tracing filter this CLI asks for: RUST_LOG when set, otherwise --debug, otherwise --loglevel. Errors on a level that does not exist, and on a RUST_LOG that is set but does not parse — an unset RUST_LOG is an absence, not a failure (issue #1134). |
log_filter_with |
fn log_filter_with(cli: &Cli, rust_log: Option<&str>) -> anyhow::Result<EnvFilter> |
The same rules with the RUST_LOG value supplied rather than read (None = unset), for a host that resolves it some other way — or wants to test without mutating the process environment. |
DEFAULT_PIDFILE |
pub const DEFAULT_PIDFILE: &str |
The rift.pid fallback stop/restart apply when --pidfile is absent. Applied at the dispatch site so a plain start never writes a PID file it wasn't asked to. |
stop_server |
fn stop_server(pidfile: &Path) -> anyhow::Result<()> |
Signal the process named in pidfile (SIGTERM on unix, taskkill /F on Windows), then remove the file. A missing pidfile is Ok (nothing to stop, as mb stop), and a stale one (process already gone) is cleaned up as Ok; a denied or failed signal is an error and the pidfile is kept. On unix it waits up to five seconds for the process to exit; one still running after that is an error and the pidfile is kept. |
stop_server_within |
fn stop_server_within(pidfile: &Path, ceiling: Duration) -> anyhow::Result<()> |
stop_server with the exit wait chosen by the caller — for an embedder whose server outlives five seconds on SIGTERM (a drain window, a cluster departure). |
save_imposters_async |
async fn save_imposters_async(host: &str, port: u16, savefile: &Path, remove_proxies: bool, api_key: Option<&str>) -> anyhow::Result<()> |
Fetch GET /imposters?replayable=true from a running admin API and write it to savefile. The async form — call it from an embedder's own runtime. api_key is sent as the raw Authorization value; pass None for an unkeyed server (issue #1154). A non-2xx admin response is an error; nothing is written to savefile. |
save_imposters |
fn save_imposters(host: &str, port: u16, savefile: &Path, remove_proxies: bool, api_key: Option<&str>) -> anyhow::Result<()> |
Blocking wrapper over save_imposters_async for the sync save subcommand path. |
Supported rcfile keys: port, host, logLevel/loglevel, allowInjection/allow_injection,
localOnly/local_only, requireAdminAuth/require_admin_auth, apiKey/api_key, datadir,
configfile, noParse/no_parse. Each must have its type — the flags are JSON booleans, host,
logLevel, apiKey, datadir and configfile are strings, and port is an integer from 0 to
65535 — or the whole rcfile is refused and nothing is applied.
apiKey sets the admin credential, like --api-key/MB_APIKEY, and like every other key it defers
to an explicitly-given flag (issue #1132). A blank value is refused by validate_admin_api_key at
startup exactly as a blank --api-key is — an rcfile is a normal place to keep the credential, so
keep it readable only by the user the server runs as.
use rift_http_proxy::bootstrap;
use rift_http_proxy::server::{Cli, Commands};
use clap::Parser;
let mut cli = Cli::parse();
if let Some(rcfile) = cli.rcfile.clone() {
bootstrap::apply_rcfile_defaults(&mut cli, &rcfile)?;
}
match &cli.command {
// `--pidfile` is a single global binding (issue #827): it parses before or after the
// subcommand, and the stop/restart default lives here, not on the flag.
Some(Commands::Stop) => return bootstrap::stop_server(&pidfile_or_default(&cli)),
// `restart` is `stop` followed by the normal start path; a missing PID file is "nothing to
// stop", so it just starts.
Some(Commands::Restart) => bootstrap::stop_server(&pidfile_or_default(&cli))?,
Some(Commands::Save { savefile, remove_proxies }) => {
return bootstrap::save_imposters(
&cli.host,
cli.port,
savefile,
*remove_proxies,
cli.api_key.as_deref(),
);
}
_ => {}
}
The rift binary writes --pidfile only on the serving path, after ServerBuilder::start
returns, never before subcommand dispatch — otherwise rift --pidfile p restart would record its
own PID and then signal itself, and a transient save/healthcheck would clobber a running
server's file (issue #827). Writing it after start means the admin API and every startup imposter
are bound by the time it exists, so tooling can wait on it as a readiness signal, as with
Mountebank, and a failed startup leaves none (issue #1657). start itself never writes it. An
alternative binary should keep that ordering.
save_imposters builds its own tokio runtime and blocks on it (it is a sync subcommand path), so do
not call it from inside a running async runtime — it would panic starting a nested runtime. From
async code call save_imposters_async directly instead; it awaits rather than driving its own
runtime, so it is safe on an async worker thread.
TLS: install the crypto provider¶
Before serving any HTTPS imposter from an embedding host, install the default rustls (ring) crypto
provider once:
It is idempotent, so calling it more than once is safe. The rift binary does this for you; an
embedding host must call it itself if it serves TLS.
Call it before building any reqwest::Client of your own, too. Rift uses reqwest 0.13 with the
rustls-no-provider feature (reqwest has no ring-backed one), so a default Client takes the
process-default provider and panics at build() when none is installed. A client handed to
ImposterManager::with_upstream_client must be a reqwest 0.13 Client; building it from
rift_mock_core::proxy::OutboundTls::reqwest_builder() sidesteps the provider question, since that
config names ring itself.