Skip to content

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:

let report = manager.apply_one(config).await?;
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.
  • config must name an explicit port. A config with no port, or port 0, is refused with ImposterError::ExplicitPortRequired; auto-assigned imposters go through apply_config.
  • Err means nothing changed: an invalid protocol, a duplicate stub id, or a missing port. A failure while applying, such as a bind failure, is reported in ApplyReport::failed.
  • apply_one never deletes. Remove a port with delete_imposter.
  • Nothing serializes apply_one against a concurrent apply_config, whose delete sweep can remove the port, just as two concurrent apply_config calls 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 wait or delayRange whose min exceeds its max;
  • _behaviors given as an array, or behaviors as a scalar;
  • a single-valued header (proxy.injectHeaders, _rift.fault.error.headers) named twice;
  • a _rift.fault.tcp probability outside 0.0–1.0, or its object form without a type.

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):

[dev-dependencies]
rift-http-proxy = { version = "*", features = ["test-util"] }
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:

rift_http_proxy::install_default_crypto_provider();

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.